refactor: riscrivi app in Flutter (da branch flutter)

Migrazione completa da Vue 3 + Vite a Flutter:
- rimossa sorgente Vue 3/Vite/Playwright
- app riscritta in Flutter con Material 3, Provider, SharedPreferences
- aggiunto supporto QR code per condivisione piano pasti
- aggregazione duplicati nella lista della spesa
- guida utente con 3 tab (Pasti, Converti, Spesa)
- containerizzazione Docker per dev e build APK
- suite di 110 test (unit + widget)
- ottimizzazioni APK: spazio e allocazioni a runtime

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-18 21:06:30 +02:00
79 changed files with 4196 additions and 7412 deletions
+19
View File
@@ -2,3 +2,22 @@ node_modules/
dist/
.DS_Store
*.jks
# Flutter — output e scaffold generati da flutter create / flutter build
.dart_tool/
.flutter-plugins
.flutter-plugins-dependencies
/build/
*.g.dart
pubspec.lock
# Scaffold generato da flutter create (creato nel container, non versionare)
/android/
/ios/
/linux/
/macos/
/windows/
/web/
/.idea/
*.iml
.metadata
+74 -44
View File
@@ -4,63 +4,93 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project Overview
BitePlan is a mobile-first Vue 3 PWA for meal planning, raw↔cooked weight conversion, and shopping list management. It targets Android via Capacitor and is designed for a 480px-max viewport. All UI strings and documentation are in **Italian**.
BitePlan è un'app Android per meal planning, conversione crudo/cotto e lista della spesa, scritta in Flutter. Tutta la UI e la UX sono in **italiano**. L'obiettivo finale è un APK Android buildabile via Docker.
## Commands
## Comandi
```bash
npm run dev # Dev server at http://localhost:5173
npm run build # Production build → dist/
npm run preview # Preview built app
# Sviluppo (web server con hot reload)
cd docker/dev && docker compose up
# → http://localhost:5173
# Con il container attivo: docker compose attach dev → r = hot reload
npm test # Vitest unit + integration tests (watch mode)
npm run test:coverage # Coverage report (v8 provider)
npm run test:e2e # Playwright e2e (requires dev server running)
npm run test:e2e:ui # Playwright interactive UI mode
# Test (dalla root del progetto — richiede immagine biteplan-build)
docker run --rm -v "$(pwd):/workspace" -w /workspace biteplan-build \
bash -c "flutter pub get && flutter test"
# Single test file
npx vitest run tests/unit/conversion.test.js
# Test singolo file
docker run --rm -v "$(pwd):/workspace" -w /workspace biteplan-build \
bash -c "flutter test test/features/meal_planner/qr_test.dart"
# Android APK
bash docker/build.sh # Debug APK
bash docker/build.sh --release # Signed release APK
# Build APK (headless, da host)
bash docker/build/build.sh # debug → dist/biteplan-debug.apk
bash docker/build/build.sh --release # release → dist/biteplan-release.apk
```
E2E tests simulate iPhone 14 Pro viewport (393×852) with Italian locale.
## Architettura
## Architecture
**App.vue** is the root router — it conditionally renders three pages based on a `currentPage` ref (`meal`, `convert`, `shop`) and hosts the portrait-lock transform, `InfoPanel`, and `DocsPanel`.
**Three pages** in `src/pages/`:
- `MealPlanner.vue` — 7-day plan (MonSun), 3 meal slots each (colazione/pranzo/cena), per-day accordion via `MealCard.vue`, QR share, and "generate shopping list" export
- `Converter.vue` — real-time food search → select food+method → bidirectional raw↔cooked calculation using yield coefficients from `src/data/conversions.json`
- `ShoppingList.vue` — checklist with add/remove/check, importable from meal planner
**State & persistence**: No Pinia/Vuex — pages use Vue composables + direct `localStorage` via the wrappers in `src/utils/storage.js` (`save(key, val)` / `load(key, default)`).
**Conversion logic** lives entirely in `src/utils/conversion.js`:
```js
rawToCooked(food, method, rawGrams, db) = rawGrams * db[food][method].yield
cookedToRaw(food, method, cookedGrams, db) = cookedGrams / db[food][method].yield
```
`yield = cooked_weight / raw_weight`. The database in `src/data/conversions.json` has 50+ foods × 14 cooking methods with coefficients sourced from CREA, SINU, USDA (see `docs/conversioni.md`).
lib/
├── main.dart
├── app.dart # MaterialApp, NavigationBar, AppBar con bottone info
├── core/
│ ├── constants/app_constants.dart # kDayIds, kMealSlots, kStorageKey*, kAppVersion
│ └── theme/app_theme.dart
├── shared/
│ ├── services/storage_service.dart # wrapper SharedPreferences (load/save)
│ └── widgets/
├── features/
│ ├── meal_planner/
│ │ ├── models/meal_plan.dart # MealPlan, DayPlan
│ │ ├── providers/meal_planner_provider.dart
│ │ ├── qr_codec.dart # buildQrPayload, parseMealPlanFromQr
│ │ └── presentation/
│ │ ├── pages/meal_planner_page.dart
│ │ ├── pages/qr_scan_page.dart
│ │ └── widgets/meal_card.dart, qr_share_sheet.dart
│ ├── converter/
│ │ ├── models/conversion_entry.dart # rawToCooked, cookedToRaw (metodi sul model)
│ │ ├── providers/converter_provider.dart
│ │ └── presentation/pages/converter_page.dart
│ ├── shopping_list/
│ │ ├── models/shopping_item.dart # quantity per aggregazione duplicati
│ │ ├── providers/shopping_list_provider.dart
│ │ └── presentation/
│ │ ├── pages/shopping_list_page.dart
│ │ └── widgets/shopping_item_tile.dart
│ └── guide/
│ └── presentation/
│ ├── pages/guide_page.dart # 3 tab: Pasti, Converti, Spesa
│ └── widgets/info_bottom_sheet.dart # aperto dal bottone info in AppBar
└── assets/data/conversions.json # 50+ alimenti × metodi cottura
```
**CSS**: Vanilla CSS only, no framework. Design tokens are CSS variables in `:root` in `src/style.css` (`--color-primary: #2d6a4f`, `--nav-height: 64px`, etc.). Buttons must be min 44px; layout is single-column.
**State management**: Provider (`ChangeNotifier`).
**Persistenza**: `shared_preferences`, chiavi `meals` e `shopping_list` (JSON serializzato).
**Conversione**: logica in `ConversionEntry``rawToCooked = raw * yieldFactor`, `cookedToRaw = cooked / yieldFactor`.
**UI**: Material 3, seed color `Color(0xFF2d6a4f)`, tutto in italiano.
**QR**: payload JSON `{ "v": 1, "meals": { ... } }`, limite 2953 byte (capacità QR con error correction L).
## Testing
- **Unit** (`tests/unit/`): Pure function tests for `conversion.js` and `storage.js`
- **Integration** (`tests/integration/`): Vue Test Utils component mounts with localStorage seeding
- **E2E** (`tests/e2e/`): Playwright against the running dev server
110 test (unit + widget). Non richiedono device fisico.
`tests/setup.js` clears localStorage before/after each test. Vitest uses `happy-dom` environment.
```
test/
├── helpers/pump_app.dart # estensione pumpApp per widget test
└── features/
├── converter/
│ ├── models/conversion_entry_test.dart
│ └── providers/converter_provider_test.dart
├── meal_planner/
│ ├── models/meal_plan_test.dart
│ ├── providers/meal_planner_provider_test.dart
│ ├── widgets/meal_card_test.dart
│ └── qr_test.dart
└── shopping_list/
├── models/shopping_item_test.dart
├── providers/shopping_list_provider_test.dart
└── widgets/shopping_item_tile_test.dart
```
## Android Build
The Docker pipeline in `docker/` handles the full Android build:
1. `npm run build``dist/`
2. `cap sync` → copies dist into Android project
3. Gradle builds the APK; release APK is signed with `docker/biteplan.jks` (gitignored)
See `docker/README.md` for full APK build instructions.
I test usano `SharedPreferences.setMockInitialValues({})` per isolare lo storage.
+66 -26
View File
@@ -1,6 +1,6 @@
# BitePlan
App mobile-first per la gestione della dieta quotidiana — pianificazione pasti, conversione crudo/cotto e lista della spesa.
App Android per la gestione della dieta quotidiana — pianificazione pasti, conversione crudo/cotto e lista della spesa.
## Funzionalità
@@ -8,15 +8,16 @@ App mobile-first per la gestione della dieta quotidiana — pianificazione pasti
- Pianificazione settimanale su 7 giorni × 3 pasti (colazione, pranzo, cena)
- Card accordion per giorno, giorno corrente aperto di default
- Aggiunta e rimozione di voci per ogni pasto
- Generazione automatica della lista della spesa dai pasti pianificati
- Persistenza automatica su LocalStorage
- Generazione automatica della lista della spesa con aggregazione duplicati (es. "zucchine (x2)")
- Condivisione del piano via QR code tra dispositivi
- Persistenza automatica su SharedPreferences
### Convertitore crudo/cotto
- Conversione bidirezionale del peso (crudo → cotto e cotto → crudo)
- Ricerca alimento in tempo reale
- Oltre 50 voci tra cereali, legumi, verdure, carni, pesce e uova
- Fino a 4 metodi di cottura per alimento: bollitura, padella, forno, friggitrice ad aria
- Coefficienti di resa documentati con fonti (CREA, SINU, Istituto Muzzone, USDA)
- Coefficienti di resa documentati con fonti — vedi [docs/conversioni.md](docs/conversioni.md)
### Lista della spesa
- Checklist con aggiunta manuale o importazione dai pasti pianificati
@@ -27,36 +28,75 @@ App mobile-first per la gestione della dieta quotidiana — pianificazione pasti
| Livello | Tecnologia |
|---|---|
| Frontend | Vue 3 + Vite |
| Persistenza | LocalStorage |
| UI | CSS mobile-first (max 480px) |
| Mobile | Capacitor Android |
| Build APK | Docker |
| Framework | Flutter 3.x / Dart 3.x |
| State management | Provider |
| Persistenza | shared_preferences |
| QR code | qr_flutter + mobile_scanner |
| Build APK | Docker (headless) |
## Requisiti per lo sviluppo
## Sviluppo
| Strumento | Versione minima | Note |
|---|---|---|
| Node.js | >= 20.x LTS | testato con v24 |
| npm | 9.x | incluso con Node.js |
| Git | 2.x | |
| Browser | Chrome / Edge / Firefox recente | DevTools modalità mobile consigliati |
Per la build APK Android sono necessari requisiti aggiuntivi — vedi [docker/README.md](docker/README.md).
## Avvio in sviluppo
Il container dev avvia un web server Flutter accessibile dal browser, con hot reload.
```bash
npm install
npm run dev
cd docker/dev
docker compose up
# → http://localhost:5173
```
Aprire [http://localhost:5173](http://localhost:5173) in Chrome con DevTools in modalità mobile (viewport 360×640).
Da un dispositivo mobile sulla stessa rete, aprire `http://<ip-host>:5173`.
Con il container attivo, in un altro terminale:
## Build APK Android
```bash
docker compose attach dev
# r → hot reload | R → hot restart | q → esci
```
Vedi [docker/README.md](docker/README.md) per i requisiti e i dettagli della pipeline.
Vedi [docker/README.md](docker/README.md) per i dettagli e la build APK.
## Test
La suite copre unit test e widget test. Richiede l'immagine Docker `biteplan-build`
(creata automaticamente al primo `bash docker/build/build.sh`).
```bash
# Tutti i test (dalla root del progetto)
docker run --rm -v "$(pwd):/workspace" -w /workspace biteplan-build \
bash -c "flutter pub get && flutter test"
# Un singolo file
docker run --rm -v "$(pwd):/workspace" -w /workspace biteplan-build \
bash -c "flutter test test/features/meal_planner/qr_test.dart"
```
### Struttura
```
test/
├── helpers/
│ └── pump_app.dart # estensione pumpApp per widget test
└── features/
├── converter/
│ ├── models/conversion_entry_test.dart
│ └── providers/converter_provider_test.dart
├── meal_planner/
│ ├── models/meal_plan_test.dart
│ ├── providers/meal_planner_provider_test.dart
│ ├── widgets/meal_card_test.dart
│ └── qr_test.dart
└── shopping_list/
├── models/shopping_item_test.dart
├── providers/shopping_list_provider_test.dart
└── widgets/shopping_item_tile_test.dart
```
## Build APK
```bash
bash docker/build/build.sh # debug → dist/biteplan-debug.apk
bash docker/build/build.sh --release # release → dist/biteplan-release.apk
```
Vedi [docker/README.md](docker/README.md) per i requisiti della firma release.
## Documentazione
+1
View File
@@ -0,0 +1 @@
include: package:flutter_lints/flutter.yaml
Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 MiB

After

Width:  |  Height:  |  Size: 72 KiB

-112
View File
@@ -1,112 +0,0 @@
# ── Android SDK + Node (ambiente di build) ────────────────────────────────────
# eclipse-temurin:21 (Adoptium) su Ubuntu Jammy include Java 21
FROM eclipse-temurin:21-jdk-jammy
# Installa Node.js 20 e dipendenze di sistema
RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
wget \
unzip \
imagemagick \
&& curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
&& apt-get install -y --no-install-recommends nodejs \
&& rm -rf /var/lib/apt/lists/*
# ── Android SDK ─────────────────────────────────────────────────────────────────
ENV ANDROID_HOME=/opt/android-sdk
ENV PATH=$PATH:$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/platform-tools
RUN mkdir -p $ANDROID_HOME/cmdline-tools && \
wget -q https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip \
-O /tmp/cmdline-tools.zip && \
unzip -q /tmp/cmdline-tools.zip -d /tmp && \
mv /tmp/cmdline-tools $ANDROID_HOME/cmdline-tools/latest && \
rm /tmp/cmdline-tools.zip
RUN yes | sdkmanager --licenses > /dev/null && \
sdkmanager \
"platform-tools" \
"platforms;android-34" \
"build-tools;34.0.0"
# ── Progetto (solo dipendenze npm, senza sorgenti) ────────────────────────────
WORKDIR /app
COPY package*.json ./
RUN npm ci
# ── Capacitor config ──────────────────────────────────────────────────────────
RUN cat > capacitor.config.json <<'EOF'
{
"appId": "com.davide.biteplan",
"appName": "BitePlan",
"webDir": "dist",
"server": {
"androidScheme": "https"
}
}
EOF
# ── Installa Capacitor e prepara la piattaforma Android ───────────────────────
RUN npm install @capacitor/core @capacitor/cli @capacitor/android --save
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
# ── Orientamento bloccato in portrait ────────────────────────────────────────
RUN sed -i 's/android:exported="true"/android:exported="true"\n android:screenOrientation="portrait"/' \
android/app/src/main/AndroidManifest.xml
# ── Versione da package.json → android/app/build.gradle ──────────────────────
RUN node -e "\
const v = require('./package.json').version; \
const [a,b,c] = v.split('.').map(Number); \
const vc = a*10000 + b*100 + c; \
const fs = require('fs'); \
let g = fs.readFileSync('android/app/build.gradle','utf8'); \
g = g.replace(/versionCode \d+/, 'versionCode '+vc); \
g = g.replace(/versionName \"[^\"]*\"/, 'versionName \"'+v+'\"'); \
fs.writeFileSync('android/app/build.gradle', g); \
"
# ── Icone Android da assets/icon-only.png (via ImageMagick) ─────────────────
COPY assets/ ./assets/
RUN rm -rf android/app/src/main/res/mipmap-anydpi-v26 && \
for d in mdpi:48 hdpi:72 xhdpi:96 xxhdpi:144 xxxhdpi:192; do \
dest=android/app/src/main/res/mipmap-${d%:*}; \
convert assets/icon-only.png -resize ${d#*:}x${d#*:} $dest/ic_launcher.png; \
cp $dest/ic_launcher.png $dest/ic_launcher_round.png; \
done
# 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
# ── Runtime: dist/ e (opzionale) keystore montati come volumi dall'host ───────
# build.sh esegue: docker run -v ./dist:/app/dist ...
# 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 && \
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 && \
echo "APK debug: /app/dist/biteplan.apk"; \
fi
+96 -64
View File
@@ -1,109 +1,141 @@
# Build APK — Docker
# Docker — BitePlan
Genera un APK Android senza installare nulla sull'host oltre a Docker.
## Requisiti host
| Requisito | Dettaglio |
|-----------|-----------|
| **Architettura** | x86_64 (amd64) — ARM/aarch64 non supportato |
| **OS** | Linux x86_64 o macOS x86_64 |
| **Docker** | Installato e avviato |
| **Node.js** | v18+ (per il build Vite locale) |
> Gli Android build-tools (`aapt2`, `zipalign`, `apksigner`) sono binari nativi x86_64 e non girano su host ARM senza emulazione QEMU.
Due container distinti: uno per lo sviluppo con hot reload, uno per la build APK headless.
Tutti i comandi vanno eseguiti dalla **root del progetto** (`~/biteplan`), non da `docker/`.
---
## Build debug (default)
## Container dev (`docker/dev/`)
APK firmato con il debug keystore di Android. Adatto per test su dispositivo.
Avvia un web server Flutter accessibile dal browser, con hot reload.
```bash
bash docker/build.sh
cd docker/dev
docker compose up
```
Apri **http://localhost:5173** nel browser.
### Hot reload
Con il container attivo, in un altro terminale:
```bash
docker compose attach dev
# r → hot reload | R → hot restart | q → esci
```
Le modifiche ai file `.dart` sull'host sono visibili subito nel container grazie al volume
montato — basta premere `r` per ricaricare.
> Prima esecuzione: scarica l'immagine Flutter (~500 MB), richiede 5-10 minuti.
> Le successive usano la cache Docker e sono molto più rapide.
---
## Build release (distribuzione)
## Container build (`docker/build/`)
APK firmato con il tuo keystore personale. Necessario per distribuire l'app.
Build headless riproducibile per generare l'APK Android.
### 1. Genera il keystore (una volta sola)
### Build debug
APK firmato con il debug keystore di Android — adatto per installazione diretta sul dispositivo.
```bash
bash docker/build/build.sh
# → dist/biteplan-debug.apk
```
### Build release
APK firmato con keystore personale — necessario per la distribuzione.
**1. Genera il keystore (una sola volta)**
```bash
keytool -genkey -v \
-keystore docker/biteplan.jks \
-alias biteplan \
-keyalg RSA \
-keysize 2048 \
-validity 10000
-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.
> `docker/biteplan.jks` è in `.gitignore` e non verrà mai committato.
> Conservalo in un posto sicuro: senza di esso non puoi pubblicare aggiornamenti.
### 2. Esegui la build release
**2. Esegui la build release**
```bash
bash docker/build.sh --release
# chiede interattivamente: Password keystore / Password chiave
bash docker/build/build.sh --release
# → dist/biteplan-release.apk
```
Oppure passando le password come variabili d'ambiente (utile in CI):
Il keystore viene montato nel container come volume read-only e non viene mai copiato
nell'immagine Docker.
> Prima esecuzione: scarica Flutter SDK + Android SDK (~1-2 GB), richiede 10-15 minuti.
### Installazione sul dispositivo
```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
adb install dist/biteplan-debug.apk
# oppure copia manualmente dist/biteplan-release.apk via USB o Drive
```
---
## Flag combinabili
## Test
| 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 |
I test non richiedono il container dev — usano l'immagine `biteplan-build`, creata
automaticamente al primo `bash docker/build/build.sh`.
```bash
# Tutti i test (110 test — unit + widget)
docker run --rm -v "$(pwd):/workspace" -w /workspace biteplan-build \
bash -c "flutter pub get && flutter test"
# Un singolo file
docker run --rm -v "$(pwd):/workspace" -w /workspace biteplan-build \
bash -c "flutter test test/features/meal_planner/qr_test.dart"
# Una singola feature
docker run --rm -v "$(pwd):/workspace" -w /workspace biteplan-build \
bash -c "flutter test test/features/shopping_list/"
```
---
## Prima build
## Prima configurazione (android/ assente)
La prima esecuzione scarica Android SDK (~1 GB) e può richiedere **10-15 minuti**.
Le build successive usano la cache Docker e sono molto più rapide.
Se la cartella `android/` non esiste ancora, lo script `build.sh` la genera
automaticamente tramite `flutter create` all'interno del container — non è necessario
avere Flutter installato sull'host.
## Installazione su dispositivo
---
```bash
adb install dist/biteplan.apk
```
## Pipeline
## Flusso completo
```
[host] npm run build → dist/
[docker] cap sync → copia dist/ in android/assets/
[docker] gradlew assembleDebug/Release
[docker] zipalign + apksigner → solo in modalità release
[host] dist/biteplan.apk
# 1. Sviluppo
cd docker/dev && docker compose up
→ http://localhost:5173 (hot reload con 'r')
# 2. Test
docker run --rm -v "$(pwd):/workspace" -w /workspace biteplan-build \
bash -c "flutter pub get && flutter test"
# 3. Build
bash docker/build/build.sh # → dist/biteplan-debug.apk
bash docker/build/build.sh --release # → dist/biteplan-release.apk
# 4. Installazione
adb install dist/biteplan-debug.apk
```
---
## Note
- App ID: `com.davide.biteplan`
- App ID: `com.biteplan.biteplan`
- Android target: API 34
- Il keystore non viene mai copiato nell'immagine Docker (montato come volume read-only)
- Il keystore non viene mai copiato nell'immagine Docker (volume read-only)
- `dist/` è in `.gitignore`
-87
View File
@@ -1,87 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
DIST_DIR="$PROJECT_ROOT/dist"
FROM_HEAD=false
RELEASE=false
for arg in "$@"; do
[[ "$arg" == "--head" ]] && FROM_HEAD=true
[[ "$arg" == "--release" ]] && RELEASE=true
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 ────────────────────────────────────────────────────────────────
if $FROM_HEAD; then
COMMIT_SHA=$(git -C "$PROJECT_ROOT" rev-parse --short HEAD)
echo "==> Build Vite da HEAD ($COMMIT_SHA)..."
TMPDIR=$(mktemp -d)
trap 'rm -rf "$TMPDIR"' EXIT
git -C "$PROJECT_ROOT" archive HEAD | tar -x -C "$TMPDIR"
cd "$TMPDIR"
npm ci --silent
npm run build --silent
DIST_DIR="$TMPDIR/dist"
cd "$PROJECT_ROOT"
else
echo "==> Build Vite dalla working directory..."
cd "$PROJECT_ROOT"
npm ci --silent
npm run build --silent
fi
# ── Build immagine Docker ─────────────────────────────────────────────────────
echo "==> Build immagine Docker..."
docker build \
-f "$SCRIPT_DIR/Dockerfile" \
-t biteplan-builder \
"$PROJECT_ROOT"
# ── Generazione APK ───────────────────────────────────────────────────────────
echo "==> Generazione APK${RELEASE:+ release}..."
mkdir -p "$PROJECT_ROOT/dist"
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 "APK pronto in: $PROJECT_ROOT/dist/biteplan.apk"
+35
View File
@@ -0,0 +1,35 @@
FROM ubuntu:22.04
ARG ANDROID_SDK_BUILD=11076708
ENV DEBIAN_FRONTEND=noninteractive \
FLUTTER_HOME=/opt/flutter \
ANDROID_SDK_ROOT=/opt/android-sdk \
JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64 \
PATH="/opt/flutter/bin:/opt/android-sdk/cmdline-tools/latest/bin:/opt/android-sdk/platform-tools:${PATH}"
RUN apt-get update && apt-get install -y --no-install-recommends \
curl wget git unzip xz-utils ca-certificates \
openjdk-17-jdk \
&& rm -rf /var/lib/apt/lists/*
RUN git clone --depth 1 --branch stable \
https://github.com/flutter/flutter.git ${FLUTTER_HOME} && \
flutter config --no-analytics && \
flutter precache --android
RUN mkdir -p ${ANDROID_SDK_ROOT}/cmdline-tools && \
wget -q "https://dl.google.com/android/repository/commandlinetools-linux-${ANDROID_SDK_BUILD}_latest.zip" \
-O /tmp/cmdtools.zip && \
unzip -q /tmp/cmdtools.zip -d ${ANDROID_SDK_ROOT}/cmdline-tools && \
mv ${ANDROID_SDK_ROOT}/cmdline-tools/cmdline-tools \
${ANDROID_SDK_ROOT}/cmdline-tools/latest && \
rm /tmp/cmdtools.zip
RUN yes | sdkmanager --licenses && \
sdkmanager \
"platform-tools" \
"platforms;android-34" \
"build-tools;34.0.0"
WORKDIR /workspace
+83
View File
@@ -0,0 +1,83 @@
#!/bin/bash
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)"
RELEASE=false
for arg in "$@"; do
[[ "$arg" == "--release" ]] && RELEASE=true
done
echo "→ Build immagine biteplan-build..."
docker build -t biteplan-build "${SCRIPT_DIR}"
if [[ ! -d "${PROJECT_ROOT}/android" ]]; then
echo "→ Cartella android/ non trovata, eseguo flutter create..."
docker run --rm \
-v "${PROJECT_ROOT}:/workspace" \
biteplan-build \
bash -c "
cd /workspace &&
flutter create --project-name biteplan --org com.biteplan --platforms=android . &&
MANIFEST=android/app/src/main/AndroidManifest.xml &&
grep -q 'CAMERA' \"\$MANIFEST\" ||
sed -i 's|<application|<uses-permission android:name=\"android.permission.CAMERA\" />\n <application|' \"\$MANIFEST\"
"
fi
mkdir -p "${PROJECT_ROOT}/dist"
if [[ "$RELEASE" == "true" ]]; then
KEYSTORE="${SCRIPT_DIR}/../biteplan.jks"
if [[ ! -f "$KEYSTORE" ]]; then
echo "ERRORE: keystore non trovato in docker/biteplan.jks"
echo "Generalo con:"
echo " keytool -genkey -v -keystore docker/biteplan.jks -alias biteplan -keyalg RSA -keysize 2048 -validity 10000"
exit 1
fi
echo "→ Building release APK..."
docker run --rm \
-v "${PROJECT_ROOT}:/workspace" \
-v "${HOME}/.gradle:/root/.gradle" \
biteplan-build \
bash -c "cd /workspace && flutter pub get && flutter pub run flutter_launcher_icons && flutter build apk --release"
echo "→ Zipalign..."
docker run --rm \
-v "${PROJECT_ROOT}:/workspace" \
biteplan-build \
bash -c "zipalign -v -p 4 \
/workspace/build/app/outputs/flutter-apk/app-release.apk \
/workspace/dist/biteplan-aligned.apk"
echo "→ Firma APK..."
docker run --rm \
-v "${PROJECT_ROOT}:/workspace" \
-v "$(realpath "${KEYSTORE%/*}"):/keystore:ro" \
biteplan-build \
bash -c "apksigner sign \
--ks /keystore/biteplan.jks \
--out /workspace/dist/biteplan-release.apk \
/workspace/dist/biteplan-aligned.apk && \
rm -f /workspace/dist/biteplan-aligned.apk && \
apksigner verify --verbose /workspace/dist/biteplan-release.apk"
echo ""
echo "✓ Release APK: dist/biteplan-release.apk"
else
echo "→ Building debug APK..."
docker run --rm \
-v "${PROJECT_ROOT}:/workspace" \
-v "${HOME}/.gradle:/root/.gradle" \
biteplan-build \
bash -c "cd /workspace && flutter pub get && flutter pub run flutter_launcher_icons && flutter build apk --debug"
cp "${PROJECT_ROOT}/build/app/outputs/flutter-apk/app-debug.apk" \
"${PROJECT_ROOT}/dist/biteplan-debug.apk"
echo ""
echo "✓ Debug APK: dist/biteplan-debug.apk"
fi
+16
View File
@@ -0,0 +1,16 @@
FROM ubuntu:22.04
ENV DEBIAN_FRONTEND=noninteractive \
FLUTTER_HOME=/opt/flutter \
PATH="/opt/flutter/bin:${PATH}"
RUN apt-get update && apt-get install -y --no-install-recommends \
curl git unzip xz-utils ca-certificates \
&& rm -rf /var/lib/apt/lists/*
RUN git clone --depth 1 --branch stable \
https://github.com/flutter/flutter.git /opt/flutter && \
flutter config --no-analytics --enable-web && \
flutter precache --web
WORKDIR /workspace
+14
View File
@@ -0,0 +1,14 @@
services:
dev:
build: .
ports:
- "5173:5173"
volumes:
- ../../:/workspace
- pub-cache:/root/.pub-cache
command: bash /workspace/docker/dev/entrypoint.sh
stdin_open: true
tty: true
volumes:
pub-cache:
+18
View File
@@ -0,0 +1,18 @@
#!/bin/bash
set -e
# Aggiunge supporto web se mancante (crea solo web/, non tocca lib/)
if [[ ! -d /workspace/web ]]; then
echo "→ Inizializzazione supporto web..."
flutter create . --platforms=web
fi
# Aggiunge permesso CAMERA se assente (richiesto da mobile_scanner)
MANIFEST=/workspace/android/app/src/main/AndroidManifest.xml
if [[ -f "$MANIFEST" ]] && ! grep -q "CAMERA" "$MANIFEST"; then
echo "→ Aggiunta permesso CAMERA in AndroidManifest.xml..."
sed -i 's|<application|<uses-permission android:name="android.permission.CAMERA" />\n <application|' "$MANIFEST"
fi
flutter pub get
exec flutter run -d web-server --web-hostname=0.0.0.0 --web-port=5173
-13
View File
@@ -1,13 +0,0 @@
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<link rel="icon" type="image/png" href="/favicon.png" />
<title>BitePlan</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
+225
View File
@@ -0,0 +1,225 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:provider/provider.dart';
import 'package:biteplan/app.dart';
import 'package:biteplan/features/meal_planner/providers/meal_planner_provider.dart';
import 'package:biteplan/features/converter/providers/converter_provider.dart';
import 'package:biteplan/features/shopping_list/providers/shopping_list_provider.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
setUp(() async {
SharedPreferences.setMockInitialValues({});
});
Widget buildApp() => MultiProvider(
providers: [
ChangeNotifierProvider(
create: (_) => MealPlannerProvider()..load()),
ChangeNotifierProvider(
create: (_) => ConverterProvider()..loadDb()),
ChangeNotifierProvider(
create: (_) => ShoppingListProvider()..load()),
],
child: const BitePlanApp(),
);
// ── Navigazione ────────────────────────────────────────────────────────────
group('Navigazione', () {
testWidgets('il tab iniziale è Piano Pasti', (tester) async {
await tester.pumpWidget(buildApp());
await tester.pumpAndSettle();
expect(find.text('Piano Pasti'), findsOneWidget);
});
testWidgets('passa al tab Convertitore', (tester) async {
await tester.pumpWidget(buildApp());
await tester.pumpAndSettle();
await tester.tap(find.text('Converti'));
await tester.pumpAndSettle();
expect(find.text('Convertitore'), findsOneWidget);
});
testWidgets('passa al tab Lista della spesa', (tester) async {
await tester.pumpWidget(buildApp());
await tester.pumpAndSettle();
await tester.tap(find.text('Spesa'));
await tester.pumpAndSettle();
expect(find.text('Lista della spesa'), findsOneWidget);
});
testWidgets('naviga tra tutti e tre i tab', (tester) async {
await tester.pumpWidget(buildApp());
await tester.pumpAndSettle();
await tester.tap(find.text('Converti'));
await tester.pumpAndSettle();
expect(find.text('Convertitore'), findsOneWidget);
await tester.tap(find.text('Spesa'));
await tester.pumpAndSettle();
expect(find.text('Lista della spesa'), findsOneWidget);
await tester.tap(find.text('Pasti'));
await tester.pumpAndSettle();
expect(find.text('Piano Pasti'), findsOneWidget);
});
});
// ── Lista della spesa ──────────────────────────────────────────────────────
group('Lista della spesa', () {
testWidgets('aggiunge un elemento e lo mostra', (tester) async {
await tester.pumpWidget(buildApp());
await tester.pumpAndSettle();
await tester.tap(find.text('Spesa'));
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField), 'Pollo');
await tester.testTextInput.receiveAction(TextInputAction.done);
await tester.pumpAndSettle();
expect(find.text('Pollo'), findsOneWidget);
});
testWidgets('rimuove un elemento con il pulsante chiudi', (tester) async {
await tester.pumpWidget(buildApp());
await tester.pumpAndSettle();
await tester.tap(find.text('Spesa'));
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField), 'Riso');
await tester.testTextInput.receiveAction(TextInputAction.done);
await tester.pumpAndSettle();
await tester.tap(find.byIcon(Icons.close).first);
await tester.pumpAndSettle();
expect(find.text('Riso'), findsNothing);
});
testWidgets('spuntare un elemento lo sposta in Completati', (tester) async {
await tester.pumpWidget(buildApp());
await tester.pumpAndSettle();
await tester.tap(find.text('Spesa'));
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField), 'Pasta');
await tester.testTextInput.receiveAction(TextInputAction.done);
await tester.pumpAndSettle();
await tester.tap(find.byType(Checkbox).first);
await tester.pumpAndSettle();
expect(find.text('COMPLETATI (1)'), findsOneWidget);
});
testWidgets('aggiorna il contatore completati/totale', (tester) async {
await tester.pumpWidget(buildApp());
await tester.pumpAndSettle();
await tester.tap(find.text('Spesa'));
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField), 'Pollo');
await tester.testTextInput.receiveAction(TextInputAction.done);
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField), 'Riso');
await tester.testTextInput.receiveAction(TextInputAction.done);
await tester.pumpAndSettle();
await tester.tap(find.byType(Checkbox).first);
await tester.pumpAndSettle();
expect(find.text('1 / 2 completato'), findsOneWidget);
});
});
// ── Convertitore ──────────────────────────────────────────────────────────
group('Convertitore', () {
testWidgets('mostra hint iniziale', (tester) async {
await tester.pumpWidget(buildApp());
await tester.pumpAndSettle();
await tester.tap(find.text('Converti'));
await tester.pumpAndSettle();
expect(find.text('Cerca un alimento per iniziare'), findsOneWidget);
});
testWidgets('la ricerca mostra risultati pertinenti', (tester) async {
await tester.pumpWidget(buildApp());
await tester.pumpAndSettle();
await tester.tap(find.text('Converti'));
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField), 'pollo');
await tester.pumpAndSettle();
expect(find.textContaining('Pollo'), findsWidgets);
});
testWidgets('selezionare un alimento mostra la card del convertitore',
(tester) async {
await tester.pumpWidget(buildApp());
await tester.pumpAndSettle();
await tester.tap(find.text('Converti'));
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField), 'riso basmati');
await tester.pumpAndSettle();
await tester.tap(find.textContaining('Riso basmati').first);
await tester.pumpAndSettle();
expect(find.text('CRUDO'), findsOneWidget);
expect(find.text('COTTO'), findsOneWidget);
expect(find.text('Cambia'), findsOneWidget);
});
testWidgets('il pulsante Cambia resetta la selezione', (tester) async {
await tester.pumpWidget(buildApp());
await tester.pumpAndSettle();
await tester.tap(find.text('Converti'));
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField), 'riso basmati');
await tester.pumpAndSettle();
await tester.tap(find.textContaining('Riso basmati').first);
await tester.pumpAndSettle();
await tester.tap(find.text('Cambia'));
await tester.pumpAndSettle();
expect(find.text('Cerca un alimento per iniziare'), findsOneWidget);
});
});
// ── Meal Planner ───────────────────────────────────────────────────────────
group('Piano Pasti', () {
testWidgets('genera lista della spesa e naviga al tab Spesa',
(tester) async {
await tester.pumpWidget(buildApp());
await tester.pumpAndSettle();
// Trova il giorno corrente espanso e aggiungi un elemento
final addButtons = find.byIcon(Icons.add);
if (addButtons.evaluate().isNotEmpty) {
final firstTextField = find.byType(TextField).first;
await tester.enterText(firstTextField, 'Broccoli');
await tester.testTextInput.receiveAction(TextInputAction.done);
await tester.pumpAndSettle();
}
await tester.tap(find.text('Genera lista della spesa'));
await tester.pumpAndSettle();
// Deve essere atterrato sul tab Spesa
expect(find.text('Lista della spesa'), findsOneWidget);
});
});
}
+87
View File
@@ -0,0 +1,87 @@
import 'package:flutter/material.dart';
import 'core/theme/app_theme.dart';
import 'features/meal_planner/presentation/pages/meal_planner_page.dart';
import 'features/converter/presentation/pages/converter_page.dart';
import 'features/shopping_list/presentation/pages/shopping_list_page.dart';
import 'features/guide/presentation/widgets/info_bottom_sheet.dart';
class BitePlanApp extends StatelessWidget {
const BitePlanApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'BitePlan',
theme: AppTheme.light(),
debugShowCheckedModeBanner: false,
home: const _MainScaffold(),
);
}
}
class _MainScaffold extends StatefulWidget {
const _MainScaffold();
@override
State<_MainScaffold> createState() => _MainScaffoldState();
}
class _MainScaffoldState extends State<_MainScaffold> {
int _currentIndex = 0;
late final List<Widget> _pages;
static const _titles = ['Piano Pasti', 'Convertitore', 'Lista della spesa'];
@override
void initState() {
super.initState();
_pages = [
MealPlannerPage(
onGoShopping: () => setState(() => _currentIndex = 2),
),
const ConverterPage(),
const ShoppingListPage(),
];
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(_titles[_currentIndex]),
actions: [
IconButton(
icon: const Icon(Icons.info_outline),
tooltip: 'Informazioni',
onPressed: () => showInfoBottomSheet(context),
),
],
),
body: IndexedStack(
index: _currentIndex,
children: _pages,
),
bottomNavigationBar: NavigationBar(
selectedIndex: _currentIndex,
onDestinationSelected: (i) => setState(() => _currentIndex = i),
destinations: const [
NavigationDestination(
icon: Icon(Icons.restaurant_menu_outlined),
selectedIcon: Icon(Icons.restaurant_menu),
label: 'Pasti',
),
NavigationDestination(
icon: Icon(Icons.swap_horiz_outlined),
selectedIcon: Icon(Icons.swap_horiz),
label: 'Converti',
),
NavigationDestination(
icon: Icon(Icons.shopping_cart_outlined),
selectedIcon: Icon(Icons.shopping_cart),
label: 'Spesa',
),
],
),
);
}
}
+34
View File
@@ -0,0 +1,34 @@
const String kAppVersion = '2.0.0';
const String kAppAuthor = 'Davide Grilli';
const String kAppLicense = 'EUPL v1.2';
const List<String> kDayIds = [
'lunedi',
'martedi',
'mercoledi',
'giovedi',
'venerdi',
'sabato',
'domenica',
];
const Map<String, String> kDayLabels = {
'lunedi': 'Lunedì',
'martedi': 'Martedì',
'mercoledi': 'Mercoledì',
'giovedi': 'Giovedì',
'venerdi': 'Venerdì',
'sabato': 'Sabato',
'domenica': 'Domenica',
};
const List<String> kMealSlots = ['colazione', 'pranzo', 'cena'];
const Map<String, String> kMealSlotLabels = {
'colazione': 'Colazione',
'pranzo': 'Pranzo',
'cena': 'Cena',
};
const String kStorageKeyMeals = 'meals';
const String kStorageKeyShopping = 'shopping_list';
+24
View File
@@ -0,0 +1,24 @@
import 'package:flutter/material.dart';
class AppTheme {
static const _seed = Color(0xFF2d6a4f);
static ThemeData light() {
final scheme = ColorScheme.fromSeed(seedColor: _seed);
return ThemeData(
useMaterial3: true,
colorScheme: scheme,
cardTheme: CardThemeData(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(color: scheme.outlineVariant),
),
),
inputDecorationTheme: InputDecorationTheme(
border: OutlineInputBorder(borderRadius: BorderRadius.circular(10)),
contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
),
);
}
}
@@ -0,0 +1,14 @@
class ConversionEntry {
final String food;
final String method;
final double yieldFactor;
const ConversionEntry({
required this.food,
required this.method,
required this.yieldFactor,
});
double rawToCooked(double raw) => raw * yieldFactor;
double cookedToRaw(double cooked) => cooked / yieldFactor;
}
@@ -0,0 +1,381 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../models/conversion_entry.dart';
import '../../providers/converter_provider.dart';
import '../../../../shared/widgets/empty_state.dart';
class ConverterPage extends StatefulWidget {
const ConverterPage({super.key});
@override
State<ConverterPage> createState() => _ConverterPageState();
}
class _ConverterPageState extends State<ConverterPage> {
final _searchController = TextEditingController();
final _gramsController = TextEditingController();
@override
void dispose() {
_searchController.dispose();
_gramsController.dispose();
super.dispose();
}
void _onSelect(ConversionEntry entry) {
_searchController.clear();
_gramsController.clear();
context.read<ConverterProvider>().select(entry);
}
void _onReset() {
_gramsController.clear();
context.read<ConverterProvider>().reset();
}
void _onSwap() {
_gramsController.clear();
context.read<ConverterProvider>().swapDirection();
}
String _cap(String s) =>
s.isEmpty ? s : s[0].toUpperCase() + s.substring(1);
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final provider = context.watch<ConverterProvider>();
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 56, 16, 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Convertitore',
style: theme.textTheme.headlineSmall
?.copyWith(fontWeight: FontWeight.bold),
),
Text(
'Calcola il peso cotto dal crudo e viceversa',
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurface.withValues(alpha: 0.6),
),
),
],
),
),
Expanded(
child: ListView(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
children: [
if (provider.selected == null) ...[
TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: 'Cerca alimento…',
prefixIcon: const Icon(Icons.search),
suffixIcon: _searchController.text.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
_searchController.clear();
context.read<ConverterProvider>().search('');
},
)
: null,
),
onChanged: (v) {
context.read<ConverterProvider>().search(v);
},
),
if (provider.results.isNotEmpty) ...[
const SizedBox(height: 8),
Card(
margin: EdgeInsets.zero,
child: Column(
children: [
for (int i = 0; i < provider.results.length; i++) ...[
if (i > 0) const Divider(height: 1),
ListTile(
title: Text(
_cap(provider.results[i].food),
style: const TextStyle(fontWeight: FontWeight.w600),
),
trailing: Text(
_cap(provider.results[i].method),
style: TextStyle(
color: theme.colorScheme.onSurface
.withValues(alpha: 0.55),
fontSize: 13,
),
),
onTap: () => _onSelect(provider.results[i]),
dense: true,
),
],
],
),
),
],
if (provider.results.isEmpty &&
_searchController.text.isEmpty)
const Padding(
padding: EdgeInsets.only(top: 48),
child: EmptyState(
icon: Icons.search,
title: 'Cerca un alimento per iniziare',
subtitle: 'es. pollo, riso, zucchine',
),
),
if (provider.results.isEmpty &&
_searchController.text.isNotEmpty)
const Padding(
padding: EdgeInsets.only(top: 48),
child: EmptyState(
icon: Icons.search_off,
title: 'Nessun risultato',
),
),
],
if (provider.selected != null)
_ConverterCard(
provider: provider,
gramsController: _gramsController,
onReset: _onReset,
onSwap: _onSwap,
onGramsChanged: (v) => context
.read<ConverterProvider>()
.setGrams(double.tryParse(v)),
),
],
),
),
],
);
}
}
class _ConverterCard extends StatelessWidget {
final ConverterProvider provider;
final TextEditingController gramsController;
final VoidCallback onReset;
final VoidCallback onSwap;
final void Function(String) onGramsChanged;
const _ConverterCard({
required this.provider,
required this.gramsController,
required this.onReset,
required this.onSwap,
required this.onGramsChanged,
});
String _cap(String s) =>
s.isEmpty ? s : s[0].toUpperCase() + s.substring(1);
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final selected = provider.selected!;
final result = provider.result;
final isRtC = provider.rawToCooked;
final muted = theme.colorScheme.onSurface.withValues(alpha: 0.5);
return Card(
margin: EdgeInsets.zero,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header row
Padding(
padding: const EdgeInsets.fromLTRB(16, 4, 4, 4),
child: Row(
children: [
Expanded(
child: Wrap(
crossAxisAlignment: WrapCrossAlignment.center,
spacing: 4,
children: [
Text(
_cap(selected.food),
style: theme.textTheme.titleSmall
?.copyWith(fontWeight: FontWeight.bold),
),
Text('·',
style: TextStyle(
color: theme.colorScheme.outline, fontSize: 16)),
Text(
_cap(selected.method),
style: theme.textTheme.bodyMedium?.copyWith(color: muted),
),
],
),
),
TextButton(onPressed: onReset, child: const Text('Cambia')),
],
),
),
const Divider(height: 1),
// Calculator
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
// Input
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
isRtC ? 'CRUDO' : 'COTTO',
style: theme.textTheme.labelSmall?.copyWith(
fontWeight: FontWeight.w700,
letterSpacing: 0.8,
color: muted,
),
),
const SizedBox(height: 4),
Row(
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
children: [
Expanded(
child: TextField(
controller: gramsController,
keyboardType:
const TextInputType.numberWithOptions(
decimal: true),
style: theme.textTheme.headlineMedium
?.copyWith(fontWeight: FontWeight.bold),
decoration: const InputDecoration(
hintText: '0',
border: UnderlineInputBorder(),
enabledBorder: UnderlineInputBorder(),
focusedBorder: UnderlineInputBorder(),
contentPadding: EdgeInsets.zero,
),
onChanged: onGramsChanged,
),
),
const SizedBox(width: 4),
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Text('g',
style: theme.textTheme.titleMedium
?.copyWith(color: muted)),
),
],
),
],
),
),
// Swap button
Padding(
padding:
const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
child: IconButton.outlined(
onPressed: onSwap,
icon: const Icon(Icons.swap_horiz),
style: IconButton.styleFrom(
minimumSize: const Size(44, 44)),
),
),
// Output
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
isRtC ? 'COTTO' : 'CRUDO',
style: theme.textTheme.labelSmall?.copyWith(
fontWeight: FontWeight.w700,
letterSpacing: 0.8,
color: muted,
),
),
const SizedBox(height: 4),
Container(
padding: const EdgeInsets.only(bottom: 8),
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: theme.colorScheme.outline
.withValues(alpha: 0.4),
),
),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
children: [
Expanded(
child: Text(
result != null
? _formatResult(result)
: '',
style: result != null
? theme.textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.bold,
color: theme.colorScheme.primary,
)
: theme.textTheme.headlineMedium?.copyWith(
color: theme.colorScheme.onSurface
.withValues(alpha: 0.2),
fontWeight: FontWeight.w300,
),
),
),
if (result != null) ...[
const SizedBox(width: 4),
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Text('g',
style: theme.textTheme.titleMedium
?.copyWith(color: muted)),
),
],
],
),
),
],
),
),
],
),
),
// Footer
if (result != null)
Container(
width: double.infinity,
padding: const EdgeInsets.fromLTRB(16, 8, 16, 12),
decoration: BoxDecoration(
border: Border(
top: BorderSide(
color: theme.colorScheme.outline.withValues(alpha: 0.2),
),
),
),
child: Text(
'fattore di resa ${selected.yieldFactor} · '
'${isRtC ? 'da mangiare cotti' : 'peso crudo equivalente'}',
style: theme.textTheme.bodySmall?.copyWith(color: muted),
textAlign: TextAlign.center,
),
),
],
),
);
}
String _formatResult(double v) =>
v == v.truncateToDouble() ? v.toInt().toString() : v.toString();
}
@@ -0,0 +1,77 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../models/conversion_entry.dart';
class ConverterProvider extends ChangeNotifier {
List<ConversionEntry> _db = [];
List<ConversionEntry> _results = [];
ConversionEntry? _selected;
double? _grams;
bool _rawToCooked = true;
List<ConversionEntry> get results => _results;
ConversionEntry? get selected => _selected;
double? get grams => _grams;
bool get rawToCooked => _rawToCooked;
double? get result {
if (_selected == null || _grams == null || _grams! <= 0) return null;
final v = _rawToCooked
? _selected!.rawToCooked(_grams!)
: _selected!.cookedToRaw(_grams!);
return (v * 10).roundToDouble() / 10;
}
Future<void> loadDb() async {
final raw = await rootBundle.loadString('assets/data/conversions.json');
final Map<String, dynamic> json = jsonDecode(raw);
_db = [
for (final food in json.keys)
for (final method in (json[food] as Map).keys)
ConversionEntry(
food: food,
method: method,
yieldFactor:
(json[food][method]['yield'] as num).toDouble(),
),
];
notifyListeners();
}
void search(String q) {
if (q.trim().isEmpty) {
_results = [];
} else {
final lower = q.toLowerCase();
_results = _db.where((e) => e.food.contains(lower)).toList();
}
notifyListeners();
}
void select(ConversionEntry entry) {
_selected = entry;
_results = [];
_grams = null;
notifyListeners();
}
void setGrams(double? value) {
_grams = value;
notifyListeners();
}
void swapDirection() {
_rawToCooked = !_rawToCooked;
_grams = null;
notifyListeners();
}
void reset() {
_selected = null;
_results = [];
_grams = null;
_rawToCooked = true;
notifyListeners();
}
}
@@ -0,0 +1,334 @@
import 'package:flutter/material.dart';
import '../../../../core/constants/app_constants.dart';
class GuidePage extends StatelessWidget {
const GuidePage({super.key});
@override
Widget build(BuildContext context) {
return DefaultTabController(
length: 3,
child: Scaffold(
appBar: AppBar(
title: const Text('Guida'),
bottom: TabBar(
tabs: const [
Tab(text: 'Pasti'),
Tab(text: 'Converti'),
Tab(text: 'Spesa'),
],
labelColor: Theme.of(context).colorScheme.primary,
indicatorColor: Theme.of(context).colorScheme.primary,
),
),
body: const TabBarView(
children: [
_PastiTab(),
_ConvertiTab(),
_SpesaTab(),
],
),
),
);
}
}
// ── Pasti ────────────────────────────────────────────────────────────────────
class _PastiTab extends StatelessWidget {
const _PastiTab();
@override
Widget build(BuildContext context) {
return ListView(
padding: const EdgeInsets.all(16),
children: const [
_DocCard(
title: 'Aggiungere un alimento',
child: _Steps(steps: [
'Tocca il giorno per espandere la card',
'Scegli il pasto: Colazione, Pranzo o Cena',
'Scrivi il nome nel campo di testo',
'Premi + o Invio per aggiungerlo',
]),
),
_TipCard(
text:
'Premi "Genera lista della spesa" in fondo alla pagina per importare automaticamente tutti gli alimenti della settimana, senza duplicati.',
),
_DocCard(
title: 'Rimuovere un alimento',
child: _Body(
text:
'Tocca il pulsante × a destra dell\'elemento per eliminarlo.'),
),
_DocCard(
title: 'Salvataggio automatico',
child: _Body(
text:
'I dati vengono salvati automaticamente sul dispositivo. Non serve premere nessun tasto.'),
),
],
);
}
}
// ── Converti ─────────────────────────────────────────────────────────────────
class _ConvertiTab extends StatelessWidget {
const _ConvertiTab();
static const _categories = [
('Cereali e pasta', 'Riso (4 varietà), pasta, farro, orzo, quinoa, cous cous'),
('Legumi secchi', 'Ceci, fagioli, lenticchie'),
('Verdure', 'Carote, zucchine, patate, spinaci, broccoli, asparagi e altri'),
('Carni', 'Pollo petto, tacchino fesa, hamburger, vitello'),
('Pesce', 'Tonno, merluzzo, spigola, sogliola'),
('Uova', 'Uovo al tegamino, frittata'),
];
static const _methods = ['Bollitura', 'Padella', 'Forno', 'Friggitrice ad aria'];
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return ListView(
padding: const EdgeInsets.all(16),
children: [
const _DocCard(
title: 'Come usarlo',
child: _Steps(steps: [
'Cerca l\'alimento nel campo (es. pollo, riso)',
'Seleziona il metodo di cottura dall\'elenco',
'Inserisci il peso in grammi',
'Il risultato appare in tempo reale',
'Premi il pulsante "Cambia" per invertire crudo / cotto',
]),
),
_DocCard(
title: 'Alimenti disponibili',
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
decoration: BoxDecoration(
border: Border.all(color: scheme.outlineVariant),
borderRadius: BorderRadius.circular(8),
),
child: Column(
children: _categories.indexed.map((entry) {
final (i, cat) = entry;
return Container(
decoration: BoxDecoration(
border: i < _categories.length - 1
? Border(
bottom: BorderSide(color: scheme.outlineVariant))
: null,
),
padding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 9),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 110,
child: Text(cat.$1,
style: const TextStyle(
fontWeight: FontWeight.w600,
fontSize: 13)),
),
const SizedBox(width: 8),
Expanded(
child: Text(cat.$2,
style: TextStyle(
fontSize: 12,
color: scheme.onSurfaceVariant)),
),
],
),
);
}).toList(),
),
),
const SizedBox(height: 12),
Wrap(
spacing: 6,
runSpacing: 6,
children: _methods
.map((m) => Chip(
label: Text(m,
style: const TextStyle(fontSize: 12)),
padding: EdgeInsets.zero,
visualDensity: VisualDensity.compact,
))
.toList(),
),
],
),
),
],
);
}
}
// ── Spesa ─────────────────────────────────────────────────────────────────────
class _SpesaTab extends StatelessWidget {
const _SpesaTab();
@override
Widget build(BuildContext context) {
return ListView(
padding: const EdgeInsets.all(16),
children: const [
_DocCard(
title: 'Aggiungere un elemento',
child: _Body(
text: 'Scrivi il nome nel campo in alto e premi + o Invio.'),
),
_TipCard(
text:
'Vai alla tab Pasti e premi "Genera lista della spesa" per importare automaticamente gli alimenti pianificati, senza duplicati.',
),
_DocCard(
title: 'Spuntare un elemento',
child: _Body(
text:
'Tocca la casella a sinistra. Gli elementi completati vengono spostati in una sezione separata con testo barrato.'),
),
_DocCard(
title: 'Rimuovere / svuotare',
child: _Body(
text:
'Tocca × per rimuovere un singolo elemento, oppure "Svuota lista" in fondo per eliminare tutto (richiede conferma).'),
),
],
);
}
}
// ── Componenti condivisi ──────────────────────────────────────────────────────
class _DocCard extends StatelessWidget {
final String title;
final Widget child;
const _DocCard({required this.title, required this.child});
@override
Widget build(BuildContext context) {
return Card(
margin: const EdgeInsets.only(bottom: 10),
child: Padding(
padding: const EdgeInsets.all(14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title.toUpperCase(),
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w700,
letterSpacing: 0.7,
color: Theme.of(context).colorScheme.onSurfaceVariant)),
const SizedBox(height: 10),
child,
],
),
),
);
}
}
class _TipCard extends StatelessWidget {
final String text;
const _TipCard({required this.text});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return Container(
margin: const EdgeInsets.only(bottom: 10),
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: scheme.primaryContainer.withValues(alpha: 0.4),
border: Border(left: BorderSide(color: scheme.primary, width: 3)),
borderRadius: BorderRadius.circular(8),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.info_outline, size: 14, color: scheme.primary),
const SizedBox(width: 5),
Text('SUGGERIMENTO',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w700,
letterSpacing: 0.7,
color: scheme.primary)),
],
),
const SizedBox(height: 6),
Text(text, style: const TextStyle(fontSize: 14, height: 1.5)),
],
),
);
}
}
class _Steps extends StatelessWidget {
final List<String> steps;
const _Steps({required this.steps});
@override
Widget build(BuildContext context) {
return Column(
children: steps.indexed.map((entry) {
final (i, step) = entry;
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 22,
height: 22,
alignment: Alignment.center,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary,
shape: BoxShape.circle,
),
child: Text('${i + 1}',
style: const TextStyle(
color: Colors.white,
fontSize: 11,
fontWeight: FontWeight.bold)),
),
const SizedBox(width: 10),
Expanded(
child: Padding(
padding: const EdgeInsets.only(top: 2),
child: Text(step,
style:
const TextStyle(fontSize: 14, height: 1.5)),
)),
],
),
);
}).toList(),
);
}
}
class _Body extends StatelessWidget {
final String text;
const _Body({required this.text});
@override
Widget build(BuildContext context) {
return Text(text, style: const TextStyle(fontSize: 14, height: 1.5));
}
}
// ignore footer version — accessed via kAppVersion constant
String get _footerText => 'BitePlan · v$kAppVersion · $kAppAuthor';
@@ -0,0 +1,164 @@
import 'package:flutter/material.dart';
import '../../../../core/constants/app_constants.dart';
import '../pages/guide_page.dart';
void showInfoBottomSheet(BuildContext context) {
showModalBottomSheet(
context: context,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (_) => const _InfoSheet(),
);
}
class _InfoSheet extends StatelessWidget {
const _InfoSheet();
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 12, 20, 24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// handle
Container(
width: 36,
height: 4,
decoration: BoxDecoration(
color: scheme.outlineVariant,
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(height: 20),
// icona + nome + versione
Row(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Image.asset(
'assets/icon-only.png',
width: 52,
height: 52,
fit: BoxFit.cover,
),
),
const SizedBox(width: 14),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('BitePlan',
style: Theme.of(context)
.textTheme
.titleMedium
?.copyWith(fontWeight: FontWeight.bold)),
Text('Versione $kAppVersion',
style: Theme.of(context)
.textTheme
.bodySmall
?.copyWith(color: scheme.onSurfaceVariant)),
],
),
],
),
const SizedBox(height: 20),
// righe info
_InfoCard(children: [
_InfoRow(label: 'Autore', value: kAppAuthor),
_InfoRow(label: 'Licenza', value: kAppLicense),
_InfoRowButton(
label: 'Guida',
onTap: () {
Navigator.pop(context);
Navigator.push(
context,
MaterialPageRoute(builder: (_) => const GuidePage()),
);
},
),
]),
],
),
),
);
}
}
class _InfoCard extends StatelessWidget {
final List<Widget> children;
const _InfoCard({required this.children});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return Container(
decoration: BoxDecoration(
border: Border.all(color: scheme.outlineVariant),
borderRadius: BorderRadius.circular(12),
),
child: Column(children: children),
);
}
}
class _InfoRow extends StatelessWidget {
final String label;
final String value;
const _InfoRow({required this.label, required this.value});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 13),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(label, style: Theme.of(context).textTheme.bodyMedium),
Text(value,
style: Theme.of(context)
.textTheme
.bodyMedium
?.copyWith(color: scheme.onSurfaceVariant)),
],
),
);
}
}
class _InfoRowButton extends StatelessWidget {
final String label;
final VoidCallback onTap;
const _InfoRowButton({required this.label, required this.onTap});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return InkWell(
onTap: onTap,
borderRadius: const BorderRadius.vertical(bottom: Radius.circular(12)),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 13),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(label, style: Theme.of(context).textTheme.bodyMedium),
Row(
children: [
Text('Apri',
style: TextStyle(
color: scheme.primary, fontWeight: FontWeight.w600)),
const SizedBox(width: 4),
Icon(Icons.chevron_right, size: 18, color: scheme.primary),
],
),
],
),
),
);
}
}
@@ -0,0 +1,81 @@
import 'dart:convert';
class DayPlan {
final List<String> colazione;
final List<String> pranzo;
final List<String> cena;
DayPlan({
List<String>? colazione,
List<String>? pranzo,
List<String>? cena,
}) : colazione = colazione ?? [],
pranzo = pranzo ?? [],
cena = cena ?? [];
DayPlan copyWith({
List<String>? colazione,
List<String>? pranzo,
List<String>? cena,
}) =>
DayPlan(
colazione: colazione ?? List.of(this.colazione),
pranzo: pranzo ?? List.of(this.pranzo),
cena: cena ?? List.of(this.cena),
);
List<String> slot(String name) => switch (name) {
'colazione' => colazione,
'pranzo' => pranzo,
'cena' => cena,
_ => const [],
};
Map<String, dynamic> toJson() => {
'colazione': colazione,
'pranzo': pranzo,
'cena': cena,
};
factory DayPlan.fromJson(Map<String, dynamic> json) => DayPlan(
colazione: List<String>.from(json['colazione'] ?? []),
pranzo: List<String>.from(json['pranzo'] ?? []),
cena: List<String>.from(json['cena'] ?? []),
);
}
class MealPlan {
final Map<String, DayPlan> days;
const MealPlan(this.days);
factory MealPlan.empty(List<String> dayIds) =>
MealPlan({for (final d in dayIds) d: DayPlan()});
MealPlan withUpdatedDay(String dayId, DayPlan updated) =>
MealPlan({...days, dayId: updated});
bool get hasAnyMeal => days.values.any(
(d) =>
d.colazione.isNotEmpty ||
d.pranzo.isNotEmpty ||
d.cena.isNotEmpty,
);
List<String> get allItems => days.values
.expand((d) => [...d.colazione, ...d.pranzo, ...d.cena])
.toList();
Map<String, dynamic> toJson() =>
{for (final e in days.entries) e.key: e.value.toJson()};
factory MealPlan.fromJson(Map<String, dynamic> json) => MealPlan({
for (final e in json.entries)
e.key: DayPlan.fromJson(e.value as Map<String, dynamic>),
});
String toJsonString() => jsonEncode(toJson());
factory MealPlan.fromJsonString(String s) =>
MealPlan.fromJson(jsonDecode(s) as Map<String, dynamic>);
}
@@ -0,0 +1,173 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../providers/meal_planner_provider.dart';
import '../widgets/meal_card.dart';
import '../widgets/qr_share_sheet.dart';
import '../pages/qr_scan_page.dart';
import '../../../shopping_list/providers/shopping_list_provider.dart';
import '../../../../core/constants/app_constants.dart';
class MealPlannerPage extends StatelessWidget {
final VoidCallback onGoShopping;
const MealPlannerPage({super.key, required this.onGoShopping});
String get _todayId {
const ids = [
'',
'lunedi',
'martedi',
'mercoledi',
'giovedi',
'venerdi',
'sabato',
'domenica',
];
return ids[DateTime.now().weekday];
}
String get _todayFormatted {
final now = DateTime.now();
const months = [
'gennaio', 'febbraio', 'marzo', 'aprile', 'maggio', 'giugno',
'luglio', 'agosto', 'settembre', 'ottobre', 'novembre', 'dicembre',
];
const weekdays = [
'', 'lunedì', 'martedì', 'mercoledì', 'giovedì', 'venerdì', 'sabato', 'domenica',
];
return 'Oggi, ${weekdays[now.weekday]} ${now.day} ${months[now.month - 1]}';
}
Future<void> _confirmClear(BuildContext context) async {
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Svuota piano'),
content: const Text('Svuotare tutto il piano settimanale?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Annulla'),
),
TextButton(
onPressed: () => Navigator.pop(ctx, true),
style: TextButton.styleFrom(
foregroundColor: Theme.of(ctx).colorScheme.error,
),
child: const Text('Svuota'),
),
],
),
);
if (ok == true && context.mounted) {
context.read<MealPlannerProvider>().clearAll();
}
}
void _generateShopping(BuildContext context) {
final plan = context.read<MealPlannerProvider>().plan;
context.read<ShoppingListProvider>().addAll(plan.allItems);
onGoShopping();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final provider = context.watch<MealPlannerProvider>();
final todayId = _todayId;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 56, 16, 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Piano Pasti',
style: theme.textTheme.headlineSmall
?.copyWith(fontWeight: FontWeight.bold),
),
Text(
_todayFormatted,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurface.withValues(alpha: 0.6),
),
),
],
),
),
Expanded(
child: ListView(
padding: const EdgeInsets.fromLTRB(16, 4, 16, 16),
children: [
for (final dayId in kDayIds)
MealCard(
key: ValueKey(dayId),
dayId: dayId,
dayLabel: kDayLabels[dayId]!,
dayPlan: provider.plan.days[dayId] ??
(throw StateError('Day $dayId not found')),
initiallyExpanded: dayId == todayId,
onAdd: (slot, text) =>
context.read<MealPlannerProvider>().addItem(dayId, slot, text),
onRemove: (slot, index) =>
context.read<MealPlannerProvider>().removeItem(dayId, slot, index),
),
const SizedBox(height: 8),
FilledButton.icon(
onPressed: () => _generateShopping(context),
icon: const Icon(Icons.shopping_cart_outlined),
label: const Text('Genera lista della spesa'),
style: FilledButton.styleFrom(
minimumSize: const Size(double.infinity, 52),
),
),
if (provider.plan.hasAnyMeal) ...[
const SizedBox(height: 8),
OutlinedButton(
onPressed: () => _confirmClear(context),
style: OutlinedButton.styleFrom(
foregroundColor: theme.colorScheme.error,
side: BorderSide(
color: theme.colorScheme.error.withValues(alpha: 0.4),
),
minimumSize: const Size(double.infinity, 48),
),
child: const Text('Svuota piano'),
),
],
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: provider.plan.hasAnyMeal
? () => showQrShareSheet(context, provider.plan)
: null,
icon: const Icon(Icons.qr_code, size: 18),
label: const Text('Condividi'),
),
),
const SizedBox(width: 8),
Expanded(
child: OutlinedButton.icon(
onPressed: () => Navigator.push(
context,
MaterialPageRoute(
builder: (_) => const QrScanPage()),
),
icon: const Icon(Icons.qr_code_scanner, size: 18),
label: const Text('Ricevi'),
),
),
],
),
],
),
),
],
);
}
}
@@ -0,0 +1,86 @@
import 'package:flutter/material.dart';
import 'package:mobile_scanner/mobile_scanner.dart';
import 'package:provider/provider.dart';
import '../../providers/meal_planner_provider.dart';
import '../../qr_codec.dart';
class QrScanPage extends StatefulWidget {
const QrScanPage({super.key});
@override
State<QrScanPage> createState() => _QrScanPageState();
}
class _QrScanPageState extends State<QrScanPage> {
bool _handled = false;
void _onDetect(BarcodeCapture capture, BuildContext context) {
if (_handled) return;
final raw = capture.barcodes.firstOrNull?.rawValue;
if (raw == null) return;
setState(() => _handled = true);
final (plan, error) = parseMealPlanFromQr(raw);
if (error != null) {
_showError(context, error);
return;
}
context.read<MealPlannerProvider>().importPlan(plan!);
if (context.mounted) {
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Piano ricevuto!')),
);
}
}
void _showError(BuildContext context, String msg) {
if (!context.mounted) return;
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(msg),
backgroundColor: Theme.of(context).colorScheme.error,
),
);
}
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return Scaffold(
appBar: AppBar(title: const Text('Scansiona QR')),
body: Stack(
children: [
MobileScanner(
onDetect: (capture) => _onDetect(capture, context),
),
// cornice di mira
Center(
child: Container(
width: 220,
height: 220,
decoration: BoxDecoration(
border: Border.all(color: scheme.primary, width: 3),
borderRadius: BorderRadius.circular(12),
),
),
),
Positioned(
bottom: 40,
left: 0, right: 0,
child: Text(
'Inquadra il codice QR dell\'altro dispositivo',
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.white, fontSize: 14),
),
),
],
),
);
}
}
@@ -0,0 +1,194 @@
import 'package:flutter/material.dart';
import '../../models/meal_plan.dart';
import '../../../../core/constants/app_constants.dart';
class MealCard extends StatefulWidget {
final String dayId;
final String dayLabel;
final DayPlan dayPlan;
final bool initiallyExpanded;
final void Function(String slot, String text) onAdd;
final void Function(String slot, int index) onRemove;
const MealCard({
super.key,
required this.dayId,
required this.dayLabel,
required this.dayPlan,
required this.onAdd,
required this.onRemove,
this.initiallyExpanded = false,
});
@override
State<MealCard> createState() => _MealCardState();
}
class _MealCardState extends State<MealCard> {
late final Map<String, TextEditingController> _controllers = {
for (final slot in kMealSlots) slot: TextEditingController(),
};
@override
void dispose() {
for (final c in _controllers.values) {
c.dispose();
}
super.dispose();
}
void _add(String slot) {
final text = _controllers[slot]!.text.trim();
if (text.isEmpty) return;
widget.onAdd(slot, text);
_controllers[slot]!.clear();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final totalItems = widget.dayPlan.colazione.length +
widget.dayPlan.pranzo.length +
widget.dayPlan.cena.length;
return Card(
margin: const EdgeInsets.only(bottom: 8),
clipBehavior: Clip.antiAlias,
child: ExpansionTile(
initiallyExpanded: widget.initiallyExpanded,
title: Text(
widget.dayLabel,
style: theme.textTheme.titleMedium
?.copyWith(fontWeight: FontWeight.w600),
),
subtitle: totalItems > 0
? Text('$totalItems voc${totalItems == 1 ? 'e' : 'i'}')
: null,
children: [
const Divider(height: 1),
for (final slot in kMealSlots)
_SlotSection(
label: kMealSlotLabels[slot]!,
items: widget.dayPlan.slot(slot),
controller: _controllers[slot]!,
onAdd: () => _add(slot),
onRemove: (i) => widget.onRemove(slot, i),
isLast: slot == kMealSlots.last,
),
],
),
);
}
}
class _SlotSection extends StatelessWidget {
final String label;
final List<String> items;
final TextEditingController controller;
final VoidCallback onAdd;
final void Function(int) onRemove;
final bool isLast;
const _SlotSection({
required this.label,
required this.items,
required this.controller,
required this.onAdd,
required this.onRemove,
required this.isLast,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 6),
child: Text(
label,
style: theme.textTheme.labelMedium?.copyWith(
color: theme.colorScheme.primary,
fontWeight: FontWeight.w700,
letterSpacing: 0.3,
),
),
),
for (int i = 0; i < items.length; i++)
_ItemRow(text: items[i], onRemove: () => onRemove(i)),
Padding(
padding: const EdgeInsets.fromLTRB(16, 6, 16, 12),
child: Row(
children: [
Expanded(
child: TextField(
controller: controller,
decoration: InputDecoration(
hintText: 'Aggiungi a ${label.toLowerCase()}',
isDense: true,
contentPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 10,
),
),
onSubmitted: (_) => onAdd(),
textInputAction: TextInputAction.done,
),
),
const SizedBox(width: 8),
SizedBox(
width: 44,
height: 44,
child: FilledButton(
onPressed: onAdd,
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
),
child: const Icon(Icons.add, size: 20),
),
),
],
),
),
if (!isLast) const Divider(height: 1),
],
);
}
}
class _ItemRow extends StatelessWidget {
final String text;
final VoidCallback onRemove;
const _ItemRow({required this.text, required this.onRemove});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 3),
child: Row(
children: [
Icon(
Icons.circle,
size: 5,
color: theme.colorScheme.onSurface.withValues(alpha: 0.3),
),
const SizedBox(width: 10),
Expanded(child: Text(text, style: theme.textTheme.bodyMedium)),
IconButton(
onPressed: onRemove,
icon: const Icon(Icons.close, size: 16),
visualDensity: VisualDensity.compact,
color: theme.colorScheme.onSurface.withValues(alpha: 0.35),
),
],
),
);
}
}
@@ -0,0 +1,87 @@
import 'package:flutter/material.dart';
import 'package:qr_flutter/qr_flutter.dart';
import '../../models/meal_plan.dart';
import '../../qr_codec.dart';
void showQrShareSheet(BuildContext context, MealPlan plan) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (_) => _QrShareSheet(plan: plan),
);
}
class _QrShareSheet extends StatelessWidget {
final MealPlan plan;
const _QrShareSheet({required this.plan});
String? _buildPayload() => buildQrPayload(plan);
@override
Widget build(BuildContext context) {
final payload = _buildPayload();
final scheme = Theme.of(context).colorScheme;
return SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 12, 20, 32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// handle
Container(
width: 36, height: 4,
decoration: BoxDecoration(
color: scheme.outlineVariant,
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('Condividi piano',
style: Theme.of(context)
.textTheme
.titleMedium
?.copyWith(fontWeight: FontWeight.bold)),
IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.pop(context),
),
],
),
const SizedBox(height: 4),
Text(
'Fai scansionare questo codice dall\'altro dispositivo',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: scheme.onSurfaceVariant,
),
),
const SizedBox(height: 20),
if (payload == null)
Padding(
padding: const EdgeInsets.symmetric(vertical: 24),
child: Text(
'Dati troppo grandi per un QR code.\nRiduci il numero di alimenti inseriti.',
textAlign: TextAlign.center,
style: TextStyle(color: scheme.error),
),
)
else
QrImageView(
data: payload,
version: QrVersions.auto,
size: 260,
errorCorrectionLevel: QrErrorCorrectLevel.L,
backgroundColor: Colors.white,
),
],
),
),
);
}
}
@@ -0,0 +1,68 @@
import 'package:flutter/material.dart';
import '../models/meal_plan.dart';
import '../../../shared/services/storage_service.dart';
import '../../../core/constants/app_constants.dart';
class MealPlannerProvider extends ChangeNotifier {
MealPlan _plan = MealPlan.empty(kDayIds);
MealPlan get plan => _plan;
Future<void> load() async {
final json = await StorageService.load(kStorageKeyMeals);
if (json != null) {
try {
_plan = MealPlan.fromJsonString(json);
} catch (_) {
_plan = MealPlan.empty(kDayIds);
}
}
notifyListeners();
}
Future<void> _save() =>
StorageService.save(kStorageKeyMeals, _plan.toJsonString());
void addItem(String dayId, String slot, String text) {
final t = text.trim();
if (t.isEmpty) return;
final day = _plan.days[dayId]!;
final updated = switch (slot) {
'colazione' => day.copyWith(colazione: [...day.colazione, t]),
'pranzo' => day.copyWith(pranzo: [...day.pranzo, t]),
'cena' => day.copyWith(cena: [...day.cena, t]),
_ => day,
};
_plan = _plan.withUpdatedDay(dayId, updated);
notifyListeners();
_save();
}
void removeItem(String dayId, String slot, int index) {
final day = _plan.days[dayId]!;
final updated = switch (slot) {
'colazione' =>
day.copyWith(colazione: List.of(day.colazione)..removeAt(index)),
'pranzo' =>
day.copyWith(pranzo: List.of(day.pranzo)..removeAt(index)),
'cena' =>
day.copyWith(cena: List.of(day.cena)..removeAt(index)),
_ => day,
};
_plan = _plan.withUpdatedDay(dayId, updated);
notifyListeners();
_save();
}
void clearAll() {
_plan = MealPlan.empty(kDayIds);
notifyListeners();
_save();
}
void importPlan(MealPlan plan) {
_plan = plan;
notifyListeners();
_save();
}
}
+60
View File
@@ -0,0 +1,60 @@
import 'dart:convert';
import 'models/meal_plan.dart';
import '../../core/constants/app_constants.dart';
const int kQrMaxBytes = 2953;
/// Serializza il piano in JSON QR. Restituisce null se supera il limite di byte.
String? buildQrPayload(MealPlan plan) {
final payload = jsonEncode({
'v': 1,
'meals': plan.days.map(
(k, v) => MapEntry(k, {
'colazione': v.colazione,
'pranzo': v.pranzo,
'cena': v.cena,
}),
),
});
if (payload.length > kQrMaxBytes) return null;
return payload;
}
/// Parsa una stringa QR in MealPlan.
/// Restituisce (plan, null) in caso di successo, (null, messaggio) in caso di errore.
(MealPlan?, String?) parseMealPlanFromQr(String raw) {
try {
final parsed = jsonDecode(raw) as Map<String, dynamic>;
if (parsed['v'] != 1 || parsed['meals'] is! Map) {
return (null, 'QR non valido: dati non riconosciuti.');
}
final mealsMap = parsed['meals'] as Map<String, dynamic>;
for (final day in kDayIds) {
final dayData = mealsMap[day];
if (dayData is! Map) {
return (null, 'QR non valido: struttura dati errata.');
}
for (final slot in kMealSlots) {
if (dayData[slot] is! List) {
return (null, 'QR non valido: struttura dati errata.');
}
}
}
final plan = MealPlan(Map.fromEntries(
kDayIds.map((day) {
final d = mealsMap[day] as Map<String, dynamic>;
return MapEntry(
day,
DayPlan(
colazione: List<String>.from(d['colazione'] as List),
pranzo: List<String>.from(d['pranzo'] as List),
cena: List<String>.from(d['cena'] as List),
),
);
}),
));
return (plan, null);
} catch (_) {
return (null, 'QR non riconosciuto.');
}
}
@@ -0,0 +1,35 @@
class ShoppingItem {
final String id;
final String name;
final bool checked;
final int quantity;
const ShoppingItem({
required this.id,
required this.name,
this.checked = false,
this.quantity = 1,
});
ShoppingItem copyWith({String? id, String? name, bool? checked, int? quantity}) =>
ShoppingItem(
id: id ?? this.id,
name: name ?? this.name,
checked: checked ?? this.checked,
quantity: quantity ?? this.quantity,
);
Map<String, dynamic> toJson() => {
'id': id,
'name': name,
'checked': checked,
'quantity': quantity,
};
factory ShoppingItem.fromJson(Map<String, dynamic> json) => ShoppingItem(
id: json['id'] as String,
name: json['name'] as String,
checked: json['checked'] as bool? ?? false,
quantity: json['quantity'] as int? ?? 1,
);
}
@@ -0,0 +1,218 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../providers/shopping_list_provider.dart';
import '../widgets/shopping_item_tile.dart';
import '../../../../shared/widgets/empty_state.dart';
class ShoppingListPage extends StatefulWidget {
const ShoppingListPage({super.key});
@override
State<ShoppingListPage> createState() => _ShoppingListPageState();
}
class _ShoppingListPageState extends State<ShoppingListPage> {
final _controller = TextEditingController();
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _add() {
final text = _controller.text.trim();
if (text.isEmpty) return;
context.read<ShoppingListProvider>().add(text);
_controller.clear();
}
Future<void> _confirmClear() async {
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Svuota lista'),
content: const Text('Svuotare tutta la lista della spesa?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Annulla'),
),
TextButton(
onPressed: () => Navigator.pop(ctx, true),
style: TextButton.styleFrom(
foregroundColor: Theme.of(ctx).colorScheme.error,
),
child: const Text('Svuota'),
),
],
),
);
if (ok == true && mounted) {
context.read<ShoppingListProvider>().clearAll();
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final provider = context.watch<ShoppingListProvider>();
final pending = provider.pendingItems;
final checked = provider.checkedItems;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 56, 16, 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Lista della spesa',
style: theme.textTheme.headlineSmall
?.copyWith(fontWeight: FontWeight.bold),
),
if (provider.items.isNotEmpty)
Text(
'${checked.length} / ${provider.items.length} '
'completat${checked.length == 1 ? 'o' : 'i'}',
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurface.withValues(alpha: 0.6),
),
),
],
),
),
// Add row
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
child: Row(
children: [
Expanded(
child: TextField(
controller: _controller,
decoration:
const InputDecoration(hintText: 'Aggiungi elemento…'),
onSubmitted: (_) => _add(),
textInputAction: TextInputAction.done,
),
),
const SizedBox(width: 8),
FilledButton(
onPressed: _add,
style: FilledButton.styleFrom(
minimumSize: const Size(52, 52),
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
),
child: const Icon(Icons.add),
),
],
),
),
Expanded(
child: provider.items.isEmpty
? const EmptyState(
icon: Icons.shopping_cart_outlined,
title: 'Lista vuota',
subtitle: 'Aggiungi qualcosa con il campo qui sopra.',
)
: ListView(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
children: [
// Pending items
if (pending.isNotEmpty)
Card(
margin: EdgeInsets.zero,
child: Column(
children: [
for (int i = 0; i < pending.length; i++) ...[
if (i > 0)
const Divider(
height: 1, indent: 16, endIndent: 16),
ShoppingItemTile(
item: pending[i],
onToggle: () => context
.read<ShoppingListProvider>()
.toggle(pending[i].id),
onRemove: () => context
.read<ShoppingListProvider>()
.remove(pending[i].id),
),
],
],
),
),
// Checked section
if (checked.isNotEmpty) ...[
Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
child: Row(
children: [
Text(
'COMPLETATI (${checked.length})',
style: theme.textTheme.labelSmall?.copyWith(
fontWeight: FontWeight.w700,
letterSpacing: 0.8,
color: theme.colorScheme.onSurface
.withValues(alpha: 0.5),
),
),
const SizedBox(width: 8),
Expanded(
child: Divider(
color: theme.colorScheme.outline
.withValues(alpha: 0.4),
),
),
],
),
),
Card(
margin: EdgeInsets.zero,
child: Column(
children: [
for (int i = 0; i < checked.length; i++) ...[
if (i > 0)
const Divider(
height: 1, indent: 16, endIndent: 16),
ShoppingItemTile(
item: checked[i],
onToggle: () => context
.read<ShoppingListProvider>()
.toggle(checked[i].id),
onRemove: () => context
.read<ShoppingListProvider>()
.remove(checked[i].id),
),
],
],
),
),
],
const SizedBox(height: 16),
OutlinedButton(
onPressed: _confirmClear,
style: OutlinedButton.styleFrom(
foregroundColor: theme.colorScheme.error,
side: BorderSide(
color: theme.colorScheme.error.withValues(alpha: 0.4),
),
minimumSize: const Size(double.infinity, 48),
),
child: const Text('Svuota lista'),
),
],
),
),
],
);
}
}
@@ -0,0 +1,75 @@
import 'package:flutter/material.dart';
import '../../models/shopping_item.dart';
class ShoppingItemTile extends StatelessWidget {
final ShoppingItem item;
final VoidCallback onToggle;
final VoidCallback onRemove;
const ShoppingItemTile({
super.key,
required this.item,
required this.onToggle,
required this.onRemove,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final muted = theme.colorScheme.onSurface.withValues(alpha: 0.35);
return InkWell(
onTap: onToggle,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
child: Row(
children: [
Checkbox(
value: item.checked,
onChanged: (_) => onToggle(),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(4),
),
),
Expanded(
child: Row(
children: [
Expanded(
child: Text(
item.name,
style: item.checked
? theme.textTheme.bodyMedium?.copyWith(
decoration: TextDecoration.lineThrough,
color: muted,
)
: theme.textTheme.bodyMedium,
),
),
if (item.quantity > 1)
Padding(
padding: const EdgeInsets.only(left: 6),
child: Text(
'(x${item.quantity})',
style: theme.textTheme.bodySmall?.copyWith(
color: item.checked
? muted
: theme.colorScheme.primary,
fontWeight: FontWeight.w600,
),
),
),
],
),
),
IconButton(
onPressed: onRemove,
icon: const Icon(Icons.close, size: 16),
visualDensity: VisualDensity.compact,
color: muted,
),
],
),
),
);
}
}
@@ -0,0 +1,98 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import '../models/shopping_item.dart';
import '../../../shared/services/storage_service.dart';
import '../../../core/constants/app_constants.dart';
class ShoppingListProvider extends ChangeNotifier {
List<ShoppingItem> _items = [];
int _idSeq = 0;
List<ShoppingItem> get items => _items;
List<ShoppingItem> get pendingItems =>
_items.where((i) => !i.checked).toList();
List<ShoppingItem> get checkedItems =>
_items.where((i) => i.checked).toList();
Future<void> load() async {
final json = await StorageService.load(kStorageKeyShopping);
if (json != null) {
try {
final list = jsonDecode(json) as List;
_items = list
.map((e) => ShoppingItem.fromJson(e as Map<String, dynamic>))
.toList();
} catch (_) {
_items = [];
}
}
notifyListeners();
}
Future<void> _save() async {
final json = jsonEncode(_items.map((i) => i.toJson()).toList());
await StorageService.save(kStorageKeyShopping, json);
}
void add(String name) {
final t = name.trim();
if (t.isEmpty) return;
_items = [
..._items,
ShoppingItem(
id: '${DateTime.now().millisecondsSinceEpoch}_${_idSeq++}',
name: t,
),
];
notifyListeners();
_save();
}
void addAll(List<String> names) {
final existing = _items.map((i) => i.name.toLowerCase()).toSet();
// Conta le occorrenze mantenendo il nome nella sua forma originale (prima occorrenza)
final counts = <String, int>{};
final canonical = <String, String>{};
for (final name in names) {
final key = name.toLowerCase().trim();
if (key.isEmpty) continue;
counts[key] = (counts[key] ?? 0) + 1;
canonical.putIfAbsent(key, () => name.trim());
}
final toAdd = <ShoppingItem>[];
for (final entry in counts.entries) {
if (existing.contains(entry.key)) continue;
toAdd.add(ShoppingItem(
id: '${DateTime.now().millisecondsSinceEpoch}_${_idSeq++}',
name: canonical[entry.key]!,
quantity: entry.value,
));
}
if (toAdd.isEmpty) return;
_items = [..._items, ...toAdd];
notifyListeners();
_save();
}
void toggle(String id) {
_items = _items
.map((i) => i.id == id ? i.copyWith(checked: !i.checked) : i)
.toList();
notifyListeners();
_save();
}
void remove(String id) {
_items = _items.where((i) => i.id != id).toList();
notifyListeners();
_save();
}
void clearAll() {
_items = [];
notifyListeners();
_save();
}
}
+22
View File
@@ -0,0 +1,22 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import 'app.dart';
import 'features/meal_planner/providers/meal_planner_provider.dart';
import 'features/converter/providers/converter_provider.dart';
import 'features/shopping_list/providers/shopping_list_provider.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]);
runApp(
MultiProvider(
providers: [
ChangeNotifierProvider(create: (_) => MealPlannerProvider()..load()),
ChangeNotifierProvider(create: (_) => ConverterProvider()..loadDb()),
ChangeNotifierProvider(create: (_) => ShoppingListProvider()..load()),
],
child: const BitePlanApp(),
),
);
}
+13
View File
@@ -0,0 +1,13 @@
import 'package:shared_preferences/shared_preferences.dart';
class StorageService {
static Future<String?> load(String key) async {
final prefs = await SharedPreferences.getInstance();
return prefs.getString(key);
}
static Future<void> save(String key, String value) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(key, value);
}
}
+48
View File
@@ -0,0 +1,48 @@
import 'package:flutter/material.dart';
class EmptyState extends StatelessWidget {
final IconData icon;
final String title;
final String? subtitle;
const EmptyState({
super.key,
required this.icon,
required this.title,
this.subtitle,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final muted = theme.colorScheme.onSurface.withValues(alpha: 0.4);
return Center(
child: Padding(
padding: const EdgeInsets.all(40),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 48, color: muted),
const SizedBox(height: 16),
Text(
title,
style: theme.textTheme.bodyLarge?.copyWith(color: muted),
textAlign: TextAlign.center,
),
if (subtitle != null) ...[
const SizedBox(height: 6),
Text(
subtitle!,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurface.withValues(alpha: 0.3),
),
textAlign: TextAlign.center,
),
],
],
),
),
);
}
}
-3572
View File
File diff suppressed because it is too large Load Diff
-29
View File
@@ -1,29 +0,0 @@
{
"name": "biteplan",
"version": "1.2.1",
"private": true,
"scripts": {
"dev": "vite --host",
"build": "vite build",
"preview": "vite preview",
"test": "vitest",
"test:coverage": "vitest --coverage",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui"
},
"dependencies": {
"jsqr": "^1.4.0",
"qrcode": "^1.5.4",
"vue": "^3.4.0"
},
"devDependencies": {
"@playwright/test": "^1.58.2",
"@vitejs/plugin-vue": "^5.2.0",
"@vitest/coverage-v8": "^3.0.0",
"@vue/test-utils": "^2.4.6",
"happy-dom": "^20.8.9",
"jsdom": "^29.0.1",
"vite": "^6.0.0",
"vitest": "^3.0.0"
}
}
-19
View File
@@ -1,19 +0,0 @@
import { defineConfig, devices } from '@playwright/test'
export default defineConfig({
testDir: './tests/e2e',
use: {
baseURL: 'http://localhost:5173',
// Simula iPhone 14 Pro — dimensioni target dell'app
viewport: { width: 393, height: 852 },
locale: 'it-IT',
},
webServer: {
command: 'npm run dev',
url: 'http://localhost:5173',
reuseExistingServer: true,
},
projects: [
{ name: 'mobile-chrome', use: { ...devices['Pixel 5'] } },
],
})
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 MiB

+37
View File
@@ -0,0 +1,37 @@
name: biteplan
description: App per meal planning, conversione crudo/cotto e lista della spesa.
publish_to: 'none'
version: 2.0.0+1
environment:
sdk: '>=3.3.0 <4.0.0'
dependencies:
flutter:
sdk: flutter
provider: ^6.1.2
shared_preferences: ^2.3.2
qr_flutter: ^4.1.0
mobile_scanner: ^5.0.0
dev_dependencies:
flutter_test:
sdk: flutter
integration_test:
sdk: flutter
flutter_lints: ^4.0.0
flutter_launcher_icons: ^0.14.1
flutter_launcher_icons:
android: true
ios: false
image_path: "assets/icon-only.png"
min_sdk_android: 21
adaptive_icon_background: "#2d6a4f"
adaptive_icon_foreground: "assets/icon-only.png"
flutter:
uses-material-design: true
assets:
- assets/data/conversions.json
- assets/icon-only.png
+111 -94
View File
@@ -1,11 +1,11 @@
# SOP — BitePlan (Vue 3 + Vite → Capacitor Android)
# SOP — BitePlan (Flutter → Android APK)
## Scope
App mobile-first con tre funzionalità:
App mobile Android con tre funzionalità:
1. **Meal Planner** — pianificazione settimanale (7 giorni × 3 pasti: colazione, pranzo, cena). Ogni pasto contiene una lista di voci testuali liberamente modificabili.
2. **Conversione crudo/cotto** — calcolo del peso cotto a partire dal peso crudo (e viceversa) tramite coefficienti di resa definiti in un JSON interno.
2. **Conversione crudo/cotto** — calcolo del peso cotto a partire dal peso crudo (e viceversa) tramite coefficienti di resa definiti in un asset JSON interno.
3. **Lista della spesa** — checklist con aggiunta, spunta e rimozione elementi.
---
@@ -14,130 +14,158 @@ App mobile-first con tre funzionalità:
| Livello | Scelta |
|---|---|
| Frontend | Vue 3 + Vite |
| Stato | Composables (no store) |
| Persistenza | LocalStorage |
| UI | CSS base mobile-first |
| Mobile (fase successiva) | Capacitor Android |
| Build riproducibile (opzionale) | Docker |
Sviluppo iniziale: `npm run dev` + Chrome DevTools modalità mobile.
| Framework | Flutter (Dart) |
| Stato | Provider o Riverpod |
| Persistenza | shared_preferences |
| UI | Material 3, mobile-first |
| Build APK | Docker (container build) |
| Sviluppo | Docker + noVNC (container dev con GUI) |
---
## Struttura progetto
```
src/
lib/
├── main.dart
├── app.dart # MaterialApp, routing, BottomNavigationBar
├── pages/
│ ├── MealPlanner.vue
│ ├── Converter.vue
│ └── ShoppingList.vue
├── components/
│ ├── BottomNav.vue
── MealCard.vue
│ └── CheckboxItem.vue
├── data/
│ └── conversions.json
├── utils/
│ ├── conversion.js
│ └── storage.js
└── App.vue
│ ├── meal_planner_page.dart
│ ├── converter_page.dart
│ └── shopping_list_page.dart
├── widgets/
│ ├── meal_card.dart # Accordion giorno
── checkbox_item.dart
├── models/
│ ├── meal_plan.dart
│ └── shopping_item.dart
├── services/
│ ├── storage_service.dart # Wrapper shared_preferences
│ └── conversion_service.dart # rawToCooked / cookedToRaw
└── data/
└── conversions.json # Asset: 50+ alimenti × metodi cottura
docker/
├── dev/
│ ├── Dockerfile # Flutter SDK + Android SDK + Xvfb + noVNC
│ └── docker-compose.yml # Porta 6080 → noVNC, volume su ./
├── build/
│ ├── Dockerfile # Flutter SDK + Android SDK headless
│ └── build.sh # flutter build apk --release + firma
pubspec.yaml
```
---
## Fase 1 — Setup
## Container dev (noVNC)
Il container di sviluppo espone un desktop virtuale via browser, utile per eseguire l'emulatore Android o Android Studio senza installare nulla sull'host.
**Stack interno**: Ubuntu + Flutter SDK + Android SDK + Xvfb + noVNC + websockify.
```bash
npm create vue@latest biteplan
cd biteplan
npm install
npm run dev
# Avvio
cd docker/dev
docker compose up
# Accesso GUI
# → http://localhost:6080 (noVNC, nessuna password)
```
Test mobile: Chrome → F12 → viewport 360×640.
Il volume mappa `./` (root del progetto) in `/workspace` nel container, quindi le modifiche ai file sono bidirezionali in tempo reale.
**Workflow nel container**:
1. Aprire un terminale nel desktop noVNC
2. `cd /workspace && flutter pub get`
3. Avviare l'emulatore o collegare device via ADB
4. `flutter run`
---
## Fase 2 — Meal Planner
## Container build (APK)
**Struttura dati**
Build headless riproducibile, senza GUI.
```js
{
lunedi: { colazione: [], pranzo: [], cena: [] },
martedi: { ... }
```bash
# Debug
bash docker/build/build.sh
# Release firmato
bash docker/build/build.sh --release
```
Pipeline interna:
1. `flutter pub get`
2. `flutter build apk --release``build/app/outputs/flutter-apk/app-release.apk`
3. Firma con `apksigner` (keystore montato come volume o secret)
4. `zipalign`
L'APK firmato viene copiato in `dist/` nella root del progetto.
---
## Modello dati
**Meal Plan**
```dart
class MealPlan {
final Map<String, DayPlan> days; // 'lunedi' … 'domenica'
}
class DayPlan {
final List<String> colazione;
final List<String> pranzo;
final List<String> cena;
}
```
**Funzionalità**
- Aggiungere/rimuovere voci per ogni pasto
- Visualizzare l'intera settimana
- Salvataggio automatico su LocalStorage
**Shopping Item**
```dart
class ShoppingItem {
final String id;
final String name;
bool checked;
}
```
---
## Fase 3 — Conversione crudo/cotto
**JSON**`src/data/conversions.json`
## Conversione crudo/cotto
**Asset**`lib/data/conversions.json`
```json
{
"pollo": { "forno": { "yield": 0.75 }, "padella": { "yield": 0.70 } },
"riso": { "bollito": { "yield": 2.5 } }
"riso basmati": { "bollitura": { "yield": 3.00 } }
}
```
`yield = peso_cotto / peso_crudo`
**Funzioni**`src/utils/conversion.js`
```js
export const rawToCooked = (food, method, raw, db) => raw * db[food][method].yield
export const cookedToRaw = (food, method, cooked, db) => cooked / db[food][method].yield
**Service**`lib/services/conversion_service.dart`
```dart
double rawToCooked(double raw, double yield) => raw * yield;
double cookedToRaw(double cooked, double yield) => cooked / yield;
```
**UX**: ricerca testuale sull'alimento → selezione alimento+metodo → input grammi → risultato in tempo reale.
Esempio: 140 g pollo crudo → 105 g cotti al forno.
UX: ricerca testuale → selezione alimento + metodo → input grammi → risultato in tempo reale.
---
## Fase 4 — Lista della spesa
## Persistenza
**Struttura dati**
```js
[{ id, name, checked }]
```
**Funzionalità**: aggiungi, spunta, elimina, svuota lista.
`StorageService` wrappa `shared_preferences`:
- Meal plan serializzato come JSON stringa sotto chiave `meals`
- Lista spesa serializzata come JSON stringa sotto chiave `shopping_list`
---
## Fase 5 — UI Mobile
## UI
Navigazione bottom bar: Pasti | Converti | Spesa.
Linee guida: bottoni min 44px, input semplici, una funzione per schermata.
---
## Fase 6 — Android (Capacitor)
```bash
npm install @capacitor/core @capacitor/cli
npx cap init
npx cap add android
npm run build && npx cap sync && npx cap run android
```
---
## Fase 7 — Docker (opzionale)
Pipeline: install → build frontend → cap sync → gradle build APK.
- `BottomNavigationBar` con 3 tab: Pasti | Converti | Spesa
- Material 3, `ColorScheme.fromSeed(seedColor: Color(0xFF2d6a4f))`
- Target touch minimo 48×48 dp (Material baseline)
- Orientamento bloccato in portrait (`SystemChrome.setPreferredOrientations`)
---
@@ -145,16 +173,5 @@ Pipeline: install → build frontend → cap sync → gradle build APK.
- Modifica coefficienti di conversione in-app
- Calcolo kcal
- Generazione automatica lista spesa dai pasti pianificati
- Condivisione piano via QR code
- Sync cloud
---
## Checklist
- [ ] Meal planner funzionante
- [ ] Conversioni corrette
- [ ] JSON alimenti presente (12+ voci)
- [ ] Lista spesa funzionante
- [ ] Persistenza attiva
- [ ] APK testabile
-113
View File
@@ -1,113 +0,0 @@
<template>
<div id="app-root">
<MealPlanner v-if="page === 'meal'" @go-shop="page = 'shop'" />
<Converter v-else-if="page === 'convert'" />
<ShoppingList v-else-if="page === 'shop'" />
<BottomNav v-model="page" />
<!-- bottone info fisso in alto a destra -->
<button class="btn-info" @click="showInfo = true" aria-label="Informazioni app">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"/>
<line x1="12" y1="16" x2="12" y2="12"/>
<line x1="12" y1="8" x2="12.01" y2="8"/>
</svg>
</button>
<InfoPanel v-model="showInfo" @open-docs="showDocs = true" />
<DocsPanel v-model="showDocs" />
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
import BottomNav from './components/BottomNav.vue'
import InfoPanel from './components/InfoPanel.vue'
import DocsPanel from './components/DocsPanel.vue'
import MealPlanner from './pages/MealPlanner.vue'
import Converter from './pages/Converter.vue'
import ShoppingList from './pages/ShoppingList.vue'
const page = ref('meal')
const showInfo = ref(false)
const showDocs = ref(false)
function fixOrientation() {
const isLandscape = window.innerWidth > window.innerHeight
// screen.orientation.angle potrebbe non essere ancora aggiornato o non disponibile:
// fallback basato su dimensioni finestra
const angle = screen.orientation?.angle ?? (isLandscape ? 90 : 0)
const root = document.getElementById('app-root')
if (!root) return
if (isLandscape) {
const w = window.innerWidth
const h = window.innerHeight
root.style.cssText = `
position: fixed;
width: ${h}px;
height: ${w}px;
top: ${(h - w) / 2}px;
left: ${(w - h) / 2}px;
transform: rotate(${angle === 90 ? -90 : 90}deg);
transform-origin: center center;
`
} else {
root.style.cssText = ''
}
}
// orientationchange si triggera prima che angle/dimensioni siano aggiornati:
// aspettiamo un tick
function onOrientationChange() {
setTimeout(fixOrientation, 50)
}
onMounted(() => {
fixOrientation()
window.addEventListener('orientationchange', onOrientationChange)
window.addEventListener('resize', fixOrientation)
})
onUnmounted(() => {
window.removeEventListener('orientationchange', onOrientationChange)
window.removeEventListener('resize', fixOrientation)
})
</script>
<style scoped>
#app-root {
height: 100dvh;
display: flex;
flex-direction: column;
position: relative;
}
.btn-info {
position: fixed;
top: calc(14px + env(safe-area-inset-top));
right: 16px;
/* centra rispetto al max-width del layout */
max-width: calc(480px);
background: var(--color-surface);
color: var(--color-muted);
min-height: 36px;
min-width: 36px;
padding: 0;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
box-shadow: var(--shadow-sm);
border: 1px solid var(--color-border);
z-index: 50;
transition: color var(--transition), box-shadow var(--transition);
}
.btn-info:active {
color: var(--color-primary);
box-shadow: var(--shadow-md);
opacity: 1;
}
</style>
-110
View File
@@ -1,110 +0,0 @@
<template>
<nav class="bottom-nav">
<button
v-for="tab in tabs"
:key="tab.id"
:class="['nav-btn', { active: modelValue === tab.id }]"
@click="$emit('update:modelValue', tab.id)"
:aria-label="tab.label"
>
<span class="nav-icon" v-html="tab.icon" />
<span class="nav-label">{{ tab.label }}</span>
</button>
</nav>
</template>
<script setup>
defineProps({ modelValue: String })
defineEmits(['update:modelValue'])
const tabs = [
{
id: 'meal',
label: 'Pasti',
icon: `<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="4" width="18" height="18" rx="2"/>
<line x1="16" y1="2" x2="16" y2="6"/>
<line x1="8" y1="2" x2="8" y2="6"/>
<line x1="3" y1="10" x2="21" y2="10"/>
</svg>`,
},
{
id: 'convert',
label: 'Converti',
icon: `<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="17 1 21 5 17 9"/>
<path d="M3 11V9a4 4 0 014-4h14"/>
<polyline points="7 23 3 19 7 15"/>
<path d="M21 13v2a4 4 0 01-4 4H3"/>
</svg>`,
},
{
id: 'shop',
label: 'Spesa',
icon: `<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M6 2L3 6v14a2 2 0 002 2h14a2 2 0 002-2V6l-3-4z"/>
<line x1="3" y1="6" x2="21" y2="6"/>
<path d="M16 10a4 4 0 01-8 0"/>
</svg>`,
},
]
</script>
<style scoped>
.bottom-nav {
position: fixed;
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 100%;
max-width: 480px;
height: var(--nav-height);
background: var(--color-surface);
border-top: 1px solid var(--color-border);
display: flex;
z-index: 100;
box-shadow: 0 -4px 16px rgba(0, 0, 0, 0.06);
}
.nav-btn {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 3px;
background: none;
border-radius: 0;
color: var(--color-muted);
min-height: unset;
padding: 0;
position: relative;
transition: color var(--transition);
}
.nav-btn.active {
color: var(--color-primary);
}
/* indicatore attivo: pill sopra l'icona — segnala la tab senza aggressività */
.nav-btn.active::before {
content: '';
position: absolute;
top: 6px;
width: 28px;
height: 3px;
background: var(--color-primary);
border-radius: var(--radius-full);
}
.nav-icon {
display: flex;
align-items: center;
justify-content: center;
}
.nav-label {
font-size: 0.68rem;
font-weight: 500;
}
</style>
-81
View File
@@ -1,81 +0,0 @@
<template>
<li class="checkbox-item" :class="{ checked: item.checked }">
<label class="item-label">
<input type="checkbox" :checked="item.checked" @change="$emit('toggle')" />
<span class="item-name">{{ item.name }}</span>
</label>
<button class="btn-remove" @click="$emit('remove')" :aria-label="`Rimuovi ${item.name}`">
<svg width="14" height="14" 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>
</li>
</template>
<script setup>
defineProps({ item: Object })
defineEmits(['toggle', 'remove'])
</script>
<style scoped>
.checkbox-item {
display: flex;
align-items: center;
justify-content: space-between;
background: var(--color-surface);
border-radius: var(--radius-sm);
padding: 4px 4px 4px 14px;
border: 1.5px solid var(--color-border);
box-shadow: var(--shadow-sm);
transition: background var(--transition), border-color var(--transition);
}
.checkbox-item.checked {
background: var(--color-bg);
border-color: transparent;
box-shadow: none;
}
.item-label {
display: flex;
align-items: center;
gap: 12px;
cursor: pointer;
flex: 1;
padding: 8px 0;
}
input[type="checkbox"] {
width: 20px;
height: 20px;
cursor: pointer;
accent-color: var(--color-primary);
flex-shrink: 0;
}
.item-name {
font-size: 1rem;
transition: color var(--transition);
}
.checked .item-name {
text-decoration: line-through;
color: var(--color-muted);
}
/* touch target pieno 44×44px */
.btn-remove {
background: none;
color: var(--color-muted);
min-height: 44px;
min-width: 44px;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-sm);
flex-shrink: 0;
}
.btn-remove:active { color: var(--color-danger); }
</style>
-523
View File
@@ -1,523 +0,0 @@
<template>
<Transition name="slide-right">
<div v-if="modelValue" class="docs-panel" role="dialog" aria-label="Guida utente">
<!-- Header sticky: back arrow + titolo centrato + spacer bilanciante -->
<div class="docs-header">
<button class="btn-back" @click="$emit('update:modelValue', false)" aria-label="Torna indietro">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<polyline points="15 18 9 12 15 6"/>
</svg>
</button>
<span class="docs-title">Guida</span>
<!-- spacer identico al btn-back per centrare il titolo -->
<div aria-hidden="true" class="header-spacer" />
</div>
<!-- Pill nav scrollabile IntersectionObserver aggiorna la pill attiva -->
<div class="section-nav" role="tablist" aria-label="Sezioni">
<button
v-for="s in sections"
:key="s.id"
:class="['nav-pill', { active: activeSection === s.id }]"
role="tab"
:aria-selected="activeSection === s.id"
@click="scrollToSection(s.id)"
>{{ s.label }}</button>
</div>
<!-- Contenuto scorrevole -->
<div class="docs-scroll" ref="scrollEl">
<!-- Pasti -->
<section data-section="pasti">
<div class="section-head">
<span class="section-icon" aria-hidden="true">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="4" width="18" height="18" rx="2"/>
<line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/>
<line x1="3" y1="10" x2="21" y2="10"/>
</svg>
</span>
<h2 class="section-heading">Pasti</h2>
</div>
<div class="doc-card">
<h3 class="card-title">Aggiungere un alimento</h3>
<ol class="steps">
<li>Tocca il giorno per espandere la card</li>
<li>Scegli il pasto: <strong>Colazione</strong>, <strong>Pranzo</strong> o <strong>Cena</strong></li>
<li>Scrivi il nome nel campo di testo</li>
<li>Premi <kbd>+</kbd> o Invio per aggiungerlo</li>
</ol>
</div>
<div class="doc-card tip">
<p class="tip-label" aria-hidden="true">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12.01" y2="8"/><line x1="12" y1="12" x2="12" y2="16"/>
</svg>
Suggerimento
</p>
<p>Premi <strong>Genera lista della spesa</strong> in fondo alla pagina per importare automaticamente tutti gli alimenti della settimana, senza duplicati.</p>
</div>
<div class="doc-card">
<h3 class="card-title">Rimuovere un alimento</h3>
<p>Tocca il pulsante <strong>×</strong> a destra dell'elemento.</p>
</div>
<div class="doc-card">
<h3 class="card-title">Condividere il piano</h3>
<ol class="steps">
<li>Premi <strong>Condividi</strong> in fondo alla pagina</li>
<li>Viene generato un QR code con l'intero piano settimanale</li>
<li>Fai scansionare il codice dall'altro dispositivo</li>
</ol>
</div>
<div class="doc-card">
<h3 class="card-title">Ricevere un piano</h3>
<ol class="steps">
<li>Premi <strong>Scansiona QR</strong> in fondo alla pagina</li>
<li>Consenti l'accesso alla fotocamera quando richiesto</li>
<li>Inquadra il QR code il piano viene importato automaticamente</li>
</ol>
</div>
<div class="doc-card">
<h3 class="card-title">Salvataggio automatico</h3>
<p>I dati vengono salvati automaticamente sul dispositivo. Non serve premere nessun tasto.</p>
</div>
</section>
<!-- Converti -->
<section data-section="converti">
<div class="section-head">
<span class="section-icon" aria-hidden="true">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="17 1 21 5 17 9"/>
<path d="M3 11V9a4 4 0 014-4h14"/>
<polyline points="7 23 3 19 7 15"/>
<path d="M21 13v2a4 4 0 01-4 4H3"/>
</svg>
</span>
<h2 class="section-heading">Converti</h2>
</div>
<div class="doc-card">
<h3 class="card-title">Come usarlo</h3>
<ol class="steps">
<li>Cerca l'alimento nel campo (es. <em>pollo</em>, <em>riso</em>)</li>
<li>Seleziona il metodo di cottura dall'elenco</li>
<li>Inserisci il peso in grammi</li>
<li>Il risultato appare in tempo reale</li>
<li>Premi <strong></strong> per invertire crudo cotto</li>
</ol>
</div>
<div class="doc-card">
<h3 class="card-title">Alimenti disponibili</h3>
<div class="category-table" role="list">
<div class="cat-row" v-for="cat in categories" :key="cat.label" role="listitem">
<span class="cat-label">{{ cat.label }}</span>
<span class="cat-items">{{ cat.items }}</span>
</div>
</div>
<div class="methods-row" aria-label="Metodi di cottura disponibili">
<span class="method-chip" v-for="m in cookingMethods" :key="m">{{ m }}</span>
</div>
</div>
</section>
<!-- Spesa -->
<section data-section="spesa">
<div class="section-head">
<span class="section-icon" aria-hidden="true">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M6 2L3 6v14a2 2 0 002 2h14a2 2 0 002-2V6l-3-4z"/>
<line x1="3" y1="6" x2="21" y2="6"/>
<path d="M16 10a4 4 0 01-8 0"/>
</svg>
</span>
<h2 class="section-heading">Spesa</h2>
</div>
<div class="doc-card">
<h3 class="card-title">Aggiungere un elemento</h3>
<p>Scrivi il nome nel campo in alto e premi <kbd>+</kbd> o Invio.</p>
</div>
<div class="doc-card tip">
<p class="tip-label" aria-hidden="true">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12.01" y2="8"/><line x1="12" y1="12" x2="12" y2="16"/>
</svg>
Suggerimento
</p>
<p>Vai alla tab <strong>Pasti</strong> e premi <strong>Genera lista della spesa</strong> per importare automaticamente gli alimenti pianificati.</p>
</div>
<div class="doc-card">
<h3 class="card-title">Spuntare un elemento</h3>
<p>Tocca la casella a sinistra. Gli elementi completati vengono spostati in una sezione separata con testo barrato.</p>
</div>
<div class="doc-card">
<h3 class="card-title">Rimuovere / svuotare</h3>
<p>Tocca <strong>×</strong> per rimuovere un elemento singolo, oppure <strong>Svuota lista</strong> in fondo per eliminare tutto (richiede conferma).</p>
</div>
</section>
<div class="docs-footer">BitePlan · v{{ version }} · Davide Grilli</div>
</div>
</div>
</Transition>
</template>
<script setup>
import { ref, watch, nextTick, onUnmounted } from 'vue'
import pkg from '../../package.json'
const props = defineProps({ modelValue: Boolean })
defineEmits(['update:modelValue'])
const version = pkg.version
const scrollEl = ref(null)
const activeSection = ref('pasti')
let observer = null
const sections = [
{ id: 'pasti', label: 'Pasti' },
{ id: 'converti', label: 'Converti' },
{ id: 'spesa', label: 'Spesa' },
]
const categories = [
{ label: 'Cereali e pasta', items: 'Riso (4 varietà), pasta, farro, orzo, quinoa, cous cous' },
{ label: 'Legumi secchi', items: 'Ceci, fagioli, lenticchie' },
{ label: 'Verdure', items: 'Carote, zucchine, patate, spinaci, broccoli, asparagi e altri' },
{ label: 'Carni', items: 'Pollo petto, tacchino fesa, hamburger, vitello' },
{ label: 'Pesce', items: 'Tonno, merluzzo, spigola, sogliola' },
{ label: 'Uova', items: 'Uovo al tegamino, frittata' },
]
const cookingMethods = ['Bollitura', 'Padella', 'Forno', 'Friggitrice ad aria']
watch(() => props.modelValue, async (open) => {
if (open) {
await nextTick()
setupObserver()
activeSection.value = 'pasti'
scrollEl.value?.scrollTo({ top: 0 })
} else {
observer?.disconnect()
observer = null
}
})
function setupObserver() {
if (!scrollEl.value) return
observer?.disconnect()
// rootMargin: entra in zona attiva quando la sezione raggiunge il 20% dall'alto del container
observer = new IntersectionObserver(
(entries) => {
const visible = entries.filter(e => e.isIntersecting)
if (visible.length) activeSection.value = visible[0].target.dataset.section
},
{ root: scrollEl.value, rootMargin: '0px 0px -65% 0px', threshold: 0 }
)
scrollEl.value.querySelectorAll('[data-section]').forEach(el => observer.observe(el))
}
function scrollToSection(id) {
const el = scrollEl.value?.querySelector(`[data-section="${id}"]`)
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
onUnmounted(() => observer?.disconnect())
</script>
<style scoped>
/* ── Panel container ─────────────────────────── */
.docs-panel {
position: fixed;
inset: 0;
max-width: 480px;
margin: 0 auto;
background: var(--color-bg);
z-index: 300;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* ── Animazione slide da destra ──────────────── */
/* enter ease-out: risposta immediata, decelera all'arrivo (feel nativo) */
.slide-right-enter-active { transition: transform 320ms cubic-bezier(0.25, 1, 0.5, 1); }
/* leave ease-in: parte lentamente, accelera — chiusura decisa */
.slide-right-leave-active { transition: transform 240ms cubic-bezier(0.55, 0, 1, 0.45); }
.slide-right-enter-from,
.slide-right-leave-to { transform: translateX(100%); }
/* ── Header ──────────────────────────────────── */
.docs-header {
display: flex;
align-items: center;
justify-content: space-between;
/* safe-area-inset-top: evita sovrapposizione con status bar su iOS */
padding: calc(env(safe-area-inset-top, 0px) + 10px) 8px 10px;
background: var(--color-surface);
border-bottom: 1px solid var(--color-border);
flex-shrink: 0;
}
.btn-back {
display: flex;
align-items: center;
justify-content: center;
min-height: 44px;
min-width: 44px;
background: none;
color: var(--color-primary);
border-radius: var(--radius-sm);
padding: 0;
transition: background var(--transition);
}
.btn-back:active {
background: var(--color-primary-muted);
opacity: 1;
}
.docs-title {
font-size: 1rem;
font-weight: 700;
color: var(--color-text);
letter-spacing: -0.01em;
}
/* spacer uguale al btn-back per centrare il titolo otticamente */
.header-spacer { width: 44px; }
/* ── Navigazione sezioni (pill) ──────────────── */
.section-nav {
display: flex;
gap: 6px;
padding: 10px 16px;
overflow-x: auto;
scrollbar-width: none;
background: var(--color-surface);
border-bottom: 1px solid var(--color-border);
flex-shrink: 0;
}
.section-nav::-webkit-scrollbar { display: none; }
.nav-pill {
flex-shrink: 0;
font-size: 0.85rem;
font-weight: 600;
padding: 0 14px;
min-height: 32px;
border-radius: var(--radius-full);
background: var(--color-bg);
color: var(--color-muted);
border: 1px solid var(--color-border);
transition: background var(--transition), color var(--transition), border-color var(--transition);
}
.nav-pill.active {
background: var(--color-primary);
color: #fff;
border-color: var(--color-primary);
}
/* ── Scroll container ────────────────────────── */
.docs-scroll {
flex: 1;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
padding-bottom: calc(env(safe-area-inset-bottom, 0px) + 40px);
}
/* ── Section header ──────────────────────────── */
section { padding-top: 4px; }
section + section { margin-top: 8px; }
.section-head {
display: flex;
align-items: center;
gap: 10px;
padding: 24px 16px 10px;
}
.section-icon {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
/* quadratino con sfondo primario muted — identifica visivamente la sezione */
background: var(--color-primary-muted);
border-radius: var(--radius-sm);
color: var(--color-primary);
flex-shrink: 0;
}
.section-heading {
font-size: 1.1rem;
font-weight: 800;
letter-spacing: -0.02em;
color: var(--color-text);
}
/* ── Card contenuto ──────────────────────────── */
.doc-card {
margin: 0 16px 8px;
background: var(--color-surface);
border-radius: var(--radius);
padding: 14px 16px;
border: 1px solid var(--color-border);
box-shadow: var(--shadow-sm);
font-size: 0.9rem;
line-height: 1.55;
color: var(--color-text);
}
.card-title {
font-size: 0.82rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-muted);
margin-bottom: 8px;
}
/* ── Card tip / suggerimento ─────────────────── */
/* border-left accent: segnala contenuto accessorio senza competere con le card */
.doc-card.tip {
background: var(--color-primary-muted);
border-color: transparent;
border-left: 3px solid var(--color-primary);
box-shadow: none;
}
.tip-label {
display: flex;
align-items: center;
gap: 5px;
font-size: 0.72rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.07em;
color: var(--color-primary);
margin-bottom: 6px;
}
/* ── Lista numerata con badge ────────────────── */
/* counter CSS invece di <span> inline: mantiene semantica <ol> accessibile */
.steps {
list-style: none;
counter-reset: steps;
display: flex;
flex-direction: column;
gap: 9px;
margin-top: 8px;
}
.steps li {
counter-increment: steps;
display: flex;
align-items: flex-start;
gap: 10px;
font-size: 0.9rem;
line-height: 1.5;
}
.steps li::before {
content: counter(steps);
display: flex;
align-items: center;
justify-content: center;
min-width: 22px;
height: 22px;
background: var(--color-primary);
color: #fff;
border-radius: 50%;
font-size: 0.7rem;
font-weight: 700;
flex-shrink: 0;
margin-top: 2px;
}
/* ── Kbd inline ──────────────────────────────── */
kbd {
display: inline-flex;
align-items: center;
padding: 1px 7px;
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: 5px;
font-family: inherit;
font-size: 0.85em;
font-weight: 600;
color: var(--color-text);
}
/* ── Tabella categorie alimenti ──────────────── */
.category-table {
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
overflow: hidden;
margin-top: 10px;
}
.cat-row {
display: flex;
align-items: baseline;
gap: 8px;
padding: 9px 12px;
border-bottom: 1px solid var(--color-border);
font-size: 0.86rem;
}
.cat-row:last-child { border-bottom: none; }
.cat-label {
font-weight: 600;
color: var(--color-text);
flex-shrink: 0;
min-width: 110px;
}
.cat-items { color: var(--color-muted); font-size: 0.82rem; }
/* ── Chip metodi cottura ─────────────────────── */
.methods-row {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 12px;
}
.method-chip {
font-size: 0.78rem;
font-weight: 600;
padding: 3px 10px;
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--radius-full);
color: var(--color-muted);
}
/* ── Footer ──────────────────────────────────── */
.docs-footer {
margin: 24px 16px 0;
text-align: center;
font-size: 0.78rem;
color: var(--color-muted);
opacity: 0.6;
}
</style>
-188
View File
@@ -1,188 +0,0 @@
<template>
<Transition name="fade">
<div v-if="modelValue" class="overlay" @click="$emit('update:modelValue', false)" />
</Transition>
<Transition name="slide-up">
<div v-if="modelValue" class="sheet" role="dialog" aria-label="Informazioni app">
<div class="sheet-handle" />
<div class="sheet-top">
<div class="app-identity">
<img class="app-icon" :src="appIcon" alt="BitePlan" />
<div>
<div class="app-name">BitePlan</div>
<div class="app-version">Versione {{ version }}</div>
</div>
</div>
<button class="btn-x" @click="$emit('update:modelValue', false)" 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>
<div class="info-list">
<div class="info-row">
<span class="info-label">Autore</span>
<span class="info-value">Davide Grilli</span>
</div>
<div class="info-row">
<span class="info-label">Licenza</span>
<span class="info-value">EUPL v1.2</span>
</div>
<button class="info-row info-row--btn" @click="$emit('open-docs')" aria-label="Apri guida">
<span class="info-label">Guida</span>
<span class="info-link">
Apri
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<polyline points="9 18 15 12 9 6"/>
</svg>
</span>
</button>
</div>
</div>
</Transition>
</template>
<script setup>
import pkg from '../../package.json'
import appIcon from '../../assets/icon-only.png'
defineProps({ modelValue: Boolean })
defineEmits(['update:modelValue', 'open-docs'])
const version = pkg.version
</script>
<style scoped>
.overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.4);
z-index: 200;
}
.sheet {
position: fixed;
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 100%;
max-width: 480px;
background: var(--color-surface);
border-radius: var(--radius) var(--radius) 0 0;
padding: 12px 20px 48px;
z-index: 201;
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 24px;
}
/* header: icona app + nome/versione + X */
.sheet-top {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 24px;
}
.app-identity {
display: flex;
align-items: center;
gap: 14px;
}
.app-icon {
width: 48px;
height: 48px;
border-radius: 12px;
object-fit: cover;
}
.app-name {
font-size: 1rem;
font-weight: 700;
color: var(--color-text);
}
.app-version {
font-size: 0.8rem;
color: var(--color-muted);
margin-top: 1px;
}
.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);
}
/* lista righe info */
.info-list {
border: 1px solid var(--color-border);
border-radius: var(--radius);
overflow: hidden;
}
.info-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 14px 16px;
background: var(--color-surface);
border-bottom: 1px solid var(--color-border);
}
.info-row:last-child { border-bottom: none; }
/* riga cliccabile: reset button, mantiene layout identico alle righe statiche */
.info-row--btn {
width: 100%;
min-height: unset;
border-radius: 0;
background: var(--color-surface);
text-align: left;
}
.info-row--btn:active { background: var(--color-bg); opacity: 1; }
.info-label {
font-size: 0.9rem;
color: var(--color-text);
}
.info-value {
font-size: 0.9rem;
color: var(--color-muted);
}
.info-link {
font-size: 0.9rem;
font-weight: 600;
color: var(--color-primary);
text-decoration: none;
display: flex;
align-items: center;
gap: 4px;
}
/* transizioni */
.fade-enter-active, .fade-leave-active { transition: opacity 200ms ease; }
.fade-enter-from, .fade-leave-to { opacity: 0; }
.slide-up-enter-active, .slide-up-leave-active { transition: transform 250ms ease; }
.slide-up-enter-from, .slide-up-leave-to { transform: translateX(-50%) translateY(100%); }
</style>
-197
View File
@@ -1,197 +0,0 @@
<template>
<div class="meal-card" :class="{ open: isOpen }">
<button class="card-header" @click="isOpen = !isOpen" :aria-expanded="isOpen">
<span class="day-name">{{ dayName }}</span>
<span class="day-summary" v-if="!isOpen && totalItems > 0">{{ totalItems }} {{ totalItems === 1 ? 'voce' : 'voci' }}</span>
<span class="chevron" :class="{ rotated: isOpen }">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<polyline points="6 9 12 15 18 9"/>
</svg>
</span>
</button>
<div class="card-body" v-show="isOpen">
<div v-for="slot in slots" :key="slot.id" class="meal-slot">
<div class="slot-header">
<span class="slot-icon">{{ slot.icon }}</span>
<h3 class="slot-label">{{ slot.label }}</h3>
</div>
<ul v-if="meals[slot.id].length" class="item-list">
<li v-for="(item, idx) in meals[slot.id]" :key="idx" class="item-row">
<span class="item-text">{{ item }}</span>
<button class="btn-remove" @click="$emit('remove', slot.id, idx)" :aria-label="`Rimuovi ${item}`"></button>
</li>
</ul>
<div class="input-row">
<input
v-model="inputs[slot.id]"
type="text"
:placeholder="`Aggiungi a ${slot.label.toLowerCase()}...`"
@keyup.enter="submit(slot.id)"
/>
<button class="btn-add" @click="submit(slot.id)" aria-label="Aggiungi">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
</svg>
</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, reactive, computed } from 'vue'
const props = defineProps({
dayName: String,
meals: Object,
defaultOpen: { type: Boolean, default: false },
})
const emit = defineEmits(['add', 'remove'])
const isOpen = ref(props.defaultOpen)
const slots = [
{ id: 'colazione', label: 'Colazione', icon: '☀️' },
{ id: 'pranzo', label: 'Pranzo', icon: '🍽️' },
{ id: 'cena', label: 'Cena', icon: '🌙' },
]
const inputs = reactive({ colazione: '', pranzo: '', cena: '' })
const totalItems = computed(() =>
slots.reduce((sum, s) => sum + props.meals[s.id].length, 0)
)
function submit(slotId) {
const t = inputs[slotId].trim()
if (!t) return
emit('add', slotId, t)
inputs[slotId] = ''
}
</script>
<style scoped>
.meal-card {
background: var(--color-surface);
border-radius: var(--radius);
margin-bottom: 10px;
box-shadow: var(--shadow-sm);
overflow: hidden;
border: 1px solid var(--color-border);
}
.card-header {
width: 100%;
display: flex;
align-items: center;
gap: 8px;
padding: 14px 16px;
background: none;
border-radius: 0;
min-height: unset;
text-align: left;
}
.day-name {
font-size: 1rem;
font-weight: 700;
color: var(--color-text);
flex: 1;
}
/* numero voci mostrato quando la card è chiusa */
.day-summary {
font-size: 0.75rem;
color: var(--color-muted);
background: var(--color-primary-muted);
color: var(--color-primary);
padding: 2px 8px;
border-radius: var(--radius-full);
font-weight: 600;
}
.chevron {
color: var(--color-muted);
display: flex;
transition: transform var(--transition);
}
.chevron.rotated { transform: rotate(180deg); }
.card-body {
padding: 0 16px 16px;
display: flex;
flex-direction: column;
gap: 16px;
}
.meal-slot { display: flex; flex-direction: column; gap: 6px; }
.slot-header {
display: flex;
align-items: center;
gap: 6px;
}
.slot-icon { font-size: 0.9rem; }
.slot-label {
font-size: 0.75rem;
font-weight: 700;
color: var(--color-muted);
text-transform: uppercase;
letter-spacing: 0.06em;
}
.item-list {
list-style: none;
display: flex;
flex-direction: column;
gap: 4px;
}
.item-row {
display: flex;
align-items: center;
justify-content: space-between;
background: var(--color-bg);
border-radius: var(--radius-sm);
padding: 8px 10px;
}
.item-text { font-size: 0.9rem; flex: 1; }
/* touch target 44px garantito con padding */
.btn-remove {
background: none;
color: var(--color-muted);
min-height: 44px;
min-width: 44px;
padding: 0;
font-size: 0.75rem;
display: flex;
align-items: center;
justify-content: center;
}
.btn-remove:active { color: var(--color-danger); }
.input-row { display: flex; gap: 8px; }
.btn-add {
background: var(--color-primary);
color: #fff;
min-width: 48px;
min-height: 48px;
flex-shrink: 0;
padding: 0;
border-radius: var(--radius-sm);
display: flex;
align-items: center;
justify-content: center;
}
</style>
-5
View File
@@ -1,5 +0,0 @@
import { createApp } from 'vue'
import App from './App.vue'
import './style.css'
createApp(App).mount('#app')
-381
View File
@@ -1,381 +0,0 @@
<template>
<div class="page">
<div class="page-header">
<h1 class="page-title">Convertitore</h1>
<p class="page-subtitle">Calcola il peso cotto dal crudo e viceversa</p>
</div>
<!-- step 1: ricerca -->
<div class="search-wrapper">
<div class="search-field">
<svg class="search-icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
</svg>
<input
v-model="query"
type="text"
placeholder="Cerca alimento…"
@input="onSearch"
:disabled="!!selected"
/>
</div>
<ul v-if="results.length && !selected" class="results-list">
<li
v-for="r in results"
:key="r.key"
class="result-item"
@click="selectItem(r)"
>
<span class="result-food">{{ capFirst(r.food) }}</span>
<span class="result-method">{{ capFirst(r.method) }}</span>
</li>
</ul>
</div>
<!-- step 2: converter card -->
<div v-if="selected" class="converter-card">
<div class="card-top">
<div class="food-info">
<span class="food-name">{{ capFirst(selected.food) }}</span>
<span class="food-sep">·</span>
<span class="food-method">{{ capFirst(selected.method) }}</span>
</div>
<button class="btn-reset" @click="reset" aria-label="Cambia alimento">
Cambia
</button>
</div>
<div class="card-divider" />
<!-- riga input swap output -->
<div class="calc-row">
<div class="calc-side input-side">
<div class="calc-label">{{ direction === 'rawToCooked' ? 'crudo' : 'cotto' }}</div>
<div class="calc-input-wrap">
<input
v-model.number="grams"
type="number"
min="0"
placeholder="0"
class="calc-input"
/>
<span class="calc-unit">g</span>
</div>
</div>
<button class="btn-swap" @click="swapDirection" aria-label="Inverti direzione">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="17 1 21 5 17 9"/>
<path d="M3 11V9a4 4 0 014-4h14"/>
<polyline points="7 23 3 19 7 15"/>
<path d="M21 13v2a4 4 0 01-4 4H3"/>
</svg>
</button>
<div class="calc-side output-side">
<div class="calc-label">{{ direction === 'rawToCooked' ? 'cotto' : 'crudo' }}</div>
<div class="calc-output">
<span v-if="result !== null" class="output-value">{{ result }}</span>
<span v-else class="output-placeholder"></span>
<span v-if="result !== null" class="calc-unit">g</span>
</div>
</div>
</div>
<div v-if="result !== null" class="card-footer">
fattore di resa {{ yieldValue }} · {{ direction === 'rawToCooked' ? 'da mangiare cotti' : 'peso crudo equivalente' }}
</div>
</div>
<!-- stato iniziale -->
<div v-if="!selected && !results.length && !query" class="hint-state">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
</svg>
<p>Cerca un alimento per iniziare</p>
<p class="hint-sub">es. pollo, riso, zucchine</p>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
import db from '../data/conversions.json'
import { rawToCooked, cookedToRaw } from '../utils/conversion.js'
const query = ref('')
const results = ref([])
const selected = ref(null)
const direction = ref('rawToCooked')
const grams = ref(null)
const yieldValue = computed(() => {
if (!selected.value) return ''
return db[selected.value.food][selected.value.method].yield
})
const result = computed(() => {
if (!selected.value || !grams.value || grams.value <= 0) return null
const { food, method } = selected.value
const val = direction.value === 'rawToCooked'
? rawToCooked(food, method, grams.value, db)
: cookedToRaw(food, method, grams.value, db)
return Math.round(val * 10) / 10
})
function onSearch() {
const q = query.value.toLowerCase().trim()
if (!q) { results.value = []; return }
const matches = []
for (const food of Object.keys(db)) {
if (food.includes(q)) {
for (const method of Object.keys(db[food])) {
matches.push({ key: `${food}-${method}`, food, method })
}
}
}
results.value = matches
}
function selectItem(r) {
selected.value = r
query.value = ''
results.value = []
grams.value = null
}
function reset() {
selected.value = null
query.value = ''
results.value = []
grams.value = null
direction.value = 'rawToCooked'
}
function capFirst(s) {
return s ? s.charAt(0).toUpperCase() + s.slice(1) : s
}
function swapDirection() {
direction.value = direction.value === 'rawToCooked' ? 'cookedToRaw' : 'rawToCooked'
grams.value = null
}
</script>
<style scoped>
/* ── ricerca ───────────────────────────────────────── */
.search-wrapper { margin-bottom: 16px; }
.search-field {
position: relative;
display: flex;
align-items: center;
}
.search-icon {
position: absolute;
left: 14px;
color: var(--color-muted);
pointer-events: none;
}
.search-field input {
padding-left: 42px;
}
.results-list {
list-style: none;
background: var(--color-surface);
border: 1.5px solid var(--color-border);
border-top: none;
border-radius: 0 0 var(--radius) var(--radius);
overflow: hidden;
box-shadow: var(--shadow-sm);
}
.result-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
cursor: pointer;
border-bottom: 1px solid var(--color-border);
transition: background var(--transition);
}
.result-item:last-child { border-bottom: none; }
.result-item:active { background: var(--color-bg); }
.result-food { font-weight: 600; }
.result-method { font-size: 0.85rem; color: var(--color-muted); }
/* ── converter card ───────────────────────────────── */
.converter-card {
background: var(--color-surface);
border-radius: var(--radius);
border: 1.5px solid var(--color-border);
box-shadow: var(--shadow-md);
overflow: hidden;
}
.card-top {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
}
.food-info {
display: flex;
align-items: center;
gap: 6px;
}
.food-name { font-weight: 700; font-size: 1rem; }
.food-sep { color: var(--color-border); font-size: 1.1rem; }
.food-method { font-size: 0.9rem; color: var(--color-muted); }
.btn-reset {
color: var(--color-primary);
font-size: 0.85rem;
font-weight: 600;
min-height: unset;
padding: 4px 10px;
border: 1.5px solid var(--color-primary-muted);
border-radius: var(--radius-full);
background: var(--color-primary-muted);
}
.card-divider {
height: 1px;
background: var(--color-border);
margin: 0 16px;
}
/* ── riga calcolatrice ───────────────────────────── */
.calc-row {
display: flex;
align-items: center;
padding: 20px 16px;
gap: 12px;
}
.calc-side {
flex: 1;
display: flex;
flex-direction: column;
gap: 6px;
}
.calc-label {
font-size: 0.72rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--color-muted);
}
.calc-input-wrap {
display: flex;
align-items: center;
gap: 6px;
}
/* override globale per input dentro la card */
.calc-input {
font-size: 1.6rem;
font-weight: 700;
letter-spacing: -0.02em;
border: none;
border-bottom: 2px solid var(--color-border);
border-radius: 0;
padding: 4px 0;
min-height: unset;
background: transparent;
width: 100%;
color: var(--color-text);
transition: border-color var(--transition);
}
.calc-input:focus {
outline: none;
box-shadow: none;
border-bottom-color: var(--color-primary);
}
.calc-unit {
font-size: 1rem;
font-weight: 600;
color: var(--color-muted);
align-self: flex-end;
padding-bottom: 6px;
}
/* bottone swap centrale */
.btn-swap {
background: var(--color-bg);
color: var(--color-primary);
min-height: 44px;
min-width: 44px;
padding: 0;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
border: 1.5px solid var(--color-border);
transition: background var(--transition), transform var(--transition);
}
.btn-swap:active {
background: var(--color-primary-muted);
transform: rotate(180deg);
opacity: 1;
}
/* colonna output: underline visivo per simmetria con il lato input */
.calc-output {
display: flex;
align-items: center;
gap: 4px;
min-height: 44px;
border-bottom: 2px solid var(--color-border);
padding-bottom: 4px;
}
.output-value {
font-size: 1.6rem;
font-weight: 800;
letter-spacing: -0.03em;
color: var(--color-primary);
line-height: 1;
}
.output-placeholder {
font-size: 1.6rem;
font-weight: 300;
color: var(--color-border);
line-height: 1;
}
/* ── footer card ─────────────────────────────────── */
.card-footer {
padding: 10px 16px 14px;
font-size: 0.78rem;
color: var(--color-muted);
border-top: 1px solid var(--color-border);
text-align: center;
}
/* ── stato iniziale ──────────────────────────────── */
.hint-state {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 60px 20px;
color: var(--color-muted);
}
.hint-sub { font-size: 0.85rem; opacity: 0.6; }
</style>
-505
View File
@@ -1,505 +0,0 @@
<template>
<div class="page">
<div class="page-header">
<h1 class="page-title">Piano Pasti</h1>
<p class="page-subtitle">{{ todayDate }}</p>
</div>
<MealCard
v-for="day in days"
:key="day.id"
:day-name="day.label"
:meals="meals[day.id]"
:default-open="day.id === todayId"
@add="(slot, text) => addItem(day.id, slot, text)"
@remove="(slot, idx) => removeItem(day.id, slot, idx)"
/>
<button class="btn-generate" @click="generateShopping">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M6 2L3 6v14a2 2 0 002 2h14a2 2 0 002-2V6l-3-4z"/>
<line x1="3" y1="6" x2="21" y2="6"/>
<path d="M16 10a4 4 0 01-8 0"/>
</svg>
Genera lista della spesa
</button>
<button v-if="hasMeals" class="btn-clear" @click="clearAll">Svuota piano</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>
<!-- 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>
<script setup>
import { reactive, watch, ref, computed, onUnmounted, nextTick } from 'vue'
import MealCard from '../components/MealCard.vue'
import { save, load } from '../utils/storage.js'
import QRCode from 'qrcode'
import jsQR from 'jsqr'
const emit = defineEmits(['go-shop'])
const days = [
{ id: 'lunedi', label: 'Lunedì' },
{ id: 'martedi', label: 'Martedì' },
{ id: 'mercoledi', label: 'Mercoledì' },
{ id: 'giovedi', label: 'Giovedì' },
{ id: 'venerdi', label: 'Venerdì' },
{ id: 'sabato', label: 'Sabato' },
{ id: 'domenica', label: 'Domenica' },
]
const todayMap = ['domenica', 'lunedi', 'martedi', 'mercoledi', 'giovedi', 'venerdi', 'sabato']
const todayId = todayMap[new Date().getDay()]
const todayDate = 'Oggi, ' + new Date().toLocaleDateString('it-IT', { weekday: 'long', day: 'numeric', month: 'long' })
const defaultMeals = () =>
Object.fromEntries(days.map(d => [d.id, { colazione: [], pranzo: [], cena: [] }]))
const meals = reactive(load('meals', defaultMeals()))
watch(meals, () => save('meals', meals), { deep: true })
function addItem(day, slot, text) {
const t = text.trim()
if (t) meals[day][slot].push(t)
}
function removeItem(day, slot, idx) {
meals[day][slot].splice(idx, 1)
}
const hasMeals = computed(() =>
days.some(d => ['colazione', 'pranzo', 'cena'].some(s => meals[d.id][s].length > 0))
)
function clearAll() {
if (confirm('Svuotare tutto il piano settimanale?')) Object.assign(meals, defaultMeals())
}
function generateShopping() {
const allItems = days.flatMap(d =>
['colazione', 'pranzo', 'cena'].flatMap(slot => meals[d.id][slot])
)
const existing = load('shopping', [])
const existingNames = new Set(existing.map(i => i.name.toLowerCase()))
const seen = new Set()
const toAdd = []
for (const name of allItems) {
const key = name.toLowerCase()
if (!existingNames.has(key) && !seen.has(key)) {
seen.add(key)
toAdd.push({ id: Date.now() + Math.random(), name, checked: false })
}
}
save('shopping', [...existing, ...toAdd])
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>
<style scoped>
.btn-generate {
width: 100%;
margin-top: 8px;
background: var(--color-primary);
color: #fff;
font-weight: 600;
font-size: 0.95rem;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 48px;
border-radius: var(--radius);
}
.btn-generate:active {
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>
-161
View File
@@ -1,161 +0,0 @@
<template>
<div class="page">
<div class="page-header">
<h1 class="page-title">Lista della spesa</h1>
<p class="page-subtitle" v-if="items.length">
{{ checkedCount }} / {{ items.length }} completat{{ checkedCount === 1 ? 'o' : 'i' }}
</p>
</div>
<div class="add-row">
<input
v-model="newItem"
type="text"
placeholder="Aggiungi elemento..."
@keyup.enter="add"
/>
<button class="btn-add" @click="add" aria-label="Aggiungi">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
</svg>
</button>
</div>
<template v-if="items.length">
<ul class="shop-list">
<CheckboxItem
v-for="item in pendingItems"
:key="item.id"
:item="item"
@toggle="toggle(item.id)"
@remove="remove(item.id)"
/>
</ul>
<template v-if="checkedCount > 0">
<div class="section-divider">
<span>Completati ({{ checkedCount }})</span>
</div>
<ul class="shop-list muted">
<CheckboxItem
v-for="item in checkedItems"
:key="item.id"
:item="item"
@toggle="toggle(item.id)"
@remove="remove(item.id)"
/>
</ul>
</template>
<button class="btn-clear" @click="clearAll">Svuota lista</button>
</template>
<div v-else class="empty-state">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" style="color: var(--color-border)">
<path d="M6 2L3 6v14a2 2 0 002 2h14a2 2 0 002-2V6l-3-4z"/>
<line x1="3" y1="6" x2="21" y2="6"/>
<path d="M16 10a4 4 0 01-8 0"/>
</svg>
<p>Lista vuota</p>
<p class="empty-hint">Aggiungi qualcosa con il campo qui sopra.</p>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import CheckboxItem from '../components/CheckboxItem.vue'
import { save, load } from '../utils/storage.js'
const items = ref(load('shopping', []))
const newItem = ref('')
const pendingItems = computed(() => items.value.filter(i => !i.checked))
const checkedItems = computed(() => items.value.filter(i => i.checked))
const checkedCount = computed(() => checkedItems.value.length)
watch(items, () => save('shopping', items.value), { deep: true })
function add() {
const t = newItem.value.trim()
if (!t) return
items.value.push({ id: Date.now(), name: t, checked: false })
newItem.value = ''
}
function toggle(id) {
const item = items.value.find(i => i.id === id)
if (item) item.checked = !item.checked
}
function remove(id) {
items.value = items.value.filter(i => i.id !== id)
}
function clearAll() {
if (confirm('Svuotare tutta la lista?')) items.value = []
}
</script>
<style scoped>
.add-row {
display: flex;
gap: 8px;
margin-bottom: 16px;
}
.btn-add {
background: var(--color-primary);
color: #fff;
min-width: 48px;
min-height: 48px;
flex-shrink: 0;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-sm);
}
.shop-list {
list-style: none;
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 8px;
}
.section-divider {
display: flex;
align-items: center;
gap: 8px;
margin: 12px 0 8px;
font-size: 0.75rem;
font-weight: 600;
color: var(--color-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.section-divider::after {
content: '';
flex: 1;
height: 1px;
background: var(--color-border);
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 60px 20px;
color: var(--color-muted);
}
.empty-hint {
font-size: 0.85rem;
color: var(--color-muted);
opacity: 0.7;
}
</style>
-117
View File
@@ -1,117 +0,0 @@
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
:root {
--color-bg: #f0f4f1;
--color-surface: #ffffff;
--color-primary: #2d6a4f;
--color-primary-light: #52b788;
--color-primary-muted: #e8f5ee;
--color-text: #1a1a1a;
--color-muted: #6b7280;
--color-border: #e2e8e4;
--color-danger: #dc2626;
--color-danger-muted: #fef2f2;
--radius: 12px;
--radius-sm: 8px;
--radius-full: 999px;
--nav-height: 64px;
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.04);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.08), 0 2px 4px rgba(0, 0, 0, 0.04);
--transition: 150ms ease;
}
html, body {
height: 100%;
font-family: system-ui, -apple-system, sans-serif;
font-size: 16px;
background: var(--color-bg);
color: var(--color-text);
-webkit-font-smoothing: antialiased;
}
#app {
height: 100%;
display: flex;
flex-direction: column;
max-width: 480px;
margin: 0 auto;
background: var(--color-bg);
}
.page {
flex: 1;
overflow-y: auto;
padding: 20px 16px;
padding-bottom: calc(var(--nav-height) + 20px);
}
.page-header {
margin-bottom: 20px;
}
.page-title {
font-size: 1.4rem;
font-weight: 800;
letter-spacing: -0.02em;
color: var(--color-text);
}
.page-subtitle {
font-size: 0.85rem;
color: var(--color-muted);
margin-top: 2px;
}
button {
font-size: 1rem;
cursor: pointer;
border: none;
border-radius: var(--radius-sm);
min-height: 44px;
padding: 0 16px;
transition: opacity var(--transition), background var(--transition), color var(--transition);
}
button:active { opacity: 0.75; }
/* bottone distruttivo in ghost style — meno aggressivo del solid rosso */
.btn-clear {
width: 100%;
background: transparent;
color: var(--color-danger);
border: 1.5px solid var(--color-danger);
margin-top: 16px;
font-weight: 600;
}
.btn-clear:active {
background: var(--color-danger-muted);
opacity: 1;
}
input[type="text"],
input[type="number"] {
font-size: 1rem;
border: 1.5px solid var(--color-border);
border-radius: var(--radius-sm);
padding: 10px 14px;
min-height: 48px;
width: 100%;
background: var(--color-surface);
color: var(--color-text);
transition: border-color var(--transition), box-shadow var(--transition);
}
input:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 3px rgba(45, 106, 79, 0.12);
}
input::placeholder {
color: #b0bab4;
}
-2
View File
@@ -1,2 +0,0 @@
export const rawToCooked = (food, method, raw, db) => raw * db[food][method].yield
export const cookedToRaw = (food, method, cooked, db) => cooked / db[food][method].yield
-5
View File
@@ -1,5 +0,0 @@
export const save = (key, val) => localStorage.setItem(key, JSON.stringify(val))
export const load = (key, def) => {
const v = localStorage.getItem(key)
return v ? JSON.parse(v) : def
}
@@ -0,0 +1,74 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:biteplan/features/converter/models/conversion_entry.dart';
void main() {
group('ConversionEntry', () {
test('rawToCooked moltiplica per il fattore di resa', () {
const entry = ConversionEntry(
food: 'riso basmati',
method: 'bollitura',
yieldFactor: 3.0,
);
expect(entry.rawToCooked(100), closeTo(300.0, 0.001));
expect(entry.rawToCooked(70), closeTo(210.0, 0.001));
});
test('cookedToRaw divide per il fattore di resa', () {
const entry = ConversionEntry(
food: 'riso basmati',
method: 'bollitura',
yieldFactor: 3.0,
);
expect(entry.cookedToRaw(300), closeTo(100.0, 0.001));
expect(entry.cookedToRaw(150), closeTo(50.0, 0.001));
});
test('conversione bidirezionale è consistente', () {
const entry = ConversionEntry(
food: 'pollo petto',
method: 'forno',
yieldFactor: 0.67,
);
final cooked = entry.rawToCooked(140);
final backToRaw = entry.cookedToRaw(cooked);
expect(backToRaw, closeTo(140, 0.001));
});
test('yield > 1: i cereali aumentano di peso con la cottura', () {
const entry = ConversionEntry(
food: 'pasta',
method: 'bollitura',
yieldFactor: 2.1,
);
expect(entry.rawToCooked(80), greaterThan(80));
});
test('yield < 1: la carne perde peso con la cottura', () {
const entry = ConversionEntry(
food: 'pollo petto',
method: 'forno',
yieldFactor: 0.67,
);
expect(entry.rawToCooked(100), lessThan(100));
});
test('valore limite: 0 grammi restituisce 0', () {
const entry = ConversionEntry(
food: 'riso',
method: 'bollitura',
yieldFactor: 3.0,
);
expect(entry.rawToCooked(0), 0.0);
expect(entry.cookedToRaw(0), 0.0);
});
test('yield = 0: rawToCooked restituisce 0', () {
const entry = ConversionEntry(
food: 'test',
method: 'test',
yieldFactor: 0.0,
);
expect(entry.rawToCooked(100), 0.0);
});
});
}
@@ -0,0 +1,191 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:biteplan/features/converter/providers/converter_provider.dart';
import 'package:biteplan/features/converter/models/conversion_entry.dart';
void main() {
group('ConverterProvider', () {
late ConverterProvider provider;
setUp(() {
provider = ConverterProvider();
});
group('stato iniziale', () {
test('results è vuoto', () {
expect(provider.results, isEmpty);
});
test('selected è null', () {
expect(provider.selected, isNull);
});
test('grams è null', () {
expect(provider.grams, isNull);
});
test('rawToCooked è true', () {
expect(provider.rawToCooked, true);
});
test('result è null', () {
expect(provider.result, isNull);
});
});
group('select()', () {
const entry = ConversionEntry(food: 'riso', method: 'bollitura', yieldFactor: 3.0);
test('imposta l\'alimento selezionato', () {
provider.select(entry);
expect(provider.selected, entry);
});
test('azzera i grammi', () {
provider.setGrams(100);
provider.select(entry);
expect(provider.grams, isNull);
});
test('svuota i risultati', () {
provider.select(entry);
expect(provider.results, isEmpty);
});
test('notifica i listener', () {
var notified = false;
provider.addListener(() => notified = true);
provider.select(entry);
expect(notified, true);
});
});
group('setGrams()', () {
test('aggiorna il valore', () {
provider.setGrams(150.0);
expect(provider.grams, 150.0);
});
test('accetta null', () {
provider.setGrams(100.0);
provider.setGrams(null);
expect(provider.grams, isNull);
});
test('notifica i listener', () {
var notified = false;
provider.addListener(() => notified = true);
provider.setGrams(100.0);
expect(notified, true);
});
});
group('result', () {
const entry = ConversionEntry(food: 'riso', method: 'bollitura', yieldFactor: 3.0);
test('null se nessun alimento selezionato', () {
provider.setGrams(100.0);
expect(provider.result, isNull);
});
test('null se grammi non impostati', () {
provider.select(entry);
expect(provider.result, isNull);
});
test('null se grammi sono 0', () {
provider.select(entry);
provider.setGrams(0.0);
expect(provider.result, isNull);
});
test('null se grammi sono negativi', () {
provider.select(entry);
provider.setGrams(-10.0);
expect(provider.result, isNull);
});
test('calcola crudo→cotto', () {
provider.select(entry);
provider.setGrams(100.0);
expect(provider.result, closeTo(300.0, 0.05));
});
test('calcola cotto→crudo dopo swapDirection', () {
provider.select(entry);
provider.swapDirection();
provider.setGrams(300.0);
expect(provider.result, closeTo(100.0, 0.05));
});
test('arrotonda a 1 decimale', () {
const e = ConversionEntry(food: 'pollo', method: 'forno', yieldFactor: 0.67);
provider.select(e);
provider.setGrams(33.0);
// 33 * 0.67 = 22.11 → arrotondato a 22.1
expect(provider.result, closeTo(22.1, 0.05));
});
});
group('swapDirection()', () {
test('inverte la direzione', () {
expect(provider.rawToCooked, true);
provider.swapDirection();
expect(provider.rawToCooked, false);
provider.swapDirection();
expect(provider.rawToCooked, true);
});
test('azzera i grammi', () {
provider.setGrams(100.0);
provider.swapDirection();
expect(provider.grams, isNull);
});
test('notifica i listener', () {
var notified = false;
provider.addListener(() => notified = true);
provider.swapDirection();
expect(notified, true);
});
});
group('reset()', () {
test('azzera tutti i campi', () {
provider.select(const ConversionEntry(food: 'riso', method: 'bollitura', yieldFactor: 3.0));
provider.setGrams(100.0);
provider.swapDirection();
provider.reset();
expect(provider.selected, isNull);
expect(provider.grams, isNull);
expect(provider.rawToCooked, true);
expect(provider.results, isEmpty);
});
test('notifica i listener', () {
var notified = false;
provider.addListener(() => notified = true);
provider.reset();
expect(notified, true);
});
});
group('search()', () {
test('risultati vuoti con query vuota', () {
provider.search('');
expect(provider.results, isEmpty);
});
test('risultati vuoti con solo spazi', () {
provider.search(' ');
expect(provider.results, isEmpty);
});
test('notifica i listener', () {
var notified = false;
provider.addListener(() => notified = true);
provider.search('riso');
expect(notified, true);
});
});
});
}
@@ -0,0 +1,100 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:biteplan/features/meal_planner/models/meal_plan.dart';
void main() {
group('DayPlan', () {
test('crea con liste vuote di default', () {
final day = DayPlan();
expect(day.colazione, isEmpty);
expect(day.pranzo, isEmpty);
expect(day.cena, isEmpty);
});
test('slot() ritorna la lista corretta', () {
final day = DayPlan(
colazione: ['latte'],
pranzo: ['pasta'],
cena: ['pollo'],
);
expect(day.slot('colazione'), ['latte']);
expect(day.slot('pranzo'), ['pasta']);
expect(day.slot('cena'), ['pollo']);
expect(day.slot('unknown'), isEmpty);
});
test('copyWith() non modifica lorigine', () {
final day = DayPlan(colazione: ['latte']);
final updated = day.copyWith(colazione: ['latte', 'caffè']);
expect(updated.colazione, ['latte', 'caffè']);
expect(day.colazione, ['latte']);
});
test('copyWith() preserva i campi non aggiornati', () {
final day = DayPlan(colazione: ['latte'], pranzo: ['pasta']);
final updated = day.copyWith(colazione: ['caffè']);
expect(updated.pranzo, ['pasta']);
});
test('JSON round-trip preserva i dati', () {
final day = DayPlan(colazione: ['latte', 'biscotti'], cena: ['pollo']);
final restored = DayPlan.fromJson(day.toJson());
expect(restored.colazione, ['latte', 'biscotti']);
expect(restored.cena, ['pollo']);
expect(restored.pranzo, isEmpty);
});
});
group('MealPlan', () {
test('empty() crea piano con tutti i giorni', () {
const days = ['lunedi', 'martedi', 'mercoledi'];
final plan = MealPlan.empty(days);
expect(plan.days.keys, containsAll(days));
for (final d in days) {
expect(plan.days[d]!.colazione, isEmpty);
}
});
test('hasAnyMeal è false con piano vuoto', () {
final plan = MealPlan.empty(['lunedi', 'martedi']);
expect(plan.hasAnyMeal, false);
});
test('hasAnyMeal è true con almeno un elemento', () {
final plan = MealPlan({'lunedi': DayPlan(colazione: ['latte'])});
expect(plan.hasAnyMeal, true);
});
test('allItems ritorna lista appiattita', () {
final plan = MealPlan({
'lunedi': DayPlan(colazione: ['latte'], pranzo: ['pasta']),
'martedi': DayPlan(cena: ['pollo']),
});
expect(plan.allItems, containsAll(['latte', 'pasta', 'pollo']));
expect(plan.allItems.length, 3);
});
test('allItems è vuota con piano vuoto', () {
final plan = MealPlan.empty(['lunedi']);
expect(plan.allItems, isEmpty);
});
test('withUpdatedDay() aggiorna solo il giorno specificato', () {
final plan = MealPlan.empty(['lunedi', 'martedi']);
final updated =
plan.withUpdatedDay('lunedi', DayPlan(colazione: ['latte']));
expect(updated.days['lunedi']!.colazione, ['latte']);
expect(updated.days['martedi']!.colazione, isEmpty);
});
test('JSON round-trip preserva tutti i dati', () {
final original = MealPlan({
'lunedi': DayPlan(colazione: ['latte', 'biscotti'], cena: ['pollo']),
'martedi': DayPlan(pranzo: ['pasta al pomodoro']),
});
final restored = MealPlan.fromJsonString(original.toJsonString());
expect(restored.days['lunedi']!.colazione, ['latte', 'biscotti']);
expect(restored.days['lunedi']!.cena, ['pollo']);
expect(restored.days['martedi']!.pranzo, ['pasta al pomodoro']);
});
});
}
@@ -0,0 +1,140 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:biteplan/features/meal_planner/providers/meal_planner_provider.dart';
import 'package:biteplan/features/meal_planner/models/meal_plan.dart';
import 'package:biteplan/core/constants/app_constants.dart';
void main() {
setUp(() {
SharedPreferences.setMockInitialValues({});
});
group('MealPlannerProvider', () {
test('inizia con piano vuoto', () async {
final provider = MealPlannerProvider();
await provider.load();
expect(provider.plan.hasAnyMeal, false);
});
test('addItem aggiunge nello slot corretto', () async {
final provider = MealPlannerProvider();
await provider.load();
provider.addItem('lunedi', 'colazione', 'latte');
expect(provider.plan.days['lunedi']!.colazione, ['latte']);
expect(provider.plan.days['lunedi']!.pranzo, isEmpty);
});
test('addItem aggiunge in tutti e tre gli slot', () async {
final provider = MealPlannerProvider();
await provider.load();
provider.addItem('martedi', 'colazione', 'yogurt');
provider.addItem('martedi', 'pranzo', 'pasta');
provider.addItem('martedi', 'cena', 'pollo');
expect(provider.plan.days['martedi']!.colazione, ['yogurt']);
expect(provider.plan.days['martedi']!.pranzo, ['pasta']);
expect(provider.plan.days['martedi']!.cena, ['pollo']);
});
test('addItem ignora testo vuoto o solo spazi', () async {
final provider = MealPlannerProvider();
await provider.load();
provider.addItem('lunedi', 'colazione', ' ');
provider.addItem('lunedi', 'colazione', '');
expect(provider.plan.days['lunedi']!.colazione, isEmpty);
});
test('addItem esegue trim del testo', () async {
final provider = MealPlannerProvider();
await provider.load();
provider.addItem('lunedi', 'pranzo', ' pasta ');
expect(provider.plan.days['lunedi']!.pranzo, ['pasta']);
});
test('removeItem rimuove dalla posizione corretta', () async {
final provider = MealPlannerProvider();
await provider.load();
provider.addItem('lunedi', 'pranzo', 'pasta');
provider.addItem('lunedi', 'pranzo', 'insalata');
provider.addItem('lunedi', 'pranzo', 'frutta');
provider.removeItem('lunedi', 'pranzo', 1);
expect(provider.plan.days['lunedi']!.pranzo, ['pasta', 'frutta']);
});
test('clearAll svuota tutti i giorni', () async {
final provider = MealPlannerProvider();
await provider.load();
provider.addItem('lunedi', 'colazione', 'latte');
provider.addItem('venerdi', 'cena', 'pesce');
provider.clearAll();
expect(provider.plan.hasAnyMeal, false);
});
test('persiste e ricarica i dati', () async {
final provider1 = MealPlannerProvider();
await provider1.load();
provider1.addItem('lunedi', 'cena', 'pollo');
await Future.delayed(const Duration(milliseconds: 50));
final provider2 = MealPlannerProvider();
await provider2.load();
expect(provider2.plan.days['lunedi']!.cena, ['pollo']);
});
test('notifica i listener dopo addItem', () async {
final provider = MealPlannerProvider();
await provider.load();
var notified = false;
provider.addListener(() => notified = true);
provider.addItem('lunedi', 'colazione', 'latte');
expect(notified, true);
});
group('importPlan()', () {
test('sovrascrive il piano corrente', () async {
final provider = MealPlannerProvider();
await provider.load();
provider.addItem('lunedi', 'colazione', 'latte');
final newPlan = MealPlan({
...Map.fromEntries(kDayIds.map((d) => MapEntry(d, DayPlan()))),
'martedi': DayPlan(pranzo: ['pasta']),
});
provider.importPlan(newPlan);
expect(provider.plan.days['lunedi']!.colazione, isEmpty);
expect(provider.plan.days['martedi']!.pranzo, ['pasta']);
});
test('persiste il piano importato', () async {
final provider1 = MealPlannerProvider();
await provider1.load();
final newPlan = MealPlan({
...Map.fromEntries(kDayIds.map((d) => MapEntry(d, DayPlan()))),
'mercoledi': DayPlan(cena: ['pesce']),
});
provider1.importPlan(newPlan);
await Future.delayed(const Duration(milliseconds: 50));
final provider2 = MealPlannerProvider();
await provider2.load();
expect(provider2.plan.days['mercoledi']!.cena, ['pesce']);
});
test('notifica i listener', () async {
final provider = MealPlannerProvider();
await provider.load();
var notified = false;
provider.addListener(() => notified = true);
provider.importPlan(MealPlan.empty(kDayIds));
expect(notified, true);
});
});
test('load gestisce JSON corrotto con piano vuoto', () async {
SharedPreferences.setMockInitialValues({'meals': 'json non valido {'});
final provider = MealPlannerProvider();
await provider.load();
expect(provider.plan.hasAnyMeal, false);
});
});
}
+104
View File
@@ -0,0 +1,104 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:biteplan/features/meal_planner/qr_codec.dart';
import 'package:biteplan/features/meal_planner/models/meal_plan.dart';
import 'package:biteplan/core/constants/app_constants.dart';
void main() {
group('buildQrPayload', () {
test('genera payload non nullo per piano vuoto', () {
final plan = MealPlan.empty(kDayIds);
expect(buildQrPayload(plan), isNotNull);
});
test('il payload contiene versione e dati pasti', () {
final plan = MealPlan.empty(kDayIds);
final payload = buildQrPayload(plan)!;
expect(payload, contains('"v":1'));
expect(payload, contains('"meals"'));
});
test('il payload include i dati inseriti', () {
final plan = MealPlan({
...Map.fromEntries(kDayIds.map((d) => MapEntry(d, DayPlan()))),
'lunedi': DayPlan(colazione: ['latte', 'biscotti'], pranzo: ['pasta']),
});
final payload = buildQrPayload(plan)!;
expect(payload, contains('latte'));
expect(payload, contains('biscotti'));
expect(payload, contains('pasta'));
});
test('restituisce null se payload supera 2953 byte', () {
final longName = 'a' * 400;
final plan = MealPlan(Map.fromEntries(kDayIds.map((d) => MapEntry(
d,
DayPlan(
colazione: List.generate(5, (_) => longName),
pranzo: List.generate(5, (_) => longName),
cena: List.generate(5, (_) => longName),
),
))));
expect(buildQrPayload(plan), isNull);
});
});
group('parseMealPlanFromQr', () {
test('round-trip: parse di payload generato da buildQrPayload', () {
final plan = MealPlan.empty(kDayIds);
final payload = buildQrPayload(plan)!;
final (parsed, error) = parseMealPlanFromQr(payload);
expect(error, isNull);
expect(parsed, isNotNull);
for (final day in kDayIds) {
expect(parsed!.days.containsKey(day), true);
}
});
test('round-trip preserva tutti i pasti', () {
final original = MealPlan({
...Map.fromEntries(kDayIds.map((d) => MapEntry(d, DayPlan()))),
'lunedi': DayPlan(colazione: ['latte'], pranzo: ['pasta'], cena: ['pollo']),
'martedi': DayPlan(cena: ['pesce']),
});
final payload = buildQrPayload(original)!;
final (parsed, error) = parseMealPlanFromQr(payload);
expect(error, isNull);
expect(parsed!.days['lunedi']!.colazione, ['latte']);
expect(parsed.days['lunedi']!.pranzo, ['pasta']);
expect(parsed.days['lunedi']!.cena, ['pollo']);
expect(parsed.days['martedi']!.cena, ['pesce']);
expect(parsed.days['mercoledi']!.colazione, isEmpty);
});
test('errore su stringa non JSON', () {
final (plan, error) = parseMealPlanFromQr('testo non json');
expect(plan, isNull);
expect(error, isNotNull);
});
test('errore se versione non è 1', () {
final (plan, error) = parseMealPlanFromQr('{"v":2,"meals":{}}');
expect(plan, isNull);
expect(error, contains('non valido'));
});
test('errore se meals è una lista invece di una mappa', () {
final (plan, error) = parseMealPlanFromQr('{"v":1,"meals":[]}');
expect(plan, isNull);
expect(error, isNotNull);
});
test('errore se struttura di un giorno non è valida', () {
final (plan, error) = parseMealPlanFromQr('{"v":1,"meals":{"lunedi":"stringa"}}');
expect(plan, isNull);
expect(error, isNotNull);
});
test('errore se campo v è assente', () {
final (plan, error) = parseMealPlanFromQr('{"meals":{}}');
expect(plan, isNull);
expect(error, isNotNull);
});
});
}
@@ -0,0 +1,104 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:biteplan/features/meal_planner/presentation/widgets/meal_card.dart';
import 'package:biteplan/features/meal_planner/models/meal_plan.dart';
import '../../../helpers/pump_app.dart';
Widget _card({
DayPlan? dayPlan,
bool initiallyExpanded = false,
void Function(String, String)? onAdd,
void Function(String, int)? onRemove,
}) =>
Scaffold(
body: SingleChildScrollView(
child: MealCard(
dayId: 'lunedi',
dayLabel: 'Lunedì',
dayPlan: dayPlan ?? DayPlan(),
initiallyExpanded: initiallyExpanded,
onAdd: onAdd ?? (_, __) {},
onRemove: onRemove ?? (_, __) {},
),
),
);
void main() {
group('MealCard', () {
testWidgets('mostra il nome del giorno', (tester) async {
await tester.pumpApp(_card());
expect(find.text('Lunedì'), findsOneWidget);
});
testWidgets('mostra il conteggio voci nel sottotitolo', (tester) async {
await tester.pumpApp(_card(
dayPlan: DayPlan(colazione: ['latte', 'caffè'], pranzo: ['pasta']),
));
expect(find.text('3 voci'), findsOneWidget);
});
testWidgets('usa forma singolare per 1 voce', (tester) async {
await tester.pumpApp(_card(dayPlan: DayPlan(colazione: ['latte'])));
expect(find.text('1 voce'), findsOneWidget);
});
testWidgets('nessun sottotitolo quando il giorno è vuoto', (tester) async {
await tester.pumpApp(_card());
expect(find.text('0 voci'), findsNothing);
expect(find.text('voce'), findsNothing);
});
testWidgets('mostra gli elementi quando espanso', (tester) async {
await tester.pumpApp(_card(
dayPlan: DayPlan(colazione: ['latte'], cena: ['pollo al forno']),
initiallyExpanded: true,
));
await tester.pumpAndSettle();
expect(find.text('latte'), findsOneWidget);
expect(find.text('pollo al forno'), findsOneWidget);
});
testWidgets('chiama onAdd con slot e testo corretti', (tester) async {
String? capturedSlot;
String? capturedText;
await tester.pumpApp(_card(
initiallyExpanded: true,
onAdd: (slot, text) {
capturedSlot = slot;
capturedText = text;
},
));
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField).first, 'yogurt');
await tester.testTextInput.receiveAction(TextInputAction.done);
await tester.pump();
expect(capturedSlot, 'colazione');
expect(capturedText, 'yogurt');
});
testWidgets('chiama onRemove quando si preme il pulsante elimina',
(tester) async {
String? removedSlot;
int? removedIndex;
await tester.pumpApp(_card(
dayPlan: DayPlan(colazione: ['latte']),
initiallyExpanded: true,
onRemove: (slot, index) {
removedSlot = slot;
removedIndex = index;
},
));
await tester.pumpAndSettle();
await tester.tap(find.byIcon(Icons.close).first);
await tester.pump();
expect(removedSlot, 'colazione');
expect(removedIndex, 0);
});
});
}
@@ -0,0 +1,65 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:biteplan/features/shopping_list/models/shopping_item.dart';
void main() {
group('ShoppingItem', () {
test('checked è false di default', () {
final item = ShoppingItem(id: '1', name: 'Pollo');
expect(item.checked, false);
});
test('quantity è 1 di default', () {
final item = ShoppingItem(id: '1', name: 'Pollo');
expect(item.quantity, 1);
});
test('copyWith() aggiorna solo il campo specificato', () {
final item = ShoppingItem(id: '1', name: 'Pollo');
final checked = item.copyWith(checked: true);
expect(checked.checked, true);
expect(checked.name, 'Pollo');
expect(checked.id, '1');
});
test('copyWith() aggiorna quantity', () {
final item = ShoppingItem(id: '1', name: 'Zucchine');
final updated = item.copyWith(quantity: 3);
expect(updated.quantity, 3);
expect(updated.name, 'Zucchine');
});
test('copyWith() non modifica lorigine', () {
final item = ShoppingItem(id: '1', name: 'Pollo');
item.copyWith(checked: true);
expect(item.checked, false);
});
test('JSON round-trip preserva tutti i campi incluso quantity', () {
const item = ShoppingItem(id: '42', name: 'Riso', checked: true, quantity: 3);
final restored = ShoppingItem.fromJson(item.toJson());
expect(restored.id, '42');
expect(restored.name, 'Riso');
expect(restored.checked, true);
expect(restored.quantity, 3);
});
test('fromJson gestisce campo checked assente (default false)', () {
final item = ShoppingItem.fromJson({'id': '1', 'name': 'Pasta'});
expect(item.checked, false);
});
test('fromJson gestisce campo quantity assente (default 1)', () {
final item = ShoppingItem.fromJson({'id': '1', 'name': 'Pasta', 'checked': false});
expect(item.quantity, 1);
});
test('toJson produce la mappa corretta con quantity', () {
const item = ShoppingItem(id: '5', name: 'Zucchine', checked: false, quantity: 2);
final json = item.toJson();
expect(json['id'], '5');
expect(json['name'], 'Zucchine');
expect(json['checked'], false);
expect(json['quantity'], 2);
});
});
}
@@ -0,0 +1,157 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:biteplan/features/shopping_list/providers/shopping_list_provider.dart';
void main() {
setUp(() {
SharedPreferences.setMockInitialValues({});
});
group('ShoppingListProvider', () {
test('inizia vuota', () async {
final provider = ShoppingListProvider();
await provider.load();
expect(provider.items, isEmpty);
});
group('add()', () {
test('aggiunge un elemento', () async {
final provider = ShoppingListProvider();
await provider.load();
provider.add('Pollo');
expect(provider.items.length, 1);
expect(provider.items.first.name, 'Pollo');
expect(provider.items.first.checked, false);
});
test('ignora stringa vuota o solo spazi', () async {
final provider = ShoppingListProvider();
await provider.load();
provider.add(' ');
provider.add('');
expect(provider.items, isEmpty);
});
test('esegue trim del testo', () async {
final provider = ShoppingListProvider();
await provider.load();
provider.add(' Riso ');
expect(provider.items.first.name, 'Riso');
});
});
group('toggle()', () {
test('inverte lo stato checked', () async {
final provider = ShoppingListProvider();
await provider.load();
provider.add('Riso');
final id = provider.items.first.id;
provider.toggle(id);
expect(provider.items.first.checked, true);
provider.toggle(id);
expect(provider.items.first.checked, false);
});
});
group('pendingItems / checkedItems', () {
test('separano correttamente', () async {
final provider = ShoppingListProvider();
await provider.load();
provider.add('Riso');
provider.add('Pasta');
provider.add('Pollo');
provider.toggle(provider.items[0].id);
provider.toggle(provider.items[2].id);
expect(provider.pendingItems.length, 1);
expect(provider.checkedItems.length, 2);
});
});
group('remove()', () {
test('elimina per id', () async {
final provider = ShoppingListProvider();
await provider.load();
provider.add('Pollo');
provider.add('Riso');
final id = provider.items.first.id;
provider.remove(id);
expect(provider.items.length, 1);
expect(provider.items.first.name, 'Riso');
});
});
group('addAll()', () {
test('non aggiunge duplicati (case-insensitive)', () async {
final provider = ShoppingListProvider();
await provider.load();
provider.add('Pollo');
provider.addAll(['pollo', 'Riso', 'RISO', 'Pasta']);
expect(provider.items.length, 3); // Pollo, Riso, Pasta
});
test('ignora elementi già presenti', () async {
final provider = ShoppingListProvider();
await provider.load();
provider.add('Riso');
provider.addAll(['Riso', 'Pasta']);
expect(provider.items.length, 2);
});
test('aggrega duplicati con quantity', () async {
final provider = ShoppingListProvider();
await provider.load();
provider.addAll(['Zucchine', 'Pollo', 'Zucchine', 'Pollo', 'Pollo']);
final zucchine = provider.items.firstWhere((i) => i.name.toLowerCase() == 'zucchine');
final pollo = provider.items.firstWhere((i) => i.name.toLowerCase() == 'pollo');
expect(zucchine.quantity, 2);
expect(pollo.quantity, 3);
});
test('imposta quantity=1 per elementi non duplicati', () async {
final provider = ShoppingListProvider();
await provider.load();
provider.addAll(['Pasta', 'Riso']);
for (final item in provider.items) {
expect(item.quantity, 1);
}
});
test('non aggiunge se tutti già presenti', () async {
final provider = ShoppingListProvider();
await provider.load();
provider.add('Riso');
provider.addAll(['riso']);
expect(provider.items.length, 1);
});
});
group('clearAll()', () {
test('svuota la lista', () async {
final provider = ShoppingListProvider();
await provider.load();
provider.add('Pollo');
provider.add('Riso');
provider.clearAll();
expect(provider.items, isEmpty);
});
});
test('persiste e ricarica i dati', () async {
final provider1 = ShoppingListProvider();
await provider1.load();
provider1.add('Merluzzo');
await Future.delayed(const Duration(milliseconds: 50));
final provider2 = ShoppingListProvider();
await provider2.load();
expect(provider2.items.first.name, 'Merluzzo');
});
test('load gestisce JSON corrotto con lista vuota', () async {
SharedPreferences.setMockInitialValues({'shopping_list': 'json non valido {'});
final provider = ShoppingListProvider();
await provider.load();
expect(provider.items, isEmpty);
});
});
}
@@ -0,0 +1,84 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:biteplan/features/shopping_list/presentation/widgets/shopping_item_tile.dart';
import 'package:biteplan/features/shopping_list/models/shopping_item.dart';
import '../../../helpers/pump_app.dart';
Widget _tile({
required ShoppingItem item,
VoidCallback? onToggle,
VoidCallback? onRemove,
}) =>
Scaffold(
body: ShoppingItemTile(
item: item,
onToggle: onToggle ?? () {},
onRemove: onRemove ?? () {},
),
);
void main() {
group('ShoppingItemTile', () {
testWidgets('mostra il nome dell\'elemento', (tester) async {
await tester.pumpApp(_tile(item: const ShoppingItem(id: '1', name: 'Pollo')));
expect(find.text('Pollo'), findsOneWidget);
});
testWidgets('checkbox non spuntato quando checked è false', (tester) async {
await tester.pumpApp(_tile(item: const ShoppingItem(id: '1', name: 'Riso')));
final checkbox = tester.widget<Checkbox>(find.byType(Checkbox));
expect(checkbox.value, false);
});
testWidgets('checkbox spuntato quando checked è true', (tester) async {
await tester.pumpApp(
_tile(item: const ShoppingItem(id: '1', name: 'Riso', checked: true)));
final checkbox = tester.widget<Checkbox>(find.byType(Checkbox));
expect(checkbox.value, true);
});
testWidgets('chiama onToggle al tap sul checkbox', (tester) async {
var toggled = false;
await tester.pumpApp(_tile(
item: const ShoppingItem(id: '1', name: 'Pollo'),
onToggle: () => toggled = true,
));
await tester.tap(find.byType(Checkbox));
expect(toggled, true);
});
testWidgets('chiama onRemove al tap sul pulsante chiudi', (tester) async {
var removed = false;
await tester.pumpApp(_tile(
item: const ShoppingItem(id: '1', name: 'Pasta'),
onRemove: () => removed = true,
));
await tester.tap(find.byIcon(Icons.close));
expect(removed, true);
});
testWidgets('testo barrato quando elemento è completato', (tester) async {
await tester.pumpApp(
_tile(item: const ShoppingItem(id: '1', name: 'Tonno', checked: true)));
final textWidget = tester.widget<Text>(find.text('Tonno'));
expect(textWidget.style?.decoration, TextDecoration.lineThrough);
});
testWidgets('non mostra indicatore quantità quando quantity è 1', (tester) async {
await tester.pumpApp(_tile(item: const ShoppingItem(id: '1', name: 'Riso')));
expect(find.text('(x1)'), findsNothing);
});
testWidgets('mostra (x2) quando quantity è 2', (tester) async {
await tester.pumpApp(
_tile(item: const ShoppingItem(id: '1', name: 'Zucchine', quantity: 2)));
expect(find.text('(x2)'), findsOneWidget);
});
testWidgets('mostra (xN) per quantità maggiori', (tester) async {
await tester.pumpApp(
_tile(item: const ShoppingItem(id: '1', name: 'Pollo', quantity: 5)));
expect(find.text('(x5)'), findsOneWidget);
});
});
}
+9
View File
@@ -0,0 +1,9 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:biteplan/core/theme/app_theme.dart';
extension PumpApp on WidgetTester {
Future<void> pumpApp(Widget widget) => pumpWidget(
MaterialApp(theme: AppTheme.light(), home: widget),
);
}
-73
View File
@@ -1,73 +0,0 @@
import { test, expect } from '@playwright/test'
test.describe('Convertitore', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/')
await page.locator('.nav-btn', { hasText: 'Converti' }).click()
})
test('mostra il messaggio iniziale prima di cercare', async ({ page }) => {
await expect(page.locator('.hint-state')).toBeVisible()
await expect(page.locator('.converter-card')).not.toBeVisible()
})
test('cerca un alimento e mostra i risultati', async ({ page }) => {
await page.locator('input[type="text"]').fill('riso')
await expect(page.locator('.result-item').first()).toBeVisible()
})
test('i nomi nella lista hanno solo l\'iniziale maiuscola', async ({ page }) => {
await page.locator('input[type="text"]').fill('pollo')
const firstFood = await page.locator('.result-food').first().textContent()
// "Pollo petto" → solo la prima lettera maiuscola
expect(firstFood[0]).toBe(firstFood[0].toUpperCase())
if (firstFood.includes(' ')) {
const secondWord = firstFood.split(' ')[1]
expect(secondWord[0]).toBe(secondWord[0].toLowerCase())
}
})
test('seleziona un alimento e mostra la converter card', async ({ page }) => {
await page.locator('input[type="text"]').fill('riso basmati')
await page.locator('.result-item').first().click()
await expect(page.locator('.converter-card')).toBeVisible()
await expect(page.locator('.result-item')).toHaveCount(0)
})
test('calcola il peso cotto inserendo i grammi', async ({ page }) => {
await page.locator('input[type="text"]').fill('riso basmati')
await page.locator('.result-item').first().click()
await page.locator('.calc-input').fill('100')
// Riso basmati ha fattore 3.0 → 300g cotto
await expect(page.locator('.output-value')).toBeVisible()
const result = await page.locator('.output-value').textContent()
expect(parseFloat(result)).toBeCloseTo(300, 0)
})
test('il pulsante ⇄ inverte la direzione crudo↔cotto', async ({ page }) => {
await page.locator('input[type="text"]').fill('pasta')
await page.locator('.result-item').first().click()
const labelBefore = await page.locator('.calc-label').first().textContent()
await page.locator('.btn-swap').click()
const labelAfter = await page.locator('.calc-label').first().textContent()
expect(labelBefore).not.toBe(labelAfter)
})
test('il pulsante Cambia torna alla ricerca', async ({ page }) => {
await page.locator('input[type="text"]').fill('riso')
await page.locator('.result-item').first().click()
await page.locator('.btn-reset').click()
await expect(page.locator('.converter-card')).not.toBeVisible()
await expect(page.locator('input[type="text"]')).not.toBeDisabled()
})
test('mostra il footer con fattore di resa quando c\'è un risultato', async ({ page }) => {
await page.locator('input[type="text"]').fill('riso')
await page.locator('.result-item').first().click()
await page.locator('.calc-input').fill('100')
await expect(page.locator('.card-footer')).toContainText('fattore di resa')
})
})
-78
View File
@@ -1,78 +0,0 @@
import { test, expect } from '@playwright/test'
test.describe('Piano Pasti', () => {
test.beforeEach(async ({ page }) => {
// Pulisce localStorage per partire da uno stato noto
await page.goto('/')
await page.evaluate(() => localStorage.clear())
await page.reload()
})
test('mostra 7 card giornaliere', async ({ page }) => {
const cards = page.locator('.meal-card')
await expect(cards).toHaveCount(7)
})
test('il giorno corrente è espanso di default', async ({ page }) => {
// Almeno una card deve essere aperta (class "open")
await expect(page.locator('.meal-card.open')).toHaveCount(1)
})
test('si può espandere e chiudere una card con tap', async ({ page }) => {
const firstHeader = page.locator('.card-header').first()
const firstCard = page.locator('.meal-card').first()
// Se la prima card è già aperta, chiudila prima
const isOpen = await firstCard.evaluate(el => el.classList.contains('open'))
await firstHeader.click()
if (isOpen) {
await expect(firstCard).not.toHaveClass(/open/)
} else {
await expect(firstCard).toHaveClass(/open/)
}
})
test('aggiunge un alimento al pranzo del giorno corrente', async ({ page }) => {
const openCard = page.locator('.meal-card.open')
const pranzoInput = openCard.locator('.meal-slot').nth(1).locator('input[type="text"]')
await pranzoInput.fill('pasta al pomodoro')
await pranzoInput.press('Enter')
await expect(openCard.locator('.item-text', { hasText: 'pasta al pomodoro' })).toBeVisible()
})
test('rimuove un alimento con il pulsante ×', async ({ page }) => {
const openCard = page.locator('.meal-card.open')
const pranzoInput = openCard.locator('.meal-slot').nth(1).locator('input[type="text"]')
await pranzoInput.fill('riso')
await pranzoInput.press('Enter')
const itemRow = openCard.locator('.item-row', { hasText: 'riso' })
await expect(itemRow).toBeVisible()
await itemRow.locator('.btn-remove').click()
await expect(itemRow).not.toBeVisible()
})
test('genera la lista della spesa e passa alla tab Spesa', async ({ page }) => {
const openCard = page.locator('.meal-card.open')
const cenahInput = openCard.locator('.meal-slot').nth(2).locator('input[type="text"]')
await cenahInput.fill('pollo')
await cenahInput.press('Enter')
await page.locator('.btn-generate').click()
// Deve essere passato alla tab Spesa
await expect(page.locator('.page-title')).toContainText('Lista della spesa')
await expect(page.locator('.item-name', { hasText: 'pollo' })).toBeVisible()
})
test('i dati persistono dopo il reload', async ({ page }) => {
const openCard = page.locator('.meal-card.open')
const colazioneInput = openCard.locator('.meal-slot').first().locator('input[type="text"]')
await colazioneInput.fill('caffè')
await colazioneInput.press('Enter')
await page.reload()
await expect(page.locator('.meal-card.open .item-text', { hasText: 'caffè' })).toBeVisible()
})
})
-42
View File
@@ -1,42 +0,0 @@
import { test, expect } from '@playwright/test'
test.describe('Navigazione tra tab', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/')
})
test('la tab Pasti è attiva al caricamento', async ({ page }) => {
await expect(page.locator('.nav-btn.active')).toContainText('Pasti')
await expect(page.locator('.page-title')).toContainText('Piano Pasti')
})
test('la tab Converti mostra il convertitore', async ({ page }) => {
await page.locator('.nav-btn', { hasText: 'Converti' }).click()
await expect(page.locator('.page-title')).toContainText('Convertitore')
await expect(page.locator('.nav-btn.active')).toContainText('Converti')
})
test('la tab Spesa mostra la lista della spesa', async ({ page }) => {
await page.locator('.nav-btn', { hasText: 'Spesa' }).click()
await expect(page.locator('.page-title')).toContainText('Lista della spesa')
await expect(page.locator('.nav-btn.active')).toContainText('Spesa')
})
test('si può tornare a Pasti da un\'altra tab', async ({ page }) => {
await page.locator('.nav-btn', { hasText: 'Converti' }).click()
await page.locator('.nav-btn', { hasText: 'Pasti' }).click()
await expect(page.locator('.page-title')).toContainText('Piano Pasti')
})
test('il pulsante info apre il pannello informazioni', async ({ page }) => {
await page.locator('.btn-info').click()
await expect(page.locator('.sheet')).toBeVisible()
await expect(page.locator('.app-name')).toContainText('BitePlan')
})
test('il pannello info si chiude con la X', async ({ page }) => {
await page.locator('.btn-info').click()
await page.locator('.btn-x').click()
await expect(page.locator('.sheet')).not.toBeVisible()
})
})
-89
View File
@@ -1,89 +0,0 @@
import { test, expect } from '@playwright/test'
test.describe('Lista della spesa', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/')
await page.evaluate(() => localStorage.clear())
await page.locator('.nav-btn', { hasText: 'Spesa' }).click()
})
test('mostra stato vuoto con lista vuota', async ({ page }) => {
await expect(page.locator('.empty-state')).toBeVisible()
})
test('aggiunge un elemento tramite il pulsante +', async ({ page }) => {
await page.locator('input[type="text"]').fill('latte')
await page.locator('.btn-add').click()
await expect(page.locator('.item-name', { hasText: 'latte' })).toBeVisible()
})
test('aggiunge un elemento con il tasto Invio', async ({ page }) => {
await page.locator('input[type="text"]').fill('burro')
await page.locator('input[type="text"]').press('Enter')
await expect(page.locator('.item-name', { hasText: 'burro' })).toBeVisible()
})
test('svuota il campo dopo l\'aggiunta', async ({ page }) => {
await page.locator('input[type="text"]').fill('olio')
await page.locator('.btn-add').click()
await expect(page.locator('input[type="text"]')).toHaveValue('')
})
test('spunta un elemento e lo sposta nei completati', async ({ page }) => {
await page.locator('input[type="text"]').fill('pasta')
await page.locator('.btn-add').click()
await page.locator('.checkbox-item').first().locator('input[type="checkbox"]').click()
await expect(page.locator('.section-divider')).toBeVisible()
await expect(page.locator('.muted .item-name', { hasText: 'pasta' })).toBeVisible()
})
test('rimuove un singolo elemento con ×', async ({ page }) => {
await page.locator('input[type="text"]').fill('farina')
await page.locator('.btn-add').click()
await page.locator('.checkbox-item').first().locator('.btn-remove').click()
await expect(page.locator('.item-name', { hasText: 'farina' })).not.toBeVisible()
await expect(page.locator('.empty-state')).toBeVisible()
})
test('svuota lista con conferma del dialog', async ({ page }) => {
await page.locator('input[type="text"]').fill('test')
await page.locator('.btn-add').click()
page.once('dialog', dialog => dialog.accept())
await page.locator('.btn-clear').click()
await expect(page.locator('.empty-state')).toBeVisible()
})
test('non svuota lista se si annulla il dialog', async ({ page }) => {
await page.locator('input[type="text"]').fill('test')
await page.locator('.btn-add').click()
page.once('dialog', dialog => dialog.dismiss())
await page.locator('.btn-clear').click()
await expect(page.locator('.item-name', { hasText: 'test' })).toBeVisible()
})
test('il contatore mostra elementi completati / totale', async ({ page }) => {
await page.locator('input[type="text"]').fill('a')
await page.locator('.btn-add').click()
await page.locator('input[type="text"]').fill('b')
await page.locator('.btn-add').click()
await page.locator('.checkbox-item').first().locator('input[type="checkbox"]').click()
const subtitle = await page.locator('.page-subtitle').textContent()
expect(subtitle).toMatch(/1/)
expect(subtitle).toMatch(/2/)
})
test('i dati persistono dopo il reload', async ({ page }) => {
await page.locator('input[type="text"]').fill('yogurt')
await page.locator('.btn-add').click()
await page.reload()
await page.locator('.nav-btn', { hasText: 'Spesa' }).click()
await expect(page.locator('.item-name', { hasText: 'yogurt' })).toBeVisible()
})
})
-173
View File
@@ -1,173 +0,0 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import Converter from '../../src/pages/Converter.vue'
function mountConverter() {
return mount(Converter, { attachTo: document.body })
}
describe('Converter — stato iniziale', () => {
it('mostra il messaggio hint quando non c\'è nessuna ricerca', () => {
const w = mountConverter()
expect(w.find('.hint-state').exists()).toBe(true)
expect(w.find('.converter-card').exists()).toBe(false)
})
it('non mostra risultati senza input', () => {
const w = mountConverter()
expect(w.findAll('.result-item')).toHaveLength(0)
})
})
describe('Converter — ricerca', () => {
it('mostra risultati corrispondenti alla query', async () => {
const w = mountConverter()
const input = w.find('input[type="text"]')
await input.setValue('riso')
await input.trigger('input')
expect(w.findAll('.result-item').length).toBeGreaterThan(0)
})
it('ogni risultato ha nome alimento e metodo di cottura', async () => {
const w = mountConverter()
await w.find('input[type="text"]').setValue('pollo')
await w.find('input[type="text"]').trigger('input')
const first = w.find('.result-item')
expect(first.find('.result-food').text()).toBeTruthy()
expect(first.find('.result-method').text()).toBeTruthy()
})
it('nasconde l\'hint state durante la ricerca', async () => {
const w = mountConverter()
await w.find('input[type="text"]').setValue('pasta')
await w.find('input[type="text"]').trigger('input')
expect(w.find('.hint-state').exists()).toBe(false)
})
it('svuota i risultati per query vuota', async () => {
const w = mountConverter()
const input = w.find('input[type="text"]')
await input.setValue('riso')
await input.trigger('input')
await input.setValue('')
await input.trigger('input')
expect(w.findAll('.result-item')).toHaveLength(0)
expect(w.find('.hint-state').exists()).toBe(true)
})
it('i nomi degli alimenti hanno solo l\'iniziale maiuscola', async () => {
const w = mountConverter()
await w.find('input[type="text"]').setValue('riso')
await w.find('input[type="text"]').trigger('input')
const foods = w.findAll('.result-food').map(el => el.text())
// Nessun testo deve avere lettere maiuscole dopo la prima (test multi-word)
foods.forEach(f => {
if (f.includes(' ')) {
// "Riso basmati" → la seconda parola non deve essere capitalizzata
const words = f.split(' ')
words.slice(1).forEach(w => expect(w[0]).toBe(w[0].toLowerCase()))
}
})
})
})
describe('Converter — selezione alimento', () => {
async function selectFirstResult(w, query = 'riso') {
await w.find('input[type="text"]').setValue(query)
await w.find('input[type="text"]').trigger('input')
await w.find('.result-item').trigger('click')
}
it('mostra la converter card dopo la selezione', async () => {
const w = mountConverter()
await selectFirstResult(w)
expect(w.find('.converter-card').exists()).toBe(true)
})
it('nasconde i risultati dopo la selezione', async () => {
const w = mountConverter()
await selectFirstResult(w)
expect(w.findAll('.result-item')).toHaveLength(0)
})
it('mostra nome alimento e metodo nella card', async () => {
const w = mountConverter()
await selectFirstResult(w)
expect(w.find('.food-name').text()).toBeTruthy()
expect(w.find('.food-method').text()).toBeTruthy()
})
it('disabilita il campo di ricerca dopo la selezione', async () => {
const w = mountConverter()
await selectFirstResult(w)
expect(w.find('input[type="text"]').attributes('disabled')).toBeDefined()
})
})
describe('Converter — calcolo', () => {
async function mountWithSelection(query = 'riso basmati') {
const w = mountConverter()
await w.find('input[type="text"]').setValue(query)
await w.find('input[type="text"]').trigger('input')
await w.find('.result-item').trigger('click')
return w
}
it('mostra il risultato dopo aver inserito i grammi', async () => {
const w = await mountWithSelection()
await w.find('.calc-input').setValue('100')
await w.find('.calc-input').trigger('input')
expect(w.find('.output-value').exists()).toBe(true)
})
it('non mostra risultato per input 0', async () => {
const w = await mountWithSelection()
await w.find('.calc-input').setValue('0')
await w.find('.calc-input').trigger('input')
expect(w.find('.output-value').exists()).toBe(false)
})
it('mostra il footer con fattore di resa quando c\'è un risultato', async () => {
const w = await mountWithSelection()
await w.find('.calc-input').setValue('100')
await w.find('.calc-input').trigger('input')
expect(w.find('.card-footer').exists()).toBe(true)
expect(w.find('.card-footer').text()).toContain('fattore di resa')
})
})
describe('Converter — swap direzione', () => {
it('il pulsante swap inverte le label crudo/cotto', async () => {
const w = mountConverter()
await w.find('input[type="text"]').setValue('pasta')
await w.find('input[type="text"]').trigger('input')
await w.find('.result-item').trigger('click')
expect(w.find('.calc-label').text()).toMatch(/crudo/i)
await w.find('.btn-swap').trigger('click')
expect(w.find('.calc-label').text()).toMatch(/cotto/i)
})
it('swap azzera il campo grammi', async () => {
const w = mountConverter()
await w.find('input[type="text"]').setValue('pasta')
await w.find('input[type="text"]').trigger('input')
await w.find('.result-item').trigger('click')
await w.find('.calc-input').setValue('200')
await w.find('.btn-swap').trigger('click')
// Dopo lo swap il campo deve essere vuoto (grams = null)
expect(w.find('.calc-input').element.value).toBe('')
})
})
describe('Converter — reset', () => {
it('il pulsante Cambia riporta allo stato di ricerca', async () => {
const w = mountConverter()
await w.find('input[type="text"]').setValue('riso')
await w.find('input[type="text"]').trigger('input')
await w.find('.result-item').trigger('click')
await w.find('.btn-reset').trigger('click')
expect(w.find('.converter-card').exists()).toBe(false)
expect(w.find('input[type="text"]').attributes('disabled')).toBeUndefined()
})
})
-190
View File
@@ -1,190 +0,0 @@
import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { nextTick } from 'vue'
import MealPlanner from '../../src/pages/MealPlanner.vue'
import { load } from '../../src/utils/storage.js'
// Stub di MealCard: espone props e può emettere add/remove
const MealCardStub = {
name: 'MealCard',
template: '<div class="meal-card-stub" />',
props: ['dayName', 'meals', 'defaultOpen'],
emits: ['add', 'remove'],
}
function mountPlanner() {
return mount(MealPlanner, {
global: { stubs: { MealCard: MealCardStub } },
})
}
// Pre-popola il localStorage con un piano pasti completo
function seedMeals(overrides = {}) {
const base = Object.fromEntries(
['lunedi', 'martedi', 'mercoledi', 'giovedi', 'venerdi', 'sabato', 'domenica'].map(
d => [d, { colazione: [], pranzo: [], cena: [] }]
)
)
const merged = { ...base, ...overrides }
localStorage.setItem('meals', JSON.stringify(merged))
return merged
}
describe('MealPlanner — rendering', () => {
it('rende 7 card giornaliere', () => {
const w = mountPlanner()
expect(w.findAll('.meal-card-stub')).toHaveLength(7)
})
it('mostra il pulsante "Genera lista della spesa"', () => {
const w = mountPlanner()
expect(w.find('.btn-generate').exists()).toBe(true)
})
it('mostra la data corrente nel sottotitolo', () => {
const w = mountPlanner()
expect(w.find('.page-subtitle').text()).toMatch(/oggi/i)
})
})
describe('MealPlanner — aggiunta e rimozione voci', () => {
it('aggiunge un alimento al pranzo di lunedì', async () => {
const w = mountPlanner()
const cards = w.findAllComponents(MealCardStub)
// lunedì è il primo giorno (indice 0)
await cards[0].vm.$emit('add', 'pranzo', 'pasta')
await nextTick()
const saved = load('meals', {})
expect(saved.lunedi.pranzo).toContain('pasta')
})
it('non aggiunge stringhe vuote', async () => {
// Seed necessario: il watcher non scatta se nulla cambia,
// quindi localStorage rimarrebbe vuoto e load() returnerebbe {}
seedMeals()
const w = mountPlanner()
const cards = w.findAllComponents(MealCardStub)
await cards[0].vm.$emit('add', 'pranzo', ' ')
await nextTick()
const saved = load('meals', {})
expect(saved.lunedi.pranzo).toHaveLength(0)
})
it('rimuove un alimento tramite indice', async () => {
seedMeals({ lunedi: { colazione: [], pranzo: ['pasta', 'insalata'], cena: [] } })
const w = mountPlanner()
const cards = w.findAllComponents(MealCardStub)
await cards[0].vm.$emit('remove', 'pranzo', 0) // rimuovi "pasta"
await nextTick()
const saved = load('meals', {})
expect(saved.lunedi.pranzo).toEqual(['insalata'])
})
it('persiste le modifiche in localStorage', async () => {
const w = mountPlanner()
const cards = w.findAllComponents(MealCardStub)
await cards[1].vm.$emit('add', 'cena', 'pollo')
await nextTick()
expect(load('meals', {}).martedi.cena).toContain('pollo')
})
})
describe('MealPlanner — genera lista della spesa', () => {
it('emette go-shop al click del pulsante', async () => {
const w = mountPlanner()
await w.find('.btn-generate').trigger('click')
expect(w.emitted('go-shop')).toBeTruthy()
})
it('salva gli alimenti del piano in localStorage come spesa', async () => {
seedMeals({
lunedi: { colazione: ['caffè'], pranzo: ['pasta'], cena: ['pollo'] },
martedi: { colazione: [], pranzo: ['riso'], cena: [] },
})
const w = mountPlanner()
await w.find('.btn-generate').trigger('click')
const shopping = load('shopping', [])
const names = shopping.map(i => i.name)
expect(names).toContain('caffè')
expect(names).toContain('pasta')
expect(names).toContain('pollo')
expect(names).toContain('riso')
})
it('non aggiunge duplicati rispetto agli elementi già in lista', async () => {
seedMeals({ lunedi: { colazione: [], pranzo: ['pasta'], cena: [] } })
// Pasta già presente nella lista della spesa
localStorage.setItem('shopping', JSON.stringify([
{ id: 1, name: 'pasta', checked: false },
]))
const w = mountPlanner()
await w.find('.btn-generate').trigger('click')
const shopping = load('shopping', [])
const pastaCount = shopping.filter(i => i.name.toLowerCase() === 'pasta').length
expect(pastaCount).toBe(1)
})
it('non aggiunge duplicati tra i giorni del piano', async () => {
seedMeals({
lunedi: { colazione: [], pranzo: ['pasta'], cena: [] },
martedi: { colazione: [], pranzo: ['pasta'], cena: [] }, // stesso alimento
})
const w = mountPlanner()
await w.find('.btn-generate').trigger('click')
const shopping = load('shopping', [])
const pastaCount = shopping.filter(i => i.name.toLowerCase() === 'pasta').length
expect(pastaCount).toBe(1)
})
it('non aggiunge nulla se il piano è vuoto', async () => {
const w = mountPlanner()
await w.find('.btn-generate').trigger('click')
expect(load('shopping', [])).toHaveLength(0)
})
})
describe('MealPlanner — svuota piano', () => {
it('non mostra il pulsante se il piano è vuoto', () => {
const w = mountPlanner()
expect(w.find('.btn-clear').exists()).toBe(false)
})
it('mostra il pulsante se c\'è almeno un pasto', () => {
seedMeals({ lunedi: { colazione: ['caffè'], pranzo: [], cena: [] } })
const w = mountPlanner()
expect(w.find('.btn-clear').exists()).toBe(true)
})
it('svuota il piano dopo conferma', async () => {
vi.spyOn(window, 'confirm').mockReturnValue(true)
seedMeals({ lunedi: { colazione: ['caffè'], pranzo: ['pasta'], cena: [] } })
const w = mountPlanner()
await w.find('.btn-clear').trigger('click')
await nextTick()
const saved = load('meals', {})
expect(saved.lunedi.colazione).toHaveLength(0)
expect(saved.lunedi.pranzo).toHaveLength(0)
expect(w.find('.btn-clear').exists()).toBe(false)
})
it('non svuota se l\'utente annulla', async () => {
vi.spyOn(window, 'confirm').mockReturnValue(false)
seedMeals({ lunedi: { colazione: ['caffè'], pranzo: [], cena: [] } })
const w = mountPlanner()
await w.find('.btn-clear').trigger('click')
await nextTick()
expect(load('meals', {}).lunedi.colazione).toHaveLength(1)
})
})
-157
View File
@@ -1,157 +0,0 @@
import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { nextTick } from 'vue'
import ShoppingList from '../../src/pages/ShoppingList.vue'
import { load } from '../../src/utils/storage.js'
const CheckboxItemStub = {
name: 'CheckboxItem',
template: '<li class="checkbox-stub">{{ item.name }}</li>',
props: ['item'],
emits: ['toggle', 'remove'],
}
function seedShopping(items) {
localStorage.setItem('shopping', JSON.stringify(items))
}
function mountShoppingList() {
return mount(ShoppingList, {
global: { stubs: { CheckboxItem: CheckboxItemStub } },
})
}
describe('ShoppingList — stato iniziale', () => {
it('mostra lo stato vuoto se non ci sono elementi', () => {
const w = mountShoppingList()
expect(w.find('.empty-state').exists()).toBe(true)
})
it('non mostra il pulsante svuota lista se la lista è vuota', () => {
const w = mountShoppingList()
expect(w.find('.btn-clear').exists()).toBe(false)
})
it('carica gli elementi dal localStorage all\'avvio', () => {
seedShopping([{ id: 1, name: 'pasta', checked: false }])
const w = mountShoppingList()
expect(w.find('.empty-state').exists()).toBe(false)
expect(w.findAll('.checkbox-stub')).toHaveLength(1)
})
})
describe('ShoppingList — aggiunta elementi', () => {
it('aggiunge un elemento tramite click su +', async () => {
const w = mountShoppingList()
await w.find('input[type="text"]').setValue('latte')
await w.find('.btn-add').trigger('click')
const items = load('shopping', [])
expect(items).toHaveLength(1)
expect(items[0].name).toBe('latte')
expect(items[0].checked).toBe(false)
})
it('aggiunge un elemento tramite tasto Invio', async () => {
const w = mountShoppingList()
const input = w.find('input[type="text"]')
await input.setValue('burro')
await input.trigger('keyup.enter')
expect(load('shopping', []).map(i => i.name)).toContain('burro')
})
it('svuota il campo input dopo l\'aggiunta', async () => {
const w = mountShoppingList()
await w.find('input[type="text"]').setValue('olio')
await w.find('.btn-add').trigger('click')
expect(w.find('input[type="text"]').element.value).toBe('')
})
it('non aggiunge stringhe vuote', async () => {
const w = mountShoppingList()
await w.find('input[type="text"]').setValue(' ')
await w.find('.btn-add').trigger('click')
expect(load('shopping', [])).toHaveLength(0)
expect(w.find('.empty-state').exists()).toBe(true)
})
})
describe('ShoppingList — toggle e rimozione', () => {
it('sposta un elemento nei completati dopo toggle', async () => {
seedShopping([{ id: 1, name: 'pasta', checked: false }])
const w = mountShoppingList()
await w.findComponent(CheckboxItemStub).vm.$emit('toggle')
await nextTick()
const items = load('shopping', [])
expect(items[0].checked).toBe(true)
})
it('ripristina un elemento già spuntato dopo un secondo toggle', async () => {
seedShopping([{ id: 1, name: 'pasta', checked: true }])
const w = mountShoppingList()
await w.findComponent(CheckboxItemStub).vm.$emit('toggle')
await nextTick()
expect(load('shopping', [])[0].checked).toBe(false)
})
it('rimuove un singolo elemento', async () => {
seedShopping([
{ id: 1, name: 'pasta', checked: false },
{ id: 2, name: 'riso', checked: false },
])
const w = mountShoppingList()
await w.findAllComponents(CheckboxItemStub)[0].vm.$emit('remove')
await nextTick()
const items = load('shopping', [])
expect(items).toHaveLength(1)
expect(items[0].name).toBe('riso')
})
})
describe('ShoppingList — svuota lista', () => {
it('svuota la lista dopo conferma', async () => {
vi.spyOn(window, 'confirm').mockReturnValue(true)
seedShopping([{ id: 1, name: 'pasta', checked: false }])
const w = mountShoppingList()
await w.find('.btn-clear').trigger('click')
await nextTick()
expect(w.find('.empty-state').exists()).toBe(true)
expect(load('shopping', [])).toHaveLength(0)
})
it('non svuota se l\'utente annulla', async () => {
vi.spyOn(window, 'confirm').mockReturnValue(false)
seedShopping([{ id: 1, name: 'pasta', checked: false }])
const w = mountShoppingList()
await w.find('.btn-clear').trigger('click')
await nextTick()
expect(load('shopping', [])).toHaveLength(1)
})
})
describe('ShoppingList — contatore completati', () => {
it('mostra N/totale nel sottotitolo', () => {
seedShopping([
{ id: 1, name: 'pasta', checked: true },
{ id: 2, name: 'riso', checked: false },
{ id: 3, name: 'patate', checked: false },
])
const w = mountShoppingList()
const subtitle = w.find('.page-subtitle').text()
expect(subtitle).toMatch(/1/)
expect(subtitle).toMatch(/3/)
})
it('non mostra il sottotitolo se la lista è vuota', () => {
const w = mountShoppingList()
expect(w.find('.page-subtitle').exists()).toBe(false)
})
})
-5
View File
@@ -1,5 +0,0 @@
import { beforeEach, afterEach } from 'vitest'
// Svuota localStorage prima e dopo ogni test per garantire isolamento
beforeEach(() => localStorage.clear())
afterEach(() => localStorage.clear())
-75
View File
@@ -1,75 +0,0 @@
import { describe, it, expect } from 'vitest'
import { rawToCooked, cookedToRaw } from '../../src/utils/conversion.js'
// DB minimale — isola i test dalle modifiche al JSON reale
const db = {
'riso basmati': { bollitura: { yield: 3.00 } },
'pasta semola': { bollitura: { yield: 1.88 } },
'pollo petto': { padella: { yield: 0.75 }, forno: { yield: 0.70 } },
'zucchine': { bollitura: { yield: 0.90 }, padella: { yield: 0.82 } },
'ceci secchi': { bollitura: { yield: 2.90 } },
}
describe('rawToCooked', () => {
it('calcola il peso cotto con fattore > 1 (cereali)', () => {
expect(rawToCooked('riso basmati', 'bollitura', 100, db)).toBe(300)
})
it('calcola il peso cotto con fattore non intero', () => {
expect(rawToCooked('pasta semola', 'bollitura', 100, db)).toBeCloseTo(188)
})
it('calcola il peso cotto con fattore < 1 (verdure)', () => {
expect(rawToCooked('zucchine', 'bollitura', 200, db)).toBeCloseTo(180)
})
it('restituisce 0 per peso 0', () => {
expect(rawToCooked('riso basmati', 'bollitura', 0, db)).toBe(0)
})
it('scala linearmente con la quantità', () => {
const single = rawToCooked('riso basmati', 'bollitura', 100, db)
const double = rawToCooked('riso basmati', 'bollitura', 200, db)
expect(double).toBeCloseTo(single * 2)
})
it('usa il metodo di cottura corretto', () => {
const padella = rawToCooked('pollo petto', 'padella', 100, db)
const forno = rawToCooked('pollo petto', 'forno', 100, db)
expect(padella).not.toBe(forno)
expect(padella).toBe(75)
expect(forno).toBe(70)
})
it('funziona con legumi secchi (fattore molto > 1)', () => {
expect(rawToCooked('ceci secchi', 'bollitura', 100, db)).toBeCloseTo(290)
})
})
describe('cookedToRaw', () => {
it('calcola il peso crudo da un peso cotto', () => {
expect(cookedToRaw('riso basmati', 'bollitura', 300, db)).toBeCloseTo(100)
})
it('è l\'inverso esatto di rawToCooked', () => {
const rawIn = 150
const cooked = rawToCooked('pollo petto', 'padella', rawIn, db)
const rawOut = cookedToRaw('pollo petto', 'padella', cooked, db)
expect(rawOut).toBeCloseTo(rawIn)
})
it('funziona con verdure (fattore < 1 → crudo > cotto)', () => {
const crudo = cookedToRaw('zucchine', 'padella', 82, db)
expect(crudo).toBeCloseTo(100)
})
it('restituisce 0 per peso cotto 0', () => {
expect(cookedToRaw('riso basmati', 'bollitura', 0, db)).toBe(0)
})
it('scala linearmente', () => {
const base = cookedToRaw('riso basmati', 'bollitura', 300, db)
const doppio = cookedToRaw('riso basmati', 'bollitura', 600, db)
expect(doppio).toBeCloseTo(base * 2)
})
})
-59
View File
@@ -1,59 +0,0 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { save, load } from '../../src/utils/storage.js'
// localStorage viene svuotato in tests/setup.js — qui garantiamo isolamento locale
beforeEach(() => localStorage.clear())
describe('save', () => {
it('serializza un oggetto in JSON', () => {
save('test', { a: 1, b: 'due' })
expect(localStorage.getItem('test')).toBe('{"a":1,"b":"due"}')
})
it('serializza un array', () => {
save('list', [1, 2, 3])
expect(localStorage.getItem('list')).toBe('[1,2,3]')
})
it('sovrascrive un valore esistente', () => {
save('key', 'primo')
save('key', 'secondo')
expect(localStorage.getItem('key')).toBe('"secondo"')
})
it('gestisce valori primitivi (numero, stringa, booleano)', () => {
save('n', 42)
save('s', 'ciao')
save('b', false)
expect(JSON.parse(localStorage.getItem('n'))).toBe(42)
expect(JSON.parse(localStorage.getItem('s'))).toBe('ciao')
expect(JSON.parse(localStorage.getItem('b'))).toBe(false)
})
})
describe('load', () => {
it('restituisce il valore parsato se la chiave esiste', () => {
localStorage.setItem('obj', '{"a":1}')
expect(load('obj', null)).toEqual({ a: 1 })
})
it('restituisce il default se la chiave non esiste', () => {
expect(load('nonexistent', 'fallback')).toBe('fallback')
})
it('restituisce il default anche per array vuoto come fallback', () => {
expect(load('missing', [])).toEqual([])
})
it('round-trip: save → load restituisce il valore originale', () => {
const data = [{ id: 1, name: 'pasta', checked: false }]
save('shopping', data)
expect(load('shopping', [])).toEqual(data)
})
it('round-trip su struttura pasti annidata', () => {
const meals = { lunedi: { colazione: ['caffè'], pranzo: [], cena: [] } }
save('meals', meals)
expect(load('meals', {})).toEqual(meals)
})
})
-6
View File
@@ -1,6 +0,0 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()]
})
-17
View File
@@ -1,17 +0,0 @@
import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
test: {
environment: 'happy-dom',
setupFiles: ['./tests/setup.js'],
include: ['tests/unit/**/*.test.js', 'tests/integration/**/*.test.js'],
coverage: {
provider: 'v8',
reporter: ['text', 'html'],
include: ['src/utils/**', 'src/pages/**', 'src/components/**'],
exclude: ['src/data/**'],
},
},
})