Compare commits

...

10 Commits

Author SHA1 Message Date
davide 7c6cf6fd78 docs: riorganizza README con flusso guidato prima-volta/build/lancio
Aggiunge una guida rapida in cima che rimanda a requisiti, installazione,
sviluppo e build nell'ordine in cui vanno seguiti, e documenta il fix per
i file lasciati di proprietà root da una build Docker precedente.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-16 14:12:00 +02:00
davide 65d0eb461c docs: guida step-by-step per testare l'APK di debug su emulatore
Aggiunge in README.md la procedura completa (avvio emulatore, adb
install, avvio app, log, disinstallazione) e corregge l'App ID
riportato in docker/README.md, disallineato dal valore reale in
android/app/build.gradle.kts.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-13 18:48:14 +02:00
davide 494c0fdbd4 docs: aggiorna CLAUDE.md con conteggio test, coverage e insidie note
184 test, coverage ~93%; documenta il coverage helper e il vincolo
sul caricamento async dei provider nei widget test.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 20:38:57 +02:00
davide 57f522e3a2 fix(test): corregge i fallimenti emersi alla prima esecuzione
- update_service_test: il fake HttpClientResponse tipizza lo stream
  come Stream<List<int>> (utf8.encode ritorna Uint8List)
- guide/info_bottom_sheet test: i titoli delle _DocCard sono resi
  con toUpperCase(), i finder ora cercano il testo maiuscolo
- converter_page_test e app_test: loadDb()/load() dei provider
  spostati nel setUp — chiamarli nel body di testWidgets (zona
  FakeAsync) bloccava la suite fino al timeout di 10 minuti

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 20:38:57 +02:00
davide d94bd5fdf7 test: widget test per il layer di presentazione
Prima era esercitato solo dall'integration test su emulatore:

- converter_page: ricerca, stati vuoti, selezione, calcolo, swap, reset
- shopping_list_page: aggiunta, contatore, sezione completati, dialog svuota
- meal_planner_page: 7 card, giorno espanso, genera lista, dialog svuota,
  stato del pulsante Condividi
- qr_share_sheet: QR per piano valido, errore oltre il limite, chiusura
- app (BitePlanApp): navigazione fra i tab, bottom sheet informazioni
- guide_page e info_bottom_sheet: contenuti e navigazione alla guida
- empty_state: icona, titolo, sottotitolo opzionale
- coverage_helper: importa tutta lib/ così lcov include anche i file
  non toccati da alcun test

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 20:08:49 +02:00
davide 9384bd6117 test: colma i buchi negli unit test esistenti
- UpdateService.checkUpdate() coperto via HttpOverrides con fake
  HttpClient: versione più recente/uguale/più vecchia, status != 200,
  body malformato, errore di rete
- ConverterProvider.loadDb() con l'asset reale + validazione che ogni
  voce di conversions.json abbia yield positivo
- MealPlannerProvider.removeItem() sui rami colazione e cena
  (prima era coperto solo pranzo)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 20:08:32 +02:00
