feat(receipt-import): enhance package handling with new fields and UI adjustments

This commit is contained in:
Nils-Johan Gynther
2026-05-02 10:11:31 +02:00
parent f9dc55d42c
commit 9161de5d52
4 changed files with 251 additions and 78 deletions
@@ -18,6 +18,9 @@ class ItemEdit {
final CategorySelectionSource? categorySource;
final double? quantity;
final String? unit;
final double? packQuantity;
final String? packUnit;
final double? packageCount;
final ImportDestination destination;
const ItemEdit({
@@ -28,6 +31,9 @@ class ItemEdit {
this.categorySource,
this.quantity,
this.unit,
this.packQuantity,
this.packUnit,
this.packageCount,
this.destination = ImportDestination.inventory,
});
}
@@ -22,14 +22,8 @@ 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();
bool _isPackageLikeUnit(String? unit) {
if (unit == null) return false;
const packageUnits = {
'paket',
'forpackning',
@@ -41,37 +35,63 @@ enum _ProductEntryMode { existing, create }
'fp',
'pkt',
'pack',
'pak',
'st',
'styck',
};
if (!packageUnits.contains(normalizedUnit)) return null;
return packageUnits.contains(unit.trim().toLowerCase());
}
({double packQuantity, String packUnit})? _extractPackageSizeFromRawName(
String rawName,
) {
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;
return (packQuantity: value, packUnit: sizeUnit);
}
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;
({double? packQuantity, String? packUnit, double packageCount, double? totalQuantity, String? totalUnit})
_inferPackageFields({
required String rawName,
required double? quantity,
required String? unit,
}) {
if (quantity == null || unit == null) {
return (
packQuantity: null,
packUnit: null,
packageCount: 1,
totalQuantity: quantity,
totalUnit: unit,
);
}
final looksLikePackage = _isPackageLikeUnit(unit);
final extracted = _extractPackageSizeFromRawName(rawName);
if (looksLikePackage && extracted != null) {
return (
packQuantity: extracted.packQuantity,
packUnit: extracted.packUnit,
packageCount: quantity,
totalQuantity: extracted.packQuantity * quantity,
totalUnit: extracted.packUnit,
);
}
return (
packQuantity: quantity,
packUnit: unit,
packageCount: 1,
totalQuantity: quantity,
totalUnit: unit,
);
}
String _formatCompactNumber(double value) {
@@ -86,6 +106,48 @@ String _formatSwedishNumber(double value) {
return _formatCompactNumber(value).replaceAll('.', ',');
}
double? _convertQuantity(double quantity, String fromUnit, String toUnit) {
final from = fromUnit.trim().toLowerCase();
final to = toUnit.trim().toLowerCase();
if (from.isEmpty || to.isEmpty) return null;
if (from == to) return quantity;
// Mass
if (from == 'mg' && to == 'g') return quantity / 1000.0;
if (from == 'mg' && to == 'kg') return quantity / 1000000.0;
if (from == 'mg' && to == 'hg') return quantity / 100000.0;
if (from == 'g' && to == 'mg') return quantity * 1000.0;
if (from == 'g' && to == 'hg') return quantity / 100.0;
if (from == 'g' && to == 'kg') return quantity / 1000.0;
if (from == 'hg' && to == 'mg') return quantity * 100000.0;
if (from == 'hg' && to == 'g') return quantity * 100.0;
if (from == 'hg' && to == 'kg') return quantity / 10.0;
if (from == 'kg' && to == 'mg') return quantity * 1000000.0;
if (from == 'kg' && to == 'hg') return quantity * 10.0;
if (from == 'kg' && to == 'g') return quantity * 1000.0;
// Volume
if (from == 'ml' && to == 'l') return quantity / 1000.0;
if (from == 'cl' && to == 'l') return quantity / 100.0;
if (from == 'dl' && to == 'l') return quantity / 10.0;
if (from == 'l' && to == 'ml') return quantity * 1000.0;
if (from == 'l' && to == 'cl') return quantity * 100.0;
if (from == 'l' && to == 'dl') return quantity * 10.0;
// Intra-volume conversions
if (from == 'ml' && to == 'cl') return quantity / 10.0;
if (from == 'ml' && to == 'dl') return quantity / 100.0;
if (from == 'cl' && to == 'ml') return quantity * 10.0;
if (from == 'cl' && to == 'dl') return quantity / 10.0;
if (from == 'dl' && to == 'ml') return quantity * 100.0;
if (from == 'dl' && to == 'cl') return quantity * 10.0;
return null;
}
/// Konverterar VERSALER-produktnamn till Title Case med smarta regler:
/// - Token med `/` (förkortningar) lämnas i versaler: KY/KAL/LE/TO
/// - Token som börjar med siffra (mängd/storlek) görs till gemener: 284g, 12x85g
@@ -129,6 +191,7 @@ class _EditDialog extends StatefulWidget {
class _EditDialogState extends State<_EditDialog> {
late final TextEditingController _quantityCtrl;
late final TextEditingController _unitCtrl;
late final TextEditingController _packageCountCtrl;
late final TextEditingController _newProductNameCtrl;
int? _productId;
String? _productName;
@@ -161,11 +224,23 @@ class _EditDialogState extends State<_EditDialog> {
_newCategoryId = widget.current.categoryId ?? widget.item.categorySuggestionId;
_newCategoryPath = widget.current.categoryPath ?? widget.item.categorySuggestionPath;
_newCategorySource = widget.current.categorySource;
final inferred = _inferPackageFields(
rawName: widget.item.rawName,
quantity: widget.current.quantity ?? widget.item.quantity,
unit: widget.current.unit ?? widget.item.unit,
);
final initialPackQuantity = widget.current.packQuantity ?? inferred.packQuantity;
final initialPackUnit = widget.current.packUnit ?? inferred.packUnit;
final initialPackageCount = widget.current.packageCount ?? inferred.packageCount;
_quantityCtrl = TextEditingController(
text: (widget.current.quantity ?? widget.item.quantity)?.toString() ?? '',
text: initialPackQuantity?.toString() ?? '',
);
_unitCtrl = TextEditingController(
text: widget.current.unit ?? widget.item.unit ?? '',
text: initialPackUnit ?? '',
);
_packageCountCtrl = TextEditingController(
text: initialPackageCount.toString(),
);
_newProductNameCtrl = TextEditingController(
text: _normalizeProductName(widget.current.productName ?? widget.item.rawName),
@@ -200,6 +275,7 @@ class _EditDialogState extends State<_EditDialog> {
void dispose() {
_quantityCtrl.dispose();
_unitCtrl.dispose();
_packageCountCtrl.dispose();
_newProductNameCtrl.dispose();
super.dispose();
}
@@ -309,6 +385,14 @@ class _EditDialogState extends State<_EditDialog> {
if (!mounted || _productId == null) return;
}
final packQuantity = double.tryParse(_quantityCtrl.text.replaceAll(',', '.'));
final packageCount =
double.tryParse(_packageCountCtrl.text.replaceAll(',', '.')) ?? 1.0;
final packUnit = _unitCtrl.text.trim().isEmpty
? (widget.current.packUnit ?? widget.current.unit ?? widget.item.unit)
: _unitCtrl.text.trim();
final totalQuantity = packQuantity != null ? packQuantity * packageCount : widget.item.quantity;
Navigator.pop(
context,
_ItemEdit(
@@ -317,8 +401,11 @@ class _EditDialogState extends State<_EditDialog> {
categoryId: _productCategoryId,
categoryPath: _productCategoryPath,
categorySource: _productCategorySource,
quantity: double.tryParse(_quantityCtrl.text) ?? widget.item.quantity,
unit: _unitCtrl.text.trim().isEmpty ? widget.item.unit : _unitCtrl.text.trim(),
quantity: totalQuantity,
unit: packUnit,
packQuantity: packQuantity,
packUnit: packUnit,
packageCount: packageCount,
destination: _destination,
),
);
@@ -338,16 +425,15 @@ class _EditDialogState extends State<_EditDialog> {
: (item.matchedProductName?.isNotEmpty == true
? _normalizeProductName(item.matchedProductName!)
: 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;
final currentPackQuantity =
double.tryParse(_quantityCtrl.text.replaceAll(',', '.'));
final currentPackageCount =
double.tryParse(_packageCountCtrl.text.replaceAll(',', '.')) ?? 1.0;
final currentUnit = _unitCtrl.text.trim().isEmpty
? (widget.current.packUnit ?? widget.current.unit ?? widget.item.unit)
: _unitCtrl.text.trim();
final totalPreview =
currentPackQuantity == null ? null : currentPackQuantity * currentPackageCount;
return AlertDialog(
title: Text(
@@ -592,7 +678,10 @@ class _EditDialogState extends State<_EditDialog> {
child: TextField(
controller: _quantityCtrl,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
decoration: const InputDecoration(labelText: 'Antal', border: OutlineInputBorder()),
decoration: const InputDecoration(
labelText: 'Mangd per forpackning',
border: OutlineInputBorder(),
),
onChanged: (_) => setState(() {}),
),
),
@@ -600,12 +689,25 @@ class _EditDialogState extends State<_EditDialog> {
Expanded(
child: TextField(
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),
TextField(
controller: _packageCountCtrl,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
decoration: const InputDecoration(
labelText: 'Antal forpackningar',
border: OutlineInputBorder(),
),
onChanged: (_) => setState(() {}),
),
if (totalPreview != null && currentUnit != null && currentUnit.isNotEmpty) ...[
const SizedBox(height: 8),
Container(
width: double.infinity,
@@ -616,8 +718,8 @@ class _EditDialogState extends State<_EditDialog> {
border: Border.all(color: Colors.green.shade200),
),
child: Text(
'Tolkat som totalt ${_formatSwedishNumber(normalizationPreview.quantity)} ${normalizationPreview.unit} '
'(antal x förpackningsstorlek).',
'Totalt: ${_formatSwedishNumber(totalPreview)} $currentUnit '
'(mangd x antal forpackningar).',
style: theme.textTheme.bodySmall?.copyWith(
color: Colors.green.shade800,
),
@@ -821,6 +923,11 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
final pid = it.matchedProductId ?? it.suggestedProductId;
notifier.setSelected(i, pid != null);
if (pid != null) {
final inferred = _inferPackageFields(
rawName: it.rawName,
quantity: it.quantity,
unit: it.unit,
);
final name = it.matchedProductName ?? it.suggestedProductName;
final resolvedCategoryId = it.categorySuggestionId ?? _categoryIdForProduct(pid);
final resolvedCategoryPath = it.categorySuggestionPath ??
@@ -833,8 +940,11 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
categorySource: it.categorySuggestionId != null
? CategorySelectionSource.ai
: null,
quantity: it.quantity,
unit: it.unit,
quantity: inferred.totalQuantity,
unit: inferred.totalUnit,
packQuantity: inferred.packQuantity,
packUnit: inferred.packUnit,
packageCount: inferred.packageCount,
));
}
}
@@ -852,6 +962,11 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
_ProductEntryMode? initialEntryMode,
}) async {
final item = _items![index];
final inferred = _inferPackageFields(
rawName: item.rawName,
quantity: item.quantity,
unit: item.unit,
);
final current = _edits[index] ??
_ItemEdit(
productId: item.matchedProductId ?? item.suggestedProductId,
@@ -866,8 +981,11 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
categorySource: item.categorySuggestionId != null
? CategorySelectionSource.ai
: null,
quantity: item.quantity,
unit: item.unit,
quantity: inferred.totalQuantity,
unit: inferred.totalUnit,
packQuantity: inferred.packQuantity,
packUnit: inferred.packUnit,
packageCount: inferred.packageCount,
);
final result = await showDialog<_ItemEdit>(
@@ -948,18 +1066,26 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
pantryAdded++;
}
} else {
final normalized = _normalizePackageQuantityFromRawName(
final inferred = _inferPackageFields(
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 packageCount = edit.packageCount ?? inferred.packageCount;
final packQuantity = edit.packQuantity ?? inferred.packQuantity;
final packUnit = edit.packUnit ?? inferred.packUnit ?? edit.unit ?? item.unit ?? 'st';
final qty = packQuantity != null
? (packQuantity * packageCount)
: (edit.quantity ?? inferred.totalQuantity ?? item.quantity ?? 1.0);
final unit = packUnit;
final existing = _inventoryByProduct[pid];
if (existing != null) {
final qtyInExistingUnit = existing == null
? null
: _convertQuantity(qty, unit, existing.unit);
if (existing != null && qtyInExistingUnit != null) {
await invRepo.updateInventoryItem(
existing.id,
{'quantity': existing.quantity + qty},
{'quantity': existing.quantity + qtyInExistingUnit},
token: token,
);
merged++;
@@ -1129,9 +1255,32 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
final hasProduct = edit?.productId != null;
final isMatched = item.matchedProductId != null;
final isSuggested = item.suggestedProductId != null && item.matchedProductId == null;
final existingInv = edit?.productId != null && edit?.destination != _Destination.pantry
final existingInv = edit?.productId != null && edit?.destination != _Destination.pantry
? _inventoryByProduct[edit!.productId]
: null;
final inferredForPreview = _inferPackageFields(
rawName: item.rawName,
quantity: edit?.quantity ?? item.quantity,
unit: edit?.unit ?? item.unit,
);
final previewPackageCount = edit?.packageCount ?? inferredForPreview.packageCount;
final previewPackQuantity = edit?.packQuantity ?? inferredForPreview.packQuantity;
final previewIncomingQty = previewPackQuantity != null
? (previewPackQuantity * previewPackageCount)
: (edit?.quantity ?? inferredForPreview.totalQuantity ?? item.quantity ?? 0);
final previewIncomingUnit = edit?.packUnit ??
inferredForPreview.packUnit ??
edit?.unit ??
item.unit ??
'st';
final convertedPreviewQty = existingInv == null
? null
: _convertQuantity(
previewIncomingQty,
previewIncomingUnit,
existingInv.unit,
);
final canMergePreview = existingInv != null && convertedPreviewQty != null;
final alreadyInPantry = edit?.productId != null && edit?.destination == _Destination.pantry
? _pantryProductIds.contains(edit!.productId)
: false;
@@ -1250,17 +1399,28 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
],
),
],
if (existingInv != null) ...[
if (existingInv != null && canMergePreview) ...[
const SizedBox(height: 2),
Row(children: [
Icon(Icons.kitchen_outlined, size: 12, color: Colors.blue.shade700),
const SizedBox(width: 3),
Text(
'I lager: ${existingInv.quantity} ${existingInv.unit} → blir ${(existingInv.quantity + (edit?.quantity ?? item.quantity ?? 0)).toStringAsFixed(existingInv.quantity % 1 == 0 ? 0 : 2)} ${existingInv.unit}',
'I lager: ${existingInv.quantity} ${existingInv.unit} → blir ${(existingInv.quantity + (convertedPreviewQty ?? 0)).toStringAsFixed(existingInv.quantity % 1 == 0 ? 0 : 2)} ${existingInv.unit}',
style: theme.textTheme.bodySmall?.copyWith(color: Colors.blue.shade700),
),
]),
],
if (existingInv != null && !canMergePreview) ...[
const SizedBox(height: 2),
Row(children: [
Icon(Icons.info_outline, size: 12, color: Colors.orange.shade700),
const SizedBox(width: 3),
Text(
'Finns i lager med annan enhet (${existingInv.unit}) - sparas som ny rad',
style: theme.textTheme.bodySmall?.copyWith(color: Colors.orange.shade700),
),
]),
],
if (alreadyInPantry) ...[
const SizedBox(height: 2),
Row(children: [