feat: add initial query support to ProductPickerField and enhance ParsedReceiptItem with categorySuggestionPath
This commit is contained in:
@@ -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,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
Reference in New Issue
Block a user