davide e494855fe6 docs: guida passo passo per emulatore e requisiti host nel README
Aggiunge requisiti (Linux x86_64, KVM, Java 17+) e installazione
completa di Flutter, Android SDK cmdline-tools e AVD dedicato;
ignora coverage/ generata da flutter test --coverage.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 20:08:17 +02:00
davide 45d53c1bd1 docs: aggiorna CLAUDE.md al nuovo workflow e aggiunge ruolo esperto
Riflette lo sviluppo su host + emulatore (comandi, sezione "Sviluppo
locale"), condensa architettura/testing sui contenuti non deducibili
dal codice, e aggiunge il flusso atteso per le richieste di modifica:
analisi critica → implementazione → test suggeriti (non scritti salvo
richiesta esplicita).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 19:30:51 +02:00
davide 0d3943f9f9 chore: rimuove sop.md, non più necessario
Documento di pianificazione iniziale, ormai obsoleto e superato dal
codice e dalla documentazione attuale (CLAUDE.md, README.md).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 19:30:44 +02:00
davide 6f82aa0225 docs: aggiorna README e docker/README al nuovo workflow di sviluppo
Sostituisce le istruzioni per il container dev con Flutter host +
emulatore Android; aggiorna i path docker/build/build.sh → docker/build.sh.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 19:30:34 +02:00
17 changed files with 1019 additions and 316 deletions
+1
View File
@@ -22,3 +22,4 @@ test/widget_test.dart
/.idea/
*.iml
.metadata
coverage/
+60 -81
View File
@@ -4,113 +4,92 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project Overview
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.
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**. Il rilascio avviene come APK Android buildato via Docker.
## Ruolo atteso
Quando viene richiesta una modifica o una nuova funzionalità, segui questo flusso:
1. **Analizza prima di implementare**: valuta se la richiesta ha senso — fattibilità, complessità reale, impatto su architettura/UX/persistenza. Se esiste un modo migliore di implementarla, proponilo; se la richiesta è mal posta o ha un costo sproporzionato al beneficio, dillo esplicitamente con l'alternativa. Motiva i giudizi con riferimenti concreti al codice.
2. **Implementa da esperto**: se la richiesta va bene (o dopo l'eventuale discussione), implementala seguendo le convenzioni del progetto (feature-first, Provider, tutto in italiano).
3. **Suggerisci i test, non scriverli**: al termine indica quali test coprirebbero la modifica (casi e file dove andrebbero), ma implementali solo se richiesto esplicitamente.
## Comandi
```bash
# 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
# Sviluppo (host, no Docker)
emulator -avd biteplan & # AVD dedicato, Pixel 6 / Android 14 (google_apis x86_64)
flutter run # hot reload con "r", hot restart con "R"
# Test (dalla root del progetto — richiede immagine biteplan-build)
# Analisi statica
flutter analyze # lint: package:flutter_lints/flutter.yaml
# Test rapidi (host)
flutter test # tutta la suite
flutter test test/features/meal_planner/qr_test.dart # singolo file
# Test riproducibili (Docker, immagine biteplan-build — usati come riferimento)
docker run --rm -v "$(pwd):/workspace" -w /workspace biteplan-build \
bash -c "flutter pub get --enforce-lockfile && flutter test"
# Test singolo file
docker run --rm -v "$(pwd):/workspace" -w /workspace biteplan-build \
bash -c "flutter test test/features/meal_planner/qr_test.dart"
# Integration test (richiede emulatore avviato)
flutter test integration_test/app_test.dart
# Build APK (headless, da host)
bash docker/build/build.sh # debug → dist/biteplan-debug.apk
export BITEPLAN_KEYSTORE_PASS=password # richiesto solo per --release
bash docker/build/build.sh --release # release → dist/biteplan-release.apk
# Build APK (headless, via Docker)
bash docker/build.sh # debug → dist/biteplan-debug.apk
export BITEPLAN_KEYSTORE_PASS=password # richiesto solo per --release
bash docker/build.sh --release # release → dist/biteplan-release.apk
```
## Sviluppo locale
- **Flutter host**: installato in `~/flutter` (tag `3.41.9`, stessa versione pinnata nel Dockerfile), nel `PATH` via `~/.bashrc`. Docker non si usa più per il dev; resta per test di riferimento e build APK (isolamento, `--enforce-lockfile`, firma release).
- **Emulatore**: Android SDK in `/home/davide/android-sdk`; accelerazione hardware via `/dev/kvm` (WSL2 con KVM abilitato).
- **AVD `biteplan`**: dedicato a questo progetto (Pixel 6, Android 14, google_apis/x86_64). Non riutilizzare AVD di altri progetti (es. `palladium_wallet`).
- **Permessi**: le build Docker girano come root nel volume montato — se `.dart_tool/` o `build/` risultano di proprietà root, serve `sudo chown -R davide:davide .dart_tool build` prima di usare flutter da host.
## Build e firma
- **Flutter pinnato**: versione `3.41.9` nei Dockerfile (`ARG FLUTTER_VERSION`); aggiornare anche `.flutter-version`
- **Gradle cache**: named volume Docker `gradle-cache`, isolata dall'host
- **Flutter pinnato**: versione `3.41.9` in `docker/Dockerfile` (`ARG FLUTTER_VERSION`); aggiornare anche `.flutter-version` e l'install host
- **Dipendenze**: `pubspec.lock` committato; la build usa `--enforce-lockfile` e fallisce se il lock non è allineato
- **Keystore**: `docker/biteplan.jks` è in `.gitignore`; da ottenere dall'autore o generare con `keytool`
- **Firma release**: password passata via `BITEPLAN_KEYSTORE_PASS` (mai hardcoded); alias keystore: `biteplan`
- **Keystore**: `docker/biteplan.jks` è in `.gitignore`; da ottenere dall'autore o generare con `keytool`. Password via `BITEPLAN_KEYSTORE_PASS` (mai hardcoded); alias: `biteplan`
- **applicationId**: `com.davide.biteplan` — non cambiare, garantisce aggiornamento diretto dalla v1.2.1
- **versionCode**: letto da `pubspec.yaml` (`version: X.Y.Z+BUILD`); BUILD deve essere sempre crescente
## Architettura
Feature-first sotto `lib/features/` (meal_planner, converter, shopping_list, guide), ognuna con `models/`, `providers/`, `presentation/`. Codice trasversale in `lib/shared/` e `lib/core/`.
```
lib/
├── main.dart
├── app.dart # MaterialApp, NavigationBar; initState chiama UpdateService.checkUpdate() e mostra showUpdateDialog() se c'è aggiornamento
├── core/
│ ├── constants/app_constants.dart # kDayIds, kMealSlots, kStorageKey*, kAppVersion
│ └── theme/app_theme.dart
├── app.dart # MaterialApp + NavigationBar; in initState chiama UpdateService.checkUpdate() e mostra showUpdateDialog()
├── core/constants/app_constants.dart # kDayIds, kMealSlots, kStorageKey*, kAppVersion
├── shared/
│ ├── services/
│ ├── storage_service.dart # wrapper SharedPreferences (load/save)
│ ├── update_service.dart # checkUpdate() → GitHub API, isNewer(), parseTagName() (@visibleForTesting)
│ └── url_launcher_service.dart # MethodChannel 'com.davide.biteplan/launcher' → Intent.ACTION_VIEW
│ └── widgets/
│ └── update_dialog.dart # showUpdateDialog() — AlertDialog con "Più tardi" / "Scarica"
│ ├── services/storage_service.dart # wrapper SharedPreferences (load/save)
│ ├── services/update_service.dart # checkUpdate() → GitHub API; isNewer(), parseTagName() (@visibleForTesting)
│ ├── services/url_launcher_service.dart # MethodChannel 'com.davide.biteplan/launcher' → Intent.ACTION_VIEW
│ └── widgets/update_dialog.dart # showUpdateDialog() — "Più tardi" / "Scarica"
├── 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
│ ├── meal_planner/ # MealPlan/DayPlan, qr_codec.dart (buildQrPayload, parseMealPlanFromQr), pagine planner + scan QR
│ ├── converter/ # ConversionEntry con rawToCooked/cookedToRaw, dati da assets/data/conversions.json
│ ├── shopping_list/ # ShoppingItem con quantity per aggregazione duplicati
└── guide/ # guide_page (3 tab) + info_bottom_sheet
└── assets/data/conversions.json # 50+ alimenti × metodi cottura
```
**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).
**Update checker**: `UpdateService.checkUpdate()` usa `dart:io` `HttpClient` (nessun package aggiuntivo); disabilitato su web (`kIsWeb`). Il `MethodChannel` `com.davide.biteplan/launcher` è implementato in `android/app/src/main/kotlin/com/davide/biteplan/MainActivity.kt`.
**Widget test con MethodChannel**: usare `TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler` per mockare il canale nei test — vedi `test/shared/widgets/update_dialog_test.dart`.
**State management**: Provider (`ChangeNotifier`), uno per feature, registrati in `main.dart` con `MultiProvider`.
**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).
**Update checker**: `UpdateService.checkUpdate()` usa `dart:io` `HttpClient` (nessun package aggiuntivo); disabilitato su web (`kIsWeb`). Il `MethodChannel` `com.davide.biteplan/launcher` è implementato in `android/app/src/main/kotlin/com/davide/biteplan/MainActivity.kt`.
## Testing
130 test (unit + widget). Non richiedono device fisico.
184 test unit + widget in `test/` (rispecchia la struttura di `lib/`), più un integration test in `integration_test/app_test.dart`. I test unit/widget non richiedono device. Coverage ~93% su `lib/` (esclusi per scelta: `main.dart`, il percorso camera di `qr_scan_page`).
```
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
└── shared/
├── services/update_service_test.dart
└── widgets/update_dialog_test.dart
```
I test usano `SharedPreferences.setMockInitialValues({})` per isolare lo storage.
- Storage isolato con `SharedPreferences.setMockInitialValues({})`
- Widget test: estensione `pumpApp` in `test/helpers/pump_app.dart`
- MethodChannel nei test: `TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler` — vedi `test/shared/widgets/update_dialog_test.dart`
- `test/coverage_helper_test.dart` importa tutta `lib/` così lcov include anche i file a coverage zero — aggiungere i nuovi file lì
- **Non chiamare** `loadDb()`/`load()` dei provider dentro il body di `testWidgets`: l'async reale nella zona FakeAsync può bloccare la suite (timeout 10 min). Farlo nel `setUp` — vedi `test/features/converter/pages/converter_page_test.dart`
+139 -18
View File
@@ -38,29 +38,159 @@ App Android per la gestione della dieta quotidiana — pianificazione pasti, con
| QR code | qr_flutter + mobile_scanner |
| Build APK | Docker (headless) |
## Guida rapida
1. **Prima volta**: segui [Requisiti host](#requisiti-host) e [Installazione passo passo](#installazione-passo-passo) per installare Flutter, Android SDK ed emulatore.
2. **Sviluppo quotidiano**: vedi [Sviluppo](#sviluppo) per avviare l'emulatore e lanciare l'app con hot reload.
3. **Build APK**: vedi [Build APK](#build-apk) per generare `dist/biteplan-debug.apk` o `-release.apk` via Docker.
4. **Provare l'APK buildato sull'emulatore** (senza `flutter run`): vedi [Installare e testare l'APK di debug sull'emulatore](#installare-e-testare-lapk-di-debug-sullemulatore).
## Requisiti host
- **Linux x86_64** (Ubuntu/Debian o simili; funziona anche su **WSL2** se il kernel espone `/dev/kvm`)
- **KVM** per l'accelerazione hardware — senza, l'emulatore è inutilizzabile. Verifica con:
```bash
ls /dev/kvm # deve esistere
groups | grep kvm # l'utente deve essere nel gruppo kvm
# se manca il gruppo: sudo usermod -aG kvm $USER (poi rilogin)
```
- **Java 17+** (`sudo apt install openjdk-21-jdk`)
- Strumenti di base: `git curl unzip` — e ~15 GB di spazio libero
### Installazione passo passo
**1. Flutter** (versione pinnata `3.41.9`, la stessa di `.flutter-version`):
```bash
git clone --depth 1 --branch 3.41.9 https://github.com/flutter/flutter.git ~/flutter
echo 'export PATH="$HOME/flutter/bin:$PATH"' >> ~/.bashrc && source ~/.bashrc
flutter --version # al primo avvio scarica il Dart SDK
```
**2. Android SDK command-line tools:**
```bash
mkdir -p ~/android-sdk/cmdline-tools
cd ~/android-sdk/cmdline-tools
curl -O https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip
unzip commandlinetools-linux-11076708_latest.zip && mv cmdline-tools latest
rm commandlinetools-linux-11076708_latest.zip
cat >> ~/.bashrc <<'EOF'
export ANDROID_HOME="$HOME/android-sdk"
export PATH="$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/platform-tools:$ANDROID_HOME/emulator:$PATH"
EOF
source ~/.bashrc
```
**3. Pacchetti SDK e licenze:**
```bash
sdkmanager "platform-tools" "emulator" \
"platforms;android-34" "build-tools;34.0.0" \
"system-images;android-34;google_apis;x86_64"
yes | sdkmanager --licenses
```
**4. AVD dedicato al progetto** (Pixel 6, Android 14):
```bash
echo "no" | avdmanager create avd -n biteplan \
-k "system-images;android-34;google_apis;x86_64" -d pixel_6
```
**5. Verifica e primo avvio:**
```bash
flutter doctor # "Android toolchain" deve risultare ✓
emulator -avd biteplan &
# attendi il boot (1-2 min al primo avvio), poi dalla root del progetto:
flutter run
```
> **Nota WSL2**: serve un kernel con KVM abilitato (WSL2 recenti lo hanno di serie —
> verifica con `ls /dev/kvm`). La finestra dell'emulatore compare tramite WSLg.
## Sviluppo
Il container dev avvia un web server Flutter accessibile dal browser, con hot reload.
Una volta completata l'installazione, il ciclo quotidiano è:
```bash
cd docker/dev
docker compose up
# → http://localhost:5173
emulator -avd biteplan &
# attendi il boot completo (qualche secondo se l'AVD ha già uno snapshot, 1-2 min altrimenti), poi:
flutter run
# hot reload con "r", hot restart con "R", "q" per terminare
```
Con il container attivo, in un altro terminale:
Lancialo da un terminale interattivo (non in background): `r`/`R` leggono da stdin, quindi
un processo lanciato senza terminale (es. `nohup ... &`) non risponde all'hot reload.
> **Permessi dopo una build Docker**: `bash docker/build.sh` (vedi [Build APK](#build-apk))
> gira come root nel volume montato. Se dopo una build `flutter run` fallisce con errori di
> permessi (es. su `android/local.properties`) o Gradle si lamenta di file in `/root/.pub-cache`,
> i file sono rimasti di proprietà di root:
> ```bash
> sudo chown -R $(whoami):$(whoami) android .dart_tool build
> rm -rf .dart_tool build && flutter pub get
> ```
Vedi [docker/README.md](docker/README.md) per test e build APK via Docker.
## Build APK
```bash
docker compose attach dev
# r → hot reload | R → hot restart | q → esci
bash docker/build.sh # debug → dist/biteplan-debug.apk
bash docker/build.sh --release # release → dist/biteplan-release.apk
```
Vedi [docker/README.md](docker/README.md) per i dettagli e la build APK.
Vedi [docker/README.md](docker/README.md) per i requisiti della firma release.
## Installare e testare l'APK di debug sull'emulatore
Una volta creato l'AVD `biteplan` (vedi sopra), per installare e provare un APK di debug
già buildato (es. `dist/biteplan-debug.apk` generato da `bash docker/build.sh`) senza
passare da `flutter run`:
**1. Avvia l'emulatore e attendi il boot completo:**
```bash
emulator -avd biteplan &
adb wait-for-device
# poi attendi che il boot sia terminato (può richiedere 1-2 min al primo avvio):
adb shell 'while [ "$(getprop sys.boot_completed)" != "1" ]; do sleep 1; done'
```
**2. Installa l'APK:**
```bash
adb install -r dist/biteplan-debug.apk # -r = reinstalla sovrascrivendo se già presente
```
**3. Avvia l'app:**
```bash
adb shell monkey -p com.davide.biteplan -c android.intent.category.LAUNCHER 1
```
**4. Osserva i log in tempo reale (utile per debug):**
```bash
adb logcat --pid=$(adb shell pidof -s com.davide.biteplan)
```
**5. Disinstalla quando hai finito:**
```bash
adb uninstall com.davide.biteplan
```
> Per lo sviluppo iterativo (hot reload) usa invece `flutter run` — questa procedura serve
> a validare l'APK esatto che verrà distribuito.
## Test
La suite copre unit test e widget test. Richiede l'immagine Docker `biteplan-build`
(creata automaticamente al primo `bash docker/build/build.sh`).
(creata automaticamente al primo `bash docker/build.sh`).
```bash
# Tutti i test (dalla root del progetto)
@@ -98,15 +228,6 @@ test/
└── update_dialog_test.dart # widget test dialog aggiornamento
```
## 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
- [Guida utente](docs/guida-utente.md)
+12 -40
View File
@@ -1,39 +1,12 @@
# Docker — BitePlan
Due container distinti: uno per lo sviluppo con hot reload, uno per la build APK headless.
Un solo container, usato per test riproducibili e build APK headless.
Lo sviluppo (hot reload) avviene sull'host con Flutter + emulatore Android — vedi `CLAUDE.md`.
Tutti i comandi vanno eseguiti dalla **root del progetto** (`~/biteplan`), non da `docker/`.
---
## Container dev (`docker/dev/`)
Avvia un web server Flutter accessibile dal browser, con hot reload.
```bash
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.
---
## Container build (`docker/build/`)
## Container build (`docker/`)
Build headless riproducibile per generare l'APK Android.
@@ -42,7 +15,7 @@ Build headless riproducibile per generare l'APK Android.
APK firmato con il debug keystore di Android — adatto per installazione diretta sul dispositivo.
```bash
bash docker/build/build.sh
bash docker/build.sh
# → dist/biteplan-debug.apk
```
@@ -83,7 +56,7 @@ aggiungila a `~/.bashrc` o `~/.zshrc`.
**3. Esegui la build release**
```bash
bash docker/build/build.sh --release
bash docker/build.sh --release
# → dist/biteplan-release.apk
```
@@ -107,8 +80,7 @@ adb install dist/biteplan-debug.apk
## Test
I test non richiedono il container dev — usano l'immagine `biteplan-build`, creata
automaticamente al primo `bash docker/build/build.sh`.
I test usano l'immagine `biteplan-build`, creata automaticamente al primo `bash docker/build.sh`.
```bash
# Tutti i test (110 test — unit + widget)
@@ -137,17 +109,17 @@ avere Flutter installato sull'host.
## Flusso completo
```
# 1. Sviluppo
cd docker/dev && docker compose up
→ http://localhost:5173 (hot reload con 'r')
# 1. Sviluppo (host, no Docker — vedi CLAUDE.md)
emulator -avd biteplan &
flutter run
# 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
bash docker/build.sh # → dist/biteplan-debug.apk
bash docker/build.sh --release # → dist/biteplan-release.apk
# 4. Installazione
adb install dist/biteplan-debug.apk
@@ -157,7 +129,7 @@ adb install dist/biteplan-debug.apk
## Note
- App ID: `com.biteplan.biteplan`
- App ID: `com.davide.biteplan`
- Android target: API 34
- Il keystore non viene mai copiato nell'immagine Docker (volume read-only)
- `dist/` è in `.gitignore`
-177
View File
@@ -1,177 +0,0 @@
# SOP — BitePlan (Flutter → Android APK)
## Scope
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 asset JSON interno.
3. **Lista della spesa** — checklist con aggiunta, spunta e rimozione elementi.
---
## Stack tecnologico
| Livello | Scelta |
|---|---|
| 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
```
lib/
├── main.dart
├── app.dart # MaterialApp, routing, BottomNavigationBar
├── pages/
│ ├── 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
```
---
## 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
# Avvio
cd docker/dev
docker compose up
# Accesso GUI
# → http://localhost:6080 (noVNC, nessuna password)
```
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`
---
## Container build (APK)
Build headless riproducibile, senza GUI.
```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;
}
```
**Shopping Item**
```dart
class ShoppingItem {
final String id;
final String name;
bool checked;
}
```
---
## Conversione crudo/cotto
**Asset**`lib/data/conversions.json`
```json
{
"pollo": { "forno": { "yield": 0.75 }, "padella": { "yield": 0.70 } },
"riso basmati": { "bollitura": { "yield": 3.00 } }
}
```
`yield = peso_cotto / peso_crudo`
**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 → selezione alimento + metodo → input grammi → risultato in tempo reale.
---
## Persistenza
`StorageService` wrappa `shared_preferences`:
- Meal plan serializzato come JSON stringa sotto chiave `meals`
- Lista spesa serializzata come JSON stringa sotto chiave `shopping_list`
---
## UI
- `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`)
---
## Fasi future
- Modifica coefficienti di conversione in-app
- Calcolo kcal
- Condivisione piano via QR code
- Sync cloud
+80
View File
@@ -0,0 +1,80 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:biteplan/app.dart';
import 'package:biteplan/core/constants/app_constants.dart';
import 'package:biteplan/features/converter/providers/converter_provider.dart';
import 'package:biteplan/features/meal_planner/providers/meal_planner_provider.dart';
import 'package:biteplan/features/shopping_list/providers/shopping_list_provider.dart';
late MealPlannerProvider _meals;
late ConverterProvider _converter;
late ShoppingListProvider _shopping;
Future<void> _pumpApp(WidgetTester tester) async {
await tester.binding.setSurfaceSize(const Size(800, 1600));
addTearDown(() => tester.binding.setSurfaceSize(null));
await tester.pumpWidget(
MultiProvider(
providers: [
ChangeNotifierProvider.value(value: _meals),
ChangeNotifierProvider.value(value: _converter),
ChangeNotifierProvider.value(value: _shopping),
],
child: const BitePlanApp(),
),
);
await tester.pumpAndSettle();
}
Finder _appBarTitle(String text) => find.descendant(
of: find.byType(AppBar), matching: find.text(text));
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
// I provider sono creati e caricati nel setUp (fuori dalla zona FakeAsync
// di testWidgets): il caricamento async dentro il body può bloccare la suite.
setUp(() async {
SharedPreferences.setMockInitialValues({});
_meals = MealPlannerProvider();
_converter = ConverterProvider();
_shopping = ShoppingListProvider();
await _meals.load();
await _converter.loadDb();
await _shopping.load();
});
group('BitePlanApp', () {
testWidgets('parte sul tab Piano Pasti', (tester) async {
await _pumpApp(tester);
expect(_appBarTitle('Piano Pasti'), findsOneWidget);
expect(find.byType(NavigationBar), findsOneWidget);
});
testWidgets('naviga al tab Convertitore', (tester) async {
await _pumpApp(tester);
await tester.tap(find.text('Converti'));
await tester.pumpAndSettle();
expect(_appBarTitle('Convertitore'), findsOneWidget);
expect(find.text('Cerca un alimento per iniziare'), findsOneWidget);
});
testWidgets('naviga al tab Lista della spesa', (tester) async {
await _pumpApp(tester);
await tester.tap(find.text('Spesa'));
await tester.pumpAndSettle();
expect(_appBarTitle('Lista della spesa'), findsOneWidget);
expect(find.text('Lista vuota'), findsOneWidget);
});
testWidgets('il bottone info apre il bottom sheet informazioni',
(tester) async {
await _pumpApp(tester);
await tester.tap(find.byIcon(Icons.info_outline));
await tester.pumpAndSettle();
expect(find.text('Versione $kAppVersion'), findsOneWidget);
});
});
}
+35
View File
@@ -0,0 +1,35 @@
// Importa tutti i file di lib/ così il report di coverage include anche
// i file non toccati da alcun test (altrimenti lcov li ignora del tutto).
// ignore_for_file: unused_import
import 'package:biteplan/app.dart';
import 'package:biteplan/core/constants/app_constants.dart';
import 'package:biteplan/core/theme/app_theme.dart';
import 'package:biteplan/features/converter/models/conversion_entry.dart';
import 'package:biteplan/features/converter/presentation/pages/converter_page.dart';
import 'package:biteplan/features/converter/providers/converter_provider.dart';
import 'package:biteplan/features/guide/presentation/pages/guide_page.dart';
import 'package:biteplan/features/guide/presentation/widgets/info_bottom_sheet.dart';
import 'package:biteplan/features/meal_planner/models/meal_plan.dart';
import 'package:biteplan/features/meal_planner/presentation/pages/meal_planner_page.dart';
import 'package:biteplan/features/meal_planner/presentation/pages/qr_scan_page.dart';
import 'package:biteplan/features/meal_planner/presentation/widgets/meal_card.dart';
import 'package:biteplan/features/meal_planner/presentation/widgets/qr_share_sheet.dart';
import 'package:biteplan/features/meal_planner/providers/meal_planner_provider.dart';
import 'package:biteplan/features/meal_planner/qr_codec.dart';
import 'package:biteplan/features/shopping_list/models/shopping_item.dart';
import 'package:biteplan/features/shopping_list/presentation/pages/shopping_list_page.dart';
import 'package:biteplan/features/shopping_list/presentation/widgets/shopping_item_tile.dart';
import 'package:biteplan/features/shopping_list/providers/shopping_list_provider.dart';
import 'package:biteplan/main.dart';
import 'package:biteplan/shared/services/storage_service.dart';
import 'package:biteplan/shared/services/update_service.dart';
import 'package:biteplan/shared/services/url_launcher_service.dart';
import 'package:biteplan/shared/widgets/empty_state.dart';
import 'package:biteplan/shared/widgets/update_dialog.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
test('coverage helper', () {
expect(true, isTrue);
});
}
@@ -0,0 +1,104 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:provider/provider.dart';
import 'package:biteplan/features/converter/models/conversion_entry.dart';
import 'package:biteplan/features/converter/presentation/pages/converter_page.dart';
import 'package:biteplan/features/converter/providers/converter_provider.dart';
import '../../../helpers/pump_app.dart';
Widget _page(ConverterProvider provider) => ChangeNotifierProvider.value(
value: provider,
child: const Scaffold(body: ConverterPage()),
);
const _riso =
ConversionEntry(food: 'riso', method: 'bollitura', yieldFactor: 3.0);
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
late ConverterProvider provider;
// loadDb nel setUp (fuori dalla zona FakeAsync di testWidgets):
// caricarlo dentro il body del test può bloccare la suite.
setUp(() async {
provider = ConverterProvider();
await provider.loadDb();
});
group('ConverterPage — ricerca', () {
testWidgets('mostra lo stato vuoto iniziale', (tester) async {
await tester.pumpApp(_page(provider));
expect(find.text('Cerca un alimento per iniziare'), findsOneWidget);
});
testWidgets('la ricerca mostra risultati dal database', (tester) async {
await tester.pumpApp(_page(provider));
await tester.enterText(find.byType(TextField), 'riso');
await tester.pump();
expect(find.byType(ListTile), findsWidgets);
});
testWidgets('ricerca senza corrispondenze mostra "Nessun risultato"',
(tester) async {
await tester.pumpApp(_page(provider));
await tester.enterText(find.byType(TextField), 'xyzabc');
await tester.pump();
expect(find.text('Nessun risultato'), findsOneWidget);
});
testWidgets('tap su un risultato apre la card di conversione',
(tester) async {
await tester.pumpApp(_page(provider));
await tester.enterText(find.byType(TextField), 'riso');
await tester.pump();
await tester.tap(find.byType(ListTile).first);
await tester.pump();
expect(find.text('Cambia'), findsOneWidget);
expect(find.text('CRUDO'), findsOneWidget);
expect(find.text('COTTO'), findsOneWidget);
});
});
group('ConverterPage — card di conversione', () {
testWidgets('calcola e mostra il risultato formattato', (tester) async {
provider.select(_riso);
await tester.pumpApp(_page(provider));
await tester.enterText(find.byType(TextField), '100');
await tester.pump();
// 100 * 3.0 = 300, intero → senza decimali
expect(find.text('300'), findsOneWidget);
expect(find.textContaining('fattore di resa 3.0'), findsOneWidget);
});
testWidgets('input non numerico mostra il placeholder —', (tester) async {
provider.select(_riso);
await tester.pumpApp(_page(provider));
await tester.enterText(find.byType(TextField), 'abc');
await tester.pump();
expect(find.text(''), findsOneWidget);
});
testWidgets('swap inverte la direzione e azzera il risultato',
(tester) async {
provider.select(_riso);
await tester.pumpApp(_page(provider));
await tester.enterText(find.byType(TextField), '100');
await tester.pump();
expect(find.text('300'), findsOneWidget);
await tester.tap(find.byIcon(Icons.swap_horiz));
await tester.pump();
expect(provider.rawToCooked, isFalse);
expect(find.text('300'), findsNothing);
});
testWidgets('"Cambia" torna alla ricerca', (tester) async {
provider.select(_riso);
await tester.pumpApp(_page(provider));
await tester.tap(find.text('Cambia'));
await tester.pump();
expect(find.text('Cerca un alimento per iniziare'), findsOneWidget);
expect(provider.selected, isNull);
});
});
}
@@ -1,8 +1,12 @@
import 'dart:convert';
import 'package:flutter/services.dart';
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() {
TestWidgetsFlutterBinding.ensureInitialized();
group('ConverterProvider', () {
late ConverterProvider provider;
@@ -187,5 +191,45 @@ void main() {
expect(notified, true);
});
});
group('loadDb()', () {
test('carica il database e la ricerca trova alimenti noti', () async {
await provider.loadDb();
provider.search('riso');
expect(provider.results, isNotEmpty);
provider.search('pollo');
expect(provider.results, isNotEmpty);
});
test('la ricerca è case-insensitive', () async {
await provider.loadDb();
provider.search('RISO');
expect(provider.results, isNotEmpty);
});
test('notifica i listener', () async {
var notified = false;
provider.addListener(() => notified = true);
await provider.loadDb();
expect(notified, true);
});
});
});
group('conversions.json', () {
test('ogni metodo di ogni alimento ha uno yield positivo', () async {
final raw =
await rootBundle.loadString('assets/data/conversions.json');
final json = jsonDecode(raw) as Map<String, dynamic>;
expect(json, isNotEmpty);
json.forEach((food, methods) {
(methods as Map).forEach((method, data) {
final yield_ = (data as Map)['yield'];
expect(yield_, isA<num>(), reason: '$food/$method');
expect((yield_ as num).toDouble(), greaterThan(0),
reason: '$food/$method');
});
});
});
});
}
@@ -0,0 +1,37 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:biteplan/features/guide/presentation/pages/guide_page.dart';
import '../../../helpers/pump_app.dart';
void main() {
group('GuidePage', () {
testWidgets('mostra titolo e le tre tab', (tester) async {
await tester.pumpApp(const GuidePage());
expect(find.text('Guida'), findsOneWidget);
expect(find.text('Pasti'), findsOneWidget);
expect(find.text('Converti'), findsOneWidget);
expect(find.text('Spesa'), findsOneWidget);
});
// _DocCard rende i titoli con toUpperCase()
testWidgets('la tab iniziale è Pasti', (tester) async {
await tester.pumpApp(const GuidePage());
expect(find.text('AGGIUNGERE UN ALIMENTO'), findsOneWidget);
});
testWidgets('la tab Converti mostra i suoi contenuti', (tester) async {
await tester.pumpApp(const GuidePage());
await tester.tap(find.text('Converti'));
await tester.pumpAndSettle();
expect(find.text('COME USARLO'), findsOneWidget);
expect(find.text('ALIMENTI DISPONIBILI'), findsOneWidget);
});
testWidgets('la tab Spesa mostra i suoi contenuti', (tester) async {
await tester.pumpApp(const GuidePage());
await tester.tap(find.text('Spesa'));
await tester.pumpAndSettle();
expect(find.text('AGGIUNGERE UN ELEMENTO'), findsOneWidget);
expect(find.text('SPUNTARE UN ELEMENTO'), findsOneWidget);
});
});
}
@@ -0,0 +1,41 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:biteplan/core/constants/app_constants.dart';
import 'package:biteplan/features/guide/presentation/widgets/info_bottom_sheet.dart';
import '../../../helpers/pump_app.dart';
Future<void> _openSheet(WidgetTester tester) async {
await tester.pumpApp(Scaffold(
body: Builder(
builder: (context) => Center(
child: FilledButton(
onPressed: () => showInfoBottomSheet(context),
child: const Text('apri'),
),
),
),
));
await tester.tap(find.text('apri'));
await tester.pumpAndSettle();
}
void main() {
group('InfoBottomSheet', () {
testWidgets('mostra nome app, versione, autore e licenza',
(tester) async {
await _openSheet(tester);
expect(find.text('BitePlan'), findsOneWidget);
expect(find.text('Versione $kAppVersion'), findsOneWidget);
expect(find.text('Autore'), findsOneWidget);
expect(find.text('Licenza'), findsOneWidget);
});
testWidgets('"Apri" sulla riga Guida naviga alla GuidePage',
(tester) async {
await _openSheet(tester);
await tester.tap(find.text('Apri'));
await tester.pumpAndSettle();
expect(find.text('AGGIUNGERE UN ALIMENTO'), findsOneWidget);
});
});
}
@@ -0,0 +1,134 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:biteplan/features/meal_planner/presentation/pages/meal_planner_page.dart';
import 'package:biteplan/features/meal_planner/presentation/widgets/meal_card.dart';
import 'package:biteplan/features/meal_planner/providers/meal_planner_provider.dart';
import 'package:biteplan/features/shopping_list/providers/shopping_list_provider.dart';
import '../../../helpers/pump_app.dart';
Widget _page({
required MealPlannerProvider meals,
required ShoppingListProvider shopping,
VoidCallback? onGoShopping,
}) =>
MultiProvider(
providers: [
ChangeNotifierProvider.value(value: meals),
ChangeNotifierProvider.value(value: shopping),
],
child: Scaffold(
body: MealPlannerPage(onGoShopping: onGoShopping ?? () {}),
),
);
String get _todayId {
const ids = [
'',
'lunedi',
'martedi',
'mercoledi',
'giovedi',
'venerdi',
'sabato',
'domenica',
];
return ids[DateTime.now().weekday];
}
void main() {
late MealPlannerProvider meals;
late ShoppingListProvider shopping;
setUp(() async {
SharedPreferences.setMockInitialValues({});
meals = MealPlannerProvider();
shopping = ShoppingListProvider();
await meals.load();
await shopping.load();
});
Future<void> pumpPage(WidgetTester tester, {VoidCallback? onGoShopping}) async {
await tester.binding.setSurfaceSize(const Size(800, 1800));
addTearDown(() => tester.binding.setSurfaceSize(null));
await tester.pumpApp(
_page(meals: meals, shopping: shopping, onGoShopping: onGoShopping));
}
group('MealPlannerPage', () {
testWidgets('mostra le 7 card dei giorni', (tester) async {
await pumpPage(tester);
expect(find.byType(MealCard), findsNWidgets(7));
});
testWidgets('solo la card di oggi è inizialmente espansa', (tester) async {
await pumpPage(tester);
for (final card in
tester.widgetList<MealCard>(find.byType(MealCard))) {
expect(card.initiallyExpanded, card.dayId == _todayId,
reason: card.dayId);
}
});
testWidgets('"Condividi" è disabilitato con piano vuoto', (tester) async {
await pumpPage(tester);
final button = tester.widget<OutlinedButton>(
find.widgetWithText(OutlinedButton, 'Condividi'));
expect(button.onPressed, isNull);
});
testWidgets('"Condividi" è abilitato con almeno un pasto', (tester) async {
meals.addItem('lunedi', 'pranzo', 'pasta');
await pumpPage(tester);
final button = tester.widget<OutlinedButton>(
find.widgetWithText(OutlinedButton, 'Condividi'));
expect(button.onPressed, isNotNull);
});
testWidgets('"Svuota piano" appare solo con pasti presenti',
(tester) async {
await pumpPage(tester);
expect(find.text('Svuota piano'), findsNothing);
meals.addItem('lunedi', 'pranzo', 'pasta');
await tester.pump();
expect(find.text('Svuota piano'), findsOneWidget);
});
testWidgets('"Genera lista" popola la spesa e chiama onGoShopping',
(tester) async {
meals.addItem('lunedi', 'pranzo', 'pasta');
meals.addItem('martedi', 'cena', 'pollo');
var navigated = false;
await pumpPage(tester, onGoShopping: () => navigated = true);
await tester.tap(find.text('Genera lista della spesa'));
await tester.pump();
expect(navigated, isTrue);
expect(shopping.items.map((i) => i.name),
containsAll(['pasta', 'pollo']));
});
testWidgets('"Svuota piano" con Annulla mantiene i pasti', (tester) async {
meals.addItem('lunedi', 'pranzo', 'pasta');
await pumpPage(tester);
await tester.tap(find.widgetWithText(OutlinedButton, 'Svuota piano'));
await tester.pumpAndSettle();
await tester.tap(find.text('Annulla'));
await tester.pumpAndSettle();
expect(meals.plan.hasAnyMeal, isTrue);
});
testWidgets('"Svuota piano" con Svuota azzera il piano', (tester) async {
meals.addItem('lunedi', 'pranzo', 'pasta');
await pumpPage(tester);
await tester.tap(find.widgetWithText(OutlinedButton, 'Svuota piano'));
await tester.pumpAndSettle();
await tester.tap(find.text('Svuota'));
await tester.pumpAndSettle();
expect(meals.plan.hasAnyMeal, isFalse);
});
});
}
@@ -60,6 +60,24 @@ void main() {
expect(provider.plan.days['lunedi']!.pranzo, ['pasta', 'frutta']);
});
test('removeItem rimuove dallo slot colazione', () async {
final provider = MealPlannerProvider();
await provider.load();
provider.addItem('lunedi', 'colazione', 'latte');
provider.addItem('lunedi', 'colazione', 'caffè');
provider.removeItem('lunedi', 'colazione', 0);
expect(provider.plan.days['lunedi']!.colazione, ['caffè']);
});
test('removeItem rimuove dallo slot cena', () async {
final provider = MealPlannerProvider();
await provider.load();
provider.addItem('lunedi', 'cena', 'pollo');
provider.addItem('lunedi', 'cena', 'verdure');
provider.removeItem('lunedi', 'cena', 1);
expect(provider.plan.days['lunedi']!.cena, ['pollo']);
});
test('clearAll svuota tutti i giorni', () async {
final provider = MealPlannerProvider();
await provider.load();
@@ -0,0 +1,66 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:qr_flutter/qr_flutter.dart';
import 'package:biteplan/core/constants/app_constants.dart';
import 'package:biteplan/features/meal_planner/models/meal_plan.dart';
import 'package:biteplan/features/meal_planner/presentation/widgets/qr_share_sheet.dart';
import '../../../helpers/pump_app.dart';
MealPlan _planWith(Map<String, DayPlan> overrides) => MealPlan({
...Map.fromEntries(kDayIds.map((d) => MapEntry(d, DayPlan()))),
...overrides,
});
Future<void> _openSheet(WidgetTester tester, MealPlan plan) async {
await tester.pumpApp(Scaffold(
body: Builder(
builder: (context) => Center(
child: FilledButton(
onPressed: () => showQrShareSheet(context, plan),
child: const Text('apri'),
),
),
),
));
await tester.tap(find.text('apri'));
await tester.pumpAndSettle();
}
void main() {
group('QrShareSheet', () {
testWidgets('piano valido mostra il QR code', (tester) async {
final plan = _planWith({
'lunedi': DayPlan(pranzo: ['pasta'])
});
await _openSheet(tester, plan);
expect(find.text('Condividi piano'), findsOneWidget);
expect(find.byType(QrImageView), findsOneWidget);
});
testWidgets('piano troppo grande mostra il messaggio di errore',
(tester) async {
final plan = _planWith({
for (final d in kDayIds)
d: DayPlan(
colazione: List.generate(
30, (i) => 'alimento molto lungo numero $i della colazione'),
pranzo: List.generate(
30, (i) => 'alimento molto lungo numero $i del pranzo'),
),
});
await _openSheet(tester, plan);
expect(find.textContaining('Dati troppo grandi'), findsOneWidget);
expect(find.byType(QrImageView), findsNothing);
});
testWidgets('il pulsante chiudi fa scomparire il foglio', (tester) async {
final plan = _planWith({
'lunedi': DayPlan(pranzo: ['pasta'])
});
await _openSheet(tester, plan);
await tester.tap(find.byIcon(Icons.close));
await tester.pumpAndSettle();
expect(find.text('Condividi piano'), findsNothing);
});
});
}
@@ -0,0 +1,108 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:biteplan/features/shopping_list/presentation/pages/shopping_list_page.dart';
import 'package:biteplan/features/shopping_list/providers/shopping_list_provider.dart';
import '../../../helpers/pump_app.dart';
Widget _page(ShoppingListProvider provider) => ChangeNotifierProvider.value(
value: provider,
child: const Scaffold(body: ShoppingListPage()),
);
void main() {
late ShoppingListProvider provider;
setUp(() async {
SharedPreferences.setMockInitialValues({});
provider = ShoppingListProvider();
await provider.load();
});
group('ShoppingListPage', () {
testWidgets('lista vuota mostra EmptyState', (tester) async {
await tester.pumpApp(_page(provider));
expect(find.text('Lista vuota'), findsOneWidget);
});
testWidgets('aggiunge un elemento con il pulsante +', (tester) async {
await tester.pumpApp(_page(provider));
await tester.enterText(find.byType(TextField), 'pane');
await tester.tap(find.byIcon(Icons.add));
await tester.pump();
expect(find.text('pane'), findsOneWidget);
expect(provider.items, hasLength(1));
});
testWidgets('aggiunge un elemento con Invio', (tester) async {
await tester.pumpApp(_page(provider));
await tester.enterText(find.byType(TextField), 'latte');
await tester.testTextInput.receiveAction(TextInputAction.done);
await tester.pump();
expect(find.text('latte'), findsOneWidget);
});
testWidgets('testo vuoto o solo spazi non aggiunge nulla', (tester) async {
await tester.pumpApp(_page(provider));
await tester.enterText(find.byType(TextField), ' ');
await tester.tap(find.byIcon(Icons.add));
await tester.pump();
expect(find.text('Lista vuota'), findsOneWidget);
expect(provider.items, isEmpty);
});
testWidgets('il contatore usa il singolare con 1 completato',
(tester) async {
provider.add('pane');
provider.add('latte');
provider.toggle(provider.items.first.id);
await tester.pumpApp(_page(provider));
expect(find.text('1 / 2 completato'), findsOneWidget);
});
testWidgets('il contatore usa il plurale con più completati',
(tester) async {
provider.add('pane');
provider.add('latte');
for (final item in [...provider.items]) {
provider.toggle(item.id);
}
await tester.pumpApp(_page(provider));
expect(find.text('2 / 2 completati'), findsOneWidget);
});
testWidgets('la sezione COMPLETATI appare solo con elementi spuntati',
(tester) async {
provider.add('pane');
await tester.pumpApp(_page(provider));
expect(find.textContaining('COMPLETATI'), findsNothing);
provider.toggle(provider.items.first.id);
await tester.pump();
expect(find.text('COMPLETATI (1)'), findsOneWidget);
});
testWidgets('"Svuota lista" con Annulla non svuota', (tester) async {
provider.add('pane');
await tester.pumpApp(_page(provider));
await tester.tap(find.widgetWithText(OutlinedButton, 'Svuota lista'));
await tester.pumpAndSettle();
await tester.tap(find.text('Annulla'));
await tester.pumpAndSettle();
expect(provider.items, hasLength(1));
expect(find.text('pane'), findsOneWidget);
});
testWidgets('"Svuota lista" con Svuota svuota tutto', (tester) async {
provider.add('pane');
await tester.pumpApp(_page(provider));
await tester.tap(find.widgetWithText(OutlinedButton, 'Svuota lista'));
await tester.pumpAndSettle();
await tester.tap(find.text('Svuota'));
await tester.pumpAndSettle();
expect(provider.items, isEmpty);
expect(find.text('Lista vuota'), findsOneWidget);
});
});
}
@@ -1,7 +1,50 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:biteplan/core/constants/app_constants.dart';
import 'package:biteplan/shared/services/update_service.dart';
void main() {
group('UpdateService.checkUpdate', () {
tearDown(() => HttpOverrides.global = null);
test('ritorna la versione quando il tag remoto è più recente', () async {
HttpOverrides.global =
_FakeHttpOverrides(body: '{"tag_name":"v99.0.0"}');
expect(await UpdateService.checkUpdate(), '99.0.0');
});
test('ritorna null quando il tag remoto è uguale alla versione corrente',
() async {
HttpOverrides.global =
_FakeHttpOverrides(body: '{"tag_name":"v$kAppVersion"}');
expect(await UpdateService.checkUpdate(), isNull);
});
test('ritorna null quando il tag remoto è più vecchio', () async {
HttpOverrides.global = _FakeHttpOverrides(body: '{"tag_name":"v0.0.1"}');
expect(await UpdateService.checkUpdate(), isNull);
});
test('ritorna null con status diverso da 200', () async {
HttpOverrides.global = _FakeHttpOverrides(
statusCode: 404, body: '{"message":"Not Found"}');
expect(await UpdateService.checkUpdate(), isNull);
});
test('ritorna null con body malformato', () async {
HttpOverrides.global = _FakeHttpOverrides(body: 'non json');
expect(await UpdateService.checkUpdate(), isNull);
});
test('ritorna null se la connessione fallisce', () async {
HttpOverrides.global = _FakeHttpOverrides(fail: true);
expect(await UpdateService.checkUpdate(), isNull);
});
});
group('UpdateService.parseTagName', () {
test('strips v prefix from tag_name', () {
expect(UpdateService.parseTagName('{"tag_name":"v2.1.0"}'), '2.1.0');
@@ -71,3 +114,66 @@ void main() {
});
});
}
// ── Fake HTTP per checkUpdate ────────────────────────────────────────────────
class _FakeHttpOverrides extends HttpOverrides {
_FakeHttpOverrides({this.statusCode = 200, this.body = '', this.fail = false});
final int statusCode;
final String body;
final bool fail;
@override
HttpClient createHttpClient(SecurityContext? context) =>
_FakeHttpClient(statusCode: statusCode, body: body, fail: fail);
}
class _FakeHttpClient extends Fake implements HttpClient {
_FakeHttpClient(
{required this.statusCode, required this.body, required this.fail});
final int statusCode;
final String body;
final bool fail;
@override
set connectionTimeout(Duration? value) {}
@override
Future<HttpClientRequest> getUrl(Uri url) async {
if (fail) throw const SocketException('rete assente');
return _FakeHttpClientRequest(statusCode: statusCode, body: body);
}
@override
void close({bool force = false}) {}
}
class _FakeHttpClientRequest extends Fake implements HttpClientRequest {
_FakeHttpClientRequest({required this.statusCode, required this.body});
final int statusCode;
final String body;
@override
HttpHeaders get headers => _FakeHttpHeaders();
@override
Future<HttpClientResponse> close() async =>
_FakeHttpClientResponse(statusCode: statusCode, body: body);
}
class _FakeHttpHeaders extends Fake implements HttpHeaders {
@override
void set(String name, Object value, {bool preserveHeaderCase = false}) {}
}
class _FakeHttpClientResponse extends Fake implements HttpClientResponse {
_FakeHttpClientResponse({required this.statusCode, required this.body});
@override
final int statusCode;
final String body;
@override
Stream<S> transform<S>(StreamTransformer<List<int>, S> streamTransformer) =>
Stream<List<int>>.value(utf8.encode(body)).transform(streamTransformer);
}
+34
View File
@@ -0,0 +1,34 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:biteplan/shared/widgets/empty_state.dart';
import '../../helpers/pump_app.dart';
void main() {
group('EmptyState', () {
testWidgets('mostra icona e titolo', (tester) async {
await tester.pumpApp(const Scaffold(
body: EmptyState(icon: Icons.search, title: 'Nessun elemento'),
));
expect(find.byIcon(Icons.search), findsOneWidget);
expect(find.text('Nessun elemento'), findsOneWidget);
});
testWidgets('mostra il sottotitolo quando presente', (tester) async {
await tester.pumpApp(const Scaffold(
body: EmptyState(
icon: Icons.search,
title: 'Titolo',
subtitle: 'Sottotitolo di prova',
),
));
expect(find.text('Sottotitolo di prova'), findsOneWidget);
});
testWidgets('nessun sottotitolo quando assente', (tester) async {
await tester.pumpApp(const Scaffold(
body: EmptyState(icon: Icons.search, title: 'Titolo'),
));
expect(find.byType(Text), findsOneWidget);
});
});
}