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
This commit is contained in:
@@ -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å `<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`.
|
||||
@@ -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
|
||||
|
||||
<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: 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
|
||||
|
||||
<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 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 <script>-taggen:
|
||||
html
|
||||
Copy
|
||||
|
||||
<script src="/flutter_bootstrap.js" async></script>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
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.
|
||||
@@ -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.
|
||||
+4
-2
@@ -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
|
||||
|
||||
+27
-7
@@ -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 {
|
||||
|
||||
+14
-5
@@ -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
|
||||
|
||||
@@ -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<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);
|
||||
|
||||
@@ -244,26 +277,29 @@ final appRouterProvider = Provider<GoRouter>((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<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';
|
||||
},
|
||||
|
||||
@@ -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')),
|
||||
],
|
||||
),
|
||||
],
|
||||
@@ -532,7 +551,7 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
||||
final remainingEdits = <int, ItemEdit>{};
|
||||
final remainingSelected = <int, bool>{};
|
||||
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<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;
|
||||
}
|
||||
}
|
||||
@@ -708,22 +738,25 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
||||
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<String, dynamic>?;
|
||||
final errors = response['errors'] as List? ?? [];
|
||||
final unitMappingsLearned = response['unitMappingsLearned'] as int? ?? 0;
|
||||
final flyerAutoSync = response['flyerAutoSync'] as Map<String, dynamic>?;
|
||||
final errors = response['errors'] as List? ?? [];
|
||||
|
||||
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 ((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 = <String>[];
|
||||
@@ -748,7 +781,7 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
||||
final remainingItems = <ParsedReceiptItem>[];
|
||||
final remainingEdits = <int, ItemEdit>{};
|
||||
final remainingSelected = <int, bool>{};
|
||||
|
||||
|
||||
var newIndex = 0;
|
||||
for (var oldIndex = 0; oldIndex < items.length; oldIndex++) {
|
||||
if (!addedIndexSet.contains(oldIndex)) {
|
||||
@@ -762,7 +795,7 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
||||
newIndex++;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
final notifier = ref.read(receiptImportSessionProvider.notifier);
|
||||
if (remainingItems.isEmpty) {
|
||||
notifier.clear();
|
||||
@@ -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 {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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();
|
||||
+11
-10
@@ -1,11 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<base href="/">
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Recipe App</title>
|
||||
<script src="flutter_bootstrap.js" async></script>
|
||||
</head>
|
||||
<body></body>
|
||||
</html>
|
||||
<html lang="sv">
|
||||
<head>
|
||||
<base href="/">
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<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>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
User-agent: *
|
||||
Allow: /
|
||||
Reference in New Issue
Block a user