diff --git a/flutter/PERFORMANCE.md b/flutter/PERFORMANCE.md new file mode 100644 index 00000000..3fd061d5 --- /dev/null +++ b/flutter/PERFORMANCE.md @@ -0,0 +1,133 @@ +# Flutter Performance – Profileringsguide + +## Mål + +| Mätpunkt | Gränsvärde | +|---|---| +| Frame build-tid (60 Hz) | < 16 ms | +| Frame build-tid (120 Hz) | < 8 ms | +| Scroll jank (tappade frames) | 0 vid normal scroll | +| Minnesfotavtryck (app) | < 200 MB | + +--- + +## 1. Starta i profile-läge + +Kör alltid profilmätningar i **profile mode**, inte debug. Debug-läget har JIT-kompilering och extra overhead. + +```bash +# Mot fysisk enhet +flutter run --profile + +# Mot Chrome (web) +flutter run -d chrome --profile +``` + +--- + +## 2. Flutter DevTools – Öppna + +```bash +flutter pub global activate devtools +flutter pub global run devtools +``` + +Eller anslut direkt från terminalen när appen körs i profile mode – Flutter skriver ut en DevTools-URL. + +--- + +## 3. Timeline – Mät frame-tider + +1. Öppna **Performance**-fliken i DevTools. +2. Klicka **Record**. +3. Utför den aktion du vill mäta (t.ex. byt vy, scrolla). +4. Klicka **Stop**. +5. Granska: + - **UI thread** (Dart-kod) – bör vara < 16 ms per frame. + - **Raster thread** (GPU) – bör vara < 16 ms per frame. + - Röda/gula staplar = jank. + +### Kritiska mätpunkter i appen + +| Scenario | Vad att leta efter | +|---|---| +| Byta vy (NavigationBar) | Frame-tid vid `StatefulShellRoute`-byte; bör vara < 32 ms totalt | +| Scrolla receptlista | Inga röda frames; `GridView.builder` bör recykla element | +| Scrolla adminpaneler | `ListView.builder` i embedded-läge; verifiera att ingen `NeverScrollableScrollPhysics` blockerar | +| Kvittoimport – kryssa i rad | Endast den berörda raden bör rebuilda (`ConsumerWidget.select`) | +| Kvittoimport – "Välj alla" | Batch-uppdatering via `setSelectedForAll` – en enda `state =` | + +--- + +## 4. Widget Rebuild-spårning + +Aktivera rebuild-räknare i DevTools under **Inspector → Widget rebuild counts**. + +Alternativt: lägg till tillfällig räknare i en widget: + +```dart +int _buildCount = 0; + +@override +Widget build(BuildContext context, WidgetRef ref) { + debugPrint('${widget.runtimeType} build #${++_buildCount}'); + // ... +} +``` + +### Förväntade rebuild-mönster efter optimeringar + +- `_ReceiptImportResultRow` med index X ska bara rebuilda när `selected[X]` ändras, inte när andra rader kryssas. +- `AppShell` ska inte rebuilda vid vy-byte (StatefulShellRoute bevarar grenar). +- Admin-paneler ska inte rebuilda hela listan vid en alias-ändring. + +--- + +## 5. Memory Profiler + +1. DevTools → **Memory**-fliken. +2. Klicka **Take snapshot** före och efter en tung operation. +3. Jämför levande objekt – leta efter läckor (ackumulerade `StreamSubscription`, `Timer`, `Notifier`). + +--- + +## 6. flutter analyze + dart fix + +```bash +flutter analyze +dart fix --apply +``` + +Åtgärda alla varningar om `const` och onödiga rebuilds. + +--- + +## 7. Identifierade optimeringar (genomförda) + +| Område | Åtgärd | Effekt | +|---|---|---| +| Admin-paneler | Tog bort `NeverScrollableScrollPhysics` + `shrinkWrap` | Scroll fungerar, O(n) layout istället för O(n²) | +| Admin alias-lista | `ListView.builder` istället för spread | Virtualiserad lista | +| FABs | Explicita `heroTag` på alla FABs | Eliminerar hero-animation-krasch vid vy-byte | +| Scrollables | `PageStorageKey` på alla listvy | Scrollposition bevaras vid vy-byte | +| Router | `StatefulShellRoute.indexedStack` | Branch-state bevaras; ingen ombyggnad vid tab-byte | +| Kvittoimport – resultatlista | `ListView.builder` + `SizedBox` bound height | Virtualiserad; max 620 px synlig | +| Kvittoimport – radwidget | `ConsumerWidget` med `provider.select((s) => s?.selected[index])` | Endast ändrad rad rebuildar vid checkbox-toggle | +| Kvittoimport – batch-API | `setSelectedForAll`, `setSelectedForIndexes`, `setImportedResult` | En `state =` och en SharedPreferences-skrivning per operation | + +--- + +## 8. Snabbtest – Verifiera förbättringar + +```bash +# Kör i profile mode och öppna DevTools automatiskt +flutter run --profile --devtools-server-address=http://127.0.0.1:9100 +``` + +Kontrollchecklista: + +- [ ] Vy-byte NavigationBar: inga röda frames i Timeline +- [ ] Scroll i receptlista: < 2 tappade frames per 100 frames +- [ ] Scroll i admin-flikar: fungerar utan lock +- [ ] Kvittoimport – checkbox-toggle: rebuild-räknare ökar bara för berörd rad +- [ ] Kvittoimport – "Välj alla": en burst av rebuilds (en per rad), inga dubbla diff --git a/flutter/lib/core/router/app_router.dart b/flutter/lib/core/router/app_router.dart index cb1eb61c..687a6717 100644 --- a/flutter/lib/core/router/app_router.dart +++ b/flutter/lib/core/router/app_router.dart @@ -24,6 +24,17 @@ import '../../features/pantry/presentation/pantry_screen.dart'; import '../../features/import/presentation/import_screen.dart'; import '../../features/admin/presentation/admin_screen.dart'; +int? _shellBranchIndexForPath(String path) { + if (path.startsWith('/recipes')) return 0; + if (path.startsWith('/inventory')) return 1; + if (path.startsWith('/matsedel')) return 2; + if (path.startsWith('/baslager')) return 3; + if (path.startsWith('/import')) return 4; + if (path.startsWith('/profile')) return 5; + if (path.startsWith('/admin')) return 6; + return null; +} + final appRouterProvider = Provider((ref) { final authState = ref.watch(authStateProvider); @@ -175,42 +186,90 @@ final appRouterProvider = Provider((ref) { }, ), // Shell routes — shared AppShell with navigation bar. - ShellRoute( - builder: (context, state, child) { - return AppShell(location: state.uri.path, child: child); - }, - routes: [ - GoRoute( - path: '/recipes', - builder: (context, state) => const RecipesScreen(), - ), - GoRoute( - path: '/inventory', - builder: (context, state) => const InventoryScreen(), - ), - GoRoute( - path: '/matsedel', - builder: (context, state) => const MealPlanScreen(), - ), - GoRoute( - path: '/baslager', - builder: (context, state) => const PantryScreen(), - ), - GoRoute( - path: '/import', - builder: (context, state) => const ImportScreen(), - ), - GoRoute( - path: '/profile', - builder: (context, state) => const ProfileScreen(), - ), - GoRoute( - path: '/admin', - redirect: (context, state) { - final token = ref.read(authStateProvider).maybeWhen(data: (t) => t, orElse: () => null); - return jwtIsAdmin(token) ? null : '/recipes'; + StatefulShellRoute.indexedStack( + builder: (context, state, navigationShell) { + return AppShell( + location: state.uri.path, + onNavigateToPath: (path) { + final index = _shellBranchIndexForPath(path); + if (index == null) { + context.go(path); + return; + } + + if (index == navigationShell.currentIndex) { + if (state.uri.path != path) { + context.go(path); + } + return; + } + + navigationShell.goBranch(index); }, - builder: (context, state) => const AdminScreen(), + child: navigationShell, + ); + }, + branches: [ + StatefulShellBranch( + routes: [ + GoRoute( + path: '/recipes', + builder: (context, state) => const RecipesScreen(), + ), + ], + ), + StatefulShellBranch( + routes: [ + GoRoute( + path: '/inventory', + builder: (context, state) => const InventoryScreen(), + ), + ], + ), + StatefulShellBranch( + routes: [ + GoRoute( + path: '/matsedel', + builder: (context, state) => const MealPlanScreen(), + ), + ], + ), + StatefulShellBranch( + routes: [ + GoRoute( + path: '/baslager', + builder: (context, state) => const PantryScreen(), + ), + ], + ), + StatefulShellBranch( + routes: [ + GoRoute( + path: '/import', + builder: (context, state) => const ImportScreen(), + ), + ], + ), + StatefulShellBranch( + routes: [ + GoRoute( + path: '/profile', + builder: (context, state) => const ProfileScreen(), + ), + ], + ), + StatefulShellBranch( + routes: [ + GoRoute( + path: '/admin', + redirect: (context, state) { + final token = ref.read(authStateProvider) + .maybeWhen(data: (t) => t, orElse: () => null); + return jwtIsAdmin(token) ? null : '/recipes'; + }, + builder: (context, state) => const AdminScreen(), + ), + ], ), ], ), diff --git a/flutter/lib/core/ui/app_shell.dart b/flutter/lib/core/ui/app_shell.dart index 404efeef..4d37c22d 100644 --- a/flutter/lib/core/ui/app_shell.dart +++ b/flutter/lib/core/ui/app_shell.dart @@ -21,11 +21,13 @@ const _adminHeaderDestination = _AppDestination( class AppShell extends ConsumerWidget { final String location; + final ValueChanged onNavigateToPath; final Widget child; const AppShell({ super.key, required this.location, + required this.onNavigateToPath, required this.child, }); @@ -101,7 +103,7 @@ class AppShell extends ConsumerWidget { void navigateTo(int index) { final target = dests[index].path; if (target != location && context.mounted) { - context.go(target); + onNavigateToPath(target); } } @@ -178,7 +180,7 @@ class AppShell extends ConsumerWidget { switch (value) { case 'profile': if (location != '/profile' && context.mounted) { - context.go('/profile'); + onNavigateToPath('/profile'); } } }, diff --git a/flutter/lib/features/admin/presentation/admin_aliases_panel.dart b/flutter/lib/features/admin/presentation/admin_aliases_panel.dart index 6c65e338..f166ad11 100644 --- a/flutter/lib/features/admin/presentation/admin_aliases_panel.dart +++ b/flutter/lib/features/admin/presentation/admin_aliases_panel.dart @@ -166,6 +166,49 @@ class _AdminAliasesPanelState extends ConsumerState { alias.displayProductName.toLowerCase().contains(query); }).toList(); + Widget buildAliasCard(ReceiptAlias alias) { + return Card( + child: ListTile( + leading: const Icon(Icons.link_outlined), + title: Text(alias.receiptName, style: const TextStyle(fontWeight: FontWeight.w500)), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '→ ${alias.displayProductName}', + style: const TextStyle(fontWeight: FontWeight.w400), + ), + Text( + 'Produkt-ID: ${alias.productId}', + style: TextStyle( + fontSize: 11, + color: Theme.of(context).colorScheme.outline, + ), + ), + ], + ), + trailing: IconButton( + onPressed: () => _removeAlias(alias), + icon: const Icon(Icons.delete_outline), + tooltip: 'Ta bort alias', + color: Theme.of(context).colorScheme.error, + ), + ), + ); + } + + Widget buildAliasList({EdgeInsetsGeometry padding = EdgeInsets.zero}) { + return ListView.builder( + padding: padding, + itemCount: filteredAliases.length, + itemBuilder: (context, index) { + final alias = filteredAliases[index]; + return buildAliasCard(alias); + }, + ); + } + final content = Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -230,51 +273,37 @@ class _AdminAliasesPanelState extends ConsumerState { onChanged: (value) => setState(() => _search = value), ), const SizedBox(height: 12), - if (filteredAliases.isEmpty) - const Text('Inga alias hittades.') - else - ...filteredAliases.map( - (alias) => Card( - child: ListTile( - leading: const Icon(Icons.link_outlined), - title: Text(alias.receiptName, style: const TextStyle(fontWeight: FontWeight.w500)), - subtitle: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - '→ ${alias.displayProductName}', - style: const TextStyle(fontWeight: FontWeight.w400), - ), - Text( - 'Produkt-ID: ${alias.productId}', - style: TextStyle( - fontSize: 11, - color: Theme.of(context).colorScheme.outline, - ), - ), - ], - ), - trailing: IconButton( - onPressed: () => _removeAlias(alias), - icon: const Icon(Icons.delete_outline), - tooltip: 'Ta bort alias', - color: Theme.of(context).colorScheme.error, - ), - ), - ), - ), + if (filteredAliases.isEmpty) const Text('Inga alias hittades.'), ], ); if (!widget.embedded) { + if (filteredAliases.isEmpty) { + return ListView( + padding: const EdgeInsets.all(16), + children: [content], + ); + } return ListView( padding: const EdgeInsets.all(16), - children: [content], + children: [ + content, + const SizedBox(height: 8), + ...filteredAliases.map(buildAliasCard), + ], ); } - return content; + if (filteredAliases.isEmpty) return content; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + content, + const SizedBox(height: 8), + Expanded(child: buildAliasList()), + ], + ); } } diff --git a/flutter/lib/features/admin/presentation/admin_pending_products_panel.dart b/flutter/lib/features/admin/presentation/admin_pending_products_panel.dart index 0e45e0ea..4a5dc20e 100644 --- a/flutter/lib/features/admin/presentation/admin_pending_products_panel.dart +++ b/flutter/lib/features/admin/presentation/admin_pending_products_panel.dart @@ -93,8 +93,8 @@ class _AdminPendingProductsPanelState } final content = ListView.builder( - shrinkWrap: widget.embedded, - physics: widget.embedded ? const NeverScrollableScrollPhysics() : null, + shrinkWrap: false, + physics: null, itemCount: _products.length, itemBuilder: (context, index) { final product = _products[index]; @@ -148,7 +148,7 @@ class _AdminPendingProductsPanelState style: theme.textTheme.bodyMedium, ), const SizedBox(height: 12), - content, + Expanded(child: content), ], ); } diff --git a/flutter/lib/features/admin/presentation/admin_products_panel.dart b/flutter/lib/features/admin/presentation/admin_products_panel.dart index 85c3e2dd..e8acf608 100644 --- a/flutter/lib/features/admin/presentation/admin_products_panel.dart +++ b/flutter/lib/features/admin/presentation/admin_products_panel.dart @@ -738,7 +738,10 @@ class _AdminProductsPanelState extends ConsumerState { ); } - return content; + return ListView( + padding: EdgeInsets.zero, + children: [content], + ); } } diff --git a/flutter/lib/features/admin/presentation/admin_users_panel.dart b/flutter/lib/features/admin/presentation/admin_users_panel.dart index 9e5a94ca..b3f17041 100644 --- a/flutter/lib/features/admin/presentation/admin_users_panel.dart +++ b/flutter/lib/features/admin/presentation/admin_users_panel.dart @@ -328,10 +328,8 @@ class _AdminUsersPanelState extends ConsumerState { } final list = ListView.builder( - shrinkWrap: widget.embedded, - physics: widget.embedded - ? const NeverScrollableScrollPhysics() - : null, + shrinkWrap: false, + physics: null, padding: widget.embedded ? EdgeInsets.zero : const EdgeInsets.fromLTRB(16, 8, 16, 80), @@ -376,7 +374,7 @@ class _AdminUsersPanelState extends ConsumerState { label: Text(context.l10n.adminNewUser), ), const SizedBox(height: 16), - list, + Expanded(child: list), ], ); } diff --git a/flutter/lib/features/import/data/receipt_import_session.dart b/flutter/lib/features/import/data/receipt_import_session.dart index 9c61c1b3..c851bb02 100644 --- a/flutter/lib/features/import/data/receipt_import_session.dart +++ b/flutter/lib/features/import/data/receipt_import_session.dart @@ -186,6 +186,20 @@ class ReceiptImportSessionNotifier unawaited(_persist()); } + void setImportedResult({ + required List items, + required Map edits, + required Map selected, + }) { + final current = state ?? const ReceiptImportSession(); + state = current.copyWith( + items: items, + edits: edits, + selected: selected, + ); + unawaited(_persist()); + } + void setEdit(int index, ItemEdit edit) { if (state == null) return; final edits = Map.from(state!.edits)..[index] = edit; @@ -200,6 +214,25 @@ class ReceiptImportSessionNotifier unawaited(_persist()); } + void setSelectedForIndexes(Iterable indexes, bool value) { + if (state == null) return; + final selected = Map.from(state!.selected); + for (final index in indexes) { + selected[index] = value; + } + state = state!.copyWith(selected: selected); + unawaited(_persist()); + } + + void setSelectedForAll(int count, bool value) { + if (state == null) return; + final selected = { + for (var i = 0; i < count; i++) i: value, + }; + state = state!.copyWith(selected: selected); + unawaited(_persist()); + } + Future restore() async { final prefs = await SharedPreferences.getInstance(); final raw = prefs.getString(_storageKey); diff --git a/flutter/lib/features/import/presentation/receipt_import_tab.dart b/flutter/lib/features/import/presentation/receipt_import_tab.dart index c868798b..f4f972ac 100644 --- a/flutter/lib/features/import/presentation/receipt_import_tab.dart +++ b/flutter/lib/features/import/presentation/receipt_import_tab.dart @@ -292,12 +292,13 @@ class _ReceiptImportTabState extends ConsumerState { ); if (!mounted) return; final notifier = ref.read(receiptImportSessionProvider.notifier); - notifier.setItems(items); + final nextEdits = {}; + final nextSelected = {}; // Förmarkera rader som har en träff for (var i = 0; i < items.length; i++) { final it = items[i]; final pid = it.matchedProductId ?? it.suggestedProductId; - notifier.setSelected(i, pid != null); + nextSelected[i] = pid != null; if (pid != null) { final inferred = inferPackageFields( rawName: it.rawName, @@ -308,7 +309,7 @@ class _ReceiptImportTabState extends ConsumerState { final resolvedCategoryId = it.categorySuggestionId ?? _categoryIdForProduct(pid); final resolvedCategoryPath = it.categorySuggestionPath ?? _lookup.pathFor(resolvedCategoryId); - notifier.setEdit(i, _ItemEdit( + nextEdits[i] = _ItemEdit( productId: pid, productName: name, categoryId: resolvedCategoryId, @@ -321,9 +322,14 @@ class _ReceiptImportTabState extends ConsumerState { packQuantity: inferred.packQuantity, packUnit: inferred.packUnit, packageCount: inferred.packageCount, - )); + ); } } + notifier.setImportedResult( + items: items, + edits: nextEdits, + selected: nextSelected, + ); // Ladda inventariet för att visa befintliga poster och möjliggöra sammanslagning await _loadInventory(); } catch (e) { @@ -573,7 +579,7 @@ class _ReceiptImportTabState extends ConsumerState { ); // Avmarkera sparade rader och uppdatera inventariet final notifier = ref.read(receiptImportSessionProvider.notifier); - for (final i in toAdd) notifier.setSelected(i, false); + notifier.setSelectedForIndexes(toAdd, false); setState(() {}); await _loadInventory(); } catch (e) { @@ -678,6 +684,9 @@ class _ReceiptImportTabState extends ConsumerState { final selectedFileName = _pickedFile?.name ?? session?.fileName; final selectedFileSizeBytes = _pickedFile?.size ?? session?.fileBytes?.length; + final resultListHeight = items == null + ? 0.0 + : (items.length * 128.0).clamp(220.0, 620.0).toDouble(); return SingleChildScrollView( padding: const EdgeInsets.all(16), @@ -736,211 +745,42 @@ class _ReceiptImportTabState extends ConsumerState { TextButton( onPressed: () => setState(() { final notifier = ref.read(receiptImportSessionProvider.notifier); - for (var i = 0; i < items.length; i++) { - notifier.setSelected(i, _selectedCount < items.length); - } + notifier.setSelectedForAll(items.length, _selectedCount < items.length); }), child: Text(_selectedCount < items.length ? 'Välj alla' : 'Avmarkera alla'), ), ], ), const SizedBox(height: 4), - ...List.generate(items.length, (i) { - final item = items[i]; - final edit = _edits[i]; - final isChecked = _selected[i] ?? false; - final hasProduct = edit?.productId != null; - final isMatched = item.matchedProductId != null; - final isSuggested = item.suggestedProductId != null && item.matchedProductId == null; - final existingInv = edit?.productId != null && edit?.destination != _Destination.pantry - ? _inventoryByProduct[edit!.productId] - : null; - final inferredForPreview = inferPackageFields( - rawName: item.rawName, - quantity: edit?.quantity ?? item.quantity, - unit: edit?.unit ?? item.unit, - ); - final previewPackageCount = edit?.packageCount ?? inferredForPreview.packageCount; - final previewPackQuantity = edit?.packQuantity ?? inferredForPreview.packQuantity; - final previewIncomingQty = previewPackQuantity != null - ? (previewPackQuantity * previewPackageCount) - : (edit?.quantity ?? inferredForPreview.totalQuantity ?? item.quantity ?? 0); - final previewIncomingUnit = edit?.packUnit ?? - inferredForPreview.packUnit ?? - edit?.unit ?? - item.unit ?? - 'st'; - final convertedPreviewQty = existingInv == null - ? null - : convertQuantity( - previewIncomingQty, - previewIncomingUnit, - existingInv.unit, - ); - final canMergePreview = existingInv != null && convertedPreviewQty != null; - final alreadyInPantry = edit?.productId != null && edit?.destination == _Destination.pantry - ? _pantryProductIds.contains(edit!.productId) - : false; - - return Card( - margin: const EdgeInsets.symmetric(vertical: 3), - child: ListTile( - leading: Checkbox( - value: isChecked, - onChanged: (v) { - ref.read(receiptImportSessionProvider.notifier).setSelected(i, v ?? false); - setState(() {}); + SizedBox( + height: resultListHeight, + child: ListView.builder( + key: const PageStorageKey('receipt-import-result-list'), + itemCount: items.length, + itemBuilder: (context, i) { + return _ReceiptImportResultRow( + index: i, + item: items[i], + edit: _edits[i], + existingInventoryByProduct: _inventoryByProduct, + pantryProductIds: _pantryProductIds, + onCheckedChanged: (v) { + ref.read(receiptImportSessionProvider.notifier).setSelected(i, v); }, - ), - title: Text( - normalizeProductName(item.rawName), - style: theme.textTheme.bodyMedium, - ), - subtitle: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - [ - if ((edit?.quantity ?? item.quantity) != null) - '${edit?.quantity ?? item.quantity}', - if ((edit?.unit ?? item.unit) != null) - edit?.unit ?? item.unit!, - if (item.price != null) '· ${item.price} kr', - ].join(' '), - style: theme.textTheme.bodySmall, - ), - const SizedBox(height: 2), - if (hasProduct) - Wrap( - spacing: 6, - runSpacing: 4, - crossAxisAlignment: WrapCrossAlignment.center, - children: [ - Text( - 'Produktnamn: ${normalizeProductName(edit!.productName ?? '')}', - style: theme.textTheme.bodySmall?.copyWith( - color: isMatched ? Colors.green.shade700 : theme.colorScheme.primary, - fontWeight: FontWeight.w500, - ), - ), - _buildMatchedViaBadge(item, theme), - if (edit.categorySource != null) - Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), - decoration: BoxDecoration( - color: edit.categorySource == CategorySelectionSource.ai - ? Colors.green.shade50 - : theme.colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(999), - border: Border.all( - color: edit.categorySource == CategorySelectionSource.ai - ? Colors.green.shade300 - : theme.colorScheme.outlineVariant, - ), - ), - child: Text( - edit.categorySource == CategorySelectionSource.ai ? 'AI' : 'Manuell', - style: theme.textTheme.labelSmall?.copyWith( - color: edit.categorySource == CategorySelectionSource.ai - ? Colors.green.shade800 - : theme.colorScheme.onSurfaceVariant, - ), - ), - ), - ], - ) - else if (isSuggested) - Text('Namnförslag: ${normalizeProductName(item.suggestedProductName ?? '')}', - style: theme.textTheme.bodySmall?.copyWith(color: Colors.orange.shade700)) - else - Text('Ingen matchning ännu — tryck för att välja eller skapa produkt', - style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.tertiary)), - if (hasProduct && edit?.categoryPath != null) ...[ - const SizedBox(height: 2), - Text( - 'Kategori: ${edit!.categoryPath!}', - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ), - ], - if (!hasProduct && !isSuggested) ...[ - const SizedBox(height: 8), - Wrap( - spacing: 8, - runSpacing: 8, - children: [ - OutlinedButton.icon( - onPressed: () => _openEditDialog( - i, - initialEntryMode: ImportProductEntryMode.existing, - ), - icon: const Icon(Icons.search, size: 16), - label: const Text('Välj befintlig'), - style: OutlinedButton.styleFrom( - visualDensity: VisualDensity.compact, - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - ), - ), - OutlinedButton.icon( - onPressed: () => _openEditDialog( - i, - initialEntryMode: ImportProductEntryMode.create, - ), - icon: const Icon(Icons.add_box_outlined, size: 16), - label: const Text('Ny produkt'), - style: OutlinedButton.styleFrom( - visualDensity: VisualDensity.compact, - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - ), - ), - ], - ), - ], - if (existingInv != null && canMergePreview) ...[ - const SizedBox(height: 2), - Row(children: [ - Icon(Icons.kitchen_outlined, size: 12, color: Colors.blue.shade700), - const SizedBox(width: 3), - Text( - 'I lager: ${existingInv.quantity} ${existingInv.unit} → blir ${(existingInv.quantity + (convertedPreviewQty ?? 0)).toStringAsFixed(existingInv.quantity % 1 == 0 ? 0 : 2)} ${existingInv.unit}', - style: theme.textTheme.bodySmall?.copyWith(color: Colors.blue.shade700), - ), - ]), - ], - if (existingInv != null && !canMergePreview) ...[ - const SizedBox(height: 2), - Row(children: [ - Icon(Icons.info_outline, size: 12, color: Colors.orange.shade700), - const SizedBox(width: 3), - Text( - 'Finns i lager med annan enhet (${existingInv.unit}) - sparas som ny rad', - style: theme.textTheme.bodySmall?.copyWith(color: Colors.orange.shade700), - ), - ]), - ], - if (alreadyInPantry) ...[ - const SizedBox(height: 2), - Row(children: [ - Icon(Icons.inventory_2_outlined, size: 12, color: Colors.orange.shade700), - const SizedBox(width: 3), - Text('Finns redan i baslager', - style: theme.textTheme.bodySmall?.copyWith(color: Colors.orange.shade700)), - ]), - ], - ], - ), - trailing: Icon( - hasProduct ? Icons.check_circle : (isSuggested ? Icons.help_outline : Icons.error_outline), - color: hasProduct - ? Colors.green - : (isSuggested ? Colors.orange : theme.colorScheme.tertiary), - size: 20, - ), - onTap: () => _openEditDialog(i), - ), - ); - }), + onEditRequested: () => _openEditDialog(i), + onSelectExistingRequested: () => _openEditDialog( + i, + initialEntryMode: ImportProductEntryMode.existing, + ), + onCreateRequested: () => _openEditDialog( + i, + initialEntryMode: ImportProductEntryMode.create, + ), + matchedViaBadgeBuilder: _buildMatchedViaBadge, + ); + }, + ), + ), const SizedBox(height: 16), SizedBox( width: double.infinity, @@ -959,3 +799,235 @@ class _ReceiptImportTabState extends ConsumerState { } } +class _ReceiptImportResultRow extends ConsumerWidget { + final int index; + final ParsedReceiptItem item; + final _ItemEdit? edit; + final Map existingInventoryByProduct; + final Set pantryProductIds; + final ValueChanged onCheckedChanged; + final VoidCallback onEditRequested; + final VoidCallback onSelectExistingRequested; + final VoidCallback onCreateRequested; + final Widget Function(ParsedReceiptItem item, ThemeData theme) + matchedViaBadgeBuilder; + + const _ReceiptImportResultRow({ + required this.index, + required this.item, + required this.edit, + required this.existingInventoryByProduct, + required this.pantryProductIds, + required this.onCheckedChanged, + required this.onEditRequested, + required this.onSelectExistingRequested, + required this.onCreateRequested, + required this.matchedViaBadgeBuilder, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final isChecked = ref.watch( + receiptImportSessionProvider.select((s) => s?.selected[index] ?? false), + ); + final theme = Theme.of(context); + final hasProduct = edit?.productId != null; + final isMatched = item.matchedProductId != null; + final isSuggested = + item.suggestedProductId != null && item.matchedProductId == null; + final existingInv = edit?.productId != null && edit?.destination != _Destination.pantry + ? existingInventoryByProduct[edit!.productId] + : null; + final inferredForPreview = inferPackageFields( + rawName: item.rawName, + quantity: edit?.quantity ?? item.quantity, + unit: edit?.unit ?? item.unit, + ); + final previewPackageCount = + edit?.packageCount ?? inferredForPreview.packageCount; + final previewPackQuantity = + edit?.packQuantity ?? inferredForPreview.packQuantity; + final previewIncomingQty = previewPackQuantity != null + ? (previewPackQuantity * previewPackageCount) + : (edit?.quantity ?? inferredForPreview.totalQuantity ?? item.quantity ?? 0); + final previewIncomingUnit = edit?.packUnit ?? + inferredForPreview.packUnit ?? + edit?.unit ?? + item.unit ?? + 'st'; + final convertedPreviewQty = existingInv == null + ? null + : convertQuantity( + previewIncomingQty, + previewIncomingUnit, + existingInv.unit, + ); + final canMergePreview = existingInv != null && convertedPreviewQty != null; + final alreadyInPantry = edit?.productId != null && edit?.destination == _Destination.pantry + ? pantryProductIds.contains(edit!.productId) + : false; + + return Card( + margin: const EdgeInsets.symmetric(vertical: 3), + child: ListTile( + leading: Checkbox( + value: isChecked, + onChanged: (v) => onCheckedChanged(v ?? false), + ), + title: Text( + normalizeProductName(item.rawName), + style: theme.textTheme.bodyMedium, + ), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + [ + if ((edit?.quantity ?? item.quantity) != null) + '${edit?.quantity ?? item.quantity}', + if ((edit?.unit ?? item.unit) != null) edit?.unit ?? item.unit!, + if (item.price != null) '· ${item.price} kr', + ].join(' '), + style: theme.textTheme.bodySmall, + ), + const SizedBox(height: 2), + if (hasProduct) + Wrap( + spacing: 6, + runSpacing: 4, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + Text( + 'Produktnamn: ${normalizeProductName(edit!.productName ?? '')}', + style: theme.textTheme.bodySmall?.copyWith( + color: + isMatched ? Colors.green.shade700 : theme.colorScheme.primary, + fontWeight: FontWeight.w500, + ), + ), + matchedViaBadgeBuilder(item, theme), + if (edit!.categorySource != null) + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: edit!.categorySource == CategorySelectionSource.ai + ? Colors.green.shade50 + : theme.colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(999), + border: Border.all( + color: edit!.categorySource == CategorySelectionSource.ai + ? Colors.green.shade300 + : theme.colorScheme.outlineVariant, + ), + ), + child: Text( + edit!.categorySource == CategorySelectionSource.ai + ? 'AI' + : 'Manuell', + style: theme.textTheme.labelSmall?.copyWith( + color: edit!.categorySource == CategorySelectionSource.ai + ? Colors.green.shade800 + : theme.colorScheme.onSurfaceVariant, + ), + ), + ), + ], + ) + else if (isSuggested) + Text( + 'Namnförslag: ${normalizeProductName(item.suggestedProductName ?? '')}', + style: theme.textTheme.bodySmall + ?.copyWith(color: Colors.orange.shade700), + ) + else + Text( + 'Ingen matchning ännu — tryck för att välja eller skapa produkt', + style: theme.textTheme.bodySmall + ?.copyWith(color: theme.colorScheme.tertiary), + ), + if (hasProduct && edit?.categoryPath != null) ...[ + const SizedBox(height: 2), + Text( + 'Kategori: ${edit!.categoryPath!}', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + if (!hasProduct && !isSuggested) ...[ + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + OutlinedButton.icon( + onPressed: onSelectExistingRequested, + icon: const Icon(Icons.search, size: 16), + label: const Text('Välj befintlig'), + style: OutlinedButton.styleFrom( + visualDensity: VisualDensity.compact, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + ), + OutlinedButton.icon( + onPressed: onCreateRequested, + icon: const Icon(Icons.add_box_outlined, size: 16), + label: const Text('Ny produkt'), + style: OutlinedButton.styleFrom( + visualDensity: VisualDensity.compact, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + ), + ], + ), + ], + if (existingInv != null && canMergePreview) ...[ + const SizedBox(height: 2), + Row(children: [ + Icon(Icons.kitchen_outlined, size: 12, color: Colors.blue.shade700), + const SizedBox(width: 3), + Text( + 'I lager: ${existingInv.quantity} ${existingInv.unit} → blir ${(existingInv.quantity + (convertedPreviewQty ?? 0)).toStringAsFixed(existingInv.quantity % 1 == 0 ? 0 : 2)} ${existingInv.unit}', + style: theme.textTheme.bodySmall?.copyWith(color: Colors.blue.shade700), + ), + ]), + ], + if (existingInv != null && !canMergePreview) ...[ + const SizedBox(height: 2), + Row(children: [ + Icon(Icons.info_outline, size: 12, color: Colors.orange.shade700), + const SizedBox(width: 3), + Text( + 'Finns i lager med annan enhet (${existingInv.unit}) - sparas som ny rad', + style: theme.textTheme.bodySmall?.copyWith(color: Colors.orange.shade700), + ), + ]), + ], + if (alreadyInPantry) ...[ + const SizedBox(height: 2), + Row(children: [ + Icon(Icons.inventory_2_outlined, size: 12, color: Colors.orange.shade700), + const SizedBox(width: 3), + Text( + 'Finns redan i baslager', + style: theme.textTheme.bodySmall?.copyWith(color: Colors.orange.shade700), + ), + ]), + ], + ], + ), + trailing: Icon( + hasProduct + ? Icons.check_circle + : (isSuggested ? Icons.help_outline : Icons.error_outline), + color: hasProduct + ? Colors.green + : (isSuggested ? Colors.orange : theme.colorScheme.tertiary), + size: 20, + ), + onTap: onEditRequested, + ), + ); + } +} + diff --git a/flutter/lib/features/inventory/presentation/inventory_screen.dart b/flutter/lib/features/inventory/presentation/inventory_screen.dart index bf3aeff5..7d3705ea 100644 --- a/flutter/lib/features/inventory/presentation/inventory_screen.dart +++ b/flutter/lib/features/inventory/presentation/inventory_screen.dart @@ -99,6 +99,7 @@ class InventoryScreen extends ConsumerWidget { return Stack( children: [ ListView( + key: const PageStorageKey('inventory-empty-list'), padding: const EdgeInsets.only(bottom: 88), children: [ filterSection, @@ -109,6 +110,7 @@ class InventoryScreen extends ConsumerWidget { right: 16, bottom: 16, child: FloatingActionButton.extended( + heroTag: 'inventory_add_empty', onPressed: () => context.push('/inventory/create'), icon: const Icon(Icons.add), label: Text(context.l10n.addAction), @@ -120,6 +122,7 @@ class InventoryScreen extends ConsumerWidget { return Stack( children: [ ListView.separated( + key: const PageStorageKey('inventory-main-list'), padding: const EdgeInsets.only(bottom: 88), itemCount: visibleItems.length + 1, separatorBuilder: (_, __) => const Divider(height: 1), @@ -136,12 +139,14 @@ class InventoryScreen extends ConsumerWidget { mainAxisSize: MainAxisSize.min, children: [ FloatingActionButton.extended( + heroTag: 'inventory_add', onPressed: () => context.push('/inventory/create'), icon: const Icon(Icons.add), label: Text(context.l10n.addAction), ), const SizedBox(height: 8), FloatingActionButton.extended( + heroTag: 'inventory_go_recipes', onPressed: () => context.go('/recipes'), icon: const Icon(Icons.restaurant_menu), label: Text(context.l10n.inventoryRecipesAction), diff --git a/flutter/lib/features/pantry/presentation/pantry_screen.dart b/flutter/lib/features/pantry/presentation/pantry_screen.dart index 3090d786..3e9b87e4 100644 --- a/flutter/lib/features/pantry/presentation/pantry_screen.dart +++ b/flutter/lib/features/pantry/presentation/pantry_screen.dart @@ -325,6 +325,7 @@ class _PantryScreenState extends ConsumerState { final content = filteredItems.isEmpty ? ListView( + key: const PageStorageKey('pantry-empty-list'), padding: const EdgeInsets.fromLTRB(12, 0, 12, 96), children: [ filterSection, @@ -335,6 +336,7 @@ class _PantryScreenState extends ConsumerState { ], ) : ListView.separated( + key: const PageStorageKey('pantry-main-list'), padding: const EdgeInsets.fromLTRB(12, 0, 12, 96), itemCount: filteredItems.length + 1, separatorBuilder: (_, __) => const Divider(height: 1), @@ -384,12 +386,14 @@ class _PantryScreenState extends ConsumerState { mainAxisSize: MainAxisSize.min, children: [ FloatingActionButton.extended( + heroTag: 'pantry_add', onPressed: () => context.push('/inventory/create?destination=pantry'), icon: const Icon(Icons.add), label: Text(context.l10n.addAction), ), const SizedBox(height: 8), FloatingActionButton.extended( + heroTag: 'pantry_go_recipes', onPressed: () => context.go('/recipes'), icon: const Icon(Icons.restaurant_menu), label: Text(context.l10n.inventoryRecipesAction), diff --git a/flutter/lib/features/recipes/presentation/recipes_screen.dart b/flutter/lib/features/recipes/presentation/recipes_screen.dart index 78be884f..a10cacb7 100644 --- a/flutter/lib/features/recipes/presentation/recipes_screen.dart +++ b/flutter/lib/features/recipes/presentation/recipes_screen.dart @@ -38,6 +38,7 @@ class RecipesScreen extends ConsumerWidget { if (view.mode == RecipesViewMode.grid) { return GridView.builder( + key: PageStorageKey('recipes-grid-${view.columns}'), padding: const EdgeInsets.only(bottom: 88), gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: view.columns, @@ -55,6 +56,7 @@ class RecipesScreen extends ConsumerWidget { ); } else { return ListView.builder( + key: const PageStorageKey('recipes-list'), padding: const EdgeInsets.only(bottom: 88), itemCount: recipes.length, itemBuilder: (context, index) {