feat: add EditDialog for receipt item editing and product creation
- Implemented EditDialog widget to facilitate editing of parsed receipt items. - Added functionality for selecting existing products or creating new ones. - Integrated category selection for products with a category picker. - Included utility functions for receipt import, including quantity conversion and package size extraction. - Enhanced product name normalization and category path lookup for improved user experience. Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
@@ -0,0 +1,196 @@
|
||||
/// Utility-funktioner och domänlogik för kvittoimport.
|
||||
///
|
||||
/// Separerade från UI-lagret för att möjliggöra testning och återanvändning.
|
||||
library;
|
||||
|
||||
// ── Enhetskonvertering ────────────────────────────────────────────────────────
|
||||
|
||||
// Alla massvärden normaliseras till gram (g).
|
||||
const _massToGrams = <String, double>{
|
||||
'mg': 0.001,
|
||||
'g': 1.0,
|
||||
'hg': 100.0,
|
||||
'kg': 1000.0,
|
||||
};
|
||||
|
||||
// Alla volymvärden normaliseras till milliliter (ml).
|
||||
const _volToMl = <String, double>{
|
||||
'ml': 1.0,
|
||||
'cl': 10.0,
|
||||
'dl': 100.0,
|
||||
'l': 1000.0,
|
||||
};
|
||||
|
||||
/// Konverterar [quantity] från [fromUnit] till [toUnit].
|
||||
///
|
||||
/// Stöder mass (mg/g/hg/kg) och volym (ml/cl/dl/l).
|
||||
/// Returnerar null om konverteringen inte kan göras.
|
||||
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;
|
||||
|
||||
// Massa
|
||||
if (_massToGrams.containsKey(from) && _massToGrams.containsKey(to)) {
|
||||
return quantity * _massToGrams[from]! / _massToGrams[to]!;
|
||||
}
|
||||
|
||||
// Volym
|
||||
if (_volToMl.containsKey(from) && _volToMl.containsKey(to)) {
|
||||
return quantity * _volToMl[from]! / _volToMl[to]!;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── Paketenhetskänning ────────────────────────────────────────────────────────
|
||||
|
||||
const _packageUnits = <String>{
|
||||
'paket', 'forpackning', 'forp', 'forp.', 'förpackning',
|
||||
'förp', 'förp.', 'fp', 'pkt', 'pack', 'pak', 'st', 'styck',
|
||||
};
|
||||
|
||||
bool isPackageLikeUnit(String? unit) {
|
||||
if (unit == null) return false;
|
||||
return _packageUnits.contains(unit.trim().toLowerCase());
|
||||
}
|
||||
|
||||
// ── Storleksextraktion från rånamn ────────────────────────────────────────────
|
||||
|
||||
({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);
|
||||
}
|
||||
|
||||
// ── Paketfältsinferens ────────────────────────────────────────────────────────
|
||||
|
||||
typedef PackageFields = ({
|
||||
double? packQuantity,
|
||||
String? packUnit,
|
||||
double packageCount,
|
||||
double? totalQuantity,
|
||||
String? totalUnit,
|
||||
});
|
||||
|
||||
PackageFields inferPackageFields({
|
||||
required String rawName,
|
||||
required double? quantity,
|
||||
required String? unit,
|
||||
}) {
|
||||
final normalizedUnit = unit?.trim().toLowerCase();
|
||||
final safeCount = (quantity != null && quantity > 0) ? quantity : 1.0;
|
||||
final extracted = extractPackageSizeFromRawName(rawName);
|
||||
|
||||
// Om rånamnet innehåller storlek (t.ex. "5dl") och enhet saknas eller är
|
||||
// paketliknande — använd extraherad storlek.
|
||||
if (extracted != null &&
|
||||
(normalizedUnit == null ||
|
||||
normalizedUnit.isEmpty ||
|
||||
isPackageLikeUnit(normalizedUnit))) {
|
||||
return (
|
||||
packQuantity: extracted.packQuantity,
|
||||
packUnit: extracted.packUnit,
|
||||
packageCount: safeCount,
|
||||
totalQuantity: extracted.packQuantity * safeCount,
|
||||
totalUnit: extracted.packUnit,
|
||||
);
|
||||
}
|
||||
|
||||
if (quantity == null || normalizedUnit == null || normalizedUnit.isEmpty) {
|
||||
return (
|
||||
packQuantity: null,
|
||||
packUnit: null,
|
||||
packageCount: 1,
|
||||
totalQuantity: quantity,
|
||||
totalUnit: unit,
|
||||
);
|
||||
}
|
||||
|
||||
if (isPackageLikeUnit(normalizedUnit) && extracted != null) {
|
||||
return (
|
||||
packQuantity: extracted.packQuantity,
|
||||
packUnit: extracted.packUnit,
|
||||
packageCount: quantity,
|
||||
totalQuantity: extracted.packQuantity * quantity,
|
||||
totalUnit: extracted.packUnit,
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
packQuantity: quantity,
|
||||
packUnit: normalizedUnit,
|
||||
packageCount: 1,
|
||||
totalQuantity: quantity,
|
||||
totalUnit: normalizedUnit,
|
||||
);
|
||||
}
|
||||
|
||||
// ── Talformatering ────────────────────────────────────────────────────────────
|
||||
|
||||
String formatCompactNumber(double value) {
|
||||
if (value == value.roundToDouble()) return value.toStringAsFixed(0);
|
||||
return value
|
||||
.toStringAsFixed(3)
|
||||
.replaceFirst(RegExp(r'0+$'), '')
|
||||
.replaceFirst(RegExp(r'\.$'), '');
|
||||
}
|
||||
|
||||
String formatSwedishNumber(double value) =>
|
||||
formatCompactNumber(value).replaceAll('.', ',');
|
||||
|
||||
// ── Namnomvandling ────────────────────────────────────────────────────────────
|
||||
|
||||
/// Konverterar VERSALER-produktnamn till Title Case:
|
||||
/// - Token med `/` lämnas oförändrade (t.ex. KY/KAL)
|
||||
/// - Token som börjar med siffra görs lowercase (t.ex. 284g)
|
||||
/// - Övriga token: Första bokstav versal, resten gemen
|
||||
String normalizeProductName(String raw) {
|
||||
return raw.trim().split(' ').map((token) {
|
||||
if (token.isEmpty) return token;
|
||||
if (token.contains('/')) return token;
|
||||
if (RegExp(r'^\d').hasMatch(token)) return token.toLowerCase();
|
||||
return token[0].toUpperCase() + token.substring(1).toLowerCase();
|
||||
}).join(' ');
|
||||
}
|
||||
|
||||
// ── Kategoriträd-lookup ───────────────────────────────────────────────────────
|
||||
|
||||
import '../../../features/admin/domain/admin_category_node.dart';
|
||||
|
||||
/// Hjälpklass för snabb lookup av kategori-sökväg via index.
|
||||
///
|
||||
/// Bygg en gång från trädet och återanvänd för alla rader.
|
||||
class CategoryLookup {
|
||||
final Map<int, String> _pathByid;
|
||||
|
||||
CategoryLookup._(this._pathByid);
|
||||
|
||||
factory CategoryLookup.fromTree(List<AdminCategoryNode> tree) {
|
||||
final map = <int, String>{};
|
||||
void walk(List<AdminCategoryNode> nodes, List<String> parents) {
|
||||
for (final node in nodes) {
|
||||
final path = [...parents, node.name];
|
||||
map[node.id] = path.join(' > ');
|
||||
walk(node.children, path);
|
||||
}
|
||||
}
|
||||
walk(tree, const []);
|
||||
return CategoryLookup._(map);
|
||||
}
|
||||
|
||||
/// Returnerar full sökväg för [categoryId], eller null om okänd.
|
||||
String? pathFor(int? categoryId) =>
|
||||
categoryId == null ? null : _pathByid[categoryId];
|
||||
|
||||
bool get isEmpty => _pathByid.isEmpty;
|
||||
}
|
||||
Reference in New Issue
Block a user