Compare commits
20 Commits
latest
...
5b9216e764
| Author | SHA1 | Date | |
|---|---|---|---|
| 5b9216e764 | |||
| 9a23863320 | |||
| 0e7fbbfdca | |||
| 152b541a6c | |||
| 44fe3361a3 | |||
| e43aee9b13 | |||
| d4c69e4b32 | |||
| 28457eb200 | |||
| f1c530e5b1 | |||
| 1e3198d09b | |||
| 965d6dde63 | |||
| e5a65f2dcf | |||
| ad6d369dcf | |||
| 5a4d0b9348 | |||
| 42979daaf2 | |||
| f8172d9323 | |||
| 4256f65161 | |||
| c113139303 | |||
| 6ea0479059 | |||
| 0c673ab5f1 |
71
.gitea/workflows/build-apk.yml
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
name: Build APK
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ master ]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
shell: bash
|
||||||
|
working-directory: ./apk
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: 📥 Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: 🟢 Setup Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
|
||||||
|
- name: 📦 Install dependencies
|
||||||
|
run: npm install
|
||||||
|
|
||||||
|
- name: ☕ Setup Java
|
||||||
|
uses: actions/setup-java@v4
|
||||||
|
with:
|
||||||
|
distribution: temurin
|
||||||
|
java-version: 17
|
||||||
|
|
||||||
|
- name: 📱 Setup Android SDK
|
||||||
|
uses: android-actions/setup-android@v3
|
||||||
|
|
||||||
|
- name: ⚙️ Expo prebuild
|
||||||
|
run: npx expo prebuild --clean --non-interactive
|
||||||
|
|
||||||
|
- name: 🔐 Decode Keystore
|
||||||
|
run: |
|
||||||
|
echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 --decode > android/release.keystore
|
||||||
|
|
||||||
|
- name: 🏗️ Build APK (Release)
|
||||||
|
working-directory: ./apk/android
|
||||||
|
run: |
|
||||||
|
./gradlew assembleRelease
|
||||||
|
- name: 📤 Upload APK Artifact
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: app-release-apk
|
||||||
|
path: apk/android/app/build/outputs/apk/release/app-release.apk
|
||||||
|
release:
|
||||||
|
runs-on: docker
|
||||||
|
needs: [build]
|
||||||
|
steps:
|
||||||
|
- name: Download Web Artifact
|
||||||
|
uses: actions/download-artifact@v3
|
||||||
|
with:
|
||||||
|
name: app-release-apk
|
||||||
|
path: dist
|
||||||
|
|
||||||
|
- name: Create Release
|
||||||
|
uses: https://gitea.com/actions/gitea-release-action@v1
|
||||||
|
with:
|
||||||
|
tag_name: latest
|
||||||
|
name: Latest Build
|
||||||
|
overwrite_files: true
|
||||||
|
files: |
|
||||||
|
dist/app-release-apk.zip
|
||||||
|
env:
|
||||||
|
GITEA_TOKEN: ${{ secrets.GITEA }}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
name: Build Nuxt App
|
name: Build
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
@@ -6,7 +6,7 @@ on:
|
|||||||
- master
|
- master
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build-web:
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
|
|
||||||
defaults:
|
defaults:
|
||||||
@@ -43,12 +43,61 @@ jobs:
|
|||||||
cd dist_package
|
cd dist_package
|
||||||
zip -r ../quibot-web.zip .
|
zip -r ../quibot-web.zip .
|
||||||
|
|
||||||
# Create or update release and upload asset
|
- name: Upload Web artifact
|
||||||
- name: Upload Release
|
uses: actions/upload-artifact@v3
|
||||||
uses: softprops/action-gh-release@v1
|
with:
|
||||||
|
name: quibot-web
|
||||||
|
path: quibot-web/quibot-web.zip
|
||||||
|
|
||||||
|
build-backend:
|
||||||
|
runs-on: docker
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: backend
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
apt update
|
||||||
|
apt install -y zip
|
||||||
|
|
||||||
|
- name: Create zip
|
||||||
|
run: |
|
||||||
|
zip -r backend.zip .
|
||||||
|
|
||||||
|
- name: Upload Backend artifact
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: backend
|
||||||
|
path: backend/backend.zip
|
||||||
|
|
||||||
|
release:
|
||||||
|
runs-on: docker
|
||||||
|
needs: [build-web, build-backend]
|
||||||
|
steps:
|
||||||
|
- name: Download Web Artifact
|
||||||
|
uses: actions/download-artifact@v3
|
||||||
|
with:
|
||||||
|
name: quibot-web
|
||||||
|
path: dist
|
||||||
|
|
||||||
|
- name: Download Backend Artifact
|
||||||
|
uses: actions/download-artifact@v3
|
||||||
|
with:
|
||||||
|
name: backend
|
||||||
|
path: dist
|
||||||
|
|
||||||
|
- name: Create Release
|
||||||
|
uses: https://gitea.com/actions/gitea-release-action@v1
|
||||||
with:
|
with:
|
||||||
tag_name: latest
|
tag_name: latest
|
||||||
name: Latest Build
|
name: Latest Build
|
||||||
files: quibot-web/quibot-web.zip
|
overwrite_files: true
|
||||||
|
files: |
|
||||||
|
dist/quibot-web.zip
|
||||||
|
dist/backend.zip
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITEATOKEN }}
|
GITEA_TOKEN: ${{ secrets.GITEATOKEN }}
|
||||||
|
|||||||
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
node_modules/
|
||||||
345
AGENTS.md
Normal file
@@ -0,0 +1,345 @@
|
|||||||
|
# QuiBot Project — Agent Guide
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
QuiBot is an educational robotics platform (UPC/UNE collaboration) consisting of a programmable robot with color-block recognition, gesture control, stepper motors, RGB LED eyes, and multiple input methods (web dashboard, Android voice app). The codebase comprises four independent application layers communicating via HTTP JSON APIs.
|
||||||
|
|
||||||
|
```
|
||||||
|
[quibot-web Nuxt SPA] ──HTTP──> [backend Express] ──HTTP──> [raspi FastAPI (Pi)]
|
||||||
|
│ │
|
||||||
|
▼ ▼
|
||||||
|
[apk Expo RN app] ──HTTP──> (same backend) [Python hardware drivers]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Tech stack**: Python (pigpio, FastAPI) | TypeScript/Express | Nuxt 4/Vue 3 | Expo/React Native
|
||||||
|
**No database**: All state is in-memory, file-based (`/tmp/quibot-audio/`), or localStorage.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Directory Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
quibot/
|
||||||
|
├── raspi/ # Raspberry Pi brain — Python, controls hardware
|
||||||
|
│ ├── main.py # FastAPI server (port 8000) on the Pi
|
||||||
|
│ ├── quibot.py # Main program: block/gesture threads (like Arduino QuiBot.ino)
|
||||||
|
│ ├── motion.py # Stepper class, homing, line-following, high-level tasks
|
||||||
|
│ ├── gesture.py # PAJ7620U2 gesture sensor (I2C, polled at 50ms)
|
||||||
|
│ ├── blocks.py # TCS34725 color sensor + servo block ejection
|
||||||
|
│ ├── eyes.py # WS2811 LED matrix (128 LEDs, pigpio waveforms, breathing animation)
|
||||||
|
│ ├── pins.py # BCM GPIO pin map for all hardware
|
||||||
|
│ └── tests/ # Manual diagnostic scripts (not automated)
|
||||||
|
├── backend/ # Local Express server (port 3000), proxy to Pi
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── index.ts # Express entry: CORS, JSON parser, /health
|
||||||
|
│ │ ├── config.ts # Env config: RASPBERRY_PI_HOST, PORT, QUIBOT_TOKEN
|
||||||
|
│ │ ├── routes/router.ts # Mounts all controllers
|
||||||
|
│ │ ├── services/raspi.service.ts # Axios proxy layer to Pi FastAPI
|
||||||
|
│ │ └── controllers/
|
||||||
|
│ │ ├── motor.controller.ts # Motor step/stop/upload
|
||||||
|
│ │ ├── audio.controller.ts # Audio file lifecycle (incoming/locked/processed)
|
||||||
|
│ │ ├── command.controller.ts # POST /commands proxy to raspi /run
|
||||||
|
│ │ └── settings.controller.ts # GET/PUT /settings runtime config
|
||||||
|
│ └── dist/ # Compiled output (generated)
|
||||||
|
├── quibot-web/ # Nuxt 4 dashboard SPA
|
||||||
|
│ ├── app/app.vue # Single-page control panel: block queue, D-pad, eye controls, gesture log
|
||||||
|
│ ├── server/api/ # Nitro server routes proxying to raspi
|
||||||
|
│ │ ├── motor/step/[direction].post.ts
|
||||||
|
│ │ └── motor/stop.post.ts
|
||||||
|
│ ├── nuxt.config.ts # Runtime config: QUIBOT_BASE_URL, QUIBOT_TOKEN
|
||||||
|
│ └── .output/ # Built Nitro output
|
||||||
|
├── apk/ # Expo React Native voice recorder ("VoiceDrop")
|
||||||
|
│ ├── app/index.tsx # Recording screen + upload
|
||||||
|
│ ├── app/settings.tsx # Backend URL/token configuration (AsyncStorage)
|
||||||
|
│ └── lib/recorder-settings.ts # AsyncStorage wrapper
|
||||||
|
├── .gitea/workflows/ # CI/CD pipelines
|
||||||
|
│ ├── build.yml # Web + backend → zip artifacts + Gitea release
|
||||||
|
│ └── build-apk.yml # Expo prebuild + signed APK → Gitea release
|
||||||
|
├── build.sh # Placeholder
|
||||||
|
└── README.md # Project overview (Catalan)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Raspberry Pi Layer (`raspi/`)
|
||||||
|
|
||||||
|
**Hardware target**: Raspberry Pi Zero 2W controlling a robot with:
|
||||||
|
- 5 NEMA-style stepper motors (wheels x2, arms x2, syringe) via A4988/TB600 drivers in STEP/DIR mode
|
||||||
|
- VL53L0X ToF distance sensor (I2C bus 3)
|
||||||
|
- PAJ7620U2 gesture sensor (I2C bus 3, polled at 50ms)
|
||||||
|
- TCS34725 color sensor (bit-banged I2C on GPIO22/27)
|
||||||
|
- ADS1115 ADC for TCRT5000 line-following IR sensors
|
||||||
|
- WS2811 RGB LED matrix (2x 8x8 = 128 LEDs, GPIO26, pigpio waveforms at -s 1)
|
||||||
|
- Servo motor (GPIO10 PWM) for block ejection
|
||||||
|
- Hall-effect endstops on GPIOs 12, 16, 17
|
||||||
|
- Optional: I2S audio amp (MAX98357A) + mic (SPH0645)
|
||||||
|
|
||||||
|
### Key files
|
||||||
|
|
||||||
|
- **`pins.py`** — BCM GPIO pin numbering for every component (STEP, DIR, EN pins, I2C lines, endstops, LED_DATA on GPIO26)
|
||||||
|
- **`motion.py`** — `Stepper` class with AccelStepper-style acceleration profiling via `pigpio.gpio_trigger()`. 5 motor instances (`wheel_R`, `wheel_L`, `arm_R`, `arm_L`, `syringe`). Continuous stepper daemon thread (`_stepper_loop`) at ~100Hz. Homing routines read Hall-effect endstops. Line-following with proportional correction on TCRT5000 values via ADS1115.
|
||||||
|
- **`gesture.py`** — Raw I2C via `smbus2` (bus 3). Two-register-bank init (~240 total register writes). Polls gesture result registers 0x43/0x44 every 50ms. Returns: GS_NONE, GS_FORWARD, GS_LEFT, GS_RIGHT, GS_UP, GS_DOWN, GS_CLOCKWISE, GS_ANTICLOCKWISE, GS_WAVE
|
||||||
|
- **`blocks.py`** — TCS34725 RGB reads classified via Manhattan distance against calibrated reference table (BK/RD/GN/BU/YE/OG/VT). Smooth servo movement with 3us micro-steps.
|
||||||
|
- **`eyes.py`** — 128 WS2811 LEDs via pigpio waveforms at 1us resolution. Pre-defined shapes: EYES_OPEN, EYES_FW, EYES_DOWN, EYES_GESTURE. Breathing thread oscillates brightness 80-170 at 50ms intervals.
|
||||||
|
- **`quibot.py`** — Main program (equivalent to original Arduino QuiBot.ino). Two threading tasks via FreeRTOS-style pattern: `task_read_blocks()` (color→action mapping) and `task_read_gestures()` (WAVE toggles between block/gesture mode). `threading.Lock` prevents concurrent motor movements. Handles SIGINT/SIGTERM for graceful shutdown.
|
||||||
|
- **`main.py`** — FastAPI server on port 8000 with CORS. File-based state management using `/tmp/quibot-audio/` (incoming/locked/processed directories). Token auth via query parameter matching `QUIBOT_TOKEN`.
|
||||||
|
|
||||||
|
### Color→Action Mapping (in quibot.py)
|
||||||
|
|
||||||
|
| Color | Action |
|
||||||
|
|-------|--------|
|
||||||
|
| RED | Advance forward |
|
||||||
|
| GREEN | Turn right |
|
||||||
|
| BLUE | Turn left |
|
||||||
|
| YELLOW | Take/block pick-up |
|
||||||
|
| ORANGE | Leave/eject |
|
||||||
|
| VIOLET | Idle |
|
||||||
|
| BLACK | Reference / no block |
|
||||||
|
|
||||||
|
### Motor Position Tracking
|
||||||
|
|
||||||
|
`Stepper` class in `motion.py` tracks absolute position in steps via `_pos` and `_target`. Endstops provide physical reference during homing. The stepper loop evaluates acceleration profiles to generate STEP pulses at correct intervals.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Backend Layer (`backend/`)
|
||||||
|
|
||||||
|
**Role**: Express.js HTTP proxy sitting between frontend/mobile and the Raspberry Pi's FastAPI server. Token passthrough, no business logic.
|
||||||
|
|
||||||
|
### Configuration (`.env`, loaded by `config.ts`)
|
||||||
|
|
||||||
|
| Variable | Default | Purpose |
|
||||||
|
|----------|---------|---------|
|
||||||
|
| `RASPBERRY_PI_HOST` | `http://raspberrypi.local` | Pi API URL |
|
||||||
|
| `RASPBERRY_PI_PORT` | `8000` | Pi API port |
|
||||||
|
| `QUIBOT_TOKEN` | `MY_SECRET_TOKEN` | Auth token for all Pi endpoints |
|
||||||
|
| `PORT` | `3000` | Backend listen port |
|
||||||
|
|
||||||
|
### Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
index.ts → Express app, CORS, JSON parser, /health endpoint
|
||||||
|
routes/router.ts → Mounts all controllers under /motor, /audio, /commands, /settings
|
||||||
|
config.ts → Mutable getter/setter env vars (runtime update via PUT /settings)
|
||||||
|
raspi.service.ts → Axios proxy methods for every Pi endpoint + multipart file upload handling
|
||||||
|
```
|
||||||
|
|
||||||
|
### Controllers
|
||||||
|
|
||||||
|
- **`motor.controller.ts`** — `POST /motor/step/forward`, `/motor/step/backward`, `/motor/stop`. Also `POST /motor/upload` (multer multipart → proxied as FormData to Pi).
|
||||||
|
- **`audio.controller.ts`** — `GET /audio/incoming`, `POST /audio/lock/:filename`, `/unlock/:filename`, `/cancel/:filename`, `/process/:filename`. All proxy to raspi audio file lifecycle endpoints.
|
||||||
|
- **`command.controller.ts`** — `POST /commands { task }` → proxied to raspi `/run?task=...&token=...`
|
||||||
|
- **`settings.controller.ts`** — `GET /settings` returns config; `PUT /settings` updates `raspberryPi.host`, `raspberryPi.port`, `token` at runtime.
|
||||||
|
|
||||||
|
### Build/Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
npm install # Installs express, axios, multer, dotenv, cors, typescript
|
||||||
|
npx tsc # Compiles to dist/
|
||||||
|
node dist/index.js # Or use tsx/nodemon for dev
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Web Frontend (`quibot-web/`)
|
||||||
|
|
||||||
|
**Stack**: Nuxt 4 (Nitro) + Vue 3. Single-page dashboard SPA.
|
||||||
|
|
||||||
|
### Runtime config (`nuxt.config.ts`)
|
||||||
|
|
||||||
|
| Key | Default | Purpose |
|
||||||
|
|-----|---------|---------|
|
||||||
|
| `QUIBOT_BASE_URL` | `http://quibot:8000` | Base URL for raspi FastAPI |
|
||||||
|
| `QUIBOT_TOKEN` | `MY_SECRET_TOKEN` | Auth token |
|
||||||
|
|
||||||
|
### UI Panels (`app/app.vue` — single-file SPA, 1369 lines)
|
||||||
|
|
||||||
|
1. **Block Queue Panel** — Displays color blocks in a queue (localStorage persistence + demo fallback). Shows action descriptions per color.
|
||||||
|
2. **Motion Controls** — D-pad grid: up=forward, down=back, left/right=turns, center=stop. Sends `$fetch('/api/motor/step/forward')` etc.
|
||||||
|
3. **Eye Controls** — Shape selector (open/forward/down/gesture), 8-color picker. Calls `POST /api/eye/shape`, `/api/eye/color`, `/api/eye/on`, `/api/eye/off`.
|
||||||
|
4. **Gesture Sensor Panel** — Toggle between Block Mode / Gesture Mode. Gesture detection history log. Reference table of all 8 gestures.
|
||||||
|
|
||||||
|
### State & Styling
|
||||||
|
- Dark/light theme via CSS custom properties, persisted in localStorage.
|
||||||
|
- Block queue data stored in localStorage with demo fallback.
|
||||||
|
- Toast notifications for success/error feedback.
|
||||||
|
- Responsive layout with CSS Grid (mobile-adaptive).
|
||||||
|
|
||||||
|
### Server Routes (`server/api/`)
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| POST | `/api/motor/stop` | Proxies to raspi `/motor/stop` |
|
||||||
|
| POST | `/api/motor/step/:direction` | Proxies to raspi `/motor/step/forward\|backwards` |
|
||||||
|
|
||||||
|
**Note**: The frontend also calls `POST /api/eye/shape`, `/api/eye/color`, `/api/eye/on`, `/api/eye/off`, `/api/gesture/on`, `/api/gesture/off` — server routes for these may need to be created (frontend references them but they don't have explicit server handlers yet).
|
||||||
|
|
||||||
|
### Build/Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd quibot-web
|
||||||
|
npm install
|
||||||
|
npx nuxt build # Produces .output/
|
||||||
|
# Dev mode:
|
||||||
|
npx nuxt dev
|
||||||
|
```
|
||||||
|
|
||||||
|
CI builds the Nuxt output and zips it for distribution.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Android APK (`apk/`)
|
||||||
|
|
||||||
|
**Stack**: Expo SDK ~54 + React Native 0.81.5 + Expo Router ~6.0. App name: "VoiceDrop".
|
||||||
|
|
||||||
|
### Features
|
||||||
|
- Records audio via `expo-av` (`Audio.Recording` — produces .m4a)
|
||||||
|
- Auto-uploads on stop as multipart form data to configurable backend URL
|
||||||
|
- Displays recording timer, status messages, raw server response (first 400 chars)
|
||||||
|
|
||||||
|
### Screens
|
||||||
|
- **`app/index.tsx`** — Recorder screen: start/stop recording, upload, status
|
||||||
|
- **`app/settings.tsx`** — Backend URL, Bearer token, form field name. Saved to AsyncStorage under `recorder.*` namespace.
|
||||||
|
|
||||||
|
### Persistence
|
||||||
|
Settings stored in `@react-native-async-storage/async-storage` (keys: `recorder.backendUrl`, `recorder.authToken`, `recorder.fieldName`).
|
||||||
|
|
||||||
|
### Build/Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd apk
|
||||||
|
npm install
|
||||||
|
npx expo start # Dev mode
|
||||||
|
# Production APK (local):
|
||||||
|
./build.sh # expo prebuild → generates android/ → gradle assembleRelease
|
||||||
|
```
|
||||||
|
|
||||||
|
CI: `build-apk.yml` runs expo prebuild, decodes keystore from secrets, builds signed release APK.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Complete API Reference
|
||||||
|
|
||||||
|
### Raspberry Pi FastAPI (`raspi/main.py`) — port 8000
|
||||||
|
|
||||||
|
| Method | Path | Params/Body | Description |
|
||||||
|
|--------|------|-------------|-------------|
|
||||||
|
| POST | `/run` | query: `task`, `token` | Runs whitelisted system commands (restart_nginx, uptime, update) |
|
||||||
|
| POST | `/motor/step/forward` | query: `token` | Starts motor forward (daemon thread) |
|
||||||
|
| POST | `/motor/step/backwards` | query: `token` | Starts motor backward (daemon thread) |
|
||||||
|
| POST | `/motor/stop` | query: `token` | Disables motor driver (GPIO EN HIGH) |
|
||||||
|
| POST | `/audio/upload` | multipart: `file`, query: `format` | Saves to `/tmp/quibot-audio/incoming/`, returns filename + lock_url |
|
||||||
|
| GET | `/audio/incoming` | — | Lists files with size and modified time |
|
||||||
|
| POST | `/audio/lock/{filename}` | — | incoming → locked (claim for processing) |
|
||||||
|
| POST | `/audio/unlock/{filename}` | — | locked → incoming (release) |
|
||||||
|
| POST | `/audio/cancel/{filename}` | — | locked → incoming (cancel) |
|
||||||
|
| POST | `/audio/process/{filename}` | — | locked → processed |
|
||||||
|
|
||||||
|
**Auth**: Query parameter `token` matching `QUIBOT_TOKEN` env var (default: `MY_SECRET_TOKEN`).
|
||||||
|
|
||||||
|
### Backend Express (`backend/`) — port 3000
|
||||||
|
|
||||||
|
| Method | Path | Body | Description |
|
||||||
|
|--------|------|------|-------------|
|
||||||
|
| GET | `/health` | — | Returns settings object |
|
||||||
|
| POST | `/commands` | `{ task }` | Proxy to raspi `/run` |
|
||||||
|
| POST | `/motor/step/forward` | — | Motor forward proxy |
|
||||||
|
| POST | `/motor/step/backward` | — | Motor backward proxy (maps to raspi `/backwards`) |
|
||||||
|
| POST | `/motor/stop` | — | Motor stop proxy |
|
||||||
|
| POST | `/motor/upload` | multipart file | Audio upload via multer in-memory buffer |
|
||||||
|
| GET | `/audio/incoming` | — | List incoming audio files |
|
||||||
|
| POST | `/audio/lock/:filename` | — | Lock audio file |
|
||||||
|
| POST | `/audio/unlock/:filename` | — | Unlock audio file |
|
||||||
|
| POST | `/audio/cancel/:filename` | — | Cancel locked audio |
|
||||||
|
| POST | `/audio/process/:filename` | — | Mark processed |
|
||||||
|
| GET | `/settings` | — | Returns config |
|
||||||
|
| PUT | `/settings` | `{ raspberryPi: { host, port }, token }` | Update runtime config |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Command Flow Examples
|
||||||
|
|
||||||
|
### Web → Motion Control
|
||||||
|
```
|
||||||
|
User clicks "Forward" in D-pad
|
||||||
|
→ $fetch('/api/motor/step/forward', { method: 'POST' })
|
||||||
|
→ Nuxt Nitro route: server/api/motor/step/[direction].post.ts
|
||||||
|
→ $fetch(config.quibotBaseUrl + '/motor/step/forward', { query: { token } })
|
||||||
|
→ raspi FastAPI /motor/step/forward
|
||||||
|
→ motor_step("forward") in daemon thread
|
||||||
|
→ step_motor(200, DIR, 1ms pulses)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Block Processing (internal to Pi)
|
||||||
|
```
|
||||||
|
Child inserts colored block → quibot.py task_read_blocks() polls distance sensor
|
||||||
|
→ When detected <80mm: read_block_color() via TCS34725
|
||||||
|
→ Manhattan distance classification against color lookup table
|
||||||
|
→ RED: eyes_turn_on(EYES_FW, DARK_RED, 2)
|
||||||
|
_execute_action(task_move_to, CROSSING)
|
||||||
|
→ enable_wheels(ON) → follow_line_loop(speed) (proportional on TCRT5000)
|
||||||
|
→ After action: servo_move_to(EJECT_POSITION)
|
||||||
|
```
|
||||||
|
|
||||||
|
### APK → Audio Upload
|
||||||
|
```
|
||||||
|
User stops recording in VoiceDrop → expo-av .m4a file
|
||||||
|
→ POST {backendUrl} with FormData {fieldName: "file"} + Bearer auth
|
||||||
|
→ raspi FastAPI saves to /tmp/quibot-audio/incoming/{uuid}.wav
|
||||||
|
→ Returns: { status: "received", filename, lock_url }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Gesture Mode Toggle
|
||||||
|
```
|
||||||
|
User toggles mode in web UI
|
||||||
|
→ _execute_action() locks mutex
|
||||||
|
→ If gesture mode: eyes_gesture_mode_on() (double cyan flash on 128-LED matrix)
|
||||||
|
→ Eyes breathing thread at MAX_BR(170) brightness
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CI/CD (`/.gitea/workflows/`)
|
||||||
|
|
||||||
|
### `build.yml` — Web + Backend
|
||||||
|
- Triggers: Push to `master`
|
||||||
|
- Builds web: `npm install && npx nuxt build`, zips `.output/`
|
||||||
|
- Builds backend: Zips entire `backend/` directory
|
||||||
|
- Creates Gitea release "latest" with both zip artifacts
|
||||||
|
|
||||||
|
### `build-apk.yml` — Mobile
|
||||||
|
- Triggers: Push to `master`
|
||||||
|
- expo prebuild → decode keystore from secrets → `./gradlew assembleRelease`
|
||||||
|
- Creates Gitea release with APK artifact
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
All tests in `raspi/tests/` are **manual diagnostic scripts** (not automated frameworks). Each test is run independently by uncommenting the desired function call at the bottom of the file. Requirements:
|
||||||
|
|
||||||
|
- `sudo pigpiod -s 1` daemon running
|
||||||
|
- Python venv activated with hardware dependencies installed
|
||||||
|
- Pi connected to robot hardware
|
||||||
|
|
||||||
|
| Test | What it verifies |
|
||||||
|
|------|-----------------|
|
||||||
|
| `test_simple_motor.py` | Low-level motor driver via direct GPIO writes |
|
||||||
|
| `test_motion.py` | Wheel steppers, arm/syringe homing, VL53L0X distance, ADS1115 line sensors |
|
||||||
|
| `test_blocks.py` | Servo sweep (open/eject/open), color sensor readings, raw RGB calibration |
|
||||||
|
| `test_gesture.py` | PAJ7620U2 I2C connection + 30-second gesture capture |
|
||||||
|
| `test_eyes.py` | LED shape/color rendering, animation repeat/direction, gesture animations, breathing |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key Conventions
|
||||||
|
|
||||||
|
1. **Token auth** is always a query parameter matching `QUIBOT_TOKEN` (default: `MY_SECRET_TOKEN`)
|
||||||
|
2. **No database** — use filesystem for persistence, localStorage for web client state
|
||||||
|
3. **Backend is a dumb proxy** — no business logic, just forwards HTTP requests with token passthrough
|
||||||
|
4. **Motor commands are fire-and-forget** — motor runs in daemon thread until `/motor/stop`
|
||||||
|
5. **Audio lifecycle**: incoming → locked (claim) → processed OR unlocked (release) / cancelled
|
||||||
|
6. **Eyes breathing** runs continuously at MIN_BR(80)-MAX_BR(170) brightness in background
|
||||||
|
7. **`quibot.py` owns block/gesture autonomy** — blocks are processed internally on the Pi without backend/web involvement
|
||||||
|
8. **All paths use forward slashes** in URLs; kebab-case for params
|
||||||
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 quibot-dev
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
83
README.md
@@ -1,75 +1,22 @@
|
|||||||
# Nuxt Minimal Starter
|
# QuiBot-3Dnew
|
||||||
|
Documentació oficial i codi
|
||||||
|
Normes del repositori:
|
||||||
|
- Cada alumne és responsable del seu codi
|
||||||
|
- S’ha de treballar en branques pròpies (opcional)
|
||||||
|
- No es pot modificar la carpeta d’altres zones
|
||||||
|
|
||||||
Look at the [Nuxt documentation](https://nuxt.com/docs/getting-started/introduction) to learn more.
|
# Estructura del projecte
|
||||||
|
|
||||||
## Setup
|
- robot: Carpeta amb totes les instruccions necessaries per poder reconstruir el robot
|
||||||
|
- raspi: Carpeta amb el codi necessari que cal carregar dins de la raspberry pi
|
||||||
|
- backend: Backend de tot el controlador del robot. S'executa en un portatil en local
|
||||||
|
- quibot-web: Frontend web que es comunica amb el backend
|
||||||
|
- apk: Aplicació Android que també es comunica amb el backend.
|
||||||
|
|
||||||
Make sure to install dependencies:
|
# Instalació
|
||||||
|
|
||||||
```bash
|
Necesitem primer una raspberry operativa. Un cop operativa cal descarregar el codi que controla el robot. Es necesari tenir python.
|
||||||
# npm
|
|
||||||
npm install
|
|
||||||
|
|
||||||
# pnpm
|
- Cal passar la carpeta backend a la raspberry pi
|
||||||
pnpm install
|
|
||||||
|
|
||||||
# yarn
|
|
||||||
yarn install
|
|
||||||
|
|
||||||
# bun
|
|
||||||
bun install
|
|
||||||
```
|
|
||||||
|
|
||||||
## Development Server
|
|
||||||
|
|
||||||
Start the development server on `http://localhost:3000`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# npm
|
|
||||||
npm run dev
|
|
||||||
|
|
||||||
# pnpm
|
|
||||||
pnpm dev
|
|
||||||
|
|
||||||
# yarn
|
|
||||||
yarn dev
|
|
||||||
|
|
||||||
# bun
|
|
||||||
bun run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
## Production
|
|
||||||
|
|
||||||
Build the application for production:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# npm
|
|
||||||
npm run build
|
|
||||||
|
|
||||||
# pnpm
|
|
||||||
pnpm build
|
|
||||||
|
|
||||||
# yarn
|
|
||||||
yarn build
|
|
||||||
|
|
||||||
# bun
|
|
||||||
bun run build
|
|
||||||
```
|
|
||||||
|
|
||||||
Locally preview production build:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# npm
|
|
||||||
npm run preview
|
|
||||||
|
|
||||||
# pnpm
|
|
||||||
pnpm preview
|
|
||||||
|
|
||||||
# yarn
|
|
||||||
yarn preview
|
|
||||||
|
|
||||||
# bun
|
|
||||||
bun run preview
|
|
||||||
```
|
|
||||||
|
|
||||||
Check out the [deployment documentation](https://nuxt.com/docs/getting-started/deployment) for more information.
|
|
||||||
|
|||||||
BIN
UPCLogo.jpg
Normal file
|
After Width: | Height: | Size: 26 KiB |
43
apk/.gitignore
vendored
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Expo
|
||||||
|
.expo/
|
||||||
|
dist/
|
||||||
|
web-build/
|
||||||
|
expo-env.d.ts
|
||||||
|
|
||||||
|
# Native
|
||||||
|
.kotlin/
|
||||||
|
*.orig.*
|
||||||
|
*.jks
|
||||||
|
*.p8
|
||||||
|
*.p12
|
||||||
|
*.key
|
||||||
|
*.mobileprovision
|
||||||
|
|
||||||
|
# Metro
|
||||||
|
.metro-health-check*
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.*
|
||||||
|
yarn-debug.*
|
||||||
|
yarn-error.*
|
||||||
|
|
||||||
|
# macOS
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# local env files
|
||||||
|
.env*.local
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
app-example
|
||||||
|
|
||||||
|
# generated native folders
|
||||||
|
/ios
|
||||||
|
/android
|
||||||
1
apk/.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{ "recommendations": ["expo.vscode-expo-tools"] }
|
||||||
7
apk/.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"editor.codeActionsOnSave": {
|
||||||
|
"source.fixAll": "explicit",
|
||||||
|
"source.organizeImports": "explicit",
|
||||||
|
"source.sortMembers": "explicit"
|
||||||
|
}
|
||||||
|
}
|
||||||
39
apk/README.md
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
# VoiceDrop
|
||||||
|
|
||||||
|
Minimal Expo app for one job: record audio and send it to a backend endpoint.
|
||||||
|
|
||||||
|
## What It Does
|
||||||
|
|
||||||
|
- Requests microphone permission.
|
||||||
|
- Records a single audio clip on-device.
|
||||||
|
- Uploads that clip as `multipart/form-data`.
|
||||||
|
- Lets you configure the backend URL, bearer token, and form field name from the UI.
|
||||||
|
|
||||||
|
## Run It
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npx expo start
|
||||||
|
```
|
||||||
|
|
||||||
|
If you build native apps after changing config/plugins, run prebuild again:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx expo prebuild
|
||||||
|
```
|
||||||
|
|
||||||
|
## Backend Expectations
|
||||||
|
|
||||||
|
The app sends a `POST` request to the URL you enter with a multipart body containing one file field. By default the field name is `file`.
|
||||||
|
|
||||||
|
If you enter a bearer token, the request includes:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
Authorization: Bearer <token>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- `expo-av` is required for native recording.
|
||||||
|
- The backend URL and auth token are persisted locally with AsyncStorage.
|
||||||
|
- The current environment may require a fresh `npm install` before linting or running because the dependency manifest was simplified around the new app.
|
||||||
66
apk/app.json
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
{
|
||||||
|
"expo": {
|
||||||
|
"name": "VoiceDrop",
|
||||||
|
"slug": "voice-drop",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"orientation": "portrait",
|
||||||
|
"icon": "./assets/images/icon.png",
|
||||||
|
"scheme": "voicedrop",
|
||||||
|
"userInterfaceStyle": "light",
|
||||||
|
"newArchEnabled": true,
|
||||||
|
"ios": {
|
||||||
|
"supportsTablet": true,
|
||||||
|
"bundleIdentifier": "com.arandano69.voicedrop"
|
||||||
|
},
|
||||||
|
"android": {
|
||||||
|
"permissions": [
|
||||||
|
"RECORD_AUDIO"
|
||||||
|
],
|
||||||
|
"adaptiveIcon": {
|
||||||
|
"backgroundColor": "#E6F4FE",
|
||||||
|
"foregroundImage": "./assets/images/android-icon-foreground.png",
|
||||||
|
"backgroundImage": "./assets/images/android-icon-background.png",
|
||||||
|
"monochromeImage": "./assets/images/android-icon-monochrome.png"
|
||||||
|
},
|
||||||
|
"edgeToEdgeEnabled": true,
|
||||||
|
"predictiveBackGestureEnabled": false,
|
||||||
|
"package": "com.arandano69.voicedrop"
|
||||||
|
},
|
||||||
|
"web": {
|
||||||
|
"output": "static",
|
||||||
|
"favicon": "./assets/images/favicon.png"
|
||||||
|
},
|
||||||
|
"plugins": [
|
||||||
|
"expo-router",
|
||||||
|
"@react-native-voice/voice",
|
||||||
|
[
|
||||||
|
"expo-av",
|
||||||
|
{
|
||||||
|
"microphonePermission": "Allow VoiceDrop to use your microphone."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"expo-splash-screen",
|
||||||
|
{
|
||||||
|
"image": "./assets/images/splash-icon.png",
|
||||||
|
"imageWidth": 200,
|
||||||
|
"resizeMode": "contain",
|
||||||
|
"backgroundColor": "#ffffff",
|
||||||
|
"dark": {
|
||||||
|
"backgroundColor": "#000000"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"experiments": {
|
||||||
|
"typedRoutes": true,
|
||||||
|
"reactCompiler": true
|
||||||
|
},
|
||||||
|
"extra": {
|
||||||
|
"router": {},
|
||||||
|
"eas": {
|
||||||
|
"projectId": "f761fcbd-46f2-4387-8282-005e44223075"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
16
apk/app/_layout.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { Stack } from "expo-router";
|
||||||
|
import { StatusBar } from "expo-status-bar";
|
||||||
|
|
||||||
|
export default function RootLayout() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<StatusBar style="dark" />
|
||||||
|
<Stack
|
||||||
|
screenOptions={{
|
||||||
|
headerShown: false,
|
||||||
|
contentStyle: { backgroundColor: "#f4efe4" },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
529
apk/app/index.tsx
Normal file
@@ -0,0 +1,529 @@
|
|||||||
|
import Voice from "@react-native-voice/voice";
|
||||||
|
import { router, useFocusEffect } from "expo-router";
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import Svg, { Path } from "react-native-svg";
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
Alert,
|
||||||
|
KeyboardAvoidingView,
|
||||||
|
NativeModules,
|
||||||
|
Platform,
|
||||||
|
Pressable,
|
||||||
|
ScrollView,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import { loadRecorderSettings } from "@/lib/recorder-settings";
|
||||||
|
import { getStrings, type Locale, t } from "@/lib/translations";
|
||||||
|
|
||||||
|
function formatDuration(durationMs: number) {
|
||||||
|
const totalSeconds = Math.floor(durationMs / 1000);
|
||||||
|
const minutes = Math.floor(totalSeconds / 60);
|
||||||
|
const seconds = totalSeconds % 60;
|
||||||
|
|
||||||
|
return `${minutes.toString().padStart(2, "0")}:${seconds
|
||||||
|
.toString()
|
||||||
|
.padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RecorderScreen() {
|
||||||
|
const [backendUrl, setBackendUrl] = useState("");
|
||||||
|
const [authToken, setAuthToken] = useState("");
|
||||||
|
const [locale, setLocale] = useState<Locale>("ca");
|
||||||
|
const [strings, setStrings] = useState(() => getStrings("ca"));
|
||||||
|
|
||||||
|
const [transcript, setTranscript] = useState("");
|
||||||
|
const [interimTranscript, setInterimTranscript] = useState("");
|
||||||
|
const [statusMessage, setStatusMessage] = useState("");
|
||||||
|
const [responsePreview, setResponsePreview] = useState("");
|
||||||
|
const [isSending, setIsSending] = useState(false);
|
||||||
|
const [isHolding, setIsHolding] = useState(false);
|
||||||
|
const [listeningMs, setListeningMs] = useState(0);
|
||||||
|
const startRef = useRef<number>(0);
|
||||||
|
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
const listeningActiveRef = useRef(false);
|
||||||
|
|
||||||
|
const refreshSettings = useCallback(() => {
|
||||||
|
let isMounted = true;
|
||||||
|
|
||||||
|
async function loadStoredValues() {
|
||||||
|
try {
|
||||||
|
const settings = await loadRecorderSettings();
|
||||||
|
|
||||||
|
if (!isMounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setBackendUrl(settings.backendUrl);
|
||||||
|
setAuthToken(settings.authToken);
|
||||||
|
setLocale(settings.language);
|
||||||
|
setStrings(getStrings(settings.language));
|
||||||
|
} catch {
|
||||||
|
if (isMounted) {
|
||||||
|
setStatusMessage(strings.loadError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void loadStoredValues();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isMounted = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useFocusEffect(refreshSettings);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (Platform.OS === "web" || !NativeModules.Voice) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Voice.onSpeechStart = (e) => {
|
||||||
|
console.log("Voice.onSpeechStart", e);
|
||||||
|
setTranscript("");
|
||||||
|
setInterimTranscript("");
|
||||||
|
setStatusMessage(strings.recording);
|
||||||
|
startRef.current = Date.now();
|
||||||
|
timerRef.current = setInterval(() => {
|
||||||
|
const elapsed = Date.now() - startRef.current;
|
||||||
|
setListeningMs(elapsed);
|
||||||
|
}, 250);
|
||||||
|
};
|
||||||
|
|
||||||
|
Voice.onSpeechEnd = (e) => {
|
||||||
|
console.log("Voice.onSpeechEnd", e);
|
||||||
|
listeningActiveRef.current = false;
|
||||||
|
if (timerRef.current) {
|
||||||
|
clearInterval(timerRef.current);
|
||||||
|
timerRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Voice.onSpeechResults = (e) => {
|
||||||
|
console.log("Voice.onSpeechResults", e);
|
||||||
|
const values = (e as unknown as { value: string[] }).value;
|
||||||
|
if (values && values.length > 0) {
|
||||||
|
const last = values[values.length - 1];
|
||||||
|
setTranscript(last);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Voice.onSpeechPartialResults = (e) => {
|
||||||
|
console.log("Voice.onSpeechPartialResults", e);
|
||||||
|
const values = (e as unknown as { value: string[] }).value;
|
||||||
|
if (values && values.length > 0) {
|
||||||
|
const last = values[values.length - 1];
|
||||||
|
setInterimTranscript(last || "");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Voice.onSpeechError = (e) => {
|
||||||
|
console.log("Voice.onSpeechError", e);
|
||||||
|
const message = (e as unknown as { error?: { message: string } }).error?.message || "Speech recognition error";
|
||||||
|
setStatusMessage(message);
|
||||||
|
if (timerRef.current) {
|
||||||
|
clearInterval(timerRef.current);
|
||||||
|
timerRef.current = null;
|
||||||
|
}
|
||||||
|
setListeningMs(0);
|
||||||
|
listeningActiveRef.current = false;
|
||||||
|
Alert.alert(strings.recordingFailedTitle, message);
|
||||||
|
};
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
Voice.removeAllListeners();
|
||||||
|
if (timerRef.current) {
|
||||||
|
clearInterval(timerRef.current);
|
||||||
|
timerRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (listeningActiveRef.current && Platform.OS !== "web" && NativeModules.Voice) {
|
||||||
|
Voice.stop().catch(() => undefined);
|
||||||
|
listeningActiveRef.current = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function startListening() {
|
||||||
|
try {
|
||||||
|
setResponsePreview("");
|
||||||
|
setTranscript("");
|
||||||
|
setInterimTranscript("");
|
||||||
|
const localeCode =
|
||||||
|
locale.includes("ca")
|
||||||
|
? "ca-ES"
|
||||||
|
: locale.includes("es")
|
||||||
|
? "es-ES"
|
||||||
|
: "en-US";
|
||||||
|
setIsHolding(true);
|
||||||
|
if (Platform.OS === "web") {
|
||||||
|
console.log("Voice not available on web");
|
||||||
|
Alert.alert("Not supported", "Speech recognition is only available on mobile devices. Open the app on Android or iOS.");
|
||||||
|
setIsHolding(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!NativeModules.Voice) {
|
||||||
|
Alert.alert("Not supported", "Speech recognition module not found. Make sure the app is built with native modules.");
|
||||||
|
setIsHolding(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await Voice.start(localeCode);
|
||||||
|
listeningActiveRef.current = true;
|
||||||
|
} catch (error) {
|
||||||
|
const msg = error instanceof Error ? error.message : "Failed to start speech recognition";
|
||||||
|
console.error("Voice.start failed:", error);
|
||||||
|
Alert.alert(strings.recordingFailedTitle, msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stopListeningAndSend() {
|
||||||
|
setIsHolding(false);
|
||||||
|
const wasListening = listeningActiveRef.current;
|
||||||
|
if (!wasListening) {
|
||||||
|
setStatusMessage(transcript ? strings.voiceMessageSent : strings.readyToRecord);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
listeningActiveRef.current = false;
|
||||||
|
if (Platform.OS === "web") {
|
||||||
|
const finalText = (transcript + " " + interimTranscript).trim().replace(/\s+/g, " ");
|
||||||
|
if (finalText) {
|
||||||
|
await sendCommand(finalText);
|
||||||
|
} else {
|
||||||
|
setStatusMessage(strings.readyToRecord);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await Voice.stop();
|
||||||
|
|
||||||
|
const finalText = (transcript + " " + interimTranscript).trim().replace(/\s+/g, " ");
|
||||||
|
|
||||||
|
if (!finalText) {
|
||||||
|
setStatusMessage(strings.readyToRecord);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await sendCommand(finalText);
|
||||||
|
} catch (error) {
|
||||||
|
listeningActiveRef.current = false;
|
||||||
|
const msg = error instanceof Error ? error.message : "Stop failed";
|
||||||
|
console.error("Voice.stop failed:", error);
|
||||||
|
setStatusMessage(msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendCommand(text: string) {
|
||||||
|
const trimmedUrl = backendUrl.trim().replace(/\/+$/, "");
|
||||||
|
const commandUrl = trimmedUrl.endsWith("/commands")
|
||||||
|
? `${trimmedUrl}/text`
|
||||||
|
: `${trimmedUrl}/commands/text`;
|
||||||
|
|
||||||
|
if (!commandUrl) {
|
||||||
|
setStatusMessage(strings.noBackendUrl);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsSending(true);
|
||||||
|
setStatusMessage(strings.uploadingRecording);
|
||||||
|
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
};
|
||||||
|
|
||||||
|
if (authToken.trim()) {
|
||||||
|
headers.Authorization = `Bearer ${authToken.trim()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(commandUrl, {
|
||||||
|
method: "POST",
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({ text }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const responseText = await response.text();
|
||||||
|
setResponsePreview(responseText.slice(0, 400));
|
||||||
|
setTranscript("");
|
||||||
|
setInterimTranscript("");
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`${response.status}. ${responseText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatusMessage(strings.voiceMessageSent);
|
||||||
|
} catch (error) {
|
||||||
|
setStatusMessage(strings.uploadFailed);
|
||||||
|
Alert.alert(
|
||||||
|
strings.uploadFailed,
|
||||||
|
error instanceof Error ? error.message : "",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setIsSending(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handlePressIn() {
|
||||||
|
if (isSending) return;
|
||||||
|
await startListening();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handlePressOut() {
|
||||||
|
await stopListeningAndSend();
|
||||||
|
}
|
||||||
|
|
||||||
|
const releaseLabel = t("releaseToStop", locale);
|
||||||
|
const holdLabel = t("holdToRecord", locale);
|
||||||
|
const openSettingsHint = t("openSettingsHint", locale);
|
||||||
|
const appTitleLabel = t("appTitle", locale);
|
||||||
|
const recorderTitleLabel = t("recorderTitle", locale);
|
||||||
|
const serverResponseLabel = t("serverResponse", locale);
|
||||||
|
|
||||||
|
const displayText = interimTranscript || transcript;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.safeArea}>
|
||||||
|
<KeyboardAvoidingView
|
||||||
|
style={styles.keyboardAvoidingView}
|
||||||
|
behavior={Platform.OS === "ios" ? "padding" : undefined}
|
||||||
|
>
|
||||||
|
<ScrollView
|
||||||
|
style={styles.scrollView}
|
||||||
|
contentContainerStyle={styles.content}
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
|
>
|
||||||
|
<View style={styles.hero}>
|
||||||
|
<View style={styles.heroTopRow}>
|
||||||
|
<View style={styles.heroBadge}>
|
||||||
|
<Text style={styles.heroBadgeText}>{appTitleLabel}</Text>
|
||||||
|
</View>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => router.push("/settings")}
|
||||||
|
hitSlop={10}
|
||||||
|
style={styles.settingsCog}
|
||||||
|
>
|
||||||
|
<Svg width="20" height="20" viewBox="0 0 24 24" fill="none">
|
||||||
|
<Path
|
||||||
|
d="M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6Z"
|
||||||
|
fill="#d3deea"
|
||||||
|
/>
|
||||||
|
<Path
|
||||||
|
d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09a1.65 1.65 0 0 0-1.08-1.51 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l-.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09a1.65 1.65 0 0 0 1.51-1.08 1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1.08 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9c.26.604.852.997 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1.08Z"
|
||||||
|
stroke="#d3deea"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
/>
|
||||||
|
</Svg>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.panel}>
|
||||||
|
<Text style={[styles.meterValueCentered, isHolding && { color: "#d04f2d" }]}>
|
||||||
|
{formatDuration(listeningMs)}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Pressable
|
||||||
|
disabled={isSending}
|
||||||
|
onPressIn={handlePressIn}
|
||||||
|
onPressOut={handlePressOut}
|
||||||
|
style={[
|
||||||
|
styles.micButton,
|
||||||
|
isHolding ? styles.holdingButton : styles.idleButton,
|
||||||
|
isSending && styles.buttonDisabled,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{isSending ? (
|
||||||
|
<ActivityIndicator color="#fff6f3" size="large" />
|
||||||
|
) : (
|
||||||
|
<Svg width="64" height="64" viewBox="0 0 24 24" fill="none">
|
||||||
|
<Path
|
||||||
|
d="M12 3a3 3 0 0 0-3 3v6a3 3 0 0 0 6 0V6a3 3 0 0 0-3-3z"
|
||||||
|
fill="#fff6f3"
|
||||||
|
stroke="#fff6f3"
|
||||||
|
strokeWidth="1"
|
||||||
|
/>
|
||||||
|
<Path
|
||||||
|
d="M19 10v1a7 7 0 0 1-14 0v-1"
|
||||||
|
stroke="#fff6f3"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
<Path
|
||||||
|
d="M12 18v3"
|
||||||
|
stroke="#fff6f3"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
</Svg>
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
<Text style={styles.statusText}>
|
||||||
|
{statusMessage || strings.readyToRecord}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.helperText}>
|
||||||
|
{isHolding
|
||||||
|
? releaseLabel
|
||||||
|
: backendUrl.trim()
|
||||||
|
? holdLabel
|
||||||
|
: openSettingsHint}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{displayText && (
|
||||||
|
<View style={styles.transcriptBox}>
|
||||||
|
<Text style={styles.transcriptLabel}>{strings.serverResponse}</Text>
|
||||||
|
<Text style={styles.transcriptText}>{displayText}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
safeArea: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: "#f4efe4",
|
||||||
|
},
|
||||||
|
keyboardAvoidingView: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
scrollView: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
flex: 1,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
paddingVertical: 32,
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
gap: 18,
|
||||||
|
},
|
||||||
|
hero: {
|
||||||
|
backgroundColor: "transparent",
|
||||||
|
paddingHorizontal: 22,
|
||||||
|
paddingTop: 40,
|
||||||
|
},
|
||||||
|
heroTopRow: {
|
||||||
|
alignItems: "center",
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
},
|
||||||
|
heroBadge: {
|
||||||
|
backgroundColor: "#f2b15d",
|
||||||
|
borderRadius: 999,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 6,
|
||||||
|
},
|
||||||
|
heroBadgeText: {
|
||||||
|
color: "#13304a",
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: "700",
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
textTransform: "uppercase",
|
||||||
|
},
|
||||||
|
settingsCog: {
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
borderRadius: 999,
|
||||||
|
backgroundColor: "#13304a",
|
||||||
|
marginLeft: 12,
|
||||||
|
},
|
||||||
|
panel: {
|
||||||
|
backgroundColor: "#fffaf1",
|
||||||
|
borderColor: "#dccfb9",
|
||||||
|
borderRadius: 24,
|
||||||
|
borderWidth: 1,
|
||||||
|
gap: 12,
|
||||||
|
padding: 18,
|
||||||
|
alignSelf: "center",
|
||||||
|
maxWidth: 340,
|
||||||
|
},
|
||||||
|
meterValueCentered: {
|
||||||
|
color: "#d04f2d",
|
||||||
|
fontSize: 40,
|
||||||
|
fontWeight: "800",
|
||||||
|
textAlign: "center",
|
||||||
|
},
|
||||||
|
micButton: {
|
||||||
|
alignItems: "center",
|
||||||
|
borderRadius: 999,
|
||||||
|
height: 164,
|
||||||
|
justifyContent: "center",
|
||||||
|
marginVertical: 6,
|
||||||
|
width: 164,
|
||||||
|
alignSelf: "center",
|
||||||
|
},
|
||||||
|
idleButton: {
|
||||||
|
backgroundColor: "#13304a",
|
||||||
|
},
|
||||||
|
holdingButton: {
|
||||||
|
backgroundColor: "#d04f2d",
|
||||||
|
transform: [{ scale: 1.08 }],
|
||||||
|
},
|
||||||
|
buttonDisabled: {
|
||||||
|
opacity: 0.45,
|
||||||
|
},
|
||||||
|
statusText: {
|
||||||
|
color: "#1f2d3d",
|
||||||
|
fontSize: 15,
|
||||||
|
lineHeight: 21,
|
||||||
|
textAlign: "center",
|
||||||
|
},
|
||||||
|
helperText: {
|
||||||
|
color: "#665f54",
|
||||||
|
fontSize: 13,
|
||||||
|
lineHeight: 18,
|
||||||
|
textAlign: "center",
|
||||||
|
},
|
||||||
|
transcriptBox: {
|
||||||
|
backgroundColor: "#f7f0e0",
|
||||||
|
borderRadius: 16,
|
||||||
|
gap: 6,
|
||||||
|
marginTop: 4,
|
||||||
|
padding: 14,
|
||||||
|
maxWidth: "100%",
|
||||||
|
},
|
||||||
|
transcriptLabel: {
|
||||||
|
color: "#13304a",
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: "700",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
textAlign: "center",
|
||||||
|
},
|
||||||
|
transcriptText: {
|
||||||
|
color: "#36475a",
|
||||||
|
fontSize: 16,
|
||||||
|
lineHeight: 22,
|
||||||
|
textAlign: "center",
|
||||||
|
fontWeight: "500",
|
||||||
|
},
|
||||||
|
responseBox: {
|
||||||
|
backgroundColor: "#f7f0e0",
|
||||||
|
borderRadius: 16,
|
||||||
|
gap: 6,
|
||||||
|
marginTop: 4,
|
||||||
|
padding: 14,
|
||||||
|
},
|
||||||
|
responseLabel: {
|
||||||
|
color: "#13304a",
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: "700",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
textAlign: "center",
|
||||||
|
},
|
||||||
|
responseText: {
|
||||||
|
color: "#36475a",
|
||||||
|
fontSize: 14,
|
||||||
|
lineHeight: 20,
|
||||||
|
},
|
||||||
|
});
|
||||||
252
apk/app/settings.tsx
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
import { Picker } from "@react-native-picker/picker";
|
||||||
|
import { router } from "expo-router";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
KeyboardAvoidingView,
|
||||||
|
Platform,
|
||||||
|
Pressable,
|
||||||
|
ScrollView,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import { SafeAreaView } from "react-native-safe-area-context";
|
||||||
|
import {
|
||||||
|
loadRecorderSettings,
|
||||||
|
saveRecorderSettings,
|
||||||
|
} from "@/lib/recorder-settings";
|
||||||
|
import { AVAILABLE_LOCALES, t, type Locale, getStrings } from "@/lib/translations";
|
||||||
|
|
||||||
|
function localeLabel(locale: Locale) {
|
||||||
|
if (locale === "ca") return "Catal\u00e0";
|
||||||
|
if (locale === "en") return "English";
|
||||||
|
return locale;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SettingsScreen() {
|
||||||
|
const [backendUrl, setBackendUrl] = useState("");
|
||||||
|
const [authToken, setAuthToken] = useState("");
|
||||||
|
const [fieldName, setFieldName] = useState("file");
|
||||||
|
const [language, setLanguage] = useState<Locale>("ca");
|
||||||
|
const [strings, setStrings] = useState(() => getStrings("ca"));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let isMounted = true;
|
||||||
|
|
||||||
|
async function loadSettings() {
|
||||||
|
try {
|
||||||
|
const settings = await loadRecorderSettings();
|
||||||
|
|
||||||
|
if (!isMounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setBackendUrl(settings.backendUrl);
|
||||||
|
setAuthToken(settings.authToken);
|
||||||
|
setFieldName(settings.fieldName);
|
||||||
|
setLanguage(settings.language);
|
||||||
|
setStrings(getStrings(settings.language));
|
||||||
|
} catch {
|
||||||
|
if (isMounted) {
|
||||||
|
Alert.alert(strings.loadError, strings.loadError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void loadSettings();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isMounted = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const langStrings = getStrings(language);
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
try {
|
||||||
|
await saveRecorderSettings({
|
||||||
|
authToken,
|
||||||
|
backendUrl,
|
||||||
|
fieldName,
|
||||||
|
language,
|
||||||
|
});
|
||||||
|
setStrings(getStrings(language));
|
||||||
|
router.back();
|
||||||
|
} catch {
|
||||||
|
Alert.alert(langStrings.saveError, langStrings.saveError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleLanguageChange(val: Locale) {
|
||||||
|
setLanguage(val);
|
||||||
|
setStrings(getStrings(val));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={styles.safeArea}>
|
||||||
|
<KeyboardAvoidingView
|
||||||
|
style={styles.keyboardAvoidingView}
|
||||||
|
behavior={Platform.OS === "ios" ? "padding" : undefined}
|
||||||
|
>
|
||||||
|
<ScrollView
|
||||||
|
style={styles.scrollView}
|
||||||
|
contentContainerStyle={styles.content}
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
|
>
|
||||||
|
<View style={styles.headerRow}>
|
||||||
|
<Pressable onPress={() => router.back()} style={styles.navButton}>
|
||||||
|
<Text style={styles.navButtonText}>{langStrings.back}</Text>
|
||||||
|
</Pressable>
|
||||||
|
<Text style={styles.title}>{langStrings.settingsTitle}</Text>
|
||||||
|
<Pressable onPress={handleSave} style={styles.navButton}>
|
||||||
|
<Text style={styles.navButtonText}>{langStrings.save}</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.panel}>
|
||||||
|
<Text style={styles.label}>{langStrings.backendUrl}</Text>
|
||||||
|
<TextInput
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
keyboardType="url"
|
||||||
|
onChangeText={setBackendUrl}
|
||||||
|
placeholder={langStrings.urlPlaceholder}
|
||||||
|
placeholderTextColor="#8f8a7c"
|
||||||
|
style={styles.input}
|
||||||
|
value={backendUrl}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Text style={styles.label}>{langStrings.bearerToken}</Text>
|
||||||
|
<TextInput
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
onChangeText={setAuthToken}
|
||||||
|
placeholder={langStrings.tokenOptional}
|
||||||
|
placeholderTextColor="#8f8a7c"
|
||||||
|
secureTextEntry
|
||||||
|
style={styles.input}
|
||||||
|
value={authToken}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Text style={styles.label}>{langStrings.formFieldName}</Text>
|
||||||
|
<TextInput
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
onChangeText={setFieldName}
|
||||||
|
placeholder={langStrings.fieldNamePlaceholder}
|
||||||
|
placeholderTextColor="#8f8a7c"
|
||||||
|
style={styles.input}
|
||||||
|
value={fieldName}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Text style={styles.helperText}>
|
||||||
|
{t("helperText", language, fieldName.trim() || "file")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.panel}>
|
||||||
|
<Text style={styles.label}>{langStrings.languageTitle}</Text>
|
||||||
|
<View style={styles.pickerWrapper}>
|
||||||
|
<Picker
|
||||||
|
selectedValue={language}
|
||||||
|
onValueChange={handleLanguageChange}
|
||||||
|
style={styles.picker}
|
||||||
|
>
|
||||||
|
{AVAILABLE_LOCALES.map((loc) => (
|
||||||
|
<Picker.Item
|
||||||
|
key={loc}
|
||||||
|
label={localeLabel(loc)}
|
||||||
|
value={loc}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Picker>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
safeArea: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: "#f4efe4",
|
||||||
|
},
|
||||||
|
keyboardAvoidingView: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
scrollView: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
gap: 18,
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
paddingBottom: 32,
|
||||||
|
paddingTop: 8,
|
||||||
|
},
|
||||||
|
headerRow: {
|
||||||
|
alignItems: "center",
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
},
|
||||||
|
navButton: {
|
||||||
|
borderColor: "#cdbfa8",
|
||||||
|
borderRadius: 999,
|
||||||
|
borderWidth: 1,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 8,
|
||||||
|
},
|
||||||
|
navButtonText: {
|
||||||
|
color: "#13304a",
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: "700",
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
color: "#13304a",
|
||||||
|
fontSize: 28,
|
||||||
|
fontWeight: "800",
|
||||||
|
},
|
||||||
|
panel: {
|
||||||
|
backgroundColor: "#fffaf1",
|
||||||
|
borderColor: "#dccfb9",
|
||||||
|
borderRadius: 24,
|
||||||
|
borderWidth: 1,
|
||||||
|
gap: 12,
|
||||||
|
padding: 18,
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
color: "#13304a",
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: "700",
|
||||||
|
marginBottom: -4,
|
||||||
|
textTransform: "uppercase",
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
backgroundColor: "#f7f0e0",
|
||||||
|
borderColor: "#d9ccb5",
|
||||||
|
borderRadius: 16,
|
||||||
|
borderWidth: 1,
|
||||||
|
color: "#1a2b39",
|
||||||
|
fontSize: 16,
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
paddingVertical: 14,
|
||||||
|
},
|
||||||
|
helperText: {
|
||||||
|
color: "#665f54",
|
||||||
|
fontSize: 13,
|
||||||
|
lineHeight: 18,
|
||||||
|
},
|
||||||
|
pickerWrapper: {
|
||||||
|
backgroundColor: "#f7f0e0",
|
||||||
|
borderColor: "#d9ccb5",
|
||||||
|
borderRadius: 16,
|
||||||
|
borderWidth: 1,
|
||||||
|
overflow: "hidden",
|
||||||
|
},
|
||||||
|
picker: {
|
||||||
|
height: 50,
|
||||||
|
},
|
||||||
|
});
|
||||||
BIN
apk/assets/images/android-icon-background.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
apk/assets/images/android-icon-foreground.png
Normal file
|
After Width: | Height: | Size: 77 KiB |
BIN
apk/assets/images/android-icon-monochrome.png
Normal file
|
After Width: | Height: | Size: 4.0 KiB |
BIN
apk/assets/images/favicon.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
apk/assets/images/icon.png
Normal file
|
After Width: | Height: | Size: 384 KiB |
BIN
apk/assets/images/partial-react-logo.png
Normal file
|
After Width: | Height: | Size: 5.0 KiB |
BIN
apk/assets/images/react-logo.png
Normal file
|
After Width: | Height: | Size: 6.2 KiB |
BIN
apk/assets/images/react-logo@2x.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
apk/assets/images/react-logo@3x.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
apk/assets/images/splash-icon.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
59
apk/build.sh
Executable file
@@ -0,0 +1,59 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
export ANDROID_HOME=$HOME/android
|
||||||
|
export ANDROID_SDK_ROOT=$ANDROID_HOME
|
||||||
|
export PATH=$ANDROID_HOME/emulator:$ANDROID_HOME/platform-tools:$ANDROID_HOME/tools:$ANDROID_HOME/tools/bin:$PATH
|
||||||
|
|
||||||
|
set -e # stop on error
|
||||||
|
|
||||||
|
echo "🚧 Starting APK build..."
|
||||||
|
|
||||||
|
# Step 1: Ensure native android folder exists
|
||||||
|
if [ ! -d "android" ]; then
|
||||||
|
echo "📦 Running Expo prebuild..."
|
||||||
|
npx expo prebuild
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Step 2: Go to android folder
|
||||||
|
cd android
|
||||||
|
|
||||||
|
# Step 3: Check if keystore exists
|
||||||
|
KEYSTORE_FILE="app/my-release-key.keystore"
|
||||||
|
|
||||||
|
if [ ! -f "$KEYSTORE_FILE" ]; then
|
||||||
|
echo "🔑 Generating keystore..."
|
||||||
|
|
||||||
|
keytool -genkeypair -v \
|
||||||
|
-storetype PKCS12 \
|
||||||
|
-keystore $KEYSTORE_FILE \
|
||||||
|
-alias my-key-alias \
|
||||||
|
-keyalg RSA \
|
||||||
|
-keysize 2048 \
|
||||||
|
-validity 10000 \
|
||||||
|
-storepass password \
|
||||||
|
-keypass password \
|
||||||
|
-dname "CN=Your Name, OU=Dev, O=MyApp, L=City, S=State, C=US"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Step 4: Ensure gradle.properties has signing config
|
||||||
|
GRADLE_PROPS="gradle.properties"
|
||||||
|
|
||||||
|
if ! grep -q "MYAPP_UPLOAD_STORE_FILE" $GRADLE_PROPS; then
|
||||||
|
echo "⚙️ Adding signing config..."
|
||||||
|
|
||||||
|
cat <<EOF >> $GRADLE_PROPS
|
||||||
|
MYAPP_UPLOAD_STORE_FILE=my-release-key.keystore
|
||||||
|
MYAPP_UPLOAD_KEY_ALIAS=my-key-alias
|
||||||
|
MYAPP_UPLOAD_STORE_PASSWORD=password
|
||||||
|
MYAPP_UPLOAD_KEY_PASSWORD=password
|
||||||
|
EOF
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Step 5: Build release APK
|
||||||
|
echo "🏗️ Building APK..."
|
||||||
|
./gradlew assembleRelease
|
||||||
|
|
||||||
|
# Step 6: Output path
|
||||||
|
APK_PATH="app/build/outputs/apk/release/app-release.apk"
|
||||||
|
|
||||||
|
echo "✅ Build complete!"
|
||||||
|
echo "📦 APK located at: android/$APK_PATH"
|
||||||
10
apk/eslint.config.js
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
// https://docs.expo.dev/guides/using-eslint/
|
||||||
|
const { defineConfig } = require('eslint/config');
|
||||||
|
const expoConfig = require('eslint-config-expo/flat');
|
||||||
|
|
||||||
|
module.exports = defineConfig([
|
||||||
|
expoConfig,
|
||||||
|
{
|
||||||
|
ignores: ['dist/*'],
|
||||||
|
},
|
||||||
|
]);
|
||||||
44
apk/lib/recorder-settings.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||||
|
|
||||||
|
import type { Locale } from "./translations";
|
||||||
|
|
||||||
|
export const STORAGE_KEYS = {
|
||||||
|
authToken: "recorder.authToken",
|
||||||
|
backendUrl: "recorder.backendUrl",
|
||||||
|
fieldName: "recorder.fieldName",
|
||||||
|
language: "recorder.language",
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RecorderSettings = {
|
||||||
|
authToken: string;
|
||||||
|
backendUrl: string;
|
||||||
|
fieldName: string;
|
||||||
|
language: Locale;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function loadRecorderSettings(): Promise<RecorderSettings> {
|
||||||
|
const entries = await AsyncStorage.multiGet([
|
||||||
|
STORAGE_KEYS.backendUrl,
|
||||||
|
STORAGE_KEYS.authToken,
|
||||||
|
STORAGE_KEYS.fieldName,
|
||||||
|
STORAGE_KEYS.language,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const values = Object.fromEntries(entries);
|
||||||
|
|
||||||
|
return {
|
||||||
|
authToken: values[STORAGE_KEYS.authToken] ?? "",
|
||||||
|
backendUrl: values[STORAGE_KEYS.backendUrl] ?? "",
|
||||||
|
fieldName: values[STORAGE_KEYS.fieldName] ?? "file",
|
||||||
|
language: (values[STORAGE_KEYS.language] as Locale) ?? "ca",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveRecorderSettings(settings: RecorderSettings) {
|
||||||
|
await AsyncStorage.multiSet([
|
||||||
|
[STORAGE_KEYS.backendUrl, settings.backendUrl],
|
||||||
|
[STORAGE_KEYS.authToken, settings.authToken],
|
||||||
|
[STORAGE_KEYS.fieldName, settings.fieldName || "file"],
|
||||||
|
[STORAGE_KEYS.language, settings.language],
|
||||||
|
]);
|
||||||
|
}
|
||||||
100
apk/lib/translations/index.ts
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
export type TranslationKeys = ReturnType<typeof getStrings>;
|
||||||
|
|
||||||
|
export function en() {
|
||||||
|
return {
|
||||||
|
appTitle: "Quibot Control",
|
||||||
|
settingsTitle: "Settings",
|
||||||
|
back: "Back",
|
||||||
|
save: "Save",
|
||||||
|
backendUrl: "Backend URL",
|
||||||
|
bearerToken: "Bearer token",
|
||||||
|
formFieldName: "Form field name",
|
||||||
|
tokenOptional: "Optional",
|
||||||
|
fieldNamePlaceholder: "file",
|
||||||
|
urlPlaceholder: "https://api.example.com/upload",
|
||||||
|
helperText: `The recording is uploaded as multipart field '{field}'.`,
|
||||||
|
savedAlert: "Settings saved.",
|
||||||
|
loadError: "Could not load backend settings.",
|
||||||
|
saveError: "Could not save backend settings.",
|
||||||
|
languageTitle: "Language",
|
||||||
|
recorderTitle: "Voice recorder",
|
||||||
|
readyToRecord: "Ready to record.",
|
||||||
|
recording: "Recording...",
|
||||||
|
micPermissionDenied: "Microphone permission was denied.",
|
||||||
|
micAccessRequiredTitle: "Microphone access required",
|
||||||
|
micAccessRequiredMsg: "Enable microphone access to record audio.",
|
||||||
|
couldNotStartRecording: "Could not start recording.",
|
||||||
|
recordingFailedTitle: "Recording failed",
|
||||||
|
finishedUpload: "Recording finished. Uploading...",
|
||||||
|
voiceMessageSent: "Voice message sent.",
|
||||||
|
uploadFailed: "Upload failed.",
|
||||||
|
noBackendUrl: "Recording finished. No backend URL set.",
|
||||||
|
stopFailedTitle: "Stop failed",
|
||||||
|
missingBackendUrlTitle: "Missing backend URL",
|
||||||
|
missingBackendUrlMsg: "Enter the backend endpoint first.",
|
||||||
|
uploadingRecording: "Uploading recording.",
|
||||||
|
uploadComplete: "Upload complete.",
|
||||||
|
serverResponse: "Server response",
|
||||||
|
releaseToStop: "Release your finger to stop recording and send the audio.",
|
||||||
|
holdToRecord: "Hold the microphone button to record a voice message. Release to send it immediately.",
|
||||||
|
openSettingsHint: "Open settings to add your backend URL before sending voice messages.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ca() {
|
||||||
|
return {
|
||||||
|
appTitle: "Quibot Control",
|
||||||
|
settingsTitle: "Configuraci\u00f3",
|
||||||
|
back: "Enrere",
|
||||||
|
save: "Desa",
|
||||||
|
backendUrl: "URL del servidor",
|
||||||
|
bearerToken: "Bearer token",
|
||||||
|
formFieldName: "Nom del camp del formulari",
|
||||||
|
tokenOptional: "Opcional",
|
||||||
|
fieldNamePlaceholder: "file",
|
||||||
|
urlPlaceholder: "https://api.example.com/upload",
|
||||||
|
helperText: `La gravaci\u00f3 es penja com el camp multipart '{field}'.`,
|
||||||
|
savedAlert: "Configuraci\u00f3 desada.",
|
||||||
|
loadError: "No s'han pogut carregar les configuracions.",
|
||||||
|
saveError: "No s'han pogut desar les configuracions.",
|
||||||
|
languageTitle: "Llenguatge",
|
||||||
|
recorderTitle: "Enregistrador de veu",
|
||||||
|
readyToRecord: "Preparat per enregistrar.",
|
||||||
|
recording: "Enregistrant...",
|
||||||
|
micPermissionDenied: "S'ha denegat el perm\u00eds del micr\u00f2fon.",
|
||||||
|
micAccessRequiredTitle: "Acc\u00e9s al micr\u00f2fon necess\u00e0ri",
|
||||||
|
micAccessRequiredMsg: "Activa l'acc\u00e9s al micr\u00f2fon per enregistrar \u00e0udio.",
|
||||||
|
couldNotStartRecording: "No s'ha pogut iniciar l'enregistrament.",
|
||||||
|
recordingFailedTitle: "L'enregistrament ha fallat",
|
||||||
|
finishedUpload: "Gravaci\u00f3 finalitzada. S'est\u00e0 penjant...",
|
||||||
|
voiceMessageSent: "Missatge de veu enviat.",
|
||||||
|
uploadFailed: "No s'ha pogut penjar.",
|
||||||
|
noBackendUrl: "Gravaci\u00f3 finalitzada. No hi ha URL configurada.",
|
||||||
|
stopFailedTitle: "S'ha aturat",
|
||||||
|
missingBackendUrlTitle: "Falta la URL del servidor",
|
||||||
|
missingBackendUrlMsg: "Introdueix primer l'URL del servidor.",
|
||||||
|
uploadingRecording: "S'est\u00e0 penjant la gravaci\u00f3.",
|
||||||
|
uploadComplete: "Penjada completada.",
|
||||||
|
serverResponse: "Resposta del servidor",
|
||||||
|
releaseToStop: "Allibera el dit per aturar l'enregistrament i enviar l'\u00e0udio.",
|
||||||
|
holdToRecord: "Mant\u00e9s premut el micr\u00f2fon per enregistrar un missatge de veu. Allibera'l per enviar-lo immediatament.",
|
||||||
|
openSettingsHint: "Obre la configuraci\u00f3 per afegir l'URL del servidor abans d'enviar missatges de veu.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const translations = { en, ca };
|
||||||
|
export type Locale = keyof typeof translations;
|
||||||
|
const DEFAULT_LOCALE: Locale = "ca";
|
||||||
|
export const AVAILABLE_LOCALES: readonly Locale[] = Object.keys(translations) as Locale[];
|
||||||
|
|
||||||
|
export function getStrings(locale: Locale) {
|
||||||
|
const fn = translations[locale] ?? en;
|
||||||
|
return fn();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function t(key: keyof ReturnType<typeof ca>, locale: Locale, field?: string) {
|
||||||
|
const strings = getStrings(locale);
|
||||||
|
const value = strings[key as keyof typeof strings];
|
||||||
|
if (typeof value !== "string") return String(value);
|
||||||
|
return field ? value.replace("{field}", field) : value;
|
||||||
|
}
|
||||||
13092
apk/package-lock.json
generated
Normal file
36
apk/package.json
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"name": "voice-drop",
|
||||||
|
"main": "expo-router/entry",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"scripts": {
|
||||||
|
"start": "expo start",
|
||||||
|
"android": "expo run:android",
|
||||||
|
"ios": "expo run:ios",
|
||||||
|
"web": "expo start --web",
|
||||||
|
"lint": "expo lint"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@react-native-async-storage/async-storage": "2.2.0",
|
||||||
|
"@react-native-picker/picker": "^2.11.4",
|
||||||
|
"@react-native-voice/voice": "^3.2.4",
|
||||||
|
"expo": "~54.0.33",
|
||||||
|
"expo-av": "~16.0.8",
|
||||||
|
"expo-router": "~6.0.23",
|
||||||
|
"expo-splash-screen": "~31.0.13",
|
||||||
|
"expo-status-bar": "~3.0.9",
|
||||||
|
"react": "19.1.0",
|
||||||
|
"react-dom": "19.1.0",
|
||||||
|
"react-native": "0.81.5",
|
||||||
|
"react-native-safe-area-context": "~5.6.0",
|
||||||
|
"react-native-screens": "~4.16.0",
|
||||||
|
"react-native-svg": "^15.15.5",
|
||||||
|
"react-native-web": "~0.21.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "~19.1.0",
|
||||||
|
"eslint": "^9.25.0",
|
||||||
|
"eslint-config-expo": "~10.0.0",
|
||||||
|
"typescript": "~5.9.2"
|
||||||
|
},
|
||||||
|
"private": true
|
||||||
|
}
|
||||||
17
apk/tsconfig.json
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"extends": "expo/tsconfig.base",
|
||||||
|
"compilerOptions": {
|
||||||
|
"strict": true,
|
||||||
|
"paths": {
|
||||||
|
"@/*": [
|
||||||
|
"./*"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
".expo/types/**/*.ts",
|
||||||
|
"expo-env.d.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
9
backend/.env.example
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# Raspberry Pi connection config
|
||||||
|
RASPBERRY_PI_HOST=http://raspberrypi.local
|
||||||
|
RASPBERRY_PI_PORT=8000
|
||||||
|
|
||||||
|
# Auth token for API endpoints
|
||||||
|
QUIBOT_TOKEN=MY_SECRET_TOKEN
|
||||||
|
|
||||||
|
# Backend server config
|
||||||
|
PORT=3000
|
||||||
4
backend/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.env
|
||||||
|
*.log
|
||||||
1919
backend/package-lock.json
generated
Normal file
28
backend/package.json
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"name": "quibot-backend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "QuiBot robot controller backend - runs on local laptop",
|
||||||
|
"type": "module",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "tsx watch src/index.ts",
|
||||||
|
"build": "tsc",
|
||||||
|
"start": "node dist/index.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"axios": "^1.7.0",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"dotenv": "^16.4.5",
|
||||||
|
"express": "^4.21.0",
|
||||||
|
"multer": "^1.4.5-lts.1",
|
||||||
|
"openai": "^6.44.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/cors": "^2.8.17",
|
||||||
|
"@types/express": "^4.17.21",
|
||||||
|
"@types/multer": "^1.4.12",
|
||||||
|
"@types/node": "^22.19.21",
|
||||||
|
"tsx": "^4.19.0",
|
||||||
|
"typescript": "^5.6.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
1
backend/quibot-audio-1781783002989.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
Col·la, pítalo, la ola, ola.
|
||||||
1
backend/quibot-audio-1781783032108.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
Hola, què tal, hola, hola, hola, hola...
|
||||||
1
backend/quibot-audio-1781783047628.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
Hola, que tal, bon dia.
|
||||||
34
backend/src/config.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import dotenv from 'dotenv';
|
||||||
|
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
|
let _raspberryHost = process.env.RASPBERRY_PI_HOST ?? 'http://raspberrypi.local';
|
||||||
|
let _raspberryPort = Number(process.env.RASPBERRY_PI_PORT) || 8000;
|
||||||
|
let _token = process.env.QUIBOT_TOKEN ?? 'MY_SECRET_TOKEN';
|
||||||
|
const APP_PORT = Number(process.env.PORT) || 5000;
|
||||||
|
|
||||||
|
export const getRaspberryHost = () => _raspberryHost;
|
||||||
|
export const getRaspberryPort = () => _raspberryPort;
|
||||||
|
export const getToken = () => _token;
|
||||||
|
|
||||||
|
export function setRaspberryHost(host: string) {
|
||||||
|
_raspberryHost = host;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setRaspberryPort(port: number) {
|
||||||
|
_raspberryPort = port;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setToken(token: string) {
|
||||||
|
_token = token;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getConfig = () => ({
|
||||||
|
raspberryPi: {
|
||||||
|
host: getRaspberryHost(),
|
||||||
|
port: getRaspberryPort(),
|
||||||
|
},
|
||||||
|
token: getToken(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const getAppPort = () => APP_PORT;
|
||||||
119
backend/src/controllers/audio.controller.ts
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import multer from 'multer';
|
||||||
|
import { execFile } from 'child_process';
|
||||||
|
import { tmpdir } from 'os';
|
||||||
|
import { join } from 'path';
|
||||||
|
import { promisify } from 'util';
|
||||||
|
import { writeFile, unlink } from 'fs';
|
||||||
|
import { raspiService } from '../services/raspi.service.js';
|
||||||
|
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
const writeFileAsync = promisify(writeFile);
|
||||||
|
const unlinkAsync = promisify(unlink);
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
const upload = multer({ storage: multer.memoryStorage() });
|
||||||
|
|
||||||
|
router.get('/incoming', async (_req, res) => {
|
||||||
|
try {
|
||||||
|
const result = await raspiService.listIncomingAudio();
|
||||||
|
res.json(result);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||||
|
res.status(500).json({ error: `List incoming failed: ${message}` });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/lock/:filename', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { filename } = req.params;
|
||||||
|
const result = await raspiService.lockAudio({ filename });
|
||||||
|
res.json(result);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||||
|
res.status(500).json({ error: `Lock audio failed: ${message}` });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/unlock/:filename', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { filename } = req.params;
|
||||||
|
const result = await raspiService.unlockAudio({ filename });
|
||||||
|
res.json(result);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||||
|
res.status(500).json({ error: `Unlock audio failed: ${message}` });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/cancel/:filename', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { filename } = req.params;
|
||||||
|
const result = await raspiService.cancelAudio({ filename });
|
||||||
|
res.json(result);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||||
|
res.status(500).json({ error: `Cancel audio failed: ${message}` });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/process/:filename', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { filename } = req.params;
|
||||||
|
const result = await raspiService.processAudio({ filename });
|
||||||
|
res.json(result);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||||
|
res.status(500).json({ error: `Process audio failed: ${message}` });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const whisperModel = process.env.WHISPER_MODEL ?? 'base';
|
||||||
|
const whisperLanguage = process.env.WHISPER_LANGUAGE ?? 'ca';
|
||||||
|
|
||||||
|
router.post('/upload', upload.single('file'), async (req, res) => {
|
||||||
|
let tmpFile: string | undefined;
|
||||||
|
try {
|
||||||
|
if (!req.file) {
|
||||||
|
return res.status(400).json({ error: 'No audio file provided' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const ext = req.file.originalname.split('.').pop()?.toLowerCase() || 'wav';
|
||||||
|
tmpFile = join(tmpdir(), `quibot-audio-${Date.now()}.${ext}`);
|
||||||
|
await writeFileAsync(tmpFile, req.file.buffer);
|
||||||
|
|
||||||
|
console.log(`[whisper] Model: ${whisperModel}, Language: ${whisperLanguage}, File: ${tmpFile}`);
|
||||||
|
|
||||||
|
const { stdout, stderr } = await execFileAsync('whisper', [
|
||||||
|
tmpFile,
|
||||||
|
'--model', whisperModel,
|
||||||
|
'--language', whisperLanguage,
|
||||||
|
'--output_format', 'txt',
|
||||||
|
], { maxBuffer: 50 * 1024 * 1024 });
|
||||||
|
|
||||||
|
if (stderr) {
|
||||||
|
console.log(`[whisper] stderr: ${stderr}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const transcription = stdout.trim();
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
transcription,
|
||||||
|
originalFilename: req.file.originalname,
|
||||||
|
});
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||||
|
res.status(500).json({ error: `Audio transcription failed: ${message}` });
|
||||||
|
} finally {
|
||||||
|
if (tmpFile) {
|
||||||
|
try {
|
||||||
|
await unlinkAsync(tmpFile);
|
||||||
|
} catch {
|
||||||
|
// ignore cleanup errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
20
backend/src/controllers/command.controller.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { raspiService } from '../services/raspi.service.js';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.post('/', async (req, res) => {
|
||||||
|
const { task } = req.body;
|
||||||
|
if (!task) {
|
||||||
|
return res.status(400).json({ error: 'Task name is required' });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const result = await raspiService.runTask({ task });
|
||||||
|
res.json(result);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||||
|
res.status(500).json({ error: `Run task failed: ${message}` });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
53
backend/src/controllers/motor.controller.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import multer from 'multer';
|
||||||
|
import { raspiService } from '../services/raspi.service.js';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// Multer config - store in memory for proxying to RasPi
|
||||||
|
const upload = multer({ storage: multer.memoryStorage() });
|
||||||
|
|
||||||
|
router.post('/step/forward', async (_req, res) => {
|
||||||
|
try {
|
||||||
|
const result = await raspiService.motorStepForward();
|
||||||
|
res.json(result);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||||
|
res.status(500).json({ error: `Motor step forward failed: ${message}` });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/step/backward', async (_req, res) => {
|
||||||
|
try {
|
||||||
|
const result = await raspiService.motorStepBackward();
|
||||||
|
res.json(result);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||||
|
res.status(500).json({ error: `Motor step backward failed: ${message}` });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/stop', async (_req, res) => {
|
||||||
|
try {
|
||||||
|
const result = await raspiService.motorStop();
|
||||||
|
res.json(result);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||||
|
res.status(500).json({ error: `Motor stop failed: ${message}` });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/upload', upload.single('file'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
if (!req.file) {
|
||||||
|
return res.status(400).json({ error: 'No audio file provided' });
|
||||||
|
}
|
||||||
|
const result = await raspiService.uploadAudio(req.file.buffer, req.file.originalname);
|
||||||
|
res.json(result);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||||
|
res.status(500).json({ error: `Audio upload failed: ${message}` });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
25
backend/src/controllers/settings.controller.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { getConfig, setRaspberryHost, setRaspberryPort, setToken } from '../config.js';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.get('/', (_req, res) => {
|
||||||
|
const settings = getConfig();
|
||||||
|
res.json(settings);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put('/', (req, res) => {
|
||||||
|
const { raspberryPi, token } = req.body;
|
||||||
|
if (raspberryPi?.host !== undefined) {
|
||||||
|
setRaspberryHost(raspberryPi.host);
|
||||||
|
}
|
||||||
|
if (raspberryPi?.port !== undefined) {
|
||||||
|
setRaspberryPort(Number(raspberryPi.port));
|
||||||
|
}
|
||||||
|
if (token !== undefined) {
|
||||||
|
setToken(token);
|
||||||
|
}
|
||||||
|
res.json(getConfig());
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
101
backend/src/controllers/text-command.controller.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { raspiService } from '../services/raspi.service.js';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
const commandMap = [
|
||||||
|
// Catalan forward
|
||||||
|
{ words: ['endavant', 'avança'], action: 'forward' },
|
||||||
|
// Catalan backward
|
||||||
|
{ words: ['atras', 'enrere', 'atras'], action: 'backward' },
|
||||||
|
// Catalan left
|
||||||
|
{ words: ['esquerra', 'esquerre', 'stoppa'], action: 'left' },
|
||||||
|
// Catalan right
|
||||||
|
{ words: ['dreta', 'destre'], action: 'right' },
|
||||||
|
// Stop
|
||||||
|
{ words: ['atura', 'atura', 'pare', 'stop', 'parada', 'aturar'], action: 'stop' },
|
||||||
|
// Pick up / grab
|
||||||
|
{ words: ['recull', 'pega', 'pilla', 'agafa'], action: 'pick' },
|
||||||
|
// Eject / throw
|
||||||
|
{ words: ['llança', 'tira', 'expulsa', ' llença'], action: 'eject' },
|
||||||
|
|
||||||
|
// Spanish forward
|
||||||
|
{ words: ['adelante'], action: 'forward' },
|
||||||
|
// Spanish backward
|
||||||
|
{ words: ['atras', 'atrás', 'reversa', 'al tras'], action: 'backward' },
|
||||||
|
// Spanish left
|
||||||
|
{ words: ['izquierda', 'izq'], action: 'left' },
|
||||||
|
// Spanish right
|
||||||
|
{ words: ['derecha', 'der'], action: 'right' },
|
||||||
|
// Spanish stop
|
||||||
|
{ words: ['para', 'stop', 'pare', 'deten', 'frena', 'alto'], action: 'stop' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function resolveCommand(text: string) {
|
||||||
|
const lower = text.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "");
|
||||||
|
for (const entry of commandMap) {
|
||||||
|
for (const word of entry.words) {
|
||||||
|
const normalizedWord = word.normalize("NFD").replace(/[\u0300-\u036f]/g, "");
|
||||||
|
if (lower.includes(normalizedWord)) {
|
||||||
|
return entry.action;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
router.post('/text', async (req, res) => {
|
||||||
|
const { text } = req.body;
|
||||||
|
if (!text || typeof text !== 'string') {
|
||||||
|
return res.status(400).json({ error: 'Text is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const action = resolveCommand(text);
|
||||||
|
if (!action) {
|
||||||
|
return res.json({ recognized: text.trim(), action: null, message: 'No motor command matched. Use: endavant, atras, esquerra, dreta, atura, recull, llança' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
switch (action) {
|
||||||
|
case 'forward': {
|
||||||
|
const result = await raspiService.motorStepForward();
|
||||||
|
return res.json({ recognized: text.trim(), action: 'forward', result });
|
||||||
|
}
|
||||||
|
case 'backward': {
|
||||||
|
const result = await raspiService.motorStepBackward();
|
||||||
|
return res.json({ recognized: text.trim(), action: 'backward', result });
|
||||||
|
}
|
||||||
|
case 'left': {
|
||||||
|
await raspiService.motorStepForward();
|
||||||
|
await new Promise(r => setTimeout(r, 600));
|
||||||
|
await raspiService.motorStop();
|
||||||
|
return res.json({ recognized: text.trim(), action: 'turn-left', result: { status: 'turned left' } });
|
||||||
|
}
|
||||||
|
case 'right': {
|
||||||
|
await raspiService.motorStepBackward();
|
||||||
|
await new Promise(r => setTimeout(r, 600));
|
||||||
|
await raspiService.motorStop();
|
||||||
|
return res.json({ recognized: text.trim(), action: 'turn-right', result: { status: 'turned right' } });
|
||||||
|
}
|
||||||
|
case 'stop': {
|
||||||
|
const result = await raspiService.motorStop();
|
||||||
|
return res.json({ recognized: text.trim(), action: 'stop', result });
|
||||||
|
}
|
||||||
|
case 'pick': {
|
||||||
|
return res.json({ recognized: text.trim(), action: 'pick', message: 'Pick command received' });
|
||||||
|
}
|
||||||
|
case 'eject': {
|
||||||
|
return res.json({ recognized: text.trim(), action: 'eject', message: 'Eject command received' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||||
|
return res.status(500).json({
|
||||||
|
recognized: text.trim(),
|
||||||
|
action,
|
||||||
|
error: `Command execution failed: ${message}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
25
backend/src/index.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import cors from 'cors';
|
||||||
|
import router from './routes/router.js';
|
||||||
|
import { getAppPort, getConfig } from './config.js';
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
|
||||||
|
app.use(cors());
|
||||||
|
app.use(express.json());
|
||||||
|
|
||||||
|
// Handle multipart in motor controller separately
|
||||||
|
app.use('/audio', express.json());
|
||||||
|
app.use('/motor', express.json());
|
||||||
|
app.use('/commands', express.json());
|
||||||
|
|
||||||
|
app.use(router);
|
||||||
|
|
||||||
|
app.get('/health', (_req, res) => {
|
||||||
|
const settings = getConfig();
|
||||||
|
res.json({ status: 'ok', settings });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.listen(getAppPort(), () => {
|
||||||
|
console.log(`QuiBot backend listening on port ${getAppPort()}`);
|
||||||
|
});
|
||||||
16
backend/src/routes/router.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import motorController from '../controllers/motor.controller.js';
|
||||||
|
import audioController from '../controllers/audio.controller.js';
|
||||||
|
import commandController from '../controllers/command.controller.js';
|
||||||
|
import settingsController from '../controllers/settings.controller.js';
|
||||||
|
import textCommandController from '../controllers/text-command.controller.js';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.use('/motor', motorController);
|
||||||
|
router.use('/audio', audioController);
|
||||||
|
router.use('/commands', commandController);
|
||||||
|
router.use('/commands/text', textCommandController);
|
||||||
|
router.use('/settings', settingsController);
|
||||||
|
|
||||||
|
export default router;
|
||||||
77
backend/src/services/raspi.service.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
import { getRaspberryHost, getRaspberryPort, getToken } from '../config.js';
|
||||||
|
|
||||||
|
interface RunTaskParams {
|
||||||
|
task: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AudioLockParams {
|
||||||
|
filename: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const raspiService = {
|
||||||
|
async runTask(params: RunTaskParams) {
|
||||||
|
const res = await axios.post(`${getRaspberryHost()}:${getRaspberryPort()}/run`, null, {
|
||||||
|
params: { task: params.task, token: getToken() },
|
||||||
|
});
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async motorStepForward() {
|
||||||
|
const res = await axios.post(`${getRaspberryHost()}:${getRaspberryPort()}/motor/step/forward`, null, {
|
||||||
|
params: { token: getToken() },
|
||||||
|
});
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async motorStepBackward() {
|
||||||
|
const res = await axios.post(`${getRaspberryHost()}:${getRaspberryPort()}/motor/step/backwards`, null, {
|
||||||
|
params: { token: getToken() },
|
||||||
|
});
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async motorStop() {
|
||||||
|
const res = await axios.post(`${getRaspberryHost()}:${getRaspberryPort()}/motor/stop`, null, {
|
||||||
|
params: { token: getToken() },
|
||||||
|
});
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async uploadAudio(buffer: Buffer, filename?: string) {
|
||||||
|
const fname = filename || 'audio.wav';
|
||||||
|
const ext = fname.split('.').pop()?.toLowerCase() || 'wav';
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', new Blob([buffer]), fname);
|
||||||
|
const res = await axios.post(`${getRaspberryHost()}:${getRaspberryPort()}/audio/upload`, formData, {
|
||||||
|
params: { format: ext },
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
|
});
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async listIncomingAudio() {
|
||||||
|
const res = await axios.get(`${getRaspberryHost()}:${getRaspberryPort()}/audio/incoming`);
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async lockAudio(params: AudioLockParams) {
|
||||||
|
const res = await axios.post(`${getRaspberryHost()}:${getRaspberryPort()}/audio/lock/${params.filename}`);
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async unlockAudio(params: AudioLockParams) {
|
||||||
|
const res = await axios.post(`${getRaspberryHost()}:${getRaspberryPort()}/audio/unlock/${params.filename}`);
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async cancelAudio(params: AudioLockParams) {
|
||||||
|
const res = await axios.post(`${getRaspberryHost()}:${getRaspberryPort()}/audio/cancel/${params.filename}`);
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async processAudio(params: AudioLockParams) {
|
||||||
|
const res = await axios.post(`${getRaspberryHost()}:${getRaspberryPort()}/audio/process/${params.filename}`);
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
};
|
||||||
20
backend/tsconfig.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "Node16",
|
||||||
|
"moduleResolution": "Node16",
|
||||||
|
"lib": ["ES2022"],
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"sourceMap": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
BIN
igualtat_h3.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
logo-qui-bot-capcalera.png
Normal file
|
After Width: | Height: | Size: 49 KiB |
389
package-lock.json
generated
Normal file
@@ -0,0 +1,389 @@
|
|||||||
|
{
|
||||||
|
"name": "quibot",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"dependencies": {
|
||||||
|
"vue-i18n": "^11.4.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@babel/helper-string-parser": {
|
||||||
|
"version": "7.29.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
|
||||||
|
"integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.9.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@babel/helper-validator-identifier": {
|
||||||
|
"version": "7.29.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
|
||||||
|
"integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.9.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@babel/parser": {
|
||||||
|
"version": "7.29.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz",
|
||||||
|
"integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/types": "^7.29.7"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"parser": "bin/babel-parser.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@babel/types": {
|
||||||
|
"version": "7.29.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz",
|
||||||
|
"integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/helper-string-parser": "^7.29.7",
|
||||||
|
"@babel/helper-validator-identifier": "^7.29.7"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.9.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@intlify/core-base": {
|
||||||
|
"version": "11.4.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-11.4.5.tgz",
|
||||||
|
"integrity": "sha512-lja3F/iKVIvTa48mIwmrIeDcQUFZ0F0drvFvT8AwINOvbwnAzl/S/p8p2DxILZpWEUHRi1qewfWNIkMvhD3kKA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@intlify/devtools-types": "11.4.5",
|
||||||
|
"@intlify/message-compiler": "11.4.5",
|
||||||
|
"@intlify/shared": "11.4.5"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 22"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/kazupon"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@intlify/devtools-types": {
|
||||||
|
"version": "11.4.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@intlify/devtools-types/-/devtools-types-11.4.5.tgz",
|
||||||
|
"integrity": "sha512-W5vydP9Yq3t82IyWqCM6aR0BTWCZrN5RAwjZEPpH8I2OQWp2RLy03Evh2ANZlSMhcvGAoyDg25k0so85Kwncpw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@intlify/core-base": "11.4.5",
|
||||||
|
"@intlify/shared": "11.4.5"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 22"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/kazupon"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@intlify/message-compiler": {
|
||||||
|
"version": "11.4.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-11.4.5.tgz",
|
||||||
|
"integrity": "sha512-IEOZiHtbQopyPc/Dz2M869lOlZYX1SdcniNJwphATDYHhovvIneEKf1EFF37DE7NAABZtza1FNtnwwqZWInfpw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@intlify/shared": "11.4.5",
|
||||||
|
"source-map-js": "^1.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 22"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/kazupon"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@intlify/shared": {
|
||||||
|
"version": "11.4.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-11.4.5.tgz",
|
||||||
|
"integrity": "sha512-g/i5mtdUa9ia/8BaJ4w6ZRHgAXYQd9XyCaQPRMvsd8d5qmZwkjoTmHrNsI28Q/7I8h+2ijUkI4uEnnMCziKupQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 22"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/kazupon"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@jridgewell/sourcemap-codec": {
|
||||||
|
"version": "1.5.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
||||||
|
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
|
},
|
||||||
|
"node_modules/@vue/compiler-core": {
|
||||||
|
"version": "3.5.38",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.38.tgz",
|
||||||
|
"integrity": "sha512-s99aGxWYig9ErHbct27KXEGhrBYlRI6c4MwAgXErOAbX9xiW37/uMa+XUDO69zLz83dng8UUZ70CTOJrLrYrEQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/parser": "^7.29.7",
|
||||||
|
"@vue/shared": "3.5.38",
|
||||||
|
"entities": "^7.0.1",
|
||||||
|
"estree-walker": "^2.0.2",
|
||||||
|
"source-map-js": "^1.2.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vue/compiler-dom": {
|
||||||
|
"version": "3.5.38",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.38.tgz",
|
||||||
|
"integrity": "sha512-JTqp25l8aFfJYF7/KmsXZjAxJz7T+SjmTJLoXVjHtc2BrSgSiW2n9Aem/cWq1OPe68A8JL06B3eVdhlP0H4TVw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@vue/compiler-core": "3.5.38",
|
||||||
|
"@vue/shared": "3.5.38"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vue/compiler-sfc": {
|
||||||
|
"version": "3.5.38",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.38.tgz",
|
||||||
|
"integrity": "sha512-DuA2GiZawSEW442iw/9+Fkol8hTgb4Ke5KkhmSry65QA7YuyMbIdy8p0XZRMvNwJdgRz307W8g1CSzdvS4nuNg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/parser": "^7.29.7",
|
||||||
|
"@vue/compiler-core": "3.5.38",
|
||||||
|
"@vue/compiler-dom": "3.5.38",
|
||||||
|
"@vue/compiler-ssr": "3.5.38",
|
||||||
|
"@vue/shared": "3.5.38",
|
||||||
|
"estree-walker": "^2.0.2",
|
||||||
|
"magic-string": "^0.30.21",
|
||||||
|
"postcss": "^8.5.15",
|
||||||
|
"source-map-js": "^1.2.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vue/compiler-ssr": {
|
||||||
|
"version": "3.5.38",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.38.tgz",
|
||||||
|
"integrity": "sha512-7s+W5Gc42FGxZMcuwl8H5B29T8BJPMdBT7KHFE+BbAuZ/iTEdTtv7z2XiMjiaUUw4w3ZcCEdHs36RuYJ2VA7bA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@vue/compiler-dom": "3.5.38",
|
||||||
|
"@vue/shared": "3.5.38"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vue/devtools-api": {
|
||||||
|
"version": "6.6.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz",
|
||||||
|
"integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@vue/reactivity": {
|
||||||
|
"version": "3.5.38",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.38.tgz",
|
||||||
|
"integrity": "sha512-pG6LV/NDNRbKizcUjFFLAfjaL8mcv4DmR9avNcUw2gDHBzZneuS2TWCmp633ynzxz9YYKNeEPK2I8Wraqy2HUQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@vue/shared": "3.5.38"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vue/runtime-core": {
|
||||||
|
"version": "3.5.38",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.38.tgz",
|
||||||
|
"integrity": "sha512-iyW8WVfF1CpCXxncZY5Ei6rSd6oZr5DgEom//fUjRBRl56AXPD+s9ATvukRt77ZFTuYlnVA1bxY+dJB94tWVYw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@vue/reactivity": "3.5.38",
|
||||||
|
"@vue/shared": "3.5.38"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vue/runtime-dom": {
|
||||||
|
"version": "3.5.38",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.38.tgz",
|
||||||
|
"integrity": "sha512-apX2wt9sdfDshS+a2xueFZLVpt0GkRJZSoPmrW/SA4yzXTznhfcMVW59gr7h4YQeY0vJhdJkk2rsIDwgfFgC5A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@vue/reactivity": "3.5.38",
|
||||||
|
"@vue/runtime-core": "3.5.38",
|
||||||
|
"@vue/shared": "3.5.38",
|
||||||
|
"csstype": "^3.2.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vue/server-renderer": {
|
||||||
|
"version": "3.5.38",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.38.tgz",
|
||||||
|
"integrity": "sha512-vue8vbf2QlV4quHqzwmJy6dWfmRhP1J8l4wtZg60CL6VoKqcPY2oe7may3+1d9qfpedjK5PRLFqd5k3Isj9mUw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@vue/compiler-ssr": "3.5.38",
|
||||||
|
"@vue/shared": "3.5.38"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"vue": "3.5.38"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vue/shared": {
|
||||||
|
"version": "3.5.38",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.38.tgz",
|
||||||
|
"integrity": "sha512-FTW0AFZNaK5/mOqvGBwVfUlNLU38TiQn4+DQgIFUnrBBJQ1crMJ82yeGQLV5jyKFsO8yRukpbuP7x+nRbH6aug==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
|
},
|
||||||
|
"node_modules/csstype": {
|
||||||
|
"version": "3.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||||
|
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
|
},
|
||||||
|
"node_modules/entities": {
|
||||||
|
"version": "7.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
|
||||||
|
"integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"peer": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/estree-walker": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
|
},
|
||||||
|
"node_modules/magic-string": {
|
||||||
|
"version": "0.30.21",
|
||||||
|
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||||
|
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/nanoid": {
|
||||||
|
"version": "3.3.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
|
||||||
|
"integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ai"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"bin": {
|
||||||
|
"nanoid": "bin/nanoid.cjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/picocolors": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"peer": true
|
||||||
|
},
|
||||||
|
"node_modules/postcss": {
|
||||||
|
"version": "8.5.15",
|
||||||
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
|
||||||
|
"integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/postcss/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "tidelift",
|
||||||
|
"url": "https://tidelift.com/funding/github/npm/postcss"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ai"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"nanoid": "^3.3.12",
|
||||||
|
"picocolors": "^1.1.1",
|
||||||
|
"source-map-js": "^1.2.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^10 || ^12 || >=14"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/source-map-js": {
|
||||||
|
"version": "1.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||||
|
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/vue": {
|
||||||
|
"version": "3.5.38",
|
||||||
|
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.38.tgz",
|
||||||
|
"integrity": "sha512-vAMKHfImQlYSy0C+PBue4s3ERZ2xGKfgZg5GXAsLInq1dyh2H78ILVP5sK0KPFPVW4kv+OGCIvBEondcjpZp7A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@vue/compiler-dom": "3.5.38",
|
||||||
|
"@vue/compiler-sfc": "3.5.38",
|
||||||
|
"@vue/runtime-dom": "3.5.38",
|
||||||
|
"@vue/server-renderer": "3.5.38",
|
||||||
|
"@vue/shared": "3.5.38"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"typescript": "*"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"typescript": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/vue-i18n": {
|
||||||
|
"version": "11.4.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.4.5.tgz",
|
||||||
|
"integrity": "sha512-rm8YJ6RpjOrkcgS2GLrZwLvs/VbhxbTSuEspbyXDo233+fPK0OMFNLOj3fdQYVKdOgcpSfLW91JhbqgpkkcBWA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@intlify/core-base": "11.4.5",
|
||||||
|
"@intlify/devtools-types": "11.4.5",
|
||||||
|
"@intlify/shared": "11.4.5",
|
||||||
|
"@vue/devtools-api": "^6.5.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 22"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/kazupon"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"vue": "^3.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
5
package.json
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"dependencies": {
|
||||||
|
"vue-i18n": "^11.4.5"
|
||||||
|
}
|
||||||
|
}
|
||||||
67
quibot-web/README.md
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
# Quibot Web
|
||||||
|
|
||||||
|
Interfície web per controlar el robit de la Quibot. Construida amb [Nuxt 3](https://nuxt.com/) i [Vue 3](https://vuejs.org/).
|
||||||
|
|
||||||
|
## Requisits previs
|
||||||
|
|
||||||
|
- [Node.js](https://nodejs.org/) (versió 20 o superior)
|
||||||
|
- npm, yarn o pnpm
|
||||||
|
|
||||||
|
## Instal·lació
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
## Execució en mode desenvolupament
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
L'aplicació estarà disponible a `http://localhost:3000`.
|
||||||
|
|
||||||
|
## Configuració
|
||||||
|
|
||||||
|
L'aplicació es configura mitjançant variables d'entorn:
|
||||||
|
|
||||||
|
| Variable | Descripció | Valor per defecte |
|
||||||
|
|-------------------|----------------------------------|------------------------|
|
||||||
|
| `QUIBOT_BASE_URL` | URL del backend del Quibot | `http://quibot:8000` |
|
||||||
|
| `QUIBOT_TOKEN` | Token d'autenticació del backend | `MY_SECRET_TOKEN` |
|
||||||
|
|
||||||
|
## Comandos disponibles
|
||||||
|
|
||||||
|
| Comando | Descripció |
|
||||||
|
|-----------------|--------------------------------------------|
|
||||||
|
| `npm run dev` | Inicia el servidor de desenvolupament |
|
||||||
|
| `npm run build` | Compila l'aplicació per a producció |
|
||||||
|
| `npm run generate` | Genera l'aplicació estàtica |
|
||||||
|
| `npm run preview` | Previsualitza la versió de producció |
|
||||||
|
|
||||||
|
## Funcionalitat
|
||||||
|
|
||||||
|
L'interfície permet enviar comandes al motor del Quibot:
|
||||||
|
|
||||||
|
- **Step Forward** – Mou el motor cap endavant
|
||||||
|
- **Step Backwards** – Mou el motor cap enrere
|
||||||
|
- **Stop** – Atura el motor
|
||||||
|
|
||||||
|
## Estructura del projecte
|
||||||
|
|
||||||
|
```
|
||||||
|
quibot-web/
|
||||||
|
├── app/
|
||||||
|
│ └── app.vue # Pàgina principal amb els controls del motor
|
||||||
|
├── server/
|
||||||
|
│ └── api/motor/ # Rutes API del servidor
|
||||||
|
│ ├── stop.post.ts # Comanda d'aturada
|
||||||
|
│ └── step/[direction].post.ts # Comandes de moviment
|
||||||
|
├── public/
|
||||||
|
│ └── favicon.ico # Icona del navegador
|
||||||
|
├── nuxt.config.ts # Configuració del Nuxt
|
||||||
|
├── package.json
|
||||||
|
└── tsconfig.json # Configuració de TypeScript
|
||||||
|
```
|
||||||
|
|
||||||
|
## Llicència
|
||||||
19
quibot-web/android/.gitignore
vendored
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
# OSX
|
||||||
|
#
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Android/IntelliJ
|
||||||
|
#
|
||||||
|
build/
|
||||||
|
.idea
|
||||||
|
.gradle
|
||||||
|
local.properties
|
||||||
|
*.iml
|
||||||
|
*.hprof
|
||||||
|
.cxx/
|
||||||
|
|
||||||
|
# generated inline modules
|
||||||
|
app/src/main/java/inline/
|
||||||
|
|
||||||
|
# Bundle artifacts
|
||||||
|
*.jsbundle
|
||||||
182
quibot-web/android/app/build.gradle
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
apply plugin: "com.android.application"
|
||||||
|
apply plugin: "org.jetbrains.kotlin.android"
|
||||||
|
apply plugin: "com.facebook.react"
|
||||||
|
|
||||||
|
def projectRoot = rootDir.getAbsoluteFile().getParentFile().getAbsolutePath()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This is the configuration block to customize your React Native Android app.
|
||||||
|
* By default you don't need to apply any configuration, just uncomment the lines you need.
|
||||||
|
*/
|
||||||
|
react {
|
||||||
|
entryFile = file(["node", "-e", "require('expo/scripts/resolveAppEntry')", projectRoot, "android", "absolute"].execute(null, rootDir).text.trim())
|
||||||
|
reactNativeDir = new File(["node", "--print", "require.resolve('react-native/package.json')"].execute(null, rootDir).text.trim()).getParentFile().getAbsoluteFile()
|
||||||
|
hermesCommand = new File(["node", "--print", "require.resolve('hermes-compiler/package.json', { paths: [require.resolve('react-native/package.json')] })"].execute(null, rootDir).text.trim()).getParentFile().getAbsolutePath() + "/hermesc/%OS-BIN%/hermesc"
|
||||||
|
codegenDir = new File(["node", "--print", "require.resolve('@react-native/codegen/package.json', { paths: [require.resolve('react-native/package.json')] })"].execute(null, rootDir).text.trim()).getParentFile().getAbsoluteFile()
|
||||||
|
|
||||||
|
enableBundleCompression = (findProperty('android.enableBundleCompression') ?: false).toBoolean()
|
||||||
|
// Use Expo CLI to bundle the app, this ensures the Metro config
|
||||||
|
// works correctly with Expo projects.
|
||||||
|
cliFile = new File(["node", "--print", "require.resolve('@expo/cli', { paths: [require.resolve('expo/package.json')] })"].execute(null, rootDir).text.trim())
|
||||||
|
bundleCommand = "export:embed"
|
||||||
|
|
||||||
|
/* Folders */
|
||||||
|
// The root of your project, i.e. where "package.json" lives. Default is '../..'
|
||||||
|
// root = file("../../")
|
||||||
|
// The folder where the react-native NPM package is. Default is ../../node_modules/react-native
|
||||||
|
// reactNativeDir = file("../../node_modules/react-native")
|
||||||
|
// The folder where the react-native Codegen package is. Default is ../../node_modules/@react-native/codegen
|
||||||
|
// codegenDir = file("../../node_modules/@react-native/codegen")
|
||||||
|
|
||||||
|
/* Variants */
|
||||||
|
// The list of variants to that are debuggable. For those we're going to
|
||||||
|
// skip the bundling of the JS bundle and the assets. By default is just 'debug'.
|
||||||
|
// If you add flavors like lite, prod, etc. you'll have to list your debuggableVariants.
|
||||||
|
// debuggableVariants = ["liteDebug", "prodDebug"]
|
||||||
|
|
||||||
|
/* Bundling */
|
||||||
|
// A list containing the node command and its flags. Default is just 'node'.
|
||||||
|
// nodeExecutableAndArgs = ["node"]
|
||||||
|
|
||||||
|
//
|
||||||
|
// The path to the CLI configuration file. Default is empty.
|
||||||
|
// bundleConfig = file(../rn-cli.config.js)
|
||||||
|
//
|
||||||
|
// The name of the generated asset file containing your JS bundle
|
||||||
|
// bundleAssetName = "MyApplication.android.bundle"
|
||||||
|
//
|
||||||
|
// The entry file for bundle generation. Default is 'index.android.js' or 'index.js'
|
||||||
|
// entryFile = file("../js/MyApplication.android.js")
|
||||||
|
//
|
||||||
|
// A list of extra flags to pass to the 'bundle' commands.
|
||||||
|
// See https://github.com/react-native-community/cli/blob/main/docs/commands.md#bundle
|
||||||
|
// extraPackagerArgs = []
|
||||||
|
|
||||||
|
/* Hermes Commands */
|
||||||
|
// The hermes compiler command to run. By default it is 'hermesc'
|
||||||
|
// hermesCommand = "$rootDir/my-custom-hermesc/bin/hermesc"
|
||||||
|
//
|
||||||
|
// The list of flags to pass to the Hermes compiler. By default is "-O", "-output-source-map"
|
||||||
|
// hermesFlags = ["-O", "-output-source-map"]
|
||||||
|
|
||||||
|
/* Autolinking */
|
||||||
|
autolinkLibrariesWithApp()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set this to true in release builds to optimize the app using [R8](https://developer.android.com/topic/performance/app-optimization/enable-app-optimization).
|
||||||
|
*/
|
||||||
|
def enableMinifyInReleaseBuilds = (findProperty('android.enableMinifyInReleaseBuilds') ?: false).toBoolean()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The preferred build flavor of JavaScriptCore (JSC)
|
||||||
|
*
|
||||||
|
* For example, to use the international variant, you can use:
|
||||||
|
* `def jscFlavor = 'org.webkit:android-jsc-intl:+'`
|
||||||
|
*
|
||||||
|
* The international variant includes ICU i18n library and necessary data
|
||||||
|
* allowing to use e.g. `Date.toLocaleString` and `String.localeCompare` that
|
||||||
|
* give correct results when using with locales other than en-US. Note that
|
||||||
|
* this variant is about 6MiB larger per architecture than default.
|
||||||
|
*/
|
||||||
|
def jscFlavor = 'io.github.react-native-community:jsc-android:2026004.+'
|
||||||
|
|
||||||
|
android {
|
||||||
|
ndkVersion rootProject.ext.ndkVersion
|
||||||
|
|
||||||
|
buildToolsVersion rootProject.ext.buildToolsVersion
|
||||||
|
compileSdk rootProject.ext.compileSdkVersion
|
||||||
|
|
||||||
|
namespace 'com.arandano69.quibotweb'
|
||||||
|
defaultConfig {
|
||||||
|
applicationId 'com.arandano69.quibotweb'
|
||||||
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
|
versionCode 1
|
||||||
|
versionName "1.0.0"
|
||||||
|
|
||||||
|
buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\""
|
||||||
|
}
|
||||||
|
signingConfigs {
|
||||||
|
debug {
|
||||||
|
storeFile file('debug.keystore')
|
||||||
|
storePassword 'android'
|
||||||
|
keyAlias 'androiddebugkey'
|
||||||
|
keyPassword 'android'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
buildTypes {
|
||||||
|
debug {
|
||||||
|
signingConfig signingConfigs.debug
|
||||||
|
}
|
||||||
|
release {
|
||||||
|
// Caution! In production, you need to generate your own keystore file.
|
||||||
|
// see https://reactnative.dev/docs/signed-apk-android.
|
||||||
|
signingConfig signingConfigs.debug
|
||||||
|
def enableShrinkResources = findProperty('android.enableShrinkResourcesInReleaseBuilds') ?: 'false'
|
||||||
|
shrinkResources enableShrinkResources.toBoolean()
|
||||||
|
minifyEnabled enableMinifyInReleaseBuilds
|
||||||
|
proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro"
|
||||||
|
def enablePngCrunchInRelease = findProperty('android.enablePngCrunchInReleaseBuilds') ?: 'true'
|
||||||
|
crunchPngs enablePngCrunchInRelease.toBoolean()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
packagingOptions {
|
||||||
|
jniLibs {
|
||||||
|
def enableLegacyPackaging = findProperty('expo.useLegacyPackaging') ?: 'false'
|
||||||
|
useLegacyPackaging enableLegacyPackaging.toBoolean()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
androidResources {
|
||||||
|
ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:!CVS:!thumbs.db:!picasa.ini:!*~'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply static values from `gradle.properties` to the `android.packagingOptions`
|
||||||
|
// Accepts values in comma delimited lists, example:
|
||||||
|
// android.packagingOptions.pickFirsts=/LICENSE,**/picasa.ini
|
||||||
|
["pickFirsts", "excludes", "merges", "doNotStrip"].each { prop ->
|
||||||
|
// Split option: 'foo,bar' -> ['foo', 'bar']
|
||||||
|
def options = (findProperty("android.packagingOptions.$prop") ?: "").split(",");
|
||||||
|
// Trim all elements in place.
|
||||||
|
for (i in 0..<options.size()) options[i] = options[i].trim();
|
||||||
|
// `[] - ""` is essentially `[""].filter(Boolean)` removing all empty strings.
|
||||||
|
options -= ""
|
||||||
|
|
||||||
|
if (options.length > 0) {
|
||||||
|
println "android.packagingOptions.$prop += $options ($options.length)"
|
||||||
|
// Ex: android.packagingOptions.pickFirsts += '**/SCCS/**'
|
||||||
|
options.each {
|
||||||
|
android.packagingOptions[prop] += it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
// The version of react-native is set by the React Native Gradle Plugin
|
||||||
|
implementation("com.facebook.react:react-android")
|
||||||
|
|
||||||
|
def isGifEnabled = (findProperty('expo.gif.enabled') ?: "") == "true";
|
||||||
|
def isWebpEnabled = (findProperty('expo.webp.enabled') ?: "") == "true";
|
||||||
|
def isWebpAnimatedEnabled = (findProperty('expo.webp.animated') ?: "") == "true";
|
||||||
|
|
||||||
|
if (isGifEnabled) {
|
||||||
|
// For animated gif support
|
||||||
|
implementation("com.facebook.fresco:animated-gif:${expoLibs.versions.fresco.get()}")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isWebpEnabled) {
|
||||||
|
// For webp support
|
||||||
|
implementation("com.facebook.fresco:webpsupport:${expoLibs.versions.fresco.get()}")
|
||||||
|
if (isWebpAnimatedEnabled) {
|
||||||
|
// Animated webp support
|
||||||
|
implementation("com.facebook.fresco:animated-webp:${expoLibs.versions.fresco.get()}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hermesEnabled.toBoolean()) {
|
||||||
|
implementation("com.facebook.react:hermes-android")
|
||||||
|
} else {
|
||||||
|
implementation jscFlavor
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
quibot-web/android/app/debug.keystore
Normal file
14
quibot-web/android/app/proguard-rules.pro
vendored
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
# Add project specific ProGuard rules here.
|
||||||
|
# By default, the flags in this file are appended to flags specified
|
||||||
|
# in /usr/local/Cellar/android-sdk/24.3.3/tools/proguard/proguard-android.txt
|
||||||
|
# You can edit the include path and order by changing the proguardFiles
|
||||||
|
# directive in build.gradle.
|
||||||
|
#
|
||||||
|
# For more details, see
|
||||||
|
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||||
|
|
||||||
|
# react-native-reanimated
|
||||||
|
-keep class com.swmansion.reanimated.** { *; }
|
||||||
|
-keep class com.facebook.react.turbomodule.** { *; }
|
||||||
|
|
||||||
|
# Add any project specific keep options here:
|
||||||
7
quibot-web/android/app/src/debug/AndroidManifest.xml
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
|
||||||
|
|
||||||
|
<application android:usesCleartextTraffic="true" tools:targetApi="28" tools:ignore="GoogleAppIndexingWarning" tools:replace="android:usesCleartextTraffic" />
|
||||||
|
</manifest>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
|
||||||
|
|
||||||
|
<application android:usesCleartextTraffic="true" tools:targetApi="28" tools:ignore="GoogleAppIndexingWarning" tools:replace="android:usesCleartextTraffic" />
|
||||||
|
</manifest>
|
||||||
26
quibot-web/android/app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
<uses-permission android:name="android.permission.INTERNET"/>
|
||||||
|
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" tools:replace="android:maxSdkVersion"/>
|
||||||
|
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
|
||||||
|
<uses-permission android:name="android.permission.VIBRATE"/>
|
||||||
|
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="32" tools:replace="android:maxSdkVersion"/>
|
||||||
|
<queries>
|
||||||
|
<intent>
|
||||||
|
<action android:name="android.intent.action.VIEW"/>
|
||||||
|
<category android:name="android.intent.category.BROWSABLE"/>
|
||||||
|
<data android:scheme="https"/>
|
||||||
|
</intent>
|
||||||
|
</queries>
|
||||||
|
<application android:name=".MainApplication" android:label="@string/app_name" android:icon="@mipmap/ic_launcher" android:roundIcon="@mipmap/ic_launcher_round" android:allowBackup="true" android:theme="@style/AppTheme" android:supportsRtl="true" android:enableOnBackInvokedCallback="false">
|
||||||
|
<meta-data android:name="expo.modules.updates.ENABLED" android:value="false"/>
|
||||||
|
<meta-data android:name="expo.modules.updates.ENABLE_BSDIFF_PATCH_SUPPORT" android:value="true"/>
|
||||||
|
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_CHECK_ON_LAUNCH" android:value="ALWAYS"/>
|
||||||
|
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_LAUNCH_WAIT_MS" android:value="0"/>
|
||||||
|
<activity android:name=".MainActivity" android:configChanges="keyboard|keyboardHidden|orientation|screenSize|screenLayout|uiMode|smallestScreenSize" android:launchMode="singleTask" android:windowSoftInputMode="adjustResize" android:theme="@style/Theme.App.SplashScreen" android:exported="true">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN"/>
|
||||||
|
<category android:name="android.intent.category.LAUNCHER"/>
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
</application>
|
||||||
|
</manifest>
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package com.arandano69.quibotweb
|
||||||
|
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Bundle
|
||||||
|
|
||||||
|
import com.facebook.react.ReactActivity
|
||||||
|
import com.facebook.react.ReactActivityDelegate
|
||||||
|
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.fabricEnabled
|
||||||
|
import com.facebook.react.defaults.DefaultReactActivityDelegate
|
||||||
|
|
||||||
|
import expo.modules.ReactActivityDelegateWrapper
|
||||||
|
|
||||||
|
class MainActivity : ReactActivity() {
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
// Set the theme to AppTheme BEFORE onCreate to support
|
||||||
|
// coloring the background, status bar, and navigation bar.
|
||||||
|
// This is required for expo-splash-screen.
|
||||||
|
setTheme(R.style.AppTheme);
|
||||||
|
super.onCreate(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the name of the main component registered from JavaScript. This is used to schedule
|
||||||
|
* rendering of the component.
|
||||||
|
*/
|
||||||
|
override fun getMainComponentName(): String = "main"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the instance of the [ReactActivityDelegate]. We use [DefaultReactActivityDelegate]
|
||||||
|
* which allows you to enable New Architecture with a single boolean flags [fabricEnabled]
|
||||||
|
*/
|
||||||
|
override fun createReactActivityDelegate(): ReactActivityDelegate {
|
||||||
|
return ReactActivityDelegateWrapper(
|
||||||
|
this,
|
||||||
|
BuildConfig.IS_NEW_ARCHITECTURE_ENABLED,
|
||||||
|
object : DefaultReactActivityDelegate(
|
||||||
|
this,
|
||||||
|
mainComponentName,
|
||||||
|
fabricEnabled
|
||||||
|
){})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Align the back button behavior with Android S
|
||||||
|
* where moving root activities to background instead of finishing activities.
|
||||||
|
* @see <a href="https://developer.android.com/reference/android/app/Activity#onBackPressed()">onBackPressed</a>
|
||||||
|
*/
|
||||||
|
override fun invokeDefaultOnBackPressed() {
|
||||||
|
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.R) {
|
||||||
|
if (!moveTaskToBack(false)) {
|
||||||
|
// For non-root activities, use the default implementation to finish them.
|
||||||
|
super.invokeDefaultOnBackPressed()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use the default back button implementation on Android S
|
||||||
|
// because it's doing more than [Activity.moveTaskToBack] in fact.
|
||||||
|
super.invokeDefaultOnBackPressed()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package com.arandano69.quibotweb
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import android.content.res.Configuration
|
||||||
|
|
||||||
|
import com.facebook.react.PackageList
|
||||||
|
import com.facebook.react.ReactApplication
|
||||||
|
import com.facebook.react.ReactNativeApplicationEntryPoint.loadReactNative
|
||||||
|
import com.facebook.react.ReactPackage
|
||||||
|
import com.facebook.react.ReactHost
|
||||||
|
import com.facebook.react.common.ReleaseLevel
|
||||||
|
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint
|
||||||
|
|
||||||
|
import expo.modules.ApplicationLifecycleDispatcher
|
||||||
|
import expo.modules.ExpoReactHostFactory
|
||||||
|
|
||||||
|
class MainApplication : Application(), ReactApplication {
|
||||||
|
|
||||||
|
override val reactHost: ReactHost by lazy {
|
||||||
|
ExpoReactHostFactory.getDefaultReactHost(
|
||||||
|
context = applicationContext,
|
||||||
|
packageList =
|
||||||
|
PackageList(this).packages.apply {
|
||||||
|
// Packages that cannot be autolinked yet can be added manually here, for example:
|
||||||
|
// add(MyReactNativePackage())
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
DefaultNewArchitectureEntryPoint.releaseLevel = try {
|
||||||
|
ReleaseLevel.valueOf(BuildConfig.REACT_NATIVE_RELEASE_LEVEL.uppercase())
|
||||||
|
} catch (e: IllegalArgumentException) {
|
||||||
|
ReleaseLevel.STABLE
|
||||||
|
}
|
||||||
|
loadReactNative(this)
|
||||||
|
ApplicationLifecycleDispatcher.onApplicationCreate(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onConfigurationChanged(newConfig: Configuration) {
|
||||||
|
super.onConfigurationChanged(newConfig)
|
||||||
|
ApplicationLifecycleDispatcher.onConfigurationChanged(this, newConfig)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 65 KiB |
@@ -0,0 +1,6 @@
|
|||||||
|
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<item android:drawable="@color/splashscreen_background"/>
|
||||||
|
<item>
|
||||||
|
<bitmap android:gravity="center" android:src="@drawable/splashscreen_logo"/>
|
||||||
|
</item>
|
||||||
|
</layer-list>
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Copyright (C) 2014 The Android Open Source Project
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
-->
|
||||||
|
<inset xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:insetLeft="@dimen/abc_edit_text_inset_horizontal_material"
|
||||||
|
android:insetRight="@dimen/abc_edit_text_inset_horizontal_material"
|
||||||
|
android:insetTop="@dimen/abc_edit_text_inset_top_material"
|
||||||
|
android:insetBottom="@dimen/abc_edit_text_inset_bottom_material"
|
||||||
|
>
|
||||||
|
|
||||||
|
<selector>
|
||||||
|
<!--
|
||||||
|
This file is a copy of abc_edit_text_material (https://bit.ly/3k8fX7I).
|
||||||
|
The item below with state_pressed="false" and state_focused="false" causes a NullPointerException.
|
||||||
|
NullPointerException:tempt to invoke virtual method 'android.graphics.drawable.Drawable android.graphics.drawable.Drawable$ConstantState.newDrawable(android.content.res.Resources)'
|
||||||
|
|
||||||
|
<item android:state_pressed="false" android:state_focused="false" android:drawable="@drawable/abc_textfield_default_mtrl_alpha"/>
|
||||||
|
|
||||||
|
For more info, see https://bit.ly/3CdLStv (react-native/pull/29452) and https://bit.ly/3nxOMoR.
|
||||||
|
-->
|
||||||
|
<item android:state_enabled="false" android:drawable="@drawable/abc_textfield_default_mtrl_alpha"/>
|
||||||
|
<item android:drawable="@drawable/abc_textfield_activated_mtrl_alpha"/>
|
||||||
|
</selector>
|
||||||
|
|
||||||
|
</inset>
|
||||||
BIN
quibot-web/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
BIN
quibot-web/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 6.9 KiB |
|
After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 9.0 KiB |
|
After Width: | Height: | Size: 15 KiB |
4
quibot-web/android/app/src/main/res/values/colors.xml
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<resources>
|
||||||
|
<color name="splashscreen_background">#FFFFFF</color>
|
||||||
|
<color name="colorPrimary">#023c69</color>
|
||||||
|
</resources>
|
||||||
3
quibot-web/android/app/src/main/res/values/strings.xml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<resources>
|
||||||
|
<string name="app_name">quibot-web</string>
|
||||||
|
</resources>
|
||||||
11
quibot-web/android/app/src/main/res/values/styles.xml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
<style name="AppTheme" parent="Theme.AppCompat.DayNight.NoActionBar">
|
||||||
|
<item name="android:editTextBackground">@drawable/rn_edit_text_material</item>
|
||||||
|
<item name="colorPrimary">@color/colorPrimary</item>
|
||||||
|
<item name="android:statusBarColor">@android:color/transparent</item>
|
||||||
|
<item name="android:navigationBarColor">@android:color/transparent</item>
|
||||||
|
</style>
|
||||||
|
<style name="Theme.App.SplashScreen" parent="AppTheme">
|
||||||
|
<item name="android:windowBackground">@drawable/splashscreen_logo</item>
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
24
quibot-web/android/build.gradle
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||||
|
|
||||||
|
buildscript {
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
dependencies {
|
||||||
|
classpath('com.android.tools.build:gradle')
|
||||||
|
classpath('com.facebook.react:react-native-gradle-plugin')
|
||||||
|
classpath('org.jetbrains.kotlin:kotlin-gradle-plugin')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
allprojects {
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
maven { url 'https://www.jitpack.io' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
apply plugin: "expo-root-project"
|
||||||
|
apply plugin: "com.facebook.react.rootproject"
|
||||||
63
quibot-web/android/gradle.properties
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
# Project-wide Gradle settings.
|
||||||
|
|
||||||
|
# IDE (e.g. Android Studio) users:
|
||||||
|
# Gradle settings configured through the IDE *will override*
|
||||||
|
# any settings specified in this file.
|
||||||
|
|
||||||
|
# For more details on how to configure your build environment visit
|
||||||
|
# http://www.gradle.org/docs/current/userguide/build_environment.html
|
||||||
|
|
||||||
|
# Specifies the JVM arguments used for the daemon process.
|
||||||
|
# The setting is particularly useful for tweaking memory settings.
|
||||||
|
# Default value: -Xmx512m -XX:MaxMetaspaceSize=256m
|
||||||
|
org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m
|
||||||
|
|
||||||
|
# When configured, Gradle will run in incubating parallel mode.
|
||||||
|
# This option should only be used with decoupled projects. More details, visit
|
||||||
|
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
|
||||||
|
org.gradle.parallel=true
|
||||||
|
|
||||||
|
# AndroidX package structure to make it clearer which packages are bundled with the
|
||||||
|
# Android operating system, and which are packaged with your app's APK
|
||||||
|
# https://developer.android.com/topic/libraries/support-library/androidx-rn
|
||||||
|
android.useAndroidX=true
|
||||||
|
|
||||||
|
# Enable AAPT2 PNG crunching
|
||||||
|
android.enablePngCrunchInReleaseBuilds=true
|
||||||
|
|
||||||
|
# Use this property to specify which architecture you want to build.
|
||||||
|
# You can also override it from the CLI using
|
||||||
|
# ./gradlew <task> -PreactNativeArchitectures=x86_64
|
||||||
|
reactNativeArchitectures=armeabi-v7a,arm64-v8a,x86,x86_64
|
||||||
|
|
||||||
|
# Use this property to enable support to the new architecture.
|
||||||
|
# This will allow you to use TurboModules and the Fabric render in
|
||||||
|
# your application. You should enable this flag either if you want
|
||||||
|
# to write custom TurboModules/Fabric components OR use libraries that
|
||||||
|
# are providing them.
|
||||||
|
newArchEnabled=true
|
||||||
|
|
||||||
|
# Use this property to enable or disable the Hermes JS engine.
|
||||||
|
# If set to false, you will be using JSC instead.
|
||||||
|
hermesEnabled=true
|
||||||
|
|
||||||
|
# Use this property to enable edge-to-edge display support.
|
||||||
|
# This allows your app to draw behind system bars for an immersive UI.
|
||||||
|
# Note: Only works with ReactActivity and should not be used with custom Activity.
|
||||||
|
edgeToEdgeEnabled=true
|
||||||
|
|
||||||
|
# Enable GIF support in React Native images (~200 B increase)
|
||||||
|
expo.gif.enabled=true
|
||||||
|
# Enable webp support in React Native images (~85 KB increase)
|
||||||
|
expo.webp.enabled=true
|
||||||
|
# Enable animated webp support (~3.4 MB increase)
|
||||||
|
# Disabled by default because iOS doesn't support animated webp
|
||||||
|
expo.webp.animated=false
|
||||||
|
|
||||||
|
# Enable network inspector
|
||||||
|
EX_DEV_CLIENT_NETWORK_INSPECTOR=true
|
||||||
|
|
||||||
|
# Use legacy packaging to compress native libraries in the resulting APK.
|
||||||
|
expo.useLegacyPackaging=false
|
||||||
|
|
||||||
|
expo.inlineModules.watchedDirectories=[]
|
||||||
BIN
quibot-web/android/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
7
quibot-web/android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
distributionBase=GRADLE_USER_HOME
|
||||||
|
distributionPath=wrapper/dists
|
||||||
|
distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip
|
||||||
|
networkTimeout=10000
|
||||||
|
validateDistributionUrl=true
|
||||||
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
zipStorePath=wrapper/dists
|
||||||
248
quibot-web/android/gradlew
vendored
Executable file
@@ -0,0 +1,248 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
#
|
||||||
|
# Copyright © 2015 the original authors.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: Apache-2.0
|
||||||
|
#
|
||||||
|
|
||||||
|
##############################################################################
|
||||||
|
#
|
||||||
|
# Gradle start up script for POSIX generated by Gradle.
|
||||||
|
#
|
||||||
|
# Important for running:
|
||||||
|
#
|
||||||
|
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||||
|
# noncompliant, but you have some other compliant shell such as ksh or
|
||||||
|
# bash, then to run this script, type that shell name before the whole
|
||||||
|
# command line, like:
|
||||||
|
#
|
||||||
|
# ksh Gradle
|
||||||
|
#
|
||||||
|
# Busybox and similar reduced shells will NOT work, because this script
|
||||||
|
# requires all of these POSIX shell features:
|
||||||
|
# * functions;
|
||||||
|
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||||
|
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||||
|
# * compound commands having a testable exit status, especially «case»;
|
||||||
|
# * various built-in commands including «command», «set», and «ulimit».
|
||||||
|
#
|
||||||
|
# Important for patching:
|
||||||
|
#
|
||||||
|
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||||
|
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||||
|
#
|
||||||
|
# The "traditional" practice of packing multiple parameters into a
|
||||||
|
# space-separated string is a well documented source of bugs and security
|
||||||
|
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||||
|
# options in "$@", and eventually passing that to Java.
|
||||||
|
#
|
||||||
|
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||||
|
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||||
|
# see the in-line comments for details.
|
||||||
|
#
|
||||||
|
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||||
|
# Darwin, MinGW, and NonStop.
|
||||||
|
#
|
||||||
|
# (3) This script is generated from the Groovy template
|
||||||
|
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||||
|
# within the Gradle project.
|
||||||
|
#
|
||||||
|
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||||
|
#
|
||||||
|
##############################################################################
|
||||||
|
|
||||||
|
# Attempt to set APP_HOME
|
||||||
|
|
||||||
|
# Resolve links: $0 may be a link
|
||||||
|
app_path=$0
|
||||||
|
|
||||||
|
# Need this for daisy-chained symlinks.
|
||||||
|
while
|
||||||
|
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||||
|
[ -h "$app_path" ]
|
||||||
|
do
|
||||||
|
ls=$( ls -ld "$app_path" )
|
||||||
|
link=${ls#*' -> '}
|
||||||
|
case $link in #(
|
||||||
|
/*) app_path=$link ;; #(
|
||||||
|
*) app_path=$APP_HOME$link ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# This is normally unused
|
||||||
|
# shellcheck disable=SC2034
|
||||||
|
APP_BASE_NAME=${0##*/}
|
||||||
|
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||||
|
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
|
||||||
|
|
||||||
|
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||||
|
MAX_FD=maximum
|
||||||
|
|
||||||
|
warn () {
|
||||||
|
echo "$*"
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
die () {
|
||||||
|
echo
|
||||||
|
echo "$*"
|
||||||
|
echo
|
||||||
|
exit 1
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
# OS specific support (must be 'true' or 'false').
|
||||||
|
cygwin=false
|
||||||
|
msys=false
|
||||||
|
darwin=false
|
||||||
|
nonstop=false
|
||||||
|
case "$( uname )" in #(
|
||||||
|
CYGWIN* ) cygwin=true ;; #(
|
||||||
|
Darwin* ) darwin=true ;; #(
|
||||||
|
MSYS* | MINGW* ) msys=true ;; #(
|
||||||
|
NONSTOP* ) nonstop=true ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# Determine the Java command to use to start the JVM.
|
||||||
|
if [ -n "$JAVA_HOME" ] ; then
|
||||||
|
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||||
|
# IBM's JDK on AIX uses strange locations for the executables
|
||||||
|
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||||
|
else
|
||||||
|
JAVACMD=$JAVA_HOME/bin/java
|
||||||
|
fi
|
||||||
|
if [ ! -x "$JAVACMD" ] ; then
|
||||||
|
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
JAVACMD=java
|
||||||
|
if ! command -v java >/dev/null 2>&1
|
||||||
|
then
|
||||||
|
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Increase the maximum file descriptors if we can.
|
||||||
|
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||||
|
case $MAX_FD in #(
|
||||||
|
max*)
|
||||||
|
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||||
|
# shellcheck disable=SC2039,SC3045
|
||||||
|
MAX_FD=$( ulimit -H -n ) ||
|
||||||
|
warn "Could not query maximum file descriptor limit"
|
||||||
|
esac
|
||||||
|
case $MAX_FD in #(
|
||||||
|
'' | soft) :;; #(
|
||||||
|
*)
|
||||||
|
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||||
|
# shellcheck disable=SC2039,SC3045
|
||||||
|
ulimit -n "$MAX_FD" ||
|
||||||
|
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Collect all arguments for the java command, stacking in reverse order:
|
||||||
|
# * args from the command line
|
||||||
|
# * the main class name
|
||||||
|
# * -classpath
|
||||||
|
# * -D...appname settings
|
||||||
|
# * --module-path (only if needed)
|
||||||
|
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||||
|
|
||||||
|
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||||
|
if "$cygwin" || "$msys" ; then
|
||||||
|
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||||
|
|
||||||
|
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||||
|
|
||||||
|
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||||
|
for arg do
|
||||||
|
if
|
||||||
|
case $arg in #(
|
||||||
|
-*) false ;; # don't mess with options #(
|
||||||
|
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||||
|
[ -e "$t" ] ;; #(
|
||||||
|
*) false ;;
|
||||||
|
esac
|
||||||
|
then
|
||||||
|
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||||
|
fi
|
||||||
|
# Roll the args list around exactly as many times as the number of
|
||||||
|
# args, so each arg winds up back in the position where it started, but
|
||||||
|
# possibly modified.
|
||||||
|
#
|
||||||
|
# NB: a `for` loop captures its iteration list before it begins, so
|
||||||
|
# changing the positional parameters here affects neither the number of
|
||||||
|
# iterations, nor the values presented in `arg`.
|
||||||
|
shift # remove old arg
|
||||||
|
set -- "$@" "$arg" # push replacement arg
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
|
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||||
|
|
||||||
|
# Collect all arguments for the java command:
|
||||||
|
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||||
|
# and any embedded shellness will be escaped.
|
||||||
|
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||||
|
# treated as '${Hostname}' itself on the command line.
|
||||||
|
|
||||||
|
set -- \
|
||||||
|
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||||
|
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||||
|
"$@"
|
||||||
|
|
||||||
|
# Stop when "xargs" is not available.
|
||||||
|
if ! command -v xargs >/dev/null 2>&1
|
||||||
|
then
|
||||||
|
die "xargs is not available"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Use "xargs" to parse quoted args.
|
||||||
|
#
|
||||||
|
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||||
|
#
|
||||||
|
# In Bash we could simply go:
|
||||||
|
#
|
||||||
|
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||||
|
# set -- "${ARGS[@]}" "$@"
|
||||||
|
#
|
||||||
|
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||||
|
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||||
|
# character that might be a shell metacharacter, then use eval to reverse
|
||||||
|
# that process (while maintaining the separation between arguments), and wrap
|
||||||
|
# the whole thing up as a single "set" statement.
|
||||||
|
#
|
||||||
|
# This will of course break if any of these variables contains a newline or
|
||||||
|
# an unmatched quote.
|
||||||
|
#
|
||||||
|
|
||||||
|
eval "set -- $(
|
||||||
|
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||||
|
xargs -n1 |
|
||||||
|
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||||
|
tr '\n' ' '
|
||||||
|
)" '"$@"'
|
||||||
|
|
||||||
|
exec "$JAVACMD" "$@"
|
||||||
98
quibot-web/android/gradlew.bat
vendored
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
@REM Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||||
|
@REM
|
||||||
|
@REM This source code is licensed under the MIT license found in the
|
||||||
|
@REM LICENSE file in the root directory of this source tree.
|
||||||
|
|
||||||
|
@rem
|
||||||
|
@rem Copyright 2015 the original author or authors.
|
||||||
|
@rem
|
||||||
|
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
@rem you may not use this file except in compliance with the License.
|
||||||
|
@rem You may obtain a copy of the License at
|
||||||
|
@rem
|
||||||
|
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
@rem
|
||||||
|
@rem Unless required by applicable law or agreed to in writing, software
|
||||||
|
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
@rem See the License for the specific language governing permissions and
|
||||||
|
@rem limitations under the License.
|
||||||
|
@rem
|
||||||
|
@rem SPDX-License-Identifier: Apache-2.0
|
||||||
|
@rem
|
||||||
|
|
||||||
|
@if "%DEBUG%"=="" @echo off
|
||||||
|
@rem ##########################################################################
|
||||||
|
@rem
|
||||||
|
@rem Gradle startup script for Windows
|
||||||
|
@rem
|
||||||
|
@rem ##########################################################################
|
||||||
|
|
||||||
|
@rem Set local scope for the variables with windows NT shell
|
||||||
|
if "%OS%"=="Windows_NT" setlocal
|
||||||
|
|
||||||
|
set DIRNAME=%~dp0
|
||||||
|
if "%DIRNAME%"=="" set DIRNAME=.
|
||||||
|
@rem This is normally unused
|
||||||
|
set APP_BASE_NAME=%~n0
|
||||||
|
set APP_HOME=%DIRNAME%
|
||||||
|
|
||||||
|
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||||
|
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||||
|
|
||||||
|
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||||
|
|
||||||
|
@rem Find java.exe
|
||||||
|
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||||
|
|
||||||
|
set JAVA_EXE=java.exe
|
||||||
|
%JAVA_EXE% -version >NUL 2>&1
|
||||||
|
if %ERRORLEVEL% equ 0 goto execute
|
||||||
|
|
||||||
|
echo. 1>&2
|
||||||
|
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||||
|
echo. 1>&2
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||||
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:findJavaFromJavaHome
|
||||||
|
set JAVA_HOME=%JAVA_HOME:"=%
|
||||||
|
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||||
|
|
||||||
|
if exist "%JAVA_EXE%" goto execute
|
||||||
|
|
||||||
|
echo. 1>&2
|
||||||
|
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||||
|
echo. 1>&2
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||||
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:execute
|
||||||
|
@rem Setup the command line
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@rem Execute Gradle
|
||||||
|
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
|
||||||
|
|
||||||
|
:end
|
||||||
|
@rem End local scope for the variables with windows NT shell
|
||||||
|
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||||
|
|
||||||
|
:fail
|
||||||
|
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||||
|
rem the _cmd.exe /c_ return code!
|
||||||
|
set EXIT_CODE=%ERRORLEVEL%
|
||||||
|
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||||
|
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||||
|
exit /b %EXIT_CODE%
|
||||||
|
|
||||||
|
:mainEnd
|
||||||
|
if "%OS%"=="Windows_NT" endlocal
|
||||||
|
|
||||||
|
:omega
|
||||||
39
quibot-web/android/settings.gradle
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
pluginManagement {
|
||||||
|
def reactNativeGradlePlugin = new File(
|
||||||
|
providers.exec {
|
||||||
|
workingDir(rootDir)
|
||||||
|
commandLine("node", "--print", "require.resolve('@react-native/gradle-plugin/package.json', { paths: [require.resolve('react-native/package.json')] })")
|
||||||
|
}.standardOutput.asText.get().trim()
|
||||||
|
).getParentFile().absolutePath
|
||||||
|
includeBuild(reactNativeGradlePlugin)
|
||||||
|
|
||||||
|
def expoPluginsPath = new File(
|
||||||
|
providers.exec {
|
||||||
|
workingDir(rootDir)
|
||||||
|
commandLine("node", "--print", "require.resolve('expo-modules-autolinking/package.json', { paths: [require.resolve('expo/package.json')] })")
|
||||||
|
}.standardOutput.asText.get().trim(),
|
||||||
|
"../android/expo-gradle-plugin"
|
||||||
|
).absolutePath
|
||||||
|
includeBuild(expoPluginsPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
plugins {
|
||||||
|
id("com.facebook.react.settings")
|
||||||
|
id("expo-autolinking-settings")
|
||||||
|
}
|
||||||
|
|
||||||
|
extensions.configure(com.facebook.react.ReactSettingsExtension) { ex ->
|
||||||
|
if (System.getenv('EXPO_USE_COMMUNITY_AUTOLINKING') == '1') {
|
||||||
|
ex.autolinkLibrariesFromCommand()
|
||||||
|
} else {
|
||||||
|
ex.autolinkLibrariesFromCommand(expoAutolinking.rnConfigCommand)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expoAutolinking.useExpoModules()
|
||||||
|
|
||||||
|
rootProject.name = 'quibot-web'
|
||||||
|
|
||||||
|
expoAutolinking.useExpoVersionCatalog()
|
||||||
|
|
||||||
|
include ':app'
|
||||||
|
includeBuild(expoAutolinking.reactNativeGradlePlugin)
|
||||||
5
quibot-web/app.json
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"android": {
|
||||||
|
"package": "com.arandano69.quibotweb"
|
||||||
|
}
|
||||||
|
}
|
||||||
149
quibot-web/app/locales/ca.ts
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
export default {
|
||||||
|
header: {
|
||||||
|
subtitle: 'Monitor de Control del Robot',
|
||||||
|
online: 'EN LÍNIA',
|
||||||
|
offline: 'DESCONNECTAT',
|
||||||
|
},
|
||||||
|
panels: {
|
||||||
|
blockQueue: 'Cua de Blocs',
|
||||||
|
motionControls: 'Controls de Moviment',
|
||||||
|
eyeControls: 'Controls dels Ulls',
|
||||||
|
gestureSensor: 'Sensor de Gestos',
|
||||||
|
},
|
||||||
|
blocks: {
|
||||||
|
empty: 'No hi ha blocs a la cua',
|
||||||
|
clear: 'Buidar Cua',
|
||||||
|
queued: 'A l\'espera',
|
||||||
|
processing: 'Procesant',
|
||||||
|
},
|
||||||
|
blockActions: {
|
||||||
|
rd: 'Avançar fins al creuament',
|
||||||
|
gn: 'Girar a la dreta 90\u00B0',
|
||||||
|
bu: 'Girar a l\'esquerra 90\u00B0',
|
||||||
|
ye: 'Agafar / Extrair líquid',
|
||||||
|
og: 'Deixar / Dispensar líquid',
|
||||||
|
vt: 'Pausa sorpresa',
|
||||||
|
},
|
||||||
|
colors: {
|
||||||
|
red: 'Vermell',
|
||||||
|
green: 'Verd',
|
||||||
|
blue: 'Blau',
|
||||||
|
yellow: 'Groc',
|
||||||
|
orange: 'Taronja',
|
||||||
|
violet: 'Violeta',
|
||||||
|
},
|
||||||
|
motion: {
|
||||||
|
forward: 'Endavant',
|
||||||
|
backward: 'Enrere',
|
||||||
|
left: 'Esquerra',
|
||||||
|
right: 'Dreta',
|
||||||
|
sending: 'Enviant: {dir}',
|
||||||
|
sent: 'Comanda de motor enviada: {dir}',
|
||||||
|
failed: 'La comanda ha fallat',
|
||||||
|
},
|
||||||
|
motionToast: {
|
||||||
|
success: 'Moviment {dir}',
|
||||||
|
},
|
||||||
|
eyes: {
|
||||||
|
shapeLabel: 'Color dels Ulls',
|
||||||
|
actionsLabel: 'Accions',
|
||||||
|
applyShape: 'Aplicar Forma',
|
||||||
|
applyColor: 'Aplicar Color',
|
||||||
|
eyeOn: 'Encendre',
|
||||||
|
eyeOff: 'Apagar',
|
||||||
|
toastSetShape: 'Forma dels ulls establerta: {shape}',
|
||||||
|
toastFailedShape: 'No s\'ha pogut establir la forma dels ulls',
|
||||||
|
toastSetColor: 'Color dels ulls establert: {color}',
|
||||||
|
toastFailedColor: 'No s\'ha pogut establir el color dels ulls',
|
||||||
|
toastOn: 'Ulls encesos',
|
||||||
|
toastOff: 'Ulls apagats',
|
||||||
|
toastFailedOn: 'No s\'han pogut encendre els ulls',
|
||||||
|
toastFailedOff: 'No s\'han pogut apagar els ulls',
|
||||||
|
},
|
||||||
|
eyeShapes: {
|
||||||
|
open: 'Oberts',
|
||||||
|
fw: 'Endavant',
|
||||||
|
down: 'Baix',
|
||||||
|
gesture: 'Gest',
|
||||||
|
},
|
||||||
|
eyeColors: {
|
||||||
|
white: 'Blanc',
|
||||||
|
red: 'Vermell',
|
||||||
|
green: 'Verd',
|
||||||
|
blue: 'Blau',
|
||||||
|
yellow: 'Groc',
|
||||||
|
orange: 'Taronja',
|
||||||
|
purple: 'Lila',
|
||||||
|
cyan: 'Cian',
|
||||||
|
black: 'Apagat',
|
||||||
|
},
|
||||||
|
gestures: {
|
||||||
|
modeLabel: 'Mode de Funcionament',
|
||||||
|
blockMode: 'Mode Blocs',
|
||||||
|
gestureMode: 'Mode Gestos',
|
||||||
|
detectedLabel: 'Gestos Detectats',
|
||||||
|
empty: 'Encara no s\'han detectat gestos',
|
||||||
|
reference: 'Referència de Gestos',
|
||||||
|
clearLog: 'Buidar Registre',
|
||||||
|
toggleToast: 'Canviar mode',
|
||||||
|
},
|
||||||
|
gestureNames: {
|
||||||
|
forward: 'Empènyer Endavant',
|
||||||
|
left: 'Ona Esquerra',
|
||||||
|
right: 'Ona Dreta',
|
||||||
|
up: 'Ona Amunt',
|
||||||
|
down: 'Ona Avall',
|
||||||
|
clockwise: 'Cercle CW',
|
||||||
|
anticlockwise: 'Cercle CCW',
|
||||||
|
wave: 'Ona (Commute)',
|
||||||
|
},
|
||||||
|
gestureActions: {
|
||||||
|
forward: 'Avançar creuament',
|
||||||
|
right: 'Girar a la dreta 90\u00B0',
|
||||||
|
left: 'Girar a l\'esquerra 90\u00B0',
|
||||||
|
up: 'Agafar / Extreure',
|
||||||
|
down: 'Deixar / Dispensar',
|
||||||
|
clockwise: 'Inactiu',
|
||||||
|
anticlockwise: 'Inactiu',
|
||||||
|
wave: 'Canviar mode',
|
||||||
|
},
|
||||||
|
gestureRef: {
|
||||||
|
forward: 'Empènyer Endavant',
|
||||||
|
left: 'Ona Esquerra',
|
||||||
|
right: 'Ona Dreta',
|
||||||
|
up: 'Ona Amunt',
|
||||||
|
down: 'Ona Avall',
|
||||||
|
clockwise: 'Cercle CW',
|
||||||
|
anticlockwise: 'Cercle CCW',
|
||||||
|
wave: 'Ona (Ambdues)',
|
||||||
|
},
|
||||||
|
modes: {
|
||||||
|
block: 'Mode Blocs',
|
||||||
|
gesture: 'Mode Gestos',
|
||||||
|
},
|
||||||
|
toast: {
|
||||||
|
cleared: 'Cua buidada',
|
||||||
|
switched: 'Canviat a mode {mode}',
|
||||||
|
},
|
||||||
|
theme: {
|
||||||
|
light: 'Canviar a mode clar',
|
||||||
|
dark: 'Canviar a mode fosc',
|
||||||
|
},
|
||||||
|
settings: {
|
||||||
|
title: 'Configuració',
|
||||||
|
save: 'Desar',
|
||||||
|
saved: 'Configuració desada',
|
||||||
|
theme: {
|
||||||
|
label: 'Tema',
|
||||||
|
dark: 'Fosc',
|
||||||
|
light: 'Clar',
|
||||||
|
},
|
||||||
|
language: {
|
||||||
|
label: 'Idioma',
|
||||||
|
},
|
||||||
|
piUrl: {
|
||||||
|
label: 'URL del Raspberry Pi',
|
||||||
|
placeholder: 'http://raspberrypi.local:8000',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
149
quibot-web/app/locales/en.ts
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
export default {
|
||||||
|
header: {
|
||||||
|
subtitle: 'Robot Control Monitor',
|
||||||
|
online: 'ONLINE',
|
||||||
|
offline: 'OFFLINE',
|
||||||
|
},
|
||||||
|
panels: {
|
||||||
|
blockQueue: 'Block Queue',
|
||||||
|
motionControls: 'Motion Controls',
|
||||||
|
eyeControls: 'Eye Controls',
|
||||||
|
gestureSensor: 'Gesture Sensor',
|
||||||
|
},
|
||||||
|
blocks: {
|
||||||
|
empty: 'No blocks in queue',
|
||||||
|
clear: 'Clear Queue',
|
||||||
|
queued: 'Queued',
|
||||||
|
processing: 'Processing',
|
||||||
|
},
|
||||||
|
blockActions: {
|
||||||
|
rd: 'Advance to crossing',
|
||||||
|
gn: 'Turn right 90\u00B0',
|
||||||
|
bu: 'Turn left 90\u00B0',
|
||||||
|
ye: 'Take / Extract liquid',
|
||||||
|
og: 'Leave / Dispense liquid',
|
||||||
|
vt: 'Surprise pause',
|
||||||
|
},
|
||||||
|
colors: {
|
||||||
|
red: 'Red',
|
||||||
|
green: 'Green',
|
||||||
|
blue: 'Blue',
|
||||||
|
yellow: 'Yellow',
|
||||||
|
orange: 'Orange',
|
||||||
|
violet: 'Violet',
|
||||||
|
},
|
||||||
|
motion: {
|
||||||
|
forward: 'Forward',
|
||||||
|
backward: 'Back',
|
||||||
|
left: 'Left',
|
||||||
|
right: 'Right',
|
||||||
|
sending: 'Sending: {dir}',
|
||||||
|
sent: 'Motor command sent: {dir}',
|
||||||
|
failed: 'Command failed',
|
||||||
|
},
|
||||||
|
motionToast: {
|
||||||
|
success: 'Motion {dir}',
|
||||||
|
},
|
||||||
|
eyes: {
|
||||||
|
shapeLabel: 'Eye Color',
|
||||||
|
actionsLabel: 'Actions',
|
||||||
|
applyShape: 'Apply Shape',
|
||||||
|
applyColor: 'Apply Color',
|
||||||
|
eyeOn: 'Turn On',
|
||||||
|
eyeOff: 'Turn Off',
|
||||||
|
toastSetShape: 'Eye shape set: {shape}',
|
||||||
|
toastFailedShape: 'Failed to set eye shape',
|
||||||
|
toastSetColor: 'Eye color set: {color}',
|
||||||
|
toastFailedColor: 'Failed to set eye color',
|
||||||
|
toastOn: 'Eyes turned on',
|
||||||
|
toastOff: 'Eyes turned off',
|
||||||
|
toastFailedOn: 'Failed to turn eyes on',
|
||||||
|
toastFailedOff: 'Failed to turn eyes off',
|
||||||
|
},
|
||||||
|
eyeShapes: {
|
||||||
|
open: 'Open',
|
||||||
|
fw: 'Forward',
|
||||||
|
down: 'Down',
|
||||||
|
gesture: 'Gesture',
|
||||||
|
},
|
||||||
|
eyeColors: {
|
||||||
|
white: 'White',
|
||||||
|
red: 'Red',
|
||||||
|
green: 'Green',
|
||||||
|
blue: 'Blue',
|
||||||
|
yellow: 'Yellow',
|
||||||
|
orange: 'Orange',
|
||||||
|
purple: 'Purple',
|
||||||
|
cyan: 'Cyan',
|
||||||
|
black: 'Off',
|
||||||
|
},
|
||||||
|
gestures: {
|
||||||
|
modeLabel: 'Operating Mode',
|
||||||
|
blockMode: 'Block Mode',
|
||||||
|
gestureMode: 'Gesture Mode',
|
||||||
|
detectedLabel: 'Detected Gestures',
|
||||||
|
empty: 'No gestures detected yet',
|
||||||
|
reference: 'Gesture Reference',
|
||||||
|
clearLog: 'Clear Log',
|
||||||
|
toggleToast: 'Toggle mode',
|
||||||
|
},
|
||||||
|
gestureNames: {
|
||||||
|
forward: 'Push Forward',
|
||||||
|
left: 'Wave Left',
|
||||||
|
right: 'Wave Right',
|
||||||
|
up: 'Wave Up',
|
||||||
|
down: 'Wave Down',
|
||||||
|
clockwise: 'Circle CW',
|
||||||
|
anticlockwise: 'Circle CCW',
|
||||||
|
wave: 'Wave (Toggle)',
|
||||||
|
},
|
||||||
|
gestureActions: {
|
||||||
|
forward: 'Advance crossing',
|
||||||
|
right: 'Turn right 90\u00B0',
|
||||||
|
left: 'Turn left 90\u00B0',
|
||||||
|
up: 'Take / Extract',
|
||||||
|
down: 'Leave / Dispense',
|
||||||
|
clockwise: 'Idle',
|
||||||
|
anticlockwise: 'Idle',
|
||||||
|
wave: 'Toggle mode',
|
||||||
|
},
|
||||||
|
gestureRef: {
|
||||||
|
forward: 'Push Forward',
|
||||||
|
left: 'Wave Left',
|
||||||
|
right: 'Wave Right',
|
||||||
|
up: 'Wave Up',
|
||||||
|
down: 'Wave Down',
|
||||||
|
clockwise: 'Circle CW',
|
||||||
|
anticlockwise: 'Circle CCW',
|
||||||
|
wave: 'Wave (Both)',
|
||||||
|
},
|
||||||
|
modes: {
|
||||||
|
block: 'Block Mode',
|
||||||
|
gesture: 'Gesture Mode',
|
||||||
|
},
|
||||||
|
toast: {
|
||||||
|
cleared: 'Queue cleared',
|
||||||
|
switched: 'Switched to {mode} mode',
|
||||||
|
},
|
||||||
|
theme: {
|
||||||
|
light: 'Switch to light mode',
|
||||||
|
dark: 'Switch to dark mode',
|
||||||
|
},
|
||||||
|
settings: {
|
||||||
|
title: 'Settings',
|
||||||
|
save: 'Save',
|
||||||
|
saved: 'Settings saved',
|
||||||
|
theme: {
|
||||||
|
label: 'Theme',
|
||||||
|
dark: 'Dark',
|
||||||
|
light: 'Light',
|
||||||
|
},
|
||||||
|
language: {
|
||||||
|
label: 'Language',
|
||||||
|
},
|
||||||
|
piUrl: {
|
||||||
|
label: 'Raspberry Pi URL',
|
||||||
|
placeholder: 'http://raspberrypi.local:8000',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
149
quibot-web/app/locales/es.ts
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
export default {
|
||||||
|
header: {
|
||||||
|
subtitle: 'Monitor de Control del Robot',
|
||||||
|
online: 'EN LÍNEA',
|
||||||
|
offline: 'DESCONNECTADO',
|
||||||
|
},
|
||||||
|
panels: {
|
||||||
|
blockQueue: 'Cola de Bloques',
|
||||||
|
motionControls: 'Controles de Movimiento',
|
||||||
|
eyeControls: 'Controles de los Ojos',
|
||||||
|
gestureSensor: 'Sensor de Gestos',
|
||||||
|
},
|
||||||
|
blocks: {
|
||||||
|
empty: 'No hay bloques en la cola',
|
||||||
|
clear: 'Borrar Cola',
|
||||||
|
queued: 'En espera',
|
||||||
|
processing: 'Procesando',
|
||||||
|
},
|
||||||
|
blockActions: {
|
||||||
|
rd: 'Avanzar hasta el cruce',
|
||||||
|
gn: 'Girar a la derecha 90\u00B0',
|
||||||
|
bu: 'Girar a la izquierda 90\u00B0',
|
||||||
|
ye: 'Tomar / Extraer líquido',
|
||||||
|
og: 'Dejar / Dispensar líquido',
|
||||||
|
vt: 'Pausa sorpresa',
|
||||||
|
},
|
||||||
|
colors: {
|
||||||
|
red: 'Rojo',
|
||||||
|
green: 'Verde',
|
||||||
|
blue: 'Azul',
|
||||||
|
yellow: 'Amarillo',
|
||||||
|
orange: 'Naranja',
|
||||||
|
violet: 'Violeta',
|
||||||
|
},
|
||||||
|
motion: {
|
||||||
|
forward: 'Adelante',
|
||||||
|
backward: 'Atrás',
|
||||||
|
left: 'Izquierda',
|
||||||
|
right: 'Derecha',
|
||||||
|
sending: 'Enviando: {dir}',
|
||||||
|
sent: 'Comando de motor enviado: {dir}',
|
||||||
|
failed: 'El comando falló',
|
||||||
|
},
|
||||||
|
motionToast: {
|
||||||
|
success: 'Movimiento {dir}',
|
||||||
|
},
|
||||||
|
eyes: {
|
||||||
|
shapeLabel: 'Color de los Ojos',
|
||||||
|
actionsLabel: 'Acciones',
|
||||||
|
applyShape: 'Aplicar Forma',
|
||||||
|
applyColor: 'Aplicar Color',
|
||||||
|
eyeOn: 'Encender',
|
||||||
|
eyeOff: 'Apagar',
|
||||||
|
toastSetShape: 'Forma de ojos establecida: {shape}',
|
||||||
|
toastFailedShape: 'No se pudo establecer la forma de los ojos',
|
||||||
|
toastSetColor: 'Color de ojos establecido: {color}',
|
||||||
|
toastFailedColor: 'No se pudo establecer el color de los ojos',
|
||||||
|
toastOn: 'Ojos encendidos',
|
||||||
|
toastOff: 'Ojos apagados',
|
||||||
|
toastFailedOn: 'No se pudieron encender los ojos',
|
||||||
|
toastFailedOff: 'No se pudieron apagar los ojos',
|
||||||
|
},
|
||||||
|
eyeShapes: {
|
||||||
|
open: 'Abiertos',
|
||||||
|
fw: 'Adelante',
|
||||||
|
down: 'Abajo',
|
||||||
|
gesture: 'Gesto',
|
||||||
|
},
|
||||||
|
eyeColors: {
|
||||||
|
white: 'Blanco',
|
||||||
|
red: 'Rojo',
|
||||||
|
green: 'Verde',
|
||||||
|
blue: 'Azul',
|
||||||
|
yellow: 'Amarillo',
|
||||||
|
orange: 'Naranja',
|
||||||
|
purple: 'Morado',
|
||||||
|
cyan: 'Cian',
|
||||||
|
black: 'Apagado',
|
||||||
|
},
|
||||||
|
gestures: {
|
||||||
|
modeLabel: 'Modo de Funcionamiento',
|
||||||
|
blockMode: 'Modo Bloques',
|
||||||
|
gestureMode: 'Modo Gestos',
|
||||||
|
detectedLabel: 'Gestos Detectados',
|
||||||
|
empty: 'Aún no se han detectado gestos',
|
||||||
|
reference: 'Referencia de Gestos',
|
||||||
|
clearLog: 'Borrar Registro',
|
||||||
|
toggleToast: 'Cambiar modo',
|
||||||
|
},
|
||||||
|
gestureNames: {
|
||||||
|
forward: 'Empujar Adelante',
|
||||||
|
left: 'Onda Izquierda',
|
||||||
|
right: 'Onda Derecha',
|
||||||
|
up: 'Onda Arriba',
|
||||||
|
down: 'Onda Abajo',
|
||||||
|
clockwise: 'Círculo CW',
|
||||||
|
anticlockwise: 'Círculo CCW',
|
||||||
|
wave: 'Onda (Conmute)',
|
||||||
|
},
|
||||||
|
gestureActions: {
|
||||||
|
forward: 'Avanzar cruce',
|
||||||
|
right: 'Girar a la derecha 90\u00B0',
|
||||||
|
left: 'Girar a la izquierda 90\u00B0',
|
||||||
|
up: 'Tomar / Extraer',
|
||||||
|
down: 'Dejar / Dispensar',
|
||||||
|
clockwise: 'Inactivo',
|
||||||
|
anticlockwise: 'Inactivo',
|
||||||
|
wave: 'Cambiar modo',
|
||||||
|
},
|
||||||
|
gestureRef: {
|
||||||
|
forward: 'Empujar Adelante',
|
||||||
|
left: 'Onda Izquierda',
|
||||||
|
right: 'Onda Derecha',
|
||||||
|
up: 'Onda Arriba',
|
||||||
|
down: 'Onda Abajo',
|
||||||
|
clockwise: 'Círculo CW',
|
||||||
|
anticlockwise: 'Círculo CCW',
|
||||||
|
wave: 'Onda (Ambas)',
|
||||||
|
},
|
||||||
|
modes: {
|
||||||
|
block: 'Modo Bloques',
|
||||||
|
gesture: 'Modo Gestos',
|
||||||
|
},
|
||||||
|
toast: {
|
||||||
|
cleared: 'Cola borrada',
|
||||||
|
switched: 'Cambiado a modo {mode}',
|
||||||
|
},
|
||||||
|
theme: {
|
||||||
|
light: 'Cambiar a modo claro',
|
||||||
|
dark: 'Cambiar a modo oscuro',
|
||||||
|
},
|
||||||
|
settings: {
|
||||||
|
title: 'Configuración',
|
||||||
|
save: 'Guardar',
|
||||||
|
saved: 'Configuración guardada',
|
||||||
|
theme: {
|
||||||
|
label: 'Tema',
|
||||||
|
dark: 'Oscuro',
|
||||||
|
light: 'Claro',
|
||||||
|
},
|
||||||
|
language: {
|
||||||
|
label: 'Idioma',
|
||||||
|
},
|
||||||
|
piUrl: {
|
||||||
|
label: 'URL de Raspberry Pi',
|
||||||
|
placeholder: 'http://raspberrypi.local:8000',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
38
quibot-web/app/plugins/i18n.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { defineNuxtPlugin, useRuntimeConfig } from '#app'
|
||||||
|
import { createI18n } from 'vue-i18n'
|
||||||
|
import en from '~/locales/en'
|
||||||
|
import ca from '~/locales/ca'
|
||||||
|
import es from '~/locales/es'
|
||||||
|
|
||||||
|
const locales = ['en', 'ca', 'es'] as const
|
||||||
|
type LocaleType = (typeof locales)[number]
|
||||||
|
|
||||||
|
function getSavedLocale(): LocaleType {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
const saved = localStorage.getItem('quibot-locale')
|
||||||
|
if (saved && locales.includes(saved as LocaleType)) {
|
||||||
|
return saved as LocaleType
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 'en'
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineNuxtPlugin((nuxtApp) => {
|
||||||
|
const locale = getSavedLocale()
|
||||||
|
const i18n = createI18n({
|
||||||
|
legacy: false,
|
||||||
|
locale,
|
||||||
|
fallbackLocale: 'en',
|
||||||
|
messages: { en, ca, es },
|
||||||
|
})
|
||||||
|
|
||||||
|
nuxtApp.vueApp.use(i18n)
|
||||||
|
|
||||||
|
// Expose setLocale for global access
|
||||||
|
nuxtApp.provide('setLocale', (l: LocaleType) => {
|
||||||
|
if (locales.includes(l)) {
|
||||||
|
i18n.global.locale.value = l
|
||||||
|
localStorage.setItem('quibot-locale', l)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
269
quibot-web/components/SettingsModal.vue
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch, onMounted } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const { locale } = useI18n()
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
show: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:show': [value: boolean]
|
||||||
|
toast: [msg: string, type: 'success' | 'error']
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const localShow = ref(props.show)
|
||||||
|
|
||||||
|
watch(() => props.show, (val) => {
|
||||||
|
localShow.value = val
|
||||||
|
})
|
||||||
|
|
||||||
|
const selectedTheme = ref(localStorage.getItem('quibot-theme') || 'dark')
|
||||||
|
const selectedLanguage = ref(localStorage.getItem('quibot-locale') || 'en')
|
||||||
|
const piUrl = ref(localStorage.getItem('quibot-pi-url') || 'http://raspberrypi.local:8000')
|
||||||
|
|
||||||
|
function applyTheme(theme: string) {
|
||||||
|
document.documentElement.setAttribute('data-theme', theme)
|
||||||
|
localStorage.setItem('quibot-theme', theme)
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveSettings() {
|
||||||
|
applyTheme(selectedTheme.value)
|
||||||
|
locale.value = selectedLanguage.value as any
|
||||||
|
localStorage.setItem('quibot-locale', selectedLanguage.value)
|
||||||
|
localStorage.setItem('quibot-pi-url', piUrl.value)
|
||||||
|
document.cookie = `quibot-pi-url=${encodeURIComponent(piUrl.value)};path=/`
|
||||||
|
emit('toast', t('settings.saved'), 'success')
|
||||||
|
emit('update:show', false)
|
||||||
|
}
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
localShow.value = false
|
||||||
|
emit('update:show', false)
|
||||||
|
}
|
||||||
|
|
||||||
|
function backdropClick(e: MouseEvent) {
|
||||||
|
if ((e.target as HTMLElement).classList.contains('settings-backdrop')) {
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div v-show="localShow" class="settings-backdrop" @click="backdropClick">
|
||||||
|
<div class="settings-modal">
|
||||||
|
<div class="settings-header">
|
||||||
|
<h2>{{ $t('settings.title') }}</h2>
|
||||||
|
<button class="close-btn" @click="close">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M18 6L6 18M6 6l12 12"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Theme -->
|
||||||
|
<div class="setting-row">
|
||||||
|
<span class="setting-label">{{ $t('settings.theme.label') }}</span>
|
||||||
|
<div class="theme-options">
|
||||||
|
<button
|
||||||
|
:class="['theme-option', { active: selectedTheme === 'light' }]"
|
||||||
|
@click="selectedTheme = 'light'"
|
||||||
|
>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12.79A9 9 0 1111.21 3a7 7 0 009.79 9.79z"/></svg>
|
||||||
|
<span>{{ $t('settings.theme.light') }}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
:class="['theme-option', { active: selectedTheme === 'dark' }]"
|
||||||
|
@click="selectedTheme = 'dark'"
|
||||||
|
>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="5"/><path d="M12 1v2m0 18v2m11-11h-2M3 12H1m17.07-7.07l-1.41 1.41M6.34 17.66l-1.41 1.41m14.14 0l-1.41-1.41M6.34 6.34L4.93 4.93"/></svg>
|
||||||
|
<span>{{ $t('settings.theme.dark') }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Language -->
|
||||||
|
<div class="setting-row">
|
||||||
|
<span class="setting-label">{{ $t('settings.language.label') }}</span>
|
||||||
|
<select v-model="selectedLanguage" class="lang-select">
|
||||||
|
<option value="en">English</option>
|
||||||
|
<option value="ca">Català</option>
|
||||||
|
<option value="es">Español</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- PI URL -->
|
||||||
|
<div class="setting-row">
|
||||||
|
<span class="setting-label">{{ $t('settings.piUrl.label') }}</span>
|
||||||
|
<input
|
||||||
|
v-model="piUrl"
|
||||||
|
:placeholder="$t('settings.piUrl.placeholder')"
|
||||||
|
type="text"
|
||||||
|
class="url-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Save -->
|
||||||
|
<button class="btn-save" @click="saveSettings">{{ $t('settings.save') }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.settings-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-backdrop[style*="display: block"],
|
||||||
|
.settings-backdrop[style*="display:flex"] {
|
||||||
|
display: flex !important;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-modal {
|
||||||
|
background: var(--bg-panel);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 1rem;
|
||||||
|
padding: 1.5rem;
|
||||||
|
width: 90%;
|
||||||
|
max-width: 420px;
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-header h2 {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin: 0;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn {
|
||||||
|
display: grid;
|
||||||
|
place-content: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
padding: 0;
|
||||||
|
background: var(--btn-bg);
|
||||||
|
border: 2px solid var(--border-color);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn:hover {
|
||||||
|
border-color: #ef4444;
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-row {
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-options {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-option {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.375rem;
|
||||||
|
padding: 0.625rem 1rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
font-family: inherit;
|
||||||
|
color: var(--text-muted);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 2px solid var(--border-color);
|
||||||
|
border-radius: 0.625rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-option.active {
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
background: var(--active-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-option:not(:disabled):hover {
|
||||||
|
border-color: var(--border-subtle);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lang-select,
|
||||||
|
.url-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.625rem 0.75rem;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 2px solid var(--border-color);
|
||||||
|
border-radius: 0.625rem;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.15s ease;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lang-select:focus,
|
||||||
|
.url-input:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.url-input::placeholder {
|
||||||
|
color: var(--text-ghost);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-save {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 700;
|
||||||
|
font-family: inherit;
|
||||||
|
color: #fff;
|
||||||
|
background: linear-gradient(135deg, var(--accent), #ea580c);
|
||||||
|
border: none;
|
||||||
|
border-radius: 0.625rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-save:hover {
|
||||||
|
box-shadow: 0 4px 16px var(--accent-glow);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -2,6 +2,14 @@
|
|||||||
export default defineNuxtConfig({
|
export default defineNuxtConfig({
|
||||||
compatibilityDate: '2025-07-15',
|
compatibilityDate: '2025-07-15',
|
||||||
devtools: { enabled: true },
|
devtools: { enabled: true },
|
||||||
|
runtimeConfig: {
|
||||||
|
quibotBaseUrl: process.env.QUIBOT_BASE_URL || 'http://quibot:8000',
|
||||||
|
quibotToken: process.env.QUIBOT_TOKEN || 'MY_SECRET_TOKEN',
|
||||||
|
public: {
|
||||||
|
defaultLocale: 'en',
|
||||||
|
supportedLocales: ['en', 'ca', 'es'],
|
||||||
|
}
|
||||||
|
},
|
||||||
vite: {
|
vite: {
|
||||||
optimizeDeps: {
|
optimizeDeps: {
|
||||||
include: [
|
include: [
|
||||||
|
|||||||