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:
Nils-Johan Gynther
2026-05-03 15:25:56 +02:00
parent dc74a9448b
commit c26d5a4e1d
4 changed files with 1072 additions and 1011 deletions
@@ -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;
}