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/
|
||||
*.iml
|
||||
.metadata
|
||||
coverage/
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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
@@ -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`
|
||||
|
||||
@@ -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: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);
|
||||
}
|
||||
|
||||
@@ -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