feat(web): improve web build configuration and accessibility
Test Suite / backend-pr-quick (push) Has been skipped
Test Suite / quick-import-pr-quick (push) Has been skipped
Test Suite / backend-full (push) Successful in 14m6s
Test Suite / flutter-quality (push) Failing after 4m44s

- 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
This commit is contained in:
Nils-Johan Gynther
2026-05-23 18:04:27 +02:00
parent 30d27d6b8a
commit 69bcc3e342
16 changed files with 1847 additions and 301 deletions
@@ -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"``<html>`.
- 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`.
+706
View File
@@ -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 (Flutters kompilade JavaScript).
canvaskit.wasm: 1,592 KiB (Flutters 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
<script>
window.flutterConfiguration = {
renderMode: "html", // Istället för "canvas" (CanvasKit)
};
</script>
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<void> 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 <picture> med srcset för responsiva bilder.
Exempel:
html
Copy
<picture>
<source srcset="image-480w.jpg" media="(max-width: 600px)">
<source srcset="image-800w.jpg" media="(max-width: 1200px)">
<img src="image-1200w.jpg" alt="Receptbild">
</picture>
B. Förbättra Laddningsordningen
Fördröj laddning av icke-kritiska resurser
Ladda canvaskit.wasm efter att sidan har renderats:
html
Copy
<script>
window.addEventListener('load', () => {
const script = document.createElement('script');
script.src = 'https://www.gstatic.com/canvaskit/v1/chromium/canvaskit.wasm';
document.body.appendChild(script);
});
</script>
Använd preload för kritiska resurser
Lägg till i <head>:
html
Copy
<link rel="preload" href="/main.dart.js" as="script">
<link rel="preload" href="/flutter_bootstrap.js" as="script">
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
<meta name="viewport" content="width=device-width, initial-scale=1.0">
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>:
html
Copy
<html lang="sv">
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 <head>:
html
Copy
<meta name="description" content="Upptäck och lagra dina recept. Importera kvitton och håll koll på ditt kylskåp med vår smarta app.">
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: Flutters 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 Flutters --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
<script src="/flutter_bootstrap.js" defer></script>
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 <head>:
html
Copy
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "Recipe",
"name": "Lasagne",
"author": {
"@type": "Person",
"name": "Nils-Johan Gynther"
},
"datePublished": "2026-05-21",
"description": "En klassisk lasagne med köttfärs och bechamelsås.",
"prepTime": "PT30M",
"cookTime": "PT45M",
"totalTime": "PT1H15M",
"recipeYield": "4 portioner",
"recipeCategory": "Middag",
"recipeCuisine": "Italiensk",
"keywords": "lasagne, pasta, köttfärs",
"recipeIngredient": ["500g köttfärs", "250g ost", "1 paket lasagneplattor"],
"recipeInstructions": [
{
"@type": "HowToStep",
"name": "Börja med att fräsa köttfärsen.",
"text": "Fräs köttfärsen i en stekpanna tills den är genomstekt."
},
{
"@type": "HowToStep",
"name": "Lagra lasagnen i ugnen.",
"text": "Skikta lasagneplattor, köttfärs och sås i en ugnsform. Grädda i 45 minuter på 200°C."
}
]
}
</script>
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 <html>.
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 Chromes "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 <script>-taggen:
html
Copy
<script src="/flutter_bootstrap.js" async></script>
Använd flutter_tex för text-rendering
Minskar beroendet av CanvasKit.
Override Flutters 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.
+118
View File
@@ -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.
@@ -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.
+2
View File
@@ -5,6 +5,8 @@ services:
dockerfile: Dockerfile
args:
API_BASE_URL: "/api"
SOURCE_MAPS: "false"
WEB_RENDERER: "auto"
image: recipe-flutter:local
container_name: recipe-flutter
restart: unless-stopped
+20
View File
@@ -1,6 +1,26 @@
:{$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
+11 -2
View File
@@ -17,8 +17,17 @@ 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}
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
+40 -3
View File
@@ -21,7 +21,8 @@ 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/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';
@@ -37,6 +38,38 @@ int? _shellBranchIndexForPath(String path) {
return null;
}
class _DeferredRouteLoader extends StatelessWidget {
const _DeferredRouteLoader({
required this.loadLibrary,
required this.builder,
});
final Future<void> Function() loadLibrary;
final WidgetBuilder builder;
@override
Widget build(BuildContext context) {
return FutureBuilder<void>(
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<GoRouter>((ref) {
final authState = ref.watch(authStateProvider);
@@ -248,7 +281,10 @@ final appRouterProvider = Provider<GoRouter>((ref) {
routes: [
GoRoute(
path: '/import',
builder: (context, state) => const ImportScreen(),
builder: (context, state) => _DeferredRouteLoader(
loadLibrary: import_ui.loadLibrary,
builder: (_) => import_ui.ImportScreen(),
),
),
],
),
@@ -273,7 +309,8 @@ final appRouterProvider = Provider<GoRouter>((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';
},
+12 -4
View File
@@ -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<String>(
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':
@@ -44,14 +44,17 @@ class _FlyerImportTabState extends ConsumerState<FlyerImportTab> {
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<dynamic>
? categoryData
: (categoryData is Map<String, dynamic> && categoryData['items'] is List<dynamic>)
: (categoryData is Map<String, dynamic> &&
categoryData['items'] is List<dynamic>)
? categoryData['items'] as List<dynamic>
: const <dynamic>[];
final tree = categoryList
.map((e) => AdminCategoryNode.fromJson(Map<String, dynamic>.from(e as Map)))
.map((e) =>
AdminCategoryNode.fromJson(Map<String, dynamic>.from(e as Map)))
.toList();
if (!mounted) return;
setState(() => _categoryTree = tree);
@@ -142,12 +145,14 @@ class _FlyerImportTabState extends ConsumerState<FlyerImportTab> {
}
}
Future<void> _loadRestoredSourceIfNeeded(FlyerImportResult result, String? token) async {
Future<void> _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<FlyerImportTab> {
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<FlyerImportTab> {
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<FlyerImportTab> {
),
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<FlyerImportTab> {
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<FlyerImportTab> {
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<FlyerImportTab> {
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<FlyerImportTab> {
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<FlyerImportTab> {
: 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<FlyerImportTab> {
),
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<FlyerImportTab> {
_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'),
@@ -133,7 +133,8 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
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<ReceiptImportTab> {
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<ReceiptImportTab> {
if (!mounted) return;
final mergedProducts = [
...globalList
.cast<Map<String, dynamic>>()
.map((e) => (id: e['id'] as int, name: (e['canonicalName'] ?? e['name']) as String, categoryId: (e['categoryId'] as num?)?.toInt())),
...mineList
.cast<Map<String, dynamic>>()
.map((e) => (id: e['id'] as int, name: (e['canonicalName'] ?? e['name']) as String, categoryId: (e['categoryId'] as num?)?.toInt())),
...globalList.cast<Map<String, dynamic>>().map((e) => (
id: e['id'] as int,
name: (e['canonicalName'] ?? e['name']) as String,
categoryId: (e['categoryId'] as num?)?.toInt()
)),
...mineList.cast<Map<String, dynamic>>().map((e) => (
id: e['id'] as int,
name: (e['canonicalName'] ?? e['name']) as String,
categoryId: (e['categoryId'] as num?)?.toInt()
)),
];
final dedupedById = <int, ProductOption>{
for (final product in mergedProducts) product.id: product,
@@ -181,7 +187,9 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
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<ReceiptImportTab> {
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<ReceiptImportTab> {
if (mounted) {
setState(() {
_inventoryByProduct = {
for (final item in results[0] as List<InventoryItem>) item.productId: item,
for (final item in results[0] as List<InventoryItem>)
item.productId: item,
};
_pantryProductIds = {
for (final item in results[1] as List<PantryItem>) item.productId,
@@ -267,10 +277,10 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
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<void> _submit() async {
@@ -278,10 +288,13 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
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<ReceiptImportTab> {
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<ReceiptImportTab> {
// 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<ReceiptImportTab> {
_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<ReceiptImportTab> {
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<ReceiptImportTab> {
}
}
Future<void> _showReceiptPreview(BuildContext context, List<ParsedReceiptItem> items) async {
Future<void> _showReceiptPreview(
BuildContext context, List<ParsedReceiptItem> items) async {
if (!context.mounted) return;
await showDialog(
context: context,
@@ -468,7 +485,8 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
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<ReceiptImportTab> {
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')),
],
),
],
@@ -569,7 +588,8 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
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<ReceiptImportTab> {
}
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<ReceiptImportTab> {
'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<ReceiptImportTab> {
// 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<ReceiptImportTab> {
// 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;
}
}
@@ -714,11 +744,14 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
final parts = <String>[
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 (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)
@@ -788,6 +821,7 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
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<ReceiptImportTab> {
: 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<ReceiptImportTab> {
'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<ReceiptImportTab> {
),
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<ReceiptImportTab> {
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<ReceiptImportTab> {
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<ReceiptImportTab> {
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<ReceiptImportTab> {
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<ReceiptImportTab> {
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<ReceiptImportTab> {
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(' ');
}
}
@@ -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<bool>(
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 {
);
}
}
+76
View File
@@ -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:
- `<label>-run-1.json`, `<label>-run-2.json`, `<label>-run-3.json`
- `<label>-summary.json`
- `<label>-summary.md`
## 3) Baseline-tabell (fylls med faktiska resultat)
| Miljo | Perf median | A11y median | SEO median | LCP ms median | TBT ms median | INP ms median | Transfer KB median | Requests median |
| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
| Lokal container | TODO | TODO | TODO | TODO | TODO | TODO | TODO | TODO |
| Produktion | TODO | TODO | TODO | TODO | TODO | TODO | TODO | TODO |
## 4) Re-matning efter andringar
Kor samma kommandon med nya labels, till exempel:
```bash
node flutter/lighthouse/collect.mjs --url http://localhost:5000 --label local-after --runs 3 --form-factor mobile
node flutter/lighthouse/collect.mjs --url https://DIN-DOMAN --label prod-after --runs 3 --form-factor mobile
```
Jamor `*-summary.md` mot baseline for att verifiera:
- Forbattring i minst tva nyckelmatt (exempel LCP/TBT)
- Ingen regress i Accessibility/SEO
## 5) Renderer A/B (Fas 3)
`flutter/Dockerfile` och `compose.flutter.yml` har nu build-args for rendererexperiment:
- `WEB_RENDERER=auto` (default)
- `WEB_RENDERER=canvaskit`
- `WEB_RENDERER=skwasm`
Exempel (separat buildvariant):
```bash
docker compose -f compose.yml -f compose.flutter.yml build --build-arg WEB_RENDERER=canvaskit recipe-flutter
docker compose -f compose.yml -f compose.flutter.yml up -d recipe-flutter
node flutter/lighthouse/collect.mjs --url http://localhost:5000 --label local-canvaskit --runs 3 --form-factor mobile
```
Rollback-kriterium: behall `WEB_RENDERER=auto` om A/B inte ger tydlig forbattring i transfer size + LCP/TBT utan visuell regress.
## 6) SEO routing-beslut
- `robots.txt` hanteras i Flutter web app-shell (`flutter/web/robots.txt`).
- `sitemap.xml` hanteras upstream pa domanniva eftersom absolut canonical host maste vara korrekt per miljo.
+189
View File
@@ -0,0 +1,189 @@
#!/usr/bin/env node
import { execFileSync } from 'node:child_process';
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { join, resolve } from 'node:path';
function parseArgs(argv) {
const args = {
url: '',
label: 'baseline',
runs: 3,
formFactor: 'mobile',
outDir: 'flutter/lighthouse/results',
};
for (let i = 2; i < argv.length; i += 1) {
const current = argv[i];
const next = argv[i + 1];
if (current === '--url' && next) {
args.url = next;
i += 1;
} else if (current === '--label' && next) {
args.label = next;
i += 1;
} else if (current === '--runs' && next) {
args.runs = Number.parseInt(next, 10);
i += 1;
} else if (current === '--form-factor' && next) {
args.formFactor = next;
i += 1;
} else if (current === '--out-dir' && next) {
args.outDir = next;
i += 1;
}
}
if (!args.url) {
throw new Error('Missing --url, example: node flutter/lighthouse/collect.mjs --url http://localhost:5000');
}
if (!Number.isFinite(args.runs) || args.runs < 1) {
throw new Error('--runs must be >= 1');
}
return args;
}
function median(values) {
if (values.length === 0) return null;
const ordered = [...values].sort((a, b) => a - b);
const center = Math.floor(ordered.length / 2);
if (ordered.length % 2 === 0) {
return (ordered[center - 1] + ordered[center]) / 2;
}
return ordered[center];
}
function toMs(value) {
if (typeof value !== 'number') return null;
return Math.round(value);
}
function readMetric(lhr, id) {
const audit = lhr.audits[id];
return audit && typeof audit.numericValue === 'number' ? audit.numericValue : null;
}
function readResourceSummary(lhr) {
const details = lhr.audits['resource-summary']?.details;
const items = details?.items;
if (!Array.isArray(items)) return { transferKb: null, requests: null };
const total = items.find((item) => item.resourceType === 'total');
if (!total) return { transferKb: null, requests: null };
return {
transferKb: typeof total.transferSize === 'number' ? Math.round(total.transferSize / 1024) : null,
requests: typeof total.requestCount === 'number' ? total.requestCount : null,
};
}
function formatNullable(value) {
return value == null ? 'n/a' : String(value);
}
function main() {
const args = parseArgs(process.argv);
const outputRoot = resolve(args.outDir);
mkdirSync(outputRoot, { recursive: true });
const runRows = [];
for (let runIndex = 1; runIndex <= args.runs; runIndex += 1) {
const outputPath = join(outputRoot, `${args.label}-run-${runIndex}.json`);
const flags = [
'lighthouse',
args.url,
'--only-categories=performance,accessibility,seo',
'--throttling-method=simulate',
`--form-factor=${args.formFactor}`,
'--screenEmulation.mobile=true',
'--output=json',
`--output-path=${outputPath}`,
'--chrome-flags=--headless=new --no-sandbox',
'--quiet',
];
execFileSync('npx', flags, { stdio: 'inherit' });
const lhr = JSON.parse(readFileSync(outputPath, 'utf8'));
const summary = readResourceSummary(lhr);
runRows.push({
run: runIndex,
performance: Math.round((lhr.categories.performance.score ?? 0) * 100),
accessibility: Math.round((lhr.categories.accessibility.score ?? 0) * 100),
seo: Math.round((lhr.categories.seo.score ?? 0) * 100),
lcpMs: toMs(readMetric(lhr, 'largest-contentful-paint')),
tbtMs: toMs(readMetric(lhr, 'total-blocking-time')),
inpMs: toMs(readMetric(lhr, 'interaction-to-next-paint')),
transferKb: summary.transferKb,
requests: summary.requests,
fetchTime: lhr.fetchTime,
finalUrl: lhr.finalUrl,
});
}
const medians = {
performance: median(runRows.map((r) => r.performance)),
accessibility: median(runRows.map((r) => r.accessibility)),
seo: median(runRows.map((r) => r.seo)),
lcpMs: median(runRows.filter((r) => r.lcpMs != null).map((r) => r.lcpMs)),
tbtMs: median(runRows.filter((r) => r.tbtMs != null).map((r) => r.tbtMs)),
inpMs: median(runRows.filter((r) => r.inpMs != null).map((r) => r.inpMs)),
transferKb: median(runRows.filter((r) => r.transferKb != null).map((r) => r.transferKb)),
requests: median(runRows.filter((r) => r.requests != null).map((r) => r.requests)),
};
const summaryPath = join(outputRoot, `${args.label}-summary.json`);
writeFileSync(
summaryPath,
JSON.stringify(
{
url: args.url,
label: args.label,
runs: args.runs,
formFactor: args.formFactor,
medians,
results: runRows,
},
null,
2,
),
);
const markdownPath = join(outputRoot, `${args.label}-summary.md`);
const markdown = [
`# Lighthouse summary: ${args.label}`,
'',
`- URL: ${args.url}`,
`- Runs: ${args.runs}`,
`- Form factor: ${args.formFactor}`,
'',
'| Metric | Median |',
'| --- | ---: |',
`| Performance | ${formatNullable(medians.performance)} |`,
`| Accessibility | ${formatNullable(medians.accessibility)} |`,
`| SEO | ${formatNullable(medians.seo)} |`,
`| LCP (ms) | ${formatNullable(medians.lcpMs)} |`,
`| TBT (ms) | ${formatNullable(medians.tbtMs)} |`,
`| INP (ms) | ${formatNullable(medians.inpMs)} |`,
`| Transfer (KB) | ${formatNullable(medians.transferKb)} |`,
`| Requests | ${formatNullable(medians.requests)} |`,
'',
'## Runs',
'',
'| Run | Perf | A11y | SEO | LCP | TBT | INP | KB | Req |',
'| ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |',
...runRows.map(
(row) =>
`| ${row.run} | ${row.performance} | ${row.accessibility} | ${row.seo} | ${formatNullable(row.lcpMs)} | ${formatNullable(row.tbtMs)} | ${formatNullable(row.inpMs)} | ${formatNullable(row.transferKb)} | ${formatNullable(row.requests)} |`,
),
'',
].join('\n');
writeFileSync(markdownPath, markdown);
process.stdout.write(`Wrote ${summaryPath}\n`);
process.stdout.write(`Wrote ${markdownPath}\n`);
}
main();
+3 -2
View File
@@ -1,10 +1,11 @@
<!DOCTYPE html>
<html>
<html lang="sv">
<head>
<base href="/">
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Recipe App</title>
<meta name="description" content="Planera recept, hantera inventarie och importera kvitton i en samlad matplaneringsapp.">
<title>Recipe App - Recept, inventarie och import</title>
<script src="flutter_bootstrap.js" async></script>
</head>
<body></body>
+2
View File
@@ -0,0 +1,2 @@
User-agent: *
Allow: /