feat: enhance ProductPickerField and ParsedReceiptItem to support category filtering in receipt import
This commit is contained in:
@@ -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>>{};
|
||||||
|
|||||||
Reference in New Issue
Block a user