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
+5 -1
View File
@@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:js_interop'; import 'dart:js_interop';
import 'dart:typed_data'; import 'dart:typed_data';
@@ -14,6 +15,9 @@ Future<bool> openPdfBytes(Uint8List bytes) async {
web.URL.revokeObjectURL(url); web.URL.revokeObjectURL(url);
return false; return false;
} }
web.URL.revokeObjectURL(url); // Revoke later to avoid revoking before the new tab has loaded the blob.
Future<void>.delayed(const Duration(seconds: 30), () {
web.URL.revokeObjectURL(url);
});
return true; return true;
} }
@@ -22,6 +22,70 @@ typedef _Destination = ImportDestination;
enum _ProductEntryMode { existing, create } 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 ───────────────────────────────────────────── // ── Redigeringstillstånd per rad ─────────────────────────────────────────────
typedef _ItemEdit = ItemEdit; 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 { bool get _canConfirm {
if (_entryMode == _ProductEntryMode.create) { if (_entryMode == _ProductEntryMode.create) {
return !_isCreatingProduct && return !_isCreatingProduct &&
@@ -232,6 +315,16 @@ class _EditDialogState extends State<_EditDialog> {
final aiLabel = (aiPath != null && aiPath.isNotEmpty) final aiLabel = (aiPath != null && aiPath.isNotEmpty)
? aiPath ? aiPath
: ((aiCategory != null && aiCategory.isNotEmpty) ? aiCategory : null); : ((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( return AlertDialog(
title: Text(item.rawName, maxLines: 2, overflow: TextOverflow.ellipsis), title: Text(item.rawName, maxLines: 2, overflow: TextOverflow.ellipsis),
@@ -279,43 +372,66 @@ class _EditDialogState extends State<_EditDialog> {
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
if (_entryMode == _ProductEntryMode.existing) if (_entryMode == _ProductEntryMode.existing)
Row( Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Expanded( Row(
child: ProductPickerField( children: [
products: _localProducts, Expanded(
value: _productId, child: ProductPickerField(
label: 'Produkt', products: _localProducts,
initialQuery: item.rawName, value: _productId,
onChanged: (id) { label: 'Produkt',
setState(() { initialQuery: item.rawName,
_productId = id; onChanged: (id) {
_productName = id == null setState(() {
? null _productId = id;
: _localProducts _productName = id == null
.cast<ProductOption?>() ? null
.firstWhere((p) => p?.id == id, orElse: () => null) : _localProducts
?.name; .cast<ProductOption?>()
_productCategoryId = _categoryIdForProduct(id); .firstWhere((p) => p?.id == id, orElse: () => null)
_productCategoryPath = _categoryPathForCategoryId(_productCategoryId); ?.name;
_productCategorySource = _productCategoryId = _categoryIdForProduct(id);
id == null ? null : CategorySelectionSource.manual; _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,
), ),
onPressed: () => _openExistingCategoryPicker(), const SizedBox(width: 8),
child: const Icon(Icons.account_tree_outlined, size: 20), 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 ...[ else ...[
@@ -406,6 +522,7 @@ class _EditDialogState extends State<_EditDialog> {
controller: _quantityCtrl, controller: _quantityCtrl,
keyboardType: const TextInputType.numberWithOptions(decimal: true), keyboardType: const TextInputType.numberWithOptions(decimal: true),
decoration: const InputDecoration(labelText: 'Antal', border: OutlineInputBorder()), decoration: const InputDecoration(labelText: 'Antal', border: OutlineInputBorder()),
onChanged: (_) => setState(() {}),
), ),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
@@ -413,9 +530,29 @@ class _EditDialogState extends State<_EditDialog> {
child: TextField( child: TextField(
controller: _unitCtrl, controller: _unitCtrl,
decoration: const InputDecoration(labelText: 'Enhet', border: OutlineInputBorder()), 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 ...[ ] else ...[
Text( Text(
'Baslager sparar bara produkt — ingen mängd eller enhet.', 'Baslager sparar bara produkt — ingen mängd eller enhet.',
@@ -708,8 +845,13 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
pantryAdded++; pantryAdded++;
} }
} else { } else {
final qty = edit.quantity ?? item.quantity ?? 1.0; final normalized = _normalizePackageQuantityFromRawName(
final unit = edit.unit ?? item.unit ?? 'st'; 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]; final existing = _inventoryByProduct[pid];
if (existing != null) { if (existing != null) {
await invRepo.updateInventoryItem( await invRepo.updateInventoryItem(