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.
|
||||||
@@ -5,6 +5,8 @@ services:
|
|||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
args:
|
args:
|
||||||
API_BASE_URL: "/api"
|
API_BASE_URL: "/api"
|
||||||
|
SOURCE_MAPS: "false"
|
||||||
|
WEB_RENDERER: "auto"
|
||||||
image: recipe-flutter:local
|
image: recipe-flutter:local
|
||||||
container_name: recipe-flutter
|
container_name: recipe-flutter
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
@@ -1,6 +1,26 @@
|
|||||||
:{$PORT:5000} {
|
:{$PORT:5000} {
|
||||||
root * /usr/share/caddy
|
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.
|
# Proxy API calls to backend service on the internal Docker network.
|
||||||
handle /api/* {
|
handle /api/* {
|
||||||
reverse_proxy recipe-api:8080
|
reverse_proxy recipe-api:8080
|
||||||
|
|||||||
+11
-2
@@ -17,8 +17,17 @@ RUN flutter test
|
|||||||
# Inject API base URL at build time via --dart-define.
|
# Inject API base URL at build time via --dart-define.
|
||||||
# Default to same-origin /api to avoid mixed-content in HTTPS deployments.
|
# Default to same-origin /api to avoid mixed-content in HTTPS deployments.
|
||||||
ARG API_BASE_URL=/api
|
ARG API_BASE_URL=/api
|
||||||
RUN flutter build web --release \
|
ARG SOURCE_MAPS=false
|
||||||
--dart-define=API_BASE_URL=${API_BASE_URL}
|
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
|
# Stage 2 – Serve with Caddy
|
||||||
FROM caddy:alpine AS runner
|
FROM caddy:alpine AS runner
|
||||||
|
|||||||
@@ -21,7 +21,8 @@ import '../../features/inventory/presentation/consume_inventory_screen.dart';
|
|||||||
import '../../features/inventory/presentation/consumption_history_screen.dart';
|
import '../../features/inventory/presentation/consumption_history_screen.dart';
|
||||||
import '../../features/meal_plan/presentation/meal_plan_screen.dart';
|
import '../../features/meal_plan/presentation/meal_plan_screen.dart';
|
||||||
import '../../features/pantry/presentation/pantry_screen.dart';
|
import '../../features/pantry/presentation/pantry_screen.dart';
|
||||||
import '../../features/import/presentation/import_screen.dart';
|
import '../../features/import/presentation/import_screen.dart'
|
||||||
|
deferred as import_ui;
|
||||||
import '../../features/shopping_list/presentation/shopping_list_screen.dart';
|
import '../../features/shopping_list/presentation/shopping_list_screen.dart';
|
||||||
import '../../features/admin/presentation/admin_screen.dart';
|
import '../../features/admin/presentation/admin_screen.dart';
|
||||||
|
|
||||||
@@ -37,6 +38,38 @@ int? _shellBranchIndexForPath(String path) {
|
|||||||
return null;
|
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 appRouterProvider = Provider<GoRouter>((ref) {
|
||||||
final authState = ref.watch(authStateProvider);
|
final authState = ref.watch(authStateProvider);
|
||||||
|
|
||||||
@@ -248,7 +281,10 @@ final appRouterProvider = Provider<GoRouter>((ref) {
|
|||||||
routes: [
|
routes: [
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/import',
|
path: '/import',
|
||||||
builder: (context, state) => const ImportScreen(),
|
builder: (context, state) => _DeferredRouteLoader(
|
||||||
|
loadLibrary: import_ui.loadLibrary,
|
||||||
|
builder: (_) => import_ui.ImportScreen(),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -273,7 +309,8 @@ final appRouterProvider = Provider<GoRouter>((ref) {
|
|||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/admin',
|
path: '/admin',
|
||||||
redirect: (context, state) {
|
redirect: (context, state) {
|
||||||
final token = ref.read(authStateProvider)
|
final token = ref
|
||||||
|
.read(authStateProvider)
|
||||||
.maybeWhen(data: (t) => t, orElse: () => null);
|
.maybeWhen(data: (t) => t, orElse: () => null);
|
||||||
return jwtIsAdmin(token) ? null : '/recipes';
|
return jwtIsAdmin(token) ? null : '/recipes';
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -181,9 +181,14 @@ class AppShell extends ConsumerWidget {
|
|||||||
tooltip: view.mode == RecipesViewMode.grid
|
tooltip: view.mode == RecipesViewMode.grid
|
||||||
? 'Visa som lista'
|
? 'Visa som lista'
|
||||||
: 'Visa som grid',
|
: 'Visa som grid',
|
||||||
icon: Icon(view.mode == RecipesViewMode.grid
|
icon: Icon(
|
||||||
|
view.mode == RecipesViewMode.grid
|
||||||
? Icons.view_list
|
? Icons.view_list
|
||||||
: Icons.grid_view),
|
: Icons.grid_view,
|
||||||
|
semanticLabel: view.mode == RecipesViewMode.grid
|
||||||
|
? 'Visa som lista'
|
||||||
|
: 'Visa som grid',
|
||||||
|
),
|
||||||
onPressed: () =>
|
onPressed: () =>
|
||||||
ref.read(recipesViewProvider.notifier).toggleMode(),
|
ref.read(recipesViewProvider.notifier).toggleMode(),
|
||||||
),
|
),
|
||||||
@@ -207,7 +212,10 @@ class AppShell extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
PopupMenuButton<String>(
|
PopupMenuButton<String>(
|
||||||
tooltip: 'Profil och konto',
|
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 {
|
onSelected: (value) async {
|
||||||
switch (value) {
|
switch (value) {
|
||||||
case 'profile':
|
case 'profile':
|
||||||
|
|||||||
@@ -44,14 +44,17 @@ class _FlyerImportTabState extends ConsumerState<FlyerImportTab> {
|
|||||||
try {
|
try {
|
||||||
final token = await ref.read(authStateProvider.future);
|
final token = await ref.read(authStateProvider.future);
|
||||||
final api = ref.read(apiClientProvider);
|
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>
|
final categoryList = categoryData is List<dynamic>
|
||||||
? categoryData
|
? 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>
|
? categoryData['items'] as List<dynamic>
|
||||||
: const <dynamic>[];
|
: const <dynamic>[];
|
||||||
final tree = categoryList
|
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();
|
.toList();
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() => _categoryTree = tree);
|
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 (result.sessionId == null || result.sourceAvailable != true) return;
|
||||||
if (_pickedFile?.bytes != null) return;
|
if (_pickedFile?.bytes != null) return;
|
||||||
try {
|
try {
|
||||||
final repo = ref.read(importRepositoryProvider);
|
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;
|
if (!mounted) return;
|
||||||
setState(() => _restoredSourceBytes = bytes);
|
setState(() => _restoredSourceBytes = bytes);
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
@@ -250,7 +255,8 @@ class _FlyerImportTabState extends ConsumerState<FlyerImportTab> {
|
|||||||
ref.invalidate(shoppingListItemsProvider);
|
ref.invalidate(shoppingListItemsProvider);
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
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(
|
action: SnackBarAction(
|
||||||
label: 'Öppna',
|
label: 'Öppna',
|
||||||
onPressed: () => context.go('/inkopslista'),
|
onPressed: () => context.go('/inkopslista'),
|
||||||
@@ -273,7 +279,8 @@ class _FlyerImportTabState extends ConsumerState<FlyerImportTab> {
|
|||||||
int? selectedCategoryId = item.categoryId;
|
int? selectedCategoryId = item.categoryId;
|
||||||
String? selectedCategoryPath = item.category;
|
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,
|
context: context,
|
||||||
builder: (context) {
|
builder: (context) {
|
||||||
return StatefulBuilder(
|
return StatefulBuilder(
|
||||||
@@ -295,7 +302,8 @@ class _FlyerImportTabState extends ConsumerState<FlyerImportTab> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Text(
|
Text(
|
||||||
selectedCategoryPath == null || selectedCategoryPath!.isEmpty
|
selectedCategoryPath == null ||
|
||||||
|
selectedCategoryPath!.isEmpty
|
||||||
? 'Ingen kategori vald'
|
? 'Ingen kategori vald'
|
||||||
: selectedCategoryPath!,
|
: selectedCategoryPath!,
|
||||||
),
|
),
|
||||||
@@ -307,7 +315,9 @@ class _FlyerImportTabState extends ConsumerState<FlyerImportTab> {
|
|||||||
onPressed: _categoryTree.isEmpty
|
onPressed: _categoryTree.isEmpty
|
||||||
? null
|
? null
|
||||||
: () async {
|
: () async {
|
||||||
final selected = await CategoryThenProductPicker.showCategorySheet(
|
final selected =
|
||||||
|
await CategoryThenProductPicker
|
||||||
|
.showCategorySheet(
|
||||||
context,
|
context,
|
||||||
categoryTree: _categoryTree,
|
categoryTree: _categoryTree,
|
||||||
preselectedCategoryId: selectedCategoryId,
|
preselectedCategoryId: selectedCategoryId,
|
||||||
@@ -402,7 +412,8 @@ class _FlyerImportTabState extends ConsumerState<FlyerImportTab> {
|
|||||||
String _formatPrice(double? price, String? unit) {
|
String _formatPrice(double? price, String? unit) {
|
||||||
if (price == null) return '';
|
if (price == null) return '';
|
||||||
final raw = price.toStringAsFixed(2).replaceAll('.', ',');
|
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';
|
return '$raw kr$unitPart';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -412,13 +423,20 @@ class _FlyerImportTabState extends ConsumerState<FlyerImportTab> {
|
|||||||
if (trimmedOffer.isEmpty || trimmedLimit.isEmpty) return trimmedOffer;
|
if (trimmedOffer.isEmpty || trimmedLimit.isEmpty) return trimmedOffer;
|
||||||
|
|
||||||
final escaped = RegExp.escape(trimmedLimit);
|
final escaped = RegExp.escape(trimmedLimit);
|
||||||
final withoutLimit = trimmedOffer.replaceAll(RegExp(escaped, caseSensitive: false), '').trim();
|
final withoutLimit = trimmedOffer
|
||||||
final withoutLeadingPunctuation = withoutLimit.replaceAll(RegExp(r'^[,.;:\s-]+'), '').trim();
|
.replaceAll(RegExp(escaped, caseSensitive: false), '')
|
||||||
return withoutLeadingPunctuation.replaceAll(RegExp(r'[,.;:\s-]+$'), '').trim();
|
.trim();
|
||||||
|
final withoutLeadingPunctuation =
|
||||||
|
withoutLimit.replaceAll(RegExp(r'^[,.;:\s-]+'), '').trim();
|
||||||
|
return withoutLeadingPunctuation
|
||||||
|
.replaceAll(RegExp(r'[,.;:\s-]+$'), '')
|
||||||
|
.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildOfferBadge(FlyerImportItem item, ThemeData theme) {
|
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();
|
if (!hasOffer) return const SizedBox.shrink();
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
@@ -493,7 +511,8 @@ class _FlyerImportTabState extends ConsumerState<FlyerImportTab> {
|
|||||||
children: [
|
children: [
|
||||||
Row(
|
Row(
|
||||||
children: [
|
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),
|
const SizedBox(width: 8),
|
||||||
Text(
|
Text(
|
||||||
'Varningar (${warnings.length})',
|
'Varningar (${warnings.length})',
|
||||||
@@ -546,14 +565,16 @@ class _FlyerImportTabState extends ConsumerState<FlyerImportTab> {
|
|||||||
: OutlinedButton.icon(
|
: OutlinedButton.icon(
|
||||||
icon: const Icon(Icons.open_in_new, size: 16),
|
icon: const Icon(Icons.open_in_new, size: 16),
|
||||||
label: const Text('Visa flyer'),
|
label: const Text('Visa flyer'),
|
||||||
style: OutlinedButton.styleFrom(visualDensity: VisualDensity.compact),
|
style: OutlinedButton.styleFrom(
|
||||||
|
visualDensity: VisualDensity.compact),
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
final messenger = ScaffoldMessenger.of(context);
|
final messenger = ScaffoldMessenger.of(context);
|
||||||
final opened = await openPdfBytes(bytes);
|
final opened = await openPdfBytes(bytes);
|
||||||
if (!context.mounted || opened) return;
|
if (!context.mounted || opened) return;
|
||||||
messenger.showSnackBar(
|
messenger.showSnackBar(
|
||||||
const SnackBar(
|
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,7 +621,9 @@ class _FlyerImportTabState extends ConsumerState<FlyerImportTab> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
FilledButton.icon(
|
FilledButton.icon(
|
||||||
onPressed: (!_isLoading && _pickedFile?.bytes != null) ? _parseFlyer : null,
|
onPressed: (!_isLoading && _pickedFile?.bytes != null)
|
||||||
|
? _parseFlyer
|
||||||
|
: null,
|
||||||
icon: const Icon(Icons.auto_awesome),
|
icon: const Icon(Icons.auto_awesome),
|
||||||
label: const Text('Importera flyer'),
|
label: const Text('Importera flyer'),
|
||||||
),
|
),
|
||||||
@@ -613,11 +636,13 @@ class _FlyerImportTabState extends ConsumerState<FlyerImportTab> {
|
|||||||
if (items.isNotEmpty) ...[
|
if (items.isNotEmpty) ...[
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
_buildWarningsPanel(theme),
|
_buildWarningsPanel(theme),
|
||||||
if ((_result?.warnings ?? const []).isNotEmpty) const SizedBox(height: 12),
|
if ((_result?.warnings ?? const []).isNotEmpty)
|
||||||
|
const SizedBox(height: 12),
|
||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
Text('${items.length} rader hittades', style: theme.textTheme.titleSmall),
|
Text('${items.length} rader hittades',
|
||||||
|
style: theme.textTheme.titleSmall),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
final target = selectedCount < items.length;
|
final target = selectedCount < items.length;
|
||||||
@@ -626,9 +651,13 @@ class _FlyerImportTabState extends ConsumerState<FlyerImportTab> {
|
|||||||
_selected[i] = target;
|
_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'),
|
child: Text(selectedCount < items.length
|
||||||
|
? 'Välj alla'
|
||||||
|
: 'Avmarkera alla'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -637,7 +666,8 @@ class _FlyerImportTabState extends ConsumerState<FlyerImportTab> {
|
|||||||
final index = entry.key;
|
final index = entry.key;
|
||||||
final item = entry.value;
|
final item = entry.value;
|
||||||
final priceText = _formatPrice(item.price, item.priceUnit);
|
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 limitText = item.offerLimitText?.trim();
|
||||||
final sanitizedOfferText = item.offerText == null
|
final sanitizedOfferText = item.offerText == null
|
||||||
? ''
|
? ''
|
||||||
@@ -648,7 +678,9 @@ class _FlyerImportTabState extends ConsumerState<FlyerImportTab> {
|
|||||||
onChanged: (value) {
|
onChanged: (value) {
|
||||||
final checked = value ?? false;
|
final checked = value ?? false;
|
||||||
setState(() => _selected[index] = checked);
|
setState(() => _selected[index] = checked);
|
||||||
ref.read(flyerImportSessionProvider.notifier).setSelected(index, checked);
|
ref
|
||||||
|
.read(flyerImportSessionProvider.notifier)
|
||||||
|
.setSelected(index, checked);
|
||||||
},
|
},
|
||||||
title: Row(
|
title: Row(
|
||||||
children: [
|
children: [
|
||||||
@@ -656,7 +688,11 @@ class _FlyerImportTabState extends ConsumerState<FlyerImportTab> {
|
|||||||
IconButton(
|
IconButton(
|
||||||
tooltip: 'Redigera',
|
tooltip: 'Redigera',
|
||||||
visualDensity: VisualDensity.compact,
|
visualDensity: VisualDensity.compact,
|
||||||
icon: const Icon(Icons.edit_outlined, size: 18),
|
icon: const Icon(
|
||||||
|
Icons.edit_outlined,
|
||||||
|
size: 18,
|
||||||
|
semanticLabel: 'Redigera rad',
|
||||||
|
),
|
||||||
onPressed: () => _editItem(index, item),
|
onPressed: () => _editItem(index, item),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
@@ -671,7 +707,8 @@ class _FlyerImportTabState extends ConsumerState<FlyerImportTab> {
|
|||||||
if (priceText.isNotEmpty) Text('Pris: $priceText'),
|
if (priceText.isNotEmpty) Text('Pris: $priceText'),
|
||||||
if ((item.category ?? '').trim().isNotEmpty)
|
if ((item.category ?? '').trim().isNotEmpty)
|
||||||
Text('Kategori: ${item.category}'),
|
Text('Kategori: ${item.category}'),
|
||||||
if (comparisonText.isNotEmpty) Text('Jämförpris: $comparisonText'),
|
if (comparisonText.isNotEmpty)
|
||||||
|
Text('Jämförpris: $comparisonText'),
|
||||||
if (limitText != null && limitText.isNotEmpty)
|
if (limitText != null && limitText.isNotEmpty)
|
||||||
Text(
|
Text(
|
||||||
'Begränsning: $limitText',
|
'Begränsning: $limitText',
|
||||||
@@ -681,7 +718,8 @@ class _FlyerImportTabState extends ConsumerState<FlyerImportTab> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (sanitizedOfferText.isNotEmpty) Text(sanitizedOfferText),
|
if (sanitizedOfferText.isNotEmpty) Text(sanitizedOfferText),
|
||||||
if (item.matchedProductName != null) Text('Match: ${item.matchedProductName}'),
|
if (item.matchedProductName != null)
|
||||||
|
Text('Match: ${item.matchedProductName}'),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
controlAffinity: ListTileControlAffinity.leading,
|
controlAffinity: ListTileControlAffinity.leading,
|
||||||
@@ -691,12 +729,14 @@ class _FlyerImportTabState extends ConsumerState<FlyerImportTab> {
|
|||||||
SizedBox(
|
SizedBox(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
child: FilledButton.icon(
|
child: FilledButton.icon(
|
||||||
onPressed: (_isSaving || selectedCount == 0) ? null : _planSelected,
|
onPressed:
|
||||||
|
(_isSaving || selectedCount == 0) ? null : _planSelected,
|
||||||
icon: _isSaving
|
icon: _isSaving
|
||||||
? const SizedBox(
|
? const SizedBox(
|
||||||
width: 16,
|
width: 16,
|
||||||
height: 16,
|
height: 16,
|
||||||
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2, color: Colors.white),
|
||||||
)
|
)
|
||||||
: const Icon(Icons.playlist_add_check),
|
: const Icon(Icons.playlist_add_check),
|
||||||
label: Text('Planera $selectedCount markerade'),
|
label: Text('Planera $selectedCount markerade'),
|
||||||
|
|||||||
@@ -133,7 +133,8 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
|||||||
final token = await ref.read(authStateProvider.future);
|
final token = await ref.read(authStateProvider.future);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final globalData = await api.getJson(ProductApiPaths.list, token: token);
|
final globalData =
|
||||||
|
await api.getJson(ProductApiPaths.list, token: token);
|
||||||
globalList = _extractItems(globalData);
|
globalList = _extractItems(globalData);
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
globalFailed = true;
|
globalFailed = true;
|
||||||
@@ -146,7 +147,8 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
|||||||
mineList = _extractItems(mineData);
|
mineList = _extractItems(mineData);
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
privateFailed = true;
|
privateFailed = true;
|
||||||
debugPrint('ReceiptImportTab._loadProducts private products failed: $e');
|
debugPrint(
|
||||||
|
'ReceiptImportTab._loadProducts private products failed: $e');
|
||||||
debugPrintStack(stackTrace: st);
|
debugPrintStack(stackTrace: st);
|
||||||
}
|
}
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
@@ -158,12 +160,16 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
|||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
||||||
final mergedProducts = [
|
final mergedProducts = [
|
||||||
...globalList
|
...globalList.cast<Map<String, dynamic>>().map((e) => (
|
||||||
.cast<Map<String, dynamic>>()
|
id: e['id'] as int,
|
||||||
.map((e) => (id: e['id'] as int, name: (e['canonicalName'] ?? e['name']) as String, categoryId: (e['categoryId'] as num?)?.toInt())),
|
name: (e['canonicalName'] ?? e['name']) as String,
|
||||||
...mineList
|
categoryId: (e['categoryId'] as num?)?.toInt()
|
||||||
.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>{
|
final dedupedById = <int, ProductOption>{
|
||||||
for (final product in mergedProducts) product.id: product,
|
for (final product in mergedProducts) product.id: product,
|
||||||
@@ -181,7 +187,9 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
|||||||
|
|
||||||
if (_products.isEmpty && _categoryTree.isEmpty) {
|
if (_products.isEmpty && _categoryTree.isEmpty) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
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(
|
child: Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
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),
|
const SizedBox(width: 8),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
@@ -244,7 +253,8 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
|||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_inventoryByProduct = {
|
_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 = {
|
_pantryProductIds = {
|
||||||
for (final item in results[1] as List<PantryItem>) item.productId,
|
for (final item in results[1] as List<PantryItem>) item.productId,
|
||||||
@@ -278,10 +288,13 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
|||||||
final submitBytes = _pickedFile?.bytes ?? session?.fileBytes;
|
final submitBytes = _pickedFile?.bytes ?? session?.fileBytes;
|
||||||
if (submitBytes == null) return;
|
if (submitBytes == null) return;
|
||||||
|
|
||||||
final submitFileName =
|
final submitFileName = _pickedFile?.name ??
|
||||||
_pickedFile?.name ?? session?.fileName ?? 'kvitto.${session?.fileExtension ?? 'pdf'}';
|
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
|
// Obs: setFile() i _pickFile har redan placerat bytes i session; clear() behövs ej här
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -307,8 +320,8 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
|||||||
unit: it.unit,
|
unit: it.unit,
|
||||||
);
|
);
|
||||||
final name = it.matchedProductName ?? it.suggestedProductName;
|
final name = it.matchedProductName ?? it.suggestedProductName;
|
||||||
final resolvedCategoryId =
|
final resolvedCategoryId = it.categorySuggestionId ??
|
||||||
it.categorySuggestionId ?? (pid != null ? _categoryIdForProduct(pid) : null);
|
(pid != null ? _categoryIdForProduct(pid) : null);
|
||||||
final resolvedCategoryPath =
|
final resolvedCategoryPath =
|
||||||
it.categorySuggestionPath ?? _lookup.pathFor(resolvedCategoryId);
|
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
|
// Ladda inventariet för att visa befintliga poster och möjliggöra sammanslagning
|
||||||
await _loadInventory();
|
await _loadInventory();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted) showGlobalErrorDialog(context, 'Ett fel uppstod vid import: $e');
|
if (mounted)
|
||||||
|
showGlobalErrorDialog(context, 'Ett fel uppstod vid import: $e');
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) setState(() => _isLoading = false);
|
if (mounted) setState(() => _isLoading = false);
|
||||||
}
|
}
|
||||||
@@ -380,11 +394,13 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
|||||||
productId: item.matchedProductId ?? item.suggestedProductId,
|
productId: item.matchedProductId ?? item.suggestedProductId,
|
||||||
productName: item.matchedProductName ?? item.suggestedProductName,
|
productName: item.matchedProductName ?? item.suggestedProductName,
|
||||||
categoryId: item.categorySuggestionId ??
|
categoryId: item.categorySuggestionId ??
|
||||||
_categoryIdForProduct(item.matchedProductId ?? item.suggestedProductId),
|
_categoryIdForProduct(
|
||||||
|
item.matchedProductId ?? item.suggestedProductId),
|
||||||
categoryPath: item.categorySuggestionPath ??
|
categoryPath: item.categorySuggestionPath ??
|
||||||
_lookup.pathFor(
|
_lookup.pathFor(
|
||||||
item.categorySuggestionId ??
|
item.categorySuggestionId ??
|
||||||
_categoryIdForProduct(item.matchedProductId ?? item.suggestedProductId),
|
_categoryIdForProduct(
|
||||||
|
item.matchedProductId ?? item.suggestedProductId),
|
||||||
),
|
),
|
||||||
categorySource: item.categorySuggestionId != null
|
categorySource: item.categorySuggestionId != null
|
||||||
? CategorySelectionSource.ai
|
? CategorySelectionSource.ai
|
||||||
@@ -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;
|
if (!context.mounted) return;
|
||||||
await showDialog(
|
await showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
@@ -468,7 +485,8 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
|||||||
try {
|
try {
|
||||||
final token = await ref.read(authStateProvider.future);
|
final token = await ref.read(authStateProvider.future);
|
||||||
final repo = ref.read(importRepositoryProvider);
|
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;
|
if (!mounted) return;
|
||||||
await _showHelpDialog(help);
|
await _showHelpDialog(help);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -503,7 +521,8 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
|||||||
runSpacing: 8,
|
runSpacing: 8,
|
||||||
children: [
|
children: [
|
||||||
Chip(label: Text('Scope: ${help.scope}')),
|
Chip(label: Text('Scope: ${help.scope}')),
|
||||||
if (updatedAtText != null) Chip(label: Text('Uppdaterad: $updatedAtText')),
|
if (updatedAtText != null)
|
||||||
|
Chip(label: Text('Uppdaterad: $updatedAtText')),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -569,7 +588,8 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
|||||||
|
|
||||||
if (productId == null) {
|
if (productId == null) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
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;
|
return;
|
||||||
}
|
}
|
||||||
@@ -636,7 +656,8 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
|||||||
}
|
}
|
||||||
if (toAdd.isEmpty) {
|
if (toAdd.isEmpty) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
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;
|
return;
|
||||||
}
|
}
|
||||||
@@ -657,7 +678,8 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
|||||||
'rawName': item.rawName,
|
'rawName': item.rawName,
|
||||||
'quantity': edit.quantity ?? item.quantity ?? 0,
|
'quantity': edit.quantity ?? item.quantity ?? 0,
|
||||||
'unit': (edit.unit ?? item.unit ?? 'st').trim(),
|
'unit': (edit.unit ?? item.unit ?? 'st').trim(),
|
||||||
'destination': edit.destination == _Destination.pantry ? 'pantry' : 'inventory',
|
'destination':
|
||||||
|
edit.destination == _Destination.pantry ? 'pantry' : 'inventory',
|
||||||
'productId': pid,
|
'productId': pid,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -668,14 +690,19 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
|||||||
|
|
||||||
// Päckfält för inventory
|
// Päckfält för inventory
|
||||||
if (edit.destination == _Destination.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.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
|
// Lär in alias bara om användaren uttryckligen valt det
|
||||||
final alreadyAliasMatch = item.matchedVia == 'alias' && item.matchedProductId == pid;
|
final alreadyAliasMatch =
|
||||||
if (edit.learnAlias && item.rawName.trim().isNotEmpty && !alreadyAliasMatch) {
|
item.matchedVia == 'alias' && item.matchedProductId == pid;
|
||||||
|
if (edit.learnAlias &&
|
||||||
|
item.rawName.trim().isNotEmpty &&
|
||||||
|
!alreadyAliasMatch) {
|
||||||
saveItem['learnAlias'] = true;
|
saveItem['learnAlias'] = true;
|
||||||
if (edit.learnAliasGlobally) {
|
if (edit.learnAliasGlobally) {
|
||||||
saveItem['learnAliasGlobally'] = true;
|
saveItem['learnAliasGlobally'] = true;
|
||||||
@@ -685,8 +712,11 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
|||||||
// Lär in enhetsmappning för inventory
|
// Lär in enhetsmappning för inventory
|
||||||
if (edit.destination == _Destination.inventory) {
|
if (edit.destination == _Destination.inventory) {
|
||||||
final originalUnit = (item.unit ?? '').trim().toLowerCase();
|
final originalUnit = (item.unit ?? '').trim().toLowerCase();
|
||||||
final preferredUnit = (edit.unit ?? item.unit ?? 'st').trim().toLowerCase();
|
final preferredUnit =
|
||||||
if (originalUnit.isNotEmpty && preferredUnit.isNotEmpty && originalUnit != preferredUnit) {
|
(edit.unit ?? item.unit ?? 'st').trim().toLowerCase();
|
||||||
|
if (originalUnit.isNotEmpty &&
|
||||||
|
preferredUnit.isNotEmpty &&
|
||||||
|
originalUnit != preferredUnit) {
|
||||||
saveItem['learnUnitMapping'] = true;
|
saveItem['learnUnitMapping'] = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -714,11 +744,14 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
|||||||
|
|
||||||
final parts = <String>[
|
final parts = <String>[
|
||||||
if (created > 0) '$created ny${created == 1 ? '' : 'a'} i inventarie',
|
if (created > 0) '$created ny${created == 1 ? '' : 'a'} i inventarie',
|
||||||
if (merged > 0) '$merged ${merged == 1 ? 'sammanslagen' : 'sammanslagna'} i inventarie',
|
if (merged > 0)
|
||||||
if (pantryAdded > 0) '$pantryAdded tillagd${pantryAdded == 1 ? '' : 'a'} i baslager',
|
'$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 (pantrySkipped > 0) '$pantrySkipped fanns redan i baslager',
|
||||||
if (aliasesLearned > 0) '$aliasesLearned alias inlärda',
|
if (aliasesLearned > 0) '$aliasesLearned alias inlärda',
|
||||||
if (unitMappingsLearned > 0) '$unitMappingsLearned enhetsmappningar inlärda',
|
if (unitMappingsLearned > 0)
|
||||||
|
'$unitMappingsLearned enhetsmappningar inlärda',
|
||||||
if ((flyerAutoSync?['bought'] as int? ?? 0) > 0)
|
if ((flyerAutoSync?['bought'] as int? ?? 0) > 0)
|
||||||
'${flyerAutoSync?['bought']} planerade flyer-varor markerade som köpta',
|
'${flyerAutoSync?['bought']} planerade flyer-varor markerade som köpta',
|
||||||
if ((flyerAutoSync?['ambiguous'] as int? ?? 0) > 0)
|
if ((flyerAutoSync?['ambiguous'] as int? ?? 0) > 0)
|
||||||
@@ -788,6 +821,7 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
|||||||
final session = ref.read(receiptImportSessionProvider);
|
final session = ref.read(receiptImportSessionProvider);
|
||||||
return session?.fileBytes != null;
|
return session?.fileBytes != null;
|
||||||
}
|
}
|
||||||
|
|
||||||
int get _selectedCount => _selected.values.where((v) => v).length;
|
int get _selectedCount => _selected.values.where((v) => v).length;
|
||||||
|
|
||||||
// ── Kvittobild / PDF-förhandsvisning ───────────────────────────────────────
|
// ── Kvittobild / PDF-förhandsvisning ───────────────────────────────────────
|
||||||
@@ -814,13 +848,15 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
|||||||
: OutlinedButton.icon(
|
: OutlinedButton.icon(
|
||||||
icon: const Icon(Icons.open_in_new, size: 16),
|
icon: const Icon(Icons.open_in_new, size: 16),
|
||||||
label: const Text('Visa kvitto'),
|
label: const Text('Visa kvitto'),
|
||||||
style: OutlinedButton.styleFrom(visualDensity: VisualDensity.compact),
|
style: OutlinedButton.styleFrom(
|
||||||
|
visualDensity: VisualDensity.compact),
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
final opened = await openPdfBytes(bytes);
|
final opened = await openPdfBytes(bytes);
|
||||||
if (!context.mounted || opened) return;
|
if (!context.mounted || opened) return;
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(
|
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),
|
'alias' => ('Alias', Colors.teal.shade50, Colors.teal.shade800),
|
||||||
'wordmatch' => ('Ordmatch', Colors.blue.shade50, Colors.blue.shade800),
|
'wordmatch' => ('Ordmatch', Colors.blue.shade50, Colors.blue.shade800),
|
||||||
'ai' => ('AI-kategori', Colors.purple.shade50, Colors.purple.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(
|
return Container(
|
||||||
@@ -864,7 +904,8 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
|||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
label,
|
label,
|
||||||
style: theme.textTheme.labelSmall?.copyWith(color: fg, fontWeight: FontWeight.w600),
|
style: theme.textTheme.labelSmall
|
||||||
|
?.copyWith(color: fg, fontWeight: FontWeight.w600),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -878,7 +919,6 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
|||||||
final selectedFileSizeBytes =
|
final selectedFileSizeBytes =
|
||||||
_pickedFile?.size ?? session?.fileBytes?.length;
|
_pickedFile?.size ?? session?.fileBytes?.length;
|
||||||
|
|
||||||
|
|
||||||
return SingleChildScrollView(
|
return SingleChildScrollView(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -890,7 +930,8 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
|||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
'Ladda upp ett kvitto (PDF eller bild) — raderna tolkas och kan läggas till i ditt inventarie.',
|
'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),
|
const SizedBox(width: 8),
|
||||||
@@ -911,13 +952,15 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
|||||||
OutlinedButton.icon(
|
OutlinedButton.icon(
|
||||||
onPressed: _isLoading ? null : _pickFile,
|
onPressed: _isLoading ? null : _pickFile,
|
||||||
icon: const Icon(Icons.attach_file),
|
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) ...[
|
if (selectedFileSizeBytes != null) ...[
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
'${(selectedFileSizeBytes / 1024).round()} KB',
|
'${(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 ────────────────────────────────────────
|
// ── Förhandsvisning av kvitto ────────────────────────────────────────
|
||||||
@@ -931,7 +974,8 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
|||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
'Tolkar kvittot — detta kan ta upp till en minut...',
|
'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),
|
const SizedBox(height: 16),
|
||||||
],
|
],
|
||||||
@@ -951,21 +995,28 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
|||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
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(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
TextButton.icon(
|
TextButton.icon(
|
||||||
onPressed: items.isEmpty ? null : () => _showReceiptPreview(context, items),
|
onPressed: items.isEmpty
|
||||||
|
? null
|
||||||
|
: () => _showReceiptPreview(context, items),
|
||||||
icon: const Icon(Icons.description_outlined),
|
icon: const Icon(Icons.description_outlined),
|
||||||
label: const Text('Se kvitto'),
|
label: const Text('Se kvitto'),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => setState(() {
|
onPressed: () => setState(() {
|
||||||
final notifier = ref.read(receiptImportSessionProvider.notifier);
|
final notifier =
|
||||||
notifier.setSelectedForAll(items.length, _selectedCount < items.length);
|
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'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -985,7 +1036,9 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
|||||||
existingInventoryByProduct: _inventoryByProduct,
|
existingInventoryByProduct: _inventoryByProduct,
|
||||||
pantryProductIds: _pantryProductIds,
|
pantryProductIds: _pantryProductIds,
|
||||||
onCheckedChanged: (v) {
|
onCheckedChanged: (v) {
|
||||||
ref.read(receiptImportSessionProvider.notifier).setSelected(i, v);
|
ref
|
||||||
|
.read(receiptImportSessionProvider.notifier)
|
||||||
|
.setSelected(i, v);
|
||||||
},
|
},
|
||||||
onEditRequested: () => _openEditDialog(i),
|
onEditRequested: () => _openEditDialog(i),
|
||||||
onSelectExistingRequested: () => _openEditDialog(
|
onSelectExistingRequested: () => _openEditDialog(
|
||||||
@@ -1006,11 +1059,18 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
|||||||
SizedBox(
|
SizedBox(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
child: FilledButton.icon(
|
child: FilledButton.icon(
|
||||||
onPressed: (_selectedCount > 0 && !_isSaving) ? _addSelected : null,
|
onPressed:
|
||||||
|
(_selectedCount > 0 && !_isSaving) ? _addSelected : null,
|
||||||
icon: _isSaving
|
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),
|
: 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,7 +1121,8 @@ class _ReceiptImportResultRow extends ConsumerWidget {
|
|||||||
final isMatched = item.matchedProductId != null;
|
final isMatched = item.matchedProductId != null;
|
||||||
final isSuggested =
|
final isSuggested =
|
||||||
item.suggestedProductId != null && item.matchedProductId == null;
|
item.suggestedProductId != null && item.matchedProductId == null;
|
||||||
final existingInv = edit?.productId != null && edit?.destination != _Destination.pantry
|
final existingInv =
|
||||||
|
edit?.productId != null && edit?.destination != _Destination.pantry
|
||||||
? existingInventoryByProduct[edit!.productId]
|
? existingInventoryByProduct[edit!.productId]
|
||||||
: null;
|
: null;
|
||||||
final inferredForPreview = inferPackageFields(
|
final inferredForPreview = inferPackageFields(
|
||||||
@@ -1075,7 +1136,10 @@ class _ReceiptImportResultRow extends ConsumerWidget {
|
|||||||
edit?.packQuantity ?? inferredForPreview.packQuantity;
|
edit?.packQuantity ?? inferredForPreview.packQuantity;
|
||||||
final previewIncomingQty = previewPackQuantity != null
|
final previewIncomingQty = previewPackQuantity != null
|
||||||
? (previewPackQuantity * previewPackageCount)
|
? (previewPackQuantity * previewPackageCount)
|
||||||
: (edit?.quantity ?? inferredForPreview.totalQuantity ?? item.quantity ?? 0);
|
: (edit?.quantity ??
|
||||||
|
inferredForPreview.totalQuantity ??
|
||||||
|
item.quantity ??
|
||||||
|
0);
|
||||||
final previewIncomingUnit = edit?.packUnit ??
|
final previewIncomingUnit = edit?.packUnit ??
|
||||||
inferredForPreview.packUnit ??
|
inferredForPreview.packUnit ??
|
||||||
edit?.unit ??
|
edit?.unit ??
|
||||||
@@ -1089,7 +1153,8 @@ class _ReceiptImportResultRow extends ConsumerWidget {
|
|||||||
existingInv.unit,
|
existingInv.unit,
|
||||||
);
|
);
|
||||||
final canMergePreview = existingInv != null && convertedPreviewQty != null;
|
final canMergePreview = existingInv != null && convertedPreviewQty != null;
|
||||||
final alreadyInPantry = edit?.productId != null && edit?.destination == _Destination.pantry
|
final alreadyInPantry =
|
||||||
|
edit?.productId != null && edit?.destination == _Destination.pantry
|
||||||
? pantryProductIds.contains(edit!.productId)
|
? pantryProductIds.contains(edit!.productId)
|
||||||
: false;
|
: false;
|
||||||
|
|
||||||
@@ -1126,22 +1191,26 @@ class _ReceiptImportResultRow extends ConsumerWidget {
|
|||||||
Text(
|
Text(
|
||||||
'Produktnamn: ${normalizeProductName(edit!.productName ?? '')}',
|
'Produktnamn: ${normalizeProductName(edit!.productName ?? '')}',
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
color:
|
color: isMatched
|
||||||
isMatched ? Colors.green.shade700 : theme.colorScheme.primary,
|
? Colors.green.shade700
|
||||||
|
: theme.colorScheme.primary,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
matchedViaBadgeBuilder(item, theme),
|
matchedViaBadgeBuilder(item, theme),
|
||||||
if (edit!.categorySource != null)
|
if (edit!.categorySource != null)
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 8, vertical: 2),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: edit!.categorySource == CategorySelectionSource.ai
|
color:
|
||||||
|
edit!.categorySource == CategorySelectionSource.ai
|
||||||
? Colors.green.shade50
|
? Colors.green.shade50
|
||||||
: theme.colorScheme.surfaceContainerHighest,
|
: theme.colorScheme.surfaceContainerHighest,
|
||||||
borderRadius: BorderRadius.circular(999),
|
borderRadius: BorderRadius.circular(999),
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: edit!.categorySource == CategorySelectionSource.ai
|
color:
|
||||||
|
edit!.categorySource == CategorySelectionSource.ai
|
||||||
? Colors.green.shade300
|
? Colors.green.shade300
|
||||||
: theme.colorScheme.outlineVariant,
|
: theme.colorScheme.outlineVariant,
|
||||||
),
|
),
|
||||||
@@ -1151,7 +1220,8 @@ class _ReceiptImportResultRow extends ConsumerWidget {
|
|||||||
? 'AI'
|
? 'AI'
|
||||||
: 'Manuell',
|
: 'Manuell',
|
||||||
style: theme.textTheme.labelSmall?.copyWith(
|
style: theme.textTheme.labelSmall?.copyWith(
|
||||||
color: edit!.categorySource == CategorySelectionSource.ai
|
color:
|
||||||
|
edit!.categorySource == CategorySelectionSource.ai
|
||||||
? Colors.green.shade800
|
? Colors.green.shade800
|
||||||
: theme.colorScheme.onSurfaceVariant,
|
: theme.colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
@@ -1210,33 +1280,39 @@ class _ReceiptImportResultRow extends ConsumerWidget {
|
|||||||
if (existingInv != null && canMergePreview) ...[
|
if (existingInv != null && canMergePreview) ...[
|
||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
Row(children: [
|
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),
|
const SizedBox(width: 3),
|
||||||
Text(
|
Text(
|
||||||
'I lager: ${existingInv.quantity} ${existingInv.unit} → blir ${(existingInv.quantity + convertedPreviewQty).toStringAsFixed(existingInv.quantity % 1 == 0 ? 0 : 2)} ${existingInv.unit}',
|
'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) ...[
|
if (existingInv != null && !canMergePreview) ...[
|
||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
Row(children: [
|
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),
|
const SizedBox(width: 3),
|
||||||
Text(
|
Text(
|
||||||
'Finns i lager med annan enhet (${existingInv.unit}) - sparas som ny rad',
|
'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) ...[
|
if (alreadyInPantry) ...[
|
||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
Row(children: [
|
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),
|
const SizedBox(width: 3),
|
||||||
Text(
|
Text(
|
||||||
'Finns redan i baslager',
|
'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),
|
: (isSuggested ? Icons.help_outline : Icons.error_outline),
|
||||||
color: hasProduct
|
color: hasProduct
|
||||||
? Colors.green
|
? Colors.green
|
||||||
: (isSuggested ? Colors.orange : theme.colorScheme.tertiary),
|
: (isSuggested
|
||||||
|
? Colors.orange
|
||||||
|
: theme.colorScheme.tertiary),
|
||||||
size: 20,
|
size: 20,
|
||||||
),
|
),
|
||||||
if (hasProduct)
|
if (hasProduct)
|
||||||
IconButton(
|
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,
|
onPressed: onAliasEditRequested,
|
||||||
tooltip: 'Spara alias',
|
tooltip: 'Spara alias',
|
||||||
constraints: const BoxConstraints(minWidth: 40, minHeight: 40),
|
constraints:
|
||||||
|
const BoxConstraints(minWidth: 40, minHeight: 40),
|
||||||
padding: const EdgeInsets.all(4),
|
padding: const EdgeInsets.all(4),
|
||||||
),
|
),
|
||||||
IconButton(
|
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,
|
onPressed: onDeleteRequested,
|
||||||
tooltip: 'Ta bort rad',
|
tooltip: 'Ta bort rad',
|
||||||
constraints: const BoxConstraints(minWidth: 40, minHeight: 40),
|
constraints: const BoxConstraints(minWidth: 40, minHeight: 40),
|
||||||
@@ -1315,11 +1403,12 @@ class _ReceiptPreviewDialog extends StatelessWidget {
|
|||||||
child: SelectableText.rich(
|
child: SelectableText.rich(
|
||||||
TextSpan(
|
TextSpan(
|
||||||
children: items.isEmpty
|
children: items.isEmpty
|
||||||
? [TextSpan(text: '(Inga rader)', style: theme.textTheme.bodySmall)]
|
? [
|
||||||
: items
|
TextSpan(
|
||||||
.asMap()
|
text: '(Inga rader)',
|
||||||
.entries
|
style: theme.textTheme.bodySmall)
|
||||||
.map((entry) {
|
]
|
||||||
|
: items.asMap().entries.map((entry) {
|
||||||
final item = entry.value;
|
final item = entry.value;
|
||||||
final lineNumber = entry.key + 1;
|
final lineNumber = entry.key + 1;
|
||||||
final lineText = _formatReceiptLine(item);
|
final lineText = _formatReceiptLine(item);
|
||||||
@@ -1340,8 +1429,7 @@ class _ReceiptPreviewDialog extends StatelessWidget {
|
|||||||
const TextSpan(text: '\n'),
|
const TextSpan(text: '\n'),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
})
|
}).toList(),
|
||||||
.toList(),
|
|
||||||
),
|
),
|
||||||
style: theme.textTheme.bodySmall,
|
style: theme.textTheme.bodySmall,
|
||||||
),
|
),
|
||||||
@@ -1379,4 +1467,3 @@ class _ReceiptPreviewDialog extends StatelessWidget {
|
|||||||
return parts.join(' ');
|
return parts.join(' ');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -39,8 +39,9 @@ double _stepForUnit(String unit) {
|
|||||||
|
|
||||||
/// Formats a step value for display: whole numbers without decimal,
|
/// Formats a step value for display: whole numbers without decimal,
|
||||||
/// fractions with one decimal.
|
/// fractions with one decimal.
|
||||||
String _fmtStep(double step) =>
|
String _fmtStep(double step) => step == step.roundToDouble()
|
||||||
step == step.roundToDouble() ? step.toStringAsFixed(0) : step.toStringAsFixed(1);
|
? step.toStringAsFixed(0)
|
||||||
|
: step.toStringAsFixed(1);
|
||||||
|
|
||||||
/// A [ListTile] wrapped in a swipe-to-adjust widget.
|
/// A [ListTile] wrapped in a swipe-to-adjust widget.
|
||||||
///
|
///
|
||||||
@@ -122,7 +123,8 @@ class _SwipeableInventoryTileState
|
|||||||
// Decrease: use consume endpoint so history is preserved.
|
// Decrease: use consume endpoint so history is preserved.
|
||||||
// Guard against going below zero.
|
// Guard against going below zero.
|
||||||
if (widget.item.quantity <= 0) return;
|
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(
|
await repo.consumeInventoryItem(
|
||||||
widget.item.id,
|
widget.item.id,
|
||||||
amountUsed: consume,
|
amountUsed: consume,
|
||||||
@@ -402,32 +404,44 @@ class _TrailingActions extends ConsumerWidget {
|
|||||||
Tooltip(
|
Tooltip(
|
||||||
message: 'Konsumera',
|
message: 'Konsumera',
|
||||||
child: IconButton(
|
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'),
|
onPressed: () => context.push('/inventory/${item.id}/consume'),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Tooltip(
|
Tooltip(
|
||||||
message: 'Redigera',
|
message: 'Redigera',
|
||||||
child: IconButton(
|
child: IconButton(
|
||||||
icon: const Icon(Icons.edit_outlined),
|
icon: const Icon(
|
||||||
|
Icons.edit_outlined,
|
||||||
|
semanticLabel: 'Redigera',
|
||||||
|
),
|
||||||
onPressed: () => context.push('/inventory/${item.id}/edit'),
|
onPressed: () => context.push('/inventory/${item.id}/edit'),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Tooltip(
|
Tooltip(
|
||||||
message: 'Flytta till baslager',
|
message: 'Flytta till baslager',
|
||||||
child: IconButton(
|
child: IconButton(
|
||||||
icon: const Icon(Icons.storefront_outlined),
|
icon: const Icon(
|
||||||
|
Icons.storefront_outlined,
|
||||||
|
semanticLabel: 'Flytta till baslager',
|
||||||
|
),
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
try {
|
try {
|
||||||
final token = await ref.read(authStateProvider.future);
|
final token = await ref.read(authStateProvider.future);
|
||||||
await ref.read(inventoryRepositoryProvider).moveInventoryItemToPantry(
|
await ref
|
||||||
|
.read(inventoryRepositoryProvider)
|
||||||
|
.moveInventoryItemToPantry(
|
||||||
item.id,
|
item.id,
|
||||||
token: token,
|
token: token,
|
||||||
);
|
);
|
||||||
ref.invalidate(inventoryProvider);
|
ref.invalidate(inventoryProvider);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
buildCopyableErrorSnackBar(context, mapErrorToUserMessage(e, context)),
|
buildCopyableErrorSnackBar(
|
||||||
|
context, mapErrorToUserMessage(e, context)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -449,7 +463,11 @@ class _DeleteButton extends ConsumerWidget {
|
|||||||
return Tooltip(
|
return Tooltip(
|
||||||
message: 'Ta bort',
|
message: 'Ta bort',
|
||||||
child: IconButton(
|
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 {
|
onPressed: () async {
|
||||||
final confirmed = await showDialog<bool>(
|
final confirmed = await showDialog<bool>(
|
||||||
context: context,
|
context: context,
|
||||||
@@ -477,7 +495,8 @@ class _DeleteButton extends ConsumerWidget {
|
|||||||
ref.invalidate(inventoryProvider);
|
ref.invalidate(inventoryProvider);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
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();
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html>
|
<html lang="sv">
|
||||||
<head>
|
<head>
|
||||||
<base href="/">
|
<base href="/">
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Recipe App</title>
|
<meta name="description" content="Planera recept, hantera inventarie och importera kvitton i en samlad matplaneringsapp.">
|
||||||
|
<title>Recipe App - Recept, inventarie och import</title>
|
||||||
<script src="flutter_bootstrap.js" async></script>
|
<script src="flutter_bootstrap.js" async></script>
|
||||||
</head>
|
</head>
|
||||||
<body></body>
|
<body></body>
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
User-agent: *
|
||||||
|
Allow: /
|
||||||
Reference in New Issue
Block a user