From 69bcc3e3426704773b5ae1bed2abf7f7bffd1e2c Mon Sep 17 00:00:00 2001 From: Nils-Johan Gynther Date: Sat, 23 May 2026 18:04:27 +0200 Subject: [PATCH] feat(web): improve web build configuration and accessibility - Add source maps and web renderer build arguments with defaults - Configure Caddy with CSP headers, cache policies, and service worker handling - Defer loading of import screen for performance optimization - Add semantic labels to icons for accessibility - Update web index.html with Swedish language, meta tags, and description - Add robots.txt and lighthouse configuration - Add new planning documents and archive entries --- .kilo/plans/1779550355555-hidden-mountain.md | 146 ++++ .kilo/plans/flutter-lighthouse.md | 706 ++++++++++++++++++ .kilo/plans/plan-multiplatform.md | 118 +++ .../docs/SESSION_CHECKPOINT_2026-05-21.md | 87 +++ compose.flutter.yml | 6 +- flutter/Caddyfile | 34 +- flutter/Dockerfile | 19 +- flutter/lib/core/router/app_router.dart | 93 ++- flutter/lib/core/ui/app_shell.dart | 16 +- .../import/presentation/flyer_import_tab.dart | 206 +++-- .../presentation/receipt_import_tab.dart | 389 ++++++---- .../swipeable_inventory_tile.dart | 40 +- flutter/lighthouse/README.md | 76 ++ flutter/lighthouse/collect.mjs | 189 +++++ flutter/web/index.html | 21 +- flutter/web/robots.txt | 2 + 16 files changed, 1847 insertions(+), 301 deletions(-) create mode 100644 .kilo/plans/1779550355555-hidden-mountain.md create mode 100644 .kilo/plans/flutter-lighthouse.md create mode 100644 .kilo/plans/plan-multiplatform.md create mode 100644 _archive/docs/SESSION_CHECKPOINT_2026-05-21.md create mode 100644 flutter/lighthouse/README.md create mode 100644 flutter/lighthouse/collect.mjs create mode 100644 flutter/web/robots.txt diff --git a/.kilo/plans/1779550355555-hidden-mountain.md b/.kilo/plans/1779550355555-hidden-mountain.md new file mode 100644 index 00000000..fbaf9322 --- /dev/null +++ b/.kilo/plans/1779550355555-hidden-mountain.md @@ -0,0 +1,146 @@ +# Plan: Projektanpassad Lighthouse-plan for Flutter-web + +## Mål +Höja Lighthouse-resultaten för Flutter-webklienten i detta repo utan att bryta befintlig Docker/Caddy-deploy, med fokus på mätbar förbättring av prestanda, tillgänglighet och grundläggande SEO. + +## Kontext och nuläge (verifierat i projektet) +- Flutter-web byggs redan i release-läge i `flutter/Dockerfile` via `flutter build web --release`. +- Webb klient körs via Caddy i `flutter/Caddyfile` och har redan `encode gzip`. +- `flutter/web/index.html` är minimal och innehåller redan korrekt viewport utan `user-scalable=no`. +- `flutter/web/` innehåller idag endast `index.html` (ingen `robots.txt`/`sitemap.xml` i Flutter-mappen). +- API-basurl injiceras redan korrekt med `--dart-define=API_BASE_URL=/api` via `compose.flutter.yml`. + +## Problem i befintlig flutter-lighthouse.md som inte passar repo exakt +- Planen utgår delvis från Nginx/Apache, men projektet använder Caddy för Flutter-spåret. +- Påståendet om `user-scalable=no` gäller inte nuvarande `flutter/web/index.html`. +- Påståendet om ogiltig `robots.txt` kan inte verifieras i nuvarande Flutter-webmapp. +- Förslag om tvingad HTML-renderer är för grovt; bör vara experiment med mätning och rollback-kriterier. + +## Strategi +Arbeta i tre iterationer med baseline -> lågriskoptimeringar -> tyngre optimeringar, och låt varje steg vara datadrivet. + +--- + +## Fas 1: Baseline och mätprotokoll (dag 1) +1. Skapa en reproducerbar mätbaseline för både lokal container och produktionsdomän. +2. Kör Lighthouse minst 3 gånger per miljö och ta median för: + - Performance score + - LCP + - TBT + - INP (om rapporteras) + - Transfer size / antal requests +3. Dokumentera nuläge och tröskelvärden före ändringar. + +### Acceptanskriterier Fas 1 +- Baseline-tabell finns med mätvärden från 3 körningar per miljö. +- Samma URL, samma nätprofil och samma emulering används konsekvent. + +--- + +## Fas 2: Lågriskfixar med hög nytta (dag 1-2) + +### 2.1 Index och metadata (`flutter/web/index.html`) +- Lägg till `lang="sv"` på ``. +- Lägg till relevant `meta description` för appens huvudsakliga nytta. +- Behåll `viewport` som den är (ingen ändring behövs kring zoom-blockering). + +### 2.2 Caddy-headerhygien (`flutter/Caddyfile`) +- Behåll `encode gzip`. +- Lägg till explicita cache-headers för hashade statiska assets (js/wasm/fonts) med lång TTL och immutable. +- Lägg till kortare/konservativ cache för `index.html` så nya deploys slår igenom snabbt. +- Lägg till säkerhetsheaders som är kompatibla med Flutter-web (minst grundnivå: `X-Content-Type-Options`, `X-Frame-Options`, `Referrer-Policy`). + +### 2.3 Byggoptimering (`flutter/Dockerfile`) +- Utvärdera `--no-source-maps` i produktionsbuild för mindre artifact-storlek. +- Säkerställ att eventuella ändringar inte påverkar felsökning i miljö där sourcemaps behövs. + +### Acceptanskriterier Fas 2 +- Lighthouse visar förbättring i minst två nyckelmått (t.ex. LCP/TBT). +- Ingen regress i appstart, routning eller API-proxy `/api/*`. +- Build pipeline passerar oförändrat i Docker. + +--- + +## Fas 3: Prestandaexperiment med tydlig rollback (dag 2-4) + +### 3.1 Rendering-strategi (CanvasKit vs HTML/Skwasm) +- Kör A/B-test av rendererstrategi i en separat branch/buildvariant. +- Mät skillnad i initial transfer size, LCP och renderingkvalitet på kritiska vyer. +- Beslut endast baserat på mätdata + visuell/regressionskontroll. + +### 3.2 Deferred loading i tunga featureflöden +- Identifiera kandidater för deferred imports (exempel: import/admin-vyer med hög kodvikt). +- Introducera gradvis och validera att navigation inte blir ryckig. + +### 3.3 Bootstrap-laddning +- Behåll asynkron laddning i `index.html` om mätning visar bäst resultat. +- Undvik manuella hacks som injicerar canvaskit-script ad hoc utan evidens i mätning. + +### Acceptanskriterier Fas 3 +- Minst 15-25% förbättring i TBT eller tydlig minskning i JS-exekveringstid jämfört med baseline. +- Ingen funktionell regress i kärnflöden (login, inventarie, recept, import). + +--- + +## Fas 4: Tillgänglighet (A11y) med repo-fokus (dag 3-5) +1. Inventera ikonknappar och actions i Flutter-kod och säkra `tooltip`/`semanticsLabel` där det saknas. +2. Lägg till/justera `Semantics` för centrala actions (import, spara, ta bort, navigering). +3. Verifiera tangentbordsnavigering i webbläsare för huvudflöden. + +### Acceptanskriterier Fas 4 +- Lighthouse Accessibility förbättras mätbart. +- Inga nya fokusfällor eller förlorad keyboard-navigering introduceras. + +--- + +## Fas 5: SEO-minimum för app-shell (dag 4-5) +1. Säkerställ att titel och meta description är korrekta för startsidan. +2. Besluta om `robots.txt` och `sitemap.xml` ska hanteras i Flutter-web, Caddy eller upstream-domänkonfiguration (inte antagande). +3. Implementera endast den väg som matchar faktisk domänrouting i drift. + +### Acceptanskriterier Fas 5 +- Lighthouse SEO får förbättring från baseline. +- Robots/sitemap-lösning är verifierad mot faktisk driftarkitektur. + +--- + +## Fas 6: Säkerhet och CSP (dag 5) +1. Introducera en pragmatisk CSP för Flutter-web i Caddy med minsta nödvändiga undantag. +2. Testa särskilt att Flutter bootstrap, API-anrop och ev. externa resurser fungerar. +3. Strama åt policyn iterativt istället för en aggressiv engångspolicy. + +### Acceptanskriterier Fas 6 +- Säkerhetsheaders levereras korrekt från Flutter-Caddy. +- Ingen blockerad kärnfunktion pga CSP. + +--- + +## Prioriterad genomförandeordning +1. Fas 1 (baseline) +2. Fas 2 (lågriskfixar) +3. Re-mätning +4. Fas 3 (prestandaexperiment) +5. Fas 4 (tillgänglighet) +6. Fas 5 (SEO-minimum) +7. Fas 6 (CSP hardening) +8. Slutlig Lighthouse-jämförelse och dokumentation + +## Definition of Done +- Reproducerbar före/efter-mätning finns dokumenterad. +- Performance, Accessibility och SEO har förbättrats jämfört med baseline. +- Inga regressioner i Docker/Caddy-flödet eller appens kärnflöden. +- Åtgärderna är anpassade till faktisk stack (Flutter + Caddy), inte generiska Nginx-råd. + +## Konkreta filer som sannolikt berörs vid implementation +- `flutter/web/index.html` +- `flutter/Caddyfile` +- `flutter/Dockerfile` +- ev. flera Flutter-vyer med knapp-/ikonsemantik under `flutter/lib/**` + +## Risker och motåtgärder +- **Risk:** För aggressiv CSP bryter Flutter-bootstrap. + **Motåtgärd:** Iterativ policy + verifiering efter varje ändring. +- **Risk:** Rendererbyte förbättrar vikt men försämrar visual fidelity. + **Motåtgärd:** A/B-test med tydliga rollback-kriterier. +- **Risk:** Cache-policy gör deploys "stale". + **Motåtgärd:** Lång cache endast för fingerprintade assets, kort cache för `index.html`. diff --git a/.kilo/plans/flutter-lighthouse.md b/.kilo/plans/flutter-lighthouse.md new file mode 100644 index 00000000..c9ca0916 --- /dev/null +++ b/.kilo/plans/flutter-lighthouse.md @@ -0,0 +1,706 @@ +🚨 Kritiska Problem (Prioritet 1: Fixa Omedelbart) +Dessa problem påverkar användarupplevelsen mest och bör åtgärdas först. + +1. Prestanda: Långsam inladdning & Stora Filer +🔴 Problem: + +Total storlek: 2,978 KiB (för stor för en webbapp). + +/main.dart.js: 1,216 KiB (Flutter’s kompilade JavaScript). +canvaskit.wasm: 1,592 KiB (Flutter’s CanvasKit-renderare). +MaterialIcons-Regular.otf: 9.8 KiB (ikoner). + +Largest Contentful Paint (LCP) misslyckades (tidsgräns överskreds). +Total Blocking Time (TBT) misslyckades (långa JavaScript-uppgifter blockerar huvudtråden). +JavaScript-exekveringstid: 1.8s (för långt). +🟢 Lösningar: +A. Optimera Flutter för Web +Flutter-webbappar är tyngre än traditionella webbappar på grund av CanvasKit. Här är hur du minskar storleken: + + +Använd HTML-renderaren istället för CanvasKit + +CanvasKit ger bättre grafik men är mycket tyngre. +Ändra i index.html: +html +Copy + + + + + + +Fördel: Minskar storleken med ~1.5MB (WASM-filen laddas inte). +Nackdel: Vissa avancerade animationer/efekter fungerar inte lika bra. + + +Aktivera komprimering (GZIP/Brotli) + +Din server skickar okomprimerade filer. +Lösning: + +Nginx: Lägg till i konfigurationen: +nginx +Copy + +gzip on; +gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript; +brotli on; +brotli_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript; + + + + +Apache: Använd mod_deflate eller mod_brotli. +Cloudflare: Aktivera "Brotli Compression" i inställningarna. + + + +Cachea statiska resurser + +Problem: /main.dart.js och flutter_bootstrap.js har ingen cache-TTL. +Lösning: Lägg till Cache-Control-headers: +nginx +Copy + +location ~* \.(js|css|png|jpg|jpeg|gif|ico|wasm|otf)$ { + expires 1y; + add_header Cache-Control "public, immutable"; +} + + + + +Besparing: ~1.2MB vid upprepade besök. + + +Dela upp JavaScript-koden (Code Splitting) + +Flutter laddar allt i en stor fil (main.dart.js). +Lösning: Använd deferred imports för att ladda funktioner på begäran: +dart +Copy + +// I din Dart-kod: +import 'package:flutter_web_plugins/flutter_web_plugins.dart'; +import 'package:my_app/receipt_import.dart' deferred as receipt_import; + +// Ladda endast när behövs: +Future loadReceiptImport() async { + await receipt_import.loadLibrary(); +} + + + + +Effekt: Minskar initial laddningstid. + + +Optimera bilder + +Problem: Bilder laddas inte optimalt. +Lösning: + +Använd flutter_image_compress för att komprimera bilder innan uppladdning. +För webb: Använd med srcset för responsiva bilder. +Exempel: +html +Copy + + + + + Receptbild + + + + + + + + +B. Förbättra Laddningsordningen + +Fördröj laddning av icke-kritiska resurser + +Ladda canvaskit.wasm efter att sidan har renderats: +html +Copy + + + + + + + +Använd preload för kritiska resurser + +Lägg till i : +html +Copy + + + + + + + + + +2. Tillgänglighet: Grundläggande Problem +🔴 Problem: + +[user-scalable="no"] i viewport-meta-taggen + +Varför det är dåligt: Användare med nedsatt syn kan inte zooma in. +Lösning: Ta bort user-scalable="no": +html +Copy + + + + + + + +Saknas alt-texter på bilder + +Problem: Skärmläsare kan inte beskriva bilder. +Lösning: Lägg till alt-texter i Flutter: +dart +Copy + +Image.network( + 'https://example.com/recept.jpg', + semanticLabel: 'Bild på lasagne med ost och tomatsås', // Alt-text +), + + + + + +Saknas lang-attribut + +Problem: Skärmläsare vet inte vilket språk sidan använder. +Lösning: Lägg till i : +html +Copy + + + + + + + + +3. SEO: Grundläggande Problem +🔴 Problem: + +Saknas meta description + +Varför det är dåligt: Sökmotorer visar ingen beskrivning i resultaten. +Lösning: Lägg till i : +html +Copy + + + + + + + +Ogiltig robots.txt + +Problem: Din robots.txt innehåller HTML-kod istället för korrekt syntax. +Lösning: Skapa en korrekt robots.txt: +text +Copy + +User-agent: * +Allow: / +Sitemap: https://recept.gynther.se/sitemap.xml + + + + + + +⚠️ Viktiga Förbättringar (Prioritet 2: Fixa Inom 1-2 Veckor) +Dessa problem påverkar användarupplevelsen och SEO, men är inte lika kritiska. + +1. Prestanda: JavaScript & Rendering +🟡 Problem: + +Lång JavaScript-exekveringstid (1.8s) + +Orsak: Flutter’s main.dart.js tar 1.7s att parsas och exekveras. + +Minify CSS/JS misslyckades + +Flutter genererar redan minifierad kod, men du kan optimera vidare. + +🟢 Lösningar: + + +Aktivera Flutter’s --release flagga + +Bygg appen med: +bash +Copy + +flutter build web --release + + + + +Detta genererar optimerad och minifierad kod. + + +Använd flutter build web --no-source-maps + +Source maps ökar filstorleken. Ta bort dem i produktion: +bash +Copy + +flutter build web --no-source-maps + + + + + + +Fördröj laddning av icke-kritisk JavaScript + +Använd defer eller async för skript som inte behövs omedelbart: +html +Copy + + + + + + + + +Reducera DOM-storlek + +Problem: Din sida har 21 element med en max-djup på 7 (acceptabelt, men kan optimeras). +Lösning: Undvik onödiga Container-widgets i Flutter. Använd const widgets där möjligt. + + +2. Tillgänglighet: Interaktiva Element +🟡 Problem: + +Saknas focus-indikatorer + +Användare som navigerar med tangentbord ser inte vilka element som är fokuserade. + +Saknas aria-label på anpassade knappar + +Skärmläsare vet inte vad knapparna gör. + +🟢 Lösningar: + +Lägg till focus-stilar i CSS + +Exempel: +css +Copy + +button:focus, [tabindex="0"]:focus { + outline: 2px solid #4285F4; + outline-offset: 2px; +} + + + + + +Använd semantics i Flutter för tillgänglighet + +Exempel för en knapp: +dart +Copy + +Semantics( + label: 'Importera kvitto', + button: true, + child: ElevatedButton( + onPressed: () => _importReceipt(), + child: Text('Importera'), + ), +), + + + + + +Lägg till aria-label på ikonknappar + +Exempel: +dart +Copy + +IconButton( + icon: Icon(Icons.upload), + onPressed: () => _uploadFile(), + tooltip: 'Ladda upp kvitto', // Visas på hover + semanticsLabel: 'Ladda upp kvitto', // För skärmläsare +), + + + + + + +3. Säkerhet: Content Security Policy (CSP) +🟡 Problem: + +Saknas CSP-header + +Din sida är sårbar för XSS-attacker (Cross-Site Scripting). + +🟢 Lösning: +Lägg till en stark CSP i din serverkonfiguration: +nginx +Copy + +add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://www.gstatic.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://*.gstatic.com; font-src 'self' https://fonts.gstatic.com; connect-src 'self' https://recept.gynther.se; frame-src 'none'; object-src 'none';"; + + + + +Förklaring: + +script-src: Tillåter skript från self och gstatic.com (för Flutter). +unsafe-inline: Nödvändigt för Flutter (men försök minska användningen). +img-src: Tillåter bilder från self och gstatic.com. + + +📌 Mindre Problem (Prioritet 3: Fixa När Tid Finns) +Dessa är förbättringar som inte är kritiska men kan förbättra UX och SEO. + +1. Prestanda: Bildoptimering + +Problem: Bilder laddas utan width och height (orsakar layout shifts). +Lösning: Använd Image.network med explicit storlek: +dart +Copy + +Image.network( + 'https://example.com/recept.jpg', + width: 300, + height: 200, + fit: BoxFit.cover, +), + + + + + +2. SEO: Structured Data + +Problem: Saknas schema.org-markup för recept (gör att Google kan visa "rich results"). +Lösning: Lägg till JSON-LD i : +html +Copy + + + + + + +Effekt: Google kan visa receptkort i sökresultaten (ökad klickfrekvens). + +3. Tillgänglighet: Logisk Tab-Order + +Problem: Användare kan tabba till element som är dolda eller off-screen. +Lösning: Använd FocusableAction i Flutter för att kontrollera tab-order: +dart +Copy + +FocusableActionDetector( + autofocus: true, + onFocusChange: (hasFocus) { + if (hasFocus) { + // Hantera focus + } + }, + child: MyWidget(), +), + + + + + +📊 Sammanfattning av Prioriteringar + + + + + Prioritet + Problem + Lösning + Tidsuppskattning + Impact + + + + + 1 (Kritisk) + Stora filer (2.9MB) + Byt till HTML-renderare, aktivera GZIP, cachea resurser + 1-2 timmar + ⭐⭐⭐⭐⭐ (Hög) + + + 1 (Kritisk) + Lång JavaScript-exekvering (1.8s) + Code splitting, defer non-critical JS + 2-4 timmar + ⭐⭐⭐⭐⭐ (Hög) + + + 1 (Kritisk) + user-scalable="no" + Ta bort från viewport-meta-taggen + 5 minuter + ⭐⭐⭐⭐ (Hög) + + + 1 (Kritisk) + Saknas meta description + Lägg till i + 5 minuter + ⭐⭐⭐ (Medel) + + + 1 (Kritisk) + Ogiltig robots.txt + Skapa korrekt robots.txt + 10 minuter + ⭐⭐⭐ (Medel) + + + 2 (Viktig) + Saknas CSP-header + Lägg till i serverkonfigurationen + 1 timme + ⭐⭐⭐⭐ (Hög) + + + 2 (Viktig) + Saknas alt-texter + Lägg till semanticLabel i Flutter + 1-2 timmar + ⭐⭐⭐ (Medel) + + + 2 (Viktig) + Lång DOM-parsning + Minska onödiga widgets + 1-2 timmar + ⭐⭐ (Låg) + + + 3 (Mindre) + Saknas structured data + Lägg till JSON-LD för recept + 1 timme + ⭐⭐ (Låg) + + + + + +🛠️ Konkreta Åtgärder (Steg-för-Steg) +Steg 1: Fixa Prestanda (1-2 dagar) + +Byt till HTML-renderare (sparar ~1.5MB): + +Ändra index.html som visat ovan. + +Aktivera GZIP/Brotli på servern. +Cachea statiska filer (1 år för JS/CSS). +Bygg appen med --release: +bash +Copy + +flutter build web --release --no-source-maps + + + + +Ladd upp den optimerade versionen till din server. + +Steg 2: Fixa Tillgänglighet (1 dag) + +Ta bort user-scalable="no" från viewport. +Lägg till lang="sv" i . +Lägg till alt-texter på alla bilder. +Lägg till semanticsLabel på knappar och ikoner. + +Steg 3: Fixa SEO (1 dag) + +Lägg till meta description. +Fixa robots.txt. +Lägg till structured data för recept. + +Steg 4: Säkerhet (1 timme) + +Lägg till CSP-header i serverkonfigurationen. + +📈 Förväntade Resultat + + + + + Mätning + Nuvarande + Efter Fixar + Förbättring + + + + + Total storlek + 2,978 KiB + ~500-800 KiB + -70% + + + Laddningstid (LCP) + Timeout + < 2.5s + ✅ Fixad + + + JavaScript-exekvering + 1.8s + < 1s + -45% + + + Tillgänglighet + 82/100 + 95+/100 + +15 poäng + + + SEO + 6/10 + 9/10 + +50% + + + Säkerhet + Risk för XSS + Skyddad + ✅ Fixad + + + + + +🔍 Hur du Testar Förbättringarna + +Kör Lighthouse igen i Chrome DevTools (F12 → "Lighthouse"). +Testa på mobil (använd Chrome’s "Device Toolbar"). +Använd WebPageTest (webpagetest.org) för att mäta prestanda globalt. +Testa tillgänglighet med: + +WAVE Evaluation Tool +Skärmläsare (t.ex. NVDA eller VoiceOver). + + +💡 Extra Tips för Flutter-Webbappar + + +Använd flutter build web --target=lib/main_deferred.dart + +Förbättrar code splitting. + + +Ladda Flutter asynkront + +Lägg till async i + + + + + + +Använd flutter_tex för text-rendering + +Minskar beroendet av CanvasKit. + + +Override Flutter’s default fonts + +Använd systemtypsnitt för snabbare laddning: +css +Copy + +@font-face { + font-family: 'Roboto'; + src: local('Roboto'), local('Roboto-Regular'); +} + + + + + + +🎯 Sammanfattning: Vad du ska göra nu + +Fixa de kritiska prestandaproblemen (HTML-renderare, GZIP, caching). +Fixa tillgänglighetsproblemen (user-scalable, alt-texter, lang). +Fixa SEO (meta description, robots.txt). +Lägg till säkerhetsheaders (CSP). +Testa och iterera med Lighthouse. diff --git a/.kilo/plans/plan-multiplatform.md b/.kilo/plans/plan-multiplatform.md new file mode 100644 index 00000000..abb43417 --- /dev/null +++ b/.kilo/plans/plan-multiplatform.md @@ -0,0 +1,118 @@ +# Plan: Anpassad multiplattformsplan for recipe-app + +## Mal +Gora Flutter-klienten i `flutter/` till en riktig multiplattformsklient (web + Android + iOS) utan att bryta befintligt webbflode via Docker/Caddy, och med tydlig miljohantering for API-anrop pa mobil. + +## Nulagesanalys (projektanpassad) +- Flutter-projektet ar i praktiken web-only just nu: `flutter/` saknar `android/` och `ios/`. +- Webbbygget ar redan etablerat och stabilt via Docker: + - `flutter/Dockerfile` bygger `flutter build web --dart-define=API_BASE_URL=/api`. + - `compose.flutter.yml` och `flutter/Caddyfile` proxar `/api/*` till `recipe-api:8080`. +- API-basurl hanteras redan med `--dart-define` (bra grund for multiplattform): + - `flutter/lib/core/api/api_client.dart` + - `flutter/lib/features/import/data/import_repository.dart` +- Token-lagring ar forberedd for mobil men inte implementerad: + - `flutter/lib/core/platform/token_storage.dart` + - `flutter/lib/core/platform/platform_providers.dart` (har TODO om secure storage). + +## Viktig skillnad mot gamla planen +- Ingen hardkodad `Config.apiUrl` med fasta domaner ska inforas som huvudlosning. +- Projektet anvander redan `String.fromEnvironment('API_BASE_URL')`; vi behaller detta och utokar till mobil. +- Befintlig Docker-setup for web ska inte ersattas, bara kompletteras med mobil-byggflode. + +## Foreslagen implementation + +### Fas 1: Aktivera plattformsstommar utan att rora webdeploy +1. Skapa Android/iOS-mappar i `flutter/`: + - `flutter create --platforms android,ios .` +2. Verifiera att webfiler och befintliga Dart-filer inte overskrivs pa ett destruktivt satt. +3. Bekrafta att Docker-webbygget fortfarande fungerar oforandrat. + +**Leverabel:** Projektet innehaller `flutter/android/` och `flutter/ios/` samtidigt som webflodet ar intakt. + +### Fas 2: Plattformsaker konfiguration av API-basurl +1. Standardisera API-konfiguration kring en enda kontraktspunkt: + - Behall `API_BASE_URL` via `--dart-define`. +2. Satt tydliga miljoer: + - Web i Docker: `API_BASE_URL=/api` (som idag). + - Android emulator lokalt: t.ex. `http://10.0.2.2:8080/api` (vid lokal backend utan reverse proxy). + - Fysisk mobil/test/prod: publik HTTPS-url (doman som ar natbar utanfor Docker). +3. Se over alla direkta API-basar i Flutter-koden sa att de gar via samma pattern (inga hardkodade hostnamn). + +**Leverabel:** Samma kodbas fungerar pa web och mobil genom miljoinjektering, inte forks av API-klient. + +### Fas 3: Mobilanpassad auth/tokenlagring +1. Implementera `SecureTokenStorage` med `flutter_secure_storage` for mobil. +2. Uppdatera `platform_providers.dart` till plattformsval: + - Web -> befintlig `WebTokenStorage`. + - Android/iOS -> `SecureTokenStorage`. +3. Verifiera att inloggning/logout/session beter sig lika mellan web och mobil. + +**Leverabel:** JWT lagras sakert pa mobil, befintligt webbeteende bibehalls. + +### Fas 4: UI- och UX-hardning for mindre skarmar +1. Identifiera skarmar med hog informationsdensitet (admin/import/tabeller). +2. Lagg in responsiva brytpunkter med `LayoutBuilder`/`MediaQuery` dar det behovs. +3. Prioritera funktionellt minimum for mobil i forsta iteration: + - Login + - Inventarie + - Receptlista + - Grundlaggande importfloden +4. Markera admin-tunga vyer som sekundara om de inte ar mobilkritiska i fas 1. + +**Leverabel:** Nyckelfloden ar anvandbara pa telefon utan horisontell overflow eller blockerande layoutfel. + +### Fas 5: Build- och releaseflode (utan att blanda ihop med Docker-runtime) +1. Dokumentera separata kommandon: + - Web (befintligt): Docker/compose. + - Android: `flutter build apk --release` (och ev. `appbundle`). + - iOS: `flutter build ios --release` (kraver macOS/Xcode). +2. Behall principen: mobilappar kor inte i Docker; Docker far anvandas som byggmiljo dar det ar rimligt. +3. Om CI ska byggas senare: separera web-jobb och mobil-jobb for tydlighet. + +**Leverabel:** Reproducerbar byggprocess for web och mobil med tydlig ansvarsskillnad. + +### Fas 6: Test- och verifieringsplan +1. Statisk kvalitet: + - `flutter analyze` + - `flutter test` +2. Plattformsverifiering: + - Web via befintlig container + - Android emulator + fysisk enhet + - iOS simulator (pa macOS) +3. Natverksverifiering: + - Bekrafta att mobil kan na vald `API_BASE_URL` over HTTPS/CORS/proxyregler. +4. Regression: + - Inloggning, token-refresh/logout + - CRUD i inventarie/recept + - Importendpoints med storre payloads + +**Leverabel:** Checklista med passerade verifieringspunkter innan distribution. + +## Konkreta filer som sannolikt berors +- `flutter/pubspec.yaml` (nytt beroende for secure storage) +- `flutter/lib/core/platform/platform_providers.dart` +- `flutter/lib/core/platform/token_storage.dart` (ev. endast kontraktsjustering) +- Ny fil: `flutter/lib/core/platform/secure_token_storage.dart` +- Mobilplattformar som genereras: `flutter/android/**`, `flutter/ios/**` +- Dokumentation: `README.md` och/eller `TEKNISK_BESKRIVNING.md` (kommandon och miljoexempel) + +## Risker och hantering +- iOS-bygg kan inte verifieras i Windows/Linux-miljo -> hanteras med separat macOS-steg. +- Hardkodade URL:er kan smyga sig in i featurekod -> hanteras med kodsok + central konfigpolicy. +- UI-regression pa web vid responsiva andringar -> hanteras med web-regressionstest av kritiska vyer. + +## Prioriterad ordning for implementation +1. Fas 1 (plattformsstommar) +2. Fas 2 (API-konfiguration) +3. Fas 3 (secure token storage) +4. Fas 6 del 1 (analyze/test tidigt) +5. Fas 4 (mobil UI-hardning) +6. Fas 5 + Fas 6 slutlig verifiering och dokumentation + +## Definition of Done +- Flutter-projektet bygger for web + android (och ios dar macOS finns). +- Mobil och web anvander samma API-konfigmodell via `--dart-define`. +- Mobil lagrar token sakert; webflodet ar oforandrat. +- Minst nyckelfloden login/inventarie/recept/import ar verifierade pa mobil. +- Dokumentationen beskriver exakt hur man bygger och kor respektive plattform i detta repo. diff --git a/_archive/docs/SESSION_CHECKPOINT_2026-05-21.md b/_archive/docs/SESSION_CHECKPOINT_2026-05-21.md new file mode 100644 index 00000000..34d9a828 --- /dev/null +++ b/_archive/docs/SESSION_CHECKPOINT_2026-05-21.md @@ -0,0 +1,87 @@ +# Session Checkpoint (2026-05-21) + +> Föregående checkpoint: [SESSION_CHECKPOINT_2026-05-12.md](SESSION_CHECKPOINT_2026-05-12.md) + +## Status + +- Arbetsytan är ren (`git status --short` gav ingen output). +- Kritiska build-blockers för Flutter-l10n är åtgärdade. +- Backend build + backend tester + Flutter tester verifierade gröna i denna session. + +## Klart i denna session + +### 1. Felsökning och fix av Docker-fel i Flutter `gen-l10n` + +**Problem:** Docker-bygg kraschade vid `flutter gen-l10n` p.g.a. ogiltig ARB-JSON och konflikt i locale-filer. + +**Åtgärder:** +- `flutter/lib/l10n/app_en.arb` reparerad (felaktig JSON-struktur, saknade/utanförliggande nycklar). +- Krock mellan engelska locale-filer hanterad (dubbla `en`-källor var en del av tidigare felsymptom). +- `flutter gen-l10n` kördes om utan formatteringsfel. + +### 2. Fix av Flutter test-fel: saknad l10n-nyckel `required` + +**Problem:** `flutter test` föll på: +- `The getter 'required' isn't defined for the type 'AppLocalizations'` +- fel i `lib/features/admin/presentation/admin_users_panel.dart`. + +**Åtgärder:** +- Återställde saknade nycklar i `flutter/lib/l10n/app_en.arb`: + - `required` + - `logoutAction` + - `adminAiDescription` + - `adminPagePrefix` +- Synkade svenska ARB-filen och la till saknad nyckel: + - `profileDatabaseDescription` +- Regenererade lokaliseringar med `flutter gen-l10n`. + +### 3. Kvalitetsverifiering + +Körda verifieringar: + +```bash +# Backend +cd backend +npm run build +npm run test + +# Flutter +cd ../flutter +flutter gen-l10n +flutter test --reporter compact +``` + +**Resultat:** +- Backend build: OK +- Backend tests: OK (29/29 suites, 245/245 tester) +- Flutter tests: OK (alla passerar) + +## Viktig kontext inför nästa session + +- Root-varningen från Flutter i Docker (`trying to run flutter as root`) är en varning och blockerar inte i sig. +- Den blockerande orsaken var ARB/l10n-konsistens, inte root-varningen. +- Nuvarande l10n-läge är stabilt efter regeneration. + +## Rekommenderad snabbstart imorgon + +```bash +# 1) Verifiera ren arbetsyta +git status --short + +# 2) Reprova hela lokala verifieringen +cd backend +npm run build && npm run test + +cd ../flutter +flutter gen-l10n +flutter test --reporter compact + +# 3) Om allt är grönt, kör deploy/build-pipeline igen +``` + +## Ändrade filer i denna session (huvudsakligen) + +- `flutter/lib/l10n/app_en.arb` +- `flutter/lib/l10n/app_sv.arb` +- genererade l10n-filer under `flutter/lib/l10n/generated/*` +- mindre korrigeringar i backend-test/service under felsökningen, slutläge verifierat grönt. diff --git a/compose.flutter.yml b/compose.flutter.yml index eae2db8b..b38b3625 100644 --- a/compose.flutter.yml +++ b/compose.flutter.yml @@ -3,8 +3,10 @@ services: build: context: ./flutter dockerfile: Dockerfile - args: - API_BASE_URL: "/api" + args: + API_BASE_URL: "/api" + SOURCE_MAPS: "false" + WEB_RENDERER: "auto" image: recipe-flutter:local container_name: recipe-flutter restart: unless-stopped diff --git a/flutter/Caddyfile b/flutter/Caddyfile index 03a88ca3..d0cb9611 100644 --- a/flutter/Caddyfile +++ b/flutter/Caddyfile @@ -1,10 +1,30 @@ -:{$PORT:5000} { - root * /usr/share/caddy - - # Proxy API calls to backend service on the internal Docker network. - handle /api/* { - reverse_proxy recipe-api:8080 - } +:{$PORT:5000} { + root * /usr/share/caddy + + header { + Content-Security-Policy "default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self' https: http: ws: wss:; worker-src 'self' blob:" + } + + @staticAssets { + path *.js *.wasm *.woff *.woff2 *.ttf *.otf + } + header @staticAssets Cache-Control "public, max-age=86400" + + @hashedAssets { + path_regexp hashedAssets .*[._-][0-9a-fA-F]{8,}\.(js|css|wasm|woff2?|ttf|otf)$ + } + header @hashedAssets Cache-Control "public, max-age=31536000, immutable" + + @serviceWorker path /flutter_service_worker.js /version.json + header @serviceWorker Cache-Control "no-cache, must-revalidate" + + @index path / /index.html + header @index Cache-Control "public, max-age=300, must-revalidate" + + # Proxy API calls to backend service on the internal Docker network. + handle /api/* { + reverse_proxy recipe-api:8080 + } # SPA-routing – returnera alltid index.html för okända paths handle { diff --git a/flutter/Dockerfile b/flutter/Dockerfile index aeff9756..8698b773 100644 --- a/flutter/Dockerfile +++ b/flutter/Dockerfile @@ -14,11 +14,20 @@ RUN flutter gen-l10n # Run tests RUN flutter test -# Inject API base URL at build time via --dart-define. -# Default to same-origin /api to avoid mixed-content in HTTPS deployments. -ARG API_BASE_URL=/api -RUN flutter build web --release \ - --dart-define=API_BASE_URL=${API_BASE_URL} +# Inject API base URL at build time via --dart-define. +# Default to same-origin /api to avoid mixed-content in HTTPS deployments. +ARG API_BASE_URL=/api +ARG SOURCE_MAPS=false +ARG WEB_RENDERER=auto +RUN set -eux; \ + build_args="--release --dart-define=API_BASE_URL=${API_BASE_URL}"; \ + if [ "${SOURCE_MAPS}" = "false" ]; then \ + build_args="${build_args} --no-source-maps"; \ + fi; \ + if [ "${WEB_RENDERER}" != "auto" ]; then \ + build_args="${build_args} --web-renderer=${WEB_RENDERER}"; \ + fi; \ + flutter build web ${build_args} # Stage 2 – Serve with Caddy FROM caddy:alpine AS runner diff --git a/flutter/lib/core/router/app_router.dart b/flutter/lib/core/router/app_router.dart index 8448d8ce..fdf0e940 100644 --- a/flutter/lib/core/router/app_router.dart +++ b/flutter/lib/core/router/app_router.dart @@ -21,22 +21,55 @@ import '../../features/inventory/presentation/consume_inventory_screen.dart'; import '../../features/inventory/presentation/consumption_history_screen.dart'; import '../../features/meal_plan/presentation/meal_plan_screen.dart'; import '../../features/pantry/presentation/pantry_screen.dart'; -import '../../features/import/presentation/import_screen.dart'; -import '../../features/shopping_list/presentation/shopping_list_screen.dart'; -import '../../features/admin/presentation/admin_screen.dart'; +import '../../features/import/presentation/import_screen.dart' + deferred as import_ui; +import '../../features/shopping_list/presentation/shopping_list_screen.dart'; +import '../../features/admin/presentation/admin_screen.dart'; int? _shellBranchIndexForPath(String path) { if (path.startsWith('/recipes')) return 0; if (path.startsWith('/inventory')) return 1; if (path.startsWith('/matsedel')) return 2; if (path.startsWith('/baslager')) return 3; - if (path.startsWith('/import')) return 4; - if (path.startsWith('/inkopslista')) return 5; - if (path.startsWith('/profile')) return 6; - if (path.startsWith('/admin')) return 7; + if (path.startsWith('/import')) return 4; + if (path.startsWith('/inkopslista')) return 5; + if (path.startsWith('/profile')) return 6; + if (path.startsWith('/admin')) return 7; return null; } +class _DeferredRouteLoader extends StatelessWidget { + const _DeferredRouteLoader({ + required this.loadLibrary, + required this.builder, + }); + + final Future Function() loadLibrary; + final WidgetBuilder builder; + + @override + Widget build(BuildContext context) { + return FutureBuilder( + future: loadLibrary(), + builder: (context, snapshot) { + if (snapshot.connectionState != ConnectionState.done) { + return const Scaffold( + body: LoadingStateView(label: 'Laddar vy...'), + ); + } + if (snapshot.hasError) { + return Scaffold( + body: Center( + child: Text('Kunde inte ladda sidan: ${snapshot.error}'), + ), + ); + } + return builder(context); + }, + ); + } +} + final appRouterProvider = Provider((ref) { final authState = ref.watch(authStateProvider); @@ -244,26 +277,29 @@ final appRouterProvider = Provider((ref) { ), ], ), - StatefulShellBranch( - routes: [ - GoRoute( - path: '/import', - builder: (context, state) => const ImportScreen(), - ), - ], - ), - StatefulShellBranch( - routes: [ - GoRoute( - path: '/inkopslista', - builder: (context, state) => const ShoppingListScreen(), - ), - ], - ), - StatefulShellBranch( - routes: [ - GoRoute( - path: '/profile', + StatefulShellBranch( + routes: [ + GoRoute( + path: '/import', + builder: (context, state) => _DeferredRouteLoader( + loadLibrary: import_ui.loadLibrary, + builder: (_) => import_ui.ImportScreen(), + ), + ), + ], + ), + StatefulShellBranch( + routes: [ + GoRoute( + path: '/inkopslista', + builder: (context, state) => const ShoppingListScreen(), + ), + ], + ), + StatefulShellBranch( + routes: [ + GoRoute( + path: '/profile', builder: (context, state) => const ProfileScreen(), ), ], @@ -273,7 +309,8 @@ final appRouterProvider = Provider((ref) { GoRoute( path: '/admin', redirect: (context, state) { - final token = ref.read(authStateProvider) + final token = ref + .read(authStateProvider) .maybeWhen(data: (t) => t, orElse: () => null); return jwtIsAdmin(token) ? null : '/recipes'; }, diff --git a/flutter/lib/core/ui/app_shell.dart b/flutter/lib/core/ui/app_shell.dart index 01fe23f3..6adafe44 100644 --- a/flutter/lib/core/ui/app_shell.dart +++ b/flutter/lib/core/ui/app_shell.dart @@ -181,9 +181,14 @@ class AppShell extends ConsumerWidget { tooltip: view.mode == RecipesViewMode.grid ? 'Visa som lista' : 'Visa som grid', - icon: Icon(view.mode == RecipesViewMode.grid - ? Icons.view_list - : Icons.grid_view), + icon: Icon( + view.mode == RecipesViewMode.grid + ? Icons.view_list + : Icons.grid_view, + semanticLabel: view.mode == RecipesViewMode.grid + ? 'Visa som lista' + : 'Visa som grid', + ), onPressed: () => ref.read(recipesViewProvider.notifier).toggleMode(), ), @@ -207,7 +212,10 @@ class AppShell extends ConsumerWidget { ), PopupMenuButton( tooltip: 'Profil och konto', - icon: const Icon(Icons.account_circle_outlined), + icon: const Icon( + Icons.account_circle_outlined, + semanticLabel: 'Profil och konto', + ), onSelected: (value) async { switch (value) { case 'profile': diff --git a/flutter/lib/features/import/presentation/flyer_import_tab.dart b/flutter/lib/features/import/presentation/flyer_import_tab.dart index f834628f..30339368 100644 --- a/flutter/lib/features/import/presentation/flyer_import_tab.dart +++ b/flutter/lib/features/import/presentation/flyer_import_tab.dart @@ -44,14 +44,17 @@ class _FlyerImportTabState extends ConsumerState { try { final token = await ref.read(authStateProvider.future); final api = ref.read(apiClientProvider); - final categoryData = await api.getJson(CategoryApiPaths.tree, token: token); + final categoryData = + await api.getJson(CategoryApiPaths.tree, token: token); final categoryList = categoryData is List ? categoryData - : (categoryData is Map && categoryData['items'] is List) + : (categoryData is Map && + categoryData['items'] is List) ? categoryData['items'] as List : const []; final tree = categoryList - .map((e) => AdminCategoryNode.fromJson(Map.from(e as Map))) + .map((e) => + AdminCategoryNode.fromJson(Map.from(e as Map))) .toList(); if (!mounted) return; setState(() => _categoryTree = tree); @@ -142,12 +145,14 @@ class _FlyerImportTabState extends ConsumerState { } } - Future _loadRestoredSourceIfNeeded(FlyerImportResult result, String? token) async { + Future _loadRestoredSourceIfNeeded( + FlyerImportResult result, String? token) async { if (result.sessionId == null || result.sourceAvailable != true) return; if (_pickedFile?.bytes != null) return; try { final repo = ref.read(importRepositoryProvider); - final bytes = await repo.getFlyerSourceBytes(sessionId: result.sessionId!, token: token); + final bytes = await repo.getFlyerSourceBytes( + sessionId: result.sessionId!, token: token); if (!mounted) return; setState(() => _restoredSourceBytes = bytes); } catch (_) { @@ -250,7 +255,8 @@ class _FlyerImportTabState extends ConsumerState { ref.invalidate(shoppingListItemsProvider); ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('${saved.length} planerade. Inköpslista: $created tillagda, $updated uppdaterade.'), + content: Text( + '${saved.length} planerade. Inköpslista: $created tillagda, $updated uppdaterade.'), action: SnackBarAction( label: 'Öppna', onPressed: () => context.go('/inkopslista'), @@ -273,7 +279,8 @@ class _FlyerImportTabState extends ConsumerState { int? selectedCategoryId = item.categoryId; String? selectedCategoryPath = item.category; - final payload = await showDialog<({String name, int? categoryId, String? categoryPath})>( + final payload = await showDialog< + ({String name, int? categoryId, String? categoryPath})>( context: context, builder: (context) { return StatefulBuilder( @@ -295,7 +302,8 @@ class _FlyerImportTabState extends ConsumerState { ), const SizedBox(height: 12), Text( - selectedCategoryPath == null || selectedCategoryPath!.isEmpty + selectedCategoryPath == null || + selectedCategoryPath!.isEmpty ? 'Ingen kategori vald' : selectedCategoryPath!, ), @@ -307,7 +315,9 @@ class _FlyerImportTabState extends ConsumerState { onPressed: _categoryTree.isEmpty ? null : () async { - final selected = await CategoryThenProductPicker.showCategorySheet( + final selected = + await CategoryThenProductPicker + .showCategorySheet( context, categoryTree: _categoryTree, preselectedCategoryId: selectedCategoryId, @@ -402,7 +412,8 @@ class _FlyerImportTabState extends ConsumerState { String _formatPrice(double? price, String? unit) { if (price == null) return ''; final raw = price.toStringAsFixed(2).replaceAll('.', ','); - final unitPart = (unit != null && unit.trim().isNotEmpty) ? '/${unit.trim()}' : ''; + final unitPart = + (unit != null && unit.trim().isNotEmpty) ? '/${unit.trim()}' : ''; return '$raw kr$unitPart'; } @@ -412,13 +423,20 @@ class _FlyerImportTabState extends ConsumerState { if (trimmedOffer.isEmpty || trimmedLimit.isEmpty) return trimmedOffer; final escaped = RegExp.escape(trimmedLimit); - final withoutLimit = trimmedOffer.replaceAll(RegExp(escaped, caseSensitive: false), '').trim(); - final withoutLeadingPunctuation = withoutLimit.replaceAll(RegExp(r'^[,.;:\s-]+'), '').trim(); - return withoutLeadingPunctuation.replaceAll(RegExp(r'[,.;:\s-]+$'), '').trim(); + final withoutLimit = trimmedOffer + .replaceAll(RegExp(escaped, caseSensitive: false), '') + .trim(); + final withoutLeadingPunctuation = + withoutLimit.replaceAll(RegExp(r'^[,.;:\s-]+'), '').trim(); + return withoutLeadingPunctuation + .replaceAll(RegExp(r'[,.;:\s-]+$'), '') + .trim(); } Widget _buildOfferBadge(FlyerImportItem item, ThemeData theme) { - final hasOffer = item.isOffer || (item.offerText?.trim().isNotEmpty ?? false) || item.price != null; + final hasOffer = item.isOffer || + (item.offerText?.trim().isNotEmpty ?? false) || + item.price != null; if (!hasOffer) return const SizedBox.shrink(); return Container( @@ -493,7 +511,8 @@ class _FlyerImportTabState extends ConsumerState { children: [ Row( children: [ - Icon(Icons.warning_amber_rounded, color: Colors.amber.shade800, size: 18), + Icon(Icons.warning_amber_rounded, + color: Colors.amber.shade800, size: 18), const SizedBox(width: 8), Text( 'Varningar (${warnings.length})', @@ -546,14 +565,16 @@ class _FlyerImportTabState extends ConsumerState { : OutlinedButton.icon( icon: const Icon(Icons.open_in_new, size: 16), label: const Text('Visa flyer'), - style: OutlinedButton.styleFrom(visualDensity: VisualDensity.compact), + style: OutlinedButton.styleFrom( + visualDensity: VisualDensity.compact), onPressed: () async { final messenger = ScaffoldMessenger.of(context); final opened = await openPdfBytes(bytes); if (!context.mounted || opened) return; messenger.showSnackBar( const SnackBar( - content: Text('PDF kan bara öppnas direkt i webbversionen just nu.'), + content: Text( + 'PDF kan bara öppnas direkt i webbversionen just nu.'), ), ); }, @@ -600,25 +621,29 @@ class _FlyerImportTabState extends ConsumerState { ), const SizedBox(height: 12), FilledButton.icon( - onPressed: (!_isLoading && _pickedFile?.bytes != null) ? _parseFlyer : null, + onPressed: (!_isLoading && _pickedFile?.bytes != null) + ? _parseFlyer + : null, icon: const Icon(Icons.auto_awesome), label: const Text('Importera flyer'), ), const SizedBox(height: 12), - _buildFlyerPreview(theme), - if (_isLoading) ...[ - const SizedBox(height: 12), - const LinearProgressIndicator(), - ], - if (items.isNotEmpty) ...[ - const SizedBox(height: 20), - _buildWarningsPanel(theme), - if ((_result?.warnings ?? const []).isNotEmpty) const SizedBox(height: 12), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text('${items.length} rader hittades', style: theme.textTheme.titleSmall), - TextButton( + _buildFlyerPreview(theme), + if (_isLoading) ...[ + const SizedBox(height: 12), + const LinearProgressIndicator(), + ], + if (items.isNotEmpty) ...[ + const SizedBox(height: 20), + _buildWarningsPanel(theme), + if ((_result?.warnings ?? const []).isNotEmpty) + const SizedBox(height: 12), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('${items.length} rader hittades', + style: theme.textTheme.titleSmall), + TextButton( onPressed: () { final target = selectedCount < items.length; setState(() { @@ -626,77 +651,92 @@ class _FlyerImportTabState extends ConsumerState { _selected[i] = target; } }); - ref.read(flyerImportSessionProvider.notifier).setSelectedForAll(items.length, target); + ref + .read(flyerImportSessionProvider.notifier) + .setSelectedForAll(items.length, target); }, - child: Text(selectedCount < items.length ? 'Välj alla' : 'Avmarkera alla'), - ), - ], - ), - const SizedBox(height: 8), + child: Text(selectedCount < items.length + ? 'Välj alla' + : 'Avmarkera alla'), + ), + ], + ), + const SizedBox(height: 8), ...items.asMap().entries.map((entry) { final index = entry.key; final item = entry.value; final priceText = _formatPrice(item.price, item.priceUnit); - final comparisonText = _formatPrice(item.comparisonPrice, item.comparisonUnit); + final comparisonText = + _formatPrice(item.comparisonPrice, item.comparisonUnit); final limitText = item.offerLimitText?.trim(); final sanitizedOfferText = item.offerText == null ? '' : _removeLimitTextFromOfferText(item.offerText!, limitText); - return CheckboxListTile( - value: _selected[index] ?? false, - onChanged: (value) { - final checked = value ?? false; - setState(() => _selected[index] = checked); - ref.read(flyerImportSessionProvider.notifier).setSelected(index, checked); - }, - title: Row( - children: [ - Expanded(child: Text(item.rawName)), - IconButton( - tooltip: 'Redigera', - visualDensity: VisualDensity.compact, - icon: const Icon(Icons.edit_outlined, size: 18), - onPressed: () => _editItem(index, item), + return CheckboxListTile( + value: _selected[index] ?? false, + onChanged: (value) { + final checked = value ?? false; + setState(() => _selected[index] = checked); + ref + .read(flyerImportSessionProvider.notifier) + .setSelected(index, checked); + }, + title: Row( + children: [ + Expanded(child: Text(item.rawName)), + IconButton( + tooltip: 'Redigera', + visualDensity: VisualDensity.compact, + icon: const Icon( + Icons.edit_outlined, + size: 18, + semanticLabel: 'Redigera rad', ), - const SizedBox(width: 8), - _buildQualityBadge(item, theme), - const SizedBox(width: 8), - _buildOfferBadge(item, theme), - ], - ), - subtitle: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (priceText.isNotEmpty) Text('Pris: $priceText'), - if ((item.category ?? '').trim().isNotEmpty) - Text('Kategori: ${item.category}'), - if (comparisonText.isNotEmpty) Text('Jämförpris: $comparisonText'), - if (limitText != null && limitText.isNotEmpty) - Text( - 'Begränsning: $limitText', - style: theme.textTheme.bodyMedium?.copyWith( - color: Colors.orange.shade900, - fontWeight: FontWeight.w600, - ), - ), - if (sanitizedOfferText.isNotEmpty) Text(sanitizedOfferText), - if (item.matchedProductName != null) Text('Match: ${item.matchedProductName}'), - ], - ), - controlAffinity: ListTileControlAffinity.leading, - ); + onPressed: () => _editItem(index, item), + ), + const SizedBox(width: 8), + _buildQualityBadge(item, theme), + const SizedBox(width: 8), + _buildOfferBadge(item, theme), + ], + ), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (priceText.isNotEmpty) Text('Pris: $priceText'), + if ((item.category ?? '').trim().isNotEmpty) + Text('Kategori: ${item.category}'), + if (comparisonText.isNotEmpty) + Text('Jämförpris: $comparisonText'), + if (limitText != null && limitText.isNotEmpty) + Text( + 'Begränsning: $limitText', + style: theme.textTheme.bodyMedium?.copyWith( + color: Colors.orange.shade900, + fontWeight: FontWeight.w600, + ), + ), + if (sanitizedOfferText.isNotEmpty) Text(sanitizedOfferText), + if (item.matchedProductName != null) + Text('Match: ${item.matchedProductName}'), + ], + ), + controlAffinity: ListTileControlAffinity.leading, + ); }), const SizedBox(height: 8), SizedBox( width: double.infinity, child: FilledButton.icon( - onPressed: (_isSaving || selectedCount == 0) ? null : _planSelected, + onPressed: + (_isSaving || selectedCount == 0) ? null : _planSelected, icon: _isSaving ? const SizedBox( width: 16, height: 16, - child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white), + child: CircularProgressIndicator( + strokeWidth: 2, color: Colors.white), ) : const Icon(Icons.playlist_add_check), label: Text('Planera $selectedCount markerade'), diff --git a/flutter/lib/features/import/presentation/receipt_import_tab.dart b/flutter/lib/features/import/presentation/receipt_import_tab.dart index 319cfad1..f349c33b 100644 --- a/flutter/lib/features/import/presentation/receipt_import_tab.dart +++ b/flutter/lib/features/import/presentation/receipt_import_tab.dart @@ -133,7 +133,8 @@ class _ReceiptImportTabState extends ConsumerState { final token = await ref.read(authStateProvider.future); try { - final globalData = await api.getJson(ProductApiPaths.list, token: token); + final globalData = + await api.getJson(ProductApiPaths.list, token: token); globalList = _extractItems(globalData); } catch (e, st) { globalFailed = true; @@ -146,7 +147,8 @@ class _ReceiptImportTabState extends ConsumerState { mineList = _extractItems(mineData); } catch (e, st) { privateFailed = true; - debugPrint('ReceiptImportTab._loadProducts private products failed: $e'); + debugPrint( + 'ReceiptImportTab._loadProducts private products failed: $e'); debugPrintStack(stackTrace: st); } } catch (e, st) { @@ -158,12 +160,16 @@ class _ReceiptImportTabState extends ConsumerState { if (!mounted) return; final mergedProducts = [ - ...globalList - .cast>() - .map((e) => (id: e['id'] as int, name: (e['canonicalName'] ?? e['name']) as String, categoryId: (e['categoryId'] as num?)?.toInt())), - ...mineList - .cast>() - .map((e) => (id: e['id'] as int, name: (e['canonicalName'] ?? e['name']) as String, categoryId: (e['categoryId'] as num?)?.toInt())), + ...globalList.cast>().map((e) => ( + id: e['id'] as int, + name: (e['canonicalName'] ?? e['name']) as String, + categoryId: (e['categoryId'] as num?)?.toInt() + )), + ...mineList.cast>().map((e) => ( + id: e['id'] as int, + name: (e['canonicalName'] ?? e['name']) as String, + categoryId: (e['categoryId'] as num?)?.toInt() + )), ]; final dedupedById = { for (final product in mergedProducts) product.id: product, @@ -181,7 +187,9 @@ class _ReceiptImportTabState extends ConsumerState { if (_products.isEmpty && _categoryTree.isEmpty) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Kunde inte ladda produkter eller kategorier. Försök igen.')), + const SnackBar( + content: Text( + 'Kunde inte ladda produkter eller kategorier. Försök igen.')), ); } } @@ -211,7 +219,8 @@ class _ReceiptImportTabState extends ConsumerState { child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Icon(Icons.warning_amber_rounded, color: Colors.amber.shade800, size: 18), + Icon(Icons.warning_amber_rounded, + color: Colors.amber.shade800, size: 18), const SizedBox(width: 8), Expanded( child: Text( @@ -244,7 +253,8 @@ class _ReceiptImportTabState extends ConsumerState { if (mounted) { setState(() { _inventoryByProduct = { - for (final item in results[0] as List) item.productId: item, + for (final item in results[0] as List) + item.productId: item, }; _pantryProductIds = { for (final item in results[1] as List) item.productId, @@ -267,10 +277,10 @@ class _ReceiptImportTabState extends ConsumerState { setState(() => _pickedFile = file); // Spara bildbytes i session så att förhandsvisningen överlever tabbyte ref.read(receiptImportSessionProvider.notifier).setFile( - file.bytes!, - file.extension?.toLowerCase() ?? '', - fileName: file.name, - ); + file.bytes!, + file.extension?.toLowerCase() ?? '', + fileName: file.name, + ); } Future _submit() async { @@ -278,10 +288,13 @@ class _ReceiptImportTabState extends ConsumerState { final submitBytes = _pickedFile?.bytes ?? session?.fileBytes; if (submitBytes == null) return; - final submitFileName = - _pickedFile?.name ?? session?.fileName ?? 'kvitto.${session?.fileExtension ?? 'pdf'}'; + final submitFileName = _pickedFile?.name ?? + session?.fileName ?? + 'kvitto.${session?.fileExtension ?? 'pdf'}'; - setState(() { _isLoading = true; }); + setState(() { + _isLoading = true; + }); // Obs: setFile() i _pickFile har redan placerat bytes i session; clear() behövs ej här try { @@ -307,8 +320,8 @@ class _ReceiptImportTabState extends ConsumerState { unit: it.unit, ); final name = it.matchedProductName ?? it.suggestedProductName; - final resolvedCategoryId = - it.categorySuggestionId ?? (pid != null ? _categoryIdForProduct(pid) : null); + final resolvedCategoryId = it.categorySuggestionId ?? + (pid != null ? _categoryIdForProduct(pid) : null); final resolvedCategoryPath = it.categorySuggestionPath ?? _lookup.pathFor(resolvedCategoryId); @@ -337,7 +350,8 @@ class _ReceiptImportTabState extends ConsumerState { // Ladda inventariet för att visa befintliga poster och möjliggöra sammanslagning await _loadInventory(); } catch (e) { - if (mounted) showGlobalErrorDialog(context, 'Ett fel uppstod vid import: $e'); + if (mounted) + showGlobalErrorDialog(context, 'Ett fel uppstod vid import: $e'); } finally { if (mounted) setState(() => _isLoading = false); } @@ -379,13 +393,15 @@ class _ReceiptImportTabState extends ConsumerState { _ItemEdit( productId: item.matchedProductId ?? item.suggestedProductId, productName: item.matchedProductName ?? item.suggestedProductName, - categoryId: item.categorySuggestionId ?? - _categoryIdForProduct(item.matchedProductId ?? item.suggestedProductId), - categoryPath: item.categorySuggestionPath ?? - _lookup.pathFor( - item.categorySuggestionId ?? - _categoryIdForProduct(item.matchedProductId ?? item.suggestedProductId), - ), + categoryId: item.categorySuggestionId ?? + _categoryIdForProduct( + item.matchedProductId ?? item.suggestedProductId), + categoryPath: item.categorySuggestionPath ?? + _lookup.pathFor( + item.categorySuggestionId ?? + _categoryIdForProduct( + item.matchedProductId ?? item.suggestedProductId), + ), categorySource: item.categorySuggestionId != null ? CategorySelectionSource.ai : null, @@ -432,7 +448,7 @@ class _ReceiptImportTabState extends ConsumerState { throw Exception('API-svar saknar produktnamn.'); } - final int? returnedCategoryId = raw['categoryId'] is num + final int? returnedCategoryId = raw['categoryId'] is num ? (raw['categoryId'] as num).toInt() : categoryId; @@ -454,7 +470,8 @@ class _ReceiptImportTabState extends ConsumerState { } } - Future _showReceiptPreview(BuildContext context, List items) async { + Future _showReceiptPreview( + BuildContext context, List items) async { if (!context.mounted) return; await showDialog( context: context, @@ -468,7 +485,8 @@ class _ReceiptImportTabState extends ConsumerState { try { final token = await ref.read(authStateProvider.future); final repo = ref.read(importRepositoryProvider); - final help = await repo.fetchHelpTextByKey('receipt_import', token: token); + final help = + await repo.fetchHelpTextByKey('receipt_import', token: token); if (!mounted) return; await _showHelpDialog(help); } catch (e) { @@ -503,7 +521,8 @@ class _ReceiptImportTabState extends ConsumerState { runSpacing: 8, children: [ Chip(label: Text('Scope: ${help.scope}')), - if (updatedAtText != null) Chip(label: Text('Uppdaterad: $updatedAtText')), + if (updatedAtText != null) + Chip(label: Text('Uppdaterad: $updatedAtText')), ], ), ], @@ -532,7 +551,7 @@ class _ReceiptImportTabState extends ConsumerState { final remainingEdits = {}; final remainingSelected = {}; var newIndex = 0; - + for (var oldIndex = 0; oldIndex < items.length; oldIndex++) { if (oldIndex != index) { if (_edits.containsKey(oldIndex)) { @@ -569,7 +588,8 @@ class _ReceiptImportTabState extends ConsumerState { if (productId == null) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Välj produkt för raden innan du redigerar alias.')), + const SnackBar( + content: Text('Välj produkt för raden innan du redigerar alias.')), ); return; } @@ -636,7 +656,8 @@ class _ReceiptImportTabState extends ConsumerState { } if (toAdd.isEmpty) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Välj produkter för alla markerade rader först.')), + const SnackBar( + content: Text('Välj produkter för alla markerade rader först.')), ); return; } @@ -657,7 +678,8 @@ class _ReceiptImportTabState extends ConsumerState { 'rawName': item.rawName, 'quantity': edit.quantity ?? item.quantity ?? 0, 'unit': (edit.unit ?? item.unit ?? 'st').trim(), - 'destination': edit.destination == _Destination.pantry ? 'pantry' : 'inventory', + 'destination': + edit.destination == _Destination.pantry ? 'pantry' : 'inventory', 'productId': pid, }; @@ -668,14 +690,19 @@ class _ReceiptImportTabState extends ConsumerState { // Päckfält för inventory if (edit.destination == _Destination.inventory) { - if (edit.packQuantity != null) saveItem['packQuantity'] = edit.packQuantity; + if (edit.packQuantity != null) + saveItem['packQuantity'] = edit.packQuantity; if (edit.packUnit != null) saveItem['packUnit'] = edit.packUnit; - if (edit.packageCount != null) saveItem['packageCount'] = edit.packageCount; + if (edit.packageCount != null) + saveItem['packageCount'] = edit.packageCount; } // Lär in alias bara om användaren uttryckligen valt det - final alreadyAliasMatch = item.matchedVia == 'alias' && item.matchedProductId == pid; - if (edit.learnAlias && item.rawName.trim().isNotEmpty && !alreadyAliasMatch) { + final alreadyAliasMatch = + item.matchedVia == 'alias' && item.matchedProductId == pid; + if (edit.learnAlias && + item.rawName.trim().isNotEmpty && + !alreadyAliasMatch) { saveItem['learnAlias'] = true; if (edit.learnAliasGlobally) { saveItem['learnAliasGlobally'] = true; @@ -685,8 +712,11 @@ class _ReceiptImportTabState extends ConsumerState { // Lär in enhetsmappning för inventory if (edit.destination == _Destination.inventory) { final originalUnit = (item.unit ?? '').trim().toLowerCase(); - final preferredUnit = (edit.unit ?? item.unit ?? 'st').trim().toLowerCase(); - if (originalUnit.isNotEmpty && preferredUnit.isNotEmpty && originalUnit != preferredUnit) { + final preferredUnit = + (edit.unit ?? item.unit ?? 'st').trim().toLowerCase(); + if (originalUnit.isNotEmpty && + preferredUnit.isNotEmpty && + originalUnit != preferredUnit) { saveItem['learnUnitMapping'] = true; } } @@ -708,22 +738,25 @@ class _ReceiptImportTabState extends ConsumerState { final pantryAdded = response['pantryAdded'] as int? ?? 0; final pantrySkipped = response['pantrySkipped'] as int? ?? 0; final aliasesLearned = response['aliasesLearned'] as int? ?? 0; - final unitMappingsLearned = response['unitMappingsLearned'] as int? ?? 0; - final flyerAutoSync = response['flyerAutoSync'] as Map?; - final errors = response['errors'] as List? ?? []; + final unitMappingsLearned = response['unitMappingsLearned'] as int? ?? 0; + final flyerAutoSync = response['flyerAutoSync'] as Map?; + final errors = response['errors'] as List? ?? []; final parts = [ if (created > 0) '$created ny${created == 1 ? '' : 'a'} i inventarie', - if (merged > 0) '$merged ${merged == 1 ? 'sammanslagen' : 'sammanslagna'} i inventarie', - if (pantryAdded > 0) '$pantryAdded tillagd${pantryAdded == 1 ? '' : 'a'} i baslager', + if (merged > 0) + '$merged ${merged == 1 ? 'sammanslagen' : 'sammanslagna'} i inventarie', + if (pantryAdded > 0) + '$pantryAdded tillagd${pantryAdded == 1 ? '' : 'a'} i baslager', if (pantrySkipped > 0) '$pantrySkipped fanns redan i baslager', if (aliasesLearned > 0) '$aliasesLearned alias inlärda', - if (unitMappingsLearned > 0) '$unitMappingsLearned enhetsmappningar inlärda', - if ((flyerAutoSync?['bought'] as int? ?? 0) > 0) - '${flyerAutoSync?['bought']} planerade flyer-varor markerade som köpta', - if ((flyerAutoSync?['ambiguous'] as int? ?? 0) > 0) - '${flyerAutoSync?['ambiguous']} flyer-matchningar kräver kontroll', - ]; + if (unitMappingsLearned > 0) + '$unitMappingsLearned enhetsmappningar inlärda', + if ((flyerAutoSync?['bought'] as int? ?? 0) > 0) + '${flyerAutoSync?['bought']} planerade flyer-varor markerade som köpta', + if ((flyerAutoSync?['ambiguous'] as int? ?? 0) > 0) + '${flyerAutoSync?['ambiguous']} flyer-matchningar kräver kontroll', + ]; if (errors.isNotEmpty) { final errorParts = []; @@ -748,7 +781,7 @@ class _ReceiptImportTabState extends ConsumerState { final remainingItems = []; final remainingEdits = {}; final remainingSelected = {}; - + var newIndex = 0; for (var oldIndex = 0; oldIndex < items.length; oldIndex++) { if (!addedIndexSet.contains(oldIndex)) { @@ -762,7 +795,7 @@ class _ReceiptImportTabState extends ConsumerState { newIndex++; } } - + final notifier = ref.read(receiptImportSessionProvider.notifier); if (remainingItems.isEmpty) { notifier.clear(); @@ -788,6 +821,7 @@ class _ReceiptImportTabState extends ConsumerState { final session = ref.read(receiptImportSessionProvider); return session?.fileBytes != null; } + int get _selectedCount => _selected.values.where((v) => v).length; // ── Kvittobild / PDF-förhandsvisning ─────────────────────────────────────── @@ -814,13 +848,15 @@ class _ReceiptImportTabState extends ConsumerState { : OutlinedButton.icon( icon: const Icon(Icons.open_in_new, size: 16), label: const Text('Visa kvitto'), - style: OutlinedButton.styleFrom(visualDensity: VisualDensity.compact), + style: OutlinedButton.styleFrom( + visualDensity: VisualDensity.compact), onPressed: () async { final opened = await openPdfBytes(bytes); if (!context.mounted || opened) return; ScaffoldMessenger.of(context).showSnackBar( const SnackBar( - content: Text('PDF kan bara öppnas direkt i webbversionen just nu.'), + content: Text( + 'PDF kan bara öppnas direkt i webbversionen just nu.'), ), ); }, @@ -852,7 +888,11 @@ class _ReceiptImportTabState extends ConsumerState { 'alias' => ('Alias', Colors.teal.shade50, Colors.teal.shade800), 'wordmatch' => ('Ordmatch', Colors.blue.shade50, Colors.blue.shade800), 'ai' => ('AI-kategori', Colors.purple.shade50, Colors.purple.shade800), - _ => ('Matchad', theme.colorScheme.surfaceContainerHighest, theme.colorScheme.onSurfaceVariant), + _ => ( + 'Matchad', + theme.colorScheme.surfaceContainerHighest, + theme.colorScheme.onSurfaceVariant + ), }; return Container( @@ -864,7 +904,8 @@ class _ReceiptImportTabState extends ConsumerState { ), child: Text( label, - style: theme.textTheme.labelSmall?.copyWith(color: fg, fontWeight: FontWeight.w600), + style: theme.textTheme.labelSmall + ?.copyWith(color: fg, fontWeight: FontWeight.w600), ), ); } @@ -876,8 +917,7 @@ class _ReceiptImportTabState extends ConsumerState { final items = session?.items; final selectedFileName = _pickedFile?.name ?? session?.fileName; final selectedFileSizeBytes = - _pickedFile?.size ?? session?.fileBytes?.length; - + _pickedFile?.size ?? session?.fileBytes?.length; return SingleChildScrollView( padding: const EdgeInsets.all(16), @@ -890,7 +930,8 @@ class _ReceiptImportTabState extends ConsumerState { Expanded( child: Text( 'Ladda upp ett kvitto (PDF eller bild) — raderna tolkas och kan läggas till i ditt inventarie.', - style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant), + style: theme.textTheme.bodyMedium + ?.copyWith(color: theme.colorScheme.onSurfaceVariant), ), ), const SizedBox(width: 8), @@ -911,13 +952,15 @@ class _ReceiptImportTabState extends ConsumerState { OutlinedButton.icon( onPressed: _isLoading ? null : _pickFile, icon: const Icon(Icons.attach_file), - label: Text(selectedFileName == null ? 'Välj kvittofil' : selectedFileName), + label: Text( + selectedFileName == null ? 'Välj kvittofil' : selectedFileName), ), if (selectedFileSizeBytes != null) ...[ const SizedBox(height: 8), Text( '${(selectedFileSizeBytes / 1024).round()} KB', - style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.outline), + style: theme.textTheme.bodySmall + ?.copyWith(color: theme.colorScheme.outline), ), ], // ── Förhandsvisning av kvitto ──────────────────────────────────────── @@ -931,7 +974,8 @@ class _ReceiptImportTabState extends ConsumerState { const SizedBox(height: 8), Text( 'Tolkar kvittot — detta kan ta upp till en minut...', - style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant), + style: theme.textTheme.bodySmall + ?.copyWith(color: theme.colorScheme.onSurfaceVariant), ), const SizedBox(height: 16), ], @@ -951,21 +995,28 @@ class _ReceiptImportTabState extends ConsumerState { Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Text('${items.length} rader — tryck för att redigera', style: theme.textTheme.titleSmall), + Text('${items.length} rader — tryck för att redigera', + style: theme.textTheme.titleSmall), Row( children: [ TextButton.icon( - onPressed: items.isEmpty ? null : () => _showReceiptPreview(context, items), + onPressed: items.isEmpty + ? null + : () => _showReceiptPreview(context, items), icon: const Icon(Icons.description_outlined), label: const Text('Se kvitto'), ), const SizedBox(width: 8), TextButton( onPressed: () => setState(() { - final notifier = ref.read(receiptImportSessionProvider.notifier); - notifier.setSelectedForAll(items.length, _selectedCount < items.length); + final notifier = + ref.read(receiptImportSessionProvider.notifier); + notifier.setSelectedForAll( + items.length, _selectedCount < items.length); }), - child: Text(_selectedCount < items.length ? 'Välj alla' : 'Avmarkera alla'), + child: Text(_selectedCount < items.length + ? 'Välj alla' + : 'Avmarkera alla'), ), ], ), @@ -978,39 +1029,48 @@ class _ReceiptImportTabState extends ConsumerState { physics: const NeverScrollableScrollPhysics(), itemCount: items.length, itemBuilder: (context, i) { - return _ReceiptImportResultRow( - index: i, - item: items[i], - edit: _edits[i], - existingInventoryByProduct: _inventoryByProduct, - pantryProductIds: _pantryProductIds, - onCheckedChanged: (v) { - ref.read(receiptImportSessionProvider.notifier).setSelected(i, v); - }, - onEditRequested: () => _openEditDialog(i), - onSelectExistingRequested: () => _openEditDialog( - i, - initialEntryMode: ImportProductEntryMode.existing, - ), - onCreateRequested: () => _openEditDialog( - i, - initialEntryMode: ImportProductEntryMode.create, - ), - onAliasEditRequested: () => _editAliasForItem(i), - onDeleteRequested: () => _deleteItem(i), - matchedViaBadgeBuilder: _buildMatchedViaBadge, - ); - }, + return _ReceiptImportResultRow( + index: i, + item: items[i], + edit: _edits[i], + existingInventoryByProduct: _inventoryByProduct, + pantryProductIds: _pantryProductIds, + onCheckedChanged: (v) { + ref + .read(receiptImportSessionProvider.notifier) + .setSelected(i, v); + }, + onEditRequested: () => _openEditDialog(i), + onSelectExistingRequested: () => _openEditDialog( + i, + initialEntryMode: ImportProductEntryMode.existing, + ), + onCreateRequested: () => _openEditDialog( + i, + initialEntryMode: ImportProductEntryMode.create, + ), + onAliasEditRequested: () => _editAliasForItem(i), + onDeleteRequested: () => _deleteItem(i), + matchedViaBadgeBuilder: _buildMatchedViaBadge, + ); + }, ), const SizedBox(height: 16), SizedBox( width: double.infinity, child: FilledButton.icon( - onPressed: (_selectedCount > 0 && !_isSaving) ? _addSelected : null, + onPressed: + (_selectedCount > 0 && !_isSaving) ? _addSelected : null, icon: _isSaving - ? const SizedBox(width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white)) + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, color: Colors.white)) : const Icon(Icons.add_shopping_cart), - label: Text(_selectedCount > 0 ? 'Lägg till $_selectedCount markerade' : 'Markera rader att lägga till'), + label: Text(_selectedCount > 0 + ? 'Lägg till $_selectedCount markerade' + : 'Markera rader att lägga till'), ), ), ], @@ -1061,9 +1121,10 @@ class _ReceiptImportResultRow extends ConsumerWidget { final isMatched = item.matchedProductId != null; final isSuggested = item.suggestedProductId != null && item.matchedProductId == null; - final existingInv = edit?.productId != null && edit?.destination != _Destination.pantry - ? existingInventoryByProduct[edit!.productId] - : null; + final existingInv = + edit?.productId != null && edit?.destination != _Destination.pantry + ? existingInventoryByProduct[edit!.productId] + : null; final inferredForPreview = inferPackageFields( rawName: item.rawName, quantity: edit?.quantity ?? item.quantity, @@ -1075,7 +1136,10 @@ class _ReceiptImportResultRow extends ConsumerWidget { edit?.packQuantity ?? inferredForPreview.packQuantity; final previewIncomingQty = previewPackQuantity != null ? (previewPackQuantity * previewPackageCount) - : (edit?.quantity ?? inferredForPreview.totalQuantity ?? item.quantity ?? 0); + : (edit?.quantity ?? + inferredForPreview.totalQuantity ?? + item.quantity ?? + 0); final previewIncomingUnit = edit?.packUnit ?? inferredForPreview.packUnit ?? edit?.unit ?? @@ -1089,9 +1153,10 @@ class _ReceiptImportResultRow extends ConsumerWidget { existingInv.unit, ); final canMergePreview = existingInv != null && convertedPreviewQty != null; - final alreadyInPantry = edit?.productId != null && edit?.destination == _Destination.pantry - ? pantryProductIds.contains(edit!.productId) - : false; + final alreadyInPantry = + edit?.productId != null && edit?.destination == _Destination.pantry + ? pantryProductIds.contains(edit!.productId) + : false; return Card( margin: const EdgeInsets.symmetric(vertical: 3), @@ -1126,24 +1191,28 @@ class _ReceiptImportResultRow extends ConsumerWidget { Text( 'Produktnamn: ${normalizeProductName(edit!.productName ?? '')}', style: theme.textTheme.bodySmall?.copyWith( - color: - isMatched ? Colors.green.shade700 : theme.colorScheme.primary, + color: isMatched + ? Colors.green.shade700 + : theme.colorScheme.primary, fontWeight: FontWeight.w500, ), ), matchedViaBadgeBuilder(item, theme), if (edit!.categorySource != null) Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + padding: const EdgeInsets.symmetric( + horizontal: 8, vertical: 2), decoration: BoxDecoration( - color: edit!.categorySource == CategorySelectionSource.ai - ? Colors.green.shade50 - : theme.colorScheme.surfaceContainerHighest, + color: + edit!.categorySource == CategorySelectionSource.ai + ? Colors.green.shade50 + : theme.colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(999), border: Border.all( - color: edit!.categorySource == CategorySelectionSource.ai - ? Colors.green.shade300 - : theme.colorScheme.outlineVariant, + color: + edit!.categorySource == CategorySelectionSource.ai + ? Colors.green.shade300 + : theme.colorScheme.outlineVariant, ), ), child: Text( @@ -1151,9 +1220,10 @@ class _ReceiptImportResultRow extends ConsumerWidget { ? 'AI' : 'Manuell', style: theme.textTheme.labelSmall?.copyWith( - color: edit!.categorySource == CategorySelectionSource.ai - ? Colors.green.shade800 - : theme.colorScheme.onSurfaceVariant, + color: + edit!.categorySource == CategorySelectionSource.ai + ? Colors.green.shade800 + : theme.colorScheme.onSurfaceVariant, ), ), ), @@ -1210,33 +1280,39 @@ class _ReceiptImportResultRow extends ConsumerWidget { if (existingInv != null && canMergePreview) ...[ const SizedBox(height: 2), Row(children: [ - Icon(Icons.kitchen_outlined, size: 12, color: Colors.blue.shade700), + Icon(Icons.kitchen_outlined, + size: 12, color: Colors.blue.shade700), const SizedBox(width: 3), Text( 'I lager: ${existingInv.quantity} ${existingInv.unit} → blir ${(existingInv.quantity + convertedPreviewQty).toStringAsFixed(existingInv.quantity % 1 == 0 ? 0 : 2)} ${existingInv.unit}', - style: theme.textTheme.bodySmall?.copyWith(color: Colors.blue.shade700), + style: theme.textTheme.bodySmall + ?.copyWith(color: Colors.blue.shade700), ), ]), ], if (existingInv != null && !canMergePreview) ...[ const SizedBox(height: 2), Row(children: [ - Icon(Icons.info_outline, size: 12, color: Colors.orange.shade700), + Icon(Icons.info_outline, + size: 12, color: Colors.orange.shade700), const SizedBox(width: 3), Text( 'Finns i lager med annan enhet (${existingInv.unit}) - sparas som ny rad', - style: theme.textTheme.bodySmall?.copyWith(color: Colors.orange.shade700), + style: theme.textTheme.bodySmall + ?.copyWith(color: Colors.orange.shade700), ), ]), ], if (alreadyInPantry) ...[ const SizedBox(height: 2), Row(children: [ - Icon(Icons.inventory_2_outlined, size: 12, color: Colors.orange.shade700), + Icon(Icons.inventory_2_outlined, + size: 12, color: Colors.orange.shade700), const SizedBox(width: 3), Text( 'Finns redan i baslager', - style: theme.textTheme.bodySmall?.copyWith(color: Colors.orange.shade700), + style: theme.textTheme.bodySmall + ?.copyWith(color: Colors.orange.shade700), ), ]), ], @@ -1253,19 +1329,31 @@ class _ReceiptImportResultRow extends ConsumerWidget { : (isSuggested ? Icons.help_outline : Icons.error_outline), color: hasProduct ? Colors.green - : (isSuggested ? Colors.orange : theme.colorScheme.tertiary), + : (isSuggested + ? Colors.orange + : theme.colorScheme.tertiary), size: 20, ), if (hasProduct) IconButton( - icon: const Icon(Icons.drive_file_rename_outline, size: 18), + icon: const Icon( + Icons.drive_file_rename_outline, + size: 18, + semanticLabel: 'Spara alias', + ), onPressed: onAliasEditRequested, tooltip: 'Spara alias', - constraints: const BoxConstraints(minWidth: 40, minHeight: 40), + constraints: + const BoxConstraints(minWidth: 40, minHeight: 40), padding: const EdgeInsets.all(4), ), IconButton( - icon: Icon(Icons.delete_outline, size: 18, color: theme.colorScheme.error), + icon: Icon( + Icons.delete_outline, + size: 18, + color: theme.colorScheme.error, + semanticLabel: 'Ta bort rad', + ), onPressed: onDeleteRequested, tooltip: 'Ta bort rad', constraints: const BoxConstraints(minWidth: 40, minHeight: 40), @@ -1315,33 +1403,33 @@ class _ReceiptPreviewDialog extends StatelessWidget { child: SelectableText.rich( TextSpan( children: items.isEmpty - ? [TextSpan(text: '(Inga rader)', style: theme.textTheme.bodySmall)] - : items - .asMap() - .entries - .map((entry) { - final item = entry.value; - final lineNumber = entry.key + 1; - final lineText = _formatReceiptLine(item); - return TextSpan( - children: [ - TextSpan( - text: '$lineNumber. ', - style: theme.textTheme.labelSmall?.copyWith( - color: theme.colorScheme.outlineVariant, - ), + ? [ + TextSpan( + text: '(Inga rader)', + style: theme.textTheme.bodySmall) + ] + : items.asMap().entries.map((entry) { + final item = entry.value; + final lineNumber = entry.key + 1; + final lineText = _formatReceiptLine(item); + return TextSpan( + children: [ + TextSpan( + text: '$lineNumber. ', + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.outlineVariant, ), - TextSpan( - text: lineText, - style: theme.textTheme.bodySmall?.copyWith( - fontFamily: 'monospace', - ), + ), + TextSpan( + text: lineText, + style: theme.textTheme.bodySmall?.copyWith( + fontFamily: 'monospace', ), - const TextSpan(text: '\n'), - ], - ); - }) - .toList(), + ), + const TextSpan(text: '\n'), + ], + ); + }).toList(), ), style: theme.textTheme.bodySmall, ), @@ -1379,4 +1467,3 @@ class _ReceiptPreviewDialog extends StatelessWidget { return parts.join(' '); } } - diff --git a/flutter/lib/features/inventory/presentation/swipeable_inventory_tile.dart b/flutter/lib/features/inventory/presentation/swipeable_inventory_tile.dart index 7d6295d1..2ef16255 100644 --- a/flutter/lib/features/inventory/presentation/swipeable_inventory_tile.dart +++ b/flutter/lib/features/inventory/presentation/swipeable_inventory_tile.dart @@ -39,8 +39,9 @@ double _stepForUnit(String unit) { /// Formats a step value for display: whole numbers without decimal, /// fractions with one decimal. -String _fmtStep(double step) => - step == step.roundToDouble() ? step.toStringAsFixed(0) : step.toStringAsFixed(1); +String _fmtStep(double step) => step == step.roundToDouble() + ? step.toStringAsFixed(0) + : step.toStringAsFixed(1); /// A [ListTile] wrapped in a swipe-to-adjust widget. /// @@ -122,7 +123,8 @@ class _SwipeableInventoryTileState // Decrease: use consume endpoint so history is preserved. // Guard against going below zero. if (widget.item.quantity <= 0) return; - final consume = step > widget.item.quantity ? widget.item.quantity : step; + final consume = + step > widget.item.quantity ? widget.item.quantity : step; await repo.consumeInventoryItem( widget.item.id, amountUsed: consume, @@ -402,32 +404,44 @@ class _TrailingActions extends ConsumerWidget { Tooltip( message: 'Konsumera', child: IconButton( - icon: const Icon(Icons.remove_circle_outline), + icon: const Icon( + Icons.remove_circle_outline, + semanticLabel: 'Konsumera', + ), onPressed: () => context.push('/inventory/${item.id}/consume'), ), ), Tooltip( message: 'Redigera', child: IconButton( - icon: const Icon(Icons.edit_outlined), + icon: const Icon( + Icons.edit_outlined, + semanticLabel: 'Redigera', + ), onPressed: () => context.push('/inventory/${item.id}/edit'), ), ), Tooltip( message: 'Flytta till baslager', child: IconButton( - icon: const Icon(Icons.storefront_outlined), + icon: const Icon( + Icons.storefront_outlined, + semanticLabel: 'Flytta till baslager', + ), onPressed: () async { try { final token = await ref.read(authStateProvider.future); - await ref.read(inventoryRepositoryProvider).moveInventoryItemToPantry( + await ref + .read(inventoryRepositoryProvider) + .moveInventoryItemToPantry( item.id, token: token, ); ref.invalidate(inventoryProvider); } catch (e) { ScaffoldMessenger.of(context).showSnackBar( - buildCopyableErrorSnackBar(context, mapErrorToUserMessage(e, context)), + buildCopyableErrorSnackBar( + context, mapErrorToUserMessage(e, context)), ); } }, @@ -449,7 +463,11 @@ class _DeleteButton extends ConsumerWidget { return Tooltip( message: 'Ta bort', child: IconButton( - icon: const Icon(Icons.delete_outline, color: Colors.red), + icon: const Icon( + Icons.delete_outline, + color: Colors.red, + semanticLabel: 'Ta bort', + ), onPressed: () async { final confirmed = await showDialog( context: context, @@ -477,7 +495,8 @@ class _DeleteButton extends ConsumerWidget { ref.invalidate(inventoryProvider); } catch (e) { ScaffoldMessenger.of(context).showSnackBar( - buildCopyableErrorSnackBar(context, mapErrorToUserMessage(e, context)), + buildCopyableErrorSnackBar( + context, mapErrorToUserMessage(e, context)), ); } } @@ -486,4 +505,3 @@ class _DeleteButton extends ConsumerWidget { ); } } - diff --git a/flutter/lighthouse/README.md b/flutter/lighthouse/README.md new file mode 100644 index 00000000..4d70331b --- /dev/null +++ b/flutter/lighthouse/README.md @@ -0,0 +1,76 @@ +# Lighthouse protocol for Flutter web + +Detta dokument implementerar en reproducerbar baseline/eftermatning enligt planen i `.kilo/plans/1779550355555-hidden-mountain.md`. + +## 1) Fast matprofil (anvand samma varje gang) + +- URL: samma exakta URL per miljo (`http://localhost:5000` respektive produktion). +- Form factor: `mobile`. +- Lighthouse-kategorier: `performance`, `accessibility`, `seo`. +- Antal korningar: minst 3 per miljo. +- Throttling: `simulate` (Lighthouse standard, konstant mellan korningar). + +## 2) Korningar + +Lokal container: + +```bash +docker compose -f compose.yml -f compose.flutter.yml up -d --build recipe-api recipe-flutter +node flutter/lighthouse/collect.mjs --url http://localhost:5000 --label local-baseline --runs 3 --form-factor mobile +``` + +Produktion: + +```bash +node flutter/lighthouse/collect.mjs --url https://DIN-DOMAN --label prod-baseline --runs 3 --form-factor mobile +``` + +Resultat skrivs till `flutter/lighthouse/results/` som: + +- `