From c26d5a4e1d0286ff49225e8ebcd59c090a517428 Mon Sep 17 00:00:00 2001 From: Nils-Johan Gynther Date: Sun, 3 May 2026 15:25:56 +0200 Subject: [PATCH] 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 --- .../features/admin/data/admin_repository.dart | 369 ++++---- .../import/presentation/edit_dialog.dart | 661 ++++++++++++++ .../presentation/receipt_import_tab.dart | 857 +----------------- .../import/utils/receipt_import_utils.dart | 196 ++++ 4 files changed, 1072 insertions(+), 1011 deletions(-) create mode 100644 flutter/lib/features/import/presentation/edit_dialog.dart create mode 100644 flutter/lib/features/import/utils/receipt_import_utils.dart diff --git a/flutter/lib/features/admin/data/admin_repository.dart b/flutter/lib/features/admin/data/admin_repository.dart index f1545fbf..24318024 100644 --- a/flutter/lib/features/admin/data/admin_repository.dart +++ b/flutter/lib/features/admin/data/admin_repository.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../core/api/api_client.dart'; import '../../../core/api/api_paths.dart'; @@ -20,237 +21,197 @@ class AdminRepository { AdminRepository(this._apiClient, this._ref); + // ── Interna helpers ──────────────────────────────────────────────────────── + Future _token() => _ref.read(authStateProvider.future); - Future> listUsers() async { + /// GET-anrop som returnerar en typad lista med [fromJson]. + Future> _getList( + String path, + T Function(Map) fromJson, { + bool requiresAuth = true, + }) async { + final token = requiresAuth ? await _token() : null; + final data = await guardedApiCall( + _ref, + () => _apiClient.getJson(path, token: token), + ); + return _parseList(data, fromJson); + } + + /// POST-anrop som returnerar ett typad objekt med [fromJson]. + Future _post( + String path, { + required Map? body, + required T Function(dynamic) parse, + bool requiresAuth = true, + }) async { + final token = requiresAuth ? await _token() : null; + final data = await guardedApiCall( + _ref, + () => _apiClient.postJson(path, body: body, token: token), + ); + return parse(data); + } + + /// PATCH-anrop som returnerar ett typad objekt med [fromJson]. + Future _patch( + String path, { + required Map body, + required T Function(dynamic) parse, + }) async { final token = await _token(); final data = await guardedApiCall( _ref, - () => _apiClient.getJson(UserApiPaths.list, token: token), + () => _apiClient.patchJson(path, body: body, token: token), ); - return (data as List).map((e) => UserAdmin.fromJson(e as Map)).toList(); + return parse(data); } - Future setRole(int userId, String newRole) async { - final token = await _token(); - final data = await guardedApiCall( - _ref, - () => _apiClient.patchJson(UserApiPaths.setRole(userId), body: {'role': newRole}, token: token), - ); - return UserAdmin.fromJson(data); - } - - Future setPremium(int userId, {required bool isPremium}) async { - final token = await _token(); - final data = await guardedApiCall( - _ref, - () => _apiClient.patchJson(UserApiPaths.setPremium(userId), body: {'isPremium': isPremium}, token: token), - ); - return UserAdmin.fromJson(data); - } - - Future setRecipeSharing(int userId, {required bool canShareRecipes}) async { - final token = await _token(); - final data = await guardedApiCall( - _ref, - () => _apiClient.patchJson( - UserApiPaths.setRecipeSharing(userId), - body: {'canShareRecipes': canShareRecipes}, - token: token, - ), - ); - return UserAdmin.fromJson(data); - } - - Future updateEmail(int userId, String email) async { + /// Fire-and-forget PATCH. + Future _patchVoid(String path, Map body) async { final token = await _token(); await guardedApiCall( _ref, - () => _apiClient.patchJson( - UserApiPaths.updateEmail(userId), - body: {'email': email}, - token: token, - ), + () => _apiClient.patchJson(path, body: body, token: token), ); } + /// Fire-and-forget POST. + Future _postVoid(String path, [Map? body]) async { + final token = await _token(); + await guardedApiCall( + _ref, + () => _apiClient.postJson(path, body: body, token: token), + ); + } + + /// Fire-and-forget DELETE. + Future _deleteVoid(String path) async { + final token = await _token(); + await guardedApiCall( + _ref, + () => _apiClient.deleteJson(path, token: token), + ); + } + + /// Tolerant listparsning — accepterar ren lista eller wrapper ({items, data}). + static List _parseList( + dynamic data, + T Function(Map) fromJson, + ) { + final List raw; + if (data is List) { + raw = data; + } else if (data is Map) { + raw = (data['items'] as List?) ?? + (data['data'] as List?) ?? + const []; + if (raw.isEmpty && data.isNotEmpty) { + debugPrint('[AdminRepository] Unexpected API wrapper shape: ${data.keys}'); + } + } else { + raw = const []; + } + return raw + .whereType() + .map((e) => fromJson(Map.from(e))) + .toList(); + } + + // ── Användare ────────────────────────────────────────────────────────────── + + Future> listUsers() => + _getList(UserApiPaths.list, UserAdmin.fromJson); + + Future setRole(int userId, String newRole) => + _patch(UserApiPaths.setRole(userId), + body: {'role': newRole}, parse: UserAdmin.fromJson); + + Future setPremium(int userId, {required bool isPremium}) => + _patch(UserApiPaths.setPremium(userId), + body: {'isPremium': isPremium}, parse: UserAdmin.fromJson); + + Future setRecipeSharing(int userId, + {required bool canShareRecipes}) => + _patch(UserApiPaths.setRecipeSharing(userId), + body: {'canShareRecipes': canShareRecipes}, parse: UserAdmin.fromJson); + + Future updateEmail(int userId, String email) => + _patchVoid(UserApiPaths.updateEmail(userId), {'email': email}); + Future createUser({ required String username, required String email, required String password, String role = 'user', - }) async { - final token = await _token(); - final data = await guardedApiCall( - _ref, - () => _apiClient.postJson(UserApiPaths.list, body: { - 'username': username, - 'email': email, - 'password': password, - 'role': role, - }, token: token), - ); - return UserAdmin.fromJson(data as Map); - } + }) => + _post( + UserApiPaths.list, + body: { + 'username': username, + 'email': email, + 'password': password, + 'role': role, + }, + parse: (d) => UserAdmin.fromJson(d as Map), + ); - Future deleteUser(int userId) async { - final token = await _token(); - return guardedApiCall( - _ref, - () => _apiClient.deleteJson(UserApiPaths.delete(userId), token: token), - ); - } + Future deleteUser(int userId) => _deleteVoid(UserApiPaths.delete(userId)); /// Returns `{ temporaryPassword, to, subject, body }`. - Future> resetPassword(int userId) async { - final token = await _token(); - final result = await guardedApiCall( - _ref, - () => _apiClient.postJson(UserApiPaths.resetPassword(userId), token: token), - ); - return (result as Map); - } + Future> resetPassword(int userId) => + _post>( + UserApiPaths.resetPassword(userId), + body: null, + parse: (d) => d as Map, + ); - Future> listPendingProducts() async { - final token = await _token(); - final data = await guardedApiCall( - _ref, - () => _apiClient.getJson(ProductApiPaths.pending, token: token), - ); - return (data as List) - .map((e) => PendingProduct.fromJson(e as Map)) - .toList(); - } + // ── Produkter ────────────────────────────────────────────────────────────── - Future setProductStatus(int productId, String status) async { - final token = await _token(); - await guardedApiCall( - _ref, - () => _apiClient.patchJson( - ProductApiPaths.setStatus(productId), - body: {'status': status}, - token: token, - ), - ); - } + Future> listProducts() => + _getList(ProductApiPaths.list, AdminProduct.fromJson); - Future> listAiModels() async { - final data = await guardedApiCall( - _ref, - () => _apiClient.getJson(AiApiPaths.models), - ); - return (data as List) - .map((e) => AiModelInfo.fromJson(e as Map)) - .toList(); - } + Future> listDeletedProducts() => + _getList(ProductApiPaths.deleted, AdminProduct.fromJson); - Future> listProducts() async { - final token = await _token(); - final data = await guardedApiCall( - _ref, - () => _apiClient.getJson(ProductApiPaths.list, token: token), - ); - return (data as List) - .map((e) => AdminProduct.fromJson(e as Map)) - .toList(); - } + Future> listPendingProducts() => + _getList(ProductApiPaths.pending, PendingProduct.fromJson); + + Future setProductStatus(int productId, String status) => + _patchVoid(ProductApiPaths.setStatus(productId), {'status': status}); + + Future setProductCategory(int productId, {required int? categoryId}) => + _patchVoid(ProductApiPaths.update(productId), {'categoryId': categoryId}); + + Future removeProduct(int productId) => + _deleteVoid(ProductApiPaths.remove(productId)); + + Future restoreProduct(int productId) => + _postVoid(ProductApiPaths.restore(productId)); /// Skapar en ny aktiv produkt (kräver admin). Returnerar `{id, name, categoryId?}`. - Future> createProduct(String name, {int? categoryId}) async { - final token = await _token(); - final data = await guardedApiCall( - _ref, - () => _apiClient.postJson( + Future> createProduct(String name, {int? categoryId}) => + _post>( ProductApiPaths.list, body: { 'name': name.trim(), if (categoryId != null) 'categoryId': categoryId, }, - token: token, - ), - ); - return data as Map; - } + parse: (d) => d as Map, + ); - Future> listDeletedProducts() async { - final token = await _token(); - final data = await guardedApiCall( - _ref, - () => _apiClient.getJson(ProductApiPaths.deleted, token: token), - ); - return (data as List) - .map((e) => AdminProduct.fromJson(e as Map)) - .toList(); - } - - Future> listCategoryTree() async { - final token = await _token(); - final data = await guardedApiCall( - _ref, - () => _apiClient.getJson(CategoryApiPaths.tree, token: token), - ); - return (data as List) - .map((e) => AdminCategoryNode.fromJson(e as Map)) - .toList(); - } - - Future bulkSetCategory(List ids, {required int? categoryId}) async { - final token = await _token(); - await guardedApiCall( - _ref, - () => _apiClient.postJson( - ProductApiPaths.bulkUpdate, - body: {'ids': ids, 'categoryId': categoryId}, - token: token, - ), - ); - } - - Future setProductCategory(int productId, {required int? categoryId}) async { - final token = await _token(); - await guardedApiCall( - _ref, - () => _apiClient.patchJson( - ProductApiPaths.update(productId), - body: {'categoryId': categoryId}, - token: token, - ), - ); - } - - Future removeProduct(int productId) async { - final token = await _token(); - await guardedApiCall( - _ref, - () => _apiClient.deleteJson(ProductApiPaths.remove(productId), token: token), - ); - } - - Future restoreProduct(int productId) async { - final token = await _token(); - await guardedApiCall( - _ref, - () => _apiClient.postJson(ProductApiPaths.restore(productId), token: token), - ); - } + Future bulkSetCategory(List ids, {required int? categoryId}) => + _postVoid(ProductApiPaths.bulkUpdate, {'ids': ids, 'categoryId': categoryId}); Future mergeProducts({ required int sourceProductId, required int targetProductId, - }) async { - final token = await _token(); - await guardedApiCall( - _ref, - () => _apiClient.postJson( - ProductApiPaths.merge, - body: { - 'sourceProductId': sourceProductId, - 'targetProductId': targetProductId, - }, - token: token, - ), - ); - } + }) => + _postVoid(ProductApiPaths.merge, { + 'sourceProductId': sourceProductId, + 'targetProductId': targetProductId, + }); Future> aiCategorizeBulk({ List? productIds, @@ -260,15 +221,31 @@ class AdminRepository { _ref, () => _apiClient.postJson( ProductApiPaths.aiCategorizeBulk, - body: productIds == null || productIds.isEmpty + body: (productIds == null || productIds.isEmpty) ? null : {'productIds': productIds}, token: token, ), ); - return (data as List) - .map((e) => AdminAiCategorizeResult.fromJson(e as Map)) + return _parseList(data, AdminAiCategorizeResult.fromJson) .where((e) => e.productId > 0 && e.categoryId > 0) .toList(); } + + // ── Kategorier ───────────────────────────────────────────────────────────── + + Future> listCategoryTree() async { + final token = await _token(); + final data = await guardedApiCall( + _ref, + () => _apiClient.getJson(CategoryApiPaths.tree, token: token), + ); + return _parseList(data, AdminCategoryNode.fromJson); + } + + // ── AI-modeller ──────────────────────────────────────────────────────────── + + /// OBS: endpointen /ai/models kräver autentisering. + Future> listAiModels() => + _getList(AiApiPaths.models, AiModelInfo.fromJson); } diff --git a/flutter/lib/features/import/presentation/edit_dialog.dart b/flutter/lib/features/import/presentation/edit_dialog.dart new file mode 100644 index 00000000..b133585a --- /dev/null +++ b/flutter/lib/features/import/presentation/edit_dialog.dart @@ -0,0 +1,661 @@ +import 'package:flutter/material.dart'; +import '../../../core/api/api_exception.dart'; +import '../../../core/ui/category_then_product_picker.dart'; +import '../../../core/ui/product_picker_field.dart'; +import '../../../core/utils/global_error_handler.dart'; +import '../../admin/domain/admin_category_node.dart'; +import '../data/receipt_import_session.dart'; +import '../domain/parsed_receipt_item.dart'; +import '../utils/receipt_import_utils.dart'; + +enum ImportProductEntryMode { existing, create } + +typedef _Destination = ImportDestination; + +class EditDialog extends StatefulWidget { + final ParsedReceiptItem item; + final ItemEdit current; + final List products; + final List categoryTree; + final Future Function(String name, int categoryId)? onCreate; + final ImportProductEntryMode? initialEntryMode; + + const EditDialog({ + super.key, + required this.item, + required this.current, + required this.products, + required this.categoryTree, + this.onCreate, + this.initialEntryMode, + }); + + @override + State createState() => _EditDialogState(); +} + +class _EditDialogState extends State { + late final TextEditingController _quantityCtrl; + late final TextEditingController _unitCtrl; + late final TextEditingController _packageCountCtrl; + late final TextEditingController _newProductNameCtrl; + + int? _productId; + String? _productName; + int? _productCategoryId; + String? _productCategoryPath; + CategorySelectionSource? _productCategorySource; + + int? _newCategoryId; + String? _newCategoryPath; + CategorySelectionSource? _newCategorySource; + + _Destination _destination = _Destination.inventory; + ImportProductEntryMode _entryMode = ImportProductEntryMode.existing; + bool _isCreatingProduct = false; + + // Lokal lista — utökas om nya produkter skapas under dialogen + late List _localProducts; + late CategoryLookup _lookup; + + @override + void initState() { + super.initState(); + _lookup = CategoryLookup.fromTree(widget.categoryTree); + _localProducts = List.of(widget.products); + + _productId = widget.current.productId; + _productName = widget.current.productName == null + ? null + : normalizeProductName(widget.current.productName!); + _destination = widget.current.destination; + _entryMode = widget.initialEntryMode ?? + (_productId == null + ? ImportProductEntryMode.create + : ImportProductEntryMode.existing); + + _productCategoryId = + widget.current.categoryId ?? _categoryIdForProduct(_productId); + _productCategoryPath = + widget.current.categoryPath ?? _lookup.pathFor(_productCategoryId); + _productCategorySource = widget.current.categorySource; + + _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, + ); + + _quantityCtrl = TextEditingController( + text: (widget.current.packQuantity ?? inferred.packQuantity)?.toString() ?? '', + ); + _unitCtrl = TextEditingController( + text: widget.current.packUnit ?? inferred.packUnit ?? '', + ); + _packageCountCtrl = TextEditingController( + text: (widget.current.packageCount ?? inferred.packageCount).toString(), + ); + _newProductNameCtrl = TextEditingController( + text: normalizeProductName(widget.current.productName ?? widget.item.rawName), + ); + } + + @override + void dispose() { + _quantityCtrl.dispose(); + _unitCtrl.dispose(); + _packageCountCtrl.dispose(); + _newProductNameCtrl.dispose(); + super.dispose(); + } + + // ── Hjälpmetoder ────────────────────────────────────────────────────────── + + int? _categoryIdForProduct(int? productId) { + if (productId == null) return null; + return _localProducts + .cast() + .firstWhere((p) => p?.id == productId, orElse: () => null) + ?.categoryId; + } + + // ── Kategoripicker ───────────────────────────────────────────────────────── + + Future _openCreateCategoryPicker({int? preselectedCategoryId}) async { + final selected = await CategoryThenProductPicker.showCategorySheet( + context, + categoryTree: widget.categoryTree, + preselectedCategoryId: + preselectedCategoryId ?? _newCategoryId ?? widget.item.categorySuggestionId, + ); + if (selected == null || !mounted) return; + setState(() { + _newCategoryId = selected.id; + _newCategoryPath = selected.path; + _newCategorySource = CategorySelectionSource.manual; + }); + } + + Future _openExistingCategoryPicker({int? preselectedCategoryId}) async { + Future Function(String, int)? onCreateWrapped; + if (widget.onCreate != null) { + onCreateWrapped = (name, categoryId) async { + final newProduct = await widget.onCreate!(name, categoryId); + if (newProduct != null && mounted) { + setState(() => _localProducts = [..._localProducts, newProduct]); + } + return newProduct; + }; + } + + final id = await CategoryThenProductPicker.show( + context, + categoryTree: widget.categoryTree, + products: _localProducts, + currentProductId: _productId, + preselectedCategoryId: preselectedCategoryId, + initialQuery: widget.item.rawName, + onCreate: onCreateWrapped, + ); + if (id == null || !mounted) return; + setState(() { + _productId = id; + final selectedProduct = _localProducts + .cast() + .firstWhere((p) => p?.id == id, orElse: () => null); + _productName = + selectedProduct?.name == null ? null : normalizeProductName(selectedProduct!.name); + _productCategoryId = _categoryIdForProduct(id); + _productCategoryPath = _lookup.pathFor(_productCategoryId); + _productCategorySource = CategorySelectionSource.manual; + }); + } + + /// Applicerar AI-förslag och öppnar kategoriträdet för bekräftelse. + void _applyAiSuggestion() { + int? preselectedCategoryId = widget.item.categorySuggestionId; + final suggestedId = widget.item.suggestedProductId; + if (suggestedId != null) { + setState(() { + _productId = suggestedId; + _productName = widget.item.suggestedProductName == null + ? null + : normalizeProductName(widget.item.suggestedProductName!); + _productCategoryId = + _categoryIdForProduct(suggestedId) ?? widget.item.categorySuggestionId; + _productCategoryPath = + _lookup.pathFor(_productCategoryId) ?? widget.item.categorySuggestionPath; + _productCategorySource = CategorySelectionSource.ai; + }); + preselectedCategoryId = _productCategoryId; + } + _openExistingCategoryPicker(preselectedCategoryId: preselectedCategoryId); + } + + // ── Spara ────────────────────────────────────────────────────────────────── + + bool get _canConfirm { + if (_isCreatingProduct) return false; + if (_entryMode == ImportProductEntryMode.create) return true; + return _productId != null; + } + + Future _confirm() async { + if (_entryMode == ImportProductEntryMode.create) { + final trimmedName = _newProductNameCtrl.text.trim(); + if (trimmedName.isEmpty) { + showGlobalErrorDialog(context, 'Ange ett produktnamn först.'); + return; + } + if (_newCategoryId == null) { + showGlobalErrorDialog(context, 'Välj kategori innan du skapar produkten.'); + return; + } + if (widget.onCreate == null) { + showGlobalErrorDialog(context, 'Produktskapande är inte tillgängligt i den här vyn.'); + return; + } + + setState(() => _isCreatingProduct = true); + try { + final newProduct = await widget.onCreate!(trimmedName, _newCategoryId!); + if (newProduct == null || !mounted) { + if (mounted) { + showGlobalErrorDialog(context, 'Kunde inte skapa produkten. Försök igen.'); + } + return; + } + if (!_localProducts.any((p) => p.id == newProduct.id)) { + _localProducts = [..._localProducts, newProduct]; + } + _productId = newProduct.id; + _productName = newProduct.name; + _productCategoryId = _newCategoryId; + _productCategoryPath = _newCategoryPath; + _productCategorySource = _newCategorySource ?? CategorySelectionSource.manual; + } on ApiException catch (e) { + if (mounted) { + showGlobalErrorDialog( + context, + e.message.trim().isEmpty ? 'Kunde inte skapa produkten. Försök igen.' : e.message, + ); + } + return; + } catch (_) { + if (mounted) { + showGlobalErrorDialog(context, 'Kunde inte skapa produkten. Försök igen.'); + } + return; + } finally { + if (mounted) setState(() => _isCreatingProduct = false); + } + 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( + productId: _productId, + productName: _productName, + categoryId: _productCategoryId, + categoryPath: _productCategoryPath, + categorySource: _productCategorySource, + quantity: totalQuantity, + unit: packUnit, + packQuantity: packQuantity, + packUnit: packUnit, + packageCount: packageCount, + destination: _destination, + ), + ); + } + + // ── Build ────────────────────────────────────────────────────────────────── + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final item = widget.item; + final aiLabel = _resolveAiLabel(item); + final suggestedProductLabel = _resolveSuggestedProductLabel(item); + + 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 ?? item.unit) + : _unitCtrl.text.trim(); + final totalPreview = currentPackQuantity == null + ? null + : currentPackQuantity * currentPackageCount; + + return AlertDialog( + title: Text( + normalizeProductName(item.rawName), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildDestinationPicker(theme), + const SizedBox(height: 12), + _buildEntryModePicker(theme), + const SizedBox(height: 12), + if (_entryMode == ImportProductEntryMode.existing) + _buildExistingProductSection(theme, item, aiLabel, suggestedProductLabel) + else + _buildCreateProductSection(theme, aiLabel), + const SizedBox(height: 12), + if (_destination == _Destination.inventory) + _buildQuantitySection(theme, totalPreview, currentUnit) + else + Text( + 'Baslager sparar bara produkt — ingen mängd eller enhet.', + style: theme.textTheme.bodySmall + ?.copyWith(color: theme.colorScheme.onSurfaceVariant), + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Avbryt'), + ), + FilledButton( + onPressed: _canConfirm ? _confirm : null, + child: _isCreatingProduct + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Text( + _entryMode == ImportProductEntryMode.create ? 'Skapa och välj' : 'OK', + ), + ), + ], + ); + } + + // ── Byggare för delsektioner ─────────────────────────────────────────────── + + Widget _buildDestinationPicker(ThemeData theme) => SegmentedButton<_Destination>( + segments: const [ + ButtonSegment( + value: ImportDestination.inventory, + icon: Icon(Icons.kitchen_outlined, size: 16), + label: Text('Inventarie'), + ), + ButtonSegment( + value: ImportDestination.pantry, + icon: Icon(Icons.inventory_2_outlined, size: 16), + label: Text('Baslager'), + ), + ], + selected: {_destination}, + onSelectionChanged: (s) => setState(() => _destination = s.first), + style: const ButtonStyle(visualDensity: VisualDensity.compact), + ); + + Widget _buildEntryModePicker(ThemeData theme) => + SegmentedButton( + segments: const [ + ButtonSegment( + value: ImportProductEntryMode.existing, + icon: Icon(Icons.search, size: 16), + label: Text('Befintlig'), + ), + ButtonSegment( + value: ImportProductEntryMode.create, + icon: Icon(Icons.add_box_outlined, size: 16), + label: Text('Ny produkt'), + ), + ], + selected: {_entryMode}, + onSelectionChanged: (s) => setState(() => _entryMode = s.first), + style: const ButtonStyle(visualDensity: VisualDensity.compact), + ); + + Widget _buildExistingProductSection( + ThemeData theme, + ParsedReceiptItem item, + String? aiLabel, + String? suggestedProductLabel, + ) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (suggestedProductLabel != null) ...[ + Tooltip( + message: 'Trolig matchning baserat på produktnamn i databasen', + child: ActionChip( + avatar: Icon(Icons.search, size: 14, color: Colors.blue.shade700), + label: Text('Namnförslag: $suggestedProductLabel', + style: Theme.of(context).textTheme.labelSmall), + backgroundColor: Colors.blue.shade50, + side: BorderSide(color: Colors.blue.shade300), + visualDensity: VisualDensity.compact, + onPressed: _applyAiSuggestion, + ), + ), + const SizedBox(height: 8), + ], + Row( + children: [ + Expanded( + child: ProductPickerField( + products: _localProducts, + value: _productId, + label: 'Produkt', + initialQuery: item.rawName, + onChanged: (id) { + setState(() { + _productId = id; + final selectedName = _localProducts + .cast() + .firstWhere((p) => p?.id == id, orElse: () => null) + ?.name; + _productName = selectedName == null + ? null + : normalizeProductName(selectedName); + _productCategoryId = _categoryIdForProduct(id); + _productCategoryPath = _lookup.pathFor(_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, + child: const Icon(Icons.account_tree_outlined, size: 20), + ), + ), + ], + ), + if (_productCategoryPath != null) ...[ + const SizedBox(height: 8), + ActionChip( + avatar: Icon(Icons.account_tree_outlined, + size: 14, color: Theme.of(context).colorScheme.primary), + label: Text('Kategori: $_productCategoryPath', + style: Theme.of(context).textTheme.labelSmall, + overflow: TextOverflow.ellipsis), + side: BorderSide(color: Theme.of(context).colorScheme.outlineVariant), + visualDensity: VisualDensity.compact, + onPressed: () => + _openExistingCategoryPicker(preselectedCategoryId: _productCategoryId), + ), + ], + if (aiLabel != null) ...[ + const SizedBox(height: 8), + ActionChip( + avatar: Icon(Icons.auto_awesome, size: 14, color: Colors.green.shade700), + label: Text('AI-kategori: $aiLabel', + style: Theme.of(context).textTheme.labelSmall), + backgroundColor: Colors.green.shade50, + side: BorderSide(color: Colors.green.shade300), + visualDensity: VisualDensity.compact, + onPressed: () => _openExistingCategoryPicker( + preselectedCategoryId: item.categorySuggestionId, + ), + ), + ], + ], + ); + } + + Widget _buildCreateProductSection(ThemeData theme, String? aiLabel) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: _newProductNameCtrl, + textCapitalization: TextCapitalization.sentences, + decoration: const InputDecoration( + labelText: 'Produktnamn', + border: OutlineInputBorder(), + ), + onChanged: (_) => setState(() {}), + ), + const SizedBox(height: 8), + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + onPressed: _openCreateCategoryPicker, + icon: const Icon(Icons.account_tree_outlined), + label: Text( + _newCategoryPath == null ? 'Välj kategori' : 'Kategori: $_newCategoryPath', + overflow: TextOverflow.ellipsis, + ), + ), + ), + if (_newCategoryPath != null) ...[ + const SizedBox(height: 8), + Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(12), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Vald kategori', + style: theme.textTheme.labelSmall + ?.copyWith(color: theme.colorScheme.onSurfaceVariant), + ), + const SizedBox(height: 4), + Text( + _newCategoryPath!, + style: + theme.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600), + ), + ], + ), + ), + if (aiLabel != null) ...[ + const SizedBox(height: 8), + Wrap( + children: [ + ActionChip( + avatar: Icon(Icons.auto_awesome, + size: 14, color: Colors.green.shade700), + label: Text('AI-förslag: $aiLabel', + style: Theme.of(context).textTheme.labelSmall), + backgroundColor: Colors.green.shade50, + side: BorderSide(color: Colors.green.shade300), + visualDensity: VisualDensity.compact, + onPressed: widget.item.categorySuggestionId == null + ? null + : () => _openCreateCategoryPicker( + preselectedCategoryId: widget.item.categorySuggestionId, + ), + ), + ], + ), + ], + ], + ], + ); + } + + Widget _buildQuantitySection( + ThemeData theme, + double? totalPreview, + String? currentUnit, + ) { + return Column( + children: [ + Row( + children: [ + Expanded( + child: TextField( + controller: _quantityCtrl, + keyboardType: + const TextInputType.numberWithOptions(decimal: true), + decoration: const InputDecoration( + labelText: 'Mängd per förpackning', + border: OutlineInputBorder(), + ), + onChanged: (_) => setState(() {}), + ), + ), + const SizedBox(width: 8), + Expanded( + child: TextField( + controller: _unitCtrl, + decoration: const InputDecoration( + labelText: 'Enhet', + border: OutlineInputBorder(), + ), + onChanged: (_) => setState(() {}), + ), + ), + ], + ), + const SizedBox(height: 8), + TextField( + controller: _packageCountCtrl, + keyboardType: const TextInputType.numberWithOptions(decimal: true), + decoration: const InputDecoration( + labelText: 'Antal förpackningar', + border: OutlineInputBorder(), + ), + onChanged: (_) => setState(() {}), + ), + if (totalPreview != null && + currentUnit != null && + currentUnit.isNotEmpty) ...[ + 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( + 'Totalt: ${formatSwedishNumber(totalPreview)} $currentUnit ' + '(mängd × antal förpackningar).', + style: theme.textTheme.bodySmall + ?.copyWith(color: Colors.green.shade800), + ), + ), + ], + ], + ); + } + + // ── Statiska hjälpare ────────────────────────────────────────────────────── + + static String? _resolveAiLabel(ParsedReceiptItem item) { + final path = item.categorySuggestionPath; + if (path != null && path.isNotEmpty) return path; + final name = item.categorySuggestionName; + if (name != null && name.isNotEmpty) return name; + return null; + } + + static String? _resolveSuggestedProductLabel(ParsedReceiptItem item) { + final suggested = item.suggestedProductName; + if (suggested != null && suggested.isNotEmpty) { + return normalizeProductName(suggested); + } + final matched = item.matchedProductName; + if (matched != null && matched.isNotEmpty) { + return normalizeProductName(matched); + } + return null; + } +} diff --git a/flutter/lib/features/import/presentation/receipt_import_tab.dart b/flutter/lib/features/import/presentation/receipt_import_tab.dart index 2b5ed286..7ac945cf 100644 --- a/flutter/lib/features/import/presentation/receipt_import_tab.dart +++ b/flutter/lib/features/import/presentation/receipt_import_tab.dart @@ -1,11 +1,8 @@ import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import '../../../core/api/api_exception.dart'; import '../../../core/api/api_paths.dart'; import '../../../core/api/api_providers.dart'; -import '../../../core/ui/category_then_product_picker.dart'; -import '../../../core/ui/product_picker_field.dart'; import '../../../core/utils/pdf_opener.dart'; import '../../../core/utils/global_error_handler.dart'; import '../../admin/data/admin_repository.dart'; @@ -18,791 +15,15 @@ import '../../pantry/domain/pantry_item.dart'; import '../data/import_providers.dart'; import '../data/receipt_import_session.dart'; import '../domain/parsed_receipt_item.dart'; +import '../utils/receipt_import_utils.dart'; +import 'edit_dialog.dart'; typedef _Destination = ImportDestination; -enum _ProductEntryMode { existing, create } - -bool _isPackageLikeUnit(String? unit) { - if (unit == null) return false; - const packageUnits = { - 'paket', - 'forpackning', - 'forp', - 'forp.', - 'förpackning', - 'förp', - 'förp.', - 'fp', - 'pkt', - 'pack', - 'pak', - 'st', - 'styck', - }; - 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); -} - -({double? packQuantity, String? packUnit, double packageCount, double? totalQuantity, String? totalUnit}) - _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); - - // If the receipt name contains size (e.g. "5dl"), prefer it when unit is - // missing/unknown or when OCR reports package-like count units (st/pkt/etc). - 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, - ); - } - - final looksLikePackage = _isPackageLikeUnit(normalizedUnit); - - if (looksLikePackage && 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, - ); -} - -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('.', ','); -} - -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 -/// - Övriga token: första bokstav versal, resten gemen: Aprikosmarmelad -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(' '); -} - -// ── Redigeringstillstånd per rad ───────────────────────────────────────────── +// ── Typ-alias ───────────────────────────────────────────────────────────────── typedef _ItemEdit = ItemEdit; -// ── Redigeringsdialog ───────────────────────────────────────────────────────── - -class _EditDialog extends StatefulWidget { - final ParsedReceiptItem item; - final _ItemEdit current; - final List products; - final List categoryTree; - final Future Function(String name, int categoryId)? onCreate; - final _ProductEntryMode? initialEntryMode; - - const _EditDialog({ - required this.item, - required this.current, - required this.products, - required this.categoryTree, - this.onCreate, - this.initialEntryMode, - }); - - @override - State<_EditDialog> createState() => _EditDialogState(); -} - -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; - int? _productCategoryId; - String? _productCategoryPath; - CategorySelectionSource? _productCategorySource; - int? _newCategoryId; - String? _newCategoryPath; - CategorySelectionSource? _newCategorySource; - _Destination _destination = _Destination.inventory; - _ProductEntryMode _entryMode = _ProductEntryMode.existing; - bool _isCreatingProduct = false; - // Lokal lista — utökas om nya produkter skapas under dialogen - late List _localProducts; - - @override - void initState() { - super.initState(); - _productId = widget.current.productId; - _productName = widget.current.productName == null - ? null - : _normalizeProductName(widget.current.productName!); - _destination = widget.current.destination; - _entryMode = widget.initialEntryMode ?? - (_productId == null ? _ProductEntryMode.create : _ProductEntryMode.existing); - _localProducts = List.of(widget.products); - _productCategoryId = widget.current.categoryId ?? _categoryIdForProduct(_productId); - _productCategoryPath = widget.current.categoryPath ?? _categoryPathForCategoryId(_productCategoryId); - _productCategorySource = widget.current.categorySource; - _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: initialPackQuantity?.toString() ?? '', - ); - _unitCtrl = TextEditingController( - text: initialPackUnit ?? '', - ); - _packageCountCtrl = TextEditingController( - text: initialPackageCount.toString(), - ); - _newProductNameCtrl = TextEditingController( - text: _normalizeProductName(widget.current.productName ?? widget.item.rawName), - ); - } - - int? _categoryIdForProduct(int? productId) { - if (productId == null) return null; - return _localProducts - .cast() - .firstWhere((p) => p?.id == productId, orElse: () => null) - ?.categoryId; - } - - String? _categoryPathForCategoryId(int? categoryId) { - if (categoryId == null) return null; - - List? walk(List nodes, List parents) { - for (final node in nodes) { - final path = [...parents, node.name]; - if (node.id == categoryId) return path; - final found = walk(node.children, path); - if (found != null) return found; - } - return null; - } - - return walk(widget.categoryTree, const [])?.join(' > '); - } - - @override - void dispose() { - _quantityCtrl.dispose(); - _unitCtrl.dispose(); - _packageCountCtrl.dispose(); - _newProductNameCtrl.dispose(); - super.dispose(); - } - - Future _openCreateCategoryPicker({int? preselectedCategoryId}) async { - final selected = await CategoryThenProductPicker.showCategorySheet( - context, - categoryTree: widget.categoryTree, - preselectedCategoryId: - preselectedCategoryId ?? _newCategoryId ?? widget.item.categorySuggestionId, - ); - if (selected == null || !mounted) return; - setState(() { - _newCategoryId = selected.id; - _newCategoryPath = selected.path; - _newCategorySource = CategorySelectionSource.manual; - }); - } - - Future _openExistingCategoryPicker({int? preselectedCategoryId}) async { - Future Function(String, int)? onCreateWrapped; - if (widget.onCreate != null) { - onCreateWrapped = (name, categoryId) async { - final newProduct = await widget.onCreate!(name, categoryId); - if (newProduct != null && mounted) { - setState(() => _localProducts = [..._localProducts, newProduct]); - } - return newProduct; - }; - } - - final id = await CategoryThenProductPicker.show( - context, - categoryTree: widget.categoryTree, - products: _localProducts, - currentProductId: _productId, - preselectedCategoryId: preselectedCategoryId, - initialQuery: widget.item.rawName, - onCreate: onCreateWrapped, - ); - if (id != null && mounted) { - setState(() { - _productId = id; - final selectedName = _localProducts - .cast() - .firstWhere((p) => p?.id == id, orElse: () => null) - ?.name; - _productName = selectedName == null ? null : _normalizeProductName(selectedName); - _productCategoryId = _categoryIdForProduct(id); - _productCategoryPath = _categoryPathForCategoryId(_productCategoryId); - _productCategorySource = CategorySelectionSource.manual; - }); - } - } - - void _applyAiSuggestionForExistingSelection() { - final suggestedId = widget.item.suggestedProductId; - int? preselectedCategoryId = widget.item.categorySuggestionId; - if (suggestedId != null) { - setState(() { - _productId = suggestedId; - _productName = widget.item.suggestedProductName == null - ? null - : _normalizeProductName(widget.item.suggestedProductName!); - _productCategoryId = _categoryIdForProduct(suggestedId) ?? widget.item.categorySuggestionId; - _productCategoryPath = - _categoryPathForCategoryId(_productCategoryId) ?? widget.item.categorySuggestionPath; - _productCategorySource = CategorySelectionSource.ai; - }); - preselectedCategoryId = _productCategoryId; - } - - _openExistingCategoryPicker( - preselectedCategoryId: preselectedCategoryId, - ); - } - - bool get _canConfirm { - if (_isCreatingProduct) return false; - if (_entryMode == _ProductEntryMode.create) return true; - return _productId != null; - } - - Future _confirm() async { - if (_entryMode == _ProductEntryMode.create) { - final trimmedName = _newProductNameCtrl.text.trim(); - if (trimmedName.isEmpty) { - showGlobalErrorDialog(context, 'Ange ett produktnamn först.'); - return; - } - - if (_newCategoryId == null) { - showGlobalErrorDialog(context, 'Välj kategori innan du skapar produkten.'); - return; - } - - if (widget.onCreate == null) { - showGlobalErrorDialog(context, 'Produktskapande är inte tillgängligt i den här vyn.'); - return; - } - - setState(() => _isCreatingProduct = true); - try { - final newProduct = await widget.onCreate!( - trimmedName, - _newCategoryId!, - ); - if (newProduct == null || !mounted) { - if (mounted) { - showGlobalErrorDialog(context, 'Kunde inte skapa produkten. Försök igen.'); - } - return; - } - if (!_localProducts.any((p) => p.id == newProduct.id)) { - _localProducts = [..._localProducts, newProduct]; - } - _productId = newProduct.id; - _productName = newProduct.name; - _productCategoryId = _newCategoryId; - _productCategoryPath = _newCategoryPath; - _productCategorySource = _newCategorySource ?? CategorySelectionSource.manual; - } on ApiException catch (e) { - if (mounted) { - showGlobalErrorDialog( - context, - e.message.trim().isEmpty - ? 'Kunde inte skapa produkten. Försök igen.' - : e.message, - ); - } - return; - } catch (_) { - if (mounted) { - showGlobalErrorDialog(context, 'Kunde inte skapa produkten. Försök igen.'); - } - return; - } finally { - if (mounted) setState(() => _isCreatingProduct = false); - } - 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( - productId: _productId, - productName: _productName, - categoryId: _productCategoryId, - categoryPath: _productCategoryPath, - categorySource: _productCategorySource, - quantity: totalQuantity, - unit: packUnit, - packQuantity: packQuantity, - packUnit: packUnit, - packageCount: packageCount, - destination: _destination, - ), - ); - } - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final item = widget.item; - final aiCategory = item.categorySuggestionName; - final aiPath = item.categorySuggestionPath; - final aiLabel = (aiPath != null && aiPath.isNotEmpty) - ? aiPath - : ((aiCategory != null && aiCategory.isNotEmpty) ? aiCategory : null); - final suggestedProductLabel = item.suggestedProductName?.isNotEmpty == true - ? _normalizeProductName(item.suggestedProductName!) - : (item.matchedProductName?.isNotEmpty == true - ? _normalizeProductName(item.matchedProductName!) - : 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( - _normalizeProductName(item.rawName), - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - content: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Destination - SegmentedButton<_Destination>( - segments: const [ - ButtonSegment( - value: _Destination.inventory, - icon: Icon(Icons.kitchen_outlined, size: 16), - label: Text('Inventarie'), - ), - ButtonSegment( - value: _Destination.pantry, - icon: Icon(Icons.inventory_2_outlined, size: 16), - label: Text('Baslager'), - ), - ], - selected: {_destination}, - onSelectionChanged: (s) => setState(() => _destination = s.first), - style: const ButtonStyle(visualDensity: VisualDensity.compact), - ), - const SizedBox(height: 12), - // Produktval: befintlig produkt eller skapa ny från importnamnet - SegmentedButton<_ProductEntryMode>( - segments: const [ - ButtonSegment( - value: _ProductEntryMode.existing, - icon: Icon(Icons.search, size: 16), - label: Text('Befintlig'), - ), - ButtonSegment( - value: _ProductEntryMode.create, - icon: Icon(Icons.add_box_outlined, size: 16), - label: Text('Ny produkt'), - ), - ], - selected: {_entryMode}, - onSelectionChanged: (s) => setState(() => _entryMode = s.first), - style: const ButtonStyle(visualDensity: VisualDensity.compact), - ), - const SizedBox(height: 12), - if (_entryMode == _ProductEntryMode.existing) - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (suggestedProductLabel != null) ...[ - Tooltip( - message: 'Trolig matchning baserat på produktnamn i databasen', - child: ActionChip( - avatar: Icon( - Icons.search, - size: 14, - color: Colors.blue.shade700, - ), - label: Text( - 'Namnförslag: $suggestedProductLabel', - style: theme.textTheme.labelSmall, - ), - backgroundColor: Colors.blue.shade50, - side: BorderSide(color: Colors.blue.shade300), - visualDensity: VisualDensity.compact, - onPressed: _applyAiSuggestionForExistingSelection, - ), - ), - const SizedBox(height: 8), - ], - Row( - children: [ - Expanded( - child: ProductPickerField( - products: _localProducts, - value: _productId, - label: 'Produkt', - initialQuery: item.rawName, - onChanged: (id) { - setState(() { - _productId = id; - final selectedName = id == null - ? null - : _localProducts - .cast() - .firstWhere((p) => p?.id == id, orElse: () => null) - ?.name; - _productName = selectedName == null - ? null - : _normalizeProductName(selectedName); - _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, - ), - onPressed: () => _openExistingCategoryPicker(), - child: const Icon(Icons.account_tree_outlined, size: 20), - ), - ), - ], - ), - if (_productCategoryPath != null) ...[ - const SizedBox(height: 8), - ActionChip( - avatar: Icon( - Icons.account_tree_outlined, - size: 14, - color: theme.colorScheme.primary, - ), - label: Text( - 'Kategori: $_productCategoryPath', - style: theme.textTheme.labelSmall, - overflow: TextOverflow.ellipsis, - ), - side: BorderSide(color: theme.colorScheme.outlineVariant), - visualDensity: VisualDensity.compact, - onPressed: () => _openExistingCategoryPicker( - preselectedCategoryId: _productCategoryId, - ), - ), - ], - if (aiLabel != null) ...[ - const SizedBox(height: 8), - ActionChip( - avatar: Icon( - Icons.auto_awesome, - size: 14, - color: Colors.green.shade700, - ), - label: Text( - 'AI-kategori: $aiLabel', - style: theme.textTheme.labelSmall, - ), - backgroundColor: Colors.green.shade50, - side: BorderSide(color: Colors.green.shade300), - visualDensity: VisualDensity.compact, - onPressed: () => _openExistingCategoryPicker( - preselectedCategoryId: item.categorySuggestionId, - ), - ), - ], - ], - ) - else ...[ - TextField( - controller: _newProductNameCtrl, - textCapitalization: TextCapitalization.sentences, - decoration: const InputDecoration( - labelText: 'Produktnamn', - border: OutlineInputBorder(), - ), - onChanged: (_) => setState(() {}), - ), - const SizedBox(height: 8), - SizedBox( - width: double.infinity, - child: OutlinedButton.icon( - onPressed: _openCreateCategoryPicker, - icon: const Icon(Icons.account_tree_outlined), - label: Text( - _newCategoryPath == null - ? 'Välj kategori' - : 'Kategori: $_newCategoryPath', - overflow: TextOverflow.ellipsis, - ), - ), - ), - if (_newCategoryPath != null) ...[ - const SizedBox(height: 8), - Container( - width: double.infinity, - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: theme.colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(12), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Vald kategori', - style: theme.textTheme.labelSmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: 4), - Text( - _newCategoryPath!, - style: theme.textTheme.bodyMedium?.copyWith( - fontWeight: FontWeight.w600, - ), - ), - ], - ), - ), - if (aiLabel != null) ...[ - const SizedBox(height: 8), - Wrap( - children: [ - 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: item.categorySuggestionId == null - ? null - : () => _openCreateCategoryPicker( - preselectedCategoryId: item.categorySuggestionId, - ), - ), - ], - ), - ], - ], - ], - const SizedBox(height: 12), - if (_destination == _Destination.inventory) ...[ - Row(children: [ - Expanded( - child: TextField( - controller: _quantityCtrl, - keyboardType: const TextInputType.numberWithOptions(decimal: true), - decoration: const InputDecoration( - labelText: 'Mängd per förpackning', - border: OutlineInputBorder(), - ), - onChanged: (_) => setState(() {}), - ), - ), - const SizedBox(width: 8), - Expanded( - child: TextField( - controller: _unitCtrl, - decoration: const InputDecoration( - labelText: 'Enhet', - border: OutlineInputBorder(), - ), - onChanged: (_) => setState(() {}), - ), - ), - ]), - const SizedBox(height: 8), - TextField( - controller: _packageCountCtrl, - keyboardType: const TextInputType.numberWithOptions(decimal: true), - decoration: const InputDecoration( - labelText: 'Antal förpackningar', - border: OutlineInputBorder(), - ), - onChanged: (_) => setState(() {}), - ), - if (totalPreview != null && currentUnit != null && currentUnit.isNotEmpty) ...[ - 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( - 'Totalt: ${_formatSwedishNumber(totalPreview)} $currentUnit ' - '(mängd x antal förpackningar).', - style: theme.textTheme.bodySmall?.copyWith( - color: Colors.green.shade800, - ), - ), - ), - ], - ] else ...[ - Text( - 'Baslager sparar bara produkt — ingen mängd eller enhet.', - style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant), - ), - ], - ], - ), - ), - actions: [ - TextButton(onPressed: () => Navigator.pop(context), child: const Text('Avbryt')), - FilledButton( - onPressed: _canConfirm ? _confirm : null, - child: _isCreatingProduct - ? const SizedBox( - width: 18, - height: 18, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : Text(_entryMode == _ProductEntryMode.create ? 'Skapa och välj' : 'OK'), - ), - ], - ); - } -} - // ── Huvudwidget ─────────────────────────────────────────────────────────────── class ReceiptImportTab extends ConsumerStatefulWidget { @@ -828,6 +49,7 @@ class _ReceiptImportTabState extends ConsumerState { // Kategoriträdet för tvåstegs-picker List _categoryTree = []; + CategoryLookup _lookup = CategoryLookup([]); // Befintligt inventarie: productId → InventoryItem (för sammanslagning) Map _inventoryByProduct = {}; @@ -849,22 +71,6 @@ class _ReceiptImportTabState extends ConsumerState { ?.categoryId; } - String? _categoryPathForCategoryId(int? categoryId) { - if (categoryId == null) return null; - - List? walk(List nodes, List parents) { - for (final node in nodes) { - final path = [...parents, node.name]; - if (node.id == categoryId) return path; - final found = walk(node.children, path); - if (found != null) return found; - } - return null; - } - - return walk(_categoryTree, const [])?.join(' > '); - } - Future _loadProducts() async { try { final token = await ref.read(authStateProvider.future); @@ -873,8 +79,16 @@ class _ReceiptImportTabState extends ConsumerState { final results = await Future.wait([ api.getJson(ProductApiPaths.list, token: token), api.getJson(ProductApiPaths.mine, token: token), - adminRepo.listCategoryTree(), ]); + + List categoryTree = _categoryTree; + try { + categoryTree = await adminRepo.listCategoryTree(); + } catch (e, st) { + debugPrint('ReceiptImportTab._loadProducts categoryTree failed: $e'); + debugPrintStack(stackTrace: st); + } + final globalData = results[0]; final mineData = results[1]; final globalList = globalData is List @@ -898,7 +112,8 @@ class _ReceiptImportTabState extends ConsumerState { setState(() { _products = dedupedById.values.toList(); - _categoryTree = results[2] as List; + _categoryTree = categoryTree; + _lookup = CategoryLookup(categoryTree); }); } } catch (e, st) { @@ -974,7 +189,7 @@ class _ReceiptImportTabState extends ConsumerState { final pid = it.matchedProductId ?? it.suggestedProductId; notifier.setSelected(i, pid != null); if (pid != null) { - final inferred = _inferPackageFields( + final inferred = inferPackageFields( rawName: it.rawName, quantity: it.quantity, unit: it.unit, @@ -982,7 +197,7 @@ class _ReceiptImportTabState extends ConsumerState { final name = it.matchedProductName ?? it.suggestedProductName; final resolvedCategoryId = it.categorySuggestionId ?? _categoryIdForProduct(pid); final resolvedCategoryPath = it.categorySuggestionPath ?? - _categoryPathForCategoryId(resolvedCategoryId); + _lookup.pathFor(resolvedCategoryId); notifier.setEdit(i, _ItemEdit( productId: pid, productName: name, @@ -1010,10 +225,22 @@ class _ReceiptImportTabState extends ConsumerState { Future _openEditDialog( int index, { - _ProductEntryMode? initialEntryMode, + ImportProductEntryMode? initialEntryMode, }) async { + if (_categoryTree.isEmpty) { + await _loadProducts(); + if (!mounted) return; + if (_categoryTree.isEmpty) { + showGlobalErrorDialog( + context, + 'Inga kategorier kunde laddas. Prova att uppdatera kategorier i Admin > Databas och försök igen.', + ); + return; + } + } + final item = _items![index]; - final inferred = _inferPackageFields( + final inferred = inferPackageFields( rawName: item.rawName, quantity: item.quantity, unit: item.unit, @@ -1025,7 +252,7 @@ class _ReceiptImportTabState extends ConsumerState { categoryId: item.categorySuggestionId ?? _categoryIdForProduct(item.matchedProductId ?? item.suggestedProductId), categoryPath: item.categorySuggestionPath ?? - _categoryPathForCategoryId( + _lookup.pathFor( item.categorySuggestionId ?? _categoryIdForProduct(item.matchedProductId ?? item.suggestedProductId), ), @@ -1041,7 +268,7 @@ class _ReceiptImportTabState extends ConsumerState { final result = await showDialog<_ItemEdit>( context: context, - builder: (_) => _EditDialog( + builder: (_) => EditDialog( item: item, current: current, products: _products, @@ -1134,7 +361,7 @@ class _ReceiptImportTabState extends ConsumerState { pantryAdded++; } } else { - final inferred = _inferPackageFields( + final inferred = inferPackageFields( rawName: item.rawName, quantity: edit.quantity ?? item.quantity, unit: edit.unit ?? item.unit, @@ -1149,7 +376,7 @@ class _ReceiptImportTabState extends ConsumerState { final existing = _inventoryByProduct[pid]; final qtyInExistingUnit = existing == null ? null - : _convertQuantity(qty, unit, existing.unit); + : convertQuantity(qty, unit, existing.unit); if (existing != null && qtyInExistingUnit != null) { await invRepo.updateInventoryItem( existing.id, @@ -1326,7 +553,7 @@ class _ReceiptImportTabState extends ConsumerState { final existingInv = edit?.productId != null && edit?.destination != _Destination.pantry ? _inventoryByProduct[edit!.productId] : null; - final inferredForPreview = _inferPackageFields( + final inferredForPreview = inferPackageFields( rawName: item.rawName, quantity: edit?.quantity ?? item.quantity, unit: edit?.unit ?? item.unit, @@ -1343,7 +570,7 @@ class _ReceiptImportTabState extends ConsumerState { 'st'; final convertedPreviewQty = existingInv == null ? null - : _convertQuantity( + : convertQuantity( previewIncomingQty, previewIncomingUnit, existingInv.unit, @@ -1364,7 +591,7 @@ class _ReceiptImportTabState extends ConsumerState { }, ), title: Text( - _normalizeProductName(item.rawName), + normalizeProductName(item.rawName), style: theme.textTheme.bodyMedium, ), subtitle: Column( @@ -1388,7 +615,7 @@ class _ReceiptImportTabState extends ConsumerState { crossAxisAlignment: WrapCrossAlignment.center, children: [ Text( - 'Produktnamn: ${_normalizeProductName(edit!.productName ?? '')}', + 'Produktnamn: ${normalizeProductName(edit!.productName ?? '')}', style: theme.textTheme.bodySmall?.copyWith( color: isMatched ? Colors.green.shade700 : theme.colorScheme.primary, fontWeight: FontWeight.w500, @@ -1420,7 +647,7 @@ class _ReceiptImportTabState extends ConsumerState { ], ) else if (isSuggested) - Text('Namnförslag: ${_normalizeProductName(item.suggestedProductName ?? '')}', + Text('Namnförslag: ${normalizeProductName(item.suggestedProductName ?? '')}', style: theme.textTheme.bodySmall?.copyWith(color: Colors.orange.shade700)) else Text('Ingen matchning ännu — tryck för att välja eller skapa produkt', @@ -1443,7 +670,7 @@ class _ReceiptImportTabState extends ConsumerState { OutlinedButton.icon( onPressed: () => _openEditDialog( i, - initialEntryMode: _ProductEntryMode.existing, + initialEntryMode: ImportProductEntryMode.existing, ), icon: const Icon(Icons.search, size: 16), label: const Text('Välj befintlig'), @@ -1455,7 +682,7 @@ class _ReceiptImportTabState extends ConsumerState { OutlinedButton.icon( onPressed: () => _openEditDialog( i, - initialEntryMode: _ProductEntryMode.create, + initialEntryMode: ImportProductEntryMode.create, ), icon: const Icon(Icons.add_box_outlined, size: 16), label: const Text('Ny produkt'), diff --git a/flutter/lib/features/import/utils/receipt_import_utils.dart b/flutter/lib/features/import/utils/receipt_import_utils.dart new file mode 100644 index 00000000..58d94588 --- /dev/null +++ b/flutter/lib/features/import/utils/receipt_import_utils.dart @@ -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 = { + 'mg': 0.001, + 'g': 1.0, + 'hg': 100.0, + 'kg': 1000.0, +}; + +// Alla volymvärden normaliseras till milliliter (ml). +const _volToMl = { + '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 = { + '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 _pathByid; + + CategoryLookup._(this._pathByid); + + factory CategoryLookup.fromTree(List tree) { + final map = {}; + void walk(List nodes, List 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; +}