refactor: riscrivi app in Flutter (da branch flutter)
Migrazione completa da Vue 3 + Vite a Flutter: - rimossa sorgente Vue 3/Vite/Playwright - app riscritta in Flutter con Material 3, Provider, SharedPreferences - aggiunto supporto QR code per condivisione piano pasti - aggregazione duplicati nella lista della spesa - guida utente con 3 tab (Pasti, Converti, Spesa) - containerizzazione Docker per dev e build APK - suite di 110 test (unit + widget) - ottimizzazioni APK: spazio e allocazioni a runtime Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+19
@@ -2,3 +2,22 @@ node_modules/
|
||||
dist/
|
||||
.DS_Store
|
||||
*.jks
|
||||
|
||||
# Flutter — output e scaffold generati da flutter create / flutter build
|
||||
.dart_tool/
|
||||
.flutter-plugins
|
||||
.flutter-plugins-dependencies
|
||||
/build/
|
||||
*.g.dart
|
||||
pubspec.lock
|
||||
|
||||
# Scaffold generato da flutter create (creato nel container, non versionare)
|
||||
/android/
|
||||
/ios/
|
||||
/linux/
|
||||
/macos/
|
||||
/windows/
|
||||
/web/
|
||||
/.idea/
|
||||
*.iml
|
||||
.metadata
|
||||
|
||||
@@ -4,63 +4,93 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
|
||||
## Project Overview
|
||||
|
||||
BitePlan is a mobile-first Vue 3 PWA for meal planning, raw↔cooked weight conversion, and shopping list management. It targets Android via Capacitor and is designed for a 480px-max viewport. All UI strings and documentation are in **Italian**.
|
||||
BitePlan è un'app Android per meal planning, conversione crudo/cotto e lista della spesa, scritta in Flutter. Tutta la UI e la UX sono in **italiano**. L'obiettivo finale è un APK Android buildabile via Docker.
|
||||
|
||||
## Commands
|
||||
## Comandi
|
||||
|
||||
```bash
|
||||
npm run dev # Dev server at http://localhost:5173
|
||||
npm run build # Production build → dist/
|
||||
npm run preview # Preview built app
|
||||
# Sviluppo (web server con hot reload)
|
||||
cd docker/dev && docker compose up
|
||||
# → http://localhost:5173
|
||||
# Con il container attivo: docker compose attach dev → r = hot reload
|
||||
|
||||
npm test # Vitest unit + integration tests (watch mode)
|
||||
npm run test:coverage # Coverage report (v8 provider)
|
||||
npm run test:e2e # Playwright e2e (requires dev server running)
|
||||
npm run test:e2e:ui # Playwright interactive UI mode
|
||||
# Test (dalla root del progetto — richiede immagine biteplan-build)
|
||||
docker run --rm -v "$(pwd):/workspace" -w /workspace biteplan-build \
|
||||
bash -c "flutter pub get && flutter test"
|
||||
|
||||
# Single test file
|
||||
npx vitest run tests/unit/conversion.test.js
|
||||
# Test singolo file
|
||||
docker run --rm -v "$(pwd):/workspace" -w /workspace biteplan-build \
|
||||
bash -c "flutter test test/features/meal_planner/qr_test.dart"
|
||||
|
||||
# Android APK
|
||||
bash docker/build.sh # Debug APK
|
||||
bash docker/build.sh --release # Signed release APK
|
||||
# Build APK (headless, da host)
|
||||
bash docker/build/build.sh # debug → dist/biteplan-debug.apk
|
||||
bash docker/build/build.sh --release # release → dist/biteplan-release.apk
|
||||
```
|
||||
|
||||
E2E tests simulate iPhone 14 Pro viewport (393×852) with Italian locale.
|
||||
## Architettura
|
||||
|
||||
## Architecture
|
||||
|
||||
**App.vue** is the root router — it conditionally renders three pages based on a `currentPage` ref (`meal`, `convert`, `shop`) and hosts the portrait-lock transform, `InfoPanel`, and `DocsPanel`.
|
||||
|
||||
**Three pages** in `src/pages/`:
|
||||
- `MealPlanner.vue` — 7-day plan (Mon–Sun), 3 meal slots each (colazione/pranzo/cena), per-day accordion via `MealCard.vue`, QR share, and "generate shopping list" export
|
||||
- `Converter.vue` — real-time food search → select food+method → bidirectional raw↔cooked calculation using yield coefficients from `src/data/conversions.json`
|
||||
- `ShoppingList.vue` — checklist with add/remove/check, importable from meal planner
|
||||
|
||||
**State & persistence**: No Pinia/Vuex — pages use Vue composables + direct `localStorage` via the wrappers in `src/utils/storage.js` (`save(key, val)` / `load(key, default)`).
|
||||
|
||||
**Conversion logic** lives entirely in `src/utils/conversion.js`:
|
||||
```js
|
||||
rawToCooked(food, method, rawGrams, db) = rawGrams * db[food][method].yield
|
||||
cookedToRaw(food, method, cookedGrams, db) = cookedGrams / db[food][method].yield
|
||||
```
|
||||
`yield = cooked_weight / raw_weight`. The database in `src/data/conversions.json` has 50+ foods × 1–4 cooking methods with coefficients sourced from CREA, SINU, USDA (see `docs/conversioni.md`).
|
||||
lib/
|
||||
├── main.dart
|
||||
├── app.dart # MaterialApp, NavigationBar, AppBar con bottone info
|
||||
├── core/
|
||||
│ ├── constants/app_constants.dart # kDayIds, kMealSlots, kStorageKey*, kAppVersion
|
||||
│ └── theme/app_theme.dart
|
||||
├── shared/
|
||||
│ ├── services/storage_service.dart # wrapper SharedPreferences (load/save)
|
||||
│ └── widgets/
|
||||
├── features/
|
||||
│ ├── meal_planner/
|
||||
│ │ ├── models/meal_plan.dart # MealPlan, DayPlan
|
||||
│ │ ├── providers/meal_planner_provider.dart
|
||||
│ │ ├── qr_codec.dart # buildQrPayload, parseMealPlanFromQr
|
||||
│ │ └── presentation/
|
||||
│ │ ├── pages/meal_planner_page.dart
|
||||
│ │ ├── pages/qr_scan_page.dart
|
||||
│ │ └── widgets/meal_card.dart, qr_share_sheet.dart
|
||||
│ ├── converter/
|
||||
│ │ ├── models/conversion_entry.dart # rawToCooked, cookedToRaw (metodi sul model)
|
||||
│ │ ├── providers/converter_provider.dart
|
||||
│ │ └── presentation/pages/converter_page.dart
|
||||
│ ├── shopping_list/
|
||||
│ │ ├── models/shopping_item.dart # quantity per aggregazione duplicati
|
||||
│ │ ├── providers/shopping_list_provider.dart
|
||||
│ │ └── presentation/
|
||||
│ │ ├── pages/shopping_list_page.dart
|
||||
│ │ └── widgets/shopping_item_tile.dart
|
||||
│ └── guide/
|
||||
│ └── presentation/
|
||||
│ ├── pages/guide_page.dart # 3 tab: Pasti, Converti, Spesa
|
||||
│ └── widgets/info_bottom_sheet.dart # aperto dal bottone info in AppBar
|
||||
└── assets/data/conversions.json # 50+ alimenti × metodi cottura
|
||||
```
|
||||
|
||||
**CSS**: Vanilla CSS only, no framework. Design tokens are CSS variables in `:root` in `src/style.css` (`--color-primary: #2d6a4f`, `--nav-height: 64px`, etc.). Buttons must be min 44px; layout is single-column.
|
||||
**State management**: Provider (`ChangeNotifier`).
|
||||
**Persistenza**: `shared_preferences`, chiavi `meals` e `shopping_list` (JSON serializzato).
|
||||
**Conversione**: logica in `ConversionEntry` — `rawToCooked = raw * yieldFactor`, `cookedToRaw = cooked / yieldFactor`.
|
||||
**UI**: Material 3, seed color `Color(0xFF2d6a4f)`, tutto in italiano.
|
||||
**QR**: payload JSON `{ "v": 1, "meals": { ... } }`, limite 2953 byte (capacità QR con error correction L).
|
||||
|
||||
## Testing
|
||||
|
||||
- **Unit** (`tests/unit/`): Pure function tests for `conversion.js` and `storage.js`
|
||||
- **Integration** (`tests/integration/`): Vue Test Utils component mounts with localStorage seeding
|
||||
- **E2E** (`tests/e2e/`): Playwright against the running dev server
|
||||
110 test (unit + widget). Non richiedono device fisico.
|
||||
|
||||
`tests/setup.js` clears localStorage before/after each test. Vitest uses `happy-dom` environment.
|
||||
```
|
||||
test/
|
||||
├── helpers/pump_app.dart # estensione pumpApp per widget test
|
||||
└── features/
|
||||
├── converter/
|
||||
│ ├── models/conversion_entry_test.dart
|
||||
│ └── providers/converter_provider_test.dart
|
||||
├── meal_planner/
|
||||
│ ├── models/meal_plan_test.dart
|
||||
│ ├── providers/meal_planner_provider_test.dart
|
||||
│ ├── widgets/meal_card_test.dart
|
||||
│ └── qr_test.dart
|
||||
└── shopping_list/
|
||||
├── models/shopping_item_test.dart
|
||||
├── providers/shopping_list_provider_test.dart
|
||||
└── widgets/shopping_item_tile_test.dart
|
||||
```
|
||||
|
||||
## Android Build
|
||||
|
||||
The Docker pipeline in `docker/` handles the full Android build:
|
||||
1. `npm run build` → `dist/`
|
||||
2. `cap sync` → copies dist into Android project
|
||||
3. Gradle builds the APK; release APK is signed with `docker/biteplan.jks` (gitignored)
|
||||
|
||||
See `docker/README.md` for full APK build instructions.
|
||||
I test usano `SharedPreferences.setMockInitialValues({})` per isolare lo storage.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# BitePlan
|
||||
|
||||
App mobile-first per la gestione della dieta quotidiana — pianificazione pasti, conversione crudo/cotto e lista della spesa.
|
||||
App Android per la gestione della dieta quotidiana — pianificazione pasti, conversione crudo/cotto e lista della spesa.
|
||||
|
||||
## Funzionalità
|
||||
|
||||
@@ -8,15 +8,16 @@ App mobile-first per la gestione della dieta quotidiana — pianificazione pasti
|
||||
- Pianificazione settimanale su 7 giorni × 3 pasti (colazione, pranzo, cena)
|
||||
- Card accordion per giorno, giorno corrente aperto di default
|
||||
- Aggiunta e rimozione di voci per ogni pasto
|
||||
- Generazione automatica della lista della spesa dai pasti pianificati
|
||||
- Persistenza automatica su LocalStorage
|
||||
- Generazione automatica della lista della spesa con aggregazione duplicati (es. "zucchine (x2)")
|
||||
- Condivisione del piano via QR code tra dispositivi
|
||||
- Persistenza automatica su SharedPreferences
|
||||
|
||||
### Convertitore crudo/cotto
|
||||
- Conversione bidirezionale del peso (crudo → cotto e cotto → crudo)
|
||||
- Ricerca alimento in tempo reale
|
||||
- Oltre 50 voci tra cereali, legumi, verdure, carni, pesce e uova
|
||||
- Fino a 4 metodi di cottura per alimento: bollitura, padella, forno, friggitrice ad aria
|
||||
- Coefficienti di resa documentati con fonti (CREA, SINU, Istituto Muzzone, USDA)
|
||||
- Coefficienti di resa documentati con fonti — vedi [docs/conversioni.md](docs/conversioni.md)
|
||||
|
||||
### Lista della spesa
|
||||
- Checklist con aggiunta manuale o importazione dai pasti pianificati
|
||||
@@ -27,36 +28,75 @@ App mobile-first per la gestione della dieta quotidiana — pianificazione pasti
|
||||
|
||||
| Livello | Tecnologia |
|
||||
|---|---|
|
||||
| Frontend | Vue 3 + Vite |
|
||||
| Persistenza | LocalStorage |
|
||||
| UI | CSS mobile-first (max 480px) |
|
||||
| Mobile | Capacitor Android |
|
||||
| Build APK | Docker |
|
||||
| Framework | Flutter 3.x / Dart 3.x |
|
||||
| State management | Provider |
|
||||
| Persistenza | shared_preferences |
|
||||
| QR code | qr_flutter + mobile_scanner |
|
||||
| Build APK | Docker (headless) |
|
||||
|
||||
## Requisiti per lo sviluppo
|
||||
## Sviluppo
|
||||
|
||||
| Strumento | Versione minima | Note |
|
||||
|---|---|---|
|
||||
| Node.js | >= 20.x LTS | testato con v24 |
|
||||
| npm | 9.x | incluso con Node.js |
|
||||
| Git | 2.x | |
|
||||
| Browser | Chrome / Edge / Firefox recente | DevTools modalità mobile consigliati |
|
||||
|
||||
Per la build APK Android sono necessari requisiti aggiuntivi — vedi [docker/README.md](docker/README.md).
|
||||
|
||||
## Avvio in sviluppo
|
||||
Il container dev avvia un web server Flutter accessibile dal browser, con hot reload.
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
cd docker/dev
|
||||
docker compose up
|
||||
# → http://localhost:5173
|
||||
```
|
||||
|
||||
Aprire [http://localhost:5173](http://localhost:5173) in Chrome con DevTools in modalità mobile (viewport 360×640).
|
||||
Da un dispositivo mobile sulla stessa rete, aprire `http://<ip-host>:5173`.
|
||||
Con il container attivo, in un altro terminale:
|
||||
|
||||
## Build APK Android
|
||||
```bash
|
||||
docker compose attach dev
|
||||
# r → hot reload | R → hot restart | q → esci
|
||||
```
|
||||
|
||||
Vedi [docker/README.md](docker/README.md) per i requisiti e i dettagli della pipeline.
|
||||
Vedi [docker/README.md](docker/README.md) per i dettagli e la build APK.
|
||||
|
||||
## Test
|
||||
|
||||
La suite copre unit test e widget test. Richiede l'immagine Docker `biteplan-build`
|
||||
(creata automaticamente al primo `bash docker/build/build.sh`).
|
||||
|
||||
```bash
|
||||
# Tutti i test (dalla root del progetto)
|
||||
docker run --rm -v "$(pwd):/workspace" -w /workspace biteplan-build \
|
||||
bash -c "flutter pub get && flutter test"
|
||||
|
||||
# Un singolo file
|
||||
docker run --rm -v "$(pwd):/workspace" -w /workspace biteplan-build \
|
||||
bash -c "flutter test test/features/meal_planner/qr_test.dart"
|
||||
```
|
||||
|
||||
### Struttura
|
||||
|
||||
```
|
||||
test/
|
||||
├── helpers/
|
||||
│ └── pump_app.dart # estensione pumpApp per widget test
|
||||
└── features/
|
||||
├── converter/
|
||||
│ ├── models/conversion_entry_test.dart
|
||||
│ └── providers/converter_provider_test.dart
|
||||
├── meal_planner/
|
||||
│ ├── models/meal_plan_test.dart
|
||||
│ ├── providers/meal_planner_provider_test.dart
|
||||
│ ├── widgets/meal_card_test.dart
|
||||
│ └── qr_test.dart
|
||||
└── shopping_list/
|
||||
├── models/shopping_item_test.dart
|
||||
├── providers/shopping_list_provider_test.dart
|
||||
└── widgets/shopping_item_tile_test.dart
|
||||
```
|
||||
|
||||
## Build APK
|
||||
|
||||
```bash
|
||||
bash docker/build/build.sh # debug → dist/biteplan-debug.apk
|
||||
bash docker/build/build.sh --release # release → dist/biteplan-release.apk
|
||||
```
|
||||
|
||||
Vedi [docker/README.md](docker/README.md) per i requisiti della firma release.
|
||||
|
||||
## Documentazione
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
include: package:flutter_lints/flutter.yaml
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 5.1 MiB After Width: | Height: | Size: 72 KiB |
@@ -1,112 +0,0 @@
|
||||
# ── Android SDK + Node (ambiente di build) ────────────────────────────────────
|
||||
# eclipse-temurin:21 (Adoptium) su Ubuntu Jammy include Java 21
|
||||
FROM eclipse-temurin:21-jdk-jammy
|
||||
|
||||
# Installa Node.js 20 e dipendenze di sistema
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
curl \
|
||||
wget \
|
||||
unzip \
|
||||
imagemagick \
|
||||
&& curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
|
||||
&& apt-get install -y --no-install-recommends nodejs \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# ── Android SDK ─────────────────────────────────────────────────────────────────
|
||||
ENV ANDROID_HOME=/opt/android-sdk
|
||||
ENV PATH=$PATH:$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/platform-tools
|
||||
|
||||
RUN mkdir -p $ANDROID_HOME/cmdline-tools && \
|
||||
wget -q https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip \
|
||||
-O /tmp/cmdline-tools.zip && \
|
||||
unzip -q /tmp/cmdline-tools.zip -d /tmp && \
|
||||
mv /tmp/cmdline-tools $ANDROID_HOME/cmdline-tools/latest && \
|
||||
rm /tmp/cmdline-tools.zip
|
||||
|
||||
RUN yes | sdkmanager --licenses > /dev/null && \
|
||||
sdkmanager \
|
||||
"platform-tools" \
|
||||
"platforms;android-34" \
|
||||
"build-tools;34.0.0"
|
||||
|
||||
# ── Progetto (solo dipendenze npm, senza sorgenti) ────────────────────────────
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
|
||||
# ── Capacitor config ──────────────────────────────────────────────────────────
|
||||
RUN cat > capacitor.config.json <<'EOF'
|
||||
{
|
||||
"appId": "com.davide.biteplan",
|
||||
"appName": "BitePlan",
|
||||
"webDir": "dist",
|
||||
"server": {
|
||||
"androidScheme": "https"
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
# ── Installa Capacitor e prepara la piattaforma Android ───────────────────────
|
||||
RUN npm install @capacitor/core @capacitor/cli @capacitor/android --save
|
||||
|
||||
RUN npx cap add android
|
||||
|
||||
# ── Permessi Android ──────────────────────────────────────────────────────────
|
||||
RUN sed -i 's|</manifest>| <uses-permission android:name="android.permission.CAMERA" />\n</manifest>|' \
|
||||
android/app/src/main/AndroidManifest.xml
|
||||
|
||||
# ── Orientamento bloccato in portrait ────────────────────────────────────────
|
||||
RUN sed -i 's/android:exported="true"/android:exported="true"\n android:screenOrientation="portrait"/' \
|
||||
android/app/src/main/AndroidManifest.xml
|
||||
|
||||
# ── Versione da package.json → android/app/build.gradle ──────────────────────
|
||||
RUN node -e "\
|
||||
const v = require('./package.json').version; \
|
||||
const [a,b,c] = v.split('.').map(Number); \
|
||||
const vc = a*10000 + b*100 + c; \
|
||||
const fs = require('fs'); \
|
||||
let g = fs.readFileSync('android/app/build.gradle','utf8'); \
|
||||
g = g.replace(/versionCode \d+/, 'versionCode '+vc); \
|
||||
g = g.replace(/versionName \"[^\"]*\"/, 'versionName \"'+v+'\"'); \
|
||||
fs.writeFileSync('android/app/build.gradle', g); \
|
||||
"
|
||||
|
||||
# ── Icone Android da assets/icon-only.png (via ImageMagick) ─────────────────
|
||||
COPY assets/ ./assets/
|
||||
RUN rm -rf android/app/src/main/res/mipmap-anydpi-v26 && \
|
||||
for d in mdpi:48 hdpi:72 xhdpi:96 xxhdpi:144 xxxhdpi:192; do \
|
||||
dest=android/app/src/main/res/mipmap-${d%:*}; \
|
||||
convert assets/icon-only.png -resize ${d#*:}x${d#*:} $dest/ic_launcher.png; \
|
||||
cp $dest/ic_launcher.png $dest/ic_launcher_round.png; \
|
||||
done
|
||||
|
||||
# Fix kotlin-stdlib duplicate class conflict (stdlib 1.8+ already includes jdk7/jdk8)
|
||||
RUN printf '\nsubprojects {\n configurations.all {\n resolutionStrategy {\n force "org.jetbrains.kotlin:kotlin-stdlib:1.8.22"\n force "org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.8.22"\n force "org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.8.22"\n }\n }\n}\n' >> android/build.gradle
|
||||
|
||||
# ── Runtime: dist/ e (opzionale) keystore montati come volumi dall'host ───────
|
||||
# build.sh esegue: docker run -v ./dist:/app/dist ...
|
||||
# Con --release: aggiunge -e BUILD_TYPE=release -v biteplan.jks:/app/biteplan.jks:ro
|
||||
# BUILD_TYPE=release → assembleRelease + zipalign + apksigner
|
||||
# BUILD_TYPE vuoto → assembleDebug (default)
|
||||
|
||||
CMD npx cap sync android && \
|
||||
cd android && chmod +x gradlew && \
|
||||
if [ "$BUILD_TYPE" = "release" ]; then \
|
||||
./gradlew assembleRelease --no-daemon && \
|
||||
$ANDROID_HOME/build-tools/34.0.0/zipalign -v 4 \
|
||||
app/build/outputs/apk/release/app-release-unsigned.apk \
|
||||
/tmp/aligned.apk && \
|
||||
$ANDROID_HOME/build-tools/34.0.0/apksigner sign \
|
||||
--ks /app/biteplan.jks \
|
||||
--ks-key-alias biteplan \
|
||||
--ks-pass pass:$KEYSTORE_PASS \
|
||||
--key-pass pass:$KEY_PASS \
|
||||
--out /app/dist/biteplan.apk \
|
||||
/tmp/aligned.apk && \
|
||||
echo "APK release firmato: /app/dist/biteplan.apk"; \
|
||||
else \
|
||||
./gradlew assembleDebug --no-daemon && \
|
||||
cp app/build/outputs/apk/debug/app-debug.apk /app/dist/biteplan.apk && \
|
||||
echo "APK debug: /app/dist/biteplan.apk"; \
|
||||
fi
|
||||
+96
-64
@@ -1,109 +1,141 @@
|
||||
# Build APK — Docker
|
||||
# Docker — BitePlan
|
||||
|
||||
Genera un APK Android senza installare nulla sull'host oltre a Docker.
|
||||
|
||||
## Requisiti host
|
||||
|
||||
| Requisito | Dettaglio |
|
||||
|-----------|-----------|
|
||||
| **Architettura** | x86_64 (amd64) — ARM/aarch64 non supportato |
|
||||
| **OS** | Linux x86_64 o macOS x86_64 |
|
||||
| **Docker** | Installato e avviato |
|
||||
| **Node.js** | v18+ (per il build Vite locale) |
|
||||
|
||||
> Gli Android build-tools (`aapt2`, `zipalign`, `apksigner`) sono binari nativi x86_64 e non girano su host ARM senza emulazione QEMU.
|
||||
Due container distinti: uno per lo sviluppo con hot reload, uno per la build APK headless.
|
||||
Tutti i comandi vanno eseguiti dalla **root del progetto** (`~/biteplan`), non da `docker/`.
|
||||
|
||||
---
|
||||
|
||||
## Build debug (default)
|
||||
## Container dev (`docker/dev/`)
|
||||
|
||||
APK firmato con il debug keystore di Android. Adatto per test su dispositivo.
|
||||
Avvia un web server Flutter accessibile dal browser, con hot reload.
|
||||
|
||||
```bash
|
||||
bash docker/build.sh
|
||||
cd docker/dev
|
||||
docker compose up
|
||||
```
|
||||
|
||||
Apri **http://localhost:5173** nel browser.
|
||||
|
||||
### Hot reload
|
||||
|
||||
Con il container attivo, in un altro terminale:
|
||||
|
||||
```bash
|
||||
docker compose attach dev
|
||||
# r → hot reload | R → hot restart | q → esci
|
||||
```
|
||||
|
||||
Le modifiche ai file `.dart` sull'host sono visibili subito nel container grazie al volume
|
||||
montato — basta premere `r` per ricaricare.
|
||||
|
||||
> Prima esecuzione: scarica l'immagine Flutter (~500 MB), richiede 5-10 minuti.
|
||||
> Le successive usano la cache Docker e sono molto più rapide.
|
||||
|
||||
---
|
||||
|
||||
## Build release (distribuzione)
|
||||
## Container build (`docker/build/`)
|
||||
|
||||
APK firmato con il tuo keystore personale. Necessario per distribuire l'app.
|
||||
Build headless riproducibile per generare l'APK Android.
|
||||
|
||||
### 1. Genera il keystore (una volta sola)
|
||||
### Build debug
|
||||
|
||||
APK firmato con il debug keystore di Android — adatto per installazione diretta sul dispositivo.
|
||||
|
||||
```bash
|
||||
bash docker/build/build.sh
|
||||
# → dist/biteplan-debug.apk
|
||||
```
|
||||
|
||||
### Build release
|
||||
|
||||
APK firmato con keystore personale — necessario per la distribuzione.
|
||||
|
||||
**1. Genera il keystore (una sola volta)**
|
||||
|
||||
```bash
|
||||
keytool -genkey -v \
|
||||
-keystore docker/biteplan.jks \
|
||||
-alias biteplan \
|
||||
-keyalg RSA \
|
||||
-keysize 2048 \
|
||||
-validity 10000
|
||||
-keyalg RSA -keysize 2048 -validity 10000
|
||||
```
|
||||
|
||||
> Il file `docker/biteplan.jks` è già in `.gitignore` — non verrà mai committato.
|
||||
> Conservalo in un posto sicuro: senza di esso non puoi aggiornare l'app.
|
||||
> `docker/biteplan.jks` è in `.gitignore` e non verrà mai committato.
|
||||
> Conservalo in un posto sicuro: senza di esso non puoi pubblicare aggiornamenti.
|
||||
|
||||
### 2. Esegui la build release
|
||||
**2. Esegui la build release**
|
||||
|
||||
```bash
|
||||
bash docker/build.sh --release
|
||||
# chiede interattivamente: Password keystore / Password chiave
|
||||
bash docker/build/build.sh --release
|
||||
# → dist/biteplan-release.apk
|
||||
```
|
||||
|
||||
Oppure passando le password come variabili d'ambiente (utile in CI):
|
||||
Il keystore viene montato nel container come volume read-only e non viene mai copiato
|
||||
nell'immagine Docker.
|
||||
|
||||
> Prima esecuzione: scarica Flutter SDK + Android SDK (~1-2 GB), richiede 10-15 minuti.
|
||||
|
||||
### Installazione sul dispositivo
|
||||
|
||||
```bash
|
||||
KEYSTORE_PASS=tuapassword KEY_PASS=tuapassword bash docker/build.sh --release
|
||||
```
|
||||
|
||||
Per usare un keystore in un percorso diverso da `docker/biteplan.jks`:
|
||||
|
||||
```bash
|
||||
KEYSTORE_PATH=/percorso/biteplan.jks bash docker/build.sh --release
|
||||
```
|
||||
|
||||
### 3. Verifica la firma
|
||||
|
||||
```bash
|
||||
$ANDROID_HOME/build-tools/34.0.0/apksigner verify --verbose dist/biteplan.apk
|
||||
adb install dist/biteplan-debug.apk
|
||||
# oppure copia manualmente dist/biteplan-release.apk via USB o Drive
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Flag combinabili
|
||||
## Test
|
||||
|
||||
| Comando | Risultato |
|
||||
|---------|-----------|
|
||||
| `bash docker/build.sh` | APK debug dalla working directory |
|
||||
| `bash docker/build.sh --head` | APK debug dall'ultimo commit git |
|
||||
| `bash docker/build.sh --release` | APK release firmato dalla working directory |
|
||||
| `bash docker/build.sh --head --release` | APK release firmato dall'ultimo commit git |
|
||||
I test non richiedono il container dev — usano l'immagine `biteplan-build`, creata
|
||||
automaticamente al primo `bash docker/build/build.sh`.
|
||||
|
||||
```bash
|
||||
# Tutti i test (110 test — unit + widget)
|
||||
docker run --rm -v "$(pwd):/workspace" -w /workspace biteplan-build \
|
||||
bash -c "flutter pub get && flutter test"
|
||||
|
||||
# Un singolo file
|
||||
docker run --rm -v "$(pwd):/workspace" -w /workspace biteplan-build \
|
||||
bash -c "flutter test test/features/meal_planner/qr_test.dart"
|
||||
|
||||
# Una singola feature
|
||||
docker run --rm -v "$(pwd):/workspace" -w /workspace biteplan-build \
|
||||
bash -c "flutter test test/features/shopping_list/"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Prima build
|
||||
## Prima configurazione (android/ assente)
|
||||
|
||||
La prima esecuzione scarica Android SDK (~1 GB) e può richiedere **10-15 minuti**.
|
||||
Le build successive usano la cache Docker e sono molto più rapide.
|
||||
Se la cartella `android/` non esiste ancora, lo script `build.sh` la genera
|
||||
automaticamente tramite `flutter create` all'interno del container — non è necessario
|
||||
avere Flutter installato sull'host.
|
||||
|
||||
## Installazione su dispositivo
|
||||
---
|
||||
|
||||
```bash
|
||||
adb install dist/biteplan.apk
|
||||
```
|
||||
|
||||
## Pipeline
|
||||
## Flusso completo
|
||||
|
||||
```
|
||||
[host] npm run build → dist/
|
||||
[docker] cap sync → copia dist/ in android/assets/
|
||||
[docker] gradlew assembleDebug/Release
|
||||
[docker] zipalign + apksigner → solo in modalità release
|
||||
[host] dist/biteplan.apk
|
||||
# 1. Sviluppo
|
||||
cd docker/dev && docker compose up
|
||||
→ http://localhost:5173 (hot reload con 'r')
|
||||
|
||||
# 2. Test
|
||||
docker run --rm -v "$(pwd):/workspace" -w /workspace biteplan-build \
|
||||
bash -c "flutter pub get && flutter test"
|
||||
|
||||
# 3. Build
|
||||
bash docker/build/build.sh # → dist/biteplan-debug.apk
|
||||
bash docker/build/build.sh --release # → dist/biteplan-release.apk
|
||||
|
||||
# 4. Installazione
|
||||
adb install dist/biteplan-debug.apk
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Note
|
||||
|
||||
- App ID: `com.davide.biteplan`
|
||||
- App ID: `com.biteplan.biteplan`
|
||||
- Android target: API 34
|
||||
- Il keystore non viene mai copiato nell'immagine Docker (montato come volume read-only)
|
||||
- Il keystore non viene mai copiato nell'immagine Docker (volume read-only)
|
||||
- `dist/` è in `.gitignore`
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
|
||||
DIST_DIR="$PROJECT_ROOT/dist"
|
||||
|
||||
FROM_HEAD=false
|
||||
RELEASE=false
|
||||
for arg in "$@"; do
|
||||
[[ "$arg" == "--head" ]] && FROM_HEAD=true
|
||||
[[ "$arg" == "--release" ]] && RELEASE=true
|
||||
done
|
||||
|
||||
# ── Keystore (solo in modalità release) ───────────────────────────────────────
|
||||
KEYSTORE_PATH="${KEYSTORE_PATH:-$SCRIPT_DIR/biteplan.jks}"
|
||||
|
||||
if $RELEASE; then
|
||||
if [[ ! -f "$KEYSTORE_PATH" ]]; then
|
||||
echo "Errore: keystore non trovato in $KEYSTORE_PATH"
|
||||
echo "Generalo con:"
|
||||
echo " keytool -genkey -v -keystore docker/biteplan.jks -alias biteplan -keyalg RSA -keysize 2048 -validity 10000"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -z "${KEYSTORE_PASS:-}" ]]; then
|
||||
read -rsp "Password keystore: " KEYSTORE_PASS; echo
|
||||
fi
|
||||
if [[ -z "${KEY_PASS:-}" ]]; then
|
||||
read -rsp "Password chiave: " KEY_PASS; echo
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── Build Vite ────────────────────────────────────────────────────────────────
|
||||
if $FROM_HEAD; then
|
||||
COMMIT_SHA=$(git -C "$PROJECT_ROOT" rev-parse --short HEAD)
|
||||
echo "==> Build Vite da HEAD ($COMMIT_SHA)..."
|
||||
TMPDIR=$(mktemp -d)
|
||||
trap 'rm -rf "$TMPDIR"' EXIT
|
||||
git -C "$PROJECT_ROOT" archive HEAD | tar -x -C "$TMPDIR"
|
||||
cd "$TMPDIR"
|
||||
npm ci --silent
|
||||
npm run build --silent
|
||||
DIST_DIR="$TMPDIR/dist"
|
||||
cd "$PROJECT_ROOT"
|
||||
else
|
||||
echo "==> Build Vite dalla working directory..."
|
||||
cd "$PROJECT_ROOT"
|
||||
npm ci --silent
|
||||
npm run build --silent
|
||||
fi
|
||||
|
||||
# ── Build immagine Docker ─────────────────────────────────────────────────────
|
||||
echo "==> Build immagine Docker..."
|
||||
docker build \
|
||||
-f "$SCRIPT_DIR/Dockerfile" \
|
||||
-t biteplan-builder \
|
||||
"$PROJECT_ROOT"
|
||||
|
||||
# ── Generazione APK ───────────────────────────────────────────────────────────
|
||||
echo "==> Generazione APK${RELEASE:+ release}..."
|
||||
mkdir -p "$PROJECT_ROOT/dist"
|
||||
|
||||
DOCKER_ARGS=(
|
||||
--rm
|
||||
-v "$DIST_DIR:/app/dist"
|
||||
)
|
||||
|
||||
if $RELEASE; then
|
||||
DOCKER_ARGS+=(
|
||||
-e BUILD_TYPE=release
|
||||
-e KEYSTORE_PASS="$KEYSTORE_PASS"
|
||||
-e KEY_PASS="$KEY_PASS"
|
||||
-v "$KEYSTORE_PATH:/app/biteplan.jks:ro"
|
||||
)
|
||||
fi
|
||||
|
||||
docker run "${DOCKER_ARGS[@]}" biteplan-builder
|
||||
|
||||
# Con --head l'APK è nella dir temporanea — copiarlo in dist/ del progetto
|
||||
if $FROM_HEAD; then
|
||||
mkdir -p "$PROJECT_ROOT/dist"
|
||||
cp "$DIST_DIR/biteplan.apk" "$PROJECT_ROOT/dist/biteplan.apk"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "APK pronto in: $PROJECT_ROOT/dist/biteplan.apk"
|
||||
@@ -0,0 +1,35 @@
|
||||
FROM ubuntu:22.04
|
||||
|
||||
ARG ANDROID_SDK_BUILD=11076708
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive \
|
||||
FLUTTER_HOME=/opt/flutter \
|
||||
ANDROID_SDK_ROOT=/opt/android-sdk \
|
||||
JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64 \
|
||||
PATH="/opt/flutter/bin:/opt/android-sdk/cmdline-tools/latest/bin:/opt/android-sdk/platform-tools:${PATH}"
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
curl wget git unzip xz-utils ca-certificates \
|
||||
openjdk-17-jdk \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN git clone --depth 1 --branch stable \
|
||||
https://github.com/flutter/flutter.git ${FLUTTER_HOME} && \
|
||||
flutter config --no-analytics && \
|
||||
flutter precache --android
|
||||
|
||||
RUN mkdir -p ${ANDROID_SDK_ROOT}/cmdline-tools && \
|
||||
wget -q "https://dl.google.com/android/repository/commandlinetools-linux-${ANDROID_SDK_BUILD}_latest.zip" \
|
||||
-O /tmp/cmdtools.zip && \
|
||||
unzip -q /tmp/cmdtools.zip -d ${ANDROID_SDK_ROOT}/cmdline-tools && \
|
||||
mv ${ANDROID_SDK_ROOT}/cmdline-tools/cmdline-tools \
|
||||
${ANDROID_SDK_ROOT}/cmdline-tools/latest && \
|
||||
rm /tmp/cmdtools.zip
|
||||
|
||||
RUN yes | sdkmanager --licenses && \
|
||||
sdkmanager \
|
||||
"platform-tools" \
|
||||
"platforms;android-34" \
|
||||
"build-tools;34.0.0"
|
||||
|
||||
WORKDIR /workspace
|
||||
Executable
+83
@@ -0,0 +1,83 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)"
|
||||
RELEASE=false
|
||||
|
||||
for arg in "$@"; do
|
||||
[[ "$arg" == "--release" ]] && RELEASE=true
|
||||
done
|
||||
|
||||
echo "→ Build immagine biteplan-build..."
|
||||
docker build -t biteplan-build "${SCRIPT_DIR}"
|
||||
|
||||
if [[ ! -d "${PROJECT_ROOT}/android" ]]; then
|
||||
echo "→ Cartella android/ non trovata, eseguo flutter create..."
|
||||
docker run --rm \
|
||||
-v "${PROJECT_ROOT}:/workspace" \
|
||||
biteplan-build \
|
||||
bash -c "
|
||||
cd /workspace &&
|
||||
flutter create --project-name biteplan --org com.biteplan --platforms=android . &&
|
||||
MANIFEST=android/app/src/main/AndroidManifest.xml &&
|
||||
grep -q 'CAMERA' \"\$MANIFEST\" ||
|
||||
sed -i 's|<application|<uses-permission android:name=\"android.permission.CAMERA\" />\n <application|' \"\$MANIFEST\"
|
||||
"
|
||||
fi
|
||||
|
||||
mkdir -p "${PROJECT_ROOT}/dist"
|
||||
|
||||
if [[ "$RELEASE" == "true" ]]; then
|
||||
KEYSTORE="${SCRIPT_DIR}/../biteplan.jks"
|
||||
if [[ ! -f "$KEYSTORE" ]]; then
|
||||
echo "ERRORE: keystore non trovato in docker/biteplan.jks"
|
||||
echo "Generalo con:"
|
||||
echo " keytool -genkey -v -keystore docker/biteplan.jks -alias biteplan -keyalg RSA -keysize 2048 -validity 10000"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "→ Building release APK..."
|
||||
docker run --rm \
|
||||
-v "${PROJECT_ROOT}:/workspace" \
|
||||
-v "${HOME}/.gradle:/root/.gradle" \
|
||||
biteplan-build \
|
||||
bash -c "cd /workspace && flutter pub get && flutter pub run flutter_launcher_icons && flutter build apk --release"
|
||||
|
||||
echo "→ Zipalign..."
|
||||
docker run --rm \
|
||||
-v "${PROJECT_ROOT}:/workspace" \
|
||||
biteplan-build \
|
||||
bash -c "zipalign -v -p 4 \
|
||||
/workspace/build/app/outputs/flutter-apk/app-release.apk \
|
||||
/workspace/dist/biteplan-aligned.apk"
|
||||
|
||||
echo "→ Firma APK..."
|
||||
docker run --rm \
|
||||
-v "${PROJECT_ROOT}:/workspace" \
|
||||
-v "$(realpath "${KEYSTORE%/*}"):/keystore:ro" \
|
||||
biteplan-build \
|
||||
bash -c "apksigner sign \
|
||||
--ks /keystore/biteplan.jks \
|
||||
--out /workspace/dist/biteplan-release.apk \
|
||||
/workspace/dist/biteplan-aligned.apk && \
|
||||
rm -f /workspace/dist/biteplan-aligned.apk && \
|
||||
apksigner verify --verbose /workspace/dist/biteplan-release.apk"
|
||||
|
||||
echo ""
|
||||
echo "✓ Release APK: dist/biteplan-release.apk"
|
||||
|
||||
else
|
||||
echo "→ Building debug APK..."
|
||||
docker run --rm \
|
||||
-v "${PROJECT_ROOT}:/workspace" \
|
||||
-v "${HOME}/.gradle:/root/.gradle" \
|
||||
biteplan-build \
|
||||
bash -c "cd /workspace && flutter pub get && flutter pub run flutter_launcher_icons && flutter build apk --debug"
|
||||
|
||||
cp "${PROJECT_ROOT}/build/app/outputs/flutter-apk/app-debug.apk" \
|
||||
"${PROJECT_ROOT}/dist/biteplan-debug.apk"
|
||||
|
||||
echo ""
|
||||
echo "✓ Debug APK: dist/biteplan-debug.apk"
|
||||
fi
|
||||
@@ -0,0 +1,16 @@
|
||||
FROM ubuntu:22.04
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive \
|
||||
FLUTTER_HOME=/opt/flutter \
|
||||
PATH="/opt/flutter/bin:${PATH}"
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
curl git unzip xz-utils ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN git clone --depth 1 --branch stable \
|
||||
https://github.com/flutter/flutter.git /opt/flutter && \
|
||||
flutter config --no-analytics --enable-web && \
|
||||
flutter precache --web
|
||||
|
||||
WORKDIR /workspace
|
||||
@@ -0,0 +1,14 @@
|
||||
services:
|
||||
dev:
|
||||
build: .
|
||||
ports:
|
||||
- "5173:5173"
|
||||
volumes:
|
||||
- ../../:/workspace
|
||||
- pub-cache:/root/.pub-cache
|
||||
command: bash /workspace/docker/dev/entrypoint.sh
|
||||
stdin_open: true
|
||||
tty: true
|
||||
|
||||
volumes:
|
||||
pub-cache:
|
||||
Executable
+18
@@ -0,0 +1,18 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# Aggiunge supporto web se mancante (crea solo web/, non tocca lib/)
|
||||
if [[ ! -d /workspace/web ]]; then
|
||||
echo "→ Inizializzazione supporto web..."
|
||||
flutter create . --platforms=web
|
||||
fi
|
||||
|
||||
# Aggiunge permesso CAMERA se assente (richiesto da mobile_scanner)
|
||||
MANIFEST=/workspace/android/app/src/main/AndroidManifest.xml
|
||||
if [[ -f "$MANIFEST" ]] && ! grep -q "CAMERA" "$MANIFEST"; then
|
||||
echo "→ Aggiunta permesso CAMERA in AndroidManifest.xml..."
|
||||
sed -i 's|<application|<uses-permission android:name="android.permission.CAMERA" />\n <application|' "$MANIFEST"
|
||||
fi
|
||||
|
||||
flutter pub get
|
||||
exec flutter run -d web-server --web-hostname=0.0.0.0 --web-port=5173
|
||||
-13
@@ -1,13 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="it">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
<link rel="icon" type="image/png" href="/favicon.png" />
|
||||
<title>BitePlan</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,225 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:biteplan/app.dart';
|
||||
import 'package:biteplan/features/meal_planner/providers/meal_planner_provider.dart';
|
||||
import 'package:biteplan/features/converter/providers/converter_provider.dart';
|
||||
import 'package:biteplan/features/shopping_list/providers/shopping_list_provider.dart';
|
||||
|
||||
void main() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
setUp(() async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
});
|
||||
|
||||
Widget buildApp() => MultiProvider(
|
||||
providers: [
|
||||
ChangeNotifierProvider(
|
||||
create: (_) => MealPlannerProvider()..load()),
|
||||
ChangeNotifierProvider(
|
||||
create: (_) => ConverterProvider()..loadDb()),
|
||||
ChangeNotifierProvider(
|
||||
create: (_) => ShoppingListProvider()..load()),
|
||||
],
|
||||
child: const BitePlanApp(),
|
||||
);
|
||||
|
||||
// ── Navigazione ────────────────────────────────────────────────────────────
|
||||
|
||||
group('Navigazione', () {
|
||||
testWidgets('il tab iniziale è Piano Pasti', (tester) async {
|
||||
await tester.pumpWidget(buildApp());
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Piano Pasti'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('passa al tab Convertitore', (tester) async {
|
||||
await tester.pumpWidget(buildApp());
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Converti'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Convertitore'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('passa al tab Lista della spesa', (tester) async {
|
||||
await tester.pumpWidget(buildApp());
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Spesa'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Lista della spesa'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('naviga tra tutti e tre i tab', (tester) async {
|
||||
await tester.pumpWidget(buildApp());
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text('Converti'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Convertitore'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('Spesa'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Lista della spesa'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('Pasti'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Piano Pasti'), findsOneWidget);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Lista della spesa ──────────────────────────────────────────────────────
|
||||
|
||||
group('Lista della spesa', () {
|
||||
testWidgets('aggiunge un elemento e lo mostra', (tester) async {
|
||||
await tester.pumpWidget(buildApp());
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Spesa'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.enterText(find.byType(TextField), 'Pollo');
|
||||
await tester.testTextInput.receiveAction(TextInputAction.done);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Pollo'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('rimuove un elemento con il pulsante chiudi', (tester) async {
|
||||
await tester.pumpWidget(buildApp());
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Spesa'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.enterText(find.byType(TextField), 'Riso');
|
||||
await tester.testTextInput.receiveAction(TextInputAction.done);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byIcon(Icons.close).first);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Riso'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('spuntare un elemento lo sposta in Completati', (tester) async {
|
||||
await tester.pumpWidget(buildApp());
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Spesa'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.enterText(find.byType(TextField), 'Pasta');
|
||||
await tester.testTextInput.receiveAction(TextInputAction.done);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byType(Checkbox).first);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('COMPLETATI (1)'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('aggiorna il contatore completati/totale', (tester) async {
|
||||
await tester.pumpWidget(buildApp());
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Spesa'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.enterText(find.byType(TextField), 'Pollo');
|
||||
await tester.testTextInput.receiveAction(TextInputAction.done);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.enterText(find.byType(TextField), 'Riso');
|
||||
await tester.testTextInput.receiveAction(TextInputAction.done);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byType(Checkbox).first);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('1 / 2 completato'), findsOneWidget);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Convertitore ──────────────────────────────────────────────────────────
|
||||
|
||||
group('Convertitore', () {
|
||||
testWidgets('mostra hint iniziale', (tester) async {
|
||||
await tester.pumpWidget(buildApp());
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Converti'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Cerca un alimento per iniziare'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('la ricerca mostra risultati pertinenti', (tester) async {
|
||||
await tester.pumpWidget(buildApp());
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Converti'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.enterText(find.byType(TextField), 'pollo');
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.textContaining('Pollo'), findsWidgets);
|
||||
});
|
||||
|
||||
testWidgets('selezionare un alimento mostra la card del convertitore',
|
||||
(tester) async {
|
||||
await tester.pumpWidget(buildApp());
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Converti'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.enterText(find.byType(TextField), 'riso basmati');
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.textContaining('Riso basmati').first);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('CRUDO'), findsOneWidget);
|
||||
expect(find.text('COTTO'), findsOneWidget);
|
||||
expect(find.text('Cambia'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('il pulsante Cambia resetta la selezione', (tester) async {
|
||||
await tester.pumpWidget(buildApp());
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Converti'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.enterText(find.byType(TextField), 'riso basmati');
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.textContaining('Riso basmati').first);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text('Cambia'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Cerca un alimento per iniziare'), findsOneWidget);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Meal Planner ───────────────────────────────────────────────────────────
|
||||
|
||||
group('Piano Pasti', () {
|
||||
testWidgets('genera lista della spesa e naviga al tab Spesa',
|
||||
(tester) async {
|
||||
await tester.pumpWidget(buildApp());
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Trova il giorno corrente espanso e aggiungi un elemento
|
||||
final addButtons = find.byIcon(Icons.add);
|
||||
if (addButtons.evaluate().isNotEmpty) {
|
||||
final firstTextField = find.byType(TextField).first;
|
||||
await tester.enterText(firstTextField, 'Broccoli');
|
||||
await tester.testTextInput.receiveAction(TextInputAction.done);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
await tester.tap(find.text('Genera lista della spesa'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Deve essere atterrato sul tab Spesa
|
||||
expect(find.text('Lista della spesa'), findsOneWidget);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'core/theme/app_theme.dart';
|
||||
import 'features/meal_planner/presentation/pages/meal_planner_page.dart';
|
||||
import 'features/converter/presentation/pages/converter_page.dart';
|
||||
import 'features/shopping_list/presentation/pages/shopping_list_page.dart';
|
||||
import 'features/guide/presentation/widgets/info_bottom_sheet.dart';
|
||||
|
||||
class BitePlanApp extends StatelessWidget {
|
||||
const BitePlanApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
title: 'BitePlan',
|
||||
theme: AppTheme.light(),
|
||||
debugShowCheckedModeBanner: false,
|
||||
home: const _MainScaffold(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MainScaffold extends StatefulWidget {
|
||||
const _MainScaffold();
|
||||
|
||||
@override
|
||||
State<_MainScaffold> createState() => _MainScaffoldState();
|
||||
}
|
||||
|
||||
class _MainScaffoldState extends State<_MainScaffold> {
|
||||
int _currentIndex = 0;
|
||||
late final List<Widget> _pages;
|
||||
|
||||
static const _titles = ['Piano Pasti', 'Convertitore', 'Lista della spesa'];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_pages = [
|
||||
MealPlannerPage(
|
||||
onGoShopping: () => setState(() => _currentIndex = 2),
|
||||
),
|
||||
const ConverterPage(),
|
||||
const ShoppingListPage(),
|
||||
];
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(_titles[_currentIndex]),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.info_outline),
|
||||
tooltip: 'Informazioni',
|
||||
onPressed: () => showInfoBottomSheet(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: IndexedStack(
|
||||
index: _currentIndex,
|
||||
children: _pages,
|
||||
),
|
||||
bottomNavigationBar: NavigationBar(
|
||||
selectedIndex: _currentIndex,
|
||||
onDestinationSelected: (i) => setState(() => _currentIndex = i),
|
||||
destinations: const [
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.restaurant_menu_outlined),
|
||||
selectedIcon: Icon(Icons.restaurant_menu),
|
||||
label: 'Pasti',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.swap_horiz_outlined),
|
||||
selectedIcon: Icon(Icons.swap_horiz),
|
||||
label: 'Converti',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.shopping_cart_outlined),
|
||||
selectedIcon: Icon(Icons.shopping_cart),
|
||||
label: 'Spesa',
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
const String kAppVersion = '2.0.0';
|
||||
const String kAppAuthor = 'Davide Grilli';
|
||||
const String kAppLicense = 'EUPL v1.2';
|
||||
|
||||
const List<String> kDayIds = [
|
||||
'lunedi',
|
||||
'martedi',
|
||||
'mercoledi',
|
||||
'giovedi',
|
||||
'venerdi',
|
||||
'sabato',
|
||||
'domenica',
|
||||
];
|
||||
|
||||
const Map<String, String> kDayLabels = {
|
||||
'lunedi': 'Lunedì',
|
||||
'martedi': 'Martedì',
|
||||
'mercoledi': 'Mercoledì',
|
||||
'giovedi': 'Giovedì',
|
||||
'venerdi': 'Venerdì',
|
||||
'sabato': 'Sabato',
|
||||
'domenica': 'Domenica',
|
||||
};
|
||||
|
||||
const List<String> kMealSlots = ['colazione', 'pranzo', 'cena'];
|
||||
|
||||
const Map<String, String> kMealSlotLabels = {
|
||||
'colazione': 'Colazione',
|
||||
'pranzo': 'Pranzo',
|
||||
'cena': 'Cena',
|
||||
};
|
||||
|
||||
const String kStorageKeyMeals = 'meals';
|
||||
const String kStorageKeyShopping = 'shopping_list';
|
||||
@@ -0,0 +1,24 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class AppTheme {
|
||||
static const _seed = Color(0xFF2d6a4f);
|
||||
|
||||
static ThemeData light() {
|
||||
final scheme = ColorScheme.fromSeed(seedColor: _seed);
|
||||
return ThemeData(
|
||||
useMaterial3: true,
|
||||
colorScheme: scheme,
|
||||
cardTheme: CardThemeData(
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
side: BorderSide(color: scheme.outlineVariant),
|
||||
),
|
||||
),
|
||||
inputDecorationTheme: InputDecorationTheme(
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(10)),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
class ConversionEntry {
|
||||
final String food;
|
||||
final String method;
|
||||
final double yieldFactor;
|
||||
|
||||
const ConversionEntry({
|
||||
required this.food,
|
||||
required this.method,
|
||||
required this.yieldFactor,
|
||||
});
|
||||
|
||||
double rawToCooked(double raw) => raw * yieldFactor;
|
||||
double cookedToRaw(double cooked) => cooked / yieldFactor;
|
||||
}
|
||||
@@ -0,0 +1,381 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../models/conversion_entry.dart';
|
||||
import '../../providers/converter_provider.dart';
|
||||
import '../../../../shared/widgets/empty_state.dart';
|
||||
|
||||
class ConverterPage extends StatefulWidget {
|
||||
const ConverterPage({super.key});
|
||||
|
||||
@override
|
||||
State<ConverterPage> createState() => _ConverterPageState();
|
||||
}
|
||||
|
||||
class _ConverterPageState extends State<ConverterPage> {
|
||||
final _searchController = TextEditingController();
|
||||
final _gramsController = TextEditingController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchController.dispose();
|
||||
_gramsController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onSelect(ConversionEntry entry) {
|
||||
_searchController.clear();
|
||||
_gramsController.clear();
|
||||
context.read<ConverterProvider>().select(entry);
|
||||
}
|
||||
|
||||
void _onReset() {
|
||||
_gramsController.clear();
|
||||
context.read<ConverterProvider>().reset();
|
||||
}
|
||||
|
||||
void _onSwap() {
|
||||
_gramsController.clear();
|
||||
context.read<ConverterProvider>().swapDirection();
|
||||
}
|
||||
|
||||
String _cap(String s) =>
|
||||
s.isEmpty ? s : s[0].toUpperCase() + s.substring(1);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final provider = context.watch<ConverterProvider>();
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 56, 16, 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Convertitore',
|
||||
style: theme.textTheme.headlineSmall
|
||||
?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
Text(
|
||||
'Calcola il peso cotto dal crudo e viceversa',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
|
||||
children: [
|
||||
if (provider.selected == null) ...[
|
||||
TextField(
|
||||
controller: _searchController,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Cerca alimento…',
|
||||
prefixIcon: const Icon(Icons.search),
|
||||
suffixIcon: _searchController.text.isNotEmpty
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.clear),
|
||||
onPressed: () {
|
||||
_searchController.clear();
|
||||
context.read<ConverterProvider>().search('');
|
||||
},
|
||||
)
|
||||
: null,
|
||||
),
|
||||
onChanged: (v) {
|
||||
context.read<ConverterProvider>().search(v);
|
||||
},
|
||||
),
|
||||
if (provider.results.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
Card(
|
||||
margin: EdgeInsets.zero,
|
||||
child: Column(
|
||||
children: [
|
||||
for (int i = 0; i < provider.results.length; i++) ...[
|
||||
if (i > 0) const Divider(height: 1),
|
||||
ListTile(
|
||||
title: Text(
|
||||
_cap(provider.results[i].food),
|
||||
style: const TextStyle(fontWeight: FontWeight.w600),
|
||||
),
|
||||
trailing: Text(
|
||||
_cap(provider.results[i].method),
|
||||
style: TextStyle(
|
||||
color: theme.colorScheme.onSurface
|
||||
.withValues(alpha: 0.55),
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
onTap: () => _onSelect(provider.results[i]),
|
||||
dense: true,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
if (provider.results.isEmpty &&
|
||||
_searchController.text.isEmpty)
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(top: 48),
|
||||
child: EmptyState(
|
||||
icon: Icons.search,
|
||||
title: 'Cerca un alimento per iniziare',
|
||||
subtitle: 'es. pollo, riso, zucchine',
|
||||
),
|
||||
),
|
||||
if (provider.results.isEmpty &&
|
||||
_searchController.text.isNotEmpty)
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(top: 48),
|
||||
child: EmptyState(
|
||||
icon: Icons.search_off,
|
||||
title: 'Nessun risultato',
|
||||
),
|
||||
),
|
||||
],
|
||||
if (provider.selected != null)
|
||||
_ConverterCard(
|
||||
provider: provider,
|
||||
gramsController: _gramsController,
|
||||
onReset: _onReset,
|
||||
onSwap: _onSwap,
|
||||
onGramsChanged: (v) => context
|
||||
.read<ConverterProvider>()
|
||||
.setGrams(double.tryParse(v)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ConverterCard extends StatelessWidget {
|
||||
final ConverterProvider provider;
|
||||
final TextEditingController gramsController;
|
||||
final VoidCallback onReset;
|
||||
final VoidCallback onSwap;
|
||||
final void Function(String) onGramsChanged;
|
||||
|
||||
const _ConverterCard({
|
||||
required this.provider,
|
||||
required this.gramsController,
|
||||
required this.onReset,
|
||||
required this.onSwap,
|
||||
required this.onGramsChanged,
|
||||
});
|
||||
|
||||
String _cap(String s) =>
|
||||
s.isEmpty ? s : s[0].toUpperCase() + s.substring(1);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final selected = provider.selected!;
|
||||
final result = provider.result;
|
||||
final isRtC = provider.rawToCooked;
|
||||
final muted = theme.colorScheme.onSurface.withValues(alpha: 0.5);
|
||||
|
||||
return Card(
|
||||
margin: EdgeInsets.zero,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Header row
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 4, 4, 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Wrap(
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
spacing: 4,
|
||||
children: [
|
||||
Text(
|
||||
_cap(selected.food),
|
||||
style: theme.textTheme.titleSmall
|
||||
?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
Text('·',
|
||||
style: TextStyle(
|
||||
color: theme.colorScheme.outline, fontSize: 16)),
|
||||
Text(
|
||||
_cap(selected.method),
|
||||
style: theme.textTheme.bodyMedium?.copyWith(color: muted),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
TextButton(onPressed: onReset, child: const Text('Cambia')),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
|
||||
// Calculator
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 16),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
// Input
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
isRtC ? 'CRUDO' : 'COTTO',
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.8,
|
||||
color: muted,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.baseline,
|
||||
textBaseline: TextBaseline.alphabetic,
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: gramsController,
|
||||
keyboardType:
|
||||
const TextInputType.numberWithOptions(
|
||||
decimal: true),
|
||||
style: theme.textTheme.headlineMedium
|
||||
?.copyWith(fontWeight: FontWeight.bold),
|
||||
decoration: const InputDecoration(
|
||||
hintText: '0',
|
||||
border: UnderlineInputBorder(),
|
||||
enabledBorder: UnderlineInputBorder(),
|
||||
focusedBorder: UnderlineInputBorder(),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
onChanged: onGramsChanged,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Text('g',
|
||||
style: theme.textTheme.titleMedium
|
||||
?.copyWith(color: muted)),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Swap button
|
||||
Padding(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
child: IconButton.outlined(
|
||||
onPressed: onSwap,
|
||||
icon: const Icon(Icons.swap_horiz),
|
||||
style: IconButton.styleFrom(
|
||||
minimumSize: const Size(44, 44)),
|
||||
),
|
||||
),
|
||||
|
||||
// Output
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
isRtC ? 'COTTO' : 'CRUDO',
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.8,
|
||||
color: muted,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Container(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: theme.colorScheme.outline
|
||||
.withValues(alpha: 0.4),
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.baseline,
|
||||
textBaseline: TextBaseline.alphabetic,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
result != null
|
||||
? _formatResult(result)
|
||||
: '—',
|
||||
style: result != null
|
||||
? theme.textTheme.headlineMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: theme.colorScheme.primary,
|
||||
)
|
||||
: theme.textTheme.headlineMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurface
|
||||
.withValues(alpha: 0.2),
|
||||
fontWeight: FontWeight.w300,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (result != null) ...[
|
||||
const SizedBox(width: 4),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Text('g',
|
||||
style: theme.textTheme.titleMedium
|
||||
?.copyWith(color: muted)),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Footer
|
||||
if (result != null)
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 12),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
top: BorderSide(
|
||||
color: theme.colorScheme.outline.withValues(alpha: 0.2),
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
'fattore di resa ${selected.yieldFactor} · '
|
||||
'${isRtC ? 'da mangiare cotti' : 'peso crudo equivalente'}',
|
||||
style: theme.textTheme.bodySmall?.copyWith(color: muted),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _formatResult(double v) =>
|
||||
v == v.truncateToDouble() ? v.toInt().toString() : v.toString();
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import '../models/conversion_entry.dart';
|
||||
|
||||
class ConverterProvider extends ChangeNotifier {
|
||||
List<ConversionEntry> _db = [];
|
||||
List<ConversionEntry> _results = [];
|
||||
ConversionEntry? _selected;
|
||||
double? _grams;
|
||||
bool _rawToCooked = true;
|
||||
|
||||
List<ConversionEntry> get results => _results;
|
||||
ConversionEntry? get selected => _selected;
|
||||
double? get grams => _grams;
|
||||
bool get rawToCooked => _rawToCooked;
|
||||
|
||||
double? get result {
|
||||
if (_selected == null || _grams == null || _grams! <= 0) return null;
|
||||
final v = _rawToCooked
|
||||
? _selected!.rawToCooked(_grams!)
|
||||
: _selected!.cookedToRaw(_grams!);
|
||||
return (v * 10).roundToDouble() / 10;
|
||||
}
|
||||
|
||||
Future<void> loadDb() async {
|
||||
final raw = await rootBundle.loadString('assets/data/conversions.json');
|
||||
final Map<String, dynamic> json = jsonDecode(raw);
|
||||
_db = [
|
||||
for (final food in json.keys)
|
||||
for (final method in (json[food] as Map).keys)
|
||||
ConversionEntry(
|
||||
food: food,
|
||||
method: method,
|
||||
yieldFactor:
|
||||
(json[food][method]['yield'] as num).toDouble(),
|
||||
),
|
||||
];
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void search(String q) {
|
||||
if (q.trim().isEmpty) {
|
||||
_results = [];
|
||||
} else {
|
||||
final lower = q.toLowerCase();
|
||||
_results = _db.where((e) => e.food.contains(lower)).toList();
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void select(ConversionEntry entry) {
|
||||
_selected = entry;
|
||||
_results = [];
|
||||
_grams = null;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void setGrams(double? value) {
|
||||
_grams = value;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void swapDirection() {
|
||||
_rawToCooked = !_rawToCooked;
|
||||
_grams = null;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void reset() {
|
||||
_selected = null;
|
||||
_results = [];
|
||||
_grams = null;
|
||||
_rawToCooked = true;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../../core/constants/app_constants.dart';
|
||||
|
||||
class GuidePage extends StatelessWidget {
|
||||
const GuidePage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return DefaultTabController(
|
||||
length: 3,
|
||||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Guida'),
|
||||
bottom: TabBar(
|
||||
tabs: const [
|
||||
Tab(text: 'Pasti'),
|
||||
Tab(text: 'Converti'),
|
||||
Tab(text: 'Spesa'),
|
||||
],
|
||||
labelColor: Theme.of(context).colorScheme.primary,
|
||||
indicatorColor: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
body: const TabBarView(
|
||||
children: [
|
||||
_PastiTab(),
|
||||
_ConvertiTab(),
|
||||
_SpesaTab(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Pasti ────────────────────────────────────────────────────────────────────
|
||||
|
||||
class _PastiTab extends StatelessWidget {
|
||||
const _PastiTab();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: const [
|
||||
_DocCard(
|
||||
title: 'Aggiungere un alimento',
|
||||
child: _Steps(steps: [
|
||||
'Tocca il giorno per espandere la card',
|
||||
'Scegli il pasto: Colazione, Pranzo o Cena',
|
||||
'Scrivi il nome nel campo di testo',
|
||||
'Premi + o Invio per aggiungerlo',
|
||||
]),
|
||||
),
|
||||
_TipCard(
|
||||
text:
|
||||
'Premi "Genera lista della spesa" in fondo alla pagina per importare automaticamente tutti gli alimenti della settimana, senza duplicati.',
|
||||
),
|
||||
_DocCard(
|
||||
title: 'Rimuovere un alimento',
|
||||
child: _Body(
|
||||
text:
|
||||
'Tocca il pulsante × a destra dell\'elemento per eliminarlo.'),
|
||||
),
|
||||
_DocCard(
|
||||
title: 'Salvataggio automatico',
|
||||
child: _Body(
|
||||
text:
|
||||
'I dati vengono salvati automaticamente sul dispositivo. Non serve premere nessun tasto.'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Converti ─────────────────────────────────────────────────────────────────
|
||||
|
||||
class _ConvertiTab extends StatelessWidget {
|
||||
const _ConvertiTab();
|
||||
|
||||
static const _categories = [
|
||||
('Cereali e pasta', 'Riso (4 varietà), pasta, farro, orzo, quinoa, cous cous'),
|
||||
('Legumi secchi', 'Ceci, fagioli, lenticchie'),
|
||||
('Verdure', 'Carote, zucchine, patate, spinaci, broccoli, asparagi e altri'),
|
||||
('Carni', 'Pollo petto, tacchino fesa, hamburger, vitello'),
|
||||
('Pesce', 'Tonno, merluzzo, spigola, sogliola'),
|
||||
('Uova', 'Uovo al tegamino, frittata'),
|
||||
];
|
||||
|
||||
static const _methods = ['Bollitura', 'Padella', 'Forno', 'Friggitrice ad aria'];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
const _DocCard(
|
||||
title: 'Come usarlo',
|
||||
child: _Steps(steps: [
|
||||
'Cerca l\'alimento nel campo (es. pollo, riso)',
|
||||
'Seleziona il metodo di cottura dall\'elenco',
|
||||
'Inserisci il peso in grammi',
|
||||
'Il risultato appare in tempo reale',
|
||||
'Premi il pulsante "Cambia" per invertire crudo / cotto',
|
||||
]),
|
||||
),
|
||||
_DocCard(
|
||||
title: 'Alimenti disponibili',
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: scheme.outlineVariant),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Column(
|
||||
children: _categories.indexed.map((entry) {
|
||||
final (i, cat) = entry;
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
border: i < _categories.length - 1
|
||||
? Border(
|
||||
bottom: BorderSide(color: scheme.outlineVariant))
|
||||
: null,
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12, vertical: 9),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 110,
|
||||
child: Text(cat.$1,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 13)),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(cat.$2,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: scheme.onSurfaceVariant)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 6,
|
||||
children: _methods
|
||||
.map((m) => Chip(
|
||||
label: Text(m,
|
||||
style: const TextStyle(fontSize: 12)),
|
||||
padding: EdgeInsets.zero,
|
||||
visualDensity: VisualDensity.compact,
|
||||
))
|
||||
.toList(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Spesa ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
class _SpesaTab extends StatelessWidget {
|
||||
const _SpesaTab();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: const [
|
||||
_DocCard(
|
||||
title: 'Aggiungere un elemento',
|
||||
child: _Body(
|
||||
text: 'Scrivi il nome nel campo in alto e premi + o Invio.'),
|
||||
),
|
||||
_TipCard(
|
||||
text:
|
||||
'Vai alla tab Pasti e premi "Genera lista della spesa" per importare automaticamente gli alimenti pianificati, senza duplicati.',
|
||||
),
|
||||
_DocCard(
|
||||
title: 'Spuntare un elemento',
|
||||
child: _Body(
|
||||
text:
|
||||
'Tocca la casella a sinistra. Gli elementi completati vengono spostati in una sezione separata con testo barrato.'),
|
||||
),
|
||||
_DocCard(
|
||||
title: 'Rimuovere / svuotare',
|
||||
child: _Body(
|
||||
text:
|
||||
'Tocca × per rimuovere un singolo elemento, oppure "Svuota lista" in fondo per eliminare tutto (richiede conferma).'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Componenti condivisi ──────────────────────────────────────────────────────
|
||||
|
||||
class _DocCard extends StatelessWidget {
|
||||
final String title;
|
||||
final Widget child;
|
||||
const _DocCard({required this.title, required this.child});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 10),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(14),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(title.toUpperCase(),
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.7,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant)),
|
||||
const SizedBox(height: 10),
|
||||
child,
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TipCard extends StatelessWidget {
|
||||
final String text;
|
||||
const _TipCard({required this.text});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 10),
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: scheme.primaryContainer.withValues(alpha: 0.4),
|
||||
border: Border(left: BorderSide(color: scheme.primary, width: 3)),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.info_outline, size: 14, color: scheme.primary),
|
||||
const SizedBox(width: 5),
|
||||
Text('SUGGERIMENTO',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.7,
|
||||
color: scheme.primary)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(text, style: const TextStyle(fontSize: 14, height: 1.5)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Steps extends StatelessWidget {
|
||||
final List<String> steps;
|
||||
const _Steps({required this.steps});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: steps.indexed.map((entry) {
|
||||
final (i, step) = entry;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 22,
|
||||
height: 22,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Text('${i + 1}',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.bold)),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 2),
|
||||
child: Text(step,
|
||||
style:
|
||||
const TextStyle(fontSize: 14, height: 1.5)),
|
||||
)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Body extends StatelessWidget {
|
||||
final String text;
|
||||
const _Body({required this.text});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Text(text, style: const TextStyle(fontSize: 14, height: 1.5));
|
||||
}
|
||||
}
|
||||
|
||||
// ignore footer version — accessed via kAppVersion constant
|
||||
String get _footerText => 'BitePlan · v$kAppVersion · $kAppAuthor';
|
||||
@@ -0,0 +1,164 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../../core/constants/app_constants.dart';
|
||||
import '../pages/guide_page.dart';
|
||||
|
||||
void showInfoBottomSheet(BuildContext context) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
||||
),
|
||||
builder: (_) => const _InfoSheet(),
|
||||
);
|
||||
}
|
||||
|
||||
class _InfoSheet extends StatelessWidget {
|
||||
const _InfoSheet();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
|
||||
return SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 12, 20, 24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// handle
|
||||
Container(
|
||||
width: 36,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: scheme.outlineVariant,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
// icona + nome + versione
|
||||
Row(
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Image.asset(
|
||||
'assets/icon-only.png',
|
||||
width: 52,
|
||||
height: 52,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('BitePlan',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleMedium
|
||||
?.copyWith(fontWeight: FontWeight.bold)),
|
||||
Text('Versione $kAppVersion',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodySmall
|
||||
?.copyWith(color: scheme.onSurfaceVariant)),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
// righe info
|
||||
_InfoCard(children: [
|
||||
_InfoRow(label: 'Autore', value: kAppAuthor),
|
||||
_InfoRow(label: 'Licenza', value: kAppLicense),
|
||||
_InfoRowButton(
|
||||
label: 'Guida',
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => const GuidePage()),
|
||||
);
|
||||
},
|
||||
),
|
||||
]),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _InfoCard extends StatelessWidget {
|
||||
final List<Widget> children;
|
||||
const _InfoCard({required this.children});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: scheme.outlineVariant),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Column(children: children),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _InfoRow extends StatelessWidget {
|
||||
final String label;
|
||||
final String value;
|
||||
const _InfoRow({required this.label, required this.value});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 13),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(label, style: Theme.of(context).textTheme.bodyMedium),
|
||||
Text(value,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodyMedium
|
||||
?.copyWith(color: scheme.onSurfaceVariant)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _InfoRowButton extends StatelessWidget {
|
||||
final String label;
|
||||
final VoidCallback onTap;
|
||||
const _InfoRowButton({required this.label, required this.onTap});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: const BorderRadius.vertical(bottom: Radius.circular(12)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 13),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(label, style: Theme.of(context).textTheme.bodyMedium),
|
||||
Row(
|
||||
children: [
|
||||
Text('Apri',
|
||||
style: TextStyle(
|
||||
color: scheme.primary, fontWeight: FontWeight.w600)),
|
||||
const SizedBox(width: 4),
|
||||
Icon(Icons.chevron_right, size: 18, color: scheme.primary),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import 'dart:convert';
|
||||
|
||||
class DayPlan {
|
||||
final List<String> colazione;
|
||||
final List<String> pranzo;
|
||||
final List<String> cena;
|
||||
|
||||
DayPlan({
|
||||
List<String>? colazione,
|
||||
List<String>? pranzo,
|
||||
List<String>? cena,
|
||||
}) : colazione = colazione ?? [],
|
||||
pranzo = pranzo ?? [],
|
||||
cena = cena ?? [];
|
||||
|
||||
DayPlan copyWith({
|
||||
List<String>? colazione,
|
||||
List<String>? pranzo,
|
||||
List<String>? cena,
|
||||
}) =>
|
||||
DayPlan(
|
||||
colazione: colazione ?? List.of(this.colazione),
|
||||
pranzo: pranzo ?? List.of(this.pranzo),
|
||||
cena: cena ?? List.of(this.cena),
|
||||
);
|
||||
|
||||
List<String> slot(String name) => switch (name) {
|
||||
'colazione' => colazione,
|
||||
'pranzo' => pranzo,
|
||||
'cena' => cena,
|
||||
_ => const [],
|
||||
};
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'colazione': colazione,
|
||||
'pranzo': pranzo,
|
||||
'cena': cena,
|
||||
};
|
||||
|
||||
factory DayPlan.fromJson(Map<String, dynamic> json) => DayPlan(
|
||||
colazione: List<String>.from(json['colazione'] ?? []),
|
||||
pranzo: List<String>.from(json['pranzo'] ?? []),
|
||||
cena: List<String>.from(json['cena'] ?? []),
|
||||
);
|
||||
}
|
||||
|
||||
class MealPlan {
|
||||
final Map<String, DayPlan> days;
|
||||
|
||||
const MealPlan(this.days);
|
||||
|
||||
factory MealPlan.empty(List<String> dayIds) =>
|
||||
MealPlan({for (final d in dayIds) d: DayPlan()});
|
||||
|
||||
MealPlan withUpdatedDay(String dayId, DayPlan updated) =>
|
||||
MealPlan({...days, dayId: updated});
|
||||
|
||||
bool get hasAnyMeal => days.values.any(
|
||||
(d) =>
|
||||
d.colazione.isNotEmpty ||
|
||||
d.pranzo.isNotEmpty ||
|
||||
d.cena.isNotEmpty,
|
||||
);
|
||||
|
||||
List<String> get allItems => days.values
|
||||
.expand((d) => [...d.colazione, ...d.pranzo, ...d.cena])
|
||||
.toList();
|
||||
|
||||
Map<String, dynamic> toJson() =>
|
||||
{for (final e in days.entries) e.key: e.value.toJson()};
|
||||
|
||||
factory MealPlan.fromJson(Map<String, dynamic> json) => MealPlan({
|
||||
for (final e in json.entries)
|
||||
e.key: DayPlan.fromJson(e.value as Map<String, dynamic>),
|
||||
});
|
||||
|
||||
String toJsonString() => jsonEncode(toJson());
|
||||
|
||||
factory MealPlan.fromJsonString(String s) =>
|
||||
MealPlan.fromJson(jsonDecode(s) as Map<String, dynamic>);
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../providers/meal_planner_provider.dart';
|
||||
import '../widgets/meal_card.dart';
|
||||
import '../widgets/qr_share_sheet.dart';
|
||||
import '../pages/qr_scan_page.dart';
|
||||
import '../../../shopping_list/providers/shopping_list_provider.dart';
|
||||
import '../../../../core/constants/app_constants.dart';
|
||||
|
||||
class MealPlannerPage extends StatelessWidget {
|
||||
final VoidCallback onGoShopping;
|
||||
|
||||
const MealPlannerPage({super.key, required this.onGoShopping});
|
||||
|
||||
String get _todayId {
|
||||
const ids = [
|
||||
'',
|
||||
'lunedi',
|
||||
'martedi',
|
||||
'mercoledi',
|
||||
'giovedi',
|
||||
'venerdi',
|
||||
'sabato',
|
||||
'domenica',
|
||||
];
|
||||
return ids[DateTime.now().weekday];
|
||||
}
|
||||
|
||||
String get _todayFormatted {
|
||||
final now = DateTime.now();
|
||||
const months = [
|
||||
'gennaio', 'febbraio', 'marzo', 'aprile', 'maggio', 'giugno',
|
||||
'luglio', 'agosto', 'settembre', 'ottobre', 'novembre', 'dicembre',
|
||||
];
|
||||
const weekdays = [
|
||||
'', 'lunedì', 'martedì', 'mercoledì', 'giovedì', 'venerdì', 'sabato', 'domenica',
|
||||
];
|
||||
return 'Oggi, ${weekdays[now.weekday]} ${now.day} ${months[now.month - 1]}';
|
||||
}
|
||||
|
||||
Future<void> _confirmClear(BuildContext context) async {
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('Svuota piano'),
|
||||
content: const Text('Svuotare tutto il piano settimanale?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: const Text('Annulla'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: Theme.of(ctx).colorScheme.error,
|
||||
),
|
||||
child: const Text('Svuota'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (ok == true && context.mounted) {
|
||||
context.read<MealPlannerProvider>().clearAll();
|
||||
}
|
||||
}
|
||||
|
||||
void _generateShopping(BuildContext context) {
|
||||
final plan = context.read<MealPlannerProvider>().plan;
|
||||
context.read<ShoppingListProvider>().addAll(plan.allItems);
|
||||
onGoShopping();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final provider = context.watch<MealPlannerProvider>();
|
||||
final todayId = _todayId;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 56, 16, 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Piano Pasti',
|
||||
style: theme.textTheme.headlineSmall
|
||||
?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
Text(
|
||||
_todayFormatted,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 4, 16, 16),
|
||||
children: [
|
||||
for (final dayId in kDayIds)
|
||||
MealCard(
|
||||
key: ValueKey(dayId),
|
||||
dayId: dayId,
|
||||
dayLabel: kDayLabels[dayId]!,
|
||||
dayPlan: provider.plan.days[dayId] ??
|
||||
(throw StateError('Day $dayId not found')),
|
||||
initiallyExpanded: dayId == todayId,
|
||||
onAdd: (slot, text) =>
|
||||
context.read<MealPlannerProvider>().addItem(dayId, slot, text),
|
||||
onRemove: (slot, index) =>
|
||||
context.read<MealPlannerProvider>().removeItem(dayId, slot, index),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
FilledButton.icon(
|
||||
onPressed: () => _generateShopping(context),
|
||||
icon: const Icon(Icons.shopping_cart_outlined),
|
||||
label: const Text('Genera lista della spesa'),
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size(double.infinity, 52),
|
||||
),
|
||||
),
|
||||
if (provider.plan.hasAnyMeal) ...[
|
||||
const SizedBox(height: 8),
|
||||
OutlinedButton(
|
||||
onPressed: () => _confirmClear(context),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: theme.colorScheme.error,
|
||||
side: BorderSide(
|
||||
color: theme.colorScheme.error.withValues(alpha: 0.4),
|
||||
),
|
||||
minimumSize: const Size(double.infinity, 48),
|
||||
),
|
||||
child: const Text('Svuota piano'),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: provider.plan.hasAnyMeal
|
||||
? () => showQrShareSheet(context, provider.plan)
|
||||
: null,
|
||||
icon: const Icon(Icons.qr_code, size: 18),
|
||||
label: const Text('Condividi'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => const QrScanPage()),
|
||||
),
|
||||
icon: const Icon(Icons.qr_code_scanner, size: 18),
|
||||
label: const Text('Ricevi'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:mobile_scanner/mobile_scanner.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../providers/meal_planner_provider.dart';
|
||||
import '../../qr_codec.dart';
|
||||
|
||||
class QrScanPage extends StatefulWidget {
|
||||
const QrScanPage({super.key});
|
||||
|
||||
@override
|
||||
State<QrScanPage> createState() => _QrScanPageState();
|
||||
}
|
||||
|
||||
class _QrScanPageState extends State<QrScanPage> {
|
||||
bool _handled = false;
|
||||
|
||||
void _onDetect(BarcodeCapture capture, BuildContext context) {
|
||||
if (_handled) return;
|
||||
final raw = capture.barcodes.firstOrNull?.rawValue;
|
||||
if (raw == null) return;
|
||||
|
||||
setState(() => _handled = true);
|
||||
|
||||
final (plan, error) = parseMealPlanFromQr(raw);
|
||||
if (error != null) {
|
||||
_showError(context, error);
|
||||
return;
|
||||
}
|
||||
|
||||
context.read<MealPlannerProvider>().importPlan(plan!);
|
||||
|
||||
if (context.mounted) {
|
||||
Navigator.pop(context);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Piano ricevuto!')),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _showError(BuildContext context, String msg) {
|
||||
if (!context.mounted) return;
|
||||
Navigator.pop(context);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(msg),
|
||||
backgroundColor: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Scansiona QR')),
|
||||
body: Stack(
|
||||
children: [
|
||||
MobileScanner(
|
||||
onDetect: (capture) => _onDetect(capture, context),
|
||||
),
|
||||
// cornice di mira
|
||||
Center(
|
||||
child: Container(
|
||||
width: 220,
|
||||
height: 220,
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: scheme.primary, width: 3),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
bottom: 40,
|
||||
left: 0, right: 0,
|
||||
child: Text(
|
||||
'Inquadra il codice QR dell\'altro dispositivo',
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: Colors.white, fontSize: 14),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../models/meal_plan.dart';
|
||||
import '../../../../core/constants/app_constants.dart';
|
||||
|
||||
class MealCard extends StatefulWidget {
|
||||
final String dayId;
|
||||
final String dayLabel;
|
||||
final DayPlan dayPlan;
|
||||
final bool initiallyExpanded;
|
||||
final void Function(String slot, String text) onAdd;
|
||||
final void Function(String slot, int index) onRemove;
|
||||
|
||||
const MealCard({
|
||||
super.key,
|
||||
required this.dayId,
|
||||
required this.dayLabel,
|
||||
required this.dayPlan,
|
||||
required this.onAdd,
|
||||
required this.onRemove,
|
||||
this.initiallyExpanded = false,
|
||||
});
|
||||
|
||||
@override
|
||||
State<MealCard> createState() => _MealCardState();
|
||||
}
|
||||
|
||||
class _MealCardState extends State<MealCard> {
|
||||
late final Map<String, TextEditingController> _controllers = {
|
||||
for (final slot in kMealSlots) slot: TextEditingController(),
|
||||
};
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
for (final c in _controllers.values) {
|
||||
c.dispose();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _add(String slot) {
|
||||
final text = _controllers[slot]!.text.trim();
|
||||
if (text.isEmpty) return;
|
||||
widget.onAdd(slot, text);
|
||||
_controllers[slot]!.clear();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final totalItems = widget.dayPlan.colazione.length +
|
||||
widget.dayPlan.pranzo.length +
|
||||
widget.dayPlan.cena.length;
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: ExpansionTile(
|
||||
initiallyExpanded: widget.initiallyExpanded,
|
||||
title: Text(
|
||||
widget.dayLabel,
|
||||
style: theme.textTheme.titleMedium
|
||||
?.copyWith(fontWeight: FontWeight.w600),
|
||||
),
|
||||
subtitle: totalItems > 0
|
||||
? Text('$totalItems voc${totalItems == 1 ? 'e' : 'i'}')
|
||||
: null,
|
||||
children: [
|
||||
const Divider(height: 1),
|
||||
for (final slot in kMealSlots)
|
||||
_SlotSection(
|
||||
label: kMealSlotLabels[slot]!,
|
||||
items: widget.dayPlan.slot(slot),
|
||||
controller: _controllers[slot]!,
|
||||
onAdd: () => _add(slot),
|
||||
onRemove: (i) => widget.onRemove(slot, i),
|
||||
isLast: slot == kMealSlots.last,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SlotSection extends StatelessWidget {
|
||||
final String label;
|
||||
final List<String> items;
|
||||
final TextEditingController controller;
|
||||
final VoidCallback onAdd;
|
||||
final void Function(int) onRemove;
|
||||
final bool isLast;
|
||||
|
||||
const _SlotSection({
|
||||
required this.label,
|
||||
required this.items,
|
||||
required this.controller,
|
||||
required this.onAdd,
|
||||
required this.onRemove,
|
||||
required this.isLast,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 6),
|
||||
child: Text(
|
||||
label,
|
||||
style: theme.textTheme.labelMedium?.copyWith(
|
||||
color: theme.colorScheme.primary,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.3,
|
||||
),
|
||||
),
|
||||
),
|
||||
for (int i = 0; i < items.length; i++)
|
||||
_ItemRow(text: items[i], onRemove: () => onRemove(i)),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 6, 16, 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: controller,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Aggiungi a ${label.toLowerCase()}…',
|
||||
isDense: true,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 10,
|
||||
),
|
||||
),
|
||||
onSubmitted: (_) => onAdd(),
|
||||
textInputAction: TextInputAction.done,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
SizedBox(
|
||||
width: 44,
|
||||
height: 44,
|
||||
child: FilledButton(
|
||||
onPressed: onAdd,
|
||||
style: FilledButton.styleFrom(
|
||||
padding: EdgeInsets.zero,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
),
|
||||
child: const Icon(Icons.add, size: 20),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (!isLast) const Divider(height: 1),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ItemRow extends StatelessWidget {
|
||||
final String text;
|
||||
final VoidCallback onRemove;
|
||||
|
||||
const _ItemRow({required this.text, required this.onRemove});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 3),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.circle,
|
||||
size: 5,
|
||||
color: theme.colorScheme.onSurface.withValues(alpha: 0.3),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(child: Text(text, style: theme.textTheme.bodyMedium)),
|
||||
IconButton(
|
||||
onPressed: onRemove,
|
||||
icon: const Icon(Icons.close, size: 16),
|
||||
visualDensity: VisualDensity.compact,
|
||||
color: theme.colorScheme.onSurface.withValues(alpha: 0.35),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:qr_flutter/qr_flutter.dart';
|
||||
import '../../models/meal_plan.dart';
|
||||
import '../../qr_codec.dart';
|
||||
|
||||
void showQrShareSheet(BuildContext context, MealPlan plan) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
||||
),
|
||||
builder: (_) => _QrShareSheet(plan: plan),
|
||||
);
|
||||
}
|
||||
|
||||
class _QrShareSheet extends StatelessWidget {
|
||||
final MealPlan plan;
|
||||
const _QrShareSheet({required this.plan});
|
||||
|
||||
String? _buildPayload() => buildQrPayload(plan);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final payload = _buildPayload();
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
|
||||
return SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 12, 20, 32),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// handle
|
||||
Container(
|
||||
width: 36, height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: scheme.outlineVariant,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text('Condividi piano',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleMedium
|
||||
?.copyWith(fontWeight: FontWeight.bold)),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Fai scansionare questo codice dall\'altro dispositivo',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
if (payload == null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 24),
|
||||
child: Text(
|
||||
'Dati troppo grandi per un QR code.\nRiduci il numero di alimenti inseriti.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: scheme.error),
|
||||
),
|
||||
)
|
||||
else
|
||||
QrImageView(
|
||||
data: payload,
|
||||
version: QrVersions.auto,
|
||||
size: 260,
|
||||
errorCorrectionLevel: QrErrorCorrectLevel.L,
|
||||
backgroundColor: Colors.white,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../models/meal_plan.dart';
|
||||
import '../../../shared/services/storage_service.dart';
|
||||
import '../../../core/constants/app_constants.dart';
|
||||
|
||||
class MealPlannerProvider extends ChangeNotifier {
|
||||
MealPlan _plan = MealPlan.empty(kDayIds);
|
||||
|
||||
MealPlan get plan => _plan;
|
||||
|
||||
Future<void> load() async {
|
||||
final json = await StorageService.load(kStorageKeyMeals);
|
||||
if (json != null) {
|
||||
try {
|
||||
_plan = MealPlan.fromJsonString(json);
|
||||
} catch (_) {
|
||||
_plan = MealPlan.empty(kDayIds);
|
||||
}
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> _save() =>
|
||||
StorageService.save(kStorageKeyMeals, _plan.toJsonString());
|
||||
|
||||
void addItem(String dayId, String slot, String text) {
|
||||
final t = text.trim();
|
||||
if (t.isEmpty) return;
|
||||
final day = _plan.days[dayId]!;
|
||||
final updated = switch (slot) {
|
||||
'colazione' => day.copyWith(colazione: [...day.colazione, t]),
|
||||
'pranzo' => day.copyWith(pranzo: [...day.pranzo, t]),
|
||||
'cena' => day.copyWith(cena: [...day.cena, t]),
|
||||
_ => day,
|
||||
};
|
||||
_plan = _plan.withUpdatedDay(dayId, updated);
|
||||
notifyListeners();
|
||||
_save();
|
||||
}
|
||||
|
||||
void removeItem(String dayId, String slot, int index) {
|
||||
final day = _plan.days[dayId]!;
|
||||
final updated = switch (slot) {
|
||||
'colazione' =>
|
||||
day.copyWith(colazione: List.of(day.colazione)..removeAt(index)),
|
||||
'pranzo' =>
|
||||
day.copyWith(pranzo: List.of(day.pranzo)..removeAt(index)),
|
||||
'cena' =>
|
||||
day.copyWith(cena: List.of(day.cena)..removeAt(index)),
|
||||
_ => day,
|
||||
};
|
||||
_plan = _plan.withUpdatedDay(dayId, updated);
|
||||
notifyListeners();
|
||||
_save();
|
||||
}
|
||||
|
||||
void clearAll() {
|
||||
_plan = MealPlan.empty(kDayIds);
|
||||
notifyListeners();
|
||||
_save();
|
||||
}
|
||||
|
||||
void importPlan(MealPlan plan) {
|
||||
_plan = plan;
|
||||
notifyListeners();
|
||||
_save();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import 'dart:convert';
|
||||
import 'models/meal_plan.dart';
|
||||
import '../../core/constants/app_constants.dart';
|
||||
|
||||
const int kQrMaxBytes = 2953;
|
||||
|
||||
/// Serializza il piano in JSON QR. Restituisce null se supera il limite di byte.
|
||||
String? buildQrPayload(MealPlan plan) {
|
||||
final payload = jsonEncode({
|
||||
'v': 1,
|
||||
'meals': plan.days.map(
|
||||
(k, v) => MapEntry(k, {
|
||||
'colazione': v.colazione,
|
||||
'pranzo': v.pranzo,
|
||||
'cena': v.cena,
|
||||
}),
|
||||
),
|
||||
});
|
||||
if (payload.length > kQrMaxBytes) return null;
|
||||
return payload;
|
||||
}
|
||||
|
||||
/// Parsa una stringa QR in MealPlan.
|
||||
/// Restituisce (plan, null) in caso di successo, (null, messaggio) in caso di errore.
|
||||
(MealPlan?, String?) parseMealPlanFromQr(String raw) {
|
||||
try {
|
||||
final parsed = jsonDecode(raw) as Map<String, dynamic>;
|
||||
if (parsed['v'] != 1 || parsed['meals'] is! Map) {
|
||||
return (null, 'QR non valido: dati non riconosciuti.');
|
||||
}
|
||||
final mealsMap = parsed['meals'] as Map<String, dynamic>;
|
||||
for (final day in kDayIds) {
|
||||
final dayData = mealsMap[day];
|
||||
if (dayData is! Map) {
|
||||
return (null, 'QR non valido: struttura dati errata.');
|
||||
}
|
||||
for (final slot in kMealSlots) {
|
||||
if (dayData[slot] is! List) {
|
||||
return (null, 'QR non valido: struttura dati errata.');
|
||||
}
|
||||
}
|
||||
}
|
||||
final plan = MealPlan(Map.fromEntries(
|
||||
kDayIds.map((day) {
|
||||
final d = mealsMap[day] as Map<String, dynamic>;
|
||||
return MapEntry(
|
||||
day,
|
||||
DayPlan(
|
||||
colazione: List<String>.from(d['colazione'] as List),
|
||||
pranzo: List<String>.from(d['pranzo'] as List),
|
||||
cena: List<String>.from(d['cena'] as List),
|
||||
),
|
||||
);
|
||||
}),
|
||||
));
|
||||
return (plan, null);
|
||||
} catch (_) {
|
||||
return (null, 'QR non riconosciuto.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
class ShoppingItem {
|
||||
final String id;
|
||||
final String name;
|
||||
final bool checked;
|
||||
final int quantity;
|
||||
|
||||
const ShoppingItem({
|
||||
required this.id,
|
||||
required this.name,
|
||||
this.checked = false,
|
||||
this.quantity = 1,
|
||||
});
|
||||
|
||||
ShoppingItem copyWith({String? id, String? name, bool? checked, int? quantity}) =>
|
||||
ShoppingItem(
|
||||
id: id ?? this.id,
|
||||
name: name ?? this.name,
|
||||
checked: checked ?? this.checked,
|
||||
quantity: quantity ?? this.quantity,
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'name': name,
|
||||
'checked': checked,
|
||||
'quantity': quantity,
|
||||
};
|
||||
|
||||
factory ShoppingItem.fromJson(Map<String, dynamic> json) => ShoppingItem(
|
||||
id: json['id'] as String,
|
||||
name: json['name'] as String,
|
||||
checked: json['checked'] as bool? ?? false,
|
||||
quantity: json['quantity'] as int? ?? 1,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../providers/shopping_list_provider.dart';
|
||||
import '../widgets/shopping_item_tile.dart';
|
||||
import '../../../../shared/widgets/empty_state.dart';
|
||||
|
||||
class ShoppingListPage extends StatefulWidget {
|
||||
const ShoppingListPage({super.key});
|
||||
|
||||
@override
|
||||
State<ShoppingListPage> createState() => _ShoppingListPageState();
|
||||
}
|
||||
|
||||
class _ShoppingListPageState extends State<ShoppingListPage> {
|
||||
final _controller = TextEditingController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _add() {
|
||||
final text = _controller.text.trim();
|
||||
if (text.isEmpty) return;
|
||||
context.read<ShoppingListProvider>().add(text);
|
||||
_controller.clear();
|
||||
}
|
||||
|
||||
Future<void> _confirmClear() async {
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('Svuota lista'),
|
||||
content: const Text('Svuotare tutta la lista della spesa?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: const Text('Annulla'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: Theme.of(ctx).colorScheme.error,
|
||||
),
|
||||
child: const Text('Svuota'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (ok == true && mounted) {
|
||||
context.read<ShoppingListProvider>().clearAll();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final provider = context.watch<ShoppingListProvider>();
|
||||
final pending = provider.pendingItems;
|
||||
final checked = provider.checkedItems;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 56, 16, 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Lista della spesa',
|
||||
style: theme.textTheme.headlineSmall
|
||||
?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
if (provider.items.isNotEmpty)
|
||||
Text(
|
||||
'${checked.length} / ${provider.items.length} '
|
||||
'completat${checked.length == 1 ? 'o' : 'i'}',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Add row
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _controller,
|
||||
decoration:
|
||||
const InputDecoration(hintText: 'Aggiungi elemento…'),
|
||||
onSubmitted: (_) => _add(),
|
||||
textInputAction: TextInputAction.done,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
FilledButton(
|
||||
onPressed: _add,
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size(52, 52),
|
||||
padding: EdgeInsets.zero,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
),
|
||||
child: const Icon(Icons.add),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
Expanded(
|
||||
child: provider.items.isEmpty
|
||||
? const EmptyState(
|
||||
icon: Icons.shopping_cart_outlined,
|
||||
title: 'Lista vuota',
|
||||
subtitle: 'Aggiungi qualcosa con il campo qui sopra.',
|
||||
)
|
||||
: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
||||
children: [
|
||||
// Pending items
|
||||
if (pending.isNotEmpty)
|
||||
Card(
|
||||
margin: EdgeInsets.zero,
|
||||
child: Column(
|
||||
children: [
|
||||
for (int i = 0; i < pending.length; i++) ...[
|
||||
if (i > 0)
|
||||
const Divider(
|
||||
height: 1, indent: 16, endIndent: 16),
|
||||
ShoppingItemTile(
|
||||
item: pending[i],
|
||||
onToggle: () => context
|
||||
.read<ShoppingListProvider>()
|
||||
.toggle(pending[i].id),
|
||||
onRemove: () => context
|
||||
.read<ShoppingListProvider>()
|
||||
.remove(pending[i].id),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Checked section
|
||||
if (checked.isNotEmpty) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
'COMPLETATI (${checked.length})',
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.8,
|
||||
color: theme.colorScheme.onSurface
|
||||
.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Divider(
|
||||
color: theme.colorScheme.outline
|
||||
.withValues(alpha: 0.4),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Card(
|
||||
margin: EdgeInsets.zero,
|
||||
child: Column(
|
||||
children: [
|
||||
for (int i = 0; i < checked.length; i++) ...[
|
||||
if (i > 0)
|
||||
const Divider(
|
||||
height: 1, indent: 16, endIndent: 16),
|
||||
ShoppingItemTile(
|
||||
item: checked[i],
|
||||
onToggle: () => context
|
||||
.read<ShoppingListProvider>()
|
||||
.toggle(checked[i].id),
|
||||
onRemove: () => context
|
||||
.read<ShoppingListProvider>()
|
||||
.remove(checked[i].id),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
const SizedBox(height: 16),
|
||||
OutlinedButton(
|
||||
onPressed: _confirmClear,
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: theme.colorScheme.error,
|
||||
side: BorderSide(
|
||||
color: theme.colorScheme.error.withValues(alpha: 0.4),
|
||||
),
|
||||
minimumSize: const Size(double.infinity, 48),
|
||||
),
|
||||
child: const Text('Svuota lista'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../models/shopping_item.dart';
|
||||
|
||||
class ShoppingItemTile extends StatelessWidget {
|
||||
final ShoppingItem item;
|
||||
final VoidCallback onToggle;
|
||||
final VoidCallback onRemove;
|
||||
|
||||
const ShoppingItemTile({
|
||||
super.key,
|
||||
required this.item,
|
||||
required this.onToggle,
|
||||
required this.onRemove,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final muted = theme.colorScheme.onSurface.withValues(alpha: 0.35);
|
||||
|
||||
return InkWell(
|
||||
onTap: onToggle,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
|
||||
child: Row(
|
||||
children: [
|
||||
Checkbox(
|
||||
value: item.checked,
|
||||
onChanged: (_) => onToggle(),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
item.name,
|
||||
style: item.checked
|
||||
? theme.textTheme.bodyMedium?.copyWith(
|
||||
decoration: TextDecoration.lineThrough,
|
||||
color: muted,
|
||||
)
|
||||
: theme.textTheme.bodyMedium,
|
||||
),
|
||||
),
|
||||
if (item.quantity > 1)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 6),
|
||||
child: Text(
|
||||
'(x${item.quantity})',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: item.checked
|
||||
? muted
|
||||
: theme.colorScheme.primary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: onRemove,
|
||||
icon: const Icon(Icons.close, size: 16),
|
||||
visualDensity: VisualDensity.compact,
|
||||
color: muted,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/material.dart';
|
||||
import '../models/shopping_item.dart';
|
||||
import '../../../shared/services/storage_service.dart';
|
||||
import '../../../core/constants/app_constants.dart';
|
||||
|
||||
class ShoppingListProvider extends ChangeNotifier {
|
||||
List<ShoppingItem> _items = [];
|
||||
int _idSeq = 0;
|
||||
|
||||
List<ShoppingItem> get items => _items;
|
||||
List<ShoppingItem> get pendingItems =>
|
||||
_items.where((i) => !i.checked).toList();
|
||||
List<ShoppingItem> get checkedItems =>
|
||||
_items.where((i) => i.checked).toList();
|
||||
|
||||
Future<void> load() async {
|
||||
final json = await StorageService.load(kStorageKeyShopping);
|
||||
if (json != null) {
|
||||
try {
|
||||
final list = jsonDecode(json) as List;
|
||||
_items = list
|
||||
.map((e) => ShoppingItem.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
} catch (_) {
|
||||
_items = [];
|
||||
}
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
final json = jsonEncode(_items.map((i) => i.toJson()).toList());
|
||||
await StorageService.save(kStorageKeyShopping, json);
|
||||
}
|
||||
|
||||
void add(String name) {
|
||||
final t = name.trim();
|
||||
if (t.isEmpty) return;
|
||||
_items = [
|
||||
..._items,
|
||||
ShoppingItem(
|
||||
id: '${DateTime.now().millisecondsSinceEpoch}_${_idSeq++}',
|
||||
name: t,
|
||||
),
|
||||
];
|
||||
notifyListeners();
|
||||
_save();
|
||||
}
|
||||
|
||||
void addAll(List<String> names) {
|
||||
final existing = _items.map((i) => i.name.toLowerCase()).toSet();
|
||||
|
||||
// Conta le occorrenze mantenendo il nome nella sua forma originale (prima occorrenza)
|
||||
final counts = <String, int>{};
|
||||
final canonical = <String, String>{};
|
||||
for (final name in names) {
|
||||
final key = name.toLowerCase().trim();
|
||||
if (key.isEmpty) continue;
|
||||
counts[key] = (counts[key] ?? 0) + 1;
|
||||
canonical.putIfAbsent(key, () => name.trim());
|
||||
}
|
||||
|
||||
final toAdd = <ShoppingItem>[];
|
||||
for (final entry in counts.entries) {
|
||||
if (existing.contains(entry.key)) continue;
|
||||
toAdd.add(ShoppingItem(
|
||||
id: '${DateTime.now().millisecondsSinceEpoch}_${_idSeq++}',
|
||||
name: canonical[entry.key]!,
|
||||
quantity: entry.value,
|
||||
));
|
||||
}
|
||||
if (toAdd.isEmpty) return;
|
||||
_items = [..._items, ...toAdd];
|
||||
notifyListeners();
|
||||
_save();
|
||||
}
|
||||
|
||||
void toggle(String id) {
|
||||
_items = _items
|
||||
.map((i) => i.id == id ? i.copyWith(checked: !i.checked) : i)
|
||||
.toList();
|
||||
notifyListeners();
|
||||
_save();
|
||||
}
|
||||
|
||||
void remove(String id) {
|
||||
_items = _items.where((i) => i.id != id).toList();
|
||||
notifyListeners();
|
||||
_save();
|
||||
}
|
||||
|
||||
void clearAll() {
|
||||
_items = [];
|
||||
notifyListeners();
|
||||
_save();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'app.dart';
|
||||
import 'features/meal_planner/providers/meal_planner_provider.dart';
|
||||
import 'features/converter/providers/converter_provider.dart';
|
||||
import 'features/shopping_list/providers/shopping_list_provider.dart';
|
||||
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
await SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]);
|
||||
runApp(
|
||||
MultiProvider(
|
||||
providers: [
|
||||
ChangeNotifierProvider(create: (_) => MealPlannerProvider()..load()),
|
||||
ChangeNotifierProvider(create: (_) => ConverterProvider()..loadDb()),
|
||||
ChangeNotifierProvider(create: (_) => ShoppingListProvider()..load()),
|
||||
],
|
||||
child: const BitePlanApp(),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
class StorageService {
|
||||
static Future<String?> load(String key) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getString(key);
|
||||
}
|
||||
|
||||
static Future<void> save(String key, String value) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(key, value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class EmptyState extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String title;
|
||||
final String? subtitle;
|
||||
|
||||
const EmptyState({
|
||||
super.key,
|
||||
required this.icon,
|
||||
required this.title,
|
||||
this.subtitle,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final muted = theme.colorScheme.onSurface.withValues(alpha: 0.4);
|
||||
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(40),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 48, color: muted),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
title,
|
||||
style: theme.textTheme.bodyLarge?.copyWith(color: muted),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
if (subtitle != null) ...[
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
subtitle!,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withValues(alpha: 0.3),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Generated
-3572
File diff suppressed because it is too large
Load Diff
@@ -1,29 +0,0 @@
|
||||
{
|
||||
"name": "biteplan",
|
||||
"version": "1.2.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite --host",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest",
|
||||
"test:coverage": "vitest --coverage",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:ui": "playwright test --ui"
|
||||
},
|
||||
"dependencies": {
|
||||
"jsqr": "^1.4.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"vue": "^3.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@vitejs/plugin-vue": "^5.2.0",
|
||||
"@vitest/coverage-v8": "^3.0.0",
|
||||
"@vue/test-utils": "^2.4.6",
|
||||
"happy-dom": "^20.8.9",
|
||||
"jsdom": "^29.0.1",
|
||||
"vite": "^6.0.0",
|
||||
"vitest": "^3.0.0"
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import { defineConfig, devices } from '@playwright/test'
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './tests/e2e',
|
||||
use: {
|
||||
baseURL: 'http://localhost:5173',
|
||||
// Simula iPhone 14 Pro — dimensioni target dell'app
|
||||
viewport: { width: 393, height: 852 },
|
||||
locale: 'it-IT',
|
||||
},
|
||||
webServer: {
|
||||
command: 'npm run dev',
|
||||
url: 'http://localhost:5173',
|
||||
reuseExistingServer: true,
|
||||
},
|
||||
projects: [
|
||||
{ name: 'mobile-chrome', use: { ...devices['Pixel 5'] } },
|
||||
],
|
||||
})
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 3.9 MiB |
@@ -0,0 +1,37 @@
|
||||
name: biteplan
|
||||
description: App per meal planning, conversione crudo/cotto e lista della spesa.
|
||||
publish_to: 'none'
|
||||
version: 2.0.0+1
|
||||
|
||||
environment:
|
||||
sdk: '>=3.3.0 <4.0.0'
|
||||
|
||||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
provider: ^6.1.2
|
||||
shared_preferences: ^2.3.2
|
||||
qr_flutter: ^4.1.0
|
||||
mobile_scanner: ^5.0.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
integration_test:
|
||||
sdk: flutter
|
||||
flutter_lints: ^4.0.0
|
||||
flutter_launcher_icons: ^0.14.1
|
||||
|
||||
flutter_launcher_icons:
|
||||
android: true
|
||||
ios: false
|
||||
image_path: "assets/icon-only.png"
|
||||
min_sdk_android: 21
|
||||
adaptive_icon_background: "#2d6a4f"
|
||||
adaptive_icon_foreground: "assets/icon-only.png"
|
||||
|
||||
flutter:
|
||||
uses-material-design: true
|
||||
assets:
|
||||
- assets/data/conversions.json
|
||||
- assets/icon-only.png
|
||||
@@ -1,11 +1,11 @@
|
||||
# SOP — BitePlan (Vue 3 + Vite → Capacitor Android)
|
||||
# SOP — BitePlan (Flutter → Android APK)
|
||||
|
||||
## Scope
|
||||
|
||||
App mobile-first con tre funzionalità:
|
||||
App mobile Android con tre funzionalità:
|
||||
|
||||
1. **Meal Planner** — pianificazione settimanale (7 giorni × 3 pasti: colazione, pranzo, cena). Ogni pasto contiene una lista di voci testuali liberamente modificabili.
|
||||
2. **Conversione crudo/cotto** — calcolo del peso cotto a partire dal peso crudo (e viceversa) tramite coefficienti di resa definiti in un JSON interno.
|
||||
2. **Conversione crudo/cotto** — calcolo del peso cotto a partire dal peso crudo (e viceversa) tramite coefficienti di resa definiti in un asset JSON interno.
|
||||
3. **Lista della spesa** — checklist con aggiunta, spunta e rimozione elementi.
|
||||
|
||||
---
|
||||
@@ -14,130 +14,158 @@ App mobile-first con tre funzionalità:
|
||||
|
||||
| Livello | Scelta |
|
||||
|---|---|
|
||||
| Frontend | Vue 3 + Vite |
|
||||
| Stato | Composables (no store) |
|
||||
| Persistenza | LocalStorage |
|
||||
| UI | CSS base mobile-first |
|
||||
| Mobile (fase successiva) | Capacitor Android |
|
||||
| Build riproducibile (opzionale) | Docker |
|
||||
|
||||
Sviluppo iniziale: `npm run dev` + Chrome DevTools modalità mobile.
|
||||
| Framework | Flutter (Dart) |
|
||||
| Stato | Provider o Riverpod |
|
||||
| Persistenza | shared_preferences |
|
||||
| UI | Material 3, mobile-first |
|
||||
| Build APK | Docker (container build) |
|
||||
| Sviluppo | Docker + noVNC (container dev con GUI) |
|
||||
|
||||
---
|
||||
|
||||
## Struttura progetto
|
||||
|
||||
```
|
||||
src/
|
||||
lib/
|
||||
├── main.dart
|
||||
├── app.dart # MaterialApp, routing, BottomNavigationBar
|
||||
├── pages/
|
||||
│ ├── MealPlanner.vue
|
||||
│ ├── Converter.vue
|
||||
│ └── ShoppingList.vue
|
||||
├── components/
|
||||
│ ├── BottomNav.vue
|
||||
│ ├── MealCard.vue
|
||||
│ └── CheckboxItem.vue
|
||||
├── data/
|
||||
│ └── conversions.json
|
||||
├── utils/
|
||||
│ ├── conversion.js
|
||||
│ └── storage.js
|
||||
└── App.vue
|
||||
│ ├── meal_planner_page.dart
|
||||
│ ├── converter_page.dart
|
||||
│ └── shopping_list_page.dart
|
||||
├── widgets/
|
||||
│ ├── meal_card.dart # Accordion giorno
|
||||
│ └── checkbox_item.dart
|
||||
├── models/
|
||||
│ ├── meal_plan.dart
|
||||
│ └── shopping_item.dart
|
||||
├── services/
|
||||
│ ├── storage_service.dart # Wrapper shared_preferences
|
||||
│ └── conversion_service.dart # rawToCooked / cookedToRaw
|
||||
└── data/
|
||||
└── conversions.json # Asset: 50+ alimenti × metodi cottura
|
||||
|
||||
docker/
|
||||
├── dev/
|
||||
│ ├── Dockerfile # Flutter SDK + Android SDK + Xvfb + noVNC
|
||||
│ └── docker-compose.yml # Porta 6080 → noVNC, volume su ./
|
||||
├── build/
|
||||
│ ├── Dockerfile # Flutter SDK + Android SDK headless
|
||||
│ └── build.sh # flutter build apk --release + firma
|
||||
pubspec.yaml
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Fase 1 — Setup
|
||||
## Container dev (noVNC)
|
||||
|
||||
Il container di sviluppo espone un desktop virtuale via browser, utile per eseguire l'emulatore Android o Android Studio senza installare nulla sull'host.
|
||||
|
||||
**Stack interno**: Ubuntu + Flutter SDK + Android SDK + Xvfb + noVNC + websockify.
|
||||
|
||||
```bash
|
||||
npm create vue@latest biteplan
|
||||
cd biteplan
|
||||
npm install
|
||||
npm run dev
|
||||
# Avvio
|
||||
cd docker/dev
|
||||
docker compose up
|
||||
|
||||
# Accesso GUI
|
||||
# → http://localhost:6080 (noVNC, nessuna password)
|
||||
```
|
||||
|
||||
Test mobile: Chrome → F12 → viewport 360×640.
|
||||
Il volume mappa `./` (root del progetto) in `/workspace` nel container, quindi le modifiche ai file sono bidirezionali in tempo reale.
|
||||
|
||||
**Workflow nel container**:
|
||||
1. Aprire un terminale nel desktop noVNC
|
||||
2. `cd /workspace && flutter pub get`
|
||||
3. Avviare l'emulatore o collegare device via ADB
|
||||
4. `flutter run`
|
||||
|
||||
---
|
||||
|
||||
## Fase 2 — Meal Planner
|
||||
## Container build (APK)
|
||||
|
||||
**Struttura dati**
|
||||
Build headless riproducibile, senza GUI.
|
||||
|
||||
```js
|
||||
{
|
||||
lunedi: { colazione: [], pranzo: [], cena: [] },
|
||||
martedi: { ... }
|
||||
```bash
|
||||
# Debug
|
||||
bash docker/build/build.sh
|
||||
|
||||
# Release firmato
|
||||
bash docker/build/build.sh --release
|
||||
```
|
||||
|
||||
Pipeline interna:
|
||||
1. `flutter pub get`
|
||||
2. `flutter build apk --release` → `build/app/outputs/flutter-apk/app-release.apk`
|
||||
3. Firma con `apksigner` (keystore montato come volume o secret)
|
||||
4. `zipalign`
|
||||
|
||||
L'APK firmato viene copiato in `dist/` nella root del progetto.
|
||||
|
||||
---
|
||||
|
||||
## Modello dati
|
||||
|
||||
**Meal Plan**
|
||||
```dart
|
||||
class MealPlan {
|
||||
final Map<String, DayPlan> days; // 'lunedi' … 'domenica'
|
||||
}
|
||||
|
||||
class DayPlan {
|
||||
final List<String> colazione;
|
||||
final List<String> pranzo;
|
||||
final List<String> cena;
|
||||
}
|
||||
```
|
||||
|
||||
**Funzionalità**
|
||||
- Aggiungere/rimuovere voci per ogni pasto
|
||||
- Visualizzare l'intera settimana
|
||||
- Salvataggio automatico su LocalStorage
|
||||
**Shopping Item**
|
||||
```dart
|
||||
class ShoppingItem {
|
||||
final String id;
|
||||
final String name;
|
||||
bool checked;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Fase 3 — Conversione crudo/cotto
|
||||
|
||||
**JSON** — `src/data/conversions.json`
|
||||
## Conversione crudo/cotto
|
||||
|
||||
**Asset** — `lib/data/conversions.json`
|
||||
```json
|
||||
{
|
||||
"pollo": { "forno": { "yield": 0.75 }, "padella": { "yield": 0.70 } },
|
||||
"riso": { "bollito": { "yield": 2.5 } }
|
||||
"riso basmati": { "bollitura": { "yield": 3.00 } }
|
||||
}
|
||||
```
|
||||
|
||||
`yield = peso_cotto / peso_crudo`
|
||||
|
||||
**Funzioni** — `src/utils/conversion.js`
|
||||
|
||||
```js
|
||||
export const rawToCooked = (food, method, raw, db) => raw * db[food][method].yield
|
||||
export const cookedToRaw = (food, method, cooked, db) => cooked / db[food][method].yield
|
||||
**Service** — `lib/services/conversion_service.dart`
|
||||
```dart
|
||||
double rawToCooked(double raw, double yield) => raw * yield;
|
||||
double cookedToRaw(double cooked, double yield) => cooked / yield;
|
||||
```
|
||||
|
||||
**UX**: ricerca testuale sull'alimento → selezione alimento+metodo → input grammi → risultato in tempo reale.
|
||||
|
||||
Esempio: 140 g pollo crudo → 105 g cotti al forno.
|
||||
UX: ricerca testuale → selezione alimento + metodo → input grammi → risultato in tempo reale.
|
||||
|
||||
---
|
||||
|
||||
## Fase 4 — Lista della spesa
|
||||
## Persistenza
|
||||
|
||||
**Struttura dati**
|
||||
|
||||
```js
|
||||
[{ id, name, checked }]
|
||||
```
|
||||
|
||||
**Funzionalità**: aggiungi, spunta, elimina, svuota lista.
|
||||
`StorageService` wrappa `shared_preferences`:
|
||||
- Meal plan serializzato come JSON stringa sotto chiave `meals`
|
||||
- Lista spesa serializzata come JSON stringa sotto chiave `shopping_list`
|
||||
|
||||
---
|
||||
|
||||
## Fase 5 — UI Mobile
|
||||
## UI
|
||||
|
||||
Navigazione bottom bar: Pasti | Converti | Spesa.
|
||||
|
||||
Linee guida: bottoni min 44px, input semplici, una funzione per schermata.
|
||||
|
||||
---
|
||||
|
||||
## Fase 6 — Android (Capacitor)
|
||||
|
||||
```bash
|
||||
npm install @capacitor/core @capacitor/cli
|
||||
npx cap init
|
||||
npx cap add android
|
||||
npm run build && npx cap sync && npx cap run android
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Fase 7 — Docker (opzionale)
|
||||
|
||||
Pipeline: install → build frontend → cap sync → gradle build APK.
|
||||
- `BottomNavigationBar` con 3 tab: Pasti | Converti | Spesa
|
||||
- Material 3, `ColorScheme.fromSeed(seedColor: Color(0xFF2d6a4f))`
|
||||
- Target touch minimo 48×48 dp (Material baseline)
|
||||
- Orientamento bloccato in portrait (`SystemChrome.setPreferredOrientations`)
|
||||
|
||||
---
|
||||
|
||||
@@ -145,16 +173,5 @@ Pipeline: install → build frontend → cap sync → gradle build APK.
|
||||
|
||||
- Modifica coefficienti di conversione in-app
|
||||
- Calcolo kcal
|
||||
- Generazione automatica lista spesa dai pasti pianificati
|
||||
- Condivisione piano via QR code
|
||||
- Sync cloud
|
||||
|
||||
---
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] Meal planner funzionante
|
||||
- [ ] Conversioni corrette
|
||||
- [ ] JSON alimenti presente (12+ voci)
|
||||
- [ ] Lista spesa funzionante
|
||||
- [ ] Persistenza attiva
|
||||
- [ ] APK testabile
|
||||
|
||||
-113
@@ -1,113 +0,0 @@
|
||||
<template>
|
||||
<div id="app-root">
|
||||
<MealPlanner v-if="page === 'meal'" @go-shop="page = 'shop'" />
|
||||
<Converter v-else-if="page === 'convert'" />
|
||||
<ShoppingList v-else-if="page === 'shop'" />
|
||||
<BottomNav v-model="page" />
|
||||
|
||||
<!-- bottone info fisso in alto a destra -->
|
||||
<button class="btn-info" @click="showInfo = true" aria-label="Informazioni app">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<line x1="12" y1="16" x2="12" y2="12"/>
|
||||
<line x1="12" y1="8" x2="12.01" y2="8"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<InfoPanel v-model="showInfo" @open-docs="showDocs = true" />
|
||||
<DocsPanel v-model="showDocs" />
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import BottomNav from './components/BottomNav.vue'
|
||||
import InfoPanel from './components/InfoPanel.vue'
|
||||
import DocsPanel from './components/DocsPanel.vue'
|
||||
import MealPlanner from './pages/MealPlanner.vue'
|
||||
import Converter from './pages/Converter.vue'
|
||||
import ShoppingList from './pages/ShoppingList.vue'
|
||||
|
||||
const page = ref('meal')
|
||||
const showInfo = ref(false)
|
||||
const showDocs = ref(false)
|
||||
|
||||
function fixOrientation() {
|
||||
const isLandscape = window.innerWidth > window.innerHeight
|
||||
// screen.orientation.angle potrebbe non essere ancora aggiornato o non disponibile:
|
||||
// fallback basato su dimensioni finestra
|
||||
const angle = screen.orientation?.angle ?? (isLandscape ? 90 : 0)
|
||||
const root = document.getElementById('app-root')
|
||||
if (!root) return
|
||||
if (isLandscape) {
|
||||
const w = window.innerWidth
|
||||
const h = window.innerHeight
|
||||
root.style.cssText = `
|
||||
position: fixed;
|
||||
width: ${h}px;
|
||||
height: ${w}px;
|
||||
top: ${(h - w) / 2}px;
|
||||
left: ${(w - h) / 2}px;
|
||||
transform: rotate(${angle === 90 ? -90 : 90}deg);
|
||||
transform-origin: center center;
|
||||
`
|
||||
} else {
|
||||
root.style.cssText = ''
|
||||
}
|
||||
}
|
||||
|
||||
// orientationchange si triggera prima che angle/dimensioni siano aggiornati:
|
||||
// aspettiamo un tick
|
||||
function onOrientationChange() {
|
||||
setTimeout(fixOrientation, 50)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fixOrientation()
|
||||
window.addEventListener('orientationchange', onOrientationChange)
|
||||
window.addEventListener('resize', fixOrientation)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('orientationchange', onOrientationChange)
|
||||
window.removeEventListener('resize', fixOrientation)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
#app-root {
|
||||
height: 100dvh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.btn-info {
|
||||
position: fixed;
|
||||
top: calc(14px + env(safe-area-inset-top));
|
||||
right: 16px;
|
||||
/* centra rispetto al max-width del layout */
|
||||
max-width: calc(480px);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-muted);
|
||||
min-height: 36px;
|
||||
min-width: 36px;
|
||||
padding: 0;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: var(--shadow-sm);
|
||||
border: 1px solid var(--color-border);
|
||||
z-index: 50;
|
||||
transition: color var(--transition), box-shadow var(--transition);
|
||||
}
|
||||
|
||||
.btn-info:active {
|
||||
color: var(--color-primary);
|
||||
box-shadow: var(--shadow-md);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
</style>
|
||||
@@ -1,110 +0,0 @@
|
||||
<template>
|
||||
<nav class="bottom-nav">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.id"
|
||||
:class="['nav-btn', { active: modelValue === tab.id }]"
|
||||
@click="$emit('update:modelValue', tab.id)"
|
||||
:aria-label="tab.label"
|
||||
>
|
||||
<span class="nav-icon" v-html="tab.icon" />
|
||||
<span class="nav-label">{{ tab.label }}</span>
|
||||
</button>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({ modelValue: String })
|
||||
defineEmits(['update:modelValue'])
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
id: 'meal',
|
||||
label: 'Pasti',
|
||||
icon: `<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="3" y="4" width="18" height="18" rx="2"/>
|
||||
<line x1="16" y1="2" x2="16" y2="6"/>
|
||||
<line x1="8" y1="2" x2="8" y2="6"/>
|
||||
<line x1="3" y1="10" x2="21" y2="10"/>
|
||||
</svg>`,
|
||||
},
|
||||
{
|
||||
id: 'convert',
|
||||
label: 'Converti',
|
||||
icon: `<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="17 1 21 5 17 9"/>
|
||||
<path d="M3 11V9a4 4 0 014-4h14"/>
|
||||
<polyline points="7 23 3 19 7 15"/>
|
||||
<path d="M21 13v2a4 4 0 01-4 4H3"/>
|
||||
</svg>`,
|
||||
},
|
||||
{
|
||||
id: 'shop',
|
||||
label: 'Spesa',
|
||||
icon: `<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M6 2L3 6v14a2 2 0 002 2h14a2 2 0 002-2V6l-3-4z"/>
|
||||
<line x1="3" y1="6" x2="21" y2="6"/>
|
||||
<path d="M16 10a4 4 0 01-8 0"/>
|
||||
</svg>`,
|
||||
},
|
||||
]
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.bottom-nav {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
height: var(--nav-height);
|
||||
background: var(--color-surface);
|
||||
border-top: 1px solid var(--color-border);
|
||||
display: flex;
|
||||
z-index: 100;
|
||||
box-shadow: 0 -4px 16px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.nav-btn {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 3px;
|
||||
background: none;
|
||||
border-radius: 0;
|
||||
color: var(--color-muted);
|
||||
min-height: unset;
|
||||
padding: 0;
|
||||
position: relative;
|
||||
transition: color var(--transition);
|
||||
}
|
||||
|
||||
.nav-btn.active {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
/* indicatore attivo: pill sopra l'icona — segnala la tab senza aggressività */
|
||||
.nav-btn.active::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
width: 28px;
|
||||
height: 3px;
|
||||
background: var(--color-primary);
|
||||
border-radius: var(--radius-full);
|
||||
}
|
||||
|
||||
.nav-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.nav-label {
|
||||
font-size: 0.68rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
@@ -1,81 +0,0 @@
|
||||
<template>
|
||||
<li class="checkbox-item" :class="{ checked: item.checked }">
|
||||
<label class="item-label">
|
||||
<input type="checkbox" :checked="item.checked" @change="$emit('toggle')" />
|
||||
<span class="item-name">{{ item.name }}</span>
|
||||
</label>
|
||||
<button class="btn-remove" @click="$emit('remove')" :aria-label="`Rimuovi ${item.name}`">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
</button>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({ item: Object })
|
||||
defineEmits(['toggle', 'remove'])
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.checkbox-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 4px 4px 4px 14px;
|
||||
border: 1.5px solid var(--color-border);
|
||||
box-shadow: var(--shadow-sm);
|
||||
transition: background var(--transition), border-color var(--transition);
|
||||
}
|
||||
|
||||
.checkbox-item.checked {
|
||||
background: var(--color-bg);
|
||||
border-color: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.item-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
cursor: pointer;
|
||||
flex: 1;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
input[type="checkbox"] {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
cursor: pointer;
|
||||
accent-color: var(--color-primary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.item-name {
|
||||
font-size: 1rem;
|
||||
transition: color var(--transition);
|
||||
}
|
||||
|
||||
.checked .item-name {
|
||||
text-decoration: line-through;
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
/* touch target pieno 44×44px */
|
||||
.btn-remove {
|
||||
background: none;
|
||||
color: var(--color-muted);
|
||||
min-height: 44px;
|
||||
min-width: 44px;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--radius-sm);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btn-remove:active { color: var(--color-danger); }
|
||||
</style>
|
||||
@@ -1,523 +0,0 @@
|
||||
<template>
|
||||
<Transition name="slide-right">
|
||||
<div v-if="modelValue" class="docs-panel" role="dialog" aria-label="Guida utente">
|
||||
|
||||
<!-- Header sticky: back arrow + titolo centrato + spacer bilanciante -->
|
||||
<div class="docs-header">
|
||||
<button class="btn-back" @click="$emit('update:modelValue', false)" aria-label="Torna indietro">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="15 18 9 12 15 6"/>
|
||||
</svg>
|
||||
</button>
|
||||
<span class="docs-title">Guida</span>
|
||||
<!-- spacer identico al btn-back per centrare il titolo -->
|
||||
<div aria-hidden="true" class="header-spacer" />
|
||||
</div>
|
||||
|
||||
<!-- Pill nav scrollabile — IntersectionObserver aggiorna la pill attiva -->
|
||||
<div class="section-nav" role="tablist" aria-label="Sezioni">
|
||||
<button
|
||||
v-for="s in sections"
|
||||
:key="s.id"
|
||||
:class="['nav-pill', { active: activeSection === s.id }]"
|
||||
role="tab"
|
||||
:aria-selected="activeSection === s.id"
|
||||
@click="scrollToSection(s.id)"
|
||||
>{{ s.label }}</button>
|
||||
</div>
|
||||
|
||||
<!-- Contenuto scorrevole -->
|
||||
<div class="docs-scroll" ref="scrollEl">
|
||||
|
||||
<!-- ── Pasti ──────────────────────────────── -->
|
||||
<section data-section="pasti">
|
||||
<div class="section-head">
|
||||
<span class="section-icon" aria-hidden="true">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="3" y="4" width="18" height="18" rx="2"/>
|
||||
<line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/>
|
||||
<line x1="3" y1="10" x2="21" y2="10"/>
|
||||
</svg>
|
||||
</span>
|
||||
<h2 class="section-heading">Pasti</h2>
|
||||
</div>
|
||||
|
||||
<div class="doc-card">
|
||||
<h3 class="card-title">Aggiungere un alimento</h3>
|
||||
<ol class="steps">
|
||||
<li>Tocca il giorno per espandere la card</li>
|
||||
<li>Scegli il pasto: <strong>Colazione</strong>, <strong>Pranzo</strong> o <strong>Cena</strong></li>
|
||||
<li>Scrivi il nome nel campo di testo</li>
|
||||
<li>Premi <kbd>+</kbd> o Invio per aggiungerlo</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div class="doc-card tip">
|
||||
<p class="tip-label" aria-hidden="true">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12.01" y2="8"/><line x1="12" y1="12" x2="12" y2="16"/>
|
||||
</svg>
|
||||
Suggerimento
|
||||
</p>
|
||||
<p>Premi <strong>Genera lista della spesa</strong> in fondo alla pagina per importare automaticamente tutti gli alimenti della settimana, senza duplicati.</p>
|
||||
</div>
|
||||
|
||||
<div class="doc-card">
|
||||
<h3 class="card-title">Rimuovere un alimento</h3>
|
||||
<p>Tocca il pulsante <strong>×</strong> a destra dell'elemento.</p>
|
||||
</div>
|
||||
|
||||
<div class="doc-card">
|
||||
<h3 class="card-title">Condividere il piano</h3>
|
||||
<ol class="steps">
|
||||
<li>Premi <strong>Condividi</strong> in fondo alla pagina</li>
|
||||
<li>Viene generato un QR code con l'intero piano settimanale</li>
|
||||
<li>Fai scansionare il codice dall'altro dispositivo</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div class="doc-card">
|
||||
<h3 class="card-title">Ricevere un piano</h3>
|
||||
<ol class="steps">
|
||||
<li>Premi <strong>Scansiona QR</strong> in fondo alla pagina</li>
|
||||
<li>Consenti l'accesso alla fotocamera quando richiesto</li>
|
||||
<li>Inquadra il QR code — il piano viene importato automaticamente</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div class="doc-card">
|
||||
<h3 class="card-title">Salvataggio automatico</h3>
|
||||
<p>I dati vengono salvati automaticamente sul dispositivo. Non serve premere nessun tasto.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── Converti ───────────────────────────── -->
|
||||
<section data-section="converti">
|
||||
<div class="section-head">
|
||||
<span class="section-icon" aria-hidden="true">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="17 1 21 5 17 9"/>
|
||||
<path d="M3 11V9a4 4 0 014-4h14"/>
|
||||
<polyline points="7 23 3 19 7 15"/>
|
||||
<path d="M21 13v2a4 4 0 01-4 4H3"/>
|
||||
</svg>
|
||||
</span>
|
||||
<h2 class="section-heading">Converti</h2>
|
||||
</div>
|
||||
|
||||
<div class="doc-card">
|
||||
<h3 class="card-title">Come usarlo</h3>
|
||||
<ol class="steps">
|
||||
<li>Cerca l'alimento nel campo (es. <em>pollo</em>, <em>riso</em>)</li>
|
||||
<li>Seleziona il metodo di cottura dall'elenco</li>
|
||||
<li>Inserisci il peso in grammi</li>
|
||||
<li>Il risultato appare in tempo reale</li>
|
||||
<li>Premi <strong>⇄</strong> per invertire crudo ↔ cotto</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div class="doc-card">
|
||||
<h3 class="card-title">Alimenti disponibili</h3>
|
||||
<div class="category-table" role="list">
|
||||
<div class="cat-row" v-for="cat in categories" :key="cat.label" role="listitem">
|
||||
<span class="cat-label">{{ cat.label }}</span>
|
||||
<span class="cat-items">{{ cat.items }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="methods-row" aria-label="Metodi di cottura disponibili">
|
||||
<span class="method-chip" v-for="m in cookingMethods" :key="m">{{ m }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── Spesa ──────────────────────────────── -->
|
||||
<section data-section="spesa">
|
||||
<div class="section-head">
|
||||
<span class="section-icon" aria-hidden="true">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M6 2L3 6v14a2 2 0 002 2h14a2 2 0 002-2V6l-3-4z"/>
|
||||
<line x1="3" y1="6" x2="21" y2="6"/>
|
||||
<path d="M16 10a4 4 0 01-8 0"/>
|
||||
</svg>
|
||||
</span>
|
||||
<h2 class="section-heading">Spesa</h2>
|
||||
</div>
|
||||
|
||||
<div class="doc-card">
|
||||
<h3 class="card-title">Aggiungere un elemento</h3>
|
||||
<p>Scrivi il nome nel campo in alto e premi <kbd>+</kbd> o Invio.</p>
|
||||
</div>
|
||||
|
||||
<div class="doc-card tip">
|
||||
<p class="tip-label" aria-hidden="true">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12.01" y2="8"/><line x1="12" y1="12" x2="12" y2="16"/>
|
||||
</svg>
|
||||
Suggerimento
|
||||
</p>
|
||||
<p>Vai alla tab <strong>Pasti</strong> e premi <strong>Genera lista della spesa</strong> per importare automaticamente gli alimenti pianificati.</p>
|
||||
</div>
|
||||
|
||||
<div class="doc-card">
|
||||
<h3 class="card-title">Spuntare un elemento</h3>
|
||||
<p>Tocca la casella a sinistra. Gli elementi completati vengono spostati in una sezione separata con testo barrato.</p>
|
||||
</div>
|
||||
|
||||
<div class="doc-card">
|
||||
<h3 class="card-title">Rimuovere / svuotare</h3>
|
||||
<p>Tocca <strong>×</strong> per rimuovere un elemento singolo, oppure <strong>Svuota lista</strong> in fondo per eliminare tutto (richiede conferma).</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="docs-footer">BitePlan · v{{ version }} · Davide Grilli</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch, nextTick, onUnmounted } from 'vue'
|
||||
import pkg from '../../package.json'
|
||||
|
||||
const props = defineProps({ modelValue: Boolean })
|
||||
defineEmits(['update:modelValue'])
|
||||
|
||||
const version = pkg.version
|
||||
const scrollEl = ref(null)
|
||||
const activeSection = ref('pasti')
|
||||
let observer = null
|
||||
|
||||
const sections = [
|
||||
{ id: 'pasti', label: 'Pasti' },
|
||||
{ id: 'converti', label: 'Converti' },
|
||||
{ id: 'spesa', label: 'Spesa' },
|
||||
]
|
||||
|
||||
const categories = [
|
||||
{ label: 'Cereali e pasta', items: 'Riso (4 varietà), pasta, farro, orzo, quinoa, cous cous' },
|
||||
{ label: 'Legumi secchi', items: 'Ceci, fagioli, lenticchie' },
|
||||
{ label: 'Verdure', items: 'Carote, zucchine, patate, spinaci, broccoli, asparagi e altri' },
|
||||
{ label: 'Carni', items: 'Pollo petto, tacchino fesa, hamburger, vitello' },
|
||||
{ label: 'Pesce', items: 'Tonno, merluzzo, spigola, sogliola' },
|
||||
{ label: 'Uova', items: 'Uovo al tegamino, frittata' },
|
||||
]
|
||||
|
||||
const cookingMethods = ['Bollitura', 'Padella', 'Forno', 'Friggitrice ad aria']
|
||||
|
||||
watch(() => props.modelValue, async (open) => {
|
||||
if (open) {
|
||||
await nextTick()
|
||||
setupObserver()
|
||||
activeSection.value = 'pasti'
|
||||
scrollEl.value?.scrollTo({ top: 0 })
|
||||
} else {
|
||||
observer?.disconnect()
|
||||
observer = null
|
||||
}
|
||||
})
|
||||
|
||||
function setupObserver() {
|
||||
if (!scrollEl.value) return
|
||||
observer?.disconnect()
|
||||
// rootMargin: entra in zona attiva quando la sezione raggiunge il 20% dall'alto del container
|
||||
observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
const visible = entries.filter(e => e.isIntersecting)
|
||||
if (visible.length) activeSection.value = visible[0].target.dataset.section
|
||||
},
|
||||
{ root: scrollEl.value, rootMargin: '0px 0px -65% 0px', threshold: 0 }
|
||||
)
|
||||
scrollEl.value.querySelectorAll('[data-section]').forEach(el => observer.observe(el))
|
||||
}
|
||||
|
||||
function scrollToSection(id) {
|
||||
const el = scrollEl.value?.querySelector(`[data-section="${id}"]`)
|
||||
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
}
|
||||
|
||||
onUnmounted(() => observer?.disconnect())
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* ── Panel container ─────────────────────────── */
|
||||
.docs-panel {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
max-width: 480px;
|
||||
margin: 0 auto;
|
||||
background: var(--color-bg);
|
||||
z-index: 300;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── Animazione slide da destra ──────────────── */
|
||||
/* enter ease-out: risposta immediata, decelera all'arrivo (feel nativo) */
|
||||
.slide-right-enter-active { transition: transform 320ms cubic-bezier(0.25, 1, 0.5, 1); }
|
||||
/* leave ease-in: parte lentamente, accelera — chiusura decisa */
|
||||
.slide-right-leave-active { transition: transform 240ms cubic-bezier(0.55, 0, 1, 0.45); }
|
||||
.slide-right-enter-from,
|
||||
.slide-right-leave-to { transform: translateX(100%); }
|
||||
|
||||
/* ── Header ──────────────────────────────────── */
|
||||
.docs-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
/* safe-area-inset-top: evita sovrapposizione con status bar su iOS */
|
||||
padding: calc(env(safe-area-inset-top, 0px) + 10px) 8px 10px;
|
||||
background: var(--color-surface);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btn-back {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 44px;
|
||||
min-width: 44px;
|
||||
background: none;
|
||||
color: var(--color-primary);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0;
|
||||
transition: background var(--transition);
|
||||
}
|
||||
|
||||
.btn-back:active {
|
||||
background: var(--color-primary-muted);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.docs-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-text);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
/* spacer uguale al btn-back per centrare il titolo otticamente */
|
||||
.header-spacer { width: 44px; }
|
||||
|
||||
/* ── Navigazione sezioni (pill) ──────────────── */
|
||||
.section-nav {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
padding: 10px 16px;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
background: var(--color-surface);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.section-nav::-webkit-scrollbar { display: none; }
|
||||
|
||||
.nav-pill {
|
||||
flex-shrink: 0;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
padding: 0 14px;
|
||||
min-height: 32px;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--color-bg);
|
||||
color: var(--color-muted);
|
||||
border: 1px solid var(--color-border);
|
||||
transition: background var(--transition), color var(--transition), border-color var(--transition);
|
||||
}
|
||||
|
||||
.nav-pill.active {
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
/* ── Scroll container ────────────────────────── */
|
||||
.docs-scroll {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
padding-bottom: calc(env(safe-area-inset-bottom, 0px) + 40px);
|
||||
}
|
||||
|
||||
/* ── Section header ──────────────────────────── */
|
||||
section { padding-top: 4px; }
|
||||
section + section { margin-top: 8px; }
|
||||
|
||||
.section-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 24px 16px 10px;
|
||||
}
|
||||
|
||||
.section-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
/* quadratino con sfondo primario muted — identifica visivamente la sezione */
|
||||
background: var(--color-primary-muted);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-primary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.section-heading {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
/* ── Card contenuto ──────────────────────────── */
|
||||
.doc-card {
|
||||
margin: 0 16px 8px;
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius);
|
||||
padding: 14px 16px;
|
||||
border: 1px solid var(--color-border);
|
||||
box-shadow: var(--shadow-sm);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.55;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--color-muted);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
/* ── Card tip / suggerimento ─────────────────── */
|
||||
/* border-left accent: segnala contenuto accessorio senza competere con le card */
|
||||
.doc-card.tip {
|
||||
background: var(--color-primary-muted);
|
||||
border-color: transparent;
|
||||
border-left: 3px solid var(--color-primary);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.tip-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.07em;
|
||||
color: var(--color-primary);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
/* ── Lista numerata con badge ────────────────── */
|
||||
/* counter CSS invece di <span> inline: mantiene semantica <ol> accessibile */
|
||||
.steps {
|
||||
list-style: none;
|
||||
counter-reset: steps;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 9px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.steps li {
|
||||
counter-increment: steps;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.steps li::before {
|
||||
content: counter(steps);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 22px;
|
||||
height: 22px;
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
border-radius: 50%;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* ── Kbd inline ──────────────────────────────── */
|
||||
kbd {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 1px 7px;
|
||||
background: var(--color-bg);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 5px;
|
||||
font-family: inherit;
|
||||
font-size: 0.85em;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
/* ── Tabella categorie alimenti ──────────────── */
|
||||
.category-table {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
overflow: hidden;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.cat-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
padding: 9px 12px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
font-size: 0.86rem;
|
||||
}
|
||||
|
||||
.cat-row:last-child { border-bottom: none; }
|
||||
|
||||
.cat-label {
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
flex-shrink: 0;
|
||||
min-width: 110px;
|
||||
}
|
||||
|
||||
.cat-items { color: var(--color-muted); font-size: 0.82rem; }
|
||||
|
||||
/* ── Chip metodi cottura ─────────────────────── */
|
||||
.methods-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.method-chip {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
padding: 3px 10px;
|
||||
background: var(--color-bg);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-full);
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
/* ── Footer ──────────────────────────────────── */
|
||||
.docs-footer {
|
||||
margin: 24px 16px 0;
|
||||
text-align: center;
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-muted);
|
||||
opacity: 0.6;
|
||||
}
|
||||
</style>
|
||||
@@ -1,188 +0,0 @@
|
||||
<template>
|
||||
<Transition name="fade">
|
||||
<div v-if="modelValue" class="overlay" @click="$emit('update:modelValue', false)" />
|
||||
</Transition>
|
||||
|
||||
<Transition name="slide-up">
|
||||
<div v-if="modelValue" class="sheet" role="dialog" aria-label="Informazioni app">
|
||||
|
||||
<div class="sheet-handle" />
|
||||
|
||||
<div class="sheet-top">
|
||||
<div class="app-identity">
|
||||
<img class="app-icon" :src="appIcon" alt="BitePlan" />
|
||||
<div>
|
||||
<div class="app-name">BitePlan</div>
|
||||
<div class="app-version">Versione {{ version }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn-x" @click="$emit('update:modelValue', false)" aria-label="Chiudi">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="info-list">
|
||||
<div class="info-row">
|
||||
<span class="info-label">Autore</span>
|
||||
<span class="info-value">Davide Grilli</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Licenza</span>
|
||||
<span class="info-value">EUPL v1.2</span>
|
||||
</div>
|
||||
<button class="info-row info-row--btn" @click="$emit('open-docs')" aria-label="Apri guida">
|
||||
<span class="info-label">Guida</span>
|
||||
<span class="info-link">
|
||||
Apri
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<polyline points="9 18 15 12 9 6"/>
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import pkg from '../../package.json'
|
||||
import appIcon from '../../assets/icon-only.png'
|
||||
defineProps({ modelValue: Boolean })
|
||||
defineEmits(['update:modelValue', 'open-docs'])
|
||||
const version = pkg.version
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
z-index: 200;
|
||||
}
|
||||
|
||||
.sheet {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius) var(--radius) 0 0;
|
||||
padding: 12px 20px 48px;
|
||||
z-index: 201;
|
||||
box-shadow: 0 -8px 32px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.sheet-handle {
|
||||
width: 36px;
|
||||
height: 4px;
|
||||
background: var(--color-border);
|
||||
border-radius: var(--radius-full);
|
||||
margin: 0 auto 24px;
|
||||
}
|
||||
|
||||
/* header: icona app + nome/versione + X */
|
||||
.sheet-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.app-identity {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.app-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 12px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.app-name {
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.app-version {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-muted);
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.btn-x {
|
||||
background: var(--color-bg);
|
||||
color: var(--color-muted);
|
||||
min-height: 36px;
|
||||
min-width: 36px;
|
||||
padding: 0;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
/* lista righe info */
|
||||
.info-list {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.info-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 14px 16px;
|
||||
background: var(--color-surface);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.info-row:last-child { border-bottom: none; }
|
||||
|
||||
/* riga cliccabile: reset button, mantiene layout identico alle righe statiche */
|
||||
.info-row--btn {
|
||||
width: 100%;
|
||||
min-height: unset;
|
||||
border-radius: 0;
|
||||
background: var(--color-surface);
|
||||
text-align: left;
|
||||
}
|
||||
.info-row--btn:active { background: var(--color-bg); opacity: 1; }
|
||||
|
||||
.info-label {
|
||||
font-size: 0.9rem;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.info-value {
|
||||
font-size: 0.9rem;
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.info-link {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-primary);
|
||||
text-decoration: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
/* transizioni */
|
||||
.fade-enter-active, .fade-leave-active { transition: opacity 200ms ease; }
|
||||
.fade-enter-from, .fade-leave-to { opacity: 0; }
|
||||
|
||||
.slide-up-enter-active, .slide-up-leave-active { transition: transform 250ms ease; }
|
||||
.slide-up-enter-from, .slide-up-leave-to { transform: translateX(-50%) translateY(100%); }
|
||||
</style>
|
||||
@@ -1,197 +0,0 @@
|
||||
<template>
|
||||
<div class="meal-card" :class="{ open: isOpen }">
|
||||
<button class="card-header" @click="isOpen = !isOpen" :aria-expanded="isOpen">
|
||||
<span class="day-name">{{ dayName }}</span>
|
||||
<span class="day-summary" v-if="!isOpen && totalItems > 0">{{ totalItems }} {{ totalItems === 1 ? 'voce' : 'voci' }}</span>
|
||||
<span class="chevron" :class="{ rotated: isOpen }">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="6 9 12 15 18 9"/>
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div class="card-body" v-show="isOpen">
|
||||
<div v-for="slot in slots" :key="slot.id" class="meal-slot">
|
||||
<div class="slot-header">
|
||||
<span class="slot-icon">{{ slot.icon }}</span>
|
||||
<h3 class="slot-label">{{ slot.label }}</h3>
|
||||
</div>
|
||||
|
||||
<ul v-if="meals[slot.id].length" class="item-list">
|
||||
<li v-for="(item, idx) in meals[slot.id]" :key="idx" class="item-row">
|
||||
<span class="item-text">{{ item }}</span>
|
||||
<button class="btn-remove" @click="$emit('remove', slot.id, idx)" :aria-label="`Rimuovi ${item}`">✕</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="input-row">
|
||||
<input
|
||||
v-model="inputs[slot.id]"
|
||||
type="text"
|
||||
:placeholder="`Aggiungi a ${slot.label.toLowerCase()}...`"
|
||||
@keyup.enter="submit(slot.id)"
|
||||
/>
|
||||
<button class="btn-add" @click="submit(slot.id)" aria-label="Aggiungi">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
|
||||
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
dayName: String,
|
||||
meals: Object,
|
||||
defaultOpen: { type: Boolean, default: false },
|
||||
})
|
||||
const emit = defineEmits(['add', 'remove'])
|
||||
|
||||
const isOpen = ref(props.defaultOpen)
|
||||
|
||||
const slots = [
|
||||
{ id: 'colazione', label: 'Colazione', icon: '☀️' },
|
||||
{ id: 'pranzo', label: 'Pranzo', icon: '🍽️' },
|
||||
{ id: 'cena', label: 'Cena', icon: '🌙' },
|
||||
]
|
||||
|
||||
const inputs = reactive({ colazione: '', pranzo: '', cena: '' })
|
||||
|
||||
const totalItems = computed(() =>
|
||||
slots.reduce((sum, s) => sum + props.meals[s.id].length, 0)
|
||||
)
|
||||
|
||||
function submit(slotId) {
|
||||
const t = inputs[slotId].trim()
|
||||
if (!t) return
|
||||
emit('add', slotId, t)
|
||||
inputs[slotId] = ''
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.meal-card {
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius);
|
||||
margin-bottom: 10px;
|
||||
box-shadow: var(--shadow-sm);
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 14px 16px;
|
||||
background: none;
|
||||
border-radius: 0;
|
||||
min-height: unset;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.day-name {
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-text);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* numero voci mostrato quando la card è chiusa */
|
||||
.day-summary {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-muted);
|
||||
background: var(--color-primary-muted);
|
||||
color: var(--color-primary);
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--radius-full);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.chevron {
|
||||
color: var(--color-muted);
|
||||
display: flex;
|
||||
transition: transform var(--transition);
|
||||
}
|
||||
|
||||
.chevron.rotated { transform: rotate(180deg); }
|
||||
|
||||
.card-body {
|
||||
padding: 0 16px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.meal-slot { display: flex; flex-direction: column; gap: 6px; }
|
||||
|
||||
.slot-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.slot-icon { font-size: 0.9rem; }
|
||||
|
||||
.slot-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.item-list {
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.item-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: var(--color-bg);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.item-text { font-size: 0.9rem; flex: 1; }
|
||||
|
||||
/* touch target 44px garantito con padding */
|
||||
.btn-remove {
|
||||
background: none;
|
||||
color: var(--color-muted);
|
||||
min-height: 44px;
|
||||
min-width: 44px;
|
||||
padding: 0;
|
||||
font-size: 0.75rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.btn-remove:active { color: var(--color-danger); }
|
||||
|
||||
.input-row { display: flex; gap: 8px; }
|
||||
|
||||
.btn-add {
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
min-width: 48px;
|
||||
min-height: 48px;
|
||||
flex-shrink: 0;
|
||||
padding: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +0,0 @@
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import './style.css'
|
||||
|
||||
createApp(App).mount('#app')
|
||||
@@ -1,381 +0,0 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">Convertitore</h1>
|
||||
<p class="page-subtitle">Calcola il peso cotto dal crudo e viceversa</p>
|
||||
</div>
|
||||
|
||||
<!-- step 1: ricerca -->
|
||||
<div class="search-wrapper">
|
||||
<div class="search-field">
|
||||
<svg class="search-icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
|
||||
</svg>
|
||||
<input
|
||||
v-model="query"
|
||||
type="text"
|
||||
placeholder="Cerca alimento…"
|
||||
@input="onSearch"
|
||||
:disabled="!!selected"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ul v-if="results.length && !selected" class="results-list">
|
||||
<li
|
||||
v-for="r in results"
|
||||
:key="r.key"
|
||||
class="result-item"
|
||||
@click="selectItem(r)"
|
||||
>
|
||||
<span class="result-food">{{ capFirst(r.food) }}</span>
|
||||
<span class="result-method">{{ capFirst(r.method) }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- step 2: converter card -->
|
||||
<div v-if="selected" class="converter-card">
|
||||
<div class="card-top">
|
||||
<div class="food-info">
|
||||
<span class="food-name">{{ capFirst(selected.food) }}</span>
|
||||
<span class="food-sep">·</span>
|
||||
<span class="food-method">{{ capFirst(selected.method) }}</span>
|
||||
</div>
|
||||
<button class="btn-reset" @click="reset" aria-label="Cambia alimento">
|
||||
Cambia
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="card-divider" />
|
||||
|
||||
<!-- riga input → swap → output -->
|
||||
<div class="calc-row">
|
||||
<div class="calc-side input-side">
|
||||
<div class="calc-label">{{ direction === 'rawToCooked' ? 'crudo' : 'cotto' }}</div>
|
||||
<div class="calc-input-wrap">
|
||||
<input
|
||||
v-model.number="grams"
|
||||
type="number"
|
||||
min="0"
|
||||
placeholder="0"
|
||||
class="calc-input"
|
||||
/>
|
||||
<span class="calc-unit">g</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="btn-swap" @click="swapDirection" aria-label="Inverti direzione">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="17 1 21 5 17 9"/>
|
||||
<path d="M3 11V9a4 4 0 014-4h14"/>
|
||||
<polyline points="7 23 3 19 7 15"/>
|
||||
<path d="M21 13v2a4 4 0 01-4 4H3"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div class="calc-side output-side">
|
||||
<div class="calc-label">{{ direction === 'rawToCooked' ? 'cotto' : 'crudo' }}</div>
|
||||
<div class="calc-output">
|
||||
<span v-if="result !== null" class="output-value">{{ result }}</span>
|
||||
<span v-else class="output-placeholder">—</span>
|
||||
<span v-if="result !== null" class="calc-unit">g</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="result !== null" class="card-footer">
|
||||
fattore di resa {{ yieldValue }} · {{ direction === 'rawToCooked' ? 'da mangiare cotti' : 'peso crudo equivalente' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- stato iniziale -->
|
||||
<div v-if="!selected && !results.length && !query" class="hint-state">
|
||||
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
|
||||
</svg>
|
||||
<p>Cerca un alimento per iniziare</p>
|
||||
<p class="hint-sub">es. pollo, riso, zucchine</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import db from '../data/conversions.json'
|
||||
import { rawToCooked, cookedToRaw } from '../utils/conversion.js'
|
||||
|
||||
const query = ref('')
|
||||
const results = ref([])
|
||||
const selected = ref(null)
|
||||
const direction = ref('rawToCooked')
|
||||
const grams = ref(null)
|
||||
|
||||
const yieldValue = computed(() => {
|
||||
if (!selected.value) return ''
|
||||
return db[selected.value.food][selected.value.method].yield
|
||||
})
|
||||
|
||||
const result = computed(() => {
|
||||
if (!selected.value || !grams.value || grams.value <= 0) return null
|
||||
const { food, method } = selected.value
|
||||
const val = direction.value === 'rawToCooked'
|
||||
? rawToCooked(food, method, grams.value, db)
|
||||
: cookedToRaw(food, method, grams.value, db)
|
||||
return Math.round(val * 10) / 10
|
||||
})
|
||||
|
||||
function onSearch() {
|
||||
const q = query.value.toLowerCase().trim()
|
||||
if (!q) { results.value = []; return }
|
||||
const matches = []
|
||||
for (const food of Object.keys(db)) {
|
||||
if (food.includes(q)) {
|
||||
for (const method of Object.keys(db[food])) {
|
||||
matches.push({ key: `${food}-${method}`, food, method })
|
||||
}
|
||||
}
|
||||
}
|
||||
results.value = matches
|
||||
}
|
||||
|
||||
function selectItem(r) {
|
||||
selected.value = r
|
||||
query.value = ''
|
||||
results.value = []
|
||||
grams.value = null
|
||||
}
|
||||
|
||||
function reset() {
|
||||
selected.value = null
|
||||
query.value = ''
|
||||
results.value = []
|
||||
grams.value = null
|
||||
direction.value = 'rawToCooked'
|
||||
}
|
||||
|
||||
function capFirst(s) {
|
||||
return s ? s.charAt(0).toUpperCase() + s.slice(1) : s
|
||||
}
|
||||
|
||||
function swapDirection() {
|
||||
direction.value = direction.value === 'rawToCooked' ? 'cookedToRaw' : 'rawToCooked'
|
||||
grams.value = null
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* ── ricerca ───────────────────────────────────────── */
|
||||
.search-wrapper { margin-bottom: 16px; }
|
||||
|
||||
.search-field {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
position: absolute;
|
||||
left: 14px;
|
||||
color: var(--color-muted);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.search-field input {
|
||||
padding-left: 42px;
|
||||
}
|
||||
|
||||
.results-list {
|
||||
list-style: none;
|
||||
background: var(--color-surface);
|
||||
border: 1.5px solid var(--color-border);
|
||||
border-top: none;
|
||||
border-radius: 0 0 var(--radius) var(--radius);
|
||||
overflow: hidden;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.result-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 16px;
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
transition: background var(--transition);
|
||||
}
|
||||
|
||||
.result-item:last-child { border-bottom: none; }
|
||||
.result-item:active { background: var(--color-bg); }
|
||||
.result-food { font-weight: 600; }
|
||||
.result-method { font-size: 0.85rem; color: var(--color-muted); }
|
||||
|
||||
/* ── converter card ───────────────────────────────── */
|
||||
.converter-card {
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius);
|
||||
border: 1.5px solid var(--color-border);
|
||||
box-shadow: var(--shadow-md);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.food-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.food-name { font-weight: 700; font-size: 1rem; }
|
||||
.food-sep { color: var(--color-border); font-size: 1.1rem; }
|
||||
.food-method { font-size: 0.9rem; color: var(--color-muted); }
|
||||
|
||||
.btn-reset {
|
||||
color: var(--color-primary);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
min-height: unset;
|
||||
padding: 4px 10px;
|
||||
border: 1.5px solid var(--color-primary-muted);
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--color-primary-muted);
|
||||
}
|
||||
|
||||
.card-divider {
|
||||
height: 1px;
|
||||
background: var(--color-border);
|
||||
margin: 0 16px;
|
||||
}
|
||||
|
||||
/* ── riga calcolatrice ───────────────────────────── */
|
||||
.calc-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 20px 16px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.calc-side {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.calc-label {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.calc-input-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
/* override globale per input dentro la card */
|
||||
.calc-input {
|
||||
font-size: 1.6rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
border: none;
|
||||
border-bottom: 2px solid var(--color-border);
|
||||
border-radius: 0;
|
||||
padding: 4px 0;
|
||||
min-height: unset;
|
||||
background: transparent;
|
||||
width: 100%;
|
||||
color: var(--color-text);
|
||||
transition: border-color var(--transition);
|
||||
}
|
||||
|
||||
.calc-input:focus {
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
border-bottom-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.calc-unit {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-muted);
|
||||
align-self: flex-end;
|
||||
padding-bottom: 6px;
|
||||
}
|
||||
|
||||
/* bottone swap centrale */
|
||||
.btn-swap {
|
||||
background: var(--color-bg);
|
||||
color: var(--color-primary);
|
||||
min-height: 44px;
|
||||
min-width: 44px;
|
||||
padding: 0;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
border: 1.5px solid var(--color-border);
|
||||
transition: background var(--transition), transform var(--transition);
|
||||
}
|
||||
|
||||
.btn-swap:active {
|
||||
background: var(--color-primary-muted);
|
||||
transform: rotate(180deg);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* colonna output: underline visivo per simmetria con il lato input */
|
||||
.calc-output {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-height: 44px;
|
||||
border-bottom: 2px solid var(--color-border);
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
|
||||
.output-value {
|
||||
font-size: 1.6rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.03em;
|
||||
color: var(--color-primary);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.output-placeholder {
|
||||
font-size: 1.6rem;
|
||||
font-weight: 300;
|
||||
color: var(--color-border);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* ── footer card ─────────────────────────────────── */
|
||||
.card-footer {
|
||||
padding: 10px 16px 14px;
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-muted);
|
||||
border-top: 1px solid var(--color-border);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ── stato iniziale ──────────────────────────────── */
|
||||
.hint-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 60px 20px;
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.hint-sub { font-size: 0.85rem; opacity: 0.6; }
|
||||
</style>
|
||||
@@ -1,505 +0,0 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">Piano Pasti</h1>
|
||||
<p class="page-subtitle">{{ todayDate }}</p>
|
||||
</div>
|
||||
<MealCard
|
||||
v-for="day in days"
|
||||
:key="day.id"
|
||||
:day-name="day.label"
|
||||
:meals="meals[day.id]"
|
||||
:default-open="day.id === todayId"
|
||||
@add="(slot, text) => addItem(day.id, slot, text)"
|
||||
@remove="(slot, idx) => removeItem(day.id, slot, idx)"
|
||||
/>
|
||||
<button class="btn-generate" @click="generateShopping">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M6 2L3 6v14a2 2 0 002 2h14a2 2 0 002-2V6l-3-4z"/>
|
||||
<line x1="3" y1="6" x2="21" y2="6"/>
|
||||
<path d="M16 10a4 4 0 01-8 0"/>
|
||||
</svg>
|
||||
Genera lista della spesa
|
||||
</button>
|
||||
|
||||
<button v-if="hasMeals" class="btn-clear" @click="clearAll">Svuota piano</button>
|
||||
|
||||
<div class="btn-share-row">
|
||||
<button class="btn-share" @click="openShare">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="3" y="3" width="7" height="7" rx="1"/>
|
||||
<rect x="14" y="3" width="7" height="7" rx="1"/>
|
||||
<rect x="3" y="14" width="7" height="7" rx="1"/>
|
||||
<rect x="14" y="14" width="3" height="3" rx="0.5"/>
|
||||
<rect x="19" y="14" width="2" height="2" rx="0.5"/>
|
||||
<rect x="14" y="19" width="2" height="2" rx="0.5"/>
|
||||
<rect x="18" y="19" width="3" height="2" rx="0.5"/>
|
||||
</svg>
|
||||
Condividi
|
||||
</button>
|
||||
<button class="btn-share btn-share--receive" @click="openScan">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M23 19a2 2 0 01-2 2H3a2 2 0 01-2-2V8a2 2 0 012-2h4l2-3h6l2 3h4a2 2 0 012 2z"/>
|
||||
<circle cx="12" cy="13" r="4"/>
|
||||
</svg>
|
||||
Ricevi
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal: Condividi -->
|
||||
<Teleport to="body">
|
||||
<Transition name="fade">
|
||||
<div v-if="showShareModal" class="qr-overlay" @click.self="closeShare">
|
||||
<div class="qr-sheet" role="dialog" aria-label="Condividi piano pasti" aria-modal="true">
|
||||
<div class="sheet-handle" />
|
||||
<div class="qr-header">
|
||||
<span class="qr-title">Condividi piano</span>
|
||||
<button class="btn-x" @click="closeShare" aria-label="Chiudi">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<p class="qr-hint">Fai scansionare questo codice dall'altro dispositivo</p>
|
||||
<div class="qr-img-wrap">
|
||||
<img v-if="qrDataUrl" :src="qrDataUrl" alt="QR code piano pasti" class="qr-img" />
|
||||
<p v-else-if="qrError" class="qr-error">{{ qrError }}</p>
|
||||
<div v-else class="qr-loading">Generazione in corso…</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
|
||||
<!-- Modal: Ricevi -->
|
||||
<Teleport to="body">
|
||||
<Transition name="fade">
|
||||
<div v-if="showScanModal" class="qr-overlay" @click.self="closeAndStopScan">
|
||||
<div class="qr-sheet" role="dialog" aria-label="Ricevi piano pasti" aria-modal="true">
|
||||
<div class="sheet-handle" />
|
||||
<div class="qr-header">
|
||||
<span class="qr-title">Scansiona QR</span>
|
||||
<button class="btn-x" @click="closeAndStopScan" aria-label="Chiudi">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<p class="qr-hint">Inquadra il codice QR dell'altro dispositivo</p>
|
||||
<div class="scan-wrap">
|
||||
<video ref="videoEl" class="scan-video" autoplay playsinline muted />
|
||||
<canvas ref="canvasEl" class="scan-canvas" aria-hidden="true" />
|
||||
<div class="scan-frame" aria-hidden="true" />
|
||||
</div>
|
||||
<p v-if="scanError" class="qr-error">{{ scanError }}</p>
|
||||
<p v-if="scanSuccess" class="qr-success">Piano ricevuto!</p>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { reactive, watch, ref, computed, onUnmounted, nextTick } from 'vue'
|
||||
import MealCard from '../components/MealCard.vue'
|
||||
import { save, load } from '../utils/storage.js'
|
||||
import QRCode from 'qrcode'
|
||||
import jsQR from 'jsqr'
|
||||
|
||||
const emit = defineEmits(['go-shop'])
|
||||
|
||||
const days = [
|
||||
{ id: 'lunedi', label: 'Lunedì' },
|
||||
{ id: 'martedi', label: 'Martedì' },
|
||||
{ id: 'mercoledi', label: 'Mercoledì' },
|
||||
{ id: 'giovedi', label: 'Giovedì' },
|
||||
{ id: 'venerdi', label: 'Venerdì' },
|
||||
{ id: 'sabato', label: 'Sabato' },
|
||||
{ id: 'domenica', label: 'Domenica' },
|
||||
]
|
||||
|
||||
const todayMap = ['domenica', 'lunedi', 'martedi', 'mercoledi', 'giovedi', 'venerdi', 'sabato']
|
||||
const todayId = todayMap[new Date().getDay()]
|
||||
const todayDate = 'Oggi, ' + new Date().toLocaleDateString('it-IT', { weekday: 'long', day: 'numeric', month: 'long' })
|
||||
|
||||
const defaultMeals = () =>
|
||||
Object.fromEntries(days.map(d => [d.id, { colazione: [], pranzo: [], cena: [] }]))
|
||||
|
||||
const meals = reactive(load('meals', defaultMeals()))
|
||||
|
||||
watch(meals, () => save('meals', meals), { deep: true })
|
||||
|
||||
function addItem(day, slot, text) {
|
||||
const t = text.trim()
|
||||
if (t) meals[day][slot].push(t)
|
||||
}
|
||||
|
||||
function removeItem(day, slot, idx) {
|
||||
meals[day][slot].splice(idx, 1)
|
||||
}
|
||||
|
||||
const hasMeals = computed(() =>
|
||||
days.some(d => ['colazione', 'pranzo', 'cena'].some(s => meals[d.id][s].length > 0))
|
||||
)
|
||||
|
||||
function clearAll() {
|
||||
if (confirm('Svuotare tutto il piano settimanale?')) Object.assign(meals, defaultMeals())
|
||||
}
|
||||
|
||||
function generateShopping() {
|
||||
const allItems = days.flatMap(d =>
|
||||
['colazione', 'pranzo', 'cena'].flatMap(slot => meals[d.id][slot])
|
||||
)
|
||||
const existing = load('shopping', [])
|
||||
const existingNames = new Set(existing.map(i => i.name.toLowerCase()))
|
||||
const seen = new Set()
|
||||
const toAdd = []
|
||||
for (const name of allItems) {
|
||||
const key = name.toLowerCase()
|
||||
if (!existingNames.has(key) && !seen.has(key)) {
|
||||
seen.add(key)
|
||||
toAdd.push({ id: Date.now() + Math.random(), name, checked: false })
|
||||
}
|
||||
}
|
||||
save('shopping', [...existing, ...toAdd])
|
||||
emit('go-shop')
|
||||
}
|
||||
|
||||
// ── Share (QR generation) ─────────────────────────────────────────────────
|
||||
|
||||
const showShareModal = ref(false)
|
||||
const qrDataUrl = ref('')
|
||||
const qrError = ref('')
|
||||
|
||||
async function openShare() {
|
||||
qrDataUrl.value = ''
|
||||
qrError.value = ''
|
||||
showShareModal.value = true
|
||||
|
||||
const payload = JSON.stringify({ v: 1, meals })
|
||||
if (payload.length > 2953) {
|
||||
qrError.value = 'Dati troppo grandi per un QR code. Riduci il numero di alimenti inseriti.'
|
||||
return
|
||||
}
|
||||
try {
|
||||
qrDataUrl.value = await QRCode.toDataURL(payload, {
|
||||
errorCorrectionLevel: 'L',
|
||||
margin: 1,
|
||||
width: 260,
|
||||
color: { dark: '#1a1a1a', light: '#ffffff' },
|
||||
})
|
||||
} catch {
|
||||
qrError.value = 'Impossibile generare il QR code.'
|
||||
}
|
||||
}
|
||||
|
||||
function closeShare() {
|
||||
showShareModal.value = false
|
||||
qrDataUrl.value = ''
|
||||
qrError.value = ''
|
||||
}
|
||||
|
||||
// ── Receive (camera scan) ─────────────────────────────────────────────────
|
||||
|
||||
const showScanModal = ref(false)
|
||||
const videoEl = ref(null)
|
||||
const canvasEl = ref(null)
|
||||
const scanError = ref('')
|
||||
const scanSuccess = ref(false)
|
||||
|
||||
let stream = null
|
||||
let rafId = null
|
||||
|
||||
async function openScan() {
|
||||
scanError.value = ''
|
||||
scanSuccess.value = false
|
||||
showScanModal.value = true
|
||||
await nextTick()
|
||||
|
||||
try {
|
||||
stream = await navigator.mediaDevices.getUserMedia({
|
||||
video: { facingMode: 'environment' },
|
||||
audio: false,
|
||||
})
|
||||
videoEl.value.srcObject = stream
|
||||
videoEl.value.play()
|
||||
rafId = requestAnimationFrame(scanFrame)
|
||||
} catch (err) {
|
||||
if (err.name === 'NotAllowedError') {
|
||||
scanError.value = 'Accesso fotocamera negato. Abilita il permesso nelle impostazioni del dispositivo.'
|
||||
} else {
|
||||
scanError.value = 'Impossibile accedere alla fotocamera.'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function scanFrame() {
|
||||
const video = videoEl.value
|
||||
const canvas = canvasEl.value
|
||||
if (!video || !canvas || video.readyState < 2) {
|
||||
rafId = requestAnimationFrame(scanFrame)
|
||||
return
|
||||
}
|
||||
|
||||
canvas.width = video.videoWidth
|
||||
canvas.height = video.videoHeight
|
||||
const ctx = canvas.getContext('2d')
|
||||
ctx.drawImage(video, 0, 0)
|
||||
|
||||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height)
|
||||
const code = jsQR(imageData.data, canvas.width, canvas.height, {
|
||||
inversionAttempts: 'dontInvert',
|
||||
})
|
||||
|
||||
if (code) {
|
||||
handleScannedData(code.data)
|
||||
return
|
||||
}
|
||||
|
||||
rafId = requestAnimationFrame(scanFrame)
|
||||
}
|
||||
|
||||
function handleScannedData(raw) {
|
||||
stopCamera()
|
||||
try {
|
||||
const parsed = JSON.parse(raw)
|
||||
if (parsed?.v !== 1 || typeof parsed.meals !== 'object') {
|
||||
scanError.value = 'QR non valido: dati non riconosciuti.'
|
||||
return
|
||||
}
|
||||
const expectedDays = ['lunedi', 'martedi', 'mercoledi', 'giovedi', 'venerdi', 'sabato', 'domenica']
|
||||
const expectedSlots = ['colazione', 'pranzo', 'cena']
|
||||
for (const day of expectedDays) {
|
||||
for (const slot of expectedSlots) {
|
||||
if (!Array.isArray(parsed.meals[day]?.[slot])) {
|
||||
scanError.value = 'QR non valido: struttura dati errata.'
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const day of expectedDays) {
|
||||
for (const slot of expectedSlots) {
|
||||
meals[day][slot] = parsed.meals[day][slot]
|
||||
}
|
||||
}
|
||||
scanSuccess.value = true
|
||||
setTimeout(() => closeAndStopScan(), 1200)
|
||||
} catch {
|
||||
scanError.value = 'QR non valido: impossibile leggere i dati.'
|
||||
}
|
||||
}
|
||||
|
||||
function stopCamera() {
|
||||
if (rafId) { cancelAnimationFrame(rafId); rafId = null }
|
||||
if (stream) { stream.getTracks().forEach(t => t.stop()); stream = null }
|
||||
}
|
||||
|
||||
function closeAndStopScan() {
|
||||
stopCamera()
|
||||
showScanModal.value = false
|
||||
scanError.value = ''
|
||||
scanSuccess.value = false
|
||||
}
|
||||
|
||||
onUnmounted(stopCamera)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.btn-generate {
|
||||
width: 100%;
|
||||
margin-top: 8px;
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
min-height: 48px;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.btn-generate:active {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
/* ── Riga Condividi / Ricevi ─────────────────────── */
|
||||
.btn-share-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.btn-share {
|
||||
flex: 1;
|
||||
background: var(--color-primary-muted);
|
||||
color: var(--color-primary);
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
min-height: 44px;
|
||||
border-radius: var(--radius);
|
||||
border: 1.5px solid transparent;
|
||||
}
|
||||
|
||||
.btn-share:active {
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.btn-share--receive {
|
||||
background: var(--color-surface);
|
||||
border-color: var(--color-border);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.btn-share--receive:active {
|
||||
background: var(--color-bg);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* ── Overlay ─────────────────────────────────────── */
|
||||
.qr-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 400;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* ── Bottom sheet ────────────────────────────────── */
|
||||
.qr-sheet {
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius) var(--radius) 0 0;
|
||||
padding: 12px 20px 48px;
|
||||
box-shadow: 0 -8px 32px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.sheet-handle {
|
||||
width: 36px;
|
||||
height: 4px;
|
||||
background: var(--color-border);
|
||||
border-radius: var(--radius-full);
|
||||
margin: 0 auto 20px;
|
||||
}
|
||||
|
||||
.qr-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.qr-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.btn-x {
|
||||
background: var(--color-bg);
|
||||
color: var(--color-muted);
|
||||
min-height: 36px;
|
||||
min-width: 36px;
|
||||
padding: 0;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.qr-hint {
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-muted);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
/* ── QR image ────────────────────────────────────── */
|
||||
.qr-img-wrap {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 180px;
|
||||
}
|
||||
|
||||
.qr-img {
|
||||
width: 240px;
|
||||
height: 240px;
|
||||
border-radius: var(--radius-sm);
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
.qr-loading {
|
||||
font-size: 0.9rem;
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
/* ── Scanner ─────────────────────────────────────── */
|
||||
.scan-wrap {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 320px;
|
||||
margin: 0 auto 16px;
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
aspect-ratio: 1;
|
||||
}
|
||||
|
||||
.scan-video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.scan-canvas {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.scan-frame {
|
||||
position: absolute;
|
||||
inset: 24px;
|
||||
border: 2.5px solid var(--color-primary-light);
|
||||
border-radius: var(--radius-sm);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ── Feedback ────────────────────────────────────── */
|
||||
.qr-error {
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-danger);
|
||||
background: var(--color-danger-muted);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 10px 14px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.qr-success {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-primary);
|
||||
background: var(--color-primary-muted);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 10px 14px;
|
||||
margin-top: 4px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ── Transizioni ─────────────────────────────────── */
|
||||
.fade-enter-active,
|
||||
.fade-leave-active { transition: opacity 200ms ease; }
|
||||
.fade-enter-from,
|
||||
.fade-leave-to { opacity: 0; }
|
||||
</style>
|
||||
@@ -1,161 +0,0 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">Lista della spesa</h1>
|
||||
<p class="page-subtitle" v-if="items.length">
|
||||
{{ checkedCount }} / {{ items.length }} completat{{ checkedCount === 1 ? 'o' : 'i' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="add-row">
|
||||
<input
|
||||
v-model="newItem"
|
||||
type="text"
|
||||
placeholder="Aggiungi elemento..."
|
||||
@keyup.enter="add"
|
||||
/>
|
||||
<button class="btn-add" @click="add" aria-label="Aggiungi">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
|
||||
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<template v-if="items.length">
|
||||
<ul class="shop-list">
|
||||
<CheckboxItem
|
||||
v-for="item in pendingItems"
|
||||
:key="item.id"
|
||||
:item="item"
|
||||
@toggle="toggle(item.id)"
|
||||
@remove="remove(item.id)"
|
||||
/>
|
||||
</ul>
|
||||
|
||||
<template v-if="checkedCount > 0">
|
||||
<div class="section-divider">
|
||||
<span>Completati ({{ checkedCount }})</span>
|
||||
</div>
|
||||
<ul class="shop-list muted">
|
||||
<CheckboxItem
|
||||
v-for="item in checkedItems"
|
||||
:key="item.id"
|
||||
:item="item"
|
||||
@toggle="toggle(item.id)"
|
||||
@remove="remove(item.id)"
|
||||
/>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<button class="btn-clear" @click="clearAll">Svuota lista</button>
|
||||
</template>
|
||||
|
||||
<div v-else class="empty-state">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" style="color: var(--color-border)">
|
||||
<path d="M6 2L3 6v14a2 2 0 002 2h14a2 2 0 002-2V6l-3-4z"/>
|
||||
<line x1="3" y1="6" x2="21" y2="6"/>
|
||||
<path d="M16 10a4 4 0 01-8 0"/>
|
||||
</svg>
|
||||
<p>Lista vuota</p>
|
||||
<p class="empty-hint">Aggiungi qualcosa con il campo qui sopra.</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import CheckboxItem from '../components/CheckboxItem.vue'
|
||||
import { save, load } from '../utils/storage.js'
|
||||
|
||||
const items = ref(load('shopping', []))
|
||||
const newItem = ref('')
|
||||
|
||||
const pendingItems = computed(() => items.value.filter(i => !i.checked))
|
||||
const checkedItems = computed(() => items.value.filter(i => i.checked))
|
||||
const checkedCount = computed(() => checkedItems.value.length)
|
||||
|
||||
watch(items, () => save('shopping', items.value), { deep: true })
|
||||
|
||||
function add() {
|
||||
const t = newItem.value.trim()
|
||||
if (!t) return
|
||||
items.value.push({ id: Date.now(), name: t, checked: false })
|
||||
newItem.value = ''
|
||||
}
|
||||
|
||||
function toggle(id) {
|
||||
const item = items.value.find(i => i.id === id)
|
||||
if (item) item.checked = !item.checked
|
||||
}
|
||||
|
||||
function remove(id) {
|
||||
items.value = items.value.filter(i => i.id !== id)
|
||||
}
|
||||
|
||||
function clearAll() {
|
||||
if (confirm('Svuotare tutta la lista?')) items.value = []
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.add-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.btn-add {
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
min-width: 48px;
|
||||
min-height: 48px;
|
||||
flex-shrink: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.shop-list {
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.section-divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 12px 0 8px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.section-divider::after {
|
||||
content: '';
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: var(--color-border);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 60px 20px;
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.empty-hint {
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-muted);
|
||||
opacity: 0.7;
|
||||
}
|
||||
</style>
|
||||
-117
@@ -1,117 +0,0 @@
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
:root {
|
||||
--color-bg: #f0f4f1;
|
||||
--color-surface: #ffffff;
|
||||
--color-primary: #2d6a4f;
|
||||
--color-primary-light: #52b788;
|
||||
--color-primary-muted: #e8f5ee;
|
||||
--color-text: #1a1a1a;
|
||||
--color-muted: #6b7280;
|
||||
--color-border: #e2e8e4;
|
||||
--color-danger: #dc2626;
|
||||
--color-danger-muted: #fef2f2;
|
||||
--radius: 12px;
|
||||
--radius-sm: 8px;
|
||||
--radius-full: 999px;
|
||||
--nav-height: 64px;
|
||||
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.04);
|
||||
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.08), 0 2px 4px rgba(0, 0, 0, 0.04);
|
||||
--transition: 150ms ease;
|
||||
}
|
||||
|
||||
html, body {
|
||||
height: 100%;
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
font-size: 16px;
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
#app {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-width: 480px;
|
||||
margin: 0 auto;
|
||||
background: var(--color-bg);
|
||||
}
|
||||
|
||||
.page {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 20px 16px;
|
||||
padding-bottom: calc(var(--nav-height) + 20px);
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 1.4rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-muted);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
button {
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
min-height: 44px;
|
||||
padding: 0 16px;
|
||||
transition: opacity var(--transition), background var(--transition), color var(--transition);
|
||||
}
|
||||
|
||||
button:active { opacity: 0.75; }
|
||||
|
||||
/* bottone distruttivo in ghost style — meno aggressivo del solid rosso */
|
||||
.btn-clear {
|
||||
width: 100%;
|
||||
background: transparent;
|
||||
color: var(--color-danger);
|
||||
border: 1.5px solid var(--color-danger);
|
||||
margin-top: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.btn-clear:active {
|
||||
background: var(--color-danger-muted);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
input[type="text"],
|
||||
input[type="number"] {
|
||||
font-size: 1rem;
|
||||
border: 1.5px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 10px 14px;
|
||||
min-height: 48px;
|
||||
width: 100%;
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
transition: border-color var(--transition), box-shadow var(--transition);
|
||||
}
|
||||
|
||||
input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 3px rgba(45, 106, 79, 0.12);
|
||||
}
|
||||
|
||||
input::placeholder {
|
||||
color: #b0bab4;
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export const rawToCooked = (food, method, raw, db) => raw * db[food][method].yield
|
||||
export const cookedToRaw = (food, method, cooked, db) => cooked / db[food][method].yield
|
||||
@@ -1,5 +0,0 @@
|
||||
export const save = (key, val) => localStorage.setItem(key, JSON.stringify(val))
|
||||
export const load = (key, def) => {
|
||||
const v = localStorage.getItem(key)
|
||||
return v ? JSON.parse(v) : def
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:biteplan/features/converter/models/conversion_entry.dart';
|
||||
|
||||
void main() {
|
||||
group('ConversionEntry', () {
|
||||
test('rawToCooked moltiplica per il fattore di resa', () {
|
||||
const entry = ConversionEntry(
|
||||
food: 'riso basmati',
|
||||
method: 'bollitura',
|
||||
yieldFactor: 3.0,
|
||||
);
|
||||
expect(entry.rawToCooked(100), closeTo(300.0, 0.001));
|
||||
expect(entry.rawToCooked(70), closeTo(210.0, 0.001));
|
||||
});
|
||||
|
||||
test('cookedToRaw divide per il fattore di resa', () {
|
||||
const entry = ConversionEntry(
|
||||
food: 'riso basmati',
|
||||
method: 'bollitura',
|
||||
yieldFactor: 3.0,
|
||||
);
|
||||
expect(entry.cookedToRaw(300), closeTo(100.0, 0.001));
|
||||
expect(entry.cookedToRaw(150), closeTo(50.0, 0.001));
|
||||
});
|
||||
|
||||
test('conversione bidirezionale è consistente', () {
|
||||
const entry = ConversionEntry(
|
||||
food: 'pollo petto',
|
||||
method: 'forno',
|
||||
yieldFactor: 0.67,
|
||||
);
|
||||
final cooked = entry.rawToCooked(140);
|
||||
final backToRaw = entry.cookedToRaw(cooked);
|
||||
expect(backToRaw, closeTo(140, 0.001));
|
||||
});
|
||||
|
||||
test('yield > 1: i cereali aumentano di peso con la cottura', () {
|
||||
const entry = ConversionEntry(
|
||||
food: 'pasta',
|
||||
method: 'bollitura',
|
||||
yieldFactor: 2.1,
|
||||
);
|
||||
expect(entry.rawToCooked(80), greaterThan(80));
|
||||
});
|
||||
|
||||
test('yield < 1: la carne perde peso con la cottura', () {
|
||||
const entry = ConversionEntry(
|
||||
food: 'pollo petto',
|
||||
method: 'forno',
|
||||
yieldFactor: 0.67,
|
||||
);
|
||||
expect(entry.rawToCooked(100), lessThan(100));
|
||||
});
|
||||
|
||||
test('valore limite: 0 grammi restituisce 0', () {
|
||||
const entry = ConversionEntry(
|
||||
food: 'riso',
|
||||
method: 'bollitura',
|
||||
yieldFactor: 3.0,
|
||||
);
|
||||
expect(entry.rawToCooked(0), 0.0);
|
||||
expect(entry.cookedToRaw(0), 0.0);
|
||||
});
|
||||
|
||||
test('yield = 0: rawToCooked restituisce 0', () {
|
||||
const entry = ConversionEntry(
|
||||
food: 'test',
|
||||
method: 'test',
|
||||
yieldFactor: 0.0,
|
||||
);
|
||||
expect(entry.rawToCooked(100), 0.0);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:biteplan/features/converter/providers/converter_provider.dart';
|
||||
import 'package:biteplan/features/converter/models/conversion_entry.dart';
|
||||
|
||||
void main() {
|
||||
group('ConverterProvider', () {
|
||||
late ConverterProvider provider;
|
||||
|
||||
setUp(() {
|
||||
provider = ConverterProvider();
|
||||
});
|
||||
|
||||
group('stato iniziale', () {
|
||||
test('results è vuoto', () {
|
||||
expect(provider.results, isEmpty);
|
||||
});
|
||||
|
||||
test('selected è null', () {
|
||||
expect(provider.selected, isNull);
|
||||
});
|
||||
|
||||
test('grams è null', () {
|
||||
expect(provider.grams, isNull);
|
||||
});
|
||||
|
||||
test('rawToCooked è true', () {
|
||||
expect(provider.rawToCooked, true);
|
||||
});
|
||||
|
||||
test('result è null', () {
|
||||
expect(provider.result, isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('select()', () {
|
||||
const entry = ConversionEntry(food: 'riso', method: 'bollitura', yieldFactor: 3.0);
|
||||
|
||||
test('imposta l\'alimento selezionato', () {
|
||||
provider.select(entry);
|
||||
expect(provider.selected, entry);
|
||||
});
|
||||
|
||||
test('azzera i grammi', () {
|
||||
provider.setGrams(100);
|
||||
provider.select(entry);
|
||||
expect(provider.grams, isNull);
|
||||
});
|
||||
|
||||
test('svuota i risultati', () {
|
||||
provider.select(entry);
|
||||
expect(provider.results, isEmpty);
|
||||
});
|
||||
|
||||
test('notifica i listener', () {
|
||||
var notified = false;
|
||||
provider.addListener(() => notified = true);
|
||||
provider.select(entry);
|
||||
expect(notified, true);
|
||||
});
|
||||
});
|
||||
|
||||
group('setGrams()', () {
|
||||
test('aggiorna il valore', () {
|
||||
provider.setGrams(150.0);
|
||||
expect(provider.grams, 150.0);
|
||||
});
|
||||
|
||||
test('accetta null', () {
|
||||
provider.setGrams(100.0);
|
||||
provider.setGrams(null);
|
||||
expect(provider.grams, isNull);
|
||||
});
|
||||
|
||||
test('notifica i listener', () {
|
||||
var notified = false;
|
||||
provider.addListener(() => notified = true);
|
||||
provider.setGrams(100.0);
|
||||
expect(notified, true);
|
||||
});
|
||||
});
|
||||
|
||||
group('result', () {
|
||||
const entry = ConversionEntry(food: 'riso', method: 'bollitura', yieldFactor: 3.0);
|
||||
|
||||
test('null se nessun alimento selezionato', () {
|
||||
provider.setGrams(100.0);
|
||||
expect(provider.result, isNull);
|
||||
});
|
||||
|
||||
test('null se grammi non impostati', () {
|
||||
provider.select(entry);
|
||||
expect(provider.result, isNull);
|
||||
});
|
||||
|
||||
test('null se grammi sono 0', () {
|
||||
provider.select(entry);
|
||||
provider.setGrams(0.0);
|
||||
expect(provider.result, isNull);
|
||||
});
|
||||
|
||||
test('null se grammi sono negativi', () {
|
||||
provider.select(entry);
|
||||
provider.setGrams(-10.0);
|
||||
expect(provider.result, isNull);
|
||||
});
|
||||
|
||||
test('calcola crudo→cotto', () {
|
||||
provider.select(entry);
|
||||
provider.setGrams(100.0);
|
||||
expect(provider.result, closeTo(300.0, 0.05));
|
||||
});
|
||||
|
||||
test('calcola cotto→crudo dopo swapDirection', () {
|
||||
provider.select(entry);
|
||||
provider.swapDirection();
|
||||
provider.setGrams(300.0);
|
||||
expect(provider.result, closeTo(100.0, 0.05));
|
||||
});
|
||||
|
||||
test('arrotonda a 1 decimale', () {
|
||||
const e = ConversionEntry(food: 'pollo', method: 'forno', yieldFactor: 0.67);
|
||||
provider.select(e);
|
||||
provider.setGrams(33.0);
|
||||
// 33 * 0.67 = 22.11 → arrotondato a 22.1
|
||||
expect(provider.result, closeTo(22.1, 0.05));
|
||||
});
|
||||
});
|
||||
|
||||
group('swapDirection()', () {
|
||||
test('inverte la direzione', () {
|
||||
expect(provider.rawToCooked, true);
|
||||
provider.swapDirection();
|
||||
expect(provider.rawToCooked, false);
|
||||
provider.swapDirection();
|
||||
expect(provider.rawToCooked, true);
|
||||
});
|
||||
|
||||
test('azzera i grammi', () {
|
||||
provider.setGrams(100.0);
|
||||
provider.swapDirection();
|
||||
expect(provider.grams, isNull);
|
||||
});
|
||||
|
||||
test('notifica i listener', () {
|
||||
var notified = false;
|
||||
provider.addListener(() => notified = true);
|
||||
provider.swapDirection();
|
||||
expect(notified, true);
|
||||
});
|
||||
});
|
||||
|
||||
group('reset()', () {
|
||||
test('azzera tutti i campi', () {
|
||||
provider.select(const ConversionEntry(food: 'riso', method: 'bollitura', yieldFactor: 3.0));
|
||||
provider.setGrams(100.0);
|
||||
provider.swapDirection();
|
||||
provider.reset();
|
||||
expect(provider.selected, isNull);
|
||||
expect(provider.grams, isNull);
|
||||
expect(provider.rawToCooked, true);
|
||||
expect(provider.results, isEmpty);
|
||||
});
|
||||
|
||||
test('notifica i listener', () {
|
||||
var notified = false;
|
||||
provider.addListener(() => notified = true);
|
||||
provider.reset();
|
||||
expect(notified, true);
|
||||
});
|
||||
});
|
||||
|
||||
group('search()', () {
|
||||
test('risultati vuoti con query vuota', () {
|
||||
provider.search('');
|
||||
expect(provider.results, isEmpty);
|
||||
});
|
||||
|
||||
test('risultati vuoti con solo spazi', () {
|
||||
provider.search(' ');
|
||||
expect(provider.results, isEmpty);
|
||||
});
|
||||
|
||||
test('notifica i listener', () {
|
||||
var notified = false;
|
||||
provider.addListener(() => notified = true);
|
||||
provider.search('riso');
|
||||
expect(notified, true);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:biteplan/features/meal_planner/models/meal_plan.dart';
|
||||
|
||||
void main() {
|
||||
group('DayPlan', () {
|
||||
test('crea con liste vuote di default', () {
|
||||
final day = DayPlan();
|
||||
expect(day.colazione, isEmpty);
|
||||
expect(day.pranzo, isEmpty);
|
||||
expect(day.cena, isEmpty);
|
||||
});
|
||||
|
||||
test('slot() ritorna la lista corretta', () {
|
||||
final day = DayPlan(
|
||||
colazione: ['latte'],
|
||||
pranzo: ['pasta'],
|
||||
cena: ['pollo'],
|
||||
);
|
||||
expect(day.slot('colazione'), ['latte']);
|
||||
expect(day.slot('pranzo'), ['pasta']);
|
||||
expect(day.slot('cena'), ['pollo']);
|
||||
expect(day.slot('unknown'), isEmpty);
|
||||
});
|
||||
|
||||
test('copyWith() non modifica lorigine', () {
|
||||
final day = DayPlan(colazione: ['latte']);
|
||||
final updated = day.copyWith(colazione: ['latte', 'caffè']);
|
||||
expect(updated.colazione, ['latte', 'caffè']);
|
||||
expect(day.colazione, ['latte']);
|
||||
});
|
||||
|
||||
test('copyWith() preserva i campi non aggiornati', () {
|
||||
final day = DayPlan(colazione: ['latte'], pranzo: ['pasta']);
|
||||
final updated = day.copyWith(colazione: ['caffè']);
|
||||
expect(updated.pranzo, ['pasta']);
|
||||
});
|
||||
|
||||
test('JSON round-trip preserva i dati', () {
|
||||
final day = DayPlan(colazione: ['latte', 'biscotti'], cena: ['pollo']);
|
||||
final restored = DayPlan.fromJson(day.toJson());
|
||||
expect(restored.colazione, ['latte', 'biscotti']);
|
||||
expect(restored.cena, ['pollo']);
|
||||
expect(restored.pranzo, isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
group('MealPlan', () {
|
||||
test('empty() crea piano con tutti i giorni', () {
|
||||
const days = ['lunedi', 'martedi', 'mercoledi'];
|
||||
final plan = MealPlan.empty(days);
|
||||
expect(plan.days.keys, containsAll(days));
|
||||
for (final d in days) {
|
||||
expect(plan.days[d]!.colazione, isEmpty);
|
||||
}
|
||||
});
|
||||
|
||||
test('hasAnyMeal è false con piano vuoto', () {
|
||||
final plan = MealPlan.empty(['lunedi', 'martedi']);
|
||||
expect(plan.hasAnyMeal, false);
|
||||
});
|
||||
|
||||
test('hasAnyMeal è true con almeno un elemento', () {
|
||||
final plan = MealPlan({'lunedi': DayPlan(colazione: ['latte'])});
|
||||
expect(plan.hasAnyMeal, true);
|
||||
});
|
||||
|
||||
test('allItems ritorna lista appiattita', () {
|
||||
final plan = MealPlan({
|
||||
'lunedi': DayPlan(colazione: ['latte'], pranzo: ['pasta']),
|
||||
'martedi': DayPlan(cena: ['pollo']),
|
||||
});
|
||||
expect(plan.allItems, containsAll(['latte', 'pasta', 'pollo']));
|
||||
expect(plan.allItems.length, 3);
|
||||
});
|
||||
|
||||
test('allItems è vuota con piano vuoto', () {
|
||||
final plan = MealPlan.empty(['lunedi']);
|
||||
expect(plan.allItems, isEmpty);
|
||||
});
|
||||
|
||||
test('withUpdatedDay() aggiorna solo il giorno specificato', () {
|
||||
final plan = MealPlan.empty(['lunedi', 'martedi']);
|
||||
final updated =
|
||||
plan.withUpdatedDay('lunedi', DayPlan(colazione: ['latte']));
|
||||
expect(updated.days['lunedi']!.colazione, ['latte']);
|
||||
expect(updated.days['martedi']!.colazione, isEmpty);
|
||||
});
|
||||
|
||||
test('JSON round-trip preserva tutti i dati', () {
|
||||
final original = MealPlan({
|
||||
'lunedi': DayPlan(colazione: ['latte', 'biscotti'], cena: ['pollo']),
|
||||
'martedi': DayPlan(pranzo: ['pasta al pomodoro']),
|
||||
});
|
||||
final restored = MealPlan.fromJsonString(original.toJsonString());
|
||||
expect(restored.days['lunedi']!.colazione, ['latte', 'biscotti']);
|
||||
expect(restored.days['lunedi']!.cena, ['pollo']);
|
||||
expect(restored.days['martedi']!.pranzo, ['pasta al pomodoro']);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:biteplan/features/meal_planner/providers/meal_planner_provider.dart';
|
||||
import 'package:biteplan/features/meal_planner/models/meal_plan.dart';
|
||||
import 'package:biteplan/core/constants/app_constants.dart';
|
||||
|
||||
void main() {
|
||||
setUp(() {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
});
|
||||
|
||||
group('MealPlannerProvider', () {
|
||||
test('inizia con piano vuoto', () async {
|
||||
final provider = MealPlannerProvider();
|
||||
await provider.load();
|
||||
expect(provider.plan.hasAnyMeal, false);
|
||||
});
|
||||
|
||||
test('addItem aggiunge nello slot corretto', () async {
|
||||
final provider = MealPlannerProvider();
|
||||
await provider.load();
|
||||
provider.addItem('lunedi', 'colazione', 'latte');
|
||||
expect(provider.plan.days['lunedi']!.colazione, ['latte']);
|
||||
expect(provider.plan.days['lunedi']!.pranzo, isEmpty);
|
||||
});
|
||||
|
||||
test('addItem aggiunge in tutti e tre gli slot', () async {
|
||||
final provider = MealPlannerProvider();
|
||||
await provider.load();
|
||||
provider.addItem('martedi', 'colazione', 'yogurt');
|
||||
provider.addItem('martedi', 'pranzo', 'pasta');
|
||||
provider.addItem('martedi', 'cena', 'pollo');
|
||||
expect(provider.plan.days['martedi']!.colazione, ['yogurt']);
|
||||
expect(provider.plan.days['martedi']!.pranzo, ['pasta']);
|
||||
expect(provider.plan.days['martedi']!.cena, ['pollo']);
|
||||
});
|
||||
|
||||
test('addItem ignora testo vuoto o solo spazi', () async {
|
||||
final provider = MealPlannerProvider();
|
||||
await provider.load();
|
||||
provider.addItem('lunedi', 'colazione', ' ');
|
||||
provider.addItem('lunedi', 'colazione', '');
|
||||
expect(provider.plan.days['lunedi']!.colazione, isEmpty);
|
||||
});
|
||||
|
||||
test('addItem esegue trim del testo', () async {
|
||||
final provider = MealPlannerProvider();
|
||||
await provider.load();
|
||||
provider.addItem('lunedi', 'pranzo', ' pasta ');
|
||||
expect(provider.plan.days['lunedi']!.pranzo, ['pasta']);
|
||||
});
|
||||
|
||||
test('removeItem rimuove dalla posizione corretta', () async {
|
||||
final provider = MealPlannerProvider();
|
||||
await provider.load();
|
||||
provider.addItem('lunedi', 'pranzo', 'pasta');
|
||||
provider.addItem('lunedi', 'pranzo', 'insalata');
|
||||
provider.addItem('lunedi', 'pranzo', 'frutta');
|
||||
provider.removeItem('lunedi', 'pranzo', 1);
|
||||
expect(provider.plan.days['lunedi']!.pranzo, ['pasta', 'frutta']);
|
||||
});
|
||||
|
||||
test('clearAll svuota tutti i giorni', () async {
|
||||
final provider = MealPlannerProvider();
|
||||
await provider.load();
|
||||
provider.addItem('lunedi', 'colazione', 'latte');
|
||||
provider.addItem('venerdi', 'cena', 'pesce');
|
||||
provider.clearAll();
|
||||
expect(provider.plan.hasAnyMeal, false);
|
||||
});
|
||||
|
||||
test('persiste e ricarica i dati', () async {
|
||||
final provider1 = MealPlannerProvider();
|
||||
await provider1.load();
|
||||
provider1.addItem('lunedi', 'cena', 'pollo');
|
||||
await Future.delayed(const Duration(milliseconds: 50));
|
||||
|
||||
final provider2 = MealPlannerProvider();
|
||||
await provider2.load();
|
||||
expect(provider2.plan.days['lunedi']!.cena, ['pollo']);
|
||||
});
|
||||
|
||||
test('notifica i listener dopo addItem', () async {
|
||||
final provider = MealPlannerProvider();
|
||||
await provider.load();
|
||||
var notified = false;
|
||||
provider.addListener(() => notified = true);
|
||||
provider.addItem('lunedi', 'colazione', 'latte');
|
||||
expect(notified, true);
|
||||
});
|
||||
|
||||
group('importPlan()', () {
|
||||
test('sovrascrive il piano corrente', () async {
|
||||
final provider = MealPlannerProvider();
|
||||
await provider.load();
|
||||
provider.addItem('lunedi', 'colazione', 'latte');
|
||||
|
||||
final newPlan = MealPlan({
|
||||
...Map.fromEntries(kDayIds.map((d) => MapEntry(d, DayPlan()))),
|
||||
'martedi': DayPlan(pranzo: ['pasta']),
|
||||
});
|
||||
provider.importPlan(newPlan);
|
||||
|
||||
expect(provider.plan.days['lunedi']!.colazione, isEmpty);
|
||||
expect(provider.plan.days['martedi']!.pranzo, ['pasta']);
|
||||
});
|
||||
|
||||
test('persiste il piano importato', () async {
|
||||
final provider1 = MealPlannerProvider();
|
||||
await provider1.load();
|
||||
final newPlan = MealPlan({
|
||||
...Map.fromEntries(kDayIds.map((d) => MapEntry(d, DayPlan()))),
|
||||
'mercoledi': DayPlan(cena: ['pesce']),
|
||||
});
|
||||
provider1.importPlan(newPlan);
|
||||
await Future.delayed(const Duration(milliseconds: 50));
|
||||
|
||||
final provider2 = MealPlannerProvider();
|
||||
await provider2.load();
|
||||
expect(provider2.plan.days['mercoledi']!.cena, ['pesce']);
|
||||
});
|
||||
|
||||
test('notifica i listener', () async {
|
||||
final provider = MealPlannerProvider();
|
||||
await provider.load();
|
||||
var notified = false;
|
||||
provider.addListener(() => notified = true);
|
||||
provider.importPlan(MealPlan.empty(kDayIds));
|
||||
expect(notified, true);
|
||||
});
|
||||
});
|
||||
|
||||
test('load gestisce JSON corrotto con piano vuoto', () async {
|
||||
SharedPreferences.setMockInitialValues({'meals': 'json non valido {'});
|
||||
final provider = MealPlannerProvider();
|
||||
await provider.load();
|
||||
expect(provider.plan.hasAnyMeal, false);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:biteplan/features/meal_planner/qr_codec.dart';
|
||||
import 'package:biteplan/features/meal_planner/models/meal_plan.dart';
|
||||
import 'package:biteplan/core/constants/app_constants.dart';
|
||||
|
||||
void main() {
|
||||
group('buildQrPayload', () {
|
||||
test('genera payload non nullo per piano vuoto', () {
|
||||
final plan = MealPlan.empty(kDayIds);
|
||||
expect(buildQrPayload(plan), isNotNull);
|
||||
});
|
||||
|
||||
test('il payload contiene versione e dati pasti', () {
|
||||
final plan = MealPlan.empty(kDayIds);
|
||||
final payload = buildQrPayload(plan)!;
|
||||
expect(payload, contains('"v":1'));
|
||||
expect(payload, contains('"meals"'));
|
||||
});
|
||||
|
||||
test('il payload include i dati inseriti', () {
|
||||
final plan = MealPlan({
|
||||
...Map.fromEntries(kDayIds.map((d) => MapEntry(d, DayPlan()))),
|
||||
'lunedi': DayPlan(colazione: ['latte', 'biscotti'], pranzo: ['pasta']),
|
||||
});
|
||||
final payload = buildQrPayload(plan)!;
|
||||
expect(payload, contains('latte'));
|
||||
expect(payload, contains('biscotti'));
|
||||
expect(payload, contains('pasta'));
|
||||
});
|
||||
|
||||
test('restituisce null se payload supera 2953 byte', () {
|
||||
final longName = 'a' * 400;
|
||||
final plan = MealPlan(Map.fromEntries(kDayIds.map((d) => MapEntry(
|
||||
d,
|
||||
DayPlan(
|
||||
colazione: List.generate(5, (_) => longName),
|
||||
pranzo: List.generate(5, (_) => longName),
|
||||
cena: List.generate(5, (_) => longName),
|
||||
),
|
||||
))));
|
||||
expect(buildQrPayload(plan), isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('parseMealPlanFromQr', () {
|
||||
test('round-trip: parse di payload generato da buildQrPayload', () {
|
||||
final plan = MealPlan.empty(kDayIds);
|
||||
final payload = buildQrPayload(plan)!;
|
||||
final (parsed, error) = parseMealPlanFromQr(payload);
|
||||
expect(error, isNull);
|
||||
expect(parsed, isNotNull);
|
||||
for (final day in kDayIds) {
|
||||
expect(parsed!.days.containsKey(day), true);
|
||||
}
|
||||
});
|
||||
|
||||
test('round-trip preserva tutti i pasti', () {
|
||||
final original = MealPlan({
|
||||
...Map.fromEntries(kDayIds.map((d) => MapEntry(d, DayPlan()))),
|
||||
'lunedi': DayPlan(colazione: ['latte'], pranzo: ['pasta'], cena: ['pollo']),
|
||||
'martedi': DayPlan(cena: ['pesce']),
|
||||
});
|
||||
final payload = buildQrPayload(original)!;
|
||||
final (parsed, error) = parseMealPlanFromQr(payload);
|
||||
|
||||
expect(error, isNull);
|
||||
expect(parsed!.days['lunedi']!.colazione, ['latte']);
|
||||
expect(parsed.days['lunedi']!.pranzo, ['pasta']);
|
||||
expect(parsed.days['lunedi']!.cena, ['pollo']);
|
||||
expect(parsed.days['martedi']!.cena, ['pesce']);
|
||||
expect(parsed.days['mercoledi']!.colazione, isEmpty);
|
||||
});
|
||||
|
||||
test('errore su stringa non JSON', () {
|
||||
final (plan, error) = parseMealPlanFromQr('testo non json');
|
||||
expect(plan, isNull);
|
||||
expect(error, isNotNull);
|
||||
});
|
||||
|
||||
test('errore se versione non è 1', () {
|
||||
final (plan, error) = parseMealPlanFromQr('{"v":2,"meals":{}}');
|
||||
expect(plan, isNull);
|
||||
expect(error, contains('non valido'));
|
||||
});
|
||||
|
||||
test('errore se meals è una lista invece di una mappa', () {
|
||||
final (plan, error) = parseMealPlanFromQr('{"v":1,"meals":[]}');
|
||||
expect(plan, isNull);
|
||||
expect(error, isNotNull);
|
||||
});
|
||||
|
||||
test('errore se struttura di un giorno non è valida', () {
|
||||
final (plan, error) = parseMealPlanFromQr('{"v":1,"meals":{"lunedi":"stringa"}}');
|
||||
expect(plan, isNull);
|
||||
expect(error, isNotNull);
|
||||
});
|
||||
|
||||
test('errore se campo v è assente', () {
|
||||
final (plan, error) = parseMealPlanFromQr('{"meals":{}}');
|
||||
expect(plan, isNull);
|
||||
expect(error, isNotNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:biteplan/features/meal_planner/presentation/widgets/meal_card.dart';
|
||||
import 'package:biteplan/features/meal_planner/models/meal_plan.dart';
|
||||
import '../../../helpers/pump_app.dart';
|
||||
|
||||
Widget _card({
|
||||
DayPlan? dayPlan,
|
||||
bool initiallyExpanded = false,
|
||||
void Function(String, String)? onAdd,
|
||||
void Function(String, int)? onRemove,
|
||||
}) =>
|
||||
Scaffold(
|
||||
body: SingleChildScrollView(
|
||||
child: MealCard(
|
||||
dayId: 'lunedi',
|
||||
dayLabel: 'Lunedì',
|
||||
dayPlan: dayPlan ?? DayPlan(),
|
||||
initiallyExpanded: initiallyExpanded,
|
||||
onAdd: onAdd ?? (_, __) {},
|
||||
onRemove: onRemove ?? (_, __) {},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
void main() {
|
||||
group('MealCard', () {
|
||||
testWidgets('mostra il nome del giorno', (tester) async {
|
||||
await tester.pumpApp(_card());
|
||||
expect(find.text('Lunedì'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('mostra il conteggio voci nel sottotitolo', (tester) async {
|
||||
await tester.pumpApp(_card(
|
||||
dayPlan: DayPlan(colazione: ['latte', 'caffè'], pranzo: ['pasta']),
|
||||
));
|
||||
expect(find.text('3 voci'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('usa forma singolare per 1 voce', (tester) async {
|
||||
await tester.pumpApp(_card(dayPlan: DayPlan(colazione: ['latte'])));
|
||||
expect(find.text('1 voce'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('nessun sottotitolo quando il giorno è vuoto', (tester) async {
|
||||
await tester.pumpApp(_card());
|
||||
expect(find.text('0 voci'), findsNothing);
|
||||
expect(find.text('voce'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('mostra gli elementi quando espanso', (tester) async {
|
||||
await tester.pumpApp(_card(
|
||||
dayPlan: DayPlan(colazione: ['latte'], cena: ['pollo al forno']),
|
||||
initiallyExpanded: true,
|
||||
));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('latte'), findsOneWidget);
|
||||
expect(find.text('pollo al forno'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('chiama onAdd con slot e testo corretti', (tester) async {
|
||||
String? capturedSlot;
|
||||
String? capturedText;
|
||||
|
||||
await tester.pumpApp(_card(
|
||||
initiallyExpanded: true,
|
||||
onAdd: (slot, text) {
|
||||
capturedSlot = slot;
|
||||
capturedText = text;
|
||||
},
|
||||
));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.enterText(find.byType(TextField).first, 'yogurt');
|
||||
await tester.testTextInput.receiveAction(TextInputAction.done);
|
||||
await tester.pump();
|
||||
|
||||
expect(capturedSlot, 'colazione');
|
||||
expect(capturedText, 'yogurt');
|
||||
});
|
||||
|
||||
testWidgets('chiama onRemove quando si preme il pulsante elimina',
|
||||
(tester) async {
|
||||
String? removedSlot;
|
||||
int? removedIndex;
|
||||
|
||||
await tester.pumpApp(_card(
|
||||
dayPlan: DayPlan(colazione: ['latte']),
|
||||
initiallyExpanded: true,
|
||||
onRemove: (slot, index) {
|
||||
removedSlot = slot;
|
||||
removedIndex = index;
|
||||
},
|
||||
));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byIcon(Icons.close).first);
|
||||
await tester.pump();
|
||||
|
||||
expect(removedSlot, 'colazione');
|
||||
expect(removedIndex, 0);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:biteplan/features/shopping_list/models/shopping_item.dart';
|
||||
|
||||
void main() {
|
||||
group('ShoppingItem', () {
|
||||
test('checked è false di default', () {
|
||||
final item = ShoppingItem(id: '1', name: 'Pollo');
|
||||
expect(item.checked, false);
|
||||
});
|
||||
|
||||
test('quantity è 1 di default', () {
|
||||
final item = ShoppingItem(id: '1', name: 'Pollo');
|
||||
expect(item.quantity, 1);
|
||||
});
|
||||
|
||||
test('copyWith() aggiorna solo il campo specificato', () {
|
||||
final item = ShoppingItem(id: '1', name: 'Pollo');
|
||||
final checked = item.copyWith(checked: true);
|
||||
expect(checked.checked, true);
|
||||
expect(checked.name, 'Pollo');
|
||||
expect(checked.id, '1');
|
||||
});
|
||||
|
||||
test('copyWith() aggiorna quantity', () {
|
||||
final item = ShoppingItem(id: '1', name: 'Zucchine');
|
||||
final updated = item.copyWith(quantity: 3);
|
||||
expect(updated.quantity, 3);
|
||||
expect(updated.name, 'Zucchine');
|
||||
});
|
||||
|
||||
test('copyWith() non modifica lorigine', () {
|
||||
final item = ShoppingItem(id: '1', name: 'Pollo');
|
||||
item.copyWith(checked: true);
|
||||
expect(item.checked, false);
|
||||
});
|
||||
|
||||
test('JSON round-trip preserva tutti i campi incluso quantity', () {
|
||||
const item = ShoppingItem(id: '42', name: 'Riso', checked: true, quantity: 3);
|
||||
final restored = ShoppingItem.fromJson(item.toJson());
|
||||
expect(restored.id, '42');
|
||||
expect(restored.name, 'Riso');
|
||||
expect(restored.checked, true);
|
||||
expect(restored.quantity, 3);
|
||||
});
|
||||
|
||||
test('fromJson gestisce campo checked assente (default false)', () {
|
||||
final item = ShoppingItem.fromJson({'id': '1', 'name': 'Pasta'});
|
||||
expect(item.checked, false);
|
||||
});
|
||||
|
||||
test('fromJson gestisce campo quantity assente (default 1)', () {
|
||||
final item = ShoppingItem.fromJson({'id': '1', 'name': 'Pasta', 'checked': false});
|
||||
expect(item.quantity, 1);
|
||||
});
|
||||
|
||||
test('toJson produce la mappa corretta con quantity', () {
|
||||
const item = ShoppingItem(id: '5', name: 'Zucchine', checked: false, quantity: 2);
|
||||
final json = item.toJson();
|
||||
expect(json['id'], '5');
|
||||
expect(json['name'], 'Zucchine');
|
||||
expect(json['checked'], false);
|
||||
expect(json['quantity'], 2);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:biteplan/features/shopping_list/providers/shopping_list_provider.dart';
|
||||
|
||||
void main() {
|
||||
setUp(() {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
});
|
||||
|
||||
group('ShoppingListProvider', () {
|
||||
test('inizia vuota', () async {
|
||||
final provider = ShoppingListProvider();
|
||||
await provider.load();
|
||||
expect(provider.items, isEmpty);
|
||||
});
|
||||
|
||||
group('add()', () {
|
||||
test('aggiunge un elemento', () async {
|
||||
final provider = ShoppingListProvider();
|
||||
await provider.load();
|
||||
provider.add('Pollo');
|
||||
expect(provider.items.length, 1);
|
||||
expect(provider.items.first.name, 'Pollo');
|
||||
expect(provider.items.first.checked, false);
|
||||
});
|
||||
|
||||
test('ignora stringa vuota o solo spazi', () async {
|
||||
final provider = ShoppingListProvider();
|
||||
await provider.load();
|
||||
provider.add(' ');
|
||||
provider.add('');
|
||||
expect(provider.items, isEmpty);
|
||||
});
|
||||
|
||||
test('esegue trim del testo', () async {
|
||||
final provider = ShoppingListProvider();
|
||||
await provider.load();
|
||||
provider.add(' Riso ');
|
||||
expect(provider.items.first.name, 'Riso');
|
||||
});
|
||||
});
|
||||
|
||||
group('toggle()', () {
|
||||
test('inverte lo stato checked', () async {
|
||||
final provider = ShoppingListProvider();
|
||||
await provider.load();
|
||||
provider.add('Riso');
|
||||
final id = provider.items.first.id;
|
||||
provider.toggle(id);
|
||||
expect(provider.items.first.checked, true);
|
||||
provider.toggle(id);
|
||||
expect(provider.items.first.checked, false);
|
||||
});
|
||||
});
|
||||
|
||||
group('pendingItems / checkedItems', () {
|
||||
test('separano correttamente', () async {
|
||||
final provider = ShoppingListProvider();
|
||||
await provider.load();
|
||||
provider.add('Riso');
|
||||
provider.add('Pasta');
|
||||
provider.add('Pollo');
|
||||
provider.toggle(provider.items[0].id);
|
||||
provider.toggle(provider.items[2].id);
|
||||
expect(provider.pendingItems.length, 1);
|
||||
expect(provider.checkedItems.length, 2);
|
||||
});
|
||||
});
|
||||
|
||||
group('remove()', () {
|
||||
test('elimina per id', () async {
|
||||
final provider = ShoppingListProvider();
|
||||
await provider.load();
|
||||
provider.add('Pollo');
|
||||
provider.add('Riso');
|
||||
final id = provider.items.first.id;
|
||||
provider.remove(id);
|
||||
expect(provider.items.length, 1);
|
||||
expect(provider.items.first.name, 'Riso');
|
||||
});
|
||||
});
|
||||
|
||||
group('addAll()', () {
|
||||
test('non aggiunge duplicati (case-insensitive)', () async {
|
||||
final provider = ShoppingListProvider();
|
||||
await provider.load();
|
||||
provider.add('Pollo');
|
||||
provider.addAll(['pollo', 'Riso', 'RISO', 'Pasta']);
|
||||
expect(provider.items.length, 3); // Pollo, Riso, Pasta
|
||||
});
|
||||
|
||||
test('ignora elementi già presenti', () async {
|
||||
final provider = ShoppingListProvider();
|
||||
await provider.load();
|
||||
provider.add('Riso');
|
||||
provider.addAll(['Riso', 'Pasta']);
|
||||
expect(provider.items.length, 2);
|
||||
});
|
||||
|
||||
test('aggrega duplicati con quantity', () async {
|
||||
final provider = ShoppingListProvider();
|
||||
await provider.load();
|
||||
provider.addAll(['Zucchine', 'Pollo', 'Zucchine', 'Pollo', 'Pollo']);
|
||||
final zucchine = provider.items.firstWhere((i) => i.name.toLowerCase() == 'zucchine');
|
||||
final pollo = provider.items.firstWhere((i) => i.name.toLowerCase() == 'pollo');
|
||||
expect(zucchine.quantity, 2);
|
||||
expect(pollo.quantity, 3);
|
||||
});
|
||||
|
||||
test('imposta quantity=1 per elementi non duplicati', () async {
|
||||
final provider = ShoppingListProvider();
|
||||
await provider.load();
|
||||
provider.addAll(['Pasta', 'Riso']);
|
||||
for (final item in provider.items) {
|
||||
expect(item.quantity, 1);
|
||||
}
|
||||
});
|
||||
|
||||
test('non aggiunge se tutti già presenti', () async {
|
||||
final provider = ShoppingListProvider();
|
||||
await provider.load();
|
||||
provider.add('Riso');
|
||||
provider.addAll(['riso']);
|
||||
expect(provider.items.length, 1);
|
||||
});
|
||||
});
|
||||
|
||||
group('clearAll()', () {
|
||||
test('svuota la lista', () async {
|
||||
final provider = ShoppingListProvider();
|
||||
await provider.load();
|
||||
provider.add('Pollo');
|
||||
provider.add('Riso');
|
||||
provider.clearAll();
|
||||
expect(provider.items, isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
test('persiste e ricarica i dati', () async {
|
||||
final provider1 = ShoppingListProvider();
|
||||
await provider1.load();
|
||||
provider1.add('Merluzzo');
|
||||
await Future.delayed(const Duration(milliseconds: 50));
|
||||
|
||||
final provider2 = ShoppingListProvider();
|
||||
await provider2.load();
|
||||
expect(provider2.items.first.name, 'Merluzzo');
|
||||
});
|
||||
|
||||
test('load gestisce JSON corrotto con lista vuota', () async {
|
||||
SharedPreferences.setMockInitialValues({'shopping_list': 'json non valido {'});
|
||||
final provider = ShoppingListProvider();
|
||||
await provider.load();
|
||||
expect(provider.items, isEmpty);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:biteplan/features/shopping_list/presentation/widgets/shopping_item_tile.dart';
|
||||
import 'package:biteplan/features/shopping_list/models/shopping_item.dart';
|
||||
import '../../../helpers/pump_app.dart';
|
||||
|
||||
Widget _tile({
|
||||
required ShoppingItem item,
|
||||
VoidCallback? onToggle,
|
||||
VoidCallback? onRemove,
|
||||
}) =>
|
||||
Scaffold(
|
||||
body: ShoppingItemTile(
|
||||
item: item,
|
||||
onToggle: onToggle ?? () {},
|
||||
onRemove: onRemove ?? () {},
|
||||
),
|
||||
);
|
||||
|
||||
void main() {
|
||||
group('ShoppingItemTile', () {
|
||||
testWidgets('mostra il nome dell\'elemento', (tester) async {
|
||||
await tester.pumpApp(_tile(item: const ShoppingItem(id: '1', name: 'Pollo')));
|
||||
expect(find.text('Pollo'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('checkbox non spuntato quando checked è false', (tester) async {
|
||||
await tester.pumpApp(_tile(item: const ShoppingItem(id: '1', name: 'Riso')));
|
||||
final checkbox = tester.widget<Checkbox>(find.byType(Checkbox));
|
||||
expect(checkbox.value, false);
|
||||
});
|
||||
|
||||
testWidgets('checkbox spuntato quando checked è true', (tester) async {
|
||||
await tester.pumpApp(
|
||||
_tile(item: const ShoppingItem(id: '1', name: 'Riso', checked: true)));
|
||||
final checkbox = tester.widget<Checkbox>(find.byType(Checkbox));
|
||||
expect(checkbox.value, true);
|
||||
});
|
||||
|
||||
testWidgets('chiama onToggle al tap sul checkbox', (tester) async {
|
||||
var toggled = false;
|
||||
await tester.pumpApp(_tile(
|
||||
item: const ShoppingItem(id: '1', name: 'Pollo'),
|
||||
onToggle: () => toggled = true,
|
||||
));
|
||||
await tester.tap(find.byType(Checkbox));
|
||||
expect(toggled, true);
|
||||
});
|
||||
|
||||
testWidgets('chiama onRemove al tap sul pulsante chiudi', (tester) async {
|
||||
var removed = false;
|
||||
await tester.pumpApp(_tile(
|
||||
item: const ShoppingItem(id: '1', name: 'Pasta'),
|
||||
onRemove: () => removed = true,
|
||||
));
|
||||
await tester.tap(find.byIcon(Icons.close));
|
||||
expect(removed, true);
|
||||
});
|
||||
|
||||
testWidgets('testo barrato quando elemento è completato', (tester) async {
|
||||
await tester.pumpApp(
|
||||
_tile(item: const ShoppingItem(id: '1', name: 'Tonno', checked: true)));
|
||||
final textWidget = tester.widget<Text>(find.text('Tonno'));
|
||||
expect(textWidget.style?.decoration, TextDecoration.lineThrough);
|
||||
});
|
||||
|
||||
testWidgets('non mostra indicatore quantità quando quantity è 1', (tester) async {
|
||||
await tester.pumpApp(_tile(item: const ShoppingItem(id: '1', name: 'Riso')));
|
||||
expect(find.text('(x1)'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('mostra (x2) quando quantity è 2', (tester) async {
|
||||
await tester.pumpApp(
|
||||
_tile(item: const ShoppingItem(id: '1', name: 'Zucchine', quantity: 2)));
|
||||
expect(find.text('(x2)'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('mostra (xN) per quantità maggiori', (tester) async {
|
||||
await tester.pumpApp(
|
||||
_tile(item: const ShoppingItem(id: '1', name: 'Pollo', quantity: 5)));
|
||||
expect(find.text('(x5)'), findsOneWidget);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:biteplan/core/theme/app_theme.dart';
|
||||
|
||||
extension PumpApp on WidgetTester {
|
||||
Future<void> pumpApp(Widget widget) => pumpWidget(
|
||||
MaterialApp(theme: AppTheme.light(), home: widget),
|
||||
);
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test.describe('Convertitore', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await page.locator('.nav-btn', { hasText: 'Converti' }).click()
|
||||
})
|
||||
|
||||
test('mostra il messaggio iniziale prima di cercare', async ({ page }) => {
|
||||
await expect(page.locator('.hint-state')).toBeVisible()
|
||||
await expect(page.locator('.converter-card')).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('cerca un alimento e mostra i risultati', async ({ page }) => {
|
||||
await page.locator('input[type="text"]').fill('riso')
|
||||
await expect(page.locator('.result-item').first()).toBeVisible()
|
||||
})
|
||||
|
||||
test('i nomi nella lista hanno solo l\'iniziale maiuscola', async ({ page }) => {
|
||||
await page.locator('input[type="text"]').fill('pollo')
|
||||
const firstFood = await page.locator('.result-food').first().textContent()
|
||||
// "Pollo petto" → solo la prima lettera maiuscola
|
||||
expect(firstFood[0]).toBe(firstFood[0].toUpperCase())
|
||||
if (firstFood.includes(' ')) {
|
||||
const secondWord = firstFood.split(' ')[1]
|
||||
expect(secondWord[0]).toBe(secondWord[0].toLowerCase())
|
||||
}
|
||||
})
|
||||
|
||||
test('seleziona un alimento e mostra la converter card', async ({ page }) => {
|
||||
await page.locator('input[type="text"]').fill('riso basmati')
|
||||
await page.locator('.result-item').first().click()
|
||||
await expect(page.locator('.converter-card')).toBeVisible()
|
||||
await expect(page.locator('.result-item')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('calcola il peso cotto inserendo i grammi', async ({ page }) => {
|
||||
await page.locator('input[type="text"]').fill('riso basmati')
|
||||
await page.locator('.result-item').first().click()
|
||||
await page.locator('.calc-input').fill('100')
|
||||
// Riso basmati ha fattore 3.0 → 300g cotto
|
||||
await expect(page.locator('.output-value')).toBeVisible()
|
||||
const result = await page.locator('.output-value').textContent()
|
||||
expect(parseFloat(result)).toBeCloseTo(300, 0)
|
||||
})
|
||||
|
||||
test('il pulsante ⇄ inverte la direzione crudo↔cotto', async ({ page }) => {
|
||||
await page.locator('input[type="text"]').fill('pasta')
|
||||
await page.locator('.result-item').first().click()
|
||||
|
||||
const labelBefore = await page.locator('.calc-label').first().textContent()
|
||||
await page.locator('.btn-swap').click()
|
||||
const labelAfter = await page.locator('.calc-label').first().textContent()
|
||||
|
||||
expect(labelBefore).not.toBe(labelAfter)
|
||||
})
|
||||
|
||||
test('il pulsante Cambia torna alla ricerca', async ({ page }) => {
|
||||
await page.locator('input[type="text"]').fill('riso')
|
||||
await page.locator('.result-item').first().click()
|
||||
await page.locator('.btn-reset').click()
|
||||
|
||||
await expect(page.locator('.converter-card')).not.toBeVisible()
|
||||
await expect(page.locator('input[type="text"]')).not.toBeDisabled()
|
||||
})
|
||||
|
||||
test('mostra il footer con fattore di resa quando c\'è un risultato', async ({ page }) => {
|
||||
await page.locator('input[type="text"]').fill('riso')
|
||||
await page.locator('.result-item').first().click()
|
||||
await page.locator('.calc-input').fill('100')
|
||||
await expect(page.locator('.card-footer')).toContainText('fattore di resa')
|
||||
})
|
||||
})
|
||||
@@ -1,78 +0,0 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test.describe('Piano Pasti', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Pulisce localStorage per partire da uno stato noto
|
||||
await page.goto('/')
|
||||
await page.evaluate(() => localStorage.clear())
|
||||
await page.reload()
|
||||
})
|
||||
|
||||
test('mostra 7 card giornaliere', async ({ page }) => {
|
||||
const cards = page.locator('.meal-card')
|
||||
await expect(cards).toHaveCount(7)
|
||||
})
|
||||
|
||||
test('il giorno corrente è espanso di default', async ({ page }) => {
|
||||
// Almeno una card deve essere aperta (class "open")
|
||||
await expect(page.locator('.meal-card.open')).toHaveCount(1)
|
||||
})
|
||||
|
||||
test('si può espandere e chiudere una card con tap', async ({ page }) => {
|
||||
const firstHeader = page.locator('.card-header').first()
|
||||
const firstCard = page.locator('.meal-card').first()
|
||||
|
||||
// Se la prima card è già aperta, chiudila prima
|
||||
const isOpen = await firstCard.evaluate(el => el.classList.contains('open'))
|
||||
await firstHeader.click()
|
||||
if (isOpen) {
|
||||
await expect(firstCard).not.toHaveClass(/open/)
|
||||
} else {
|
||||
await expect(firstCard).toHaveClass(/open/)
|
||||
}
|
||||
})
|
||||
|
||||
test('aggiunge un alimento al pranzo del giorno corrente', async ({ page }) => {
|
||||
const openCard = page.locator('.meal-card.open')
|
||||
const pranzoInput = openCard.locator('.meal-slot').nth(1).locator('input[type="text"]')
|
||||
await pranzoInput.fill('pasta al pomodoro')
|
||||
await pranzoInput.press('Enter')
|
||||
|
||||
await expect(openCard.locator('.item-text', { hasText: 'pasta al pomodoro' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('rimuove un alimento con il pulsante ×', async ({ page }) => {
|
||||
const openCard = page.locator('.meal-card.open')
|
||||
const pranzoInput = openCard.locator('.meal-slot').nth(1).locator('input[type="text"]')
|
||||
await pranzoInput.fill('riso')
|
||||
await pranzoInput.press('Enter')
|
||||
|
||||
const itemRow = openCard.locator('.item-row', { hasText: 'riso' })
|
||||
await expect(itemRow).toBeVisible()
|
||||
await itemRow.locator('.btn-remove').click()
|
||||
await expect(itemRow).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('genera la lista della spesa e passa alla tab Spesa', async ({ page }) => {
|
||||
const openCard = page.locator('.meal-card.open')
|
||||
const cenahInput = openCard.locator('.meal-slot').nth(2).locator('input[type="text"]')
|
||||
await cenahInput.fill('pollo')
|
||||
await cenahInput.press('Enter')
|
||||
|
||||
await page.locator('.btn-generate').click()
|
||||
|
||||
// Deve essere passato alla tab Spesa
|
||||
await expect(page.locator('.page-title')).toContainText('Lista della spesa')
|
||||
await expect(page.locator('.item-name', { hasText: 'pollo' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('i dati persistono dopo il reload', async ({ page }) => {
|
||||
const openCard = page.locator('.meal-card.open')
|
||||
const colazioneInput = openCard.locator('.meal-slot').first().locator('input[type="text"]')
|
||||
await colazioneInput.fill('caffè')
|
||||
await colazioneInput.press('Enter')
|
||||
|
||||
await page.reload()
|
||||
await expect(page.locator('.meal-card.open .item-text', { hasText: 'caffè' })).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -1,42 +0,0 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test.describe('Navigazione tra tab', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/')
|
||||
})
|
||||
|
||||
test('la tab Pasti è attiva al caricamento', async ({ page }) => {
|
||||
await expect(page.locator('.nav-btn.active')).toContainText('Pasti')
|
||||
await expect(page.locator('.page-title')).toContainText('Piano Pasti')
|
||||
})
|
||||
|
||||
test('la tab Converti mostra il convertitore', async ({ page }) => {
|
||||
await page.locator('.nav-btn', { hasText: 'Converti' }).click()
|
||||
await expect(page.locator('.page-title')).toContainText('Convertitore')
|
||||
await expect(page.locator('.nav-btn.active')).toContainText('Converti')
|
||||
})
|
||||
|
||||
test('la tab Spesa mostra la lista della spesa', async ({ page }) => {
|
||||
await page.locator('.nav-btn', { hasText: 'Spesa' }).click()
|
||||
await expect(page.locator('.page-title')).toContainText('Lista della spesa')
|
||||
await expect(page.locator('.nav-btn.active')).toContainText('Spesa')
|
||||
})
|
||||
|
||||
test('si può tornare a Pasti da un\'altra tab', async ({ page }) => {
|
||||
await page.locator('.nav-btn', { hasText: 'Converti' }).click()
|
||||
await page.locator('.nav-btn', { hasText: 'Pasti' }).click()
|
||||
await expect(page.locator('.page-title')).toContainText('Piano Pasti')
|
||||
})
|
||||
|
||||
test('il pulsante info apre il pannello informazioni', async ({ page }) => {
|
||||
await page.locator('.btn-info').click()
|
||||
await expect(page.locator('.sheet')).toBeVisible()
|
||||
await expect(page.locator('.app-name')).toContainText('BitePlan')
|
||||
})
|
||||
|
||||
test('il pannello info si chiude con la X', async ({ page }) => {
|
||||
await page.locator('.btn-info').click()
|
||||
await page.locator('.btn-x').click()
|
||||
await expect(page.locator('.sheet')).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -1,89 +0,0 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test.describe('Lista della spesa', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await page.evaluate(() => localStorage.clear())
|
||||
await page.locator('.nav-btn', { hasText: 'Spesa' }).click()
|
||||
})
|
||||
|
||||
test('mostra stato vuoto con lista vuota', async ({ page }) => {
|
||||
await expect(page.locator('.empty-state')).toBeVisible()
|
||||
})
|
||||
|
||||
test('aggiunge un elemento tramite il pulsante +', async ({ page }) => {
|
||||
await page.locator('input[type="text"]').fill('latte')
|
||||
await page.locator('.btn-add').click()
|
||||
await expect(page.locator('.item-name', { hasText: 'latte' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('aggiunge un elemento con il tasto Invio', async ({ page }) => {
|
||||
await page.locator('input[type="text"]').fill('burro')
|
||||
await page.locator('input[type="text"]').press('Enter')
|
||||
await expect(page.locator('.item-name', { hasText: 'burro' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('svuota il campo dopo l\'aggiunta', async ({ page }) => {
|
||||
await page.locator('input[type="text"]').fill('olio')
|
||||
await page.locator('.btn-add').click()
|
||||
await expect(page.locator('input[type="text"]')).toHaveValue('')
|
||||
})
|
||||
|
||||
test('spunta un elemento e lo sposta nei completati', async ({ page }) => {
|
||||
await page.locator('input[type="text"]').fill('pasta')
|
||||
await page.locator('.btn-add').click()
|
||||
|
||||
await page.locator('.checkbox-item').first().locator('input[type="checkbox"]').click()
|
||||
await expect(page.locator('.section-divider')).toBeVisible()
|
||||
await expect(page.locator('.muted .item-name', { hasText: 'pasta' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('rimuove un singolo elemento con ×', async ({ page }) => {
|
||||
await page.locator('input[type="text"]').fill('farina')
|
||||
await page.locator('.btn-add').click()
|
||||
await page.locator('.checkbox-item').first().locator('.btn-remove').click()
|
||||
await expect(page.locator('.item-name', { hasText: 'farina' })).not.toBeVisible()
|
||||
await expect(page.locator('.empty-state')).toBeVisible()
|
||||
})
|
||||
|
||||
test('svuota lista con conferma del dialog', async ({ page }) => {
|
||||
await page.locator('input[type="text"]').fill('test')
|
||||
await page.locator('.btn-add').click()
|
||||
|
||||
page.once('dialog', dialog => dialog.accept())
|
||||
await page.locator('.btn-clear').click()
|
||||
|
||||
await expect(page.locator('.empty-state')).toBeVisible()
|
||||
})
|
||||
|
||||
test('non svuota lista se si annulla il dialog', async ({ page }) => {
|
||||
await page.locator('input[type="text"]').fill('test')
|
||||
await page.locator('.btn-add').click()
|
||||
|
||||
page.once('dialog', dialog => dialog.dismiss())
|
||||
await page.locator('.btn-clear').click()
|
||||
|
||||
await expect(page.locator('.item-name', { hasText: 'test' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('il contatore mostra elementi completati / totale', async ({ page }) => {
|
||||
await page.locator('input[type="text"]').fill('a')
|
||||
await page.locator('.btn-add').click()
|
||||
await page.locator('input[type="text"]').fill('b')
|
||||
await page.locator('.btn-add').click()
|
||||
|
||||
await page.locator('.checkbox-item').first().locator('input[type="checkbox"]').click()
|
||||
const subtitle = await page.locator('.page-subtitle').textContent()
|
||||
expect(subtitle).toMatch(/1/)
|
||||
expect(subtitle).toMatch(/2/)
|
||||
})
|
||||
|
||||
test('i dati persistono dopo il reload', async ({ page }) => {
|
||||
await page.locator('input[type="text"]').fill('yogurt')
|
||||
await page.locator('.btn-add').click()
|
||||
|
||||
await page.reload()
|
||||
await page.locator('.nav-btn', { hasText: 'Spesa' }).click()
|
||||
await expect(page.locator('.item-name', { hasText: 'yogurt' })).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -1,173 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import Converter from '../../src/pages/Converter.vue'
|
||||
|
||||
function mountConverter() {
|
||||
return mount(Converter, { attachTo: document.body })
|
||||
}
|
||||
|
||||
describe('Converter — stato iniziale', () => {
|
||||
it('mostra il messaggio hint quando non c\'è nessuna ricerca', () => {
|
||||
const w = mountConverter()
|
||||
expect(w.find('.hint-state').exists()).toBe(true)
|
||||
expect(w.find('.converter-card').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('non mostra risultati senza input', () => {
|
||||
const w = mountConverter()
|
||||
expect(w.findAll('.result-item')).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Converter — ricerca', () => {
|
||||
it('mostra risultati corrispondenti alla query', async () => {
|
||||
const w = mountConverter()
|
||||
const input = w.find('input[type="text"]')
|
||||
await input.setValue('riso')
|
||||
await input.trigger('input')
|
||||
expect(w.findAll('.result-item').length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('ogni risultato ha nome alimento e metodo di cottura', async () => {
|
||||
const w = mountConverter()
|
||||
await w.find('input[type="text"]').setValue('pollo')
|
||||
await w.find('input[type="text"]').trigger('input')
|
||||
const first = w.find('.result-item')
|
||||
expect(first.find('.result-food').text()).toBeTruthy()
|
||||
expect(first.find('.result-method').text()).toBeTruthy()
|
||||
})
|
||||
|
||||
it('nasconde l\'hint state durante la ricerca', async () => {
|
||||
const w = mountConverter()
|
||||
await w.find('input[type="text"]').setValue('pasta')
|
||||
await w.find('input[type="text"]').trigger('input')
|
||||
expect(w.find('.hint-state').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('svuota i risultati per query vuota', async () => {
|
||||
const w = mountConverter()
|
||||
const input = w.find('input[type="text"]')
|
||||
await input.setValue('riso')
|
||||
await input.trigger('input')
|
||||
await input.setValue('')
|
||||
await input.trigger('input')
|
||||
expect(w.findAll('.result-item')).toHaveLength(0)
|
||||
expect(w.find('.hint-state').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('i nomi degli alimenti hanno solo l\'iniziale maiuscola', async () => {
|
||||
const w = mountConverter()
|
||||
await w.find('input[type="text"]').setValue('riso')
|
||||
await w.find('input[type="text"]').trigger('input')
|
||||
const foods = w.findAll('.result-food').map(el => el.text())
|
||||
// Nessun testo deve avere lettere maiuscole dopo la prima (test multi-word)
|
||||
foods.forEach(f => {
|
||||
if (f.includes(' ')) {
|
||||
// "Riso basmati" → la seconda parola non deve essere capitalizzata
|
||||
const words = f.split(' ')
|
||||
words.slice(1).forEach(w => expect(w[0]).toBe(w[0].toLowerCase()))
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Converter — selezione alimento', () => {
|
||||
async function selectFirstResult(w, query = 'riso') {
|
||||
await w.find('input[type="text"]').setValue(query)
|
||||
await w.find('input[type="text"]').trigger('input')
|
||||
await w.find('.result-item').trigger('click')
|
||||
}
|
||||
|
||||
it('mostra la converter card dopo la selezione', async () => {
|
||||
const w = mountConverter()
|
||||
await selectFirstResult(w)
|
||||
expect(w.find('.converter-card').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('nasconde i risultati dopo la selezione', async () => {
|
||||
const w = mountConverter()
|
||||
await selectFirstResult(w)
|
||||
expect(w.findAll('.result-item')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('mostra nome alimento e metodo nella card', async () => {
|
||||
const w = mountConverter()
|
||||
await selectFirstResult(w)
|
||||
expect(w.find('.food-name').text()).toBeTruthy()
|
||||
expect(w.find('.food-method').text()).toBeTruthy()
|
||||
})
|
||||
|
||||
it('disabilita il campo di ricerca dopo la selezione', async () => {
|
||||
const w = mountConverter()
|
||||
await selectFirstResult(w)
|
||||
expect(w.find('input[type="text"]').attributes('disabled')).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Converter — calcolo', () => {
|
||||
async function mountWithSelection(query = 'riso basmati') {
|
||||
const w = mountConverter()
|
||||
await w.find('input[type="text"]').setValue(query)
|
||||
await w.find('input[type="text"]').trigger('input')
|
||||
await w.find('.result-item').trigger('click')
|
||||
return w
|
||||
}
|
||||
|
||||
it('mostra il risultato dopo aver inserito i grammi', async () => {
|
||||
const w = await mountWithSelection()
|
||||
await w.find('.calc-input').setValue('100')
|
||||
await w.find('.calc-input').trigger('input')
|
||||
expect(w.find('.output-value').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('non mostra risultato per input 0', async () => {
|
||||
const w = await mountWithSelection()
|
||||
await w.find('.calc-input').setValue('0')
|
||||
await w.find('.calc-input').trigger('input')
|
||||
expect(w.find('.output-value').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('mostra il footer con fattore di resa quando c\'è un risultato', async () => {
|
||||
const w = await mountWithSelection()
|
||||
await w.find('.calc-input').setValue('100')
|
||||
await w.find('.calc-input').trigger('input')
|
||||
expect(w.find('.card-footer').exists()).toBe(true)
|
||||
expect(w.find('.card-footer').text()).toContain('fattore di resa')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Converter — swap direzione', () => {
|
||||
it('il pulsante swap inverte le label crudo/cotto', async () => {
|
||||
const w = mountConverter()
|
||||
await w.find('input[type="text"]').setValue('pasta')
|
||||
await w.find('input[type="text"]').trigger('input')
|
||||
await w.find('.result-item').trigger('click')
|
||||
|
||||
expect(w.find('.calc-label').text()).toMatch(/crudo/i)
|
||||
await w.find('.btn-swap').trigger('click')
|
||||
expect(w.find('.calc-label').text()).toMatch(/cotto/i)
|
||||
})
|
||||
|
||||
it('swap azzera il campo grammi', async () => {
|
||||
const w = mountConverter()
|
||||
await w.find('input[type="text"]').setValue('pasta')
|
||||
await w.find('input[type="text"]').trigger('input')
|
||||
await w.find('.result-item').trigger('click')
|
||||
await w.find('.calc-input').setValue('200')
|
||||
await w.find('.btn-swap').trigger('click')
|
||||
// Dopo lo swap il campo deve essere vuoto (grams = null)
|
||||
expect(w.find('.calc-input').element.value).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Converter — reset', () => {
|
||||
it('il pulsante Cambia riporta allo stato di ricerca', async () => {
|
||||
const w = mountConverter()
|
||||
await w.find('input[type="text"]').setValue('riso')
|
||||
await w.find('input[type="text"]').trigger('input')
|
||||
await w.find('.result-item').trigger('click')
|
||||
await w.find('.btn-reset').trigger('click')
|
||||
expect(w.find('.converter-card').exists()).toBe(false)
|
||||
expect(w.find('input[type="text"]').attributes('disabled')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -1,190 +0,0 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { nextTick } from 'vue'
|
||||
import MealPlanner from '../../src/pages/MealPlanner.vue'
|
||||
import { load } from '../../src/utils/storage.js'
|
||||
|
||||
// Stub di MealCard: espone props e può emettere add/remove
|
||||
const MealCardStub = {
|
||||
name: 'MealCard',
|
||||
template: '<div class="meal-card-stub" />',
|
||||
props: ['dayName', 'meals', 'defaultOpen'],
|
||||
emits: ['add', 'remove'],
|
||||
}
|
||||
|
||||
function mountPlanner() {
|
||||
return mount(MealPlanner, {
|
||||
global: { stubs: { MealCard: MealCardStub } },
|
||||
})
|
||||
}
|
||||
|
||||
// Pre-popola il localStorage con un piano pasti completo
|
||||
function seedMeals(overrides = {}) {
|
||||
const base = Object.fromEntries(
|
||||
['lunedi', 'martedi', 'mercoledi', 'giovedi', 'venerdi', 'sabato', 'domenica'].map(
|
||||
d => [d, { colazione: [], pranzo: [], cena: [] }]
|
||||
)
|
||||
)
|
||||
const merged = { ...base, ...overrides }
|
||||
localStorage.setItem('meals', JSON.stringify(merged))
|
||||
return merged
|
||||
}
|
||||
|
||||
describe('MealPlanner — rendering', () => {
|
||||
it('rende 7 card giornaliere', () => {
|
||||
const w = mountPlanner()
|
||||
expect(w.findAll('.meal-card-stub')).toHaveLength(7)
|
||||
})
|
||||
|
||||
it('mostra il pulsante "Genera lista della spesa"', () => {
|
||||
const w = mountPlanner()
|
||||
expect(w.find('.btn-generate').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('mostra la data corrente nel sottotitolo', () => {
|
||||
const w = mountPlanner()
|
||||
expect(w.find('.page-subtitle').text()).toMatch(/oggi/i)
|
||||
})
|
||||
})
|
||||
|
||||
describe('MealPlanner — aggiunta e rimozione voci', () => {
|
||||
it('aggiunge un alimento al pranzo di lunedì', async () => {
|
||||
const w = mountPlanner()
|
||||
const cards = w.findAllComponents(MealCardStub)
|
||||
// lunedì è il primo giorno (indice 0)
|
||||
await cards[0].vm.$emit('add', 'pranzo', 'pasta')
|
||||
await nextTick()
|
||||
|
||||
const saved = load('meals', {})
|
||||
expect(saved.lunedi.pranzo).toContain('pasta')
|
||||
})
|
||||
|
||||
it('non aggiunge stringhe vuote', async () => {
|
||||
// Seed necessario: il watcher non scatta se nulla cambia,
|
||||
// quindi localStorage rimarrebbe vuoto e load() returnerebbe {}
|
||||
seedMeals()
|
||||
const w = mountPlanner()
|
||||
const cards = w.findAllComponents(MealCardStub)
|
||||
await cards[0].vm.$emit('add', 'pranzo', ' ')
|
||||
await nextTick()
|
||||
|
||||
const saved = load('meals', {})
|
||||
expect(saved.lunedi.pranzo).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('rimuove un alimento tramite indice', async () => {
|
||||
seedMeals({ lunedi: { colazione: [], pranzo: ['pasta', 'insalata'], cena: [] } })
|
||||
const w = mountPlanner()
|
||||
const cards = w.findAllComponents(MealCardStub)
|
||||
await cards[0].vm.$emit('remove', 'pranzo', 0) // rimuovi "pasta"
|
||||
await nextTick()
|
||||
|
||||
const saved = load('meals', {})
|
||||
expect(saved.lunedi.pranzo).toEqual(['insalata'])
|
||||
})
|
||||
|
||||
it('persiste le modifiche in localStorage', async () => {
|
||||
const w = mountPlanner()
|
||||
const cards = w.findAllComponents(MealCardStub)
|
||||
await cards[1].vm.$emit('add', 'cena', 'pollo')
|
||||
await nextTick()
|
||||
|
||||
expect(load('meals', {}).martedi.cena).toContain('pollo')
|
||||
})
|
||||
})
|
||||
|
||||
describe('MealPlanner — genera lista della spesa', () => {
|
||||
it('emette go-shop al click del pulsante', async () => {
|
||||
const w = mountPlanner()
|
||||
await w.find('.btn-generate').trigger('click')
|
||||
expect(w.emitted('go-shop')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('salva gli alimenti del piano in localStorage come spesa', async () => {
|
||||
seedMeals({
|
||||
lunedi: { colazione: ['caffè'], pranzo: ['pasta'], cena: ['pollo'] },
|
||||
martedi: { colazione: [], pranzo: ['riso'], cena: [] },
|
||||
})
|
||||
const w = mountPlanner()
|
||||
await w.find('.btn-generate').trigger('click')
|
||||
|
||||
const shopping = load('shopping', [])
|
||||
const names = shopping.map(i => i.name)
|
||||
expect(names).toContain('caffè')
|
||||
expect(names).toContain('pasta')
|
||||
expect(names).toContain('pollo')
|
||||
expect(names).toContain('riso')
|
||||
})
|
||||
|
||||
it('non aggiunge duplicati rispetto agli elementi già in lista', async () => {
|
||||
seedMeals({ lunedi: { colazione: [], pranzo: ['pasta'], cena: [] } })
|
||||
// Pasta già presente nella lista della spesa
|
||||
localStorage.setItem('shopping', JSON.stringify([
|
||||
{ id: 1, name: 'pasta', checked: false },
|
||||
]))
|
||||
|
||||
const w = mountPlanner()
|
||||
await w.find('.btn-generate').trigger('click')
|
||||
|
||||
const shopping = load('shopping', [])
|
||||
const pastaCount = shopping.filter(i => i.name.toLowerCase() === 'pasta').length
|
||||
expect(pastaCount).toBe(1)
|
||||
})
|
||||
|
||||
it('non aggiunge duplicati tra i giorni del piano', async () => {
|
||||
seedMeals({
|
||||
lunedi: { colazione: [], pranzo: ['pasta'], cena: [] },
|
||||
martedi: { colazione: [], pranzo: ['pasta'], cena: [] }, // stesso alimento
|
||||
})
|
||||
const w = mountPlanner()
|
||||
await w.find('.btn-generate').trigger('click')
|
||||
|
||||
const shopping = load('shopping', [])
|
||||
const pastaCount = shopping.filter(i => i.name.toLowerCase() === 'pasta').length
|
||||
expect(pastaCount).toBe(1)
|
||||
})
|
||||
|
||||
it('non aggiunge nulla se il piano è vuoto', async () => {
|
||||
const w = mountPlanner()
|
||||
await w.find('.btn-generate').trigger('click')
|
||||
expect(load('shopping', [])).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('MealPlanner — svuota piano', () => {
|
||||
it('non mostra il pulsante se il piano è vuoto', () => {
|
||||
const w = mountPlanner()
|
||||
expect(w.find('.btn-clear').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('mostra il pulsante se c\'è almeno un pasto', () => {
|
||||
seedMeals({ lunedi: { colazione: ['caffè'], pranzo: [], cena: [] } })
|
||||
const w = mountPlanner()
|
||||
expect(w.find('.btn-clear').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('svuota il piano dopo conferma', async () => {
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true)
|
||||
seedMeals({ lunedi: { colazione: ['caffè'], pranzo: ['pasta'], cena: [] } })
|
||||
const w = mountPlanner()
|
||||
|
||||
await w.find('.btn-clear').trigger('click')
|
||||
await nextTick()
|
||||
|
||||
const saved = load('meals', {})
|
||||
expect(saved.lunedi.colazione).toHaveLength(0)
|
||||
expect(saved.lunedi.pranzo).toHaveLength(0)
|
||||
expect(w.find('.btn-clear').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('non svuota se l\'utente annulla', async () => {
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(false)
|
||||
seedMeals({ lunedi: { colazione: ['caffè'], pranzo: [], cena: [] } })
|
||||
const w = mountPlanner()
|
||||
|
||||
await w.find('.btn-clear').trigger('click')
|
||||
await nextTick()
|
||||
|
||||
expect(load('meals', {}).lunedi.colazione).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -1,157 +0,0 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { nextTick } from 'vue'
|
||||
import ShoppingList from '../../src/pages/ShoppingList.vue'
|
||||
import { load } from '../../src/utils/storage.js'
|
||||
|
||||
const CheckboxItemStub = {
|
||||
name: 'CheckboxItem',
|
||||
template: '<li class="checkbox-stub">{{ item.name }}</li>',
|
||||
props: ['item'],
|
||||
emits: ['toggle', 'remove'],
|
||||
}
|
||||
|
||||
function seedShopping(items) {
|
||||
localStorage.setItem('shopping', JSON.stringify(items))
|
||||
}
|
||||
|
||||
function mountShoppingList() {
|
||||
return mount(ShoppingList, {
|
||||
global: { stubs: { CheckboxItem: CheckboxItemStub } },
|
||||
})
|
||||
}
|
||||
|
||||
describe('ShoppingList — stato iniziale', () => {
|
||||
it('mostra lo stato vuoto se non ci sono elementi', () => {
|
||||
const w = mountShoppingList()
|
||||
expect(w.find('.empty-state').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('non mostra il pulsante svuota lista se la lista è vuota', () => {
|
||||
const w = mountShoppingList()
|
||||
expect(w.find('.btn-clear').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('carica gli elementi dal localStorage all\'avvio', () => {
|
||||
seedShopping([{ id: 1, name: 'pasta', checked: false }])
|
||||
const w = mountShoppingList()
|
||||
expect(w.find('.empty-state').exists()).toBe(false)
|
||||
expect(w.findAll('.checkbox-stub')).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('ShoppingList — aggiunta elementi', () => {
|
||||
it('aggiunge un elemento tramite click su +', async () => {
|
||||
const w = mountShoppingList()
|
||||
await w.find('input[type="text"]').setValue('latte')
|
||||
await w.find('.btn-add').trigger('click')
|
||||
|
||||
const items = load('shopping', [])
|
||||
expect(items).toHaveLength(1)
|
||||
expect(items[0].name).toBe('latte')
|
||||
expect(items[0].checked).toBe(false)
|
||||
})
|
||||
|
||||
it('aggiunge un elemento tramite tasto Invio', async () => {
|
||||
const w = mountShoppingList()
|
||||
const input = w.find('input[type="text"]')
|
||||
await input.setValue('burro')
|
||||
await input.trigger('keyup.enter')
|
||||
|
||||
expect(load('shopping', []).map(i => i.name)).toContain('burro')
|
||||
})
|
||||
|
||||
it('svuota il campo input dopo l\'aggiunta', async () => {
|
||||
const w = mountShoppingList()
|
||||
await w.find('input[type="text"]').setValue('olio')
|
||||
await w.find('.btn-add').trigger('click')
|
||||
expect(w.find('input[type="text"]').element.value).toBe('')
|
||||
})
|
||||
|
||||
it('non aggiunge stringhe vuote', async () => {
|
||||
const w = mountShoppingList()
|
||||
await w.find('input[type="text"]').setValue(' ')
|
||||
await w.find('.btn-add').trigger('click')
|
||||
expect(load('shopping', [])).toHaveLength(0)
|
||||
expect(w.find('.empty-state').exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('ShoppingList — toggle e rimozione', () => {
|
||||
it('sposta un elemento nei completati dopo toggle', async () => {
|
||||
seedShopping([{ id: 1, name: 'pasta', checked: false }])
|
||||
const w = mountShoppingList()
|
||||
await w.findComponent(CheckboxItemStub).vm.$emit('toggle')
|
||||
await nextTick()
|
||||
|
||||
const items = load('shopping', [])
|
||||
expect(items[0].checked).toBe(true)
|
||||
})
|
||||
|
||||
it('ripristina un elemento già spuntato dopo un secondo toggle', async () => {
|
||||
seedShopping([{ id: 1, name: 'pasta', checked: true }])
|
||||
const w = mountShoppingList()
|
||||
await w.findComponent(CheckboxItemStub).vm.$emit('toggle')
|
||||
await nextTick()
|
||||
|
||||
expect(load('shopping', [])[0].checked).toBe(false)
|
||||
})
|
||||
|
||||
it('rimuove un singolo elemento', async () => {
|
||||
seedShopping([
|
||||
{ id: 1, name: 'pasta', checked: false },
|
||||
{ id: 2, name: 'riso', checked: false },
|
||||
])
|
||||
const w = mountShoppingList()
|
||||
await w.findAllComponents(CheckboxItemStub)[0].vm.$emit('remove')
|
||||
await nextTick()
|
||||
|
||||
const items = load('shopping', [])
|
||||
expect(items).toHaveLength(1)
|
||||
expect(items[0].name).toBe('riso')
|
||||
})
|
||||
})
|
||||
|
||||
describe('ShoppingList — svuota lista', () => {
|
||||
it('svuota la lista dopo conferma', async () => {
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true)
|
||||
seedShopping([{ id: 1, name: 'pasta', checked: false }])
|
||||
const w = mountShoppingList()
|
||||
|
||||
await w.find('.btn-clear').trigger('click')
|
||||
await nextTick()
|
||||
|
||||
expect(w.find('.empty-state').exists()).toBe(true)
|
||||
expect(load('shopping', [])).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('non svuota se l\'utente annulla', async () => {
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(false)
|
||||
seedShopping([{ id: 1, name: 'pasta', checked: false }])
|
||||
const w = mountShoppingList()
|
||||
|
||||
await w.find('.btn-clear').trigger('click')
|
||||
await nextTick()
|
||||
|
||||
expect(load('shopping', [])).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('ShoppingList — contatore completati', () => {
|
||||
it('mostra N/totale nel sottotitolo', () => {
|
||||
seedShopping([
|
||||
{ id: 1, name: 'pasta', checked: true },
|
||||
{ id: 2, name: 'riso', checked: false },
|
||||
{ id: 3, name: 'patate', checked: false },
|
||||
])
|
||||
const w = mountShoppingList()
|
||||
const subtitle = w.find('.page-subtitle').text()
|
||||
expect(subtitle).toMatch(/1/)
|
||||
expect(subtitle).toMatch(/3/)
|
||||
})
|
||||
|
||||
it('non mostra il sottotitolo se la lista è vuota', () => {
|
||||
const w = mountShoppingList()
|
||||
expect(w.find('.page-subtitle').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,5 +0,0 @@
|
||||
import { beforeEach, afterEach } from 'vitest'
|
||||
|
||||
// Svuota localStorage prima e dopo ogni test per garantire isolamento
|
||||
beforeEach(() => localStorage.clear())
|
||||
afterEach(() => localStorage.clear())
|
||||
@@ -1,75 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { rawToCooked, cookedToRaw } from '../../src/utils/conversion.js'
|
||||
|
||||
// DB minimale — isola i test dalle modifiche al JSON reale
|
||||
const db = {
|
||||
'riso basmati': { bollitura: { yield: 3.00 } },
|
||||
'pasta semola': { bollitura: { yield: 1.88 } },
|
||||
'pollo petto': { padella: { yield: 0.75 }, forno: { yield: 0.70 } },
|
||||
'zucchine': { bollitura: { yield: 0.90 }, padella: { yield: 0.82 } },
|
||||
'ceci secchi': { bollitura: { yield: 2.90 } },
|
||||
}
|
||||
|
||||
describe('rawToCooked', () => {
|
||||
it('calcola il peso cotto con fattore > 1 (cereali)', () => {
|
||||
expect(rawToCooked('riso basmati', 'bollitura', 100, db)).toBe(300)
|
||||
})
|
||||
|
||||
it('calcola il peso cotto con fattore non intero', () => {
|
||||
expect(rawToCooked('pasta semola', 'bollitura', 100, db)).toBeCloseTo(188)
|
||||
})
|
||||
|
||||
it('calcola il peso cotto con fattore < 1 (verdure)', () => {
|
||||
expect(rawToCooked('zucchine', 'bollitura', 200, db)).toBeCloseTo(180)
|
||||
})
|
||||
|
||||
it('restituisce 0 per peso 0', () => {
|
||||
expect(rawToCooked('riso basmati', 'bollitura', 0, db)).toBe(0)
|
||||
})
|
||||
|
||||
it('scala linearmente con la quantità', () => {
|
||||
const single = rawToCooked('riso basmati', 'bollitura', 100, db)
|
||||
const double = rawToCooked('riso basmati', 'bollitura', 200, db)
|
||||
expect(double).toBeCloseTo(single * 2)
|
||||
})
|
||||
|
||||
it('usa il metodo di cottura corretto', () => {
|
||||
const padella = rawToCooked('pollo petto', 'padella', 100, db)
|
||||
const forno = rawToCooked('pollo petto', 'forno', 100, db)
|
||||
expect(padella).not.toBe(forno)
|
||||
expect(padella).toBe(75)
|
||||
expect(forno).toBe(70)
|
||||
})
|
||||
|
||||
it('funziona con legumi secchi (fattore molto > 1)', () => {
|
||||
expect(rawToCooked('ceci secchi', 'bollitura', 100, db)).toBeCloseTo(290)
|
||||
})
|
||||
})
|
||||
|
||||
describe('cookedToRaw', () => {
|
||||
it('calcola il peso crudo da un peso cotto', () => {
|
||||
expect(cookedToRaw('riso basmati', 'bollitura', 300, db)).toBeCloseTo(100)
|
||||
})
|
||||
|
||||
it('è l\'inverso esatto di rawToCooked', () => {
|
||||
const rawIn = 150
|
||||
const cooked = rawToCooked('pollo petto', 'padella', rawIn, db)
|
||||
const rawOut = cookedToRaw('pollo petto', 'padella', cooked, db)
|
||||
expect(rawOut).toBeCloseTo(rawIn)
|
||||
})
|
||||
|
||||
it('funziona con verdure (fattore < 1 → crudo > cotto)', () => {
|
||||
const crudo = cookedToRaw('zucchine', 'padella', 82, db)
|
||||
expect(crudo).toBeCloseTo(100)
|
||||
})
|
||||
|
||||
it('restituisce 0 per peso cotto 0', () => {
|
||||
expect(cookedToRaw('riso basmati', 'bollitura', 0, db)).toBe(0)
|
||||
})
|
||||
|
||||
it('scala linearmente', () => {
|
||||
const base = cookedToRaw('riso basmati', 'bollitura', 300, db)
|
||||
const doppio = cookedToRaw('riso basmati', 'bollitura', 600, db)
|
||||
expect(doppio).toBeCloseTo(base * 2)
|
||||
})
|
||||
})
|
||||
@@ -1,59 +0,0 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { save, load } from '../../src/utils/storage.js'
|
||||
|
||||
// localStorage viene svuotato in tests/setup.js — qui garantiamo isolamento locale
|
||||
beforeEach(() => localStorage.clear())
|
||||
|
||||
describe('save', () => {
|
||||
it('serializza un oggetto in JSON', () => {
|
||||
save('test', { a: 1, b: 'due' })
|
||||
expect(localStorage.getItem('test')).toBe('{"a":1,"b":"due"}')
|
||||
})
|
||||
|
||||
it('serializza un array', () => {
|
||||
save('list', [1, 2, 3])
|
||||
expect(localStorage.getItem('list')).toBe('[1,2,3]')
|
||||
})
|
||||
|
||||
it('sovrascrive un valore esistente', () => {
|
||||
save('key', 'primo')
|
||||
save('key', 'secondo')
|
||||
expect(localStorage.getItem('key')).toBe('"secondo"')
|
||||
})
|
||||
|
||||
it('gestisce valori primitivi (numero, stringa, booleano)', () => {
|
||||
save('n', 42)
|
||||
save('s', 'ciao')
|
||||
save('b', false)
|
||||
expect(JSON.parse(localStorage.getItem('n'))).toBe(42)
|
||||
expect(JSON.parse(localStorage.getItem('s'))).toBe('ciao')
|
||||
expect(JSON.parse(localStorage.getItem('b'))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('load', () => {
|
||||
it('restituisce il valore parsato se la chiave esiste', () => {
|
||||
localStorage.setItem('obj', '{"a":1}')
|
||||
expect(load('obj', null)).toEqual({ a: 1 })
|
||||
})
|
||||
|
||||
it('restituisce il default se la chiave non esiste', () => {
|
||||
expect(load('nonexistent', 'fallback')).toBe('fallback')
|
||||
})
|
||||
|
||||
it('restituisce il default anche per array vuoto come fallback', () => {
|
||||
expect(load('missing', [])).toEqual([])
|
||||
})
|
||||
|
||||
it('round-trip: save → load restituisce il valore originale', () => {
|
||||
const data = [{ id: 1, name: 'pasta', checked: false }]
|
||||
save('shopping', data)
|
||||
expect(load('shopping', [])).toEqual(data)
|
||||
})
|
||||
|
||||
it('round-trip su struttura pasti annidata', () => {
|
||||
const meals = { lunedi: { colazione: ['caffè'], pranzo: [], cena: [] } }
|
||||
save('meals', meals)
|
||||
expect(load('meals', {})).toEqual(meals)
|
||||
})
|
||||
})
|
||||
@@ -1,6 +0,0 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()]
|
||||
})
|
||||
@@ -1,17 +0,0 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
test: {
|
||||
environment: 'happy-dom',
|
||||
setupFiles: ['./tests/setup.js'],
|
||||
include: ['tests/unit/**/*.test.js', 'tests/integration/**/*.test.js'],
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'html'],
|
||||
include: ['src/utils/**', 'src/pages/**', 'src/components/**'],
|
||||
exclude: ['src/data/**'],
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user