Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7c6cf6fd78 | |||
| 65d0eb461c | |||
| 494c0fdbd4 | |||
| 57f522e3a2 | |||
| d94bd5fdf7 | |||
| 9384bd6117 | |||
| e494855fe6 | |||
| 45d53c1bd1 | |||
| 0d3943f9f9 | |||
| 6f82aa0225 |
@@ -22,3 +22,4 @@ test/widget_test.dart
|
|||||||
/.idea/
|
/.idea/
|
||||||
*.iml
|
*.iml
|
||||||
.metadata
|
.metadata
|
||||||
|
coverage/
|
||||||
|
|||||||
@@ -4,113 +4,92 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||||||
|
|
||||||
## Project Overview
|
## 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
|
## Comandi
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Sviluppo (web server con hot reload)
|
# Sviluppo (host, no Docker)
|
||||||
cd docker/dev && docker compose up
|
emulator -avd biteplan & # AVD dedicato, Pixel 6 / Android 14 (google_apis x86_64)
|
||||||
# → http://localhost:5173
|
flutter run # hot reload con "r", hot restart con "R"
|
||||||
# Con il container attivo: docker compose attach dev → r = hot reload
|
|
||||||
|
|
||||||
# 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 \
|
docker run --rm -v "$(pwd):/workspace" -w /workspace biteplan-build \
|
||||||
bash -c "flutter pub get --enforce-lockfile && flutter test"
|
bash -c "flutter pub get --enforce-lockfile && flutter test"
|
||||||
|
|
||||||
# Test singolo file
|
# Integration test (richiede emulatore avviato)
|
||||||
docker run --rm -v "$(pwd):/workspace" -w /workspace biteplan-build \
|
flutter test integration_test/app_test.dart
|
||||||
bash -c "flutter test test/features/meal_planner/qr_test.dart"
|
|
||||||
|
|
||||||
# Build APK (headless, da host)
|
# Build APK (headless, via Docker)
|
||||||
bash docker/build/build.sh # debug → dist/biteplan-debug.apk
|
bash docker/build.sh # debug → dist/biteplan-debug.apk
|
||||||
export BITEPLAN_KEYSTORE_PASS=password # richiesto solo per --release
|
export BITEPLAN_KEYSTORE_PASS=password # richiesto solo per --release
|
||||||
bash docker/build/build.sh --release # release → dist/biteplan-release.apk
|
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
|
## Build e firma
|
||||||
|
|
||||||
- **Flutter pinnato**: versione `3.41.9` nei Dockerfile (`ARG FLUTTER_VERSION`); aggiornare anche `.flutter-version`
|
- **Flutter pinnato**: versione `3.41.9` in `docker/Dockerfile` (`ARG FLUTTER_VERSION`); aggiornare anche `.flutter-version` e l'install host
|
||||||
- **Gradle cache**: named volume Docker `gradle-cache`, isolata dall'host
|
|
||||||
- **Dipendenze**: `pubspec.lock` committato; la build usa `--enforce-lockfile` e fallisce se il lock non è allineato
|
- **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`
|
- **Keystore**: `docker/biteplan.jks` è in `.gitignore`; da ottenere dall'autore o generare con `keytool`. Password via `BITEPLAN_KEYSTORE_PASS` (mai hardcoded); alias: `biteplan`
|
||||||
- **Firma release**: password passata via `BITEPLAN_KEYSTORE_PASS` (mai hardcoded); alias keystore: `biteplan`
|
|
||||||
- **applicationId**: `com.davide.biteplan` — non cambiare, garantisce aggiornamento diretto dalla v1.2.1
|
- **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
|
- **versionCode**: letto da `pubspec.yaml` (`version: X.Y.Z+BUILD`); BUILD deve essere sempre crescente
|
||||||
|
|
||||||
## Architettura
|
## 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/
|
lib/
|
||||||
├── main.dart
|
├── app.dart # MaterialApp + NavigationBar; in initState chiama UpdateService.checkUpdate() e mostra showUpdateDialog()
|
||||||
├── app.dart # MaterialApp, NavigationBar; initState chiama UpdateService.checkUpdate() e mostra showUpdateDialog() se c'è aggiornamento
|
├── core/constants/app_constants.dart # kDayIds, kMealSlots, kStorageKey*, kAppVersion
|
||||||
├── core/
|
|
||||||
│ ├── constants/app_constants.dart # kDayIds, kMealSlots, kStorageKey*, kAppVersion
|
|
||||||
│ └── theme/app_theme.dart
|
|
||||||
├── shared/
|
├── shared/
|
||||||
│ ├── services/
|
│ ├── services/storage_service.dart # wrapper SharedPreferences (load/save)
|
||||||
│ │ ├── storage_service.dart # wrapper SharedPreferences (load/save)
|
│ ├── services/update_service.dart # checkUpdate() → GitHub API; isNewer(), parseTagName() (@visibleForTesting)
|
||||||
│ │ ├── update_service.dart # checkUpdate() → GitHub API, isNewer(), parseTagName() (@visibleForTesting)
|
│ ├── services/url_launcher_service.dart # MethodChannel 'com.davide.biteplan/launcher' → Intent.ACTION_VIEW
|
||||||
│ │ └── url_launcher_service.dart # MethodChannel 'com.davide.biteplan/launcher' → Intent.ACTION_VIEW
|
│ └── widgets/update_dialog.dart # showUpdateDialog() — "Più tardi" / "Scarica"
|
||||||
│ └── widgets/
|
|
||||||
│ └── update_dialog.dart # showUpdateDialog() — AlertDialog con "Più tardi" / "Scarica"
|
|
||||||
├── features/
|
├── features/
|
||||||
│ ├── meal_planner/
|
│ ├── meal_planner/ # MealPlan/DayPlan, qr_codec.dart (buildQrPayload, parseMealPlanFromQr), pagine planner + scan QR
|
||||||
│ │ ├── models/meal_plan.dart # MealPlan, DayPlan
|
│ ├── converter/ # ConversionEntry con rawToCooked/cookedToRaw, dati da assets/data/conversions.json
|
||||||
│ │ ├── providers/meal_planner_provider.dart
|
│ ├── shopping_list/ # ShoppingItem con quantity per aggregazione duplicati
|
||||||
│ │ ├── qr_codec.dart # buildQrPayload, parseMealPlanFromQr
|
│ └── guide/ # guide_page (3 tab) + info_bottom_sheet
|
||||||
│ │ └── presentation/
|
└── assets/data/conversions.json # 50+ alimenti × metodi cottura
|
||||||
│ │ ├── 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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**State management**: Provider (`ChangeNotifier`).
|
**State management**: Provider (`ChangeNotifier`), uno per feature, registrati in `main.dart` con `MultiProvider`.
|
||||||
**Persistenza**: `shared_preferences`, chiavi `meals` e `shopping_list` (JSON serializzato).
|
**Persistenza**: `shared_preferences`, chiavi `meals` e `shopping_list` (JSON serializzato).
|
||||||
**Conversione**: logica in `ConversionEntry` — `rawToCooked = raw * yieldFactor`, `cookedToRaw = cooked / yieldFactor`.
|
**Conversione**: logica in `ConversionEntry` — `rawToCooked = raw * yieldFactor`, `cookedToRaw = cooked / yieldFactor`.
|
||||||
**UI**: Material 3, seed color `Color(0xFF2d6a4f)`, tutto in italiano.
|
**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).
|
**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`.
|
**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`.
|
|
||||||
|
|
||||||
## Testing
|
## 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`).
|
||||||
|
|
||||||
```
|
- Storage isolato con `SharedPreferences.setMockInitialValues({})`
|
||||||
test/
|
- Widget test: estensione `pumpApp` in `test/helpers/pump_app.dart`
|
||||||
├── helpers/pump_app.dart # estensione pumpApp per widget test
|
- MethodChannel nei test: `TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler` — vedi `test/shared/widgets/update_dialog_test.dart`
|
||||||
├── features/
|
- `test/coverage_helper_test.dart` importa tutta `lib/` così lcov include anche i file a coverage zero — aggiungere i nuovi file lì
|
||||||
│ ├── converter/
|
- **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`
|
||||||
│ │ ├── 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.
|
|
||||||
|
|||||||
@@ -38,29 +38,159 @@ App Android per la gestione della dieta quotidiana — pianificazione pasti, con
|
|||||||
| QR code | qr_flutter + mobile_scanner |
|
| QR code | qr_flutter + mobile_scanner |
|
||||||
| Build APK | Docker (headless) |
|
| 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
|
## Sviluppo
|
||||||
|
|
||||||
Il container dev avvia un web server Flutter accessibile dal browser, con hot reload.
|
Una volta completata l'installazione, il ciclo quotidiano è:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd docker/dev
|
emulator -avd biteplan &
|
||||||
docker compose up
|
# attendi il boot completo (qualche secondo se l'AVD ha già uno snapshot, 1-2 min altrimenti), poi:
|
||||||
# → http://localhost:5173
|
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
|
```bash
|
||||||
docker compose attach dev
|
bash docker/build.sh # debug → dist/biteplan-debug.apk
|
||||||
# r → hot reload | R → hot restart | q → esci
|
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
|
## Test
|
||||||
|
|
||||||
La suite copre unit test e widget test. Richiede l'immagine Docker `biteplan-build`
|
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
|
```bash
|
||||||
# Tutti i test (dalla root del progetto)
|
# Tutti i test (dalla root del progetto)
|
||||||
@@ -98,15 +228,6 @@ test/
|
|||||||
└── update_dialog_test.dart # widget test dialog aggiornamento
|
└── 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
|
## Documentazione
|
||||||
|
|
||||||
- [Guida utente](docs/guida-utente.md)
|
- [Guida utente](docs/guida-utente.md)
|
||||||
|
|||||||
+12
-40
@@ -1,39 +1,12 @@
|
|||||||
# Docker — BitePlan
|
# 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/`.
|
Tutti i comandi vanno eseguiti dalla **root del progetto** (`~/biteplan`), non da `docker/`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Container dev (`docker/dev/`)
|
## Container build (`docker/`)
|
||||||
|
|
||||||
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/`)
|
|
||||||
|
|
||||||
Build headless riproducibile per generare l'APK Android.
|
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.
|
APK firmato con il debug keystore di Android — adatto per installazione diretta sul dispositivo.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bash docker/build/build.sh
|
bash docker/build.sh
|
||||||
# → dist/biteplan-debug.apk
|
# → dist/biteplan-debug.apk
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -83,7 +56,7 @@ aggiungila a `~/.bashrc` o `~/.zshrc`.
|
|||||||
**3. Esegui la build release**
|
**3. Esegui la build release**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bash docker/build/build.sh --release
|
bash docker/build.sh --release
|
||||||
# → dist/biteplan-release.apk
|
# → dist/biteplan-release.apk
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -107,8 +80,7 @@ adb install dist/biteplan-debug.apk
|
|||||||
|
|
||||||
## Test
|
## Test
|
||||||
|
|
||||||
I test non richiedono il container dev — usano l'immagine `biteplan-build`, creata
|
I test usano l'immagine `biteplan-build`, creata automaticamente al primo `bash docker/build.sh`.
|
||||||
automaticamente al primo `bash docker/build/build.sh`.
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Tutti i test (110 test — unit + widget)
|
# Tutti i test (110 test — unit + widget)
|
||||||
@@ -137,17 +109,17 @@ avere Flutter installato sull'host.
|
|||||||
## Flusso completo
|
## Flusso completo
|
||||||
|
|
||||||
```
|
```
|
||||||
# 1. Sviluppo
|
# 1. Sviluppo (host, no Docker — vedi CLAUDE.md)
|
||||||
cd docker/dev && docker compose up
|
emulator -avd biteplan &
|
||||||
→ http://localhost:5173 (hot reload con 'r')
|
flutter run
|
||||||
|
|
||||||
# 2. Test
|
# 2. Test
|
||||||
docker run --rm -v "$(pwd):/workspace" -w /workspace biteplan-build \
|
docker run --rm -v "$(pwd):/workspace" -w /workspace biteplan-build \
|
||||||
bash -c "flutter pub get && flutter test"
|
bash -c "flutter pub get && flutter test"
|
||||||
|
|
||||||
# 3. Build
|
# 3. Build
|
||||||
bash docker/build/build.sh # → dist/biteplan-debug.apk
|
bash docker/build.sh # → dist/biteplan-debug.apk
|
||||||
bash docker/build/build.sh --release # → dist/biteplan-release.apk
|
bash docker/build.sh --release # → dist/biteplan-release.apk
|
||||||
|
|
||||||
# 4. Installazione
|
# 4. Installazione
|
||||||
adb install dist/biteplan-debug.apk
|
adb install dist/biteplan-debug.apk
|
||||||
@@ -157,7 +129,7 @@ adb install dist/biteplan-debug.apk
|
|||||||
|
|
||||||
## Note
|
## Note
|
||||||
|
|
||||||
- App ID: `com.biteplan.biteplan`
|
- App ID: `com.davide.biteplan`
|
||||||
- Android target: API 34
|
- Android target: API 34
|
||||||
- Il keystore non viene mai copiato nell'immagine Docker (volume read-only)
|
- Il keystore non viene mai copiato nell'immagine Docker (volume read-only)
|
||||||
- `dist/` è in `.gitignore`
|
- `dist/` è in `.gitignore`
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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:flutter_test/flutter_test.dart';
|
||||||
import 'package:biteplan/features/converter/providers/converter_provider.dart';
|
import 'package:biteplan/features/converter/providers/converter_provider.dart';
|
||||||
import 'package:biteplan/features/converter/models/conversion_entry.dart';
|
import 'package:biteplan/features/converter/models/conversion_entry.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
group('ConverterProvider', () {
|
group('ConverterProvider', () {
|
||||||
late ConverterProvider provider;
|
late ConverterProvider provider;
|
||||||
|
|
||||||
@@ -187,5 +191,45 @@ void main() {
|
|||||||
expect(notified, true);
|
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']);
|
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 {
|
test('clearAll svuota tutti i giorni', () async {
|
||||||
final provider = MealPlannerProvider();
|
final provider = MealPlannerProvider();
|
||||||
await provider.load();
|
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:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:biteplan/core/constants/app_constants.dart';
|
||||||
import 'package:biteplan/shared/services/update_service.dart';
|
import 'package:biteplan/shared/services/update_service.dart';
|
||||||
|
|
||||||
void main() {
|
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', () {
|
group('UpdateService.parseTagName', () {
|
||||||
test('strips v prefix from tag_name', () {
|
test('strips v prefix from tag_name', () {
|
||||||
expect(UpdateService.parseTagName('{"tag_name":"v2.1.0"}'), '2.1.0');
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user