feat: add initial query support to ProductPickerField and enhance ParsedReceiptItem with categorySuggestionPath

This commit is contained in:
Nils-Johan Gynther
2026-05-01 01:50:18 +02:00
parent 997d62ade8
commit f4fea7b92c
3 changed files with 110 additions and 58 deletions
+44 -36
View File
@@ -33,6 +33,9 @@ class ProductPickerField extends StatelessWidget {
/// Inline error message shown below the field. /// Inline error message shown below the field.
final String? errorText; final String? errorText;
/// If set, the picker bottom sheet opens with this text pre-filled in the search field.
final String? initialQuery;
const ProductPickerField({ const ProductPickerField({
super.key, super.key,
required this.products, required this.products,
@@ -42,6 +45,7 @@ class ProductPickerField extends StatelessWidget {
this.isLoading = false, this.isLoading = false,
this.label = 'Produkt', this.label = 'Produkt',
this.errorText, this.errorText,
this.initialQuery,
}); });
@override @override
@@ -87,26 +91,49 @@ class ProductPickerField extends StatelessWidget {
} }
Future<void> _openPicker(BuildContext context) async { Future<void> _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<int?> showSheet(
BuildContext context, {
required List<ProductOption> products,
int? value,
String label = 'Produkt',
String? initialQuery,
}) async {
final result = await showModalBottomSheet<Object?>( final result = await showModalBottomSheet<Object?>(
context: context, context: context,
isScrollControlled: true, isScrollControlled: true,
useSafeArea: true, useSafeArea: true,
builder: (sheetContext) { builder: (sheetContext) {
var query = ''; var query = initialQuery ?? '';
return StatefulBuilder( return StatefulBuilder(
builder: (context, setModalState) { builder: (ctx, setModalState) {
final normalizedQuery = query.trim().toLowerCase(); final normalizedQuery = query.trim().toLowerCase();
final filtered = normalizedQuery.isEmpty final filtered = normalizedQuery.isEmpty
? products ? products
: products : products
.where( .where((p) => p.name.toLowerCase().contains(normalizedQuery))
(p) => p.name.toLowerCase().contains(normalizedQuery),
)
.toList(); .toList();
return SizedBox( return SizedBox(
height: MediaQuery.of(context).size.height * 0.85, height: MediaQuery.of(ctx).size.height * 0.85,
child: Column( child: Column(
children: [ children: [
Padding( Padding(
@@ -114,14 +141,10 @@ class ProductPickerField extends StatelessWidget {
child: Row( child: Row(
children: [ children: [
Expanded( Expanded(
child: Text( child: Text(label, style: Theme.of(ctx).textTheme.titleMedium),
label,
style: Theme.of(context).textTheme.titleMedium,
),
), ),
TextButton.icon( TextButton.icon(
onPressed: () => onPressed: () => Navigator.pop(ctx, _clearSelectionToken),
Navigator.pop(context, _clearSelectionToken),
icon: const Icon(Icons.clear), icon: const Icon(Icons.clear),
label: const Text('Rensa'), label: const Text('Rensa'),
), ),
@@ -132,37 +155,30 @@ class ProductPickerField extends StatelessWidget {
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
child: TextField( child: TextField(
autofocus: true, autofocus: true,
controller: TextEditingController(text: initialQuery ?? ''),
decoration: const InputDecoration( decoration: const InputDecoration(
hintText: 'Sök produkt...', hintText: 'Sök produkt...',
prefixIcon: Icon(Icons.search), prefixIcon: Icon(Icons.search),
border: OutlineInputBorder(), border: OutlineInputBorder(),
), ),
onChanged: (text) { onChanged: (text) => setModalState(() => query = text),
setModalState(() {
query = text;
});
},
), ),
), ),
const Divider(height: 1), const Divider(height: 1),
Expanded( Expanded(
child: filtered.isEmpty child: filtered.isEmpty
? const Center( ? const Center(child: Text('Inga produkter matchar sökningen.'))
child: Text('Inga produkter matchar sökningen.'),
)
: ListView.separated( : ListView.separated(
itemCount: filtered.length, itemCount: filtered.length,
separatorBuilder: (_, __) => const Divider(height: 1), separatorBuilder: (_, __) => const Divider(height: 1),
itemBuilder: (context, index) { itemBuilder: (ctx2, index) {
final product = filtered[index]; final product = filtered[index];
final isSelected = product.id == value; final isSelected = product.id == value;
return ListTile( return ListTile(
selected: isSelected, selected: isSelected,
title: Text(product.name), title: Text(product.name),
trailing: isSelected trailing: isSelected ? const Icon(Icons.check) : null,
? const Icon(Icons.check) onTap: () => Navigator.pop(ctx2, product.id),
: null,
onTap: () => Navigator.pop(context, product.id),
); );
}, },
), ),
@@ -175,16 +191,8 @@ class ProductPickerField extends StatelessWidget {
}, },
); );
if (!context.mounted) return; if (result == null || result == _clearSelectionToken) return null;
if (result == null) { if (result is int) return result;
return; return null;
}
if (result == _clearSelectionToken) {
onChanged?.call(null);
return;
}
if (result is int) {
onChanged?.call(result);
}
} }
} }
@@ -14,6 +14,7 @@ class ParsedReceiptItem {
final String? suggestedProductName; final String? suggestedProductName;
// AI-kategorisuggestion (premium) // AI-kategorisuggestion (premium)
final String? categorySuggestionName; final String? categorySuggestionName;
final String? categorySuggestionPath;
ParsedReceiptItem({ ParsedReceiptItem({
required this.rawName, required this.rawName,
@@ -27,9 +28,12 @@ class ParsedReceiptItem {
this.suggestedProductId, this.suggestedProductId,
this.suggestedProductName, this.suggestedProductName,
this.categorySuggestionName, this.categorySuggestionName,
this.categorySuggestionPath,
}); });
factory ParsedReceiptItem.fromJson(Map<String, dynamic> json) => ParsedReceiptItem( factory ParsedReceiptItem.fromJson(Map<String, dynamic> json) {
final cat = json['categorySuggestion'] as Map<String, dynamic>?;
return ParsedReceiptItem(
rawName: json['rawName'] as String? ?? '', rawName: json['rawName'] as String? ?? '',
quantity: (json['quantity'] as num?)?.toDouble(), quantity: (json['quantity'] as num?)?.toDouble(),
unit: json['unit'] as String?, unit: json['unit'] as String?,
@@ -40,6 +44,8 @@ class ParsedReceiptItem {
matchedProductName: json['matchedProductName'] as String?, matchedProductName: json['matchedProductName'] as String?,
suggestedProductId: (json['suggestedProductId'] as num?)?.toInt(), suggestedProductId: (json['suggestedProductId'] as num?)?.toInt(),
suggestedProductName: json['suggestedProductName'] as String?, suggestedProductName: json['suggestedProductName'] as String?,
categorySuggestionName: (json['categorySuggestion'] as Map<String, dynamic>?)?['categoryName'] as String?, categorySuggestionName: cat?['categoryName'] as String?,
categorySuggestionPath: cat?['path'] as String?,
); );
} }
}
@@ -82,26 +82,64 @@ class _EditDialogState extends State<_EditDialog> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(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<ProductOption?>()
.firstWhere((p) => p?.id == id, orElse: () => null)
?.name;
});
}
});
}
}
return AlertDialog( return AlertDialog(
title: Text(widget.item.rawName, maxLines: 2, overflow: TextOverflow.ellipsis), title: Text(item.rawName, maxLines: 2, overflow: TextOverflow.ellipsis),
content: SingleChildScrollView( content: SingleChildScrollView(
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// AI-kategorisuggestion // AI-kategorisuggestion — klickbar
if (aiCategory != null) ...[ if (aiLabel != null) ...[
Wrap( Wrap(
children: [ children: [
Chip( ActionChip(
avatar: const Icon(Icons.auto_awesome, size: 14), avatar: Icon(Icons.auto_awesome, size: 14, color: Colors.green.shade700),
label: Text('AI: $aiCategory', label: Text('AI: $aiLabel', style: theme.textTheme.labelSmall),
style: theme.textTheme.labelSmall),
backgroundColor: Colors.green.shade50, backgroundColor: Colors.green.shade50,
side: BorderSide(color: Colors.green.shade300), side: BorderSide(color: Colors.green.shade300),
visualDensity: VisualDensity.compact, visualDensity: VisualDensity.compact,
tooltip: item.suggestedProductId != null
? 'Välj "${item.suggestedProductName}" automatiskt'
: 'Sök produkter i kategorin "$aiCategory"',
onPressed: acceptAiSuggestion,
), ),
], ],
), ),