feat: add package quantity normalization and AI suggestion handling in receipt import
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import 'dart:async';
|
||||
import 'dart:js_interop';
|
||||
import 'dart:typed_data';
|
||||
|
||||
@@ -14,6 +15,9 @@ Future<bool> 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<void>.delayed(const Duration(seconds: 30), () {
|
||||
web.URL.revokeObjectURL(url);
|
||||
});
|
||||
return true;
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user