feat: separate AI and product suggestion chips, normalize product names, and validate AI categories

This commit is contained in:
Nils-Johan Gynther
2026-05-01 23:59:16 +02:00
parent 2c71970fb5
commit d3dac61765
4 changed files with 151 additions and 41 deletions
@@ -148,7 +148,9 @@ class _EditDialogState extends State<_EditDialog> {
void initState() {
super.initState();
_productId = widget.current.productId;
_productName = widget.current.productName;
_productName = widget.current.productName == null
? null
: _normalizeProductName(widget.current.productName!);
_destination = widget.current.destination;
_entryMode = widget.initialEntryMode ??
(_productId == null ? _ProductEntryMode.create : _ProductEntryMode.existing);
@@ -241,10 +243,11 @@ class _EditDialogState extends State<_EditDialog> {
if (id != null && mounted) {
setState(() {
_productId = id;
_productName = _localProducts
final selectedName = _localProducts
.cast<ProductOption?>()
.firstWhere((p) => p?.id == id, orElse: () => null)
?.name;
_productName = selectedName == null ? null : _normalizeProductName(selectedName);
_productCategoryId = _categoryIdForProduct(id);
_productCategoryPath = _categoryPathForCategoryId(_productCategoryId);
_productCategorySource = CategorySelectionSource.manual;
@@ -257,7 +260,9 @@ class _EditDialogState extends State<_EditDialog> {
if (suggestedId != null) {
setState(() {
_productId = suggestedId;
_productName = widget.item.suggestedProductName;
_productName = widget.item.suggestedProductName == null
? null
: _normalizeProductName(widget.item.suggestedProductName!);
_productCategoryId = _categoryIdForProduct(suggestedId) ?? widget.item.categorySuggestionId;
_productCategoryPath =
_categoryPathForCategoryId(_productCategoryId) ?? widget.item.categorySuggestionPath;
@@ -331,7 +336,7 @@ class _EditDialogState extends State<_EditDialog> {
final suggestedProductLabel = (item.suggestedProductId != null &&
item.suggestedProductName?.isNotEmpty == true &&
item.matchedProductId == null)
? item.suggestedProductName
? _normalizeProductName(item.suggestedProductName!)
: null;
final currentQuantity =
double.tryParse(_quantityCtrl.text.replaceAll(',', '.')) ?? widget.item.quantity;
@@ -345,7 +350,11 @@ class _EditDialogState extends State<_EditDialog> {
: null;
return AlertDialog(
title: Text(item.rawName, maxLines: 2, overflow: TextOverflow.ellipsis),
title: Text(
_normalizeProductName(item.rawName),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
@@ -393,6 +402,27 @@ class _EditDialogState extends State<_EditDialog> {
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (suggestedProductLabel != null) ...[
Tooltip(
message: 'Trolig matchning baserat på produktnamn i databasen',
child: ActionChip(
avatar: Icon(
Icons.search,
size: 14,
color: Colors.blue.shade700,
),
label: Text(
'Namnförslag: $suggestedProductLabel',
style: theme.textTheme.labelSmall,
),
backgroundColor: Colors.blue.shade50,
side: BorderSide(color: Colors.blue.shade300),
visualDensity: VisualDensity.compact,
onPressed: _applyAiSuggestionForExistingSelection,
),
),
const SizedBox(height: 8),
],
Row(
children: [
Expanded(
@@ -404,12 +434,15 @@ class _EditDialogState extends State<_EditDialog> {
onChanged: (id) {
setState(() {
_productId = id;
_productName = id == null
? null
: _localProducts
.cast<ProductOption?>()
.firstWhere((p) => p?.id == id, orElse: () => null)
?.name;
final selectedName = id == null
? null
: _localProducts
.cast<ProductOption?>()
.firstWhere((p) => p?.id == id, orElse: () => null)
?.name;
_productName = selectedName == null
? null
: _normalizeProductName(selectedName);
_productCategoryId = _categoryIdForProduct(id);
_productCategoryPath = _categoryPathForCategoryId(_productCategoryId);
_productCategorySource =
@@ -432,24 +465,28 @@ class _EditDialogState extends State<_EditDialog> {
),
],
),
if (suggestedProductLabel != null) ...[ const SizedBox(height: 8),
if (_productCategoryPath != null) ...[
const SizedBox(height: 8),
ActionChip(
avatar: Icon(
Icons.search,
Icons.account_tree_outlined,
size: 14,
color: Colors.blue.shade700,
color: theme.colorScheme.primary,
),
label: Text(
'Förslag: $suggestedProductLabel',
'Kategori: $_productCategoryPath',
style: theme.textTheme.labelSmall,
overflow: TextOverflow.ellipsis,
),
backgroundColor: Colors.blue.shade50,
side: BorderSide(color: Colors.blue.shade300),
side: BorderSide(color: theme.colorScheme.outlineVariant),
visualDensity: VisualDensity.compact,
onPressed: _applyAiSuggestionForExistingSelection,
onPressed: () => _openExistingCategoryPicker(
preselectedCategoryId: _productCategoryId,
),
),
],
if (aiLabel != null) ...[ const SizedBox(height: 8),
if (aiLabel != null) ...[
const SizedBox(height: 8),
ActionChip(
avatar: Icon(
Icons.auto_awesome,
@@ -651,6 +688,30 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
_loadProducts();
}
int? _categoryIdForProduct(int? productId) {
if (productId == null) return null;
return _products
.cast<ProductOption?>()
.firstWhere((p) => p?.id == productId, orElse: () => null)
?.categoryId;
}
String? _categoryPathForCategoryId(int? categoryId) {
if (categoryId == null) return null;
List<String>? walk(List<AdminCategoryNode> nodes, List<String> parents) {
for (final node in nodes) {
final path = [...parents, node.name];
if (node.id == categoryId) return path;
final found = walk(node.children, path);
if (found != null) return found;
}
return null;
}
return walk(_categoryTree, const [])?.join(' > ');
}
Future<void> _loadProducts() async {
try {
final token = await ref.read(authStateProvider.future);
@@ -761,11 +822,14 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
notifier.setSelected(i, pid != null);
if (pid != null) {
final name = it.matchedProductName ?? it.suggestedProductName;
final resolvedCategoryId = it.categorySuggestionId ?? _categoryIdForProduct(pid);
final resolvedCategoryPath = it.categorySuggestionPath ??
_categoryPathForCategoryId(resolvedCategoryId);
notifier.setEdit(i, _ItemEdit(
productId: pid,
productName: name,
categoryId: it.categorySuggestionId,
categoryPath: it.categorySuggestionPath,
categoryId: resolvedCategoryId,
categoryPath: resolvedCategoryPath,
categorySource: it.categorySuggestionId != null
? CategorySelectionSource.ai
: null,
@@ -792,8 +856,13 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
_ItemEdit(
productId: item.matchedProductId ?? item.suggestedProductId,
productName: item.matchedProductName ?? item.suggestedProductName,
categoryId: item.categorySuggestionId,
categoryPath: item.categorySuggestionPath,
categoryId: item.categorySuggestionId ??
_categoryIdForProduct(item.matchedProductId ?? item.suggestedProductId),
categoryPath: item.categorySuggestionPath ??
_categoryPathForCategoryId(
item.categorySuggestionId ??
_categoryIdForProduct(item.matchedProductId ?? item.suggestedProductId),
),
categorySource: item.categorySuggestionId != null
? CategorySelectionSource.ai
: null,
@@ -954,7 +1023,7 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
? null
: OutlinedButton.icon(
icon: const Icon(Icons.open_in_new, size: 16),
label: const Text('Öppna PDF'),
label: const Text('Visa kvitto'),
style: OutlinedButton.styleFrom(visualDensity: VisualDensity.compact),
onPressed: () async {
final opened = await openPdfBytes(bytes);
@@ -979,15 +1048,7 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
),
)
else
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
child: Text(
'PDF-förhandsvisning stöds inte i appen — se importerade rader nedan.',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
const SizedBox(height: 8),
],
),
);
@@ -1085,7 +1146,10 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
setState(() {});
},
),
title: Text(item.rawName, style: theme.textTheme.bodyMedium),
title: Text(
_normalizeProductName(item.rawName),
style: theme.textTheme.bodyMedium,
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -1106,10 +1170,13 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
runSpacing: 4,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
Text(edit!.productName ?? '', style: theme.textTheme.bodySmall?.copyWith(
color: isMatched ? Colors.green.shade700 : theme.colorScheme.primary,
fontWeight: FontWeight.w500,
)),
Text(
'Produktnamn: ${_normalizeProductName(edit!.productName ?? '')}',
style: theme.textTheme.bodySmall?.copyWith(
color: isMatched ? Colors.green.shade700 : theme.colorScheme.primary,
fontWeight: FontWeight.w500,
),
),
if (edit.categorySource != null)
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
@@ -1136,7 +1203,7 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
],
)
else if (isSuggested)
Text('Förslag: ${item.suggestedProductName ?? ''}',
Text('Namnförslag: ${_normalizeProductName(item.suggestedProductName ?? '')}',
style: theme.textTheme.bodySmall?.copyWith(color: Colors.orange.shade700))
else
Text('Ingen matchning ännu — tryck för att välja eller skapa produkt',
@@ -1144,7 +1211,7 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
if (hasProduct && edit?.categoryPath != null) ...[
const SizedBox(height: 2),
Text(
edit!.categoryPath!,
'Kategori: ${edit!.categoryPath!}',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),