Compare commits
9 Commits
0c2a227be2
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| d79ce06278 | |||
| 55257f849d | |||
| 7dd1bce80f | |||
| 9b099dac83 | |||
| 8685ea0aca | |||
| 7a63706af5 | |||
| 5df7956069 | |||
| 76cf672d80 | |||
| 96f75df6f2 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,3 +1,4 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
dist/
|
dist/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
*.jks
|
||||||
|
|||||||
20
CHANGELOG.md
20
CHANGELOG.md
@@ -7,6 +7,26 @@ il progetto aderisce al [Semantic Versioning](https://semver.org/lang/it/).
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## [1.0.0] — 2026-03-28
|
||||||
|
|
||||||
|
### Aggiunto
|
||||||
|
|
||||||
|
- **Build APK release firmato** — pipeline Docker aggiornata con supporto
|
||||||
|
`assembleRelease` + `zipalign` + `apksigner`; flag `--release` in `build.sh`
|
||||||
|
per attivare la firma; keystore montato come volume read-only (mai nell'immagine)
|
||||||
|
- **`build.sh --release`** — chiede interattivamente le password del keystore;
|
||||||
|
supporta override via `KEYSTORE_PASS`, `KEY_PASS`, `KEYSTORE_PATH`
|
||||||
|
- **Flag combinabili** — `--head` e `--release` ora combinabili in qualsiasi ordine
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- `build.sh --head` — APK ora copiato in `dist/` del progetto invece di andare
|
||||||
|
perso nella directory temporanea cancellata dal trap EXIT
|
||||||
|
- `build.sh` — `dist/` creata con `mkdir -p` prima del `docker run` per evitare
|
||||||
|
ownership root che causava "Permission denied" nelle build successive
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [0.9.1] — 2026-03-27
|
## [0.9.1] — 2026-03-27
|
||||||
|
|
||||||
### Aggiunto
|
### Aggiunto
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ App mobile-first per la gestione della dieta quotidiana — pianificazione pasti
|
|||||||
|
|
||||||
| Strumento | Versione minima | Note |
|
| Strumento | Versione minima | Note |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Node.js | 18.x | |
|
| Node.js | >= 20.x LTS | testato con v24 |
|
||||||
| npm | 9.x | incluso con Node.js |
|
| npm | 9.x | incluso con Node.js |
|
||||||
| Git | 2.x | |
|
| Git | 2.x | |
|
||||||
| Browser | Chrome / Edge / Firefox recente | DevTools modalità mobile consigliati |
|
| Browser | Chrome / Edge / Firefox recente | DevTools modalità mobile consigliati |
|
||||||
|
|||||||
@@ -52,6 +52,10 @@ RUN npm install @capacitor/core @capacitor/cli @capacitor/android --save
|
|||||||
|
|
||||||
RUN npx cap add android
|
RUN npx cap add android
|
||||||
|
|
||||||
|
# ── Permessi Android ──────────────────────────────────────────────────────────
|
||||||
|
RUN sed -i 's|</manifest>| <uses-permission android:name="android.permission.CAMERA" />\n</manifest>|' \
|
||||||
|
android/app/src/main/AndroidManifest.xml
|
||||||
|
|
||||||
# ── Versione da package.json → android/app/build.gradle ──────────────────────
|
# ── Versione da package.json → android/app/build.gradle ──────────────────────
|
||||||
RUN node -e "\
|
RUN node -e "\
|
||||||
const v = require('./package.json').version; \
|
const v = require('./package.json').version; \
|
||||||
@@ -76,11 +80,29 @@ RUN rm -rf android/app/src/main/res/mipmap-anydpi-v26 && \
|
|||||||
# Fix kotlin-stdlib duplicate class conflict (stdlib 1.8+ already includes jdk7/jdk8)
|
# Fix kotlin-stdlib duplicate class conflict (stdlib 1.8+ already includes jdk7/jdk8)
|
||||||
RUN printf '\nsubprojects {\n configurations.all {\n resolutionStrategy {\n force "org.jetbrains.kotlin:kotlin-stdlib:1.8.22"\n force "org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.8.22"\n force "org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.8.22"\n }\n }\n}\n' >> android/build.gradle
|
RUN printf '\nsubprojects {\n configurations.all {\n resolutionStrategy {\n force "org.jetbrains.kotlin:kotlin-stdlib:1.8.22"\n force "org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.8.22"\n force "org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.8.22"\n }\n }\n}\n' >> android/build.gradle
|
||||||
|
|
||||||
# ── Runtime: dist/ viene montato come volume dall'host ────────────────────────
|
# ── Runtime: dist/ e (opzionale) keystore montati come volumi dall'host ───────
|
||||||
# build.sh esegue: docker run -v ./dist:/app/dist ...
|
# build.sh esegue: docker run -v ./dist:/app/dist ...
|
||||||
# Qui cap sync copia dist/ in android/assets, poi Gradle builda l'APK
|
# Con --release: aggiunge -e BUILD_TYPE=release -v biteplan.jks:/app/biteplan.jks:ro
|
||||||
|
# BUILD_TYPE=release → assembleRelease + zipalign + apksigner
|
||||||
|
# BUILD_TYPE vuoto → assembleDebug (default)
|
||||||
|
|
||||||
CMD npx cap sync android && \
|
CMD npx cap sync android && \
|
||||||
cd android && chmod +x gradlew && ./gradlew assembleDebug --no-daemon && \
|
cd android && chmod +x gradlew && \
|
||||||
|
if [ "$BUILD_TYPE" = "release" ]; then \
|
||||||
|
./gradlew assembleRelease --no-daemon && \
|
||||||
|
$ANDROID_HOME/build-tools/34.0.0/zipalign -v 4 \
|
||||||
|
app/build/outputs/apk/release/app-release-unsigned.apk \
|
||||||
|
/tmp/aligned.apk && \
|
||||||
|
$ANDROID_HOME/build-tools/34.0.0/apksigner sign \
|
||||||
|
--ks /app/biteplan.jks \
|
||||||
|
--ks-key-alias biteplan \
|
||||||
|
--ks-pass pass:$KEYSTORE_PASS \
|
||||||
|
--key-pass pass:$KEY_PASS \
|
||||||
|
--out /app/dist/biteplan.apk \
|
||||||
|
/tmp/aligned.apk && \
|
||||||
|
echo "APK release firmato: /app/dist/biteplan.apk"; \
|
||||||
|
else \
|
||||||
|
./gradlew assembleDebug --no-daemon && \
|
||||||
cp app/build/outputs/apk/debug/app-debug.apk /app/dist/biteplan.apk && \
|
cp app/build/outputs/apk/debug/app-debug.apk /app/dist/biteplan.apk && \
|
||||||
echo "APK generato: /app/dist/biteplan.apk"
|
echo "APK debug: /app/dist/biteplan.apk"; \
|
||||||
|
fi
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Build APK — Docker
|
# Build APK — Docker
|
||||||
|
|
||||||
Genera un APK Android debug senza installare nulla sull'host oltre a Docker.
|
Genera un APK Android senza installare nulla sull'host oltre a Docker.
|
||||||
|
|
||||||
## Requisiti host
|
## Requisiti host
|
||||||
|
|
||||||
@@ -11,21 +11,75 @@ Genera un APK Android debug senza installare nulla sull'host oltre a Docker.
|
|||||||
| **Docker** | Installato e avviato |
|
| **Docker** | Installato e avviato |
|
||||||
| **Node.js** | v18+ (per il build Vite locale) |
|
| **Node.js** | v18+ (per il build Vite locale) |
|
||||||
|
|
||||||
> Gli Android build-tools (`aapt2`, `zipalign`, ecc.) sono binari nativi x86_64 e non girano su host ARM senza emulazione QEMU.
|
> Gli Android build-tools (`aapt2`, `zipalign`, `apksigner`) sono binari nativi x86_64 e non girano su host ARM senza emulazione QEMU.
|
||||||
|
|
||||||
## Utilizzo
|
---
|
||||||
|
|
||||||
Dalla root del progetto:
|
## Build debug (default)
|
||||||
|
|
||||||
|
APK firmato con il debug keystore di Android. Adatto per test su dispositivo.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Build dalla working directory (file attuali)
|
|
||||||
bash docker/build.sh
|
bash docker/build.sh
|
||||||
|
|
||||||
# Build da HEAD (ignora modifiche non committate)
|
|
||||||
bash docker/build.sh --head
|
|
||||||
```
|
```
|
||||||
|
|
||||||
L'APK viene generato in `dist/biteplan.apk`.
|
---
|
||||||
|
|
||||||
|
## Build release (distribuzione)
|
||||||
|
|
||||||
|
APK firmato con il tuo keystore personale. Necessario per distribuire l'app.
|
||||||
|
|
||||||
|
### 1. Genera il keystore (una volta sola)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
keytool -genkey -v \
|
||||||
|
-keystore docker/biteplan.jks \
|
||||||
|
-alias biteplan \
|
||||||
|
-keyalg RSA \
|
||||||
|
-keysize 2048 \
|
||||||
|
-validity 10000
|
||||||
|
```
|
||||||
|
|
||||||
|
> Il file `docker/biteplan.jks` è già in `.gitignore` — non verrà mai committato.
|
||||||
|
> Conservalo in un posto sicuro: senza di esso non puoi aggiornare l'app.
|
||||||
|
|
||||||
|
### 2. Esegui la build release
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash docker/build.sh --release
|
||||||
|
# chiede interattivamente: Password keystore / Password chiave
|
||||||
|
```
|
||||||
|
|
||||||
|
Oppure passando le password come variabili d'ambiente (utile in CI):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
KEYSTORE_PASS=tuapassword KEY_PASS=tuapassword bash docker/build.sh --release
|
||||||
|
```
|
||||||
|
|
||||||
|
Per usare un keystore in un percorso diverso da `docker/biteplan.jks`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
KEYSTORE_PATH=/percorso/biteplan.jks bash docker/build.sh --release
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Verifica la firma
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ANDROID_HOME/build-tools/34.0.0/apksigner verify --verbose dist/biteplan.apk
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Flag combinabili
|
||||||
|
|
||||||
|
| Comando | Risultato |
|
||||||
|
|---------|-----------|
|
||||||
|
| `bash docker/build.sh` | APK debug dalla working directory |
|
||||||
|
| `bash docker/build.sh --head` | APK debug dall'ultimo commit git |
|
||||||
|
| `bash docker/build.sh --release` | APK release firmato dalla working directory |
|
||||||
|
| `bash docker/build.sh --head --release` | APK release firmato dall'ultimo commit git |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Prima build
|
## Prima build
|
||||||
|
|
||||||
@@ -34,8 +88,6 @@ Le build successive usano la cache Docker e sono molto più rapide.
|
|||||||
|
|
||||||
## Installazione su dispositivo
|
## Installazione su dispositivo
|
||||||
|
|
||||||
Con ADB:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
adb install dist/biteplan.apk
|
adb install dist/biteplan.apk
|
||||||
```
|
```
|
||||||
@@ -43,14 +95,15 @@ adb install dist/biteplan.apk
|
|||||||
## Pipeline
|
## Pipeline
|
||||||
|
|
||||||
```
|
```
|
||||||
[host] npm run build → dist/ (montato come volume in sola lettura)
|
[host] npm run build → dist/
|
||||||
[docker] cap sync → copia dist/ in android/assets/
|
[docker] cap sync → copia dist/ in android/assets/
|
||||||
[docker] gradlew assembleDebug
|
[docker] gradlew assembleDebug/Release
|
||||||
[host] output/biteplan.apk
|
[docker] zipalign + apksigner → solo in modalità release
|
||||||
|
[host] dist/biteplan.apk
|
||||||
```
|
```
|
||||||
|
|
||||||
## Note
|
## Note
|
||||||
|
|
||||||
- APK di tipo **debug**, non firmato per la produzione
|
|
||||||
- App ID: `com.davide.biteplan`
|
- App ID: `com.davide.biteplan`
|
||||||
- Android target: API 34
|
- Android target: API 34
|
||||||
|
- Il keystore non viene mai copiato nell'immagine Docker (montato come volume read-only)
|
||||||
|
|||||||
@@ -6,10 +6,31 @@ PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
|
|||||||
DIST_DIR="$PROJECT_ROOT/dist"
|
DIST_DIR="$PROJECT_ROOT/dist"
|
||||||
|
|
||||||
FROM_HEAD=false
|
FROM_HEAD=false
|
||||||
|
RELEASE=false
|
||||||
for arg in "$@"; do
|
for arg in "$@"; do
|
||||||
[[ "$arg" == "--head" ]] && FROM_HEAD=true
|
[[ "$arg" == "--head" ]] && FROM_HEAD=true
|
||||||
|
[[ "$arg" == "--release" ]] && RELEASE=true
|
||||||
done
|
done
|
||||||
|
|
||||||
|
# ── Keystore (solo in modalità release) ───────────────────────────────────────
|
||||||
|
KEYSTORE_PATH="${KEYSTORE_PATH:-$SCRIPT_DIR/biteplan.jks}"
|
||||||
|
|
||||||
|
if $RELEASE; then
|
||||||
|
if [[ ! -f "$KEYSTORE_PATH" ]]; then
|
||||||
|
echo "Errore: keystore non trovato in $KEYSTORE_PATH"
|
||||||
|
echo "Generalo con:"
|
||||||
|
echo " keytool -genkey -v -keystore docker/biteplan.jks -alias biteplan -keyalg RSA -keysize 2048 -validity 10000"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -z "${KEYSTORE_PASS:-}" ]]; then
|
||||||
|
read -rsp "Password keystore: " KEYSTORE_PASS; echo
|
||||||
|
fi
|
||||||
|
if [[ -z "${KEY_PASS:-}" ]]; then
|
||||||
|
read -rsp "Password chiave: " KEY_PASS; echo
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
# ── Build Vite ────────────────────────────────────────────────────────────────
|
# ── Build Vite ────────────────────────────────────────────────────────────────
|
||||||
if $FROM_HEAD; then
|
if $FROM_HEAD; then
|
||||||
COMMIT_SHA=$(git -C "$PROJECT_ROOT" rev-parse --short HEAD)
|
COMMIT_SHA=$(git -C "$PROJECT_ROOT" rev-parse --short HEAD)
|
||||||
@@ -36,11 +57,31 @@ docker build \
|
|||||||
-t biteplan-builder \
|
-t biteplan-builder \
|
||||||
"$PROJECT_ROOT"
|
"$PROJECT_ROOT"
|
||||||
|
|
||||||
# ── Generazione APK (dist/ montato come volume) ───────────────────────────────
|
# ── Generazione APK ───────────────────────────────────────────────────────────
|
||||||
echo "==> Generazione APK..."
|
echo "==> Generazione APK${RELEASE:+ release}..."
|
||||||
docker run --rm \
|
mkdir -p "$PROJECT_ROOT/dist"
|
||||||
-v "$DIST_DIR:/app/dist" \
|
|
||||||
biteplan-builder
|
DOCKER_ARGS=(
|
||||||
|
--rm
|
||||||
|
-v "$DIST_DIR:/app/dist"
|
||||||
|
)
|
||||||
|
|
||||||
|
if $RELEASE; then
|
||||||
|
DOCKER_ARGS+=(
|
||||||
|
-e BUILD_TYPE=release
|
||||||
|
-e KEYSTORE_PASS="$KEYSTORE_PASS"
|
||||||
|
-e KEY_PASS="$KEY_PASS"
|
||||||
|
-v "$KEYSTORE_PATH:/app/biteplan.jks:ro"
|
||||||
|
)
|
||||||
|
fi
|
||||||
|
|
||||||
|
docker run "${DOCKER_ARGS[@]}" biteplan-builder
|
||||||
|
|
||||||
|
# Con --head l'APK è nella dir temporanea — copiarlo in dist/ del progetto
|
||||||
|
if $FROM_HEAD; then
|
||||||
|
mkdir -p "$PROJECT_ROOT/dist"
|
||||||
|
cp "$DIST_DIR/biteplan.apk" "$PROJECT_ROOT/dist/biteplan.apk"
|
||||||
|
fi
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "APK pronto in: $DIST_DIR/biteplan.apk"
|
echo "APK pronto in: $PROJECT_ROOT/dist/biteplan.apk"
|
||||||
|
|||||||
1326
package-lock.json
generated
1326
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
12
package.json
12
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "biteplan",
|
"name": "biteplan",
|
||||||
"version": "0.9.1",
|
"version": "1.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --host",
|
"dev": "vite --host",
|
||||||
@@ -12,16 +12,18 @@
|
|||||||
"test:e2e:ui": "playwright test --ui"
|
"test:e2e:ui": "playwright test --ui"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"jsqr": "^1.4.0",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
"vue": "^3.4.0"
|
"vue": "^3.4.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.58.2",
|
"@playwright/test": "^1.58.2",
|
||||||
"@vitejs/plugin-vue": "^5.0.0",
|
"@vitejs/plugin-vue": "^5.2.0",
|
||||||
"@vitest/coverage-v8": "^2.1.9",
|
"@vitest/coverage-v8": "^3.0.0",
|
||||||
"@vue/test-utils": "^2.4.6",
|
"@vue/test-utils": "^2.4.6",
|
||||||
"happy-dom": "^20.8.9",
|
"happy-dom": "^20.8.9",
|
||||||
"jsdom": "^29.0.1",
|
"jsdom": "^29.0.1",
|
||||||
"vite": "^5.0.0",
|
"vite": "^6.0.0",
|
||||||
"vitest": "^2.1.9"
|
"vitest": "^3.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,13 +21,89 @@
|
|||||||
</svg>
|
</svg>
|
||||||
Genera lista della spesa
|
Genera lista della spesa
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<div class="btn-share-row">
|
||||||
|
<button class="btn-share" @click="openShare">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<rect x="3" y="3" width="7" height="7" rx="1"/>
|
||||||
|
<rect x="14" y="3" width="7" height="7" rx="1"/>
|
||||||
|
<rect x="3" y="14" width="7" height="7" rx="1"/>
|
||||||
|
<rect x="14" y="14" width="3" height="3" rx="0.5"/>
|
||||||
|
<rect x="19" y="14" width="2" height="2" rx="0.5"/>
|
||||||
|
<rect x="14" y="19" width="2" height="2" rx="0.5"/>
|
||||||
|
<rect x="18" y="19" width="3" height="2" rx="0.5"/>
|
||||||
|
</svg>
|
||||||
|
Condividi
|
||||||
|
</button>
|
||||||
|
<button class="btn-share btn-share--receive" @click="openScan">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M23 19a2 2 0 01-2 2H3a2 2 0 01-2-2V8a2 2 0 012-2h4l2-3h6l2 3h4a2 2 0 012 2z"/>
|
||||||
|
<circle cx="12" cy="13" r="4"/>
|
||||||
|
</svg>
|
||||||
|
Ricevi
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal: Condividi -->
|
||||||
|
<Teleport to="body">
|
||||||
|
<Transition name="fade">
|
||||||
|
<div v-if="showShareModal" class="qr-overlay" @click.self="closeShare">
|
||||||
|
<div class="qr-sheet" role="dialog" aria-label="Condividi piano pasti" aria-modal="true">
|
||||||
|
<div class="sheet-handle" />
|
||||||
|
<div class="qr-header">
|
||||||
|
<span class="qr-title">Condividi piano</span>
|
||||||
|
<button class="btn-x" @click="closeShare" aria-label="Chiudi">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="qr-hint">Fai scansionare questo codice dall'altro dispositivo</p>
|
||||||
|
<div class="qr-img-wrap">
|
||||||
|
<img v-if="qrDataUrl" :src="qrDataUrl" alt="QR code piano pasti" class="qr-img" />
|
||||||
|
<p v-else-if="qrError" class="qr-error">{{ qrError }}</p>
|
||||||
|
<div v-else class="qr-loading">Generazione in corso…</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</Teleport>
|
||||||
|
|
||||||
|
<!-- Modal: Ricevi -->
|
||||||
|
<Teleport to="body">
|
||||||
|
<Transition name="fade">
|
||||||
|
<div v-if="showScanModal" class="qr-overlay" @click.self="closeAndStopScan">
|
||||||
|
<div class="qr-sheet" role="dialog" aria-label="Ricevi piano pasti" aria-modal="true">
|
||||||
|
<div class="sheet-handle" />
|
||||||
|
<div class="qr-header">
|
||||||
|
<span class="qr-title">Scansiona QR</span>
|
||||||
|
<button class="btn-x" @click="closeAndStopScan" aria-label="Chiudi">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="qr-hint">Inquadra il codice QR dell'altro dispositivo</p>
|
||||||
|
<div class="scan-wrap">
|
||||||
|
<video ref="videoEl" class="scan-video" autoplay playsinline muted />
|
||||||
|
<canvas ref="canvasEl" class="scan-canvas" aria-hidden="true" />
|
||||||
|
<div class="scan-frame" aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
<p v-if="scanError" class="qr-error">{{ scanError }}</p>
|
||||||
|
<p v-if="scanSuccess" class="qr-success">Piano ricevuto!</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</Teleport>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { reactive, watch } from 'vue'
|
import { reactive, watch, ref, onUnmounted, nextTick } from 'vue'
|
||||||
import MealCard from '../components/MealCard.vue'
|
import MealCard from '../components/MealCard.vue'
|
||||||
import { save, load } from '../utils/storage.js'
|
import { save, load } from '../utils/storage.js'
|
||||||
|
import QRCode from 'qrcode'
|
||||||
|
import jsQR from 'jsqr'
|
||||||
|
|
||||||
const emit = defineEmits(['go-shop'])
|
const emit = defineEmits(['go-shop'])
|
||||||
|
|
||||||
@@ -79,6 +155,144 @@ function generateShopping() {
|
|||||||
save('shopping', [...existing, ...toAdd])
|
save('shopping', [...existing, ...toAdd])
|
||||||
emit('go-shop')
|
emit('go-shop')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Share (QR generation) ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const showShareModal = ref(false)
|
||||||
|
const qrDataUrl = ref('')
|
||||||
|
const qrError = ref('')
|
||||||
|
|
||||||
|
async function openShare() {
|
||||||
|
qrDataUrl.value = ''
|
||||||
|
qrError.value = ''
|
||||||
|
showShareModal.value = true
|
||||||
|
|
||||||
|
const payload = JSON.stringify({ v: 1, meals })
|
||||||
|
if (payload.length > 2953) {
|
||||||
|
qrError.value = 'Dati troppo grandi per un QR code. Riduci il numero di alimenti inseriti.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
qrDataUrl.value = await QRCode.toDataURL(payload, {
|
||||||
|
errorCorrectionLevel: 'L',
|
||||||
|
margin: 1,
|
||||||
|
width: 260,
|
||||||
|
color: { dark: '#1a1a1a', light: '#ffffff' },
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
qrError.value = 'Impossibile generare il QR code.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeShare() {
|
||||||
|
showShareModal.value = false
|
||||||
|
qrDataUrl.value = ''
|
||||||
|
qrError.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Receive (camera scan) ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const showScanModal = ref(false)
|
||||||
|
const videoEl = ref(null)
|
||||||
|
const canvasEl = ref(null)
|
||||||
|
const scanError = ref('')
|
||||||
|
const scanSuccess = ref(false)
|
||||||
|
|
||||||
|
let stream = null
|
||||||
|
let rafId = null
|
||||||
|
|
||||||
|
async function openScan() {
|
||||||
|
scanError.value = ''
|
||||||
|
scanSuccess.value = false
|
||||||
|
showScanModal.value = true
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
try {
|
||||||
|
stream = await navigator.mediaDevices.getUserMedia({
|
||||||
|
video: { facingMode: 'environment' },
|
||||||
|
audio: false,
|
||||||
|
})
|
||||||
|
videoEl.value.srcObject = stream
|
||||||
|
videoEl.value.play()
|
||||||
|
rafId = requestAnimationFrame(scanFrame)
|
||||||
|
} catch (err) {
|
||||||
|
if (err.name === 'NotAllowedError') {
|
||||||
|
scanError.value = 'Accesso fotocamera negato. Abilita il permesso nelle impostazioni del dispositivo.'
|
||||||
|
} else {
|
||||||
|
scanError.value = 'Impossibile accedere alla fotocamera.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scanFrame() {
|
||||||
|
const video = videoEl.value
|
||||||
|
const canvas = canvasEl.value
|
||||||
|
if (!video || !canvas || video.readyState < 2) {
|
||||||
|
rafId = requestAnimationFrame(scanFrame)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas.width = video.videoWidth
|
||||||
|
canvas.height = video.videoHeight
|
||||||
|
const ctx = canvas.getContext('2d')
|
||||||
|
ctx.drawImage(video, 0, 0)
|
||||||
|
|
||||||
|
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height)
|
||||||
|
const code = jsQR(imageData.data, canvas.width, canvas.height, {
|
||||||
|
inversionAttempts: 'dontInvert',
|
||||||
|
})
|
||||||
|
|
||||||
|
if (code) {
|
||||||
|
handleScannedData(code.data)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rafId = requestAnimationFrame(scanFrame)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleScannedData(raw) {
|
||||||
|
stopCamera()
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw)
|
||||||
|
if (parsed?.v !== 1 || typeof parsed.meals !== 'object') {
|
||||||
|
scanError.value = 'QR non valido: dati non riconosciuti.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const expectedDays = ['lunedi', 'martedi', 'mercoledi', 'giovedi', 'venerdi', 'sabato', 'domenica']
|
||||||
|
const expectedSlots = ['colazione', 'pranzo', 'cena']
|
||||||
|
for (const day of expectedDays) {
|
||||||
|
for (const slot of expectedSlots) {
|
||||||
|
if (!Array.isArray(parsed.meals[day]?.[slot])) {
|
||||||
|
scanError.value = 'QR non valido: struttura dati errata.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const day of expectedDays) {
|
||||||
|
for (const slot of expectedSlots) {
|
||||||
|
meals[day][slot] = parsed.meals[day][slot]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
scanSuccess.value = true
|
||||||
|
setTimeout(() => closeAndStopScan(), 1200)
|
||||||
|
} catch {
|
||||||
|
scanError.value = 'QR non valido: impossibile leggere i dati.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopCamera() {
|
||||||
|
if (rafId) { cancelAnimationFrame(rafId); rafId = null }
|
||||||
|
if (stream) { stream.getTracks().forEach(t => t.stop()); stream = null }
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeAndStopScan() {
|
||||||
|
stopCamera()
|
||||||
|
showScanModal.value = false
|
||||||
|
scanError.value = ''
|
||||||
|
scanSuccess.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
onUnmounted(stopCamera)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -100,4 +314,182 @@ function generateShopping() {
|
|||||||
.btn-generate:active {
|
.btn-generate:active {
|
||||||
opacity: 0.85;
|
opacity: 0.85;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Riga Condividi / Ricevi ─────────────────────── */
|
||||||
|
.btn-share-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-share {
|
||||||
|
flex: 1;
|
||||||
|
background: var(--color-primary-muted);
|
||||||
|
color: var(--color-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
min-height: 44px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
border: 1.5px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-share:active {
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: #fff;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-share--receive {
|
||||||
|
background: var(--color-surface);
|
||||||
|
border-color: var(--color-border);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-share--receive:active {
|
||||||
|
background: var(--color-bg);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Overlay ─────────────────────────────────────── */
|
||||||
|
.qr-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
z-index: 400;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Bottom sheet ────────────────────────────────── */
|
||||||
|
.qr-sheet {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 480px;
|
||||||
|
background: var(--color-surface);
|
||||||
|
border-radius: var(--radius) var(--radius) 0 0;
|
||||||
|
padding: 12px 20px 48px;
|
||||||
|
box-shadow: 0 -8px 32px rgba(0, 0, 0, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet-handle {
|
||||||
|
width: 36px;
|
||||||
|
height: 4px;
|
||||||
|
background: var(--color-border);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
margin: 0 auto 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-title {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-x {
|
||||||
|
background: var(--color-bg);
|
||||||
|
color: var(--color-muted);
|
||||||
|
min-height: 36px;
|
||||||
|
min-width: 36px;
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-hint {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--color-muted);
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── QR image ────────────────────────────────────── */
|
||||||
|
.qr-img-wrap {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-img {
|
||||||
|
width: 240px;
|
||||||
|
height: 240px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
image-rendering: pixelated;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-loading {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--color-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Scanner ─────────────────────────────────────── */
|
||||||
|
.scan-wrap {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 320px;
|
||||||
|
margin: 0 auto 16px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
overflow: hidden;
|
||||||
|
background: #000;
|
||||||
|
aspect-ratio: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scan-video {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scan-canvas {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scan-frame {
|
||||||
|
position: absolute;
|
||||||
|
inset: 24px;
|
||||||
|
border: 2.5px solid var(--color-primary-light);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Feedback ────────────────────────────────────── */
|
||||||
|
.qr-error {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--color-danger);
|
||||||
|
background: var(--color-danger-muted);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 10px 14px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-success {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-primary);
|
||||||
|
background: var(--color-primary-muted);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 10px 14px;
|
||||||
|
margin-top: 4px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Transizioni ─────────────────────────────────── */
|
||||||
|
.fade-enter-active,
|
||||||
|
.fade-leave-active { transition: opacity 200ms ease; }
|
||||||
|
.fade-enter-from,
|
||||||
|
.fade-leave-to { opacity: 0; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user