feat: add package quantity normalization and AI suggestion handling in receipt import

This commit is contained in:
Nils-Johan Gynther
2026-05-01 23:18:32 +02:00
parent 32e83caa35
commit e4f1aae047
2 changed files with 183 additions and 37 deletions
@@ -22,6 +22,70 @@ typedef _Destination = ImportDestination;
enum _ProductEntryMode { existing, create }
({double quantity, String unit})? _normalizePackageQuantityFromRawName({
required String rawName,
required double? quantity,
required String? unit,
}) {
if (quantity == null || unit == null) return null;
final normalizedUnit = unit.trim().toLowerCase();
const packageUnits = {
'paket',
'forpackning',
'forp',
'forp.',
'förpackning',
'förp',
'förp.',
'fp',
'pkt',
'pack',
'st',
'styck',
};
if (!packageUnits.contains(normalizedUnit)) return null;
final match = RegExp(
r'(\d+(?:[\.,]\d+)?)\s*(ml|cl|dl|l|g|kg)\b',
caseSensitive: false,
).firstMatch(rawName);
if (match == null) return null;
final value = double.tryParse(match.group(1)!.replaceAll(',', '.'));
final sizeUnit = match.group(2)!.toLowerCase();
if (value == null) return null;
switch (sizeUnit) {
case 'ml':
return (quantity: quantity * (value / 1000.0), unit: 'l');
case 'cl':
return (quantity: quantity * (value / 100.0), unit: 'l');
case 'dl':
return (quantity: quantity * (value / 10.0), unit: 'l');
case 'l':
return (quantity: quantity * value, unit: 'l');
case 'g':
return (quantity: quantity * (value / 1000.0), unit: 'kg');
case 'kg':
return (quantity: quantity * value, unit: 'kg');
default:
return null;
}
}
String _formatCompactNumber(double value) {
if (value == value.roundToDouble()) return value.toStringAsFixed(0);
final formatted = value.toStringAsFixed(3);
return formatted
.replaceFirst(RegExp(r'0+$'), '')
.replaceFirst(RegExp(r'\.$'), '');
}
String _formatSwedishNumber(double value) {
return _formatCompactNumber(value).replaceAll('.', ',');
}
// ── Redigeringstillstånd per rad ─────────────────────────────────────────────
typedef _ItemEdit = ItemEdit;
@@ -175,6 +239,25 @@ class _EditDialogState extends State<_EditDialog> {
}
}
void _applyAiSuggestionForExistingSelection() {
final suggestedId = widget.item.suggestedProductId;
if (suggestedId != null) {
setState(() {
_productId = suggestedId;
_productName = widget.item.suggestedProductName;
_productCategoryId = _categoryIdForProduct(suggestedId) ?? widget.item.categorySuggestionId;
_productCategoryPath =
_categoryPathForCategoryId(_productCategoryId) ?? widget.item.categorySuggestionPath;
_productCategorySource = CategorySelectionSource.ai;
});
return;
}
_openExistingCategoryPicker(
preselectedCategoryId: widget.item.categorySuggestionId,
);
}
bool get _canConfirm {
if (_entryMode == _ProductEntryMode.create) {
return !_isCreatingProduct &&
@@ -232,6 +315,16 @@ class _EditDialogState extends State<_EditDialog> {
final aiLabel = (aiPath != null && aiPath.isNotEmpty)
? aiPath
: ((aiCategory != null && aiCategory.isNotEmpty) ? aiCategory : null);
final currentQuantity =
double.tryParse(_quantityCtrl.text.replaceAll(',', '.')) ?? widget.item.quantity;
final currentUnit = _unitCtrl.text.trim().isEmpty ? widget.item.unit : _unitCtrl.text.trim();
final normalizationPreview = _destination == _Destination.inventory
? _normalizePackageQuantityFromRawName(
rawName: item.rawName,
quantity: currentQuantity,
unit: currentUnit,
)
: null;
return AlertDialog(
title: Text(item.rawName, maxLines: 2, overflow: TextOverflow.ellipsis),
@@ -279,43 +372,66 @@ class _EditDialogState extends State<_EditDialog> {
),
const SizedBox(height: 12),
if (_entryMode == _ProductEntryMode.existing)
Row(
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: ProductPickerField(
products: _localProducts,
value: _productId,
label: 'Produkt',
initialQuery: item.rawName,
onChanged: (id) {
setState(() {
_productId = id;
_productName = id == null
? null
: _localProducts
.cast<ProductOption?>()
.firstWhere((p) => p?.id == id, orElse: () => null)
?.name;
_productCategoryId = _categoryIdForProduct(id);
_productCategoryPath = _categoryPathForCategoryId(_productCategoryId);
_productCategorySource =
id == null ? null : CategorySelectionSource.manual;
});
},
),
),
const SizedBox(width: 8),
Tooltip(
message: 'Välj via kategori',
child: OutlinedButton(
style: OutlinedButton.styleFrom(
minimumSize: const Size(44, 56),
padding: EdgeInsets.zero,
Row(
children: [
Expanded(
child: ProductPickerField(
products: _localProducts,
value: _productId,
label: 'Produkt',
initialQuery: item.rawName,
onChanged: (id) {
setState(() {
_productId = id;
_productName = id == null
? null
: _localProducts
.cast<ProductOption?>()
.firstWhere((p) => p?.id == id, orElse: () => null)
?.name;
_productCategoryId = _categoryIdForProduct(id);
_productCategoryPath = _categoryPathForCategoryId(_productCategoryId);
_productCategorySource =
id == null ? null : CategorySelectionSource.manual;
});
},
),
),
onPressed: () => _openExistingCategoryPicker(),
child: const Icon(Icons.account_tree_outlined, size: 20),
),
const SizedBox(width: 8),
Tooltip(
message: 'Välj via kategori',
child: OutlinedButton(
style: OutlinedButton.styleFrom(
minimumSize: const Size(44, 56),
padding: EdgeInsets.zero,
),
onPressed: () => _openExistingCategoryPicker(),
child: const Icon(Icons.account_tree_outlined, size: 20),
),
),
],
),
if (aiLabel != null) ...[
const SizedBox(height: 8),
ActionChip(
avatar: Icon(
Icons.auto_awesome,
size: 14,
color: Colors.green.shade700,
),
label: Text(
'AI-forslag: $aiLabel',
style: theme.textTheme.labelSmall,
),
backgroundColor: Colors.green.shade50,
side: BorderSide(color: Colors.green.shade300),
visualDensity: VisualDensity.compact,
onPressed: _applyAiSuggestionForExistingSelection,
),
],
],
)
else ...[
@@ -406,6 +522,7 @@ class _EditDialogState extends State<_EditDialog> {
controller: _quantityCtrl,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
decoration: const InputDecoration(labelText: 'Antal', border: OutlineInputBorder()),
onChanged: (_) => setState(() {}),
),
),
const SizedBox(width: 8),
@@ -413,9 +530,29 @@ class _EditDialogState extends State<_EditDialog> {
child: TextField(
controller: _unitCtrl,
decoration: const InputDecoration(labelText: 'Enhet', border: OutlineInputBorder()),
onChanged: (_) => setState(() {}),
),
),
]),
if (normalizationPreview != null) ...[
const SizedBox(height: 8),
Container(
width: double.infinity,
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: Colors.green.shade50,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: Colors.green.shade200),
),
child: Text(
'Tolkat som totalt ${_formatSwedishNumber(normalizationPreview.quantity)} ${normalizationPreview.unit} '
'(antal x förpackningsstorlek).',
style: theme.textTheme.bodySmall?.copyWith(
color: Colors.green.shade800,
),
),
),
],
] else ...[
Text(
'Baslager sparar bara produkt — ingen mängd eller enhet.',
@@ -708,8 +845,13 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
pantryAdded++;
}
} else {
final qty = edit.quantity ?? item.quantity ?? 1.0;
final unit = edit.unit ?? item.unit ?? 'st';
final normalized = _normalizePackageQuantityFromRawName(
rawName: item.rawName,
quantity: edit.quantity ?? item.quantity,
unit: edit.unit ?? item.unit,
);
final qty = normalized?.quantity ?? edit.quantity ?? item.quantity ?? 1.0;
final unit = normalized?.unit ?? edit.unit ?? item.unit ?? 'st';
final existing = _inventoryByProduct[pid];
if (existing != null) {
await invRepo.updateInventoryItem(