diff --git a/.gitignore b/.gitignore index 0ce32ec..9f2ec55 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,22 @@ node_modules/ dist/ .DS_Store *.jks + +# Flutter — output e scaffold generati da flutter create / flutter build +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +/build/ +*.g.dart +pubspec.lock + +# Scaffold generato da flutter create (creato nel container, non versionare) +/android/ +/ios/ +/linux/ +/macos/ +/windows/ +/web/ +/.idea/ +*.iml +.metadata diff --git a/CLAUDE.md b/CLAUDE.md index dde3497..7d75565 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,63 +4,93 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Overview -BitePlan is a mobile-first Vue 3 PWA for meal planning, raw↔cooked weight conversion, and shopping list management. It targets Android via Capacitor and is designed for a 480px-max viewport. All UI strings and documentation are in **Italian**. +BitePlan è un'app Android per meal planning, conversione crudo/cotto e lista della spesa, scritta in Flutter. Tutta la UI e la UX sono in **italiano**. L'obiettivo finale è un APK Android buildabile via Docker. -## Commands +## Comandi ```bash -npm run dev # Dev server at http://localhost:5173 -npm run build # Production build → dist/ -npm run preview # Preview built app +# Sviluppo (web server con hot reload) +cd docker/dev && docker compose up +# → http://localhost:5173 +# Con il container attivo: docker compose attach dev → r = hot reload -npm test # Vitest unit + integration tests (watch mode) -npm run test:coverage # Coverage report (v8 provider) -npm run test:e2e # Playwright e2e (requires dev server running) -npm run test:e2e:ui # Playwright interactive UI mode +# Test (dalla root del progetto — richiede immagine biteplan-build) +docker run --rm -v "$(pwd):/workspace" -w /workspace biteplan-build \ + bash -c "flutter pub get && flutter test" -# Single test file -npx vitest run tests/unit/conversion.test.js +# Test singolo file +docker run --rm -v "$(pwd):/workspace" -w /workspace biteplan-build \ + bash -c "flutter test test/features/meal_planner/qr_test.dart" -# Android APK -bash docker/build.sh # Debug APK -bash docker/build.sh --release # Signed release APK +# Build APK (headless, da host) +bash docker/build/build.sh # debug → dist/biteplan-debug.apk +bash docker/build/build.sh --release # release → dist/biteplan-release.apk ``` -E2E tests simulate iPhone 14 Pro viewport (393×852) with Italian locale. +## Architettura -## Architecture - -**App.vue** is the root router — it conditionally renders three pages based on a `currentPage` ref (`meal`, `convert`, `shop`) and hosts the portrait-lock transform, `InfoPanel`, and `DocsPanel`. - -**Three pages** in `src/pages/`: -- `MealPlanner.vue` — 7-day plan (Mon–Sun), 3 meal slots each (colazione/pranzo/cena), per-day accordion via `MealCard.vue`, QR share, and "generate shopping list" export -- `Converter.vue` — real-time food search → select food+method → bidirectional raw↔cooked calculation using yield coefficients from `src/data/conversions.json` -- `ShoppingList.vue` — checklist with add/remove/check, importable from meal planner - -**State & persistence**: No Pinia/Vuex — pages use Vue composables + direct `localStorage` via the wrappers in `src/utils/storage.js` (`save(key, val)` / `load(key, default)`). - -**Conversion logic** lives entirely in `src/utils/conversion.js`: -```js -rawToCooked(food, method, rawGrams, db) = rawGrams * db[food][method].yield -cookedToRaw(food, method, cookedGrams, db) = cookedGrams / db[food][method].yield ``` -`yield = cooked_weight / raw_weight`. The database in `src/data/conversions.json` has 50+ foods × 1–4 cooking methods with coefficients sourced from CREA, SINU, USDA (see `docs/conversioni.md`). +lib/ +├── main.dart +├── app.dart # MaterialApp, NavigationBar, AppBar con bottone info +├── core/ +│ ├── constants/app_constants.dart # kDayIds, kMealSlots, kStorageKey*, kAppVersion +│ └── theme/app_theme.dart +├── shared/ +│ ├── services/storage_service.dart # wrapper SharedPreferences (load/save) +│ └── widgets/ +├── features/ +│ ├── meal_planner/ +│ │ ├── models/meal_plan.dart # MealPlan, DayPlan +│ │ ├── providers/meal_planner_provider.dart +│ │ ├── qr_codec.dart # buildQrPayload, parseMealPlanFromQr +│ │ └── presentation/ +│ │ ├── pages/meal_planner_page.dart +│ │ ├── pages/qr_scan_page.dart +│ │ └── widgets/meal_card.dart, qr_share_sheet.dart +│ ├── converter/ +│ │ ├── models/conversion_entry.dart # rawToCooked, cookedToRaw (metodi sul model) +│ │ ├── providers/converter_provider.dart +│ │ └── presentation/pages/converter_page.dart +│ ├── shopping_list/ +│ │ ├── models/shopping_item.dart # quantity per aggregazione duplicati +│ │ ├── providers/shopping_list_provider.dart +│ │ └── presentation/ +│ │ ├── pages/shopping_list_page.dart +│ │ └── widgets/shopping_item_tile.dart +│ └── guide/ +│ └── presentation/ +│ ├── pages/guide_page.dart # 3 tab: Pasti, Converti, Spesa +│ └── widgets/info_bottom_sheet.dart # aperto dal bottone info in AppBar +└── assets/data/conversions.json # 50+ alimenti × metodi cottura +``` -**CSS**: Vanilla CSS only, no framework. Design tokens are CSS variables in `:root` in `src/style.css` (`--color-primary: #2d6a4f`, `--nav-height: 64px`, etc.). Buttons must be min 44px; layout is single-column. +**State management**: Provider (`ChangeNotifier`). +**Persistenza**: `shared_preferences`, chiavi `meals` e `shopping_list` (JSON serializzato). +**Conversione**: logica in `ConversionEntry` — `rawToCooked = raw * yieldFactor`, `cookedToRaw = cooked / yieldFactor`. +**UI**: Material 3, seed color `Color(0xFF2d6a4f)`, tutto in italiano. +**QR**: payload JSON `{ "v": 1, "meals": { ... } }`, limite 2953 byte (capacità QR con error correction L). ## Testing -- **Unit** (`tests/unit/`): Pure function tests for `conversion.js` and `storage.js` -- **Integration** (`tests/integration/`): Vue Test Utils component mounts with localStorage seeding -- **E2E** (`tests/e2e/`): Playwright against the running dev server +110 test (unit + widget). Non richiedono device fisico. -`tests/setup.js` clears localStorage before/after each test. Vitest uses `happy-dom` environment. +``` +test/ +├── helpers/pump_app.dart # estensione pumpApp per widget test +└── features/ + ├── converter/ + │ ├── models/conversion_entry_test.dart + │ └── providers/converter_provider_test.dart + ├── meal_planner/ + │ ├── models/meal_plan_test.dart + │ ├── providers/meal_planner_provider_test.dart + │ ├── widgets/meal_card_test.dart + │ └── qr_test.dart + └── shopping_list/ + ├── models/shopping_item_test.dart + ├── providers/shopping_list_provider_test.dart + └── widgets/shopping_item_tile_test.dart +``` -## Android Build - -The Docker pipeline in `docker/` handles the full Android build: -1. `npm run build` → `dist/` -2. `cap sync` → copies dist into Android project -3. Gradle builds the APK; release APK is signed with `docker/biteplan.jks` (gitignored) - -See `docker/README.md` for full APK build instructions. +I test usano `SharedPreferences.setMockInitialValues({})` per isolare lo storage. diff --git a/README.md b/README.md index b93b212..7816b8e 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # BitePlan -App mobile-first per la gestione della dieta quotidiana — pianificazione pasti, conversione crudo/cotto e lista della spesa. +App Android per la gestione della dieta quotidiana — pianificazione pasti, conversione crudo/cotto e lista della spesa. ## Funzionalità @@ -8,15 +8,16 @@ App mobile-first per la gestione della dieta quotidiana — pianificazione pasti - Pianificazione settimanale su 7 giorni × 3 pasti (colazione, pranzo, cena) - Card accordion per giorno, giorno corrente aperto di default - Aggiunta e rimozione di voci per ogni pasto -- Generazione automatica della lista della spesa dai pasti pianificati -- Persistenza automatica su LocalStorage +- Generazione automatica della lista della spesa con aggregazione duplicati (es. "zucchine (x2)") +- Condivisione del piano via QR code tra dispositivi +- Persistenza automatica su SharedPreferences ### Convertitore crudo/cotto - Conversione bidirezionale del peso (crudo → cotto e cotto → crudo) - Ricerca alimento in tempo reale - Oltre 50 voci tra cereali, legumi, verdure, carni, pesce e uova - Fino a 4 metodi di cottura per alimento: bollitura, padella, forno, friggitrice ad aria -- Coefficienti di resa documentati con fonti (CREA, SINU, Istituto Muzzone, USDA) +- Coefficienti di resa documentati con fonti — vedi [docs/conversioni.md](docs/conversioni.md) ### Lista della spesa - Checklist con aggiunta manuale o importazione dai pasti pianificati @@ -27,36 +28,75 @@ App mobile-first per la gestione della dieta quotidiana — pianificazione pasti | Livello | Tecnologia | |---|---| -| Frontend | Vue 3 + Vite | -| Persistenza | LocalStorage | -| UI | CSS mobile-first (max 480px) | -| Mobile | Capacitor Android | -| Build APK | Docker | +| Framework | Flutter 3.x / Dart 3.x | +| State management | Provider | +| Persistenza | shared_preferences | +| QR code | qr_flutter + mobile_scanner | +| Build APK | Docker (headless) | -## Requisiti per lo sviluppo +## Sviluppo -| Strumento | Versione minima | Note | -|---|---|---| -| Node.js | >= 20.x LTS | testato con v24 | -| npm | 9.x | incluso con Node.js | -| Git | 2.x | | -| Browser | Chrome / Edge / Firefox recente | DevTools modalità mobile consigliati | - -Per la build APK Android sono necessari requisiti aggiuntivi — vedi [docker/README.md](docker/README.md). - -## Avvio in sviluppo +Il container dev avvia un web server Flutter accessibile dal browser, con hot reload. ```bash -npm install -npm run dev +cd docker/dev +docker compose up +# → http://localhost:5173 ``` -Aprire [http://localhost:5173](http://localhost:5173) in Chrome con DevTools in modalità mobile (viewport 360×640). -Da un dispositivo mobile sulla stessa rete, aprire `http://:5173`. +Con il container attivo, in un altro terminale: -## Build APK Android +```bash +docker compose attach dev +# r → hot reload | R → hot restart | q → esci +``` -Vedi [docker/README.md](docker/README.md) per i requisiti e i dettagli della pipeline. +Vedi [docker/README.md](docker/README.md) per i dettagli e la build APK. + +## Test + +La suite copre unit test e widget test. Richiede l'immagine Docker `biteplan-build` +(creata automaticamente al primo `bash docker/build/build.sh`). + +```bash +# Tutti i test (dalla root del progetto) +docker run --rm -v "$(pwd):/workspace" -w /workspace biteplan-build \ + bash -c "flutter pub get && flutter test" + +# Un singolo file +docker run --rm -v "$(pwd):/workspace" -w /workspace biteplan-build \ + bash -c "flutter test test/features/meal_planner/qr_test.dart" +``` + +### Struttura + +``` +test/ +├── helpers/ +│ └── pump_app.dart # estensione pumpApp per widget test +└── features/ + ├── converter/ + │ ├── models/conversion_entry_test.dart + │ └── providers/converter_provider_test.dart + ├── meal_planner/ + │ ├── models/meal_plan_test.dart + │ ├── providers/meal_planner_provider_test.dart + │ ├── widgets/meal_card_test.dart + │ └── qr_test.dart + └── shopping_list/ + ├── models/shopping_item_test.dart + ├── providers/shopping_list_provider_test.dart + └── widgets/shopping_item_tile_test.dart +``` + +## Build APK + +```bash +bash docker/build/build.sh # debug → dist/biteplan-debug.apk +bash docker/build/build.sh --release # release → dist/biteplan-release.apk +``` + +Vedi [docker/README.md](docker/README.md) per i requisiti della firma release. ## Documentazione diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000..f9b3034 --- /dev/null +++ b/analysis_options.yaml @@ -0,0 +1 @@ +include: package:flutter_lints/flutter.yaml diff --git a/src/data/conversions.json b/assets/data/conversions.json similarity index 100% rename from src/data/conversions.json rename to assets/data/conversions.json diff --git a/assets/icon-only.png b/assets/icon-only.png index 101e7b5..f484e72 100644 Binary files a/assets/icon-only.png and b/assets/icon-only.png differ diff --git a/docker/Dockerfile b/docker/Dockerfile deleted file mode 100644 index 3da4d86..0000000 --- a/docker/Dockerfile +++ /dev/null @@ -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|| \n|' \ - 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 diff --git a/docker/README.md b/docker/README.md index e0ea1a7..714d633 100644 --- a/docker/README.md +++ b/docker/README.md @@ -1,109 +1,141 @@ -# Build APK — Docker +# Docker — BitePlan -Genera un APK Android senza installare nulla sull'host oltre a Docker. - -## Requisiti host - -| Requisito | Dettaglio | -|-----------|-----------| -| **Architettura** | x86_64 (amd64) — ARM/aarch64 non supportato | -| **OS** | Linux x86_64 o macOS x86_64 | -| **Docker** | Installato e avviato | -| **Node.js** | v18+ (per il build Vite locale) | - -> Gli Android build-tools (`aapt2`, `zipalign`, `apksigner`) sono binari nativi x86_64 e non girano su host ARM senza emulazione QEMU. +Due container distinti: uno per lo sviluppo con hot reload, uno per la build APK headless. +Tutti i comandi vanno eseguiti dalla **root del progetto** (`~/biteplan`), non da `docker/`. --- -## Build debug (default) +## Container dev (`docker/dev/`) -APK firmato con il debug keystore di Android. Adatto per test su dispositivo. +Avvia un web server Flutter accessibile dal browser, con hot reload. ```bash -bash docker/build.sh +cd docker/dev +docker compose up ``` +Apri **http://localhost:5173** nel browser. + +### Hot reload + +Con il container attivo, in un altro terminale: + +```bash +docker compose attach dev +# r → hot reload | R → hot restart | q → esci +``` + +Le modifiche ai file `.dart` sull'host sono visibili subito nel container grazie al volume +montato — basta premere `r` per ricaricare. + +> Prima esecuzione: scarica l'immagine Flutter (~500 MB), richiede 5-10 minuti. +> Le successive usano la cache Docker e sono molto più rapide. + --- -## Build release (distribuzione) +## Container build (`docker/build/`) -APK firmato con il tuo keystore personale. Necessario per distribuire l'app. +Build headless riproducibile per generare l'APK Android. -### 1. Genera il keystore (una volta sola) +### Build debug + +APK firmato con il debug keystore di Android — adatto per installazione diretta sul dispositivo. + +```bash +bash docker/build/build.sh +# → dist/biteplan-debug.apk +``` + +### Build release + +APK firmato con keystore personale — necessario per la distribuzione. + +**1. Genera il keystore (una sola volta)** ```bash keytool -genkey -v \ -keystore docker/biteplan.jks \ -alias biteplan \ - -keyalg RSA \ - -keysize 2048 \ - -validity 10000 + -keyalg RSA -keysize 2048 -validity 10000 ``` -> Il file `docker/biteplan.jks` è già in `.gitignore` — non verrà mai committato. -> Conservalo in un posto sicuro: senza di esso non puoi aggiornare l'app. +> `docker/biteplan.jks` è in `.gitignore` e non verrà mai committato. +> Conservalo in un posto sicuro: senza di esso non puoi pubblicare aggiornamenti. -### 2. Esegui la build release +**2. Esegui la build release** ```bash -bash docker/build.sh --release -# chiede interattivamente: Password keystore / Password chiave +bash docker/build/build.sh --release +# → dist/biteplan-release.apk ``` -Oppure passando le password come variabili d'ambiente (utile in CI): +Il keystore viene montato nel container come volume read-only e non viene mai copiato +nell'immagine Docker. + +> Prima esecuzione: scarica Flutter SDK + Android SDK (~1-2 GB), richiede 10-15 minuti. + +### Installazione sul dispositivo ```bash -KEYSTORE_PASS=tuapassword KEY_PASS=tuapassword bash docker/build.sh --release -``` - -Per usare un keystore in un percorso diverso da `docker/biteplan.jks`: - -```bash -KEYSTORE_PATH=/percorso/biteplan.jks bash docker/build.sh --release -``` - -### 3. Verifica la firma - -```bash -$ANDROID_HOME/build-tools/34.0.0/apksigner verify --verbose dist/biteplan.apk +adb install dist/biteplan-debug.apk +# oppure copia manualmente dist/biteplan-release.apk via USB o Drive ``` --- -## Flag combinabili +## Test -| Comando | Risultato | -|---------|-----------| -| `bash docker/build.sh` | APK debug dalla working directory | -| `bash docker/build.sh --head` | APK debug dall'ultimo commit git | -| `bash docker/build.sh --release` | APK release firmato dalla working directory | -| `bash docker/build.sh --head --release` | APK release firmato dall'ultimo commit git | +I test non richiedono il container dev — usano l'immagine `biteplan-build`, creata +automaticamente al primo `bash docker/build/build.sh`. + +```bash +# Tutti i test (110 test — unit + widget) +docker run --rm -v "$(pwd):/workspace" -w /workspace biteplan-build \ + bash -c "flutter pub get && flutter test" + +# Un singolo file +docker run --rm -v "$(pwd):/workspace" -w /workspace biteplan-build \ + bash -c "flutter test test/features/meal_planner/qr_test.dart" + +# Una singola feature +docker run --rm -v "$(pwd):/workspace" -w /workspace biteplan-build \ + bash -c "flutter test test/features/shopping_list/" +``` --- -## Prima build +## Prima configurazione (android/ assente) -La prima esecuzione scarica Android SDK (~1 GB) e può richiedere **10-15 minuti**. -Le build successive usano la cache Docker e sono molto più rapide. +Se la cartella `android/` non esiste ancora, lo script `build.sh` la genera +automaticamente tramite `flutter create` all'interno del container — non è necessario +avere Flutter installato sull'host. -## Installazione su dispositivo +--- -```bash -adb install dist/biteplan.apk -``` - -## Pipeline +## Flusso completo ``` -[host] npm run build → dist/ -[docker] cap sync → copia dist/ in android/assets/ -[docker] gradlew assembleDebug/Release -[docker] zipalign + apksigner → solo in modalità release -[host] dist/biteplan.apk +# 1. Sviluppo +cd docker/dev && docker compose up +→ http://localhost:5173 (hot reload con 'r') + +# 2. Test +docker run --rm -v "$(pwd):/workspace" -w /workspace biteplan-build \ + bash -c "flutter pub get && flutter test" + +# 3. Build +bash docker/build/build.sh # → dist/biteplan-debug.apk +bash docker/build/build.sh --release # → dist/biteplan-release.apk + +# 4. Installazione +adb install dist/biteplan-debug.apk ``` +--- + ## Note -- App ID: `com.davide.biteplan` +- App ID: `com.biteplan.biteplan` - Android target: API 34 -- Il keystore non viene mai copiato nell'immagine Docker (montato come volume read-only) +- Il keystore non viene mai copiato nell'immagine Docker (volume read-only) +- `dist/` è in `.gitignore` diff --git a/docker/build.sh b/docker/build.sh deleted file mode 100755 index 56247a4..0000000 --- a/docker/build.sh +++ /dev/null @@ -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" diff --git a/docker/build/Dockerfile b/docker/build/Dockerfile new file mode 100644 index 0000000..13d5030 --- /dev/null +++ b/docker/build/Dockerfile @@ -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 diff --git a/docker/build/build.sh b/docker/build/build.sh new file mode 100755 index 0000000..1c34e9b --- /dev/null +++ b/docker/build/build.sh @@ -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|\n \n - - - - - - BitePlan - - -
- - - diff --git a/integration_test/app_test.dart b/integration_test/app_test.dart new file mode 100644 index 0000000..eea5947 --- /dev/null +++ b/integration_test/app_test.dart @@ -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); + }); + }); +} diff --git a/lib/app.dart b/lib/app.dart new file mode 100644 index 0000000..48844a4 --- /dev/null +++ b/lib/app.dart @@ -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 _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', + ), + ], + ), + ); + } +} diff --git a/lib/core/constants/app_constants.dart b/lib/core/constants/app_constants.dart new file mode 100644 index 0000000..ccc55d2 --- /dev/null +++ b/lib/core/constants/app_constants.dart @@ -0,0 +1,34 @@ +const String kAppVersion = '2.0.0'; +const String kAppAuthor = 'Davide Grilli'; +const String kAppLicense = 'EUPL v1.2'; + +const List kDayIds = [ + 'lunedi', + 'martedi', + 'mercoledi', + 'giovedi', + 'venerdi', + 'sabato', + 'domenica', +]; + +const Map kDayLabels = { + 'lunedi': 'Lunedì', + 'martedi': 'Martedì', + 'mercoledi': 'Mercoledì', + 'giovedi': 'Giovedì', + 'venerdi': 'Venerdì', + 'sabato': 'Sabato', + 'domenica': 'Domenica', +}; + +const List kMealSlots = ['colazione', 'pranzo', 'cena']; + +const Map kMealSlotLabels = { + 'colazione': 'Colazione', + 'pranzo': 'Pranzo', + 'cena': 'Cena', +}; + +const String kStorageKeyMeals = 'meals'; +const String kStorageKeyShopping = 'shopping_list'; diff --git a/lib/core/theme/app_theme.dart b/lib/core/theme/app_theme.dart new file mode 100644 index 0000000..d0392df --- /dev/null +++ b/lib/core/theme/app_theme.dart @@ -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), + ), + ); + } +} diff --git a/lib/features/converter/models/conversion_entry.dart b/lib/features/converter/models/conversion_entry.dart new file mode 100644 index 0000000..08646cd --- /dev/null +++ b/lib/features/converter/models/conversion_entry.dart @@ -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; +} diff --git a/lib/features/converter/presentation/pages/converter_page.dart b/lib/features/converter/presentation/pages/converter_page.dart new file mode 100644 index 0000000..d599b1f --- /dev/null +++ b/lib/features/converter/presentation/pages/converter_page.dart @@ -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 createState() => _ConverterPageState(); +} + +class _ConverterPageState extends State { + 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().select(entry); + } + + void _onReset() { + _gramsController.clear(); + context.read().reset(); + } + + void _onSwap() { + _gramsController.clear(); + context.read().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(); + + 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().search(''); + }, + ) + : null, + ), + onChanged: (v) { + context.read().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() + .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(); +} diff --git a/lib/features/converter/providers/converter_provider.dart b/lib/features/converter/providers/converter_provider.dart new file mode 100644 index 0000000..cdb0018 --- /dev/null +++ b/lib/features/converter/providers/converter_provider.dart @@ -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 _db = []; + List _results = []; + ConversionEntry? _selected; + double? _grams; + bool _rawToCooked = true; + + List 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 loadDb() async { + final raw = await rootBundle.loadString('assets/data/conversions.json'); + final Map 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(); + } +} diff --git a/lib/features/guide/presentation/pages/guide_page.dart b/lib/features/guide/presentation/pages/guide_page.dart new file mode 100644 index 0000000..ac87359 --- /dev/null +++ b/lib/features/guide/presentation/pages/guide_page.dart @@ -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 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'; diff --git a/lib/features/guide/presentation/widgets/info_bottom_sheet.dart b/lib/features/guide/presentation/widgets/info_bottom_sheet.dart new file mode 100644 index 0000000..81462cb --- /dev/null +++ b/lib/features/guide/presentation/widgets/info_bottom_sheet.dart @@ -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 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), + ], + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/meal_planner/models/meal_plan.dart b/lib/features/meal_planner/models/meal_plan.dart new file mode 100644 index 0000000..1ddedec --- /dev/null +++ b/lib/features/meal_planner/models/meal_plan.dart @@ -0,0 +1,81 @@ +import 'dart:convert'; + +class DayPlan { + final List colazione; + final List pranzo; + final List cena; + + DayPlan({ + List? colazione, + List? pranzo, + List? cena, + }) : colazione = colazione ?? [], + pranzo = pranzo ?? [], + cena = cena ?? []; + + DayPlan copyWith({ + List? colazione, + List? pranzo, + List? cena, + }) => + DayPlan( + colazione: colazione ?? List.of(this.colazione), + pranzo: pranzo ?? List.of(this.pranzo), + cena: cena ?? List.of(this.cena), + ); + + List slot(String name) => switch (name) { + 'colazione' => colazione, + 'pranzo' => pranzo, + 'cena' => cena, + _ => const [], + }; + + Map toJson() => { + 'colazione': colazione, + 'pranzo': pranzo, + 'cena': cena, + }; + + factory DayPlan.fromJson(Map json) => DayPlan( + colazione: List.from(json['colazione'] ?? []), + pranzo: List.from(json['pranzo'] ?? []), + cena: List.from(json['cena'] ?? []), + ); +} + +class MealPlan { + final Map days; + + const MealPlan(this.days); + + factory MealPlan.empty(List 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 get allItems => days.values + .expand((d) => [...d.colazione, ...d.pranzo, ...d.cena]) + .toList(); + + Map toJson() => + {for (final e in days.entries) e.key: e.value.toJson()}; + + factory MealPlan.fromJson(Map json) => MealPlan({ + for (final e in json.entries) + e.key: DayPlan.fromJson(e.value as Map), + }); + + String toJsonString() => jsonEncode(toJson()); + + factory MealPlan.fromJsonString(String s) => + MealPlan.fromJson(jsonDecode(s) as Map); +} diff --git a/lib/features/meal_planner/presentation/pages/meal_planner_page.dart b/lib/features/meal_planner/presentation/pages/meal_planner_page.dart new file mode 100644 index 0000000..c8277ee --- /dev/null +++ b/lib/features/meal_planner/presentation/pages/meal_planner_page.dart @@ -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 _confirmClear(BuildContext context) async { + final ok = await showDialog( + 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().clearAll(); + } + } + + void _generateShopping(BuildContext context) { + final plan = context.read().plan; + context.read().addAll(plan.allItems); + onGoShopping(); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final provider = context.watch(); + 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().addItem(dayId, slot, text), + onRemove: (slot, index) => + context.read().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'), + ), + ), + ], + ), + ], + ), + ), + ], + ); + } +} diff --git a/lib/features/meal_planner/presentation/pages/qr_scan_page.dart b/lib/features/meal_planner/presentation/pages/qr_scan_page.dart new file mode 100644 index 0000000..c7470c1 --- /dev/null +++ b/lib/features/meal_planner/presentation/pages/qr_scan_page.dart @@ -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 createState() => _QrScanPageState(); +} + +class _QrScanPageState extends State { + 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().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), + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/meal_planner/presentation/widgets/meal_card.dart b/lib/features/meal_planner/presentation/widgets/meal_card.dart new file mode 100644 index 0000000..d103811 --- /dev/null +++ b/lib/features/meal_planner/presentation/widgets/meal_card.dart @@ -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 createState() => _MealCardState(); +} + +class _MealCardState extends State { + late final Map _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 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), + ), + ], + ), + ); + } +} diff --git a/lib/features/meal_planner/presentation/widgets/qr_share_sheet.dart b/lib/features/meal_planner/presentation/widgets/qr_share_sheet.dart new file mode 100644 index 0000000..c3407f4 --- /dev/null +++ b/lib/features/meal_planner/presentation/widgets/qr_share_sheet.dart @@ -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, + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/meal_planner/providers/meal_planner_provider.dart b/lib/features/meal_planner/providers/meal_planner_provider.dart new file mode 100644 index 0000000..40a03ce --- /dev/null +++ b/lib/features/meal_planner/providers/meal_planner_provider.dart @@ -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 load() async { + final json = await StorageService.load(kStorageKeyMeals); + if (json != null) { + try { + _plan = MealPlan.fromJsonString(json); + } catch (_) { + _plan = MealPlan.empty(kDayIds); + } + } + notifyListeners(); + } + + Future _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(); + } +} diff --git a/lib/features/meal_planner/qr_codec.dart b/lib/features/meal_planner/qr_codec.dart new file mode 100644 index 0000000..ac61f6f --- /dev/null +++ b/lib/features/meal_planner/qr_codec.dart @@ -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; + if (parsed['v'] != 1 || parsed['meals'] is! Map) { + return (null, 'QR non valido: dati non riconosciuti.'); + } + final mealsMap = parsed['meals'] as Map; + 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; + return MapEntry( + day, + DayPlan( + colazione: List.from(d['colazione'] as List), + pranzo: List.from(d['pranzo'] as List), + cena: List.from(d['cena'] as List), + ), + ); + }), + )); + return (plan, null); + } catch (_) { + return (null, 'QR non riconosciuto.'); + } +} diff --git a/lib/features/shopping_list/models/shopping_item.dart b/lib/features/shopping_list/models/shopping_item.dart new file mode 100644 index 0000000..e126947 --- /dev/null +++ b/lib/features/shopping_list/models/shopping_item.dart @@ -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 toJson() => { + 'id': id, + 'name': name, + 'checked': checked, + 'quantity': quantity, + }; + + factory ShoppingItem.fromJson(Map json) => ShoppingItem( + id: json['id'] as String, + name: json['name'] as String, + checked: json['checked'] as bool? ?? false, + quantity: json['quantity'] as int? ?? 1, + ); +} diff --git a/lib/features/shopping_list/presentation/pages/shopping_list_page.dart b/lib/features/shopping_list/presentation/pages/shopping_list_page.dart new file mode 100644 index 0000000..c39819d --- /dev/null +++ b/lib/features/shopping_list/presentation/pages/shopping_list_page.dart @@ -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 createState() => _ShoppingListPageState(); +} + +class _ShoppingListPageState extends State { + final _controller = TextEditingController(); + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + void _add() { + final text = _controller.text.trim(); + if (text.isEmpty) return; + context.read().add(text); + _controller.clear(); + } + + Future _confirmClear() async { + final ok = await showDialog( + 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().clearAll(); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final provider = context.watch(); + 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() + .toggle(pending[i].id), + onRemove: () => context + .read() + .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() + .toggle(checked[i].id), + onRemove: () => context + .read() + .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'), + ), + ], + ), + ), + ], + ); + } +} diff --git a/lib/features/shopping_list/presentation/widgets/shopping_item_tile.dart b/lib/features/shopping_list/presentation/widgets/shopping_item_tile.dart new file mode 100644 index 0000000..fccc768 --- /dev/null +++ b/lib/features/shopping_list/presentation/widgets/shopping_item_tile.dart @@ -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, + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/shopping_list/providers/shopping_list_provider.dart b/lib/features/shopping_list/providers/shopping_list_provider.dart new file mode 100644 index 0000000..79fef9e --- /dev/null +++ b/lib/features/shopping_list/providers/shopping_list_provider.dart @@ -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 _items = []; + int _idSeq = 0; + + List get items => _items; + List get pendingItems => + _items.where((i) => !i.checked).toList(); + List get checkedItems => + _items.where((i) => i.checked).toList(); + + Future 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)) + .toList(); + } catch (_) { + _items = []; + } + } + notifyListeners(); + } + + Future _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 names) { + final existing = _items.map((i) => i.name.toLowerCase()).toSet(); + + // Conta le occorrenze mantenendo il nome nella sua forma originale (prima occorrenza) + final counts = {}; + final canonical = {}; + 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 = []; + 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(); + } +} diff --git a/lib/main.dart b/lib/main.dart new file mode 100644 index 0000000..90380ec --- /dev/null +++ b/lib/main.dart @@ -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(), + ), + ); +} diff --git a/lib/shared/services/storage_service.dart b/lib/shared/services/storage_service.dart new file mode 100644 index 0000000..30a58d5 --- /dev/null +++ b/lib/shared/services/storage_service.dart @@ -0,0 +1,13 @@ +import 'package:shared_preferences/shared_preferences.dart'; + +class StorageService { + static Future load(String key) async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getString(key); + } + + static Future save(String key, String value) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(key, value); + } +} diff --git a/lib/shared/widgets/empty_state.dart b/lib/shared/widgets/empty_state.dart new file mode 100644 index 0000000..e455f82 --- /dev/null +++ b/lib/shared/widgets/empty_state.dart @@ -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, + ), + ], + ], + ), + ), + ); + } +} diff --git a/package-lock.json b/package-lock.json deleted file mode 100644 index c8e5267..0000000 --- a/package-lock.json +++ /dev/null @@ -1,3572 +0,0 @@ -{ - "name": "biteplan", - "version": "1.1.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "biteplan", - "version": "1.1.0", - "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" - } - }, - "node_modules/@ampproject/remapping": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", - "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", - "dev": true, - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@asamuzakjp/css-color": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.0.1.tgz", - "integrity": "sha512-2SZFvqMyvboVV1d15lMf7XiI3m7SDqXUuKaTymJYLN6dSGadqp+fVojqJlVoMlbZnlTmu3S0TLwLTJpvBMO1Aw==", - "dev": true, - "dependencies": { - "@csstools/css-calc": "^3.1.1", - "@csstools/css-color-parser": "^4.0.2", - "@csstools/css-parser-algorithms": "^4.0.0", - "@csstools/css-tokenizer": "^4.0.0", - "lru-cache": "^11.2.6" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - } - }, - "node_modules/@asamuzakjp/dom-selector": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.0.4.tgz", - "integrity": "sha512-jXR6x4AcT3eIrS2fSNAwJpwirOkGcd+E7F7CP3zjdTqz9B/2huHOL8YJZBgekKwLML+u7qB/6P1LXQuMScsx0w==", - "dev": true, - "dependencies": { - "@asamuzakjp/nwsapi": "^2.3.9", - "bidi-js": "^1.0.3", - "css-tree": "^3.2.1", - "is-potential-custom-element-name": "^1.0.1", - "lru-cache": "^11.2.7" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - } - }, - "node_modules/@asamuzakjp/nwsapi": { - "version": "2.3.9", - "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", - "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", - "dev": true - }, - "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/parser": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", - "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", - "dependencies": { - "@babel/types": "^7.29.0" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", - "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@bcoe/v8-coverage": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", - "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/@bramus/specificity": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", - "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", - "dev": true, - "dependencies": { - "css-tree": "^3.0.0" - }, - "bin": { - "specificity": "bin/cli.js" - } - }, - "node_modules/@csstools/color-helpers": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", - "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "engines": { - "node": ">=20.19.0" - } - }, - "node_modules/@csstools/css-calc": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.1.1.tgz", - "integrity": "sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "engines": { - "node": ">=20.19.0" - }, - "peerDependencies": { - "@csstools/css-parser-algorithms": "^4.0.0", - "@csstools/css-tokenizer": "^4.0.0" - } - }, - "node_modules/@csstools/css-color-parser": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.0.2.tgz", - "integrity": "sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "dependencies": { - "@csstools/color-helpers": "^6.0.2", - "@csstools/css-calc": "^3.1.1" - }, - "engines": { - "node": ">=20.19.0" - }, - "peerDependencies": { - "@csstools/css-parser-algorithms": "^4.0.0", - "@csstools/css-tokenizer": "^4.0.0" - } - }, - "node_modules/@csstools/css-parser-algorithms": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", - "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "engines": { - "node": ">=20.19.0" - }, - "peerDependencies": { - "@csstools/css-tokenizer": "^4.0.0" - } - }, - "node_modules/@csstools/css-syntax-patches-for-csstree": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.2.tgz", - "integrity": "sha512-5GkLzz4prTIpoyeUiIu3iV6CSG3Plo7xRVOFPKI7FVEJ3mZ0A8SwK0XU3Gl7xAkiQ+mDyam+NNp875/C5y+jSA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "peerDependencies": { - "css-tree": "^3.2.1" - }, - "peerDependenciesMeta": { - "css-tree": { - "optional": true - } - } - }, - "node_modules/@csstools/css-tokenizer": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", - "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "engines": { - "node": ">=20.19.0" - } - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", - "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", - "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", - "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", - "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", - "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", - "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", - "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", - "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", - "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", - "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", - "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", - "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", - "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", - "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", - "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", - "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", - "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", - "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", - "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", - "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", - "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", - "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", - "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", - "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", - "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", - "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@exodus/bytes": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz", - "integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==", - "dev": true, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - }, - "peerDependencies": { - "@noble/hashes": "^1.8.0 || ^2.0.0" - }, - "peerDependenciesMeta": { - "@noble/hashes": { - "optional": true - } - } - }, - "node_modules/@isaacs/cliui": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", - "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", - "dev": true, - "dependencies": { - "string-width": "^5.1.2", - "string-width-cjs": "npm:string-width@^4.2.0", - "strip-ansi": "^7.0.1", - "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", - "wrap-ansi": "^8.1.0", - "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@istanbuljs/schema": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", - "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.13", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", - "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==" - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.31", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", - "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, - "node_modules/@one-ini/wasm": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@one-ini/wasm/-/wasm-0.1.1.tgz", - "integrity": "sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==", - "dev": true - }, - "node_modules/@pkgjs/parseargs": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", - "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", - "dev": true, - "optional": true, - "engines": { - "node": ">=14" - } - }, - "node_modules/@playwright/test": { - "version": "1.58.2", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz", - "integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==", - "dev": true, - "dependencies": { - "playwright": "1.58.2" - }, - "bin": { - "playwright": "cli.js" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.0.tgz", - "integrity": "sha512-WOhNW9K8bR3kf4zLxbfg6Pxu2ybOUbB2AjMDHSQx86LIF4rH4Ft7vmMwNt0loO0eonglSNy4cpD3MKXXKQu0/A==", - "cpu": [ - "arm" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.0.tgz", - "integrity": "sha512-u6JHLll5QKRvjciE78bQXDmqRqNs5M/3GVqZeMwvmjaNODJih/WIrJlFVEihvV0MiYFmd+ZyPr9wxOVbPAG2Iw==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.0.tgz", - "integrity": "sha512-qEF7CsKKzSRc20Ciu2Zw1wRrBz4g56F7r/vRwY430UPp/nt1x21Q/fpJ9N5l47WWvJlkNCPJz3QRVw008fi7yA==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.0.tgz", - "integrity": "sha512-WADYozJ4QCnXCH4wPB+3FuGmDPoFseVCUrANmA5LWwGmC6FL14BWC7pcq+FstOZv3baGX65tZ378uT6WG8ynTw==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.0.tgz", - "integrity": "sha512-6b8wGHJlDrGeSE3aH5mGNHBjA0TTkxdoNHik5EkvPHCt351XnigA4pS7Wsj/Eo9Y8RBU6f35cjN9SYmCFBtzxw==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.0.tgz", - "integrity": "sha512-h25Ga0t4jaylMB8M/JKAyrvvfxGRjnPQIR8lnCayyzEjEOx2EJIlIiMbhpWxDRKGKF8jbNH01NnN663dH638mA==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.0.tgz", - "integrity": "sha512-RzeBwv0B3qtVBWtcuABtSuCzToo2IEAIQrcyB/b2zMvBWVbjo8bZDjACUpnaafaxhTw2W+imQbP2BD1usasK4g==", - "cpu": [ - "arm" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.0.tgz", - "integrity": "sha512-Sf7zusNI2CIU1HLzuu9Tc5YGAHEZs5Lu7N1ssJG4Tkw6e0MEsN7NdjUDDfGNHy2IU+ENyWT+L2obgWiguWibWQ==", - "cpu": [ - "arm" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.0.tgz", - "integrity": "sha512-DX2x7CMcrJzsE91q7/O02IJQ5/aLkVtYFryqCjduJhUfGKG6yJV8hxaw8pZa93lLEpPTP/ohdN4wFz7yp/ry9A==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.0.tgz", - "integrity": "sha512-09EL+yFVbJZlhcQfShpswwRZ0Rg+z/CsSELFCnPt3iK+iqwGsI4zht3secj5vLEs957QvFFXnzAT0FFPIxSrkQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.0.tgz", - "integrity": "sha512-i9IcCMPr3EXm8EQg5jnja0Zyc1iFxJjZWlb4wr7U2Wx/GrddOuEafxRdMPRYVaXjgbhvqalp6np07hN1w9kAKw==", - "cpu": [ - "loong64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.0.tgz", - "integrity": "sha512-DGzdJK9kyJ+B78MCkWeGnpXJ91tK/iKA6HwHxF4TAlPIY7GXEvMe8hBFRgdrR9Ly4qebR/7gfUs9y2IoaVEyog==", - "cpu": [ - "loong64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.0.tgz", - "integrity": "sha512-RwpnLsqC8qbS8z1H1AxBA1H6qknR4YpPR9w2XX0vo2Sz10miu57PkNcnHVaZkbqyw/kUWfKMI73jhmfi9BRMUQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.0.tgz", - "integrity": "sha512-Z8pPf54Ly3aqtdWC3G4rFigZgNvd+qJlOE52fmko3KST9SoGfAdSRCwyoyG05q1HrrAblLbk1/PSIV+80/pxLg==", - "cpu": [ - "ppc64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.0.tgz", - "integrity": "sha512-3a3qQustp3COCGvnP4SvrMHnPQ9d1vzCakQVRTliaz8cIp/wULGjiGpbcqrkv0WrHTEp8bQD/B3HBjzujVWLOA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.0.tgz", - "integrity": "sha512-pjZDsVH/1VsghMJ2/kAaxt6dL0psT6ZexQVrijczOf+PeP2BUqTHYejk3l6TlPRydggINOeNRhvpLa0AYpCWSQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.0.tgz", - "integrity": "sha512-3ObQs0BhvPgiUVZrN7gqCSvmFuMWvWvsjG5ayJ3Lraqv+2KhOsp+pUbigqbeWqueGIsnn+09HBw27rJ+gYK4VQ==", - "cpu": [ - "s390x" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.0.tgz", - "integrity": "sha512-EtylprDtQPdS5rXvAayrNDYoJhIz1/vzN2fEubo3yLE7tfAw+948dO0g4M0vkTVFhKojnF+n6C8bDNe+gDRdTg==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.0.tgz", - "integrity": "sha512-k09oiRCi/bHU9UVFqD17r3eJR9bn03TyKraCrlz5ULFJGdJGi7VOmm9jl44vOJvRJ6P7WuBi/s2A97LxxHGIdw==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.0.tgz", - "integrity": "sha512-1o/0/pIhozoSaDJoDcec+IVLbnRtQmHwPV730+AOD29lHEEo4F5BEUB24H0OBdhbBBDwIOSuf7vgg0Ywxdfiiw==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "openbsd" - ] - }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.0.tgz", - "integrity": "sha512-pESDkos/PDzYwtyzB5p/UoNU/8fJo68vcXM9ZW2V0kjYayj1KaaUfi1NmTUTUpMn4UhU4gTuK8gIaFO4UGuMbA==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "openharmony" - ] - }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.0.tgz", - "integrity": "sha512-hj1wFStD7B1YBeYmvY+lWXZ7ey73YGPcViMShYikqKT1GtstIKQAtfUI6yrzPjAy/O7pO0VLXGmUVWXQMaYgTQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.0.tgz", - "integrity": "sha512-SyaIPFoxmUPlNDq5EHkTbiKzmSEmq/gOYFI/3HHJ8iS/v1mbugVa7dXUzcJGQfoytp9DJFLhHH4U3/eTy2Bq4w==", - "cpu": [ - "ia32" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.0.tgz", - "integrity": "sha512-RdcryEfzZr+lAr5kRm2ucN9aVlCCa2QNq4hXelZxb8GG0NJSazq44Z3PCCc8wISRuCVnGs0lQJVX5Vp6fKA+IA==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.0.tgz", - "integrity": "sha512-PrsWNQ8BuE00O3Xsx3ALh2Df8fAj9+cvvX9AIA6o4KpATR98c9mud4XtDWVvsEuyia5U4tVSTKygawyJkjm60w==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@types/chai": { - "version": "5.2.3", - "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", - "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/deep-eql": "*", - "assertion-error": "^2.0.1" - } - }, - "node_modules/@types/deep-eql": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", - "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true - }, - "node_modules/@types/node": { - "version": "25.5.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz", - "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==", - "dev": true, - "dependencies": { - "undici-types": "~7.18.0" - } - }, - "node_modules/@types/whatwg-mimetype": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz", - "integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==", - "dev": true - }, - "node_modules/@types/ws": { - "version": "8.18.1", - "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", - "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", - "dev": true, - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@vitejs/plugin-vue": { - "version": "5.2.4", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", - "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", - "dev": true, - "engines": { - "node": "^18.0.0 || >=20.0.0" - }, - "peerDependencies": { - "vite": "^5.0.0 || ^6.0.0", - "vue": "^3.2.25" - } - }, - "node_modules/@vitest/coverage-v8": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.2.4.tgz", - "integrity": "sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@ampproject/remapping": "^2.3.0", - "@bcoe/v8-coverage": "^1.0.2", - "ast-v8-to-istanbul": "^0.3.3", - "debug": "^4.4.1", - "istanbul-lib-coverage": "^3.2.2", - "istanbul-lib-report": "^3.0.1", - "istanbul-lib-source-maps": "^5.0.6", - "istanbul-reports": "^3.1.7", - "magic-string": "^0.30.17", - "magicast": "^0.3.5", - "std-env": "^3.9.0", - "test-exclude": "^7.0.1", - "tinyrainbow": "^2.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "@vitest/browser": "3.2.4", - "vitest": "3.2.4" - }, - "peerDependenciesMeta": { - "@vitest/browser": { - "optional": true - } - } - }, - "node_modules/@vitest/expect": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", - "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/chai": "^5.2.2", - "@vitest/spy": "3.2.4", - "@vitest/utils": "3.2.4", - "chai": "^5.2.0", - "tinyrainbow": "^2.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/mocker": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", - "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/spy": "3.2.4", - "estree-walker": "^3.0.3", - "magic-string": "^0.30.17" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "msw": "^2.4.9", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" - }, - "peerDependenciesMeta": { - "msw": { - "optional": true - }, - "vite": { - "optional": true - } - } - }, - "node_modules/@vitest/mocker/node_modules/estree-walker": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", - "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0" - } - }, - "node_modules/@vitest/pretty-format": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", - "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyrainbow": "^2.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/runner": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", - "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/utils": "3.2.4", - "pathe": "^2.0.3", - "strip-literal": "^3.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/snapshot": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", - "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "3.2.4", - "magic-string": "^0.30.17", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/spy": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", - "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyspy": "^4.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/utils": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", - "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "3.2.4", - "loupe": "^3.1.4", - "tinyrainbow": "^2.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vue/compiler-core": { - "version": "3.5.30", - "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.30.tgz", - "integrity": "sha512-s3DfdZkcu/qExZ+td75015ljzHc6vE+30cFMGRPROYjqkroYI5NV2X1yAMX9UeyBNWB9MxCfPcsjpLS11nzkkw==", - "dependencies": { - "@babel/parser": "^7.29.0", - "@vue/shared": "3.5.30", - "entities": "^7.0.1", - "estree-walker": "^2.0.2", - "source-map-js": "^1.2.1" - } - }, - "node_modules/@vue/compiler-dom": { - "version": "3.5.30", - "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.30.tgz", - "integrity": "sha512-eCFYESUEVYHhiMuK4SQTldO3RYxyMR/UQL4KdGD1Yrkfdx4m/HYuZ9jSfPdA+nWJY34VWndiYdW/wZXyiPEB9g==", - "dependencies": { - "@vue/compiler-core": "3.5.30", - "@vue/shared": "3.5.30" - } - }, - "node_modules/@vue/compiler-sfc": { - "version": "3.5.30", - "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.30.tgz", - "integrity": "sha512-LqmFPDn89dtU9vI3wHJnwaV6GfTRD87AjWpTWpyrdVOObVtjIuSeZr181z5C4PmVx/V3j2p+0f7edFKGRMpQ5A==", - "dependencies": { - "@babel/parser": "^7.29.0", - "@vue/compiler-core": "3.5.30", - "@vue/compiler-dom": "3.5.30", - "@vue/compiler-ssr": "3.5.30", - "@vue/shared": "3.5.30", - "estree-walker": "^2.0.2", - "magic-string": "^0.30.21", - "postcss": "^8.5.8", - "source-map-js": "^1.2.1" - } - }, - "node_modules/@vue/compiler-ssr": { - "version": "3.5.30", - "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.30.tgz", - "integrity": "sha512-NsYK6OMTnx109PSL2IAyf62JP6EUdk4Dmj6AkWcJGBvN0dQoMYtVekAmdqgTtWQgEJo+Okstbf/1p7qZr5H+bA==", - "dependencies": { - "@vue/compiler-dom": "3.5.30", - "@vue/shared": "3.5.30" - } - }, - "node_modules/@vue/reactivity": { - "version": "3.5.30", - "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.30.tgz", - "integrity": "sha512-179YNgKATuwj9gB+66snskRDOitDiuOZqkYia7mHKJaidOMo/WJxHKF8DuGc4V4XbYTJANlfEKb0yxTQotnx4Q==", - "dependencies": { - "@vue/shared": "3.5.30" - } - }, - "node_modules/@vue/runtime-core": { - "version": "3.5.30", - "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.30.tgz", - "integrity": "sha512-e0Z+8PQsUTdwV8TtEsLzUM7SzC7lQwYKePydb7K2ZnmS6jjND+WJXkmmfh/swYzRyfP1EY3fpdesyYoymCzYfg==", - "dependencies": { - "@vue/reactivity": "3.5.30", - "@vue/shared": "3.5.30" - } - }, - "node_modules/@vue/runtime-dom": { - "version": "3.5.30", - "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.30.tgz", - "integrity": "sha512-2UIGakjU4WSQ0T4iwDEW0W7vQj6n7AFn7taqZ9Cvm0Q/RA2FFOziLESrDL4GmtI1wV3jXg5nMoJSYO66egDUBw==", - "dependencies": { - "@vue/reactivity": "3.5.30", - "@vue/runtime-core": "3.5.30", - "@vue/shared": "3.5.30", - "csstype": "^3.2.3" - } - }, - "node_modules/@vue/server-renderer": { - "version": "3.5.30", - "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.30.tgz", - "integrity": "sha512-v+R34icapydRwbZRD0sXwtHqrQJv38JuMB4JxbOxd8NEpGLny7cncMp53W9UH/zo4j8eDHjQ1dEJXwzFQknjtQ==", - "dependencies": { - "@vue/compiler-ssr": "3.5.30", - "@vue/shared": "3.5.30" - }, - "peerDependencies": { - "vue": "3.5.30" - } - }, - "node_modules/@vue/shared": { - "version": "3.5.30", - "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.30.tgz", - "integrity": "sha512-YXgQ7JjaO18NeK2K9VTbDHaFy62WrObMa6XERNfNOkAhD1F1oDSf3ZJ7K6GqabZ0BvSDHajp8qfS5Sa2I9n8uQ==" - }, - "node_modules/@vue/test-utils": { - "version": "2.4.6", - "resolved": "https://registry.npmjs.org/@vue/test-utils/-/test-utils-2.4.6.tgz", - "integrity": "sha512-FMxEjOpYNYiFe0GkaHsnJPXFHxQ6m4t8vI/ElPGpMWxZKpmRvQ33OIrvRXemy6yha03RxhOlQuy+gZMC3CQSow==", - "dev": true, - "dependencies": { - "js-beautify": "^1.14.9", - "vue-component-type-helpers": "^2.0.0" - } - }, - "node_modules/abbrev": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-2.0.0.tgz", - "integrity": "sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==", - "dev": true, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/assertion-error": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", - "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - } - }, - "node_modules/ast-v8-to-istanbul": { - "version": "0.3.12", - "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.12.tgz", - "integrity": "sha512-BRRC8VRZY2R4Z4lFIL35MwNXmwVqBityvOIwETtsCSwvjl0IdgFsy9NhdaA6j74nUdtJJlIypeRhpDam19Wq3g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/trace-mapping": "^0.3.31", - "estree-walker": "^3.0.3", - "js-tokens": "^10.0.0" - } - }, - "node_modules/ast-v8-to-istanbul/node_modules/estree-walker": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", - "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0" - } - }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true - }, - "node_modules/bidi-js": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", - "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", - "dev": true, - "dependencies": { - "require-from-string": "^2.0.2" - } - }, - "node_modules/brace-expansion": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", - "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", - "dev": true, - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/cac": { - "version": "6.7.14", - "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", - "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/camelcase": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", - "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/chai": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", - "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", - "dev": true, - "license": "MIT", - "dependencies": { - "assertion-error": "^2.0.1", - "check-error": "^2.1.1", - "deep-eql": "^5.0.1", - "loupe": "^3.1.0", - "pathval": "^2.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/check-error": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", - "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 16" - } - }, - "node_modules/cliui": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", - "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", - "license": "ISC", - "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.0", - "wrap-ansi": "^6.2.0" - } - }, - "node_modules/cliui/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/cliui/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/cliui/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "license": "MIT" - }, - "node_modules/cliui/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/cliui/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/cliui/node_modules/wrap-ansi": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", - "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" - }, - "node_modules/commander": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", - "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==", - "dev": true, - "engines": { - "node": ">=14" - } - }, - "node_modules/config-chain": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz", - "integrity": "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==", - "dev": true, - "dependencies": { - "ini": "^1.3.4", - "proto-list": "~1.2.1" - } - }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/css-tree": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", - "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", - "dev": true, - "dependencies": { - "mdn-data": "2.27.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" - } - }, - "node_modules/csstype": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", - "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==" - }, - "node_modules/data-urls": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", - "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", - "dev": true, - "dependencies": { - "whatwg-mimetype": "^5.0.0", - "whatwg-url": "^16.0.0" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - } - }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/decamelize": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", - "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/decimal.js": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", - "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", - "dev": true - }, - "node_modules/deep-eql": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", - "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/dijkstrajs": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", - "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", - "license": "MIT" - }, - "node_modules/eastasianwidth": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", - "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "dev": true - }, - "node_modules/editorconfig": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/editorconfig/-/editorconfig-1.0.7.tgz", - "integrity": "sha512-e0GOtq/aTQhVdNyDU9e02+wz9oDDM+SIOQxWME2QRjzRX5yyLAuHDE+0aE8vHb9XRC8XD37eO2u57+F09JqFhw==", - "dev": true, - "dependencies": { - "@one-ini/wasm": "0.1.1", - "commander": "^10.0.0", - "minimatch": "^9.0.1", - "semver": "^7.5.3" - }, - "bin": { - "editorconfig": "bin/editorconfig" - }, - "engines": { - "node": ">=14" - } - }, - "node_modules/emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "dev": true - }, - "node_modules/entities": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", - "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", - "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, - "node_modules/es-module-lexer": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", - "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", - "dev": true, - "license": "MIT" - }, - "node_modules/esbuild": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", - "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.12", - "@esbuild/android-arm": "0.25.12", - "@esbuild/android-arm64": "0.25.12", - "@esbuild/android-x64": "0.25.12", - "@esbuild/darwin-arm64": "0.25.12", - "@esbuild/darwin-x64": "0.25.12", - "@esbuild/freebsd-arm64": "0.25.12", - "@esbuild/freebsd-x64": "0.25.12", - "@esbuild/linux-arm": "0.25.12", - "@esbuild/linux-arm64": "0.25.12", - "@esbuild/linux-ia32": "0.25.12", - "@esbuild/linux-loong64": "0.25.12", - "@esbuild/linux-mips64el": "0.25.12", - "@esbuild/linux-ppc64": "0.25.12", - "@esbuild/linux-riscv64": "0.25.12", - "@esbuild/linux-s390x": "0.25.12", - "@esbuild/linux-x64": "0.25.12", - "@esbuild/netbsd-arm64": "0.25.12", - "@esbuild/netbsd-x64": "0.25.12", - "@esbuild/openbsd-arm64": "0.25.12", - "@esbuild/openbsd-x64": "0.25.12", - "@esbuild/openharmony-arm64": "0.25.12", - "@esbuild/sunos-x64": "0.25.12", - "@esbuild/win32-arm64": "0.25.12", - "@esbuild/win32-ia32": "0.25.12", - "@esbuild/win32-x64": "0.25.12" - } - }, - "node_modules/estree-walker": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", - "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==" - }, - "node_modules/expect-type": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", - "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/find-up": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", - "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", - "license": "MIT", - "dependencies": { - "locate-path": "^5.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/foreground-child": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", - "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", - "dev": true, - "dependencies": { - "cross-spawn": "^7.0.6", - "signal-exit": "^4.0.1" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/get-caller-file": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", - "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "license": "ISC", - "engines": { - "node": "6.* || 8.* || >= 10.*" - } - }, - "node_modules/glob": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", - "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "dev": true, - "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/happy-dom": { - "version": "20.8.9", - "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.8.9.tgz", - "integrity": "sha512-Tz23LR9T9jOGVZm2x1EPdXqwA37G/owYMxRwU0E4miurAtFsPMQ1d2Jc2okUaSjZqAFz2oEn3FLXC5a0a+siyA==", - "dev": true, - "dependencies": { - "@types/node": ">=20.0.0", - "@types/whatwg-mimetype": "^3.0.2", - "@types/ws": "^8.18.1", - "entities": "^7.0.1", - "whatwg-mimetype": "^3.0.0", - "ws": "^8.18.3" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/happy-dom/node_modules/whatwg-mimetype": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", - "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", - "dev": true, - "engines": { - "node": ">=12" - } - }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/html-encoding-sniffer": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", - "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", - "dev": true, - "dependencies": { - "@exodus/bytes": "^1.6.0" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - } - }, - "node_modules/html-escaper": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", - "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", - "dev": true - }, - "node_modules/ini": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", - "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", - "dev": true - }, - "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "engines": { - "node": ">=8" - } - }, - "node_modules/is-potential-custom-element-name": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", - "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", - "dev": true - }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true - }, - "node_modules/istanbul-lib-coverage": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", - "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/istanbul-lib-report": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", - "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", - "dev": true, - "dependencies": { - "istanbul-lib-coverage": "^3.0.0", - "make-dir": "^4.0.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/istanbul-lib-source-maps": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", - "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", - "dev": true, - "dependencies": { - "@jridgewell/trace-mapping": "^0.3.23", - "debug": "^4.1.1", - "istanbul-lib-coverage": "^3.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/istanbul-reports": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", - "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", - "dev": true, - "dependencies": { - "html-escaper": "^2.0.0", - "istanbul-lib-report": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/jackspeak": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", - "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", - "dev": true, - "dependencies": { - "@isaacs/cliui": "^8.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - }, - "optionalDependencies": { - "@pkgjs/parseargs": "^0.11.0" - } - }, - "node_modules/js-beautify": { - "version": "1.15.4", - "resolved": "https://registry.npmjs.org/js-beautify/-/js-beautify-1.15.4.tgz", - "integrity": "sha512-9/KXeZUKKJwqCXUdBxFJ3vPh467OCckSBmYDwSK/EtV090K+iMJ7zx2S3HLVDIWFQdqMIsZWbnaGiba18aWhaA==", - "dev": true, - "dependencies": { - "config-chain": "^1.1.13", - "editorconfig": "^1.0.4", - "glob": "^10.4.2", - "js-cookie": "^3.0.5", - "nopt": "^7.2.1" - }, - "bin": { - "css-beautify": "js/bin/css-beautify.js", - "html-beautify": "js/bin/html-beautify.js", - "js-beautify": "js/bin/js-beautify.js" - }, - "engines": { - "node": ">=14" - } - }, - "node_modules/js-cookie": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz", - "integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==", - "dev": true, - "engines": { - "node": ">=14" - } - }, - "node_modules/js-tokens": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", - "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/jsdom": { - "version": "29.0.1", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.0.1.tgz", - "integrity": "sha512-z6JOK5gRO7aMybVq/y/MlIpKh8JIi68FBKMUtKkK2KH/wMSRlCxQ682d08LB9fYXplyY/UXG8P4XXTScmdjApg==", - "dev": true, - "dependencies": { - "@asamuzakjp/css-color": "^5.0.1", - "@asamuzakjp/dom-selector": "^7.0.3", - "@bramus/specificity": "^2.4.2", - "@csstools/css-syntax-patches-for-csstree": "^1.1.1", - "@exodus/bytes": "^1.15.0", - "css-tree": "^3.2.1", - "data-urls": "^7.0.0", - "decimal.js": "^10.6.0", - "html-encoding-sniffer": "^6.0.0", - "is-potential-custom-element-name": "^1.0.1", - "lru-cache": "^11.2.7", - "parse5": "^8.0.0", - "saxes": "^6.0.0", - "symbol-tree": "^3.2.4", - "tough-cookie": "^6.0.1", - "undici": "^7.24.5", - "w3c-xmlserializer": "^5.0.0", - "webidl-conversions": "^8.0.1", - "whatwg-mimetype": "^5.0.0", - "whatwg-url": "^16.0.1", - "xml-name-validator": "^5.0.0" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24.0.0" - }, - "peerDependencies": { - "canvas": "^3.0.0" - }, - "peerDependenciesMeta": { - "canvas": { - "optional": true - } - } - }, - "node_modules/jsqr": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/jsqr/-/jsqr-1.4.0.tgz", - "integrity": "sha512-dxLob7q65Xg2DvstYkRpkYtmKm2sPJ9oFhrhmudT1dZvNFFTlroai3AWSpLey/w5vMcLBXRgOJsbXpdN9HzU/A==", - "license": "Apache-2.0" - }, - "node_modules/locate-path": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", - "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", - "license": "MIT", - "dependencies": { - "p-locate": "^4.1.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/loupe": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", - "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/lru-cache": { - "version": "11.2.7", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", - "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", - "dev": true, - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" - } - }, - "node_modules/magicast": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.5.tgz", - "integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==", - "dev": true, - "dependencies": { - "@babel/parser": "^7.25.4", - "@babel/types": "^7.25.4", - "source-map-js": "^1.2.0" - } - }, - "node_modules/make-dir": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", - "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", - "dev": true, - "dependencies": { - "semver": "^7.5.3" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/mdn-data": { - "version": "2.27.1", - "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", - "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", - "dev": true - }, - "node_modules/minimatch": { - "version": "9.0.9", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", - "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", - "dev": true, - "dependencies": { - "brace-expansion": "^2.0.2" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/minipass": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", - "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", - "dev": true, - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true - }, - "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/nopt": { - "version": "7.2.1", - "resolved": "https://registry.npmjs.org/nopt/-/nopt-7.2.1.tgz", - "integrity": "sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w==", - "dev": true, - "dependencies": { - "abbrev": "^2.0.0" - }, - "bin": { - "nopt": "bin/nopt.js" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/p-limit": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", - "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", - "license": "MIT", - "dependencies": { - "p-try": "^2.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-locate": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", - "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", - "license": "MIT", - "dependencies": { - "p-limit": "^2.2.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/p-try": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", - "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/package-json-from-dist": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", - "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", - "dev": true - }, - "node_modules/parse5": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", - "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", - "dev": true, - "dependencies": { - "entities": "^6.0.0" - }, - "funding": { - "url": "https://github.com/inikulin/parse5?sponsor=1" - } - }, - "node_modules/parse5/node_modules/entities": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", - "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", - "dev": true, - "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, - "node_modules/path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/path-scurry": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", - "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", - "dev": true, - "dependencies": { - "lru-cache": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" - }, - "engines": { - "node": ">=16 || 14 >=14.18" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/path-scurry/node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true - }, - "node_modules/pathe": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", - "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "dev": true, - "license": "MIT" - }, - "node_modules/pathval": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", - "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14.16" - } - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==" - }, - "node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/playwright": { - "version": "1.58.2", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", - "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", - "dev": true, - "dependencies": { - "playwright-core": "1.58.2" - }, - "bin": { - "playwright": "cli.js" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "fsevents": "2.3.2" - } - }, - "node_modules/playwright-core": { - "version": "1.58.2", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", - "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", - "dev": true, - "bin": { - "playwright-core": "cli.js" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/playwright/node_modules/fsevents": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", - "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, - "hasInstallScript": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/pngjs": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", - "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", - "license": "MIT", - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/postcss": { - "version": "8.5.8", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", - "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "dependencies": { - "nanoid": "^3.3.11", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/proto-list": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz", - "integrity": "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==", - "dev": true - }, - "node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/qrcode": { - "version": "1.5.4", - "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", - "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", - "license": "MIT", - "dependencies": { - "dijkstrajs": "^1.0.1", - "pngjs": "^5.0.0", - "yargs": "^15.3.1" - }, - "bin": { - "qrcode": "bin/qrcode" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/require-directory": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", - "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/require-from-string": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/require-main-filename": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", - "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", - "license": "ISC" - }, - "node_modules/rollup": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.0.tgz", - "integrity": "sha512-yqjxruMGBQJ2gG4HtjZtAfXArHomazDHoFwFFmZZl0r7Pdo7qCIXKqKHZc8yeoMgzJJ+pO6pEEHa+V7uzWlrAQ==", - "dev": true, - "dependencies": { - "@types/estree": "1.0.8" - }, - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.60.0", - "@rollup/rollup-android-arm64": "4.60.0", - "@rollup/rollup-darwin-arm64": "4.60.0", - "@rollup/rollup-darwin-x64": "4.60.0", - "@rollup/rollup-freebsd-arm64": "4.60.0", - "@rollup/rollup-freebsd-x64": "4.60.0", - "@rollup/rollup-linux-arm-gnueabihf": "4.60.0", - "@rollup/rollup-linux-arm-musleabihf": "4.60.0", - "@rollup/rollup-linux-arm64-gnu": "4.60.0", - "@rollup/rollup-linux-arm64-musl": "4.60.0", - "@rollup/rollup-linux-loong64-gnu": "4.60.0", - "@rollup/rollup-linux-loong64-musl": "4.60.0", - "@rollup/rollup-linux-ppc64-gnu": "4.60.0", - "@rollup/rollup-linux-ppc64-musl": "4.60.0", - "@rollup/rollup-linux-riscv64-gnu": "4.60.0", - "@rollup/rollup-linux-riscv64-musl": "4.60.0", - "@rollup/rollup-linux-s390x-gnu": "4.60.0", - "@rollup/rollup-linux-x64-gnu": "4.60.0", - "@rollup/rollup-linux-x64-musl": "4.60.0", - "@rollup/rollup-openbsd-x64": "4.60.0", - "@rollup/rollup-openharmony-arm64": "4.60.0", - "@rollup/rollup-win32-arm64-msvc": "4.60.0", - "@rollup/rollup-win32-ia32-msvc": "4.60.0", - "@rollup/rollup-win32-x64-gnu": "4.60.0", - "@rollup/rollup-win32-x64-msvc": "4.60.0", - "fsevents": "~2.3.2" - } - }, - "node_modules/saxes": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", - "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", - "dev": true, - "dependencies": { - "xmlchars": "^2.2.0" - }, - "engines": { - "node": ">=v12.22.7" - } - }, - "node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "dev": true, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/set-blocking": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", - "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", - "license": "ISC" - }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/siginfo": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", - "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", - "dev": true, - "license": "ISC" - }, - "node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/stackback": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", - "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", - "dev": true, - "license": "MIT" - }, - "node_modules/std-env": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", - "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", - "dev": true, - "license": "MIT" - }, - "node_modules/string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "dev": true, - "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/string-width-cjs": { - "name": "string-width", - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/string-width-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/string-width-cjs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true - }, - "node_modules/string-width-cjs/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", - "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", - "dev": true, - "dependencies": { - "ansi-regex": "^6.2.2" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/strip-ansi-cjs": { - "name": "strip-ansi", - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-literal": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", - "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", - "dev": true, - "license": "MIT", - "dependencies": { - "js-tokens": "^9.0.1" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, - "node_modules/strip-literal/node_modules/js-tokens": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", - "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/symbol-tree": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", - "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", - "dev": true - }, - "node_modules/test-exclude": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.2.tgz", - "integrity": "sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==", - "dev": true, - "dependencies": { - "@istanbuljs/schema": "^0.1.2", - "glob": "^10.4.1", - "minimatch": "^10.2.2" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/test-exclude/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/test-exclude/node_modules/brace-expansion": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", - "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", - "dev": true, - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/test-exclude/node_modules/minimatch": { - "version": "10.2.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", - "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", - "dev": true, - "dependencies": { - "brace-expansion": "^5.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/tinybench": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", - "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", - "dev": true, - "license": "MIT" - }, - "node_modules/tinyexec": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", - "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", - "dev": true, - "license": "MIT" - }, - "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.3" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" - } - }, - "node_modules/tinypool": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", - "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.0.0 || >=20.0.0" - } - }, - "node_modules/tinyrainbow": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", - "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/tinyspy": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", - "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/tldts": { - "version": "7.0.27", - "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.27.tgz", - "integrity": "sha512-I4FZcVFcqCRuT0ph6dCDpPuO4Xgzvh+spkcTr1gK7peIvxWauoloVO0vuy1FQnijT63ss6AsHB6+OIM4aXHbPg==", - "dev": true, - "dependencies": { - "tldts-core": "^7.0.27" - }, - "bin": { - "tldts": "bin/cli.js" - } - }, - "node_modules/tldts-core": { - "version": "7.0.27", - "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.27.tgz", - "integrity": "sha512-YQ7uPjgWUibIK6DW5lrKujGwUKhLevU4hcGbP5O6TcIUb+oTjJYJVWPS4nZsIHrEEEG6myk/oqAJUEQmpZrHsg==", - "dev": true - }, - "node_modules/tough-cookie": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", - "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", - "dev": true, - "dependencies": { - "tldts": "^7.0.5" - }, - "engines": { - "node": ">=16" - } - }, - "node_modules/tr46": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", - "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", - "dev": true, - "dependencies": { - "punycode": "^2.3.1" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/undici": { - "version": "7.24.6", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.6.tgz", - "integrity": "sha512-Xi4agocCbRzt0yYMZGMA6ApD7gvtUFaxm4ZmeacWI4cZxaF6C+8I8QfofC20NAePiB/IcvZmzkJ7XPa471AEtA==", - "dev": true, - "engines": { - "node": ">=20.18.1" - } - }, - "node_modules/undici-types": { - "version": "7.18.2", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", - "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", - "dev": true - }, - "node_modules/vite": { - "version": "6.4.2", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz", - "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "^0.25.0", - "fdir": "^6.4.4", - "picomatch": "^4.0.2", - "postcss": "^8.5.3", - "rollup": "^4.34.9", - "tinyglobby": "^0.2.13" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", - "jiti": ">=1.21.0", - "less": "*", - "lightningcss": "^1.21.0", - "sass": "*", - "sass-embedded": "*", - "stylus": "*", - "sugarss": "*", - "terser": "^5.16.0", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "jiti": { - "optional": true - }, - "less": { - "optional": true - }, - "lightningcss": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } - } - }, - "node_modules/vite-node": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", - "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", - "dev": true, - "license": "MIT", - "dependencies": { - "cac": "^6.7.14", - "debug": "^4.4.1", - "es-module-lexer": "^1.7.0", - "pathe": "^2.0.3", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" - }, - "bin": { - "vite-node": "vite-node.mjs" - }, - "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/vitest": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", - "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/chai": "^5.2.2", - "@vitest/expect": "3.2.4", - "@vitest/mocker": "3.2.4", - "@vitest/pretty-format": "^3.2.4", - "@vitest/runner": "3.2.4", - "@vitest/snapshot": "3.2.4", - "@vitest/spy": "3.2.4", - "@vitest/utils": "3.2.4", - "chai": "^5.2.0", - "debug": "^4.4.1", - "expect-type": "^1.2.1", - "magic-string": "^0.30.17", - "pathe": "^2.0.3", - "picomatch": "^4.0.2", - "std-env": "^3.9.0", - "tinybench": "^2.9.0", - "tinyexec": "^0.3.2", - "tinyglobby": "^0.2.14", - "tinypool": "^1.1.1", - "tinyrainbow": "^2.0.0", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", - "vite-node": "3.2.4", - "why-is-node-running": "^2.3.0" - }, - "bin": { - "vitest": "vitest.mjs" - }, - "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "@edge-runtime/vm": "*", - "@types/debug": "^4.1.12", - "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", - "@vitest/browser": "3.2.4", - "@vitest/ui": "3.2.4", - "happy-dom": "*", - "jsdom": "*" - }, - "peerDependenciesMeta": { - "@edge-runtime/vm": { - "optional": true - }, - "@types/debug": { - "optional": true - }, - "@types/node": { - "optional": true - }, - "@vitest/browser": { - "optional": true - }, - "@vitest/ui": { - "optional": true - }, - "happy-dom": { - "optional": true - }, - "jsdom": { - "optional": true - } - } - }, - "node_modules/vue": { - "version": "3.5.30", - "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.30.tgz", - "integrity": "sha512-hTHLc6VNZyzzEH/l7PFGjpcTvUgiaPK5mdLkbjrTeWSRcEfxFrv56g/XckIYlE9ckuobsdwqd5mk2g1sBkMewg==", - "dependencies": { - "@vue/compiler-dom": "3.5.30", - "@vue/compiler-sfc": "3.5.30", - "@vue/runtime-dom": "3.5.30", - "@vue/server-renderer": "3.5.30", - "@vue/shared": "3.5.30" - }, - "peerDependencies": { - "typescript": "*" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/vue-component-type-helpers": { - "version": "2.2.12", - "resolved": "https://registry.npmjs.org/vue-component-type-helpers/-/vue-component-type-helpers-2.2.12.tgz", - "integrity": "sha512-YbGqHZ5/eW4SnkPNR44mKVc6ZKQoRs/Rux1sxC6rdwXb4qpbOSYfDr9DsTHolOTGmIKgM9j141mZbBeg05R1pw==", - "dev": true - }, - "node_modules/w3c-xmlserializer": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", - "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", - "dev": true, - "dependencies": { - "xml-name-validator": "^5.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/webidl-conversions": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", - "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", - "dev": true, - "engines": { - "node": ">=20" - } - }, - "node_modules/whatwg-mimetype": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", - "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", - "dev": true, - "engines": { - "node": ">=20" - } - }, - "node_modules/whatwg-url": { - "version": "16.0.1", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", - "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", - "dev": true, - "dependencies": { - "@exodus/bytes": "^1.11.0", - "tr46": "^6.0.0", - "webidl-conversions": "^8.0.1" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - } - }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/which-module": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", - "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", - "license": "ISC" - }, - "node_modules/why-is-node-running": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", - "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", - "dev": true, - "license": "MIT", - "dependencies": { - "siginfo": "^2.0.0", - "stackback": "0.0.2" - }, - "bin": { - "why-is-node-running": "cli.js" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", - "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", - "dev": true, - "dependencies": { - "ansi-styles": "^6.1.0", - "string-width": "^5.0.1", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs": { - "name": "wrap-ansi", - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true - }, - "node_modules/wrap-ansi-cjs/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/ws": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", - "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", - "dev": true, - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, - "node_modules/xml-name-validator": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", - "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", - "dev": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/xmlchars": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", - "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", - "dev": true - }, - "node_modules/y18n": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", - "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", - "license": "ISC" - }, - "node_modules/yargs": { - "version": "15.4.1", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", - "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", - "license": "MIT", - "dependencies": { - "cliui": "^6.0.0", - "decamelize": "^1.2.0", - "find-up": "^4.1.0", - "get-caller-file": "^2.0.1", - "require-directory": "^2.1.1", - "require-main-filename": "^2.0.0", - "set-blocking": "^2.0.0", - "string-width": "^4.2.0", - "which-module": "^2.0.0", - "y18n": "^4.0.0", - "yargs-parser": "^18.1.2" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/yargs-parser": { - "version": "18.1.3", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", - "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", - "license": "ISC", - "dependencies": { - "camelcase": "^5.0.0", - "decamelize": "^1.2.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/yargs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/yargs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "license": "MIT" - }, - "node_modules/yargs/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/yargs/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - } - } -} diff --git a/package.json b/package.json deleted file mode 100644 index 2dcf83e..0000000 --- a/package.json +++ /dev/null @@ -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" - } -} diff --git a/playwright.config.js b/playwright.config.js deleted file mode 100644 index b98e4a5..0000000 --- a/playwright.config.js +++ /dev/null @@ -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'] } }, - ], -}) diff --git a/public/favicon.png b/public/favicon.png deleted file mode 100644 index d895fdb..0000000 Binary files a/public/favicon.png and /dev/null differ diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..a5a20ef --- /dev/null +++ b/pubspec.yaml @@ -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 diff --git a/sop.md b/sop.md index e5fe188..e44340c 100644 --- a/sop.md +++ b/sop.md @@ -1,11 +1,11 @@ -# SOP — BitePlan (Vue 3 + Vite → Capacitor Android) +# SOP — BitePlan (Flutter → Android APK) ## Scope -App mobile-first con tre funzionalità: +App mobile Android con tre funzionalità: 1. **Meal Planner** — pianificazione settimanale (7 giorni × 3 pasti: colazione, pranzo, cena). Ogni pasto contiene una lista di voci testuali liberamente modificabili. -2. **Conversione crudo/cotto** — calcolo del peso cotto a partire dal peso crudo (e viceversa) tramite coefficienti di resa definiti in un JSON interno. +2. **Conversione crudo/cotto** — calcolo del peso cotto a partire dal peso crudo (e viceversa) tramite coefficienti di resa definiti in un asset JSON interno. 3. **Lista della spesa** — checklist con aggiunta, spunta e rimozione elementi. --- @@ -14,130 +14,158 @@ App mobile-first con tre funzionalità: | Livello | Scelta | |---|---| -| Frontend | Vue 3 + Vite | -| Stato | Composables (no store) | -| Persistenza | LocalStorage | -| UI | CSS base mobile-first | -| Mobile (fase successiva) | Capacitor Android | -| Build riproducibile (opzionale) | Docker | - -Sviluppo iniziale: `npm run dev` + Chrome DevTools modalità mobile. +| Framework | Flutter (Dart) | +| Stato | Provider o Riverpod | +| Persistenza | shared_preferences | +| UI | Material 3, mobile-first | +| Build APK | Docker (container build) | +| Sviluppo | Docker + noVNC (container dev con GUI) | --- ## Struttura progetto ``` -src/ +lib/ +├── main.dart +├── app.dart # MaterialApp, routing, BottomNavigationBar ├── pages/ -│ ├── MealPlanner.vue -│ ├── Converter.vue -│ └── ShoppingList.vue -├── components/ -│ ├── BottomNav.vue -│ ├── MealCard.vue -│ └── CheckboxItem.vue -├── data/ -│ └── conversions.json -├── utils/ -│ ├── conversion.js -│ └── storage.js -└── App.vue +│ ├── meal_planner_page.dart +│ ├── converter_page.dart +│ └── shopping_list_page.dart +├── widgets/ +│ ├── meal_card.dart # Accordion giorno +│ └── checkbox_item.dart +├── models/ +│ ├── meal_plan.dart +│ └── shopping_item.dart +├── services/ +│ ├── storage_service.dart # Wrapper shared_preferences +│ └── conversion_service.dart # rawToCooked / cookedToRaw +└── data/ + └── conversions.json # Asset: 50+ alimenti × metodi cottura + +docker/ +├── dev/ +│ ├── Dockerfile # Flutter SDK + Android SDK + Xvfb + noVNC +│ └── docker-compose.yml # Porta 6080 → noVNC, volume su ./ +├── build/ +│ ├── Dockerfile # Flutter SDK + Android SDK headless +│ └── build.sh # flutter build apk --release + firma +pubspec.yaml ``` --- -## Fase 1 — Setup +## Container dev (noVNC) + +Il container di sviluppo espone un desktop virtuale via browser, utile per eseguire l'emulatore Android o Android Studio senza installare nulla sull'host. + +**Stack interno**: Ubuntu + Flutter SDK + Android SDK + Xvfb + noVNC + websockify. ```bash -npm create vue@latest biteplan -cd biteplan -npm install -npm run dev +# Avvio +cd docker/dev +docker compose up + +# Accesso GUI +# → http://localhost:6080 (noVNC, nessuna password) ``` -Test mobile: Chrome → F12 → viewport 360×640. +Il volume mappa `./` (root del progetto) in `/workspace` nel container, quindi le modifiche ai file sono bidirezionali in tempo reale. + +**Workflow nel container**: +1. Aprire un terminale nel desktop noVNC +2. `cd /workspace && flutter pub get` +3. Avviare l'emulatore o collegare device via ADB +4. `flutter run` --- -## Fase 2 — Meal Planner +## Container build (APK) -**Struttura dati** +Build headless riproducibile, senza GUI. -```js -{ - lunedi: { colazione: [], pranzo: [], cena: [] }, - martedi: { ... } +```bash +# Debug +bash docker/build/build.sh + +# Release firmato +bash docker/build/build.sh --release +``` + +Pipeline interna: +1. `flutter pub get` +2. `flutter build apk --release` → `build/app/outputs/flutter-apk/app-release.apk` +3. Firma con `apksigner` (keystore montato come volume o secret) +4. `zipalign` + +L'APK firmato viene copiato in `dist/` nella root del progetto. + +--- + +## Modello dati + +**Meal Plan** +```dart +class MealPlan { + final Map days; // 'lunedi' … 'domenica' +} + +class DayPlan { + final List colazione; + final List pranzo; + final List cena; } ``` -**Funzionalità** -- Aggiungere/rimuovere voci per ogni pasto -- Visualizzare l'intera settimana -- Salvataggio automatico su LocalStorage +**Shopping Item** +```dart +class ShoppingItem { + final String id; + final String name; + bool checked; +} +``` --- -## Fase 3 — Conversione crudo/cotto - -**JSON** — `src/data/conversions.json` +## Conversione crudo/cotto +**Asset** — `lib/data/conversions.json` ```json { "pollo": { "forno": { "yield": 0.75 }, "padella": { "yield": 0.70 } }, - "riso": { "bollito": { "yield": 2.5 } } + "riso basmati": { "bollitura": { "yield": 3.00 } } } ``` `yield = peso_cotto / peso_crudo` -**Funzioni** — `src/utils/conversion.js` - -```js -export const rawToCooked = (food, method, raw, db) => raw * db[food][method].yield -export const cookedToRaw = (food, method, cooked, db) => cooked / db[food][method].yield +**Service** — `lib/services/conversion_service.dart` +```dart +double rawToCooked(double raw, double yield) => raw * yield; +double cookedToRaw(double cooked, double yield) => cooked / yield; ``` -**UX**: ricerca testuale sull'alimento → selezione alimento+metodo → input grammi → risultato in tempo reale. - -Esempio: 140 g pollo crudo → 105 g cotti al forno. +UX: ricerca testuale → selezione alimento + metodo → input grammi → risultato in tempo reale. --- -## Fase 4 — Lista della spesa +## Persistenza -**Struttura dati** - -```js -[{ id, name, checked }] -``` - -**Funzionalità**: aggiungi, spunta, elimina, svuota lista. +`StorageService` wrappa `shared_preferences`: +- Meal plan serializzato come JSON stringa sotto chiave `meals` +- Lista spesa serializzata come JSON stringa sotto chiave `shopping_list` --- -## Fase 5 — UI Mobile +## UI -Navigazione bottom bar: Pasti | Converti | Spesa. - -Linee guida: bottoni min 44px, input semplici, una funzione per schermata. - ---- - -## Fase 6 — Android (Capacitor) - -```bash -npm install @capacitor/core @capacitor/cli -npx cap init -npx cap add android -npm run build && npx cap sync && npx cap run android -``` - ---- - -## Fase 7 — Docker (opzionale) - -Pipeline: install → build frontend → cap sync → gradle build APK. +- `BottomNavigationBar` con 3 tab: Pasti | Converti | Spesa +- Material 3, `ColorScheme.fromSeed(seedColor: Color(0xFF2d6a4f))` +- Target touch minimo 48×48 dp (Material baseline) +- Orientamento bloccato in portrait (`SystemChrome.setPreferredOrientations`) --- @@ -145,16 +173,5 @@ Pipeline: install → build frontend → cap sync → gradle build APK. - Modifica coefficienti di conversione in-app - Calcolo kcal -- Generazione automatica lista spesa dai pasti pianificati +- Condivisione piano via QR code - Sync cloud - ---- - -## Checklist - -- [ ] Meal planner funzionante -- [ ] Conversioni corrette -- [ ] JSON alimenti presente (12+ voci) -- [ ] Lista spesa funzionante -- [ ] Persistenza attiva -- [ ] APK testabile diff --git a/src/App.vue b/src/App.vue deleted file mode 100644 index 1c56ccb..0000000 --- a/src/App.vue +++ /dev/null @@ -1,113 +0,0 @@ - - - - - diff --git a/src/components/BottomNav.vue b/src/components/BottomNav.vue deleted file mode 100644 index c8f5add..0000000 --- a/src/components/BottomNav.vue +++ /dev/null @@ -1,110 +0,0 @@ - - - - - diff --git a/src/components/CheckboxItem.vue b/src/components/CheckboxItem.vue deleted file mode 100644 index 16fe178..0000000 --- a/src/components/CheckboxItem.vue +++ /dev/null @@ -1,81 +0,0 @@ - - - - - diff --git a/src/components/DocsPanel.vue b/src/components/DocsPanel.vue deleted file mode 100644 index 62b3ce7..0000000 --- a/src/components/DocsPanel.vue +++ /dev/null @@ -1,523 +0,0 @@ - - - - - diff --git a/src/components/InfoPanel.vue b/src/components/InfoPanel.vue deleted file mode 100644 index e1d9331..0000000 --- a/src/components/InfoPanel.vue +++ /dev/null @@ -1,188 +0,0 @@ - - - - - diff --git a/src/components/MealCard.vue b/src/components/MealCard.vue deleted file mode 100644 index 3c6d613..0000000 --- a/src/components/MealCard.vue +++ /dev/null @@ -1,197 +0,0 @@ - - - - - diff --git a/src/main.js b/src/main.js deleted file mode 100644 index fe5bae3..0000000 --- a/src/main.js +++ /dev/null @@ -1,5 +0,0 @@ -import { createApp } from 'vue' -import App from './App.vue' -import './style.css' - -createApp(App).mount('#app') diff --git a/src/pages/Converter.vue b/src/pages/Converter.vue deleted file mode 100644 index 573d6bc..0000000 --- a/src/pages/Converter.vue +++ /dev/null @@ -1,381 +0,0 @@ - - - - - diff --git a/src/pages/MealPlanner.vue b/src/pages/MealPlanner.vue deleted file mode 100644 index 98b95be..0000000 --- a/src/pages/MealPlanner.vue +++ /dev/null @@ -1,505 +0,0 @@ - - - - - diff --git a/src/pages/ShoppingList.vue b/src/pages/ShoppingList.vue deleted file mode 100644 index 1f88bf3..0000000 --- a/src/pages/ShoppingList.vue +++ /dev/null @@ -1,161 +0,0 @@ - - - - - diff --git a/src/style.css b/src/style.css deleted file mode 100644 index 1cd0d10..0000000 --- a/src/style.css +++ /dev/null @@ -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; -} diff --git a/src/utils/conversion.js b/src/utils/conversion.js deleted file mode 100644 index 081342c..0000000 --- a/src/utils/conversion.js +++ /dev/null @@ -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 diff --git a/src/utils/storage.js b/src/utils/storage.js deleted file mode 100644 index 03a1f5b..0000000 --- a/src/utils/storage.js +++ /dev/null @@ -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 -} diff --git a/test/features/converter/models/conversion_entry_test.dart b/test/features/converter/models/conversion_entry_test.dart new file mode 100644 index 0000000..fa2feb4 --- /dev/null +++ b/test/features/converter/models/conversion_entry_test.dart @@ -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); + }); + }); +} diff --git a/test/features/converter/providers/converter_provider_test.dart b/test/features/converter/providers/converter_provider_test.dart new file mode 100644 index 0000000..f5bec36 --- /dev/null +++ b/test/features/converter/providers/converter_provider_test.dart @@ -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); + }); + }); + }); +} diff --git a/test/features/meal_planner/models/meal_plan_test.dart b/test/features/meal_planner/models/meal_plan_test.dart new file mode 100644 index 0000000..209f986 --- /dev/null +++ b/test/features/meal_planner/models/meal_plan_test.dart @@ -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']); + }); + }); +} diff --git a/test/features/meal_planner/providers/meal_planner_provider_test.dart b/test/features/meal_planner/providers/meal_planner_provider_test.dart new file mode 100644 index 0000000..d53fddd --- /dev/null +++ b/test/features/meal_planner/providers/meal_planner_provider_test.dart @@ -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); + }); + }); +} diff --git a/test/features/meal_planner/qr_test.dart b/test/features/meal_planner/qr_test.dart new file mode 100644 index 0000000..bc3fd7a --- /dev/null +++ b/test/features/meal_planner/qr_test.dart @@ -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); + }); + }); +} diff --git a/test/features/meal_planner/widgets/meal_card_test.dart b/test/features/meal_planner/widgets/meal_card_test.dart new file mode 100644 index 0000000..431c0ea --- /dev/null +++ b/test/features/meal_planner/widgets/meal_card_test.dart @@ -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); + }); + }); +} diff --git a/test/features/shopping_list/models/shopping_item_test.dart b/test/features/shopping_list/models/shopping_item_test.dart new file mode 100644 index 0000000..ab52367 --- /dev/null +++ b/test/features/shopping_list/models/shopping_item_test.dart @@ -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); + }); + }); +} diff --git a/test/features/shopping_list/providers/shopping_list_provider_test.dart b/test/features/shopping_list/providers/shopping_list_provider_test.dart new file mode 100644 index 0000000..d2b1f1d --- /dev/null +++ b/test/features/shopping_list/providers/shopping_list_provider_test.dart @@ -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); + }); + }); +} diff --git a/test/features/shopping_list/widgets/shopping_item_tile_test.dart b/test/features/shopping_list/widgets/shopping_item_tile_test.dart new file mode 100644 index 0000000..589a244 --- /dev/null +++ b/test/features/shopping_list/widgets/shopping_item_tile_test.dart @@ -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(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(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(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); + }); + }); +} diff --git a/test/helpers/pump_app.dart b/test/helpers/pump_app.dart new file mode 100644 index 0000000..d451979 --- /dev/null +++ b/test/helpers/pump_app.dart @@ -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 pumpApp(Widget widget) => pumpWidget( + MaterialApp(theme: AppTheme.light(), home: widget), + ); +} diff --git a/tests/e2e/converter.spec.js b/tests/e2e/converter.spec.js deleted file mode 100644 index 3e42621..0000000 --- a/tests/e2e/converter.spec.js +++ /dev/null @@ -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') - }) -}) diff --git a/tests/e2e/meal-planner.spec.js b/tests/e2e/meal-planner.spec.js deleted file mode 100644 index 46359e1..0000000 --- a/tests/e2e/meal-planner.spec.js +++ /dev/null @@ -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() - }) -}) diff --git a/tests/e2e/navigation.spec.js b/tests/e2e/navigation.spec.js deleted file mode 100644 index 9789b1a..0000000 --- a/tests/e2e/navigation.spec.js +++ /dev/null @@ -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() - }) -}) diff --git a/tests/e2e/shopping-list.spec.js b/tests/e2e/shopping-list.spec.js deleted file mode 100644 index 479bbd4..0000000 --- a/tests/e2e/shopping-list.spec.js +++ /dev/null @@ -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() - }) -}) diff --git a/tests/integration/Converter.test.js b/tests/integration/Converter.test.js deleted file mode 100644 index d314897..0000000 --- a/tests/integration/Converter.test.js +++ /dev/null @@ -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() - }) -}) diff --git a/tests/integration/MealPlanner.test.js b/tests/integration/MealPlanner.test.js deleted file mode 100644 index d3e1ed4..0000000 --- a/tests/integration/MealPlanner.test.js +++ /dev/null @@ -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: '
', - 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) - }) -}) diff --git a/tests/integration/ShoppingList.test.js b/tests/integration/ShoppingList.test.js deleted file mode 100644 index 1d6ff0a..0000000 --- a/tests/integration/ShoppingList.test.js +++ /dev/null @@ -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: '
  • {{ item.name }}
  • ', - 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) - }) -}) diff --git a/tests/setup.js b/tests/setup.js deleted file mode 100644 index 242876a..0000000 --- a/tests/setup.js +++ /dev/null @@ -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()) diff --git a/tests/unit/conversion.test.js b/tests/unit/conversion.test.js deleted file mode 100644 index 1b03eec..0000000 --- a/tests/unit/conversion.test.js +++ /dev/null @@ -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) - }) -}) diff --git a/tests/unit/storage.test.js b/tests/unit/storage.test.js deleted file mode 100644 index 1c84353..0000000 --- a/tests/unit/storage.test.js +++ /dev/null @@ -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) - }) -}) diff --git a/vite.config.mjs b/vite.config.mjs deleted file mode 100644 index 2e3d257..0000000 --- a/vite.config.mjs +++ /dev/null @@ -1,6 +0,0 @@ -import { defineConfig } from 'vite' -import vue from '@vitejs/plugin-vue' - -export default defineConfig({ - plugins: [vue()] -}) diff --git a/vitest.config.js b/vitest.config.js deleted file mode 100644 index 257e752..0000000 --- a/vitest.config.js +++ /dev/null @@ -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/**'], - }, - }, -})