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: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(
|
||||||
|
|||||||
Reference in New Issue
Block a user