From e4f1aae0473a02c12ef7a2882e70f3c8b5268e72 Mon Sep 17 00:00:00 2001 From: Nils-Johan Gynther Date: Fri, 1 May 2026 23:18:32 +0200 Subject: [PATCH] feat: add package quantity normalization and AI suggestion handling in receipt import --- flutter/lib/core/utils/pdf_opener_web.dart | 6 +- .../presentation/receipt_import_tab.dart | 214 +++++++++++++++--- 2 files changed, 183 insertions(+), 37 deletions(-) diff --git a/flutter/lib/core/utils/pdf_opener_web.dart b/flutter/lib/core/utils/pdf_opener_web.dart index bb398f7f..2f512285 100644 --- a/flutter/lib/core/utils/pdf_opener_web.dart +++ b/flutter/lib/core/utils/pdf_opener_web.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:js_interop'; import 'dart:typed_data'; @@ -14,6 +15,9 @@ Future openPdfBytes(Uint8List bytes) async { web.URL.revokeObjectURL(url); return false; } - web.URL.revokeObjectURL(url); + // Revoke later to avoid revoking before the new tab has loaded the blob. + Future.delayed(const Duration(seconds: 30), () { + web.URL.revokeObjectURL(url); + }); return true; } \ No newline at end of file diff --git a/flutter/lib/features/import/presentation/receipt_import_tab.dart b/flutter/lib/features/import/presentation/receipt_import_tab.dart index 7f0094f6..6da7a522 100644 --- a/flutter/lib/features/import/presentation/receipt_import_tab.dart +++ b/flutter/lib/features/import/presentation/receipt_import_tab.dart @@ -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() - .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() + .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 { 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(