feat: Refactor routing and navigation structure with StatefulShellRoute
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:
Nils-Johan Gynther
2026-05-08 12:51:38 +02:00
parent 73309cb110
commit 0873fa42bb
12 changed files with 625 additions and 285 deletions
+94 -35
View File
@@ -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<GoRouter>((ref) {
final authState = ref.watch(authStateProvider);
@@ -175,42 +186,90 @@ final appRouterProvider = Provider<GoRouter>((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(),
),
],
),
],
),
+4 -2
View File
@@ -21,11 +21,13 @@ const _adminHeaderDestination = _AppDestination(
class AppShell extends ConsumerWidget {
final String location;
final ValueChanged<String> 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');
}
}
},
@@ -166,6 +166,49 @@ class _AdminAliasesPanelState extends ConsumerState<AdminAliasesPanel> {
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<AdminAliasesPanel> {
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()),
],
);
}
}
@@ -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),
],
);
}
@@ -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(
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<AdminUsersPanel> {
label: Text(context.l10n.adminNewUser),
),
const SizedBox(height: 16),
list,
Expanded(child: list),
],
);
}
@@ -186,6 +186,20 @@ class ReceiptImportSessionNotifier
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) {
if (state == null) return;
final edits = Map<int, ItemEdit>.from(state!.edits)..[index] = edit;
@@ -200,6 +214,25 @@ class ReceiptImportSessionNotifier
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 {
final prefs = await SharedPreferences.getInstance();
final raw = prefs.getString(_storageKey);
@@ -292,12 +292,13 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
);
if (!mounted) return;
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
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<ReceiptImportTab> {
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<ReceiptImportTab> {
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<ReceiptImportTab> {
);
// 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<ReceiptImportTab> {
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<ReceiptImportTab> {
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<String>('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<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(
children: [
ListView(
key: const PageStorageKey<String>('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<String>('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),
@@ -325,6 +325,7 @@ class _PantryScreenState extends ConsumerState<PantryScreen> {
final content = filteredItems.isEmpty
? ListView(
key: const PageStorageKey<String>('pantry-empty-list'),
padding: const EdgeInsets.fromLTRB(12, 0, 12, 96),
children: [
filterSection,
@@ -335,6 +336,7 @@ class _PantryScreenState extends ConsumerState<PantryScreen> {
],
)
: ListView.separated(
key: const PageStorageKey<String>('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<PantryScreen> {
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),
@@ -38,6 +38,7 @@ class RecipesScreen extends ConsumerWidget {
if (view.mode == RecipesViewMode.grid) {
return GridView.builder(
key: PageStorageKey<String>('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<String>('recipes-list'),
padding: const EdgeInsets.only(bottom: 88),
itemCount: recipes.length,
itemBuilder: (context, index) {