From 3e0af925d58d3596e75ce8d6d6f4c9b5352f500c Mon Sep 17 00:00:00 2001 From: Nils-Johan Gynther Date: Mon, 11 May 2026 21:09:40 +0200 Subject: [PATCH] feat: Refactor inventory screens with category selection and product handling improvements --- flutter/lib/core/utils/display_labels.dart | 9 ++ .../presentation/create_inventory_screen.dart | 103 ++++++++--------- .../inventory_category_helpers.dart | 78 +++++++++++++ .../inventory_category_product_section.dart | 85 ++++++++++++++ .../presentation/inventory_edit_screen.dart | 105 +++++++++--------- .../swipeable_inventory_tile.dart | 41 ++++--- .../pantry/presentation/pantry_screen.dart | 43 ++++--- 7 files changed, 319 insertions(+), 145 deletions(-) create mode 100644 flutter/lib/core/utils/display_labels.dart create mode 100644 flutter/lib/features/inventory/presentation/inventory_category_helpers.dart create mode 100644 flutter/lib/features/inventory/presentation/inventory_category_product_section.dart diff --git a/flutter/lib/core/utils/display_labels.dart b/flutter/lib/core/utils/display_labels.dart new file mode 100644 index 00000000..88088b06 --- /dev/null +++ b/flutter/lib/core/utils/display_labels.dart @@ -0,0 +1,9 @@ +String? normalizedOptionalText(String? value) { + final normalized = value?.trim(); + if (normalized == null || normalized.isEmpty) return null; + return normalized; +} + +String l1CategoryChipLabel(String prefix, String l1Category) => '$prefix$l1Category'; + +String locationLabel(String prefix, String location) => '$prefix$location'; diff --git a/flutter/lib/features/inventory/presentation/create_inventory_screen.dart b/flutter/lib/features/inventory/presentation/create_inventory_screen.dart index bd98855f..e2690d9e 100644 --- a/flutter/lib/features/inventory/presentation/create_inventory_screen.dart +++ b/flutter/lib/features/inventory/presentation/create_inventory_screen.dart @@ -9,13 +9,15 @@ import '../../../core/api/api_providers.dart'; import '../../../core/forms/form_options.dart'; import '../../../core/l10n/l10n.dart'; import '../../../core/ui/category_then_product_picker.dart'; -import '../../../core/ui/product_picker_field.dart'; -import '../../../core/ui/searchable_category_field.dart'; +import '../../../core/ui/product_picker_field.dart' show ProductOption; +import '../../../core/ui/searchable_category_field.dart' show CategorySelectOption; import '../../auth/data/auth_providers.dart'; import '../../admin/domain/admin_category_node.dart'; import '../../pantry/data/pantry_providers.dart'; import '../data/inventory_providers.dart'; import '../../import/data/receipt_import_session.dart' show ImportDestination; +import 'inventory_category_helpers.dart'; +import 'inventory_category_product_section.dart'; class CreateInventoryScreen extends ConsumerStatefulWidget { final String? initialDestination; @@ -41,6 +43,8 @@ class _CreateInventoryScreenState List> _products = []; List _categoryTree = []; List _categoryOptions = []; + InventoryCategoryBranchIndex? _categoryBranchIndex; + final _sortedProductsCache = InventorySortedProductsCache(); bool _loadingProducts = false; DateTime? _purchaseDate; DateTime? _bestBeforeDate; @@ -95,6 +99,7 @@ class _CreateInventoryScreenState .map((e) => AdminCategoryNode.fromJson(Map.from(e as Map))) .toList(); _categoryOptions = _flattenCategoryOptions(_categoryTree); + _categoryBranchIndex = InventoryCategoryBranchIndex.fromTree(_categoryTree); _loadingProducts = false; }); } @@ -132,11 +137,23 @@ class _CreateInventoryScreenState return null; } + Set? _selectedCategoryBranchIds() { + final selectedId = _selectedCategoryId; + if (selectedId == null) return null; + return _categoryBranchIndex?.branchIdsFor(selectedId) ?? {selectedId}; + } + List _productOptions() { + final selectedCategoryIds = _selectedCategoryBranchIds(); final source = _selectedCategoryId == null ? _products : _products - .where((p) => (p['categoryId'] as num?)?.toInt() == _selectedCategoryId) + .where( + (p) => productInSelectedBranch( + productCategoryId: (p['categoryId'] as num?)?.toInt(), + selectedCategoryIds: selectedCategoryIds, + ), + ) .toList(); final selected = _selectedProduct(); @@ -146,13 +163,8 @@ class _CreateInventoryScreenState withSelected.add(selected); } - withSelected.sort((a, b) { - final aName = (a['canonicalName'] ?? a['name'] ?? '').toString().toLowerCase(); - final bName = (b['canonicalName'] ?? b['name'] ?? '').toString().toLowerCase(); - return aName.compareTo(bName); - }); - - return withSelected + final sorted = _sortedProductsCache.sort(withSelected); + return sorted .map( (p) => ( id: (p['id'] as num).toInt(), @@ -172,9 +184,13 @@ class _CreateInventoryScreenState if (selected == null || !mounted) return; setState(() { _selectedCategoryId = selected.id; + final selectedCategoryIds = _selectedCategoryBranchIds(); final current = _selectedProduct(); final currentCategoryId = (current?['categoryId'] as num?)?.toInt(); - if (currentCategoryId != _selectedCategoryId) { + if (!productInSelectedBranch( + productCategoryId: currentCategoryId, + selectedCategoryIds: selectedCategoryIds, + )) { _selectedProductId = null; } }); @@ -302,54 +318,41 @@ class _CreateInventoryScreenState : (selected) => setState(() => _destination = selected.first), ), const SizedBox(height: 16), - SearchableCategoryField( - options: _categoryOptions, - value: _selectedCategoryId?.toString(), - label: 'Kategori (sökbar)', - onChanged: (value) { + InventoryCategoryProductSection( + categoryOptions: _categoryOptions, + selectedCategoryId: _selectedCategoryId, + categorySearchLabel: 'Kategori (sökbar)', + onCategoryChanged: (value) { if (value == null) return; setState(() { _selectedCategoryId = int.tryParse(value); + final selectedCategoryIds = _selectedCategoryBranchIds(); final current = _selectedProduct(); final currentCategoryId = (current?['categoryId'] as num?)?.toInt(); - if (currentCategoryId != _selectedCategoryId) { + if (!productInSelectedBranch( + productCategoryId: currentCategoryId, + selectedCategoryIds: selectedCategoryIds, + )) { _selectedProductId = null; } }); }, - ), - const SizedBox(height: 12), - Row( - children: [ - OutlinedButton.icon( - onPressed: _loadingProducts || _saving || _categoryTree.isEmpty - ? null - : _pickCategory, - icon: const Icon(Icons.category_outlined), - label: const Text('Välj kategori'), - ), - const SizedBox(width: 8), - OutlinedButton.icon( - onPressed: _saving - ? null - : () { - setState(() { - _selectedCategoryId = null; - }); - }, - icon: const Icon(Icons.clear), - label: const Text('Rensa kategori'), - ), - ], - ), - const SizedBox(height: 12), - ProductPickerField( - products: productOptions, - value: _selectedProductId, - isLoading: _loadingProducts, - enabled: !_saving, - label: context.l10n.inventoryProductLabel, - onChanged: (value) => setState(() => _selectedProductId = value), + onPickCategory: _pickCategory, + pickCategoryLabel: 'Välj kategori', + onClearCategory: () { + setState(() { + _selectedCategoryId = null; + }); + }, + clearCategoryLabel: 'Rensa kategori', + canPickCategory: !_loadingProducts && !_saving && _categoryTree.isNotEmpty, + canClearCategory: !_saving, + productOptions: productOptions, + selectedProductId: _selectedProductId, + onProductChanged: (value) => setState(() => _selectedProductId = value), + isLoadingProducts: _loadingProducts, + productEnabled: !_saving, + productLabel: context.l10n.inventoryProductLabel, ), const SizedBox(height: 12), DropdownButtonFormField( diff --git a/flutter/lib/features/inventory/presentation/inventory_category_helpers.dart b/flutter/lib/features/inventory/presentation/inventory_category_helpers.dart new file mode 100644 index 00000000..b4ec9f16 --- /dev/null +++ b/flutter/lib/features/inventory/presentation/inventory_category_helpers.dart @@ -0,0 +1,78 @@ +import '../../admin/domain/admin_category_node.dart'; + +class InventoryCategoryBranchIndex { + final Map> _descendantsById; + + InventoryCategoryBranchIndex._(this._descendantsById); + + factory InventoryCategoryBranchIndex.fromTree(List roots) { + final descendantsById = >{}; + + Set collect(AdminCategoryNode node) { + final ids = {node.id}; + for (final child in node.children) { + ids.addAll(collect(child)); + } + descendantsById[node.id] = ids; + return ids; + } + + for (final root in roots) { + collect(root); + } + + return InventoryCategoryBranchIndex._(descendantsById); + } + + Set? branchIdsFor(int? categoryId) { + if (categoryId == null) return null; + return _descendantsById[categoryId] ?? {categoryId}; + } +} + +bool productInSelectedBranch({ + required int? productCategoryId, + required Set? selectedCategoryIds, +}) { + if (selectedCategoryIds == null) return true; + if (productCategoryId == null) return false; + return selectedCategoryIds.contains(productCategoryId); +} + +class InventorySortedProductsCache { + String? _lastKey; + List> _lastSorted = const []; + + List> sort(List> products) { + final key = _buildKey(products); + if (_lastKey == key) return _lastSorted; + + final sorted = [...products] + ..sort((a, b) { + final aName = (a['canonicalName'] ?? a['name'] ?? '').toString().toLowerCase(); + final bName = (b['canonicalName'] ?? b['name'] ?? '').toString().toLowerCase(); + return aName.compareTo(bName); + }); + + _lastKey = key; + _lastSorted = sorted; + return _lastSorted; + } + + String _buildKey(List> products) { + final b = StringBuffer('${products.length}|'); + for (final p in products) { + final id = (p['id'] as num?)?.toInt() ?? -1; + final categoryId = (p['categoryId'] as num?)?.toInt() ?? -1; + final name = (p['canonicalName'] ?? p['name'] ?? '').toString(); + b + ..write(id) + ..write(':') + ..write(categoryId) + ..write(':') + ..write(name) + ..write('|'); + } + return b.toString(); + } +} diff --git a/flutter/lib/features/inventory/presentation/inventory_category_product_section.dart b/flutter/lib/features/inventory/presentation/inventory_category_product_section.dart new file mode 100644 index 00000000..995b1edc --- /dev/null +++ b/flutter/lib/features/inventory/presentation/inventory_category_product_section.dart @@ -0,0 +1,85 @@ +import 'package:flutter/material.dart'; + +import '../../../core/ui/product_picker_field.dart' as product_picker; +import '../../../core/ui/searchable_category_field.dart'; + +class InventoryCategoryProductSection extends StatelessWidget { + final List categoryOptions; + final int? selectedCategoryId; + final ValueChanged onCategoryChanged; + final String categorySearchLabel; + final VoidCallback onPickCategory; + final String pickCategoryLabel; + final VoidCallback onClearCategory; + final String clearCategoryLabel; + final bool canPickCategory; + final bool canClearCategory; + final List productOptions; + final int? selectedProductId; + final ValueChanged onProductChanged; + final bool isLoadingProducts; + final bool productEnabled; + final String productLabel; + + const InventoryCategoryProductSection({ + super.key, + required this.categoryOptions, + required this.selectedCategoryId, + required this.onCategoryChanged, + required this.categorySearchLabel, + required this.onPickCategory, + required this.pickCategoryLabel, + required this.onClearCategory, + required this.clearCategoryLabel, + required this.canPickCategory, + required this.canClearCategory, + required this.productOptions, + required this.selectedProductId, + required this.onProductChanged, + required this.isLoadingProducts, + required this.productEnabled, + required this.productLabel, + }); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + SearchableCategoryField( + options: categoryOptions, + value: selectedCategoryId?.toString(), + label: categorySearchLabel, + onChanged: onCategoryChanged, + ), + const SizedBox(height: 12), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + OutlinedButton.icon( + onPressed: canPickCategory ? onPickCategory : null, + icon: const Icon(Icons.category_outlined), + label: Text(pickCategoryLabel), + ), + OutlinedButton.icon( + onPressed: canClearCategory ? onClearCategory : null, + icon: const Icon(Icons.clear), + label: Text(clearCategoryLabel), + ), + ], + ), + const SizedBox(height: 12), + product_picker.ProductPickerField( + products: productOptions, + value: selectedProductId, + isLoading: isLoadingProducts, + enabled: productEnabled, + label: productLabel, + onChanged: onProductChanged, + ), + ], + ); + } +} diff --git a/flutter/lib/features/inventory/presentation/inventory_edit_screen.dart b/flutter/lib/features/inventory/presentation/inventory_edit_screen.dart index 3fa6e1b4..b7e8c9e8 100644 --- a/flutter/lib/features/inventory/presentation/inventory_edit_screen.dart +++ b/flutter/lib/features/inventory/presentation/inventory_edit_screen.dart @@ -10,12 +10,14 @@ import '../../../core/forms/form_options.dart'; import '../../../core/l10n/l10n.dart'; import '../../../core/ui/async_state_views.dart'; import '../../../core/ui/category_then_product_picker.dart'; -import '../../../core/ui/product_picker_field.dart'; -import '../../../core/ui/searchable_category_field.dart'; +import '../../../core/ui/product_picker_field.dart' show ProductOption; +import '../../../core/ui/searchable_category_field.dart' show CategorySelectOption; import '../../auth/data/auth_providers.dart'; import '../../admin/domain/admin_category_node.dart'; import '../data/inventory_providers.dart'; import '../domain/inventory_item.dart'; +import 'inventory_category_helpers.dart'; +import 'inventory_category_product_section.dart'; class InventoryEditScreen extends ConsumerStatefulWidget { final int itemId; @@ -46,6 +48,8 @@ class _InventoryEditScreenState extends ConsumerState { List> _products = []; List _categoryTree = []; List _categoryOptions = []; + InventoryCategoryBranchIndex? _categoryBranchIndex; + final _sortedProductsCache = InventorySortedProductsCache(); @override void dispose() { @@ -113,6 +117,7 @@ class _InventoryEditScreenState extends ConsumerState { _products = products; _categoryTree = categoryTree; _categoryOptions = categoryOptions; + _categoryBranchIndex = InventoryCategoryBranchIndex.fromTree(categoryTree); final selected = _selectedProduct(); if (selected != null) { @@ -154,10 +159,24 @@ class _InventoryEditScreenState extends ConsumerState { return null; } + Set? _selectedCategoryBranchIds() { + final selectedId = _selectedCategoryId; + if (selectedId == null) return null; + return _categoryBranchIndex?.branchIdsFor(selectedId) ?? {selectedId}; + } + List _productOptions() { + final selectedCategoryIds = _selectedCategoryBranchIds(); final source = _selectedCategoryId == null ? _products - : _products.where((p) => (p['categoryId'] as num?)?.toInt() == _selectedCategoryId).toList(); + : _products + .where( + (p) => productInSelectedBranch( + productCategoryId: (p['categoryId'] as num?)?.toInt(), + selectedCategoryIds: selectedCategoryIds, + ), + ) + .toList(); final selected = _selectedProduct(); final withSelected = [...source]; @@ -165,13 +184,8 @@ class _InventoryEditScreenState extends ConsumerState { withSelected.add(selected); } - withSelected.sort((a, b) { - final aName = (a['canonicalName'] ?? a['name'] ?? '').toString().toLowerCase(); - final bName = (b['canonicalName'] ?? b['name'] ?? '').toString().toLowerCase(); - return aName.compareTo(bName); - }); - - return withSelected + final sorted = _sortedProductsCache.sort(withSelected); + return sorted .map( (p) => ( id: (p['id'] as num).toInt(), @@ -191,9 +205,13 @@ class _InventoryEditScreenState extends ConsumerState { if (selected == null || !mounted) return; setState(() { _selectedCategoryId = selected.id; + final selectedCategoryIds = _selectedCategoryBranchIds(); final current = _selectedProduct(); final currentCategoryId = (current?['categoryId'] as num?)?.toInt(); - if (currentCategoryId != _selectedCategoryId) { + if (!productInSelectedBranch( + productCategoryId: currentCategoryId, + selectedCategoryIds: selectedCategoryIds, + )) { _selectedProductId = null; } }); @@ -292,54 +310,41 @@ class _InventoryEditScreenState extends ConsumerState { Text(item.categoryPath!, style: Theme.of(context).textTheme.bodySmall), ], const SizedBox(height: 16), - SearchableCategoryField( - options: _categoryOptions, - value: _selectedCategoryId?.toString(), - label: 'Kategori (sökbar)', - onChanged: (value) { + InventoryCategoryProductSection( + categoryOptions: _categoryOptions, + selectedCategoryId: _selectedCategoryId, + categorySearchLabel: 'Kategori (sökbar)', + onCategoryChanged: (value) { if (value == null) return; setState(() { _selectedCategoryId = int.tryParse(value); + final selectedCategoryIds = _selectedCategoryBranchIds(); final current = _selectedProduct(); final currentCategoryId = (current?['categoryId'] as num?)?.toInt(); - if (currentCategoryId != _selectedCategoryId) { + if (!productInSelectedBranch( + productCategoryId: currentCategoryId, + selectedCategoryIds: selectedCategoryIds, + )) { _selectedProductId = null; } }); }, - ), - const SizedBox(height: 12), - Row( - children: [ - OutlinedButton.icon( - onPressed: _loadingProducts || _saving || _categoryTree.isEmpty - ? null - : _pickCategory, - icon: const Icon(Icons.category_outlined), - label: const Text('Välj kategori'), - ), - const SizedBox(width: 8), - OutlinedButton.icon( - onPressed: _saving - ? null - : () { - setState(() { - _selectedCategoryId = null; - }); - }, - icon: const Icon(Icons.clear), - label: const Text('Rensa kategori'), - ), - ], - ), - const SizedBox(height: 12), - ProductPickerField( - products: _productOptions(), - value: _selectedProductId, - isLoading: _loadingProducts, - enabled: !_saving, - label: context.l10n.inventoryProductLabel, - onChanged: (value) => setState(() => _selectedProductId = value), + onPickCategory: _pickCategory, + pickCategoryLabel: 'Välj kategori', + onClearCategory: () { + setState(() { + _selectedCategoryId = null; + }); + }, + clearCategoryLabel: 'Rensa kategori', + canPickCategory: !_loadingProducts && !_saving && _categoryTree.isNotEmpty, + canClearCategory: !_saving, + productOptions: _productOptions(), + selectedProductId: _selectedProductId, + onProductChanged: (value) => setState(() => _selectedProductId = value), + isLoadingProducts: _loadingProducts, + productEnabled: !_saving, + productLabel: context.l10n.inventoryProductLabel, ), Row( crossAxisAlignment: CrossAxisAlignment.start, diff --git a/flutter/lib/features/inventory/presentation/swipeable_inventory_tile.dart b/flutter/lib/features/inventory/presentation/swipeable_inventory_tile.dart index b42dbbf9..1a88b5ed 100644 --- a/flutter/lib/features/inventory/presentation/swipeable_inventory_tile.dart +++ b/flutter/lib/features/inventory/presentation/swipeable_inventory_tile.dart @@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import '../../../core/api/api_error_mapper.dart'; +import '../../../core/utils/display_labels.dart'; import '../../../core/utils/formatters.dart'; import '../../auth/data/auth_providers.dart'; import '../data/inventory_providers.dart'; @@ -286,12 +287,11 @@ class _ForegroundTile extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final theme = Theme.of(context); - final location = item.location?.trim(); - final hasLocation = location != null && location.isNotEmpty; + final location = normalizedOptionalText(item.location); final subtitleText = [ '${_fmtQty(item.quantity)} ${item.unit}', - if (hasLocation) location, + if (location != null) location, if (item.bestBeforeDate != null) 'Bäst före: ${_formatDate(item.bestBeforeDate!)}', ].join(' · '); @@ -331,26 +331,23 @@ class _ForegroundTile extends ConsumerWidget { ], ), const SizedBox(height: 6), - Row( - children: [ - Flexible( - child: Chip( - label: Text( - 'L1: ${item.l1Category}', - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - padding: EdgeInsets.zero, - visualDensity: VisualDensity.compact, - materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, - side: BorderSide(color: theme.colorScheme.outlineVariant), - backgroundColor: theme.colorScheme.surface, - labelStyle: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ), + Align( + alignment: Alignment.centerLeft, + child: Chip( + label: Text( + l1CategoryChipLabel('L1: ', item.l1Category), + maxLines: 1, + overflow: TextOverflow.ellipsis, ), - ], + padding: EdgeInsets.zero, + visualDensity: VisualDensity.compact, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + side: BorderSide(color: theme.colorScheme.outlineVariant), + backgroundColor: theme.colorScheme.surface, + labelStyle: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), ), ], ), diff --git a/flutter/lib/features/pantry/presentation/pantry_screen.dart b/flutter/lib/features/pantry/presentation/pantry_screen.dart index 634e6976..63850f73 100644 --- a/flutter/lib/features/pantry/presentation/pantry_screen.dart +++ b/flutter/lib/features/pantry/presentation/pantry_screen.dart @@ -7,6 +7,7 @@ import '../../../core/api/api_error_mapper.dart'; import '../../../core/forms/form_options.dart'; import '../../../core/l10n/l10n.dart'; import '../../../core/ui/async_state_views.dart'; +import '../../../core/utils/display_labels.dart'; import '../../auth/data/auth_providers.dart'; import '../../inventory/data/inventory_providers.dart'; import '../data/pantry_providers.dart'; @@ -373,8 +374,7 @@ class _PantryScreenState extends ConsumerState { if (index == 1) return headerSection; final item = filteredItems[index - 2]; final l1Category = _resolveL1Category(item); - final location = item.location?.trim(); - final hasLocation = location != null && location.isNotEmpty; + final location = normalizedOptionalText(item.location); return ListTile( title: Text(item.displayName), @@ -382,32 +382,29 @@ class _PantryScreenState extends ConsumerState { crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ - if (hasLocation) + if (location != null) Text( - 'Plats: $location', + locationLabel('${context.l10n.locationLabel}: ', location), overflow: TextOverflow.ellipsis, ), const SizedBox(height: 6), - Row( - children: [ - Flexible( - child: Chip( - label: Text( - 'L1: $l1Category', - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - padding: EdgeInsets.zero, - visualDensity: VisualDensity.compact, - materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, - side: BorderSide(color: colorScheme.outlineVariant), - backgroundColor: colorScheme.surface, - labelStyle: textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), + Align( + alignment: Alignment.centerLeft, + child: Chip( + label: Text( + l1CategoryChipLabel('L1: ', l1Category), + maxLines: 1, + overflow: TextOverflow.ellipsis, ), - ], + padding: EdgeInsets.zero, + visualDensity: VisualDensity.compact, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + side: BorderSide(color: colorScheme.outlineVariant), + backgroundColor: colorScheme.surface, + labelStyle: textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), ), ], ),