feat: implement alias strategy for receipt import with user-scoped and global fallback, enhance validation and normalization, and update UI components
Test Suite / test (24.15.0) (push) Has been cancelled

This commit is contained in:
Nils-Johan Gynther
2026-05-09 23:41:42 +02:00
parent b342de906e
commit 65137b41fb
17 changed files with 388 additions and 67 deletions
@@ -19,6 +19,7 @@ class EditDialog extends StatefulWidget {
final List<AdminCategoryNode> categoryTree;
final Future<ProductOption?> Function(String name, int? categoryId)? onCreate;
final ImportProductEntryMode? initialEntryMode;
final bool canLearnGlobalAlias;
const EditDialog({
super.key,
@@ -28,6 +29,7 @@ class EditDialog extends StatefulWidget {
required this.categoryTree,
this.onCreate,
this.initialEntryMode,
this.canLearnGlobalAlias = false,
});
@override
@@ -53,6 +55,8 @@ class _EditDialogState extends State<EditDialog> {
_Destination _destination = _Destination.inventory;
ImportProductEntryMode _entryMode = ImportProductEntryMode.existing;
bool _isCreatingProduct = false;
bool _learnAlias = false;
bool _learnAliasGlobally = false;
// Lokal lista — utökas om nya produkter skapas under dialogen
late List<ProductOption> _localProducts;
@@ -68,6 +72,8 @@ class _EditDialogState extends State<EditDialog> {
_productName = widget.current.productName == null
? null
: normalizeProductName(widget.current.productName!);
_learnAlias = widget.current.learnAlias;
_learnAliasGlobally = widget.current.learnAliasGlobally;
_destination = widget.current.destination;
_entryMode = widget.initialEntryMode ??
(_productId == null
@@ -273,6 +279,8 @@ class _EditDialogState extends State<EditDialog> {
ItemEdit(
productId: _productId,
productName: _productName,
learnAlias: _learnAlias,
learnAliasGlobally: _learnAlias && widget.canLearnGlobalAlias && _learnAliasGlobally,
categoryId: _productCategoryId,
categoryPath: _productCategoryPath,
categorySource: _productCategorySource,
@@ -358,6 +366,8 @@ class _EditDialogState extends State<EditDialog> {
else
_buildCreateProductSection(theme, aiLabel),
const SizedBox(height: 12),
_buildAliasSection(theme, item),
const SizedBox(height: 12),
if (_destination == _Destination.inventory)
_buildQuantitySection(theme, totalPreview, currentUnit)
else
@@ -429,6 +439,70 @@ class _EditDialogState extends State<EditDialog> {
style: const ButtonStyle(visualDensity: VisualDensity.compact),
);
Widget _buildAliasSection(ThemeData theme, ParsedReceiptItem item) {
final alreadyAliasMatch =
_entryMode == ImportProductEntryMode.existing &&
_productId != null &&
item.matchedVia == 'alias' &&
item.matchedProductId == _productId;
if (alreadyAliasMatch) {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: theme.colorScheme.primaryContainer.withValues(alpha: 0.45),
borderRadius: BorderRadius.circular(12),
),
child: Text(
'Det här kvittonamnet matchades redan via alias. Ingen ny aliasinlärning behövs.',
style: theme.textTheme.bodySmall,
),
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CheckboxListTile(
contentPadding: EdgeInsets.zero,
value: _learnAlias,
onChanged: (value) => setState(() {
_learnAlias = value ?? false;
if (!_learnAlias) _learnAliasGlobally = false;
}),
title: const Text('Lär detta kvittonamn för framtiden'),
subtitle: const Text(
'Sparar ett alias så att samma kvittonamn kan matchas direkt vid nästa import.',
),
controlAffinity: ListTileControlAffinity.leading,
),
if (widget.canLearnGlobalAlias && _learnAlias)
Padding(
padding: const EdgeInsets.only(left: 12),
child: SegmentedButton<bool>(
segments: const [
ButtonSegment<bool>(
value: false,
label: Text('Privat alias'),
icon: Icon(Icons.lock_outline, size: 16),
),
ButtonSegment<bool>(
value: true,
label: Text('Global fallback'),
icon: Icon(Icons.public_outlined, size: 16),
),
],
selected: {_learnAliasGlobally},
onSelectionChanged: (selection) =>
setState(() => _learnAliasGlobally = selection.first),
style: const ButtonStyle(visualDensity: VisualDensity.compact),
),
),
],
);
}
Widget _buildExistingProductSection(
ThemeData theme,
ParsedReceiptItem item,
@@ -402,6 +402,7 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
products: _products,
categoryTree: _categoryTree,
initialEntryMode: initialEntryMode,
canLearnGlobalAlias: ref.read(isAdminProvider),
onCreate: (name, categoryId) async {
final token = await ref.read(authStateProvider.future);
final api = ref.read(apiClientProvider);
@@ -478,7 +479,6 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
try {
final token = await ref.read(authStateProvider.future);
final repo = ref.read(importRepositoryProvider);
final canManageAliases = ref.read(isAdminProvider);
// Bygg upp items för saveReceipt endpoint
final saveItems = <Map<String, dynamic>>[];
@@ -507,10 +507,13 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
if (edit.packageCount != null) saveItem['packageCount'] = edit.packageCount;
}
// Lär in alias om den inte redan matchades via alias
// Lär in alias bara om användaren uttryckligen valt det
final alreadyAliasMatch = item.matchedVia == 'alias' && item.matchedProductId == pid;
if (item.rawName.trim().isNotEmpty && !alreadyAliasMatch) {
if (edit.learnAlias && item.rawName.trim().isNotEmpty && !alreadyAliasMatch) {
saveItem['learnAlias'] = true;
if (edit.learnAliasGlobally) {
saveItem['learnAliasGlobally'] = true;
}
}
// Lär in enhetsmappning för inventory
@@ -528,7 +531,6 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
// Gör ett enda anrop till saveReceipt
final response = await repo.saveReceipt(
items: saveItems,
isAdminLearning: canManageAliases,
token: token,
);