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/
|
dist/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
*.jks
|
*.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
|
## 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
|
```bash
|
||||||
npm run dev # Dev server at http://localhost:5173
|
# Sviluppo (web server con hot reload)
|
||||||
npm run build # Production build → dist/
|
cd docker/dev && docker compose up
|
||||||
npm run preview # Preview built app
|
# → http://localhost:5173
|
||||||
|
# Con il container attivo: docker compose attach dev → r = hot reload
|
||||||
|
|
||||||
npm test # Vitest unit + integration tests (watch mode)
|
# Test (dalla root del progetto — richiede immagine biteplan-build)
|
||||||
npm run test:coverage # Coverage report (v8 provider)
|
docker run --rm -v "$(pwd):/workspace" -w /workspace biteplan-build \
|
||||||
npm run test:e2e # Playwright e2e (requires dev server running)
|
bash -c "flutter pub get && flutter test"
|
||||||
npm run test:e2e:ui # Playwright interactive UI mode
|
|
||||||
|
|
||||||
# Single test file
|
# Test singolo file
|
||||||
npx vitest run tests/unit/conversion.test.js
|
docker run --rm -v "$(pwd):/workspace" -w /workspace biteplan-build \
|
||||||
|
bash -c "flutter test test/features/meal_planner/qr_test.dart"
|
||||||
|
|
||||||
# Android APK
|
# Build APK (headless, da host)
|
||||||
bash docker/build.sh # Debug APK
|
bash docker/build/build.sh # debug → dist/biteplan-debug.apk
|
||||||
bash docker/build.sh --release # Signed release 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
|
## Testing
|
||||||
|
|
||||||
- **Unit** (`tests/unit/`): Pure function tests for `conversion.js` and `storage.js`
|
110 test (unit + widget). Non richiedono device fisico.
|
||||||
- **Integration** (`tests/integration/`): Vue Test Utils component mounts with localStorage seeding
|
|
||||||
- **E2E** (`tests/e2e/`): Playwright against the running dev server
|
|
||||||
|
|
||||||
`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
|
I test usano `SharedPreferences.setMockInitialValues({})` per isolare lo storage.
|
||||||
|
|
||||||
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.
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# BitePlan
|
# 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à
|
## 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)
|
- Pianificazione settimanale su 7 giorni × 3 pasti (colazione, pranzo, cena)
|
||||||
- Card accordion per giorno, giorno corrente aperto di default
|
- Card accordion per giorno, giorno corrente aperto di default
|
||||||
- Aggiunta e rimozione di voci per ogni pasto
|
- Aggiunta e rimozione di voci per ogni pasto
|
||||||
- Generazione automatica della lista della spesa dai pasti pianificati
|
- Generazione automatica della lista della spesa con aggregazione duplicati (es. "zucchine (x2)")
|
||||||
- Persistenza automatica su LocalStorage
|
- Condivisione del piano via QR code tra dispositivi
|
||||||
|
- Persistenza automatica su SharedPreferences
|
||||||
|
|
||||||
### Convertitore crudo/cotto
|
### Convertitore crudo/cotto
|
||||||
- Conversione bidirezionale del peso (crudo → cotto e cotto → crudo)
|
- Conversione bidirezionale del peso (crudo → cotto e cotto → crudo)
|
||||||
- Ricerca alimento in tempo reale
|
- Ricerca alimento in tempo reale
|
||||||
- Oltre 50 voci tra cereali, legumi, verdure, carni, pesce e uova
|
- 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
|
- 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
|
### Lista della spesa
|
||||||
- Checklist con aggiunta manuale o importazione dai pasti pianificati
|
- 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 |
|
| Livello | Tecnologia |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Frontend | Vue 3 + Vite |
|
| Framework | Flutter 3.x / Dart 3.x |
|
||||||
| Persistenza | LocalStorage |
|
| State management | Provider |
|
||||||
| UI | CSS mobile-first (max 480px) |
|
| Persistenza | shared_preferences |
|
||||||
| Mobile | Capacitor Android |
|
| QR code | qr_flutter + mobile_scanner |
|
||||||
| Build APK | Docker |
|
| Build APK | Docker (headless) |
|
||||||
|
|
||||||
## Requisiti per lo sviluppo
|
## Sviluppo
|
||||||
|
|
||||||
| Strumento | Versione minima | Note |
|
Il container dev avvia un web server Flutter accessibile dal browser, con hot reload.
|
||||||
|---|---|---|
|
|
||||||
| 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
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install
|
cd docker/dev
|
||||||
npm run dev
|
docker compose up
|
||||||
|
# → http://localhost:5173
|
||||||
```
|
```
|
||||||
|
|
||||||
Aprire [http://localhost:5173](http://localhost:5173) in Chrome con DevTools in modalità mobile (viewport 360×640).
|
Con il container attivo, in un altro terminale:
|
||||||
Da un dispositivo mobile sulla stessa rete, aprire `http://<ip-host>:5173`.
|
|
||||||
|
|
||||||
## 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
|
## 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.
|
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/`.
|
||||||
## 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.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 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
|
||||||
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
|
```bash
|
||||||
keytool -genkey -v \
|
keytool -genkey -v \
|
||||||
-keystore docker/biteplan.jks \
|
-keystore docker/biteplan.jks \
|
||||||
-alias biteplan \
|
-alias biteplan \
|
||||||
-keyalg RSA \
|
-keyalg RSA -keysize 2048 -validity 10000
|
||||||
-keysize 2048 \
|
|
||||||
-validity 10000
|
|
||||||
```
|
```
|
||||||
|
|
||||||
> Il file `docker/biteplan.jks` è già in `.gitignore` — non verrà mai committato.
|
> `docker/biteplan.jks` è in `.gitignore` e non verrà mai committato.
|
||||||
> Conservalo in un posto sicuro: senza di esso non puoi aggiornare l'app.
|
> Conservalo in un posto sicuro: senza di esso non puoi pubblicare aggiornamenti.
|
||||||
|
|
||||||
### 2. Esegui la build release
|
**2. Esegui la build release**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bash docker/build.sh --release
|
bash docker/build/build.sh --release
|
||||||
# chiede interattivamente: Password keystore / Password chiave
|
# → 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
|
```bash
|
||||||
KEYSTORE_PASS=tuapassword KEY_PASS=tuapassword bash docker/build.sh --release
|
adb install dist/biteplan-debug.apk
|
||||||
```
|
# oppure copia manualmente dist/biteplan-release.apk via USB o Drive
|
||||||
|
|
||||||
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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Flag combinabili
|
## Test
|
||||||
|
|
||||||
| Comando | Risultato |
|
I test non richiedono il container dev — usano l'immagine `biteplan-build`, creata
|
||||||
|---------|-----------|
|
automaticamente al primo `bash docker/build/build.sh`.
|
||||||
| `bash docker/build.sh` | APK debug dalla working directory |
|
|
||||||
| `bash docker/build.sh --head` | APK debug dall'ultimo commit git |
|
```bash
|
||||||
| `bash docker/build.sh --release` | APK release firmato dalla working directory |
|
# Tutti i test (110 test — unit + widget)
|
||||||
| `bash docker/build.sh --head --release` | APK release firmato dall'ultimo commit git |
|
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**.
|
Se la cartella `android/` non esiste ancora, lo script `build.sh` la genera
|
||||||
Le build successive usano la cache Docker e sono molto più rapide.
|
automaticamente tramite `flutter create` all'interno del container — non è necessario
|
||||||
|
avere Flutter installato sull'host.
|
||||||
|
|
||||||
## Installazione su dispositivo
|
---
|
||||||
|
|
||||||
```bash
|
## Flusso completo
|
||||||
adb install dist/biteplan.apk
|
|
||||||
```
|
|
||||||
|
|
||||||
## Pipeline
|
|
||||||
|
|
||||||
```
|
```
|
||||||
[host] npm run build → dist/
|
# 1. Sviluppo
|
||||||
[docker] cap sync → copia dist/ in android/assets/
|
cd docker/dev && docker compose up
|
||||||
[docker] gradlew assembleDebug/Release
|
→ http://localhost:5173 (hot reload con 'r')
|
||||||
[docker] zipalign + apksigner → solo in modalità release
|
|
||||||
[host] dist/biteplan.apk
|
# 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
|
## Note
|
||||||
|
|
||||||
- App ID: `com.davide.biteplan`
|
- App ID: `com.biteplan.biteplan`
|
||||||
- Android target: API 34
|
- 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
|
## 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.
|
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.
|
3. **Lista della spesa** — checklist con aggiunta, spunta e rimozione elementi.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -14,130 +14,158 @@ App mobile-first con tre funzionalità:
|
|||||||
|
|
||||||
| Livello | Scelta |
|
| Livello | Scelta |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Frontend | Vue 3 + Vite |
|
| Framework | Flutter (Dart) |
|
||||||
| Stato | Composables (no store) |
|
| Stato | Provider o Riverpod |
|
||||||
| Persistenza | LocalStorage |
|
| Persistenza | shared_preferences |
|
||||||
| UI | CSS base mobile-first |
|
| UI | Material 3, mobile-first |
|
||||||
| Mobile (fase successiva) | Capacitor Android |
|
| Build APK | Docker (container build) |
|
||||||
| Build riproducibile (opzionale) | Docker |
|
| Sviluppo | Docker + noVNC (container dev con GUI) |
|
||||||
|
|
||||||
Sviluppo iniziale: `npm run dev` + Chrome DevTools modalità mobile.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Struttura progetto
|
## Struttura progetto
|
||||||
|
|
||||||
```
|
```
|
||||||
src/
|
lib/
|
||||||
|
├── main.dart
|
||||||
|
├── app.dart # MaterialApp, routing, BottomNavigationBar
|
||||||
├── pages/
|
├── pages/
|
||||||
│ ├── MealPlanner.vue
|
│ ├── meal_planner_page.dart
|
||||||
│ ├── Converter.vue
|
│ ├── converter_page.dart
|
||||||
│ └── ShoppingList.vue
|
│ └── shopping_list_page.dart
|
||||||
├── components/
|
├── widgets/
|
||||||
│ ├── BottomNav.vue
|
│ ├── meal_card.dart # Accordion giorno
|
||||||
│ ├── MealCard.vue
|
│ └── checkbox_item.dart
|
||||||
│ └── CheckboxItem.vue
|
├── models/
|
||||||
├── data/
|
│ ├── meal_plan.dart
|
||||||
│ └── conversions.json
|
│ └── shopping_item.dart
|
||||||
├── utils/
|
├── services/
|
||||||
│ ├── conversion.js
|
│ ├── storage_service.dart # Wrapper shared_preferences
|
||||||
│ └── storage.js
|
│ └── conversion_service.dart # rawToCooked / cookedToRaw
|
||||||
└── App.vue
|
└── 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
|
```bash
|
||||||
npm create vue@latest biteplan
|
# Avvio
|
||||||
cd biteplan
|
cd docker/dev
|
||||||
npm install
|
docker compose up
|
||||||
npm run dev
|
|
||||||
|
# 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
|
```bash
|
||||||
{
|
# Debug
|
||||||
lunedi: { colazione: [], pranzo: [], cena: [] },
|
bash docker/build/build.sh
|
||||||
martedi: { ... }
|
|
||||||
|
# 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à**
|
**Shopping Item**
|
||||||
- Aggiungere/rimuovere voci per ogni pasto
|
```dart
|
||||||
- Visualizzare l'intera settimana
|
class ShoppingItem {
|
||||||
- Salvataggio automatico su LocalStorage
|
final String id;
|
||||||
|
final String name;
|
||||||
|
bool checked;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Fase 3 — Conversione crudo/cotto
|
## Conversione crudo/cotto
|
||||||
|
|
||||||
**JSON** — `src/data/conversions.json`
|
|
||||||
|
|
||||||
|
**Asset** — `lib/data/conversions.json`
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"pollo": { "forno": { "yield": 0.75 }, "padella": { "yield": 0.70 } },
|
"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`
|
`yield = peso_cotto / peso_crudo`
|
||||||
|
|
||||||
**Funzioni** — `src/utils/conversion.js`
|
**Service** — `lib/services/conversion_service.dart`
|
||||||
|
```dart
|
||||||
```js
|
double rawToCooked(double raw, double yield) => raw * yield;
|
||||||
export const rawToCooked = (food, method, raw, db) => raw * db[food][method].yield
|
double cookedToRaw(double cooked, double yield) => cooked / yield;
|
||||||
export const cookedToRaw = (food, method, cooked, db) => cooked / db[food][method].yield
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**UX**: ricerca testuale sull'alimento → selezione alimento+metodo → input grammi → risultato in tempo reale.
|
UX: ricerca testuale → selezione alimento + metodo → input grammi → risultato in tempo reale.
|
||||||
|
|
||||||
Esempio: 140 g pollo crudo → 105 g cotti al forno.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Fase 4 — Lista della spesa
|
## Persistenza
|
||||||
|
|
||||||
**Struttura dati**
|
`StorageService` wrappa `shared_preferences`:
|
||||||
|
- Meal plan serializzato come JSON stringa sotto chiave `meals`
|
||||||
```js
|
- Lista spesa serializzata come JSON stringa sotto chiave `shopping_list`
|
||||||
[{ id, name, checked }]
|
|
||||||
```
|
|
||||||
|
|
||||||
**Funzionalità**: aggiungi, spunta, elimina, svuota lista.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Fase 5 — UI Mobile
|
## UI
|
||||||
|
|
||||||
Navigazione bottom bar: Pasti | Converti | Spesa.
|
- `BottomNavigationBar` con 3 tab: Pasti | Converti | Spesa
|
||||||
|
- Material 3, `ColorScheme.fromSeed(seedColor: Color(0xFF2d6a4f))`
|
||||||
Linee guida: bottoni min 44px, input semplici, una funzione per schermata.
|
- Target touch minimo 48×48 dp (Material baseline)
|
||||||
|
- Orientamento bloccato in portrait (`SystemChrome.setPreferredOrientations`)
|
||||||
---
|
|
||||||
|
|
||||||
## 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.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -145,16 +173,5 @@ Pipeline: install → build frontend → cap sync → gradle build APK.
|
|||||||
|
|
||||||
- Modifica coefficienti di conversione in-app
|
- Modifica coefficienti di conversione in-app
|
||||||
- Calcolo kcal
|
- Calcolo kcal
|
||||||
- Generazione automatica lista spesa dai pasti pianificati
|
- Condivisione piano via QR code
|
||||||
- Sync cloud
|
- 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