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
@@ -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<String?> _token() => _ref.read(authStateProvider.future);
Future<List<UserAdmin>> listUsers() async {
/// GET-anrop som returnerar en typad lista med [fromJson].
Future<List<T>> _getList<T>(
String path,
T Function(Map<String, dynamic>) 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<T> _post<T>(
String path, {
required Map<String, dynamic>? 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<T> _patch<T>(
String path, {
required Map<String, dynamic> 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<dynamic>).map((e) => UserAdmin.fromJson(e as Map<String, dynamic>)).toList();
return parse(data);
}
Future<UserAdmin> 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<UserAdmin> 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<UserAdmin> 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<void> updateEmail(int userId, String email) async {
/// Fire-and-forget PATCH.
Future<void> _patchVoid(String path, Map<String, dynamic> 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<void> _postVoid(String path, [Map<String, dynamic>? body]) async {
final token = await _token();
await guardedApiCall(
_ref,
() => _apiClient.postJson(path, body: body, token: token),
);
}
/// Fire-and-forget DELETE.
Future<void> _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<T> _parseList<T>(
dynamic data,
T Function(Map<String, dynamic>) fromJson,
) {
final List<dynamic> raw;
if (data is List<dynamic>) {
raw = data;
} else if (data is Map<String, dynamic>) {
raw = (data['items'] as List<dynamic>?) ??
(data['data'] as List<dynamic>?) ??
const [];
if (raw.isEmpty && data.isNotEmpty) {
debugPrint('[AdminRepository] Unexpected API wrapper shape: ${data.keys}');
}
} else {
raw = const [];
}
return raw
.whereType<Map>()
.map((e) => fromJson(Map<String, dynamic>.from(e)))
.toList();
}
// ── Användare ──────────────────────────────────────────────────────────────
Future<List<UserAdmin>> listUsers() =>
_getList(UserApiPaths.list, UserAdmin.fromJson);
Future<UserAdmin> setRole(int userId, String newRole) =>
_patch(UserApiPaths.setRole(userId),
body: {'role': newRole}, parse: UserAdmin.fromJson);
Future<UserAdmin> setPremium(int userId, {required bool isPremium}) =>
_patch(UserApiPaths.setPremium(userId),
body: {'isPremium': isPremium}, parse: UserAdmin.fromJson);
Future<UserAdmin> setRecipeSharing(int userId,
{required bool canShareRecipes}) =>
_patch(UserApiPaths.setRecipeSharing(userId),
body: {'canShareRecipes': canShareRecipes}, parse: UserAdmin.fromJson);
Future<void> updateEmail(int userId, String email) =>
_patchVoid(UserApiPaths.updateEmail(userId), {'email': email});
Future<UserAdmin> 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: {
}) =>
_post(
UserApiPaths.list,
body: {
'username': username,
'email': email,
'password': password,
'role': role,
}, token: token),
},
parse: (d) => UserAdmin.fromJson(d as Map<String, dynamic>),
);
return UserAdmin.fromJson(data as Map<String, dynamic>);
}
Future<void> deleteUser(int userId) async {
final token = await _token();
return guardedApiCall(
_ref,
() => _apiClient.deleteJson(UserApiPaths.delete(userId), token: token),
);
}
Future<void> deleteUser(int userId) => _deleteVoid(UserApiPaths.delete(userId));
/// Returns `{ temporaryPassword, to, subject, body }`.
Future<Map<String, dynamic>> resetPassword(int userId) async {
final token = await _token();
final result = await guardedApiCall<dynamic>(
_ref,
() => _apiClient.postJson(UserApiPaths.resetPassword(userId), token: token),
Future<Map<String, dynamic>> resetPassword(int userId) =>
_post<Map<String, dynamic>>(
UserApiPaths.resetPassword(userId),
body: null,
parse: (d) => d as Map<String, dynamic>,
);
return (result as Map<String, dynamic>);
}
Future<List<PendingProduct>> listPendingProducts() async {
final token = await _token();
final data = await guardedApiCall(
_ref,
() => _apiClient.getJson(ProductApiPaths.pending, token: token),
);
return (data as List<dynamic>)
.map((e) => PendingProduct.fromJson(e as Map<String, dynamic>))
.toList();
}
// ── Produkter ──────────────────────────────────────────────────────────────
Future<void> setProductStatus(int productId, String status) async {
final token = await _token();
await guardedApiCall(
_ref,
() => _apiClient.patchJson(
ProductApiPaths.setStatus(productId),
body: {'status': status},
token: token,
),
);
}
Future<List<AdminProduct>> listProducts() =>
_getList(ProductApiPaths.list, AdminProduct.fromJson);
Future<List<AiModelInfo>> listAiModels() async {
final data = await guardedApiCall(
_ref,
() => _apiClient.getJson(AiApiPaths.models),
);
return (data as List<dynamic>)
.map((e) => AiModelInfo.fromJson(e as Map<String, dynamic>))
.toList();
}
Future<List<AdminProduct>> listDeletedProducts() =>
_getList(ProductApiPaths.deleted, AdminProduct.fromJson);
Future<List<AdminProduct>> listProducts() async {
final token = await _token();
final data = await guardedApiCall(
_ref,
() => _apiClient.getJson(ProductApiPaths.list, token: token),
);
return (data as List<dynamic>)
.map((e) => AdminProduct.fromJson(e as Map<String, dynamic>))
.toList();
}
Future<List<PendingProduct>> listPendingProducts() =>
_getList(ProductApiPaths.pending, PendingProduct.fromJson);
Future<void> setProductStatus(int productId, String status) =>
_patchVoid(ProductApiPaths.setStatus(productId), {'status': status});
Future<void> setProductCategory(int productId, {required int? categoryId}) =>
_patchVoid(ProductApiPaths.update(productId), {'categoryId': categoryId});
Future<void> removeProduct(int productId) =>
_deleteVoid(ProductApiPaths.remove(productId));
Future<void> restoreProduct(int productId) =>
_postVoid(ProductApiPaths.restore(productId));
/// Skapar en ny aktiv produkt (kräver admin). Returnerar `{id, name, categoryId?}`.
Future<Map<String, dynamic>> createProduct(String name, {int? categoryId}) async {
final token = await _token();
final data = await guardedApiCall(
_ref,
() => _apiClient.postJson(
Future<Map<String, dynamic>> createProduct(String name, {int? categoryId}) =>
_post<Map<String, dynamic>>(
ProductApiPaths.list,
body: {
'name': name.trim(),
if (categoryId != null) 'categoryId': categoryId,
},
token: token,
),
parse: (d) => d as Map<String, dynamic>,
);
return data as Map<String, dynamic>;
}
Future<List<AdminProduct>> listDeletedProducts() async {
final token = await _token();
final data = await guardedApiCall(
_ref,
() => _apiClient.getJson(ProductApiPaths.deleted, token: token),
);
return (data as List<dynamic>)
.map((e) => AdminProduct.fromJson(e as Map<String, dynamic>))
.toList();
}
Future<List<AdminCategoryNode>> listCategoryTree() async {
final token = await _token();
final data = await guardedApiCall(
_ref,
() => _apiClient.getJson(CategoryApiPaths.tree, token: token),
);
return (data as List<dynamic>)
.map((e) => AdminCategoryNode.fromJson(e as Map<String, dynamic>))
.toList();
}
Future<void> bulkSetCategory(List<int> ids, {required int? categoryId}) async {
final token = await _token();
await guardedApiCall(
_ref,
() => _apiClient.postJson(
ProductApiPaths.bulkUpdate,
body: {'ids': ids, 'categoryId': categoryId},
token: token,
),
);
}
Future<void> 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<void> removeProduct(int productId) async {
final token = await _token();
await guardedApiCall(
_ref,
() => _apiClient.deleteJson(ProductApiPaths.remove(productId), token: token),
);
}
Future<void> restoreProduct(int productId) async {
final token = await _token();
await guardedApiCall(
_ref,
() => _apiClient.postJson(ProductApiPaths.restore(productId), token: token),
);
}
Future<void> bulkSetCategory(List<int> ids, {required int? categoryId}) =>
_postVoid(ProductApiPaths.bulkUpdate, {'ids': ids, 'categoryId': categoryId});
Future<void> mergeProducts({
required int sourceProductId,
required int targetProductId,
}) async {
final token = await _token();
await guardedApiCall(
_ref,
() => _apiClient.postJson(
ProductApiPaths.merge,
body: {
}) =>
_postVoid(ProductApiPaths.merge, {
'sourceProductId': sourceProductId,
'targetProductId': targetProductId,
},
token: token,
),
);
}
});
Future<List<AdminAiCategorizeResult>> aiCategorizeBulk({
List<int>? 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<dynamic>)
.map((e) => AdminAiCategorizeResult.fromJson(e as Map<String, dynamic>))
return _parseList(data, AdminAiCategorizeResult.fromJson)
.where((e) => e.productId > 0 && e.categoryId > 0)
.toList();
}
// ── Kategorier ─────────────────────────────────────────────────────────────
Future<List<AdminCategoryNode>> 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<List<AiModelInfo>> listAiModels() =>
_getList(AiApiPaths.models, AiModelInfo.fromJson);
}
@@ -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<ProductOption> products;
final List<AdminCategoryNode> categoryTree;
final Future<ProductOption?> 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<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;
ImportProductEntryMode _entryMode = ImportProductEntryMode.existing;
bool _isCreatingProduct = false;
// Lokal lista — utökas om nya produkter skapas under dialogen
late List<ProductOption> _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<ProductOption?>()
.firstWhere((p) => p?.id == productId, orElse: () => null)
?.categoryId;
}
// ── Kategoripicker ─────────────────────────────────────────────────────────
Future<void> _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<void> _openExistingCategoryPicker({int? preselectedCategoryId}) async {
Future<ProductOption?> 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<ProductOption?>()
.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<void> _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<ImportProductEntryMode>(
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<ProductOption?>()
.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;
}
}
@@ -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<ProductOption> products;
final List<AdminCategoryNode> categoryTree;
final Future<ProductOption?> 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<ProductOption> _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<ProductOption?>()
.firstWhere((p) => p?.id == productId, orElse: () => null)
?.categoryId;
}
String? _categoryPathForCategoryId(int? categoryId) {
if (categoryId == null) return null;
List<String>? walk(List<AdminCategoryNode> nodes, List<String> 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<void> _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<void> _openExistingCategoryPicker({int? preselectedCategoryId}) async {
Future<ProductOption?> 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<ProductOption?>()
.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<void> _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<ProductOption?>()
.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<ReceiptImportTab> {
// Kategoriträdet för tvåstegs-picker
List<AdminCategoryNode> _categoryTree = [];
CategoryLookup _lookup = CategoryLookup([]);
// Befintligt inventarie: productId → InventoryItem (för sammanslagning)
Map<int, InventoryItem> _inventoryByProduct = {};
@@ -849,22 +71,6 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
?.categoryId;
}
String? _categoryPathForCategoryId(int? categoryId) {
if (categoryId == null) return null;
List<String>? walk(List<AdminCategoryNode> nodes, List<String> 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<void> _loadProducts() async {
try {
final token = await ref.read(authStateProvider.future);
@@ -873,8 +79,16 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
final results = await Future.wait([
api.getJson(ProductApiPaths.list, token: token),
api.getJson(ProductApiPaths.mine, token: token),
adminRepo.listCategoryTree(),
]);
List<AdminCategoryNode> 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<ReceiptImportTab> {
setState(() {
_products = dedupedById.values.toList();
_categoryTree = results[2] as List<AdminCategoryNode>;
_categoryTree = categoryTree;
_lookup = CategoryLookup(categoryTree);
});
}
} catch (e, st) {
@@ -974,7 +189,7 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
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<ReceiptImportTab> {
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<ReceiptImportTab> {
Future<void> _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<ReceiptImportTab> {
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<ReceiptImportTab> {
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<ReceiptImportTab> {
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<ReceiptImportTab> {
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<ReceiptImportTab> {
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<ReceiptImportTab> {
'st';
final convertedPreviewQty = existingInv == null
? null
: _convertQuantity(
: convertQuantity(
previewIncomingQty,
previewIncomingUnit,
existingInv.unit,
@@ -1364,7 +591,7 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
},
),
title: Text(
_normalizeProductName(item.rawName),
normalizeProductName(item.rawName),
style: theme.textTheme.bodyMedium,
),
subtitle: Column(
@@ -1388,7 +615,7 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
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<ReceiptImportTab> {
],
)
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<ReceiptImportTab> {
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<ReceiptImportTab> {
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'),
@@ -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;
}