feat: enhance ProductPickerField and ParsedReceiptItem to support category filtering in receipt import

This commit is contained in:
Nils-Johan Gynther
2026-05-01 02:05:53 +02:00
parent 47801935e9
commit 84dfbc4a52
4 changed files with 35 additions and 9 deletions
+26 -5
View File
@@ -4,7 +4,7 @@ import 'package:flutter/material.dart';
typedef ValueChanged<T> = void Function(T value); typedef ValueChanged<T> = void Function(T value);
/// A named record representing a selectable product option. /// A named record representing a selectable product option.
typedef ProductOption = ({int id, String name}); typedef ProductOption = ({int id, String name, int? categoryId});
/// A form field that opens a searchable bottom sheet for selecting a product. /// A form field that opens a searchable bottom sheet for selecting a product.
/// ///
@@ -110,19 +110,28 @@ class ProductPickerField extends StatelessWidget {
/// Öppnar produktväljarens bottenark utan att binda den till en specifik widget-instans. /// Öppnar produktväljarens bottenark utan att binda den till en specifik widget-instans.
/// Returnerar valt produkt-id, null (ingen ändring), eller [_clearSelectionToken] (rensa). /// Returnerar valt produkt-id, null (ingen ändring), eller [_clearSelectionToken] (rensa).
///
/// [categoryFilter] — om satt visas bara produkter vars categoryId finns i listan.
/// Används med AI-kategorisuggestion för att förifiltrera på rätt kategorigren.
static Future<int?> showSheet( static Future<int?> showSheet(
BuildContext context, { BuildContext context, {
required List<ProductOption> products, required List<ProductOption> products,
int? value, int? value,
String label = 'Produkt', String label = 'Produkt',
String? initialQuery, String? initialQuery,
Set<int>? categoryFilter,
}) async { }) async {
// Filtrera på kategori om angiven, men visa alla om filtret ger nollresultat
final baseList = categoryFilter != null && categoryFilter.isNotEmpty
? products.where((p) => p.categoryId != null && categoryFilter.contains(p.categoryId)).toList()
: products;
final useList = baseList.isNotEmpty ? baseList : products;
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) {
// Skapa controller EN gång per öppnad ark — inte inuti StatefulBuilder
final controller = TextEditingController(text: initialQuery ?? ''); final controller = TextEditingController(text: initialQuery ?? '');
var query = initialQuery ?? ''; var query = initialQuery ?? '';
@@ -130,11 +139,13 @@ class ProductPickerField extends StatelessWidget {
builder: (ctx, setModalState) { builder: (ctx, setModalState) {
final normalizedQuery = query.trim().toLowerCase(); final normalizedQuery = query.trim().toLowerCase();
final filtered = normalizedQuery.isEmpty final filtered = normalizedQuery.isEmpty
? products ? useList
: products : useList
.where((p) => p.name.toLowerCase().contains(normalizedQuery)) .where((p) => p.name.toLowerCase().contains(normalizedQuery))
.toList(); .toList();
final isFiltered = useList.length < products.length;
return SizedBox( return SizedBox(
height: MediaQuery.of(ctx).size.height * 0.85, height: MediaQuery.of(ctx).size.height * 0.85,
child: Column( child: Column(
@@ -144,7 +155,17 @@ class ProductPickerField extends StatelessWidget {
child: Row( child: Row(
children: [ children: [
Expanded( Expanded(
child: Text(label, style: Theme.of(ctx).textTheme.titleMedium), child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: Theme.of(ctx).textTheme.titleMedium),
if (isFiltered)
Text(
'Visar ${useList.length} produkter i föreslagen kategori',
style: Theme.of(ctx).textTheme.labelSmall?.copyWith(color: Colors.green.shade700),
),
],
),
), ),
TextButton.icon( TextButton.icon(
onPressed: () => Navigator.pop(ctx, _clearSelectionToken), onPressed: () => Navigator.pop(ctx, _clearSelectionToken),
@@ -15,6 +15,7 @@ class ParsedReceiptItem {
// AI-kategorisuggestion (premium) // AI-kategorisuggestion (premium)
final String? categorySuggestionName; final String? categorySuggestionName;
final String? categorySuggestionPath; final String? categorySuggestionPath;
final int? categorySuggestionId;
ParsedReceiptItem({ ParsedReceiptItem({
required this.rawName, required this.rawName,
@@ -29,6 +30,7 @@ class ParsedReceiptItem {
this.suggestedProductName, this.suggestedProductName,
this.categorySuggestionName, this.categorySuggestionName,
this.categorySuggestionPath, this.categorySuggestionPath,
this.categorySuggestionId,
}); });
factory ParsedReceiptItem.fromJson(Map<String, dynamic> json) { factory ParsedReceiptItem.fromJson(Map<String, dynamic> json) {
@@ -46,6 +48,7 @@ class ParsedReceiptItem {
suggestedProductName: json['suggestedProductName'] as String?, suggestedProductName: json['suggestedProductName'] as String?,
categorySuggestionName: cat?['categoryName'] as String?, categorySuggestionName: cat?['categoryName'] as String?,
categorySuggestionPath: cat?['path'] as String?, categorySuggestionPath: cat?['path'] as String?,
categorySuggestionId: (cat?['categoryId'] as num?)?.toInt(),
); );
} }
} }
@@ -98,14 +98,16 @@ class _EditDialogState extends State<_EditDialog> {
_productName = item.suggestedProductName; _productName = item.suggestedProductName;
}); });
} else if (aiCategory != null) { } else if (aiCategory != null) {
// Öppna pickern med råtexten från kvittot som sökord — kategorinamnet // Öppna pickern filtrerad på AI-föreslagen kategori (categoryId).
// matchar inte produktnamn, men rawName gör det troligtvis // Visar bara produkter i den kategorin (eller rawName-sökning om kategorin är tom).
final catId = item.categorySuggestionId;
ProductPickerField.showSheet( ProductPickerField.showSheet(
context, context,
products: widget.products, products: widget.products,
value: _productId, value: _productId,
label: 'Produkt', label: 'Produkt',
initialQuery: item.rawName, initialQuery: item.rawName,
categoryFilter: catId != null ? {catId} : null,
).then((id) { ).then((id) {
if (id != null && mounted) { if (id != null && mounted) {
setState(() { setState(() {
@@ -276,7 +278,7 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
setState(() { setState(() {
_products = list _products = list
.cast<Map<String, dynamic>>() .cast<Map<String, dynamic>>()
.map((e) => (id: e['id'] as int, name: e['name'] as String)) .map((e) => (id: e['id'] as int, name: e['name'] as String, categoryId: (e['categoryId'] as num?)?.toInt()))
.toList(); .toList();
_loadingProducts = false; _loadingProducts = false;
}); });
@@ -270,7 +270,7 @@ class _PantryScreenState extends ConsumerState<PantryScreen> {
(a, b) => a.displayName.toLowerCase().compareTo(b.displayName.toLowerCase()), (a, b) => a.displayName.toLowerCase().compareTo(b.displayName.toLowerCase()),
); );
final availableOptions = availableProducts final availableOptions = availableProducts
.map((p) => (id: p.id, name: p.displayName)) .map((p) => (id: p.id, name: p.displayName, categoryId: null as int?))
.toList(); .toList();
final grouped = <String, List<PantryItem>>{}; final grouped = <String, List<PantryItem>>{};