diff --git a/flutter/lib/core/ui/product_picker_field.dart b/flutter/lib/core/ui/product_picker_field.dart index 5216c196..b983c7ff 100644 --- a/flutter/lib/core/ui/product_picker_field.dart +++ b/flutter/lib/core/ui/product_picker_field.dart @@ -33,6 +33,9 @@ class ProductPickerField extends StatelessWidget { /// Inline error message shown below the field. final String? errorText; + /// If set, the picker bottom sheet opens with this text pre-filled in the search field. + final String? initialQuery; + const ProductPickerField({ super.key, required this.products, @@ -42,6 +45,7 @@ class ProductPickerField extends StatelessWidget { this.isLoading = false, this.label = 'Produkt', this.errorText, + this.initialQuery, }); @override @@ -87,26 +91,49 @@ class ProductPickerField extends StatelessWidget { } Future _openPicker(BuildContext context) async { + final result = await ProductPickerField.showSheet( + context, + products: products, + value: value, + label: label, + initialQuery: initialQuery, + ); + if (!context.mounted) return; + if (result == null) return; + if (result == _clearSelectionToken) { + onChanged?.call(null); + return; + } + if (result is int) onChanged?.call(result); + } + + /// Öppnar produktväljarens bottenark utan att binda den till en specifik widget-instans. + /// Returnerar valt produkt-id, null (ingen ändring), eller [_clearSelectionToken] (rensa). + static Future showSheet( + BuildContext context, { + required List products, + int? value, + String label = 'Produkt', + String? initialQuery, + }) async { final result = await showModalBottomSheet( context: context, isScrollControlled: true, useSafeArea: true, builder: (sheetContext) { - var query = ''; + var query = initialQuery ?? ''; return StatefulBuilder( - builder: (context, setModalState) { + builder: (ctx, setModalState) { final normalizedQuery = query.trim().toLowerCase(); final filtered = normalizedQuery.isEmpty ? products : products - .where( - (p) => p.name.toLowerCase().contains(normalizedQuery), - ) + .where((p) => p.name.toLowerCase().contains(normalizedQuery)) .toList(); return SizedBox( - height: MediaQuery.of(context).size.height * 0.85, + height: MediaQuery.of(ctx).size.height * 0.85, child: Column( children: [ Padding( @@ -114,14 +141,10 @@ class ProductPickerField extends StatelessWidget { child: Row( children: [ Expanded( - child: Text( - label, - style: Theme.of(context).textTheme.titleMedium, - ), + child: Text(label, style: Theme.of(ctx).textTheme.titleMedium), ), TextButton.icon( - onPressed: () => - Navigator.pop(context, _clearSelectionToken), + onPressed: () => Navigator.pop(ctx, _clearSelectionToken), icon: const Icon(Icons.clear), label: const Text('Rensa'), ), @@ -132,37 +155,30 @@ class ProductPickerField extends StatelessWidget { padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), child: TextField( autofocus: true, + controller: TextEditingController(text: initialQuery ?? ''), decoration: const InputDecoration( hintText: 'Sök produkt...', prefixIcon: Icon(Icons.search), border: OutlineInputBorder(), ), - onChanged: (text) { - setModalState(() { - query = text; - }); - }, + onChanged: (text) => setModalState(() => query = text), ), ), const Divider(height: 1), Expanded( child: filtered.isEmpty - ? const Center( - child: Text('Inga produkter matchar sökningen.'), - ) + ? const Center(child: Text('Inga produkter matchar sökningen.')) : ListView.separated( itemCount: filtered.length, separatorBuilder: (_, __) => const Divider(height: 1), - itemBuilder: (context, index) { + itemBuilder: (ctx2, index) { final product = filtered[index]; final isSelected = product.id == value; return ListTile( selected: isSelected, title: Text(product.name), - trailing: isSelected - ? const Icon(Icons.check) - : null, - onTap: () => Navigator.pop(context, product.id), + trailing: isSelected ? const Icon(Icons.check) : null, + onTap: () => Navigator.pop(ctx2, product.id), ); }, ), @@ -175,16 +191,8 @@ class ProductPickerField extends StatelessWidget { }, ); - if (!context.mounted) return; - if (result == null) { - return; - } - if (result == _clearSelectionToken) { - onChanged?.call(null); - return; - } - if (result is int) { - onChanged?.call(result); - } + if (result == null || result == _clearSelectionToken) return null; + if (result is int) return result; + return null; } } diff --git a/flutter/lib/features/import/domain/parsed_receipt_item.dart b/flutter/lib/features/import/domain/parsed_receipt_item.dart index c7f9a15e..f9213a07 100644 --- a/flutter/lib/features/import/domain/parsed_receipt_item.dart +++ b/flutter/lib/features/import/domain/parsed_receipt_item.dart @@ -14,6 +14,7 @@ class ParsedReceiptItem { final String? suggestedProductName; // AI-kategorisuggestion (premium) final String? categorySuggestionName; + final String? categorySuggestionPath; ParsedReceiptItem({ required this.rawName, @@ -27,19 +28,24 @@ class ParsedReceiptItem { this.suggestedProductId, this.suggestedProductName, this.categorySuggestionName, + this.categorySuggestionPath, }); - factory ParsedReceiptItem.fromJson(Map json) => ParsedReceiptItem( - rawName: json['rawName'] as String? ?? '', - quantity: (json['quantity'] as num?)?.toDouble(), - unit: json['unit'] as String?, - price: (json['price'] as num?)?.toDouble(), - brand: json['brand'] as String?, - origin: json['origin'] as String?, - matchedProductId: (json['matchedProductId'] as num?)?.toInt(), - matchedProductName: json['matchedProductName'] as String?, - suggestedProductId: (json['suggestedProductId'] as num?)?.toInt(), - suggestedProductName: json['suggestedProductName'] as String?, - categorySuggestionName: (json['categorySuggestion'] as Map?)?['categoryName'] as String?, - ); + factory ParsedReceiptItem.fromJson(Map json) { + final cat = json['categorySuggestion'] as Map?; + return ParsedReceiptItem( + rawName: json['rawName'] as String? ?? '', + quantity: (json['quantity'] as num?)?.toDouble(), + unit: json['unit'] as String?, + price: (json['price'] as num?)?.toDouble(), + brand: json['brand'] as String?, + origin: json['origin'] as String?, + matchedProductId: (json['matchedProductId'] as num?)?.toInt(), + matchedProductName: json['matchedProductName'] as String?, + suggestedProductId: (json['suggestedProductId'] as num?)?.toInt(), + suggestedProductName: json['suggestedProductName'] as String?, + categorySuggestionName: cat?['categoryName'] as String?, + categorySuggestionPath: cat?['path'] as String?, + ); + } } diff --git a/flutter/lib/features/import/presentation/receipt_import_tab.dart b/flutter/lib/features/import/presentation/receipt_import_tab.dart index ae96715c..d4910ac5 100644 --- a/flutter/lib/features/import/presentation/receipt_import_tab.dart +++ b/flutter/lib/features/import/presentation/receipt_import_tab.dart @@ -82,32 +82,70 @@ class _EditDialogState extends State<_EditDialog> { @override Widget build(BuildContext context) { final theme = Theme.of(context); - final aiCategory = widget.item.categorySuggestionName; + 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: 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; + }); + } else if (aiCategory != null) { + // Öppna pickern med kategorinamnet sökt + ProductPickerField.showSheet( + context, + products: widget.products, + value: _productId, + label: 'Produkt', + initialQuery: aiCategory, + ).then((id) { + if (id != null && mounted) { + setState(() { + _productId = id; + _productName = widget.products + .cast() + .firstWhere((p) => p?.id == id, orElse: () => null) + ?.name; + }); + } + }); + } + } return AlertDialog( - title: Text(widget.item.rawName, maxLines: 2, overflow: TextOverflow.ellipsis), + title: Text(item.rawName, maxLines: 2, overflow: TextOverflow.ellipsis), content: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - // AI-kategorisuggestion - if (aiCategory != null) ...[ + // AI-kategorisuggestion — klickbar + if (aiLabel != null) ...[ Wrap( children: [ - Chip( - avatar: const Icon(Icons.auto_awesome, size: 14), - label: Text('AI: $aiCategory', - style: theme.textTheme.labelSmall), + 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' + : 'Sök produkter i kategorin "$aiCategory"', + onPressed: acceptAiSuggestion, ), ], ), const SizedBox(height: 8), ], - // Destination + // Destination SegmentedButton<_Destination>( segments: const [ ButtonSegment(