From 32e83caa35ea0ed1c3745aef7dc27b1f21c53a35 Mon Sep 17 00:00:00 2001 From: Nils-Johan Gynther Date: Fri, 1 May 2026 23:05:01 +0200 Subject: [PATCH] feat: enhance category picker functionality with preselection support and new existing category picker --- .../presentation/receipt_import_tab.dart | 221 ++++++++---------- 1 file changed, 101 insertions(+), 120 deletions(-) diff --git a/flutter/lib/features/import/presentation/receipt_import_tab.dart b/flutter/lib/features/import/presentation/receipt_import_tab.dart index 319c9224..7f0094f6 100644 --- a/flutter/lib/features/import/presentation/receipt_import_tab.dart +++ b/flutter/lib/features/import/presentation/receipt_import_tab.dart @@ -125,11 +125,12 @@ class _EditDialogState extends State<_EditDialog> { super.dispose(); } - Future _openCreateCategoryPicker() async { + Future _openCreateCategoryPicker({int? preselectedCategoryId}) async { final selected = await CategoryThenProductPicker.showCategorySheet( context, categoryTree: widget.categoryTree, - preselectedCategoryId: _newCategoryId ?? widget.item.categorySuggestionId, + preselectedCategoryId: + preselectedCategoryId ?? _newCategoryId ?? widget.item.categorySuggestionId, ); if (selected == null || !mounted) return; setState(() { @@ -139,6 +140,41 @@ class _EditDialogState extends State<_EditDialog> { }); } + Future _openExistingCategoryPicker({int? preselectedCategoryId}) async { + Future Function(String, int)? onCreateWrapped; + if (widget.onCreate != null) { + onCreateWrapped = (name, categoryId) async { + final newProduct = await widget.onCreate!(name, categoryId); + if (newProduct != null && mounted) { + setState(() => _localProducts = [..._localProducts, newProduct]); + } + return newProduct; + }; + } + + final id = await CategoryThenProductPicker.show( + context, + categoryTree: widget.categoryTree, + products: _localProducts, + currentProductId: _productId, + preselectedCategoryId: preselectedCategoryId, + initialQuery: widget.item.rawName, + onCreate: onCreateWrapped, + ); + if (id != null && mounted) { + setState(() { + _productId = id; + _productName = _localProducts + .cast() + .firstWhere((p) => p?.id == id, orElse: () => null) + ?.name; + _productCategoryId = _categoryIdForProduct(id); + _productCategoryPath = _categoryPathForCategoryId(_productCategoryId); + _productCategorySource = CategorySelectionSource.manual; + }); + } + } + bool get _canConfirm { if (_entryMode == _ProductEntryMode.create) { return !_isCreatingProduct && @@ -193,66 +229,9 @@ class _EditDialogState extends State<_EditDialog> { final item = widget.item; final aiCategory = item.categorySuggestionName; final aiPath = item.categorySuggestionPath; - // Visa hela sökvägen om det finns, annars bara kategorinamnet - final aiLabel = aiPath != null && aiPath.isNotEmpty ? aiPath : aiCategory; - - // Hjälpfunktion: välj produkt via tvåstegs-picker (kategori → produkt) - Future openCategoryPicker({int? preselectedCategoryId}) async { - // onCreate-wrapper: lägg även till den nya produkten i _localProducts - Future Function(String, int)? onCreateWrapped; - if (widget.onCreate != null) { - onCreateWrapped = (name, categoryId) async { - final newProduct = await widget.onCreate!(name, categoryId); - if (newProduct != null && mounted) { - setState(() => _localProducts = [..._localProducts, newProduct]); - } - return newProduct; - }; - } - - final id = await CategoryThenProductPicker.show( - context, - categoryTree: widget.categoryTree, - products: _localProducts, - currentProductId: _productId, - preselectedCategoryId: preselectedCategoryId, - initialQuery: item.rawName, - onCreate: onCreateWrapped, - ); - if (id != null && mounted) { - setState(() { - _productId = id; - _productName = _localProducts - .cast() - .firstWhere((p) => p?.id == id, orElse: () => null) - ?.name; - _productCategoryId = _categoryIdForProduct(id); - _productCategoryPath = _categoryPathForCategoryId(_productCategoryId); - _productCategorySource = CategorySelectionSource.manual; - }); - } - } - - // Hjälpfunktion: acceptera AI-förslaget - void acceptAiSuggestion() { - final sugId = item.suggestedProductId; - if (sugId != null) { - // Välj den föreslagna produkten direkt - setState(() { - _productId = sugId; - _productName = item.suggestedProductName; - _productCategoryId = _categoryIdForProduct(sugId) ?? item.categorySuggestionId; - _productCategoryPath = - _categoryPathForCategoryId(_productCategoryId) ?? item.categorySuggestionPath; - _productCategorySource = item.categorySuggestionPath != null - ? CategorySelectionSource.ai - : null; - }); - } else { - // Öppna kategori → produkt med AI-föreslagen kategori förvald - openCategoryPicker(preselectedCategoryId: item.categorySuggestionId); - } - } + final aiLabel = (aiPath != null && aiPath.isNotEmpty) + ? aiPath + : ((aiCategory != null && aiCategory.isNotEmpty) ? aiCategory : null); return AlertDialog( title: Text(item.rawName, maxLines: 2, overflow: TextOverflow.ellipsis), @@ -261,25 +240,6 @@ class _EditDialogState extends State<_EditDialog> { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - // AI-kategorisuggestion — klickbar - if (aiLabel != null) ...[ - Wrap( - children: [ - ActionChip( - avatar: Icon(Icons.auto_awesome, size: 14, color: Colors.green.shade700), - label: Text('AI: $aiLabel', style: theme.textTheme.labelSmall), - backgroundColor: Colors.green.shade50, - side: BorderSide(color: Colors.green.shade300), - visualDensity: VisualDensity.compact, - tooltip: item.suggestedProductId != null - ? 'Välj "${item.suggestedProductName}" automatiskt' - : 'Bläddra produkter i kategorin "$aiCategory"', - onPressed: acceptAiSuggestion, - ), - ], - ), - const SizedBox(height: 8), - ], // Destination SegmentedButton<_Destination>( segments: const [ @@ -336,6 +296,10 @@ class _EditDialogState extends State<_EditDialog> { .cast() .firstWhere((p) => p?.id == id, orElse: () => null) ?.name; + _productCategoryId = _categoryIdForProduct(id); + _productCategoryPath = _categoryPathForCategoryId(_productCategoryId); + _productCategorySource = + id == null ? null : CategorySelectionSource.manual; }); }, ), @@ -348,7 +312,7 @@ class _EditDialogState extends State<_EditDialog> { minimumSize: const Size(44, 56), padding: EdgeInsets.zero, ), - onPressed: () => openCategoryPicker(), + onPressed: () => _openExistingCategoryPicker(), child: const Icon(Icons.account_tree_outlined, size: 20), ), ), @@ -406,35 +370,32 @@ class _EditDialogState extends State<_EditDialog> { ], ), ), - ], - if (item.categorySuggestionPath != null && - item.categorySuggestionPath!.isNotEmpty) ...[ - const SizedBox(height: 8), - Wrap( - children: [ - ActionChip( - avatar: Icon( - Icons.auto_awesome, - size: 14, - color: Colors.green.shade700, + if (aiLabel != null) ...[ + const SizedBox(height: 8), + Wrap( + children: [ + ActionChip( + avatar: Icon( + Icons.auto_awesome, + size: 14, + color: Colors.green.shade700, + ), + label: Text( + 'AI-forslag: $aiLabel', + style: theme.textTheme.labelSmall, + ), + backgroundColor: Colors.green.shade50, + side: BorderSide(color: Colors.green.shade300), + visualDensity: VisualDensity.compact, + onPressed: item.categorySuggestionId == null + ? null + : () => _openCreateCategoryPicker( + preselectedCategoryId: item.categorySuggestionId, + ), ), - label: Text( - 'AI-förslag: ${item.categorySuggestionPath}', - style: theme.textTheme.labelSmall, - ), - backgroundColor: Colors.green.shade50, - side: BorderSide(color: Colors.green.shade300), - visualDensity: VisualDensity.compact, - onPressed: item.categorySuggestionId == null - ? null - : () => setState(() { - _newCategoryId = item.categorySuggestionId; - _newCategoryPath = item.categorySuggestionPath; - _newCategorySource = CategorySelectionSource.ai; - }), - ), - ], - ), + ], + ), + ], ], ], const SizedBox(height: 12), @@ -538,19 +499,32 @@ class _ReceiptImportTabState extends ConsumerState { ? mineData : ((mineData as Map?)?['items'] as List? ?? []); if (mounted) { + final mergedProducts = [ + ...globalList + .cast>() + .map((e) => (id: e['id'] as int, name: (e['canonicalName'] ?? e['name']) as String, categoryId: (e['categoryId'] as num?)?.toInt())), + ...mineList + .cast>() + .map((e) => (id: e['id'] as int, name: (e['canonicalName'] ?? e['name']) as String, categoryId: (e['categoryId'] as num?)?.toInt())), + ]; + final dedupedById = { + for (final product in mergedProducts) product.id: product, + }; + setState(() { - _products = [ - ...globalList - .cast>() - .map((e) => (id: e['id'] as int, name: (e['canonicalName'] ?? e['name']) as String, categoryId: (e['categoryId'] as num?)?.toInt())), - ...mineList - .cast>() - .map((e) => (id: e['id'] as int, name: (e['canonicalName'] ?? e['name']) as String, categoryId: (e['categoryId'] as num?)?.toInt())), - ]; + _products = dedupedById.values.toList(); _categoryTree = results[2] as List; }); } - } catch (_) {} + } catch (e, st) { + debugPrint('ReceiptImportTab._loadProducts failed: $e'); + debugPrintStack(stackTrace: st); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Kunde inte ladda produktlistan. Försök igen.')), + ); + } + } } Future _loadInventory() async { @@ -621,6 +595,9 @@ class _ReceiptImportTabState extends ConsumerState { productName: name, categoryId: it.categorySuggestionId, categoryPath: it.categorySuggestionPath, + categorySource: it.categorySuggestionId != null + ? CategorySelectionSource.ai + : null, quantity: it.quantity, unit: it.unit, )); @@ -646,6 +623,9 @@ class _ReceiptImportTabState extends ConsumerState { productName: item.matchedProductName ?? item.suggestedProductName, categoryId: item.categorySuggestionId, categoryPath: item.categorySuggestionPath, + categorySource: item.categorySuggestionId != null + ? CategorySelectionSource.ai + : null, quantity: item.quantity, unit: item.unit, ); @@ -866,7 +846,8 @@ class _ReceiptImportTabState extends ConsumerState { ), ], // ── Förhandsvisning av kvitto ──────────────────────────────────────── - if (session?.fileBytes != null) ...[ const SizedBox(height: 12), + if (session?.fileBytes != null) ...[ + const SizedBox(height: 12), _buildReceiptPreview(theme, session!), ], const SizedBox(height: 24),