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.
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<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?>(
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;
}
}
@@ -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<String, dynamic> 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<String, dynamic>?)?['categoryName'] as String?,
);
factory ParsedReceiptItem.fromJson(Map<String, dynamic> json) {
final cat = json['categorySuggestion'] as Map<String, dynamic>?;
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?,
);
}
}
@@ -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<ProductOption?>()
.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(