feat: Refactor routing and navigation structure with StatefulShellRoute
Test Suite / test (24.15.0) (push) Has been cancelled
Test Suite / test (24.15.0) (push) Has been cancelled
- Introduced a new function `_shellBranchIndexForPath` to determine the index of the shell branch based on the path. - Replaced `ShellRoute` with `StatefulShellRoute.indexedStack` for better state management during navigation. - Updated `AppShell` to handle navigation path changes and integrate with the new routing structure. - Organized routes into `StatefulShellBranch` for better modularity and clarity. - Enhanced admin panel functionality with improved alias management and UI updates. - Added new methods in `ReceiptImportSessionNotifier` for managing selected items and edits more efficiently. - Improved UI components in receipt import and admin panels for better performance and user experience. - Added PageStorageKeys to various ListViews to maintain scroll positions across navigation. - Documented performance goals and profiling strategies in a new PERFORMANCE.md file.
This commit is contained in:
@@ -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
|
||||||
@@ -24,6 +24,17 @@ import '../../features/pantry/presentation/pantry_screen.dart';
|
|||||||
import '../../features/import/presentation/import_screen.dart';
|
import '../../features/import/presentation/import_screen.dart';
|
||||||
import '../../features/admin/presentation/admin_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<GoRouter>((ref) {
|
final appRouterProvider = Provider<GoRouter>((ref) {
|
||||||
final authState = ref.watch(authStateProvider);
|
final authState = ref.watch(authStateProvider);
|
||||||
|
|
||||||
@@ -175,42 +186,90 @@ final appRouterProvider = Provider<GoRouter>((ref) {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
// Shell routes — shared AppShell with navigation bar.
|
// Shell routes — shared AppShell with navigation bar.
|
||||||
ShellRoute(
|
StatefulShellRoute.indexedStack(
|
||||||
builder: (context, state, child) {
|
builder: (context, state, navigationShell) {
|
||||||
return AppShell(location: state.uri.path, child: child);
|
return AppShell(
|
||||||
},
|
location: state.uri.path,
|
||||||
routes: [
|
onNavigateToPath: (path) {
|
||||||
GoRoute(
|
final index = _shellBranchIndexForPath(path);
|
||||||
path: '/recipes',
|
if (index == null) {
|
||||||
builder: (context, state) => const RecipesScreen(),
|
context.go(path);
|
||||||
),
|
return;
|
||||||
GoRoute(
|
}
|
||||||
path: '/inventory',
|
|
||||||
builder: (context, state) => const InventoryScreen(),
|
if (index == navigationShell.currentIndex) {
|
||||||
),
|
if (state.uri.path != path) {
|
||||||
GoRoute(
|
context.go(path);
|
||||||
path: '/matsedel',
|
}
|
||||||
builder: (context, state) => const MealPlanScreen(),
|
return;
|
||||||
),
|
}
|
||||||
GoRoute(
|
|
||||||
path: '/baslager',
|
navigationShell.goBranch(index);
|
||||||
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';
|
|
||||||
},
|
},
|
||||||
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(),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -21,11 +21,13 @@ const _adminHeaderDestination = _AppDestination(
|
|||||||
|
|
||||||
class AppShell extends ConsumerWidget {
|
class AppShell extends ConsumerWidget {
|
||||||
final String location;
|
final String location;
|
||||||
|
final ValueChanged<String> onNavigateToPath;
|
||||||
final Widget child;
|
final Widget child;
|
||||||
|
|
||||||
const AppShell({
|
const AppShell({
|
||||||
super.key,
|
super.key,
|
||||||
required this.location,
|
required this.location,
|
||||||
|
required this.onNavigateToPath,
|
||||||
required this.child,
|
required this.child,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -101,7 +103,7 @@ class AppShell extends ConsumerWidget {
|
|||||||
void navigateTo(int index) {
|
void navigateTo(int index) {
|
||||||
final target = dests[index].path;
|
final target = dests[index].path;
|
||||||
if (target != location && context.mounted) {
|
if (target != location && context.mounted) {
|
||||||
context.go(target);
|
onNavigateToPath(target);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,7 +180,7 @@ class AppShell extends ConsumerWidget {
|
|||||||
switch (value) {
|
switch (value) {
|
||||||
case 'profile':
|
case 'profile':
|
||||||
if (location != '/profile' && context.mounted) {
|
if (location != '/profile' && context.mounted) {
|
||||||
context.go('/profile');
|
onNavigateToPath('/profile');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -166,6 +166,49 @@ class _AdminAliasesPanelState extends ConsumerState<AdminAliasesPanel> {
|
|||||||
alias.displayProductName.toLowerCase().contains(query);
|
alias.displayProductName.toLowerCase().contains(query);
|
||||||
}).toList();
|
}).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(
|
final content = Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
@@ -230,51 +273,37 @@ class _AdminAliasesPanelState extends ConsumerState<AdminAliasesPanel> {
|
|||||||
onChanged: (value) => setState(() => _search = value),
|
onChanged: (value) => setState(() => _search = value),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
if (filteredAliases.isEmpty)
|
if (filteredAliases.isEmpty) const Text('Inga alias hittades.'),
|
||||||
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 (!widget.embedded) {
|
if (!widget.embedded) {
|
||||||
|
if (filteredAliases.isEmpty) {
|
||||||
|
return ListView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
children: [content],
|
||||||
|
);
|
||||||
|
}
|
||||||
return ListView(
|
return ListView(
|
||||||
padding: const EdgeInsets.all(16),
|
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()),
|
||||||
|
],
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -93,8 +93,8 @@ class _AdminPendingProductsPanelState
|
|||||||
}
|
}
|
||||||
|
|
||||||
final content = ListView.builder(
|
final content = ListView.builder(
|
||||||
shrinkWrap: widget.embedded,
|
shrinkWrap: false,
|
||||||
physics: widget.embedded ? const NeverScrollableScrollPhysics() : null,
|
physics: null,
|
||||||
itemCount: _products.length,
|
itemCount: _products.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final product = _products[index];
|
final product = _products[index];
|
||||||
@@ -148,7 +148,7 @@ class _AdminPendingProductsPanelState
|
|||||||
style: theme.textTheme.bodyMedium,
|
style: theme.textTheme.bodyMedium,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
content,
|
Expanded(child: content),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -738,7 +738,10 @@ class _AdminProductsPanelState extends ConsumerState<AdminProductsPanel> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return content;
|
return ListView(
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
children: [content],
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -328,10 +328,8 @@ class _AdminUsersPanelState extends ConsumerState<AdminUsersPanel> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final list = ListView.builder(
|
final list = ListView.builder(
|
||||||
shrinkWrap: widget.embedded,
|
shrinkWrap: false,
|
||||||
physics: widget.embedded
|
physics: null,
|
||||||
? const NeverScrollableScrollPhysics()
|
|
||||||
: null,
|
|
||||||
padding: widget.embedded
|
padding: widget.embedded
|
||||||
? EdgeInsets.zero
|
? EdgeInsets.zero
|
||||||
: const EdgeInsets.fromLTRB(16, 8, 16, 80),
|
: const EdgeInsets.fromLTRB(16, 8, 16, 80),
|
||||||
@@ -376,7 +374,7 @@ class _AdminUsersPanelState extends ConsumerState<AdminUsersPanel> {
|
|||||||
label: Text(context.l10n.adminNewUser),
|
label: Text(context.l10n.adminNewUser),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
list,
|
Expanded(child: list),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -186,6 +186,20 @@ class ReceiptImportSessionNotifier
|
|||||||
unawaited(_persist());
|
unawaited(_persist());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void setImportedResult({
|
||||||
|
required List<ParsedReceiptItem> items,
|
||||||
|
required Map<int, ItemEdit> edits,
|
||||||
|
required Map<int, bool> selected,
|
||||||
|
}) {
|
||||||
|
final current = state ?? const ReceiptImportSession();
|
||||||
|
state = current.copyWith(
|
||||||
|
items: items,
|
||||||
|
edits: edits,
|
||||||
|
selected: selected,
|
||||||
|
);
|
||||||
|
unawaited(_persist());
|
||||||
|
}
|
||||||
|
|
||||||
void setEdit(int index, ItemEdit edit) {
|
void setEdit(int index, ItemEdit edit) {
|
||||||
if (state == null) return;
|
if (state == null) return;
|
||||||
final edits = Map<int, ItemEdit>.from(state!.edits)..[index] = edit;
|
final edits = Map<int, ItemEdit>.from(state!.edits)..[index] = edit;
|
||||||
@@ -200,6 +214,25 @@ class ReceiptImportSessionNotifier
|
|||||||
unawaited(_persist());
|
unawaited(_persist());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void setSelectedForIndexes(Iterable<int> indexes, bool value) {
|
||||||
|
if (state == null) return;
|
||||||
|
final selected = Map<int, bool>.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 = <int, bool>{
|
||||||
|
for (var i = 0; i < count; i++) i: value,
|
||||||
|
};
|
||||||
|
state = state!.copyWith(selected: selected);
|
||||||
|
unawaited(_persist());
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> restore() async {
|
Future<void> restore() async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
final raw = prefs.getString(_storageKey);
|
final raw = prefs.getString(_storageKey);
|
||||||
|
|||||||
@@ -292,12 +292,13 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
|||||||
);
|
);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
final notifier = ref.read(receiptImportSessionProvider.notifier);
|
final notifier = ref.read(receiptImportSessionProvider.notifier);
|
||||||
notifier.setItems(items);
|
final nextEdits = <int, _ItemEdit>{};
|
||||||
|
final nextSelected = <int, bool>{};
|
||||||
// Förmarkera rader som har en träff
|
// Förmarkera rader som har en träff
|
||||||
for (var i = 0; i < items.length; i++) {
|
for (var i = 0; i < items.length; i++) {
|
||||||
final it = items[i];
|
final it = items[i];
|
||||||
final pid = it.matchedProductId ?? it.suggestedProductId;
|
final pid = it.matchedProductId ?? it.suggestedProductId;
|
||||||
notifier.setSelected(i, pid != null);
|
nextSelected[i] = pid != null;
|
||||||
if (pid != null) {
|
if (pid != null) {
|
||||||
final inferred = inferPackageFields(
|
final inferred = inferPackageFields(
|
||||||
rawName: it.rawName,
|
rawName: it.rawName,
|
||||||
@@ -308,7 +309,7 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
|||||||
final resolvedCategoryId = it.categorySuggestionId ?? _categoryIdForProduct(pid);
|
final resolvedCategoryId = it.categorySuggestionId ?? _categoryIdForProduct(pid);
|
||||||
final resolvedCategoryPath = it.categorySuggestionPath ??
|
final resolvedCategoryPath = it.categorySuggestionPath ??
|
||||||
_lookup.pathFor(resolvedCategoryId);
|
_lookup.pathFor(resolvedCategoryId);
|
||||||
notifier.setEdit(i, _ItemEdit(
|
nextEdits[i] = _ItemEdit(
|
||||||
productId: pid,
|
productId: pid,
|
||||||
productName: name,
|
productName: name,
|
||||||
categoryId: resolvedCategoryId,
|
categoryId: resolvedCategoryId,
|
||||||
@@ -321,9 +322,14 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
|||||||
packQuantity: inferred.packQuantity,
|
packQuantity: inferred.packQuantity,
|
||||||
packUnit: inferred.packUnit,
|
packUnit: inferred.packUnit,
|
||||||
packageCount: inferred.packageCount,
|
packageCount: inferred.packageCount,
|
||||||
));
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
notifier.setImportedResult(
|
||||||
|
items: items,
|
||||||
|
edits: nextEdits,
|
||||||
|
selected: nextSelected,
|
||||||
|
);
|
||||||
// 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) {
|
||||||
@@ -573,7 +579,7 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
|||||||
);
|
);
|
||||||
// Avmarkera sparade rader och uppdatera inventariet
|
// Avmarkera sparade rader och uppdatera inventariet
|
||||||
final notifier = ref.read(receiptImportSessionProvider.notifier);
|
final notifier = ref.read(receiptImportSessionProvider.notifier);
|
||||||
for (final i in toAdd) notifier.setSelected(i, false);
|
notifier.setSelectedForIndexes(toAdd, false);
|
||||||
setState(() {});
|
setState(() {});
|
||||||
await _loadInventory();
|
await _loadInventory();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -678,6 +684,9 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
|||||||
final selectedFileName = _pickedFile?.name ?? session?.fileName;
|
final selectedFileName = _pickedFile?.name ?? session?.fileName;
|
||||||
final selectedFileSizeBytes =
|
final selectedFileSizeBytes =
|
||||||
_pickedFile?.size ?? session?.fileBytes?.length;
|
_pickedFile?.size ?? session?.fileBytes?.length;
|
||||||
|
final resultListHeight = items == null
|
||||||
|
? 0.0
|
||||||
|
: (items.length * 128.0).clamp(220.0, 620.0).toDouble();
|
||||||
|
|
||||||
return SingleChildScrollView(
|
return SingleChildScrollView(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
@@ -736,211 +745,42 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
|||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => setState(() {
|
onPressed: () => setState(() {
|
||||||
final notifier = ref.read(receiptImportSessionProvider.notifier);
|
final notifier = ref.read(receiptImportSessionProvider.notifier);
|
||||||
for (var i = 0; i < items.length; i++) {
|
notifier.setSelectedForAll(items.length, _selectedCount < items.length);
|
||||||
notifier.setSelected(i, _selectedCount < items.length);
|
|
||||||
}
|
|
||||||
}),
|
}),
|
||||||
child: Text(_selectedCount < items.length ? 'Välj alla' : 'Avmarkera alla'),
|
child: Text(_selectedCount < items.length ? 'Välj alla' : 'Avmarkera alla'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
...List.generate(items.length, (i) {
|
SizedBox(
|
||||||
final item = items[i];
|
height: resultListHeight,
|
||||||
final edit = _edits[i];
|
child: ListView.builder(
|
||||||
final isChecked = _selected[i] ?? false;
|
key: const PageStorageKey<String>('receipt-import-result-list'),
|
||||||
final hasProduct = edit?.productId != null;
|
itemCount: items.length,
|
||||||
final isMatched = item.matchedProductId != null;
|
itemBuilder: (context, i) {
|
||||||
final isSuggested = item.suggestedProductId != null && item.matchedProductId == null;
|
return _ReceiptImportResultRow(
|
||||||
final existingInv = edit?.productId != null && edit?.destination != _Destination.pantry
|
index: i,
|
||||||
? _inventoryByProduct[edit!.productId]
|
item: items[i],
|
||||||
: null;
|
edit: _edits[i],
|
||||||
final inferredForPreview = inferPackageFields(
|
existingInventoryByProduct: _inventoryByProduct,
|
||||||
rawName: item.rawName,
|
pantryProductIds: _pantryProductIds,
|
||||||
quantity: edit?.quantity ?? item.quantity,
|
onCheckedChanged: (v) {
|
||||||
unit: edit?.unit ?? item.unit,
|
ref.read(receiptImportSessionProvider.notifier).setSelected(i, v);
|
||||||
);
|
|
||||||
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(() {});
|
|
||||||
},
|
},
|
||||||
),
|
onEditRequested: () => _openEditDialog(i),
|
||||||
title: Text(
|
onSelectExistingRequested: () => _openEditDialog(
|
||||||
normalizeProductName(item.rawName),
|
i,
|
||||||
style: theme.textTheme.bodyMedium,
|
initialEntryMode: ImportProductEntryMode.existing,
|
||||||
),
|
),
|
||||||
subtitle: Column(
|
onCreateRequested: () => _openEditDialog(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
i,
|
||||||
children: [
|
initialEntryMode: ImportProductEntryMode.create,
|
||||||
Text(
|
),
|
||||||
[
|
matchedViaBadgeBuilder: _buildMatchedViaBadge,
|
||||||
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),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
@@ -959,3 +799,235 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _ReceiptImportResultRow extends ConsumerWidget {
|
||||||
|
final int index;
|
||||||
|
final ParsedReceiptItem item;
|
||||||
|
final _ItemEdit? edit;
|
||||||
|
final Map<int, InventoryItem> existingInventoryByProduct;
|
||||||
|
final Set<int> pantryProductIds;
|
||||||
|
final ValueChanged<bool> 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,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -99,6 +99,7 @@ class InventoryScreen extends ConsumerWidget {
|
|||||||
return Stack(
|
return Stack(
|
||||||
children: [
|
children: [
|
||||||
ListView(
|
ListView(
|
||||||
|
key: const PageStorageKey<String>('inventory-empty-list'),
|
||||||
padding: const EdgeInsets.only(bottom: 88),
|
padding: const EdgeInsets.only(bottom: 88),
|
||||||
children: [
|
children: [
|
||||||
filterSection,
|
filterSection,
|
||||||
@@ -109,6 +110,7 @@ class InventoryScreen extends ConsumerWidget {
|
|||||||
right: 16,
|
right: 16,
|
||||||
bottom: 16,
|
bottom: 16,
|
||||||
child: FloatingActionButton.extended(
|
child: FloatingActionButton.extended(
|
||||||
|
heroTag: 'inventory_add_empty',
|
||||||
onPressed: () => context.push('/inventory/create'),
|
onPressed: () => context.push('/inventory/create'),
|
||||||
icon: const Icon(Icons.add),
|
icon: const Icon(Icons.add),
|
||||||
label: Text(context.l10n.addAction),
|
label: Text(context.l10n.addAction),
|
||||||
@@ -120,6 +122,7 @@ class InventoryScreen extends ConsumerWidget {
|
|||||||
return Stack(
|
return Stack(
|
||||||
children: [
|
children: [
|
||||||
ListView.separated(
|
ListView.separated(
|
||||||
|
key: const PageStorageKey<String>('inventory-main-list'),
|
||||||
padding: const EdgeInsets.only(bottom: 88),
|
padding: const EdgeInsets.only(bottom: 88),
|
||||||
itemCount: visibleItems.length + 1,
|
itemCount: visibleItems.length + 1,
|
||||||
separatorBuilder: (_, __) => const Divider(height: 1),
|
separatorBuilder: (_, __) => const Divider(height: 1),
|
||||||
@@ -136,12 +139,14 @@ class InventoryScreen extends ConsumerWidget {
|
|||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
FloatingActionButton.extended(
|
FloatingActionButton.extended(
|
||||||
|
heroTag: 'inventory_add',
|
||||||
onPressed: () => context.push('/inventory/create'),
|
onPressed: () => context.push('/inventory/create'),
|
||||||
icon: const Icon(Icons.add),
|
icon: const Icon(Icons.add),
|
||||||
label: Text(context.l10n.addAction),
|
label: Text(context.l10n.addAction),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
FloatingActionButton.extended(
|
FloatingActionButton.extended(
|
||||||
|
heroTag: 'inventory_go_recipes',
|
||||||
onPressed: () => context.go('/recipes'),
|
onPressed: () => context.go('/recipes'),
|
||||||
icon: const Icon(Icons.restaurant_menu),
|
icon: const Icon(Icons.restaurant_menu),
|
||||||
label: Text(context.l10n.inventoryRecipesAction),
|
label: Text(context.l10n.inventoryRecipesAction),
|
||||||
|
|||||||
@@ -325,6 +325,7 @@ class _PantryScreenState extends ConsumerState<PantryScreen> {
|
|||||||
|
|
||||||
final content = filteredItems.isEmpty
|
final content = filteredItems.isEmpty
|
||||||
? ListView(
|
? ListView(
|
||||||
|
key: const PageStorageKey<String>('pantry-empty-list'),
|
||||||
padding: const EdgeInsets.fromLTRB(12, 0, 12, 96),
|
padding: const EdgeInsets.fromLTRB(12, 0, 12, 96),
|
||||||
children: [
|
children: [
|
||||||
filterSection,
|
filterSection,
|
||||||
@@ -335,6 +336,7 @@ class _PantryScreenState extends ConsumerState<PantryScreen> {
|
|||||||
],
|
],
|
||||||
)
|
)
|
||||||
: ListView.separated(
|
: ListView.separated(
|
||||||
|
key: const PageStorageKey<String>('pantry-main-list'),
|
||||||
padding: const EdgeInsets.fromLTRB(12, 0, 12, 96),
|
padding: const EdgeInsets.fromLTRB(12, 0, 12, 96),
|
||||||
itemCount: filteredItems.length + 1,
|
itemCount: filteredItems.length + 1,
|
||||||
separatorBuilder: (_, __) => const Divider(height: 1),
|
separatorBuilder: (_, __) => const Divider(height: 1),
|
||||||
@@ -384,12 +386,14 @@ class _PantryScreenState extends ConsumerState<PantryScreen> {
|
|||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
FloatingActionButton.extended(
|
FloatingActionButton.extended(
|
||||||
|
heroTag: 'pantry_add',
|
||||||
onPressed: () => context.push('/inventory/create?destination=pantry'),
|
onPressed: () => context.push('/inventory/create?destination=pantry'),
|
||||||
icon: const Icon(Icons.add),
|
icon: const Icon(Icons.add),
|
||||||
label: Text(context.l10n.addAction),
|
label: Text(context.l10n.addAction),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
FloatingActionButton.extended(
|
FloatingActionButton.extended(
|
||||||
|
heroTag: 'pantry_go_recipes',
|
||||||
onPressed: () => context.go('/recipes'),
|
onPressed: () => context.go('/recipes'),
|
||||||
icon: const Icon(Icons.restaurant_menu),
|
icon: const Icon(Icons.restaurant_menu),
|
||||||
label: Text(context.l10n.inventoryRecipesAction),
|
label: Text(context.l10n.inventoryRecipesAction),
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ class RecipesScreen extends ConsumerWidget {
|
|||||||
|
|
||||||
if (view.mode == RecipesViewMode.grid) {
|
if (view.mode == RecipesViewMode.grid) {
|
||||||
return GridView.builder(
|
return GridView.builder(
|
||||||
|
key: PageStorageKey<String>('recipes-grid-${view.columns}'),
|
||||||
padding: const EdgeInsets.only(bottom: 88),
|
padding: const EdgeInsets.only(bottom: 88),
|
||||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
crossAxisCount: view.columns,
|
crossAxisCount: view.columns,
|
||||||
@@ -55,6 +56,7 @@ class RecipesScreen extends ConsumerWidget {
|
|||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
return ListView.builder(
|
return ListView.builder(
|
||||||
|
key: const PageStorageKey<String>('recipes-list'),
|
||||||
padding: const EdgeInsets.only(bottom: 88),
|
padding: const EdgeInsets.only(bottom: 88),
|
||||||
itemCount: recipes.length,
|
itemCount: recipes.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
|
|||||||
Reference in New Issue
Block a user