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:
@@ -1,3 +1,4 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import '../../../core/api/api_client.dart';
|
import '../../../core/api/api_client.dart';
|
||||||
import '../../../core/api/api_paths.dart';
|
import '../../../core/api/api_paths.dart';
|
||||||
@@ -20,237 +21,197 @@ class AdminRepository {
|
|||||||
|
|
||||||
AdminRepository(this._apiClient, this._ref);
|
AdminRepository(this._apiClient, this._ref);
|
||||||
|
|
||||||
|
// ── Interna helpers ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
Future<String?> _token() => _ref.read(authStateProvider.future);
|
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 token = await _token();
|
||||||
final data = await guardedApiCall(
|
final data = await guardedApiCall(
|
||||||
_ref,
|
_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 {
|
/// Fire-and-forget PATCH.
|
||||||
final token = await _token();
|
Future<void> _patchVoid(String path, Map<String, dynamic> body) async {
|
||||||
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 {
|
|
||||||
final token = await _token();
|
final token = await _token();
|
||||||
await guardedApiCall(
|
await guardedApiCall(
|
||||||
_ref,
|
_ref,
|
||||||
() => _apiClient.patchJson(
|
() => _apiClient.patchJson(path, body: body, token: token),
|
||||||
UserApiPaths.updateEmail(userId),
|
|
||||||
body: {'email': email},
|
|
||||||
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({
|
Future<UserAdmin> createUser({
|
||||||
required String username,
|
required String username,
|
||||||
required String email,
|
required String email,
|
||||||
required String password,
|
required String password,
|
||||||
String role = 'user',
|
String role = 'user',
|
||||||
}) async {
|
}) =>
|
||||||
final token = await _token();
|
_post(
|
||||||
final data = await guardedApiCall(
|
UserApiPaths.list,
|
||||||
_ref,
|
body: {
|
||||||
() => _apiClient.postJson(UserApiPaths.list, body: {
|
'username': username,
|
||||||
'username': username,
|
'email': email,
|
||||||
'email': email,
|
'password': password,
|
||||||
'password': password,
|
'role': role,
|
||||||
'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 {
|
Future<void> deleteUser(int userId) => _deleteVoid(UserApiPaths.delete(userId));
|
||||||
final token = await _token();
|
|
||||||
return guardedApiCall(
|
|
||||||
_ref,
|
|
||||||
() => _apiClient.deleteJson(UserApiPaths.delete(userId), token: token),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns `{ temporaryPassword, to, subject, body }`.
|
/// Returns `{ temporaryPassword, to, subject, body }`.
|
||||||
Future<Map<String, dynamic>> resetPassword(int userId) async {
|
Future<Map<String, dynamic>> resetPassword(int userId) =>
|
||||||
final token = await _token();
|
_post<Map<String, dynamic>>(
|
||||||
final result = await guardedApiCall<dynamic>(
|
UserApiPaths.resetPassword(userId),
|
||||||
_ref,
|
body: null,
|
||||||
() => _apiClient.postJson(UserApiPaths.resetPassword(userId), token: token),
|
parse: (d) => d as Map<String, dynamic>,
|
||||||
);
|
);
|
||||||
return (result as Map<String, dynamic>);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<List<PendingProduct>> listPendingProducts() async {
|
// ── Produkter ──────────────────────────────────────────────────────────────
|
||||||
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();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> setProductStatus(int productId, String status) async {
|
Future<List<AdminProduct>> listProducts() =>
|
||||||
final token = await _token();
|
_getList(ProductApiPaths.list, AdminProduct.fromJson);
|
||||||
await guardedApiCall(
|
|
||||||
_ref,
|
|
||||||
() => _apiClient.patchJson(
|
|
||||||
ProductApiPaths.setStatus(productId),
|
|
||||||
body: {'status': status},
|
|
||||||
token: token,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<List<AiModelInfo>> listAiModels() async {
|
Future<List<AdminProduct>> listDeletedProducts() =>
|
||||||
final data = await guardedApiCall(
|
_getList(ProductApiPaths.deleted, AdminProduct.fromJson);
|
||||||
_ref,
|
|
||||||
() => _apiClient.getJson(AiApiPaths.models),
|
|
||||||
);
|
|
||||||
return (data as List<dynamic>)
|
|
||||||
.map((e) => AiModelInfo.fromJson(e as Map<String, dynamic>))
|
|
||||||
.toList();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<List<AdminProduct>> listProducts() async {
|
Future<List<PendingProduct>> listPendingProducts() =>
|
||||||
final token = await _token();
|
_getList(ProductApiPaths.pending, PendingProduct.fromJson);
|
||||||
final data = await guardedApiCall(
|
|
||||||
_ref,
|
Future<void> setProductStatus(int productId, String status) =>
|
||||||
() => _apiClient.getJson(ProductApiPaths.list, token: token),
|
_patchVoid(ProductApiPaths.setStatus(productId), {'status': status});
|
||||||
);
|
|
||||||
return (data as List<dynamic>)
|
Future<void> setProductCategory(int productId, {required int? categoryId}) =>
|
||||||
.map((e) => AdminProduct.fromJson(e as Map<String, dynamic>))
|
_patchVoid(ProductApiPaths.update(productId), {'categoryId': categoryId});
|
||||||
.toList();
|
|
||||||
}
|
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?}`.
|
/// Skapar en ny aktiv produkt (kräver admin). Returnerar `{id, name, categoryId?}`.
|
||||||
Future<Map<String, dynamic>> createProduct(String name, {int? categoryId}) async {
|
Future<Map<String, dynamic>> createProduct(String name, {int? categoryId}) =>
|
||||||
final token = await _token();
|
_post<Map<String, dynamic>>(
|
||||||
final data = await guardedApiCall(
|
|
||||||
_ref,
|
|
||||||
() => _apiClient.postJson(
|
|
||||||
ProductApiPaths.list,
|
ProductApiPaths.list,
|
||||||
body: {
|
body: {
|
||||||
'name': name.trim(),
|
'name': name.trim(),
|
||||||
if (categoryId != null) 'categoryId': categoryId,
|
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 {
|
Future<void> bulkSetCategory(List<int> ids, {required int? categoryId}) =>
|
||||||
final token = await _token();
|
_postVoid(ProductApiPaths.bulkUpdate, {'ids': ids, 'categoryId': categoryId});
|
||||||
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> mergeProducts({
|
Future<void> mergeProducts({
|
||||||
required int sourceProductId,
|
required int sourceProductId,
|
||||||
required int targetProductId,
|
required int targetProductId,
|
||||||
}) async {
|
}) =>
|
||||||
final token = await _token();
|
_postVoid(ProductApiPaths.merge, {
|
||||||
await guardedApiCall(
|
'sourceProductId': sourceProductId,
|
||||||
_ref,
|
'targetProductId': targetProductId,
|
||||||
() => _apiClient.postJson(
|
});
|
||||||
ProductApiPaths.merge,
|
|
||||||
body: {
|
|
||||||
'sourceProductId': sourceProductId,
|
|
||||||
'targetProductId': targetProductId,
|
|
||||||
},
|
|
||||||
token: token,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<List<AdminAiCategorizeResult>> aiCategorizeBulk({
|
Future<List<AdminAiCategorizeResult>> aiCategorizeBulk({
|
||||||
List<int>? productIds,
|
List<int>? productIds,
|
||||||
@@ -260,15 +221,31 @@ class AdminRepository {
|
|||||||
_ref,
|
_ref,
|
||||||
() => _apiClient.postJson(
|
() => _apiClient.postJson(
|
||||||
ProductApiPaths.aiCategorizeBulk,
|
ProductApiPaths.aiCategorizeBulk,
|
||||||
body: productIds == null || productIds.isEmpty
|
body: (productIds == null || productIds.isEmpty)
|
||||||
? null
|
? null
|
||||||
: {'productIds': productIds},
|
: {'productIds': productIds},
|
||||||
token: token,
|
token: token,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
return (data as List<dynamic>)
|
return _parseList(data, AdminAiCategorizeResult.fromJson)
|
||||||
.map((e) => AdminAiCategorizeResult.fromJson(e as Map<String, dynamic>))
|
|
||||||
.where((e) => e.productId > 0 && e.categoryId > 0)
|
.where((e) => e.productId > 0 && e.categoryId > 0)
|
||||||
.toList();
|
.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:file_picker/file_picker.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import '../../../core/api/api_exception.dart';
|
|
||||||
import '../../../core/api/api_paths.dart';
|
import '../../../core/api/api_paths.dart';
|
||||||
import '../../../core/api/api_providers.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/pdf_opener.dart';
|
||||||
import '../../../core/utils/global_error_handler.dart';
|
import '../../../core/utils/global_error_handler.dart';
|
||||||
import '../../admin/data/admin_repository.dart';
|
import '../../admin/data/admin_repository.dart';
|
||||||
@@ -18,791 +15,15 @@ import '../../pantry/domain/pantry_item.dart';
|
|||||||
import '../data/import_providers.dart';
|
import '../data/import_providers.dart';
|
||||||
import '../data/receipt_import_session.dart';
|
import '../data/receipt_import_session.dart';
|
||||||
import '../domain/parsed_receipt_item.dart';
|
import '../domain/parsed_receipt_item.dart';
|
||||||
|
import '../utils/receipt_import_utils.dart';
|
||||||
|
import 'edit_dialog.dart';
|
||||||
|
|
||||||
typedef _Destination = ImportDestination;
|
typedef _Destination = ImportDestination;
|
||||||
|
|
||||||
enum _ProductEntryMode { existing, create }
|
// ── Typ-alias ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
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 ─────────────────────────────────────────────
|
|
||||||
|
|
||||||
typedef _ItemEdit = ItemEdit;
|
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 ───────────────────────────────────────────────────────────────
|
// ── Huvudwidget ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
class ReceiptImportTab extends ConsumerStatefulWidget {
|
class ReceiptImportTab extends ConsumerStatefulWidget {
|
||||||
@@ -828,6 +49,7 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
|||||||
|
|
||||||
// Kategoriträdet för tvåstegs-picker
|
// Kategoriträdet för tvåstegs-picker
|
||||||
List<AdminCategoryNode> _categoryTree = [];
|
List<AdminCategoryNode> _categoryTree = [];
|
||||||
|
CategoryLookup _lookup = CategoryLookup([]);
|
||||||
|
|
||||||
// Befintligt inventarie: productId → InventoryItem (för sammanslagning)
|
// Befintligt inventarie: productId → InventoryItem (för sammanslagning)
|
||||||
Map<int, InventoryItem> _inventoryByProduct = {};
|
Map<int, InventoryItem> _inventoryByProduct = {};
|
||||||
@@ -849,22 +71,6 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
|||||||
?.categoryId;
|
?.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 {
|
Future<void> _loadProducts() async {
|
||||||
try {
|
try {
|
||||||
final token = await ref.read(authStateProvider.future);
|
final token = await ref.read(authStateProvider.future);
|
||||||
@@ -873,8 +79,16 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
|||||||
final results = await Future.wait([
|
final results = await Future.wait([
|
||||||
api.getJson(ProductApiPaths.list, token: token),
|
api.getJson(ProductApiPaths.list, token: token),
|
||||||
api.getJson(ProductApiPaths.mine, 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 globalData = results[0];
|
||||||
final mineData = results[1];
|
final mineData = results[1];
|
||||||
final globalList = globalData is List
|
final globalList = globalData is List
|
||||||
@@ -898,7 +112,8 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
|||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_products = dedupedById.values.toList();
|
_products = dedupedById.values.toList();
|
||||||
_categoryTree = results[2] as List<AdminCategoryNode>;
|
_categoryTree = categoryTree;
|
||||||
|
_lookup = CategoryLookup(categoryTree);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
@@ -974,7 +189,7 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
|||||||
final pid = it.matchedProductId ?? it.suggestedProductId;
|
final pid = it.matchedProductId ?? it.suggestedProductId;
|
||||||
notifier.setSelected(i, pid != null);
|
notifier.setSelected(i, pid != null);
|
||||||
if (pid != null) {
|
if (pid != null) {
|
||||||
final inferred = _inferPackageFields(
|
final inferred = inferPackageFields(
|
||||||
rawName: it.rawName,
|
rawName: it.rawName,
|
||||||
quantity: it.quantity,
|
quantity: it.quantity,
|
||||||
unit: it.unit,
|
unit: it.unit,
|
||||||
@@ -982,7 +197,7 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
|||||||
final name = it.matchedProductName ?? it.suggestedProductName;
|
final name = it.matchedProductName ?? it.suggestedProductName;
|
||||||
final resolvedCategoryId = it.categorySuggestionId ?? _categoryIdForProduct(pid);
|
final resolvedCategoryId = it.categorySuggestionId ?? _categoryIdForProduct(pid);
|
||||||
final resolvedCategoryPath = it.categorySuggestionPath ??
|
final resolvedCategoryPath = it.categorySuggestionPath ??
|
||||||
_categoryPathForCategoryId(resolvedCategoryId);
|
_lookup.pathFor(resolvedCategoryId);
|
||||||
notifier.setEdit(i, _ItemEdit(
|
notifier.setEdit(i, _ItemEdit(
|
||||||
productId: pid,
|
productId: pid,
|
||||||
productName: name,
|
productName: name,
|
||||||
@@ -1010,10 +225,22 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
|||||||
|
|
||||||
Future<void> _openEditDialog(
|
Future<void> _openEditDialog(
|
||||||
int index, {
|
int index, {
|
||||||
_ProductEntryMode? initialEntryMode,
|
ImportProductEntryMode? initialEntryMode,
|
||||||
}) async {
|
}) 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 item = _items![index];
|
||||||
final inferred = _inferPackageFields(
|
final inferred = inferPackageFields(
|
||||||
rawName: item.rawName,
|
rawName: item.rawName,
|
||||||
quantity: item.quantity,
|
quantity: item.quantity,
|
||||||
unit: item.unit,
|
unit: item.unit,
|
||||||
@@ -1025,7 +252,7 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
|||||||
categoryId: item.categorySuggestionId ??
|
categoryId: item.categorySuggestionId ??
|
||||||
_categoryIdForProduct(item.matchedProductId ?? item.suggestedProductId),
|
_categoryIdForProduct(item.matchedProductId ?? item.suggestedProductId),
|
||||||
categoryPath: item.categorySuggestionPath ??
|
categoryPath: item.categorySuggestionPath ??
|
||||||
_categoryPathForCategoryId(
|
_lookup.pathFor(
|
||||||
item.categorySuggestionId ??
|
item.categorySuggestionId ??
|
||||||
_categoryIdForProduct(item.matchedProductId ?? item.suggestedProductId),
|
_categoryIdForProduct(item.matchedProductId ?? item.suggestedProductId),
|
||||||
),
|
),
|
||||||
@@ -1041,7 +268,7 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
|||||||
|
|
||||||
final result = await showDialog<_ItemEdit>(
|
final result = await showDialog<_ItemEdit>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (_) => _EditDialog(
|
builder: (_) => EditDialog(
|
||||||
item: item,
|
item: item,
|
||||||
current: current,
|
current: current,
|
||||||
products: _products,
|
products: _products,
|
||||||
@@ -1134,7 +361,7 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
|||||||
pantryAdded++;
|
pantryAdded++;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
final inferred = _inferPackageFields(
|
final inferred = inferPackageFields(
|
||||||
rawName: item.rawName,
|
rawName: item.rawName,
|
||||||
quantity: edit.quantity ?? item.quantity,
|
quantity: edit.quantity ?? item.quantity,
|
||||||
unit: edit.unit ?? item.unit,
|
unit: edit.unit ?? item.unit,
|
||||||
@@ -1149,7 +376,7 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
|||||||
final existing = _inventoryByProduct[pid];
|
final existing = _inventoryByProduct[pid];
|
||||||
final qtyInExistingUnit = existing == null
|
final qtyInExistingUnit = existing == null
|
||||||
? null
|
? null
|
||||||
: _convertQuantity(qty, unit, existing.unit);
|
: convertQuantity(qty, unit, existing.unit);
|
||||||
if (existing != null && qtyInExistingUnit != null) {
|
if (existing != null && qtyInExistingUnit != null) {
|
||||||
await invRepo.updateInventoryItem(
|
await invRepo.updateInventoryItem(
|
||||||
existing.id,
|
existing.id,
|
||||||
@@ -1326,7 +553,7 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
|||||||
final existingInv = edit?.productId != null && edit?.destination != _Destination.pantry
|
final existingInv = edit?.productId != null && edit?.destination != _Destination.pantry
|
||||||
? _inventoryByProduct[edit!.productId]
|
? _inventoryByProduct[edit!.productId]
|
||||||
: null;
|
: null;
|
||||||
final inferredForPreview = _inferPackageFields(
|
final inferredForPreview = inferPackageFields(
|
||||||
rawName: item.rawName,
|
rawName: item.rawName,
|
||||||
quantity: edit?.quantity ?? item.quantity,
|
quantity: edit?.quantity ?? item.quantity,
|
||||||
unit: edit?.unit ?? item.unit,
|
unit: edit?.unit ?? item.unit,
|
||||||
@@ -1343,7 +570,7 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
|||||||
'st';
|
'st';
|
||||||
final convertedPreviewQty = existingInv == null
|
final convertedPreviewQty = existingInv == null
|
||||||
? null
|
? null
|
||||||
: _convertQuantity(
|
: convertQuantity(
|
||||||
previewIncomingQty,
|
previewIncomingQty,
|
||||||
previewIncomingUnit,
|
previewIncomingUnit,
|
||||||
existingInv.unit,
|
existingInv.unit,
|
||||||
@@ -1364,7 +591,7 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
title: Text(
|
title: Text(
|
||||||
_normalizeProductName(item.rawName),
|
normalizeProductName(item.rawName),
|
||||||
style: theme.textTheme.bodyMedium,
|
style: theme.textTheme.bodyMedium,
|
||||||
),
|
),
|
||||||
subtitle: Column(
|
subtitle: Column(
|
||||||
@@ -1388,7 +615,7 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
|||||||
crossAxisAlignment: WrapCrossAlignment.center,
|
crossAxisAlignment: WrapCrossAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'Produktnamn: ${_normalizeProductName(edit!.productName ?? '')}',
|
'Produktnamn: ${normalizeProductName(edit!.productName ?? '')}',
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
color: isMatched ? Colors.green.shade700 : theme.colorScheme.primary,
|
color: isMatched ? Colors.green.shade700 : theme.colorScheme.primary,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
@@ -1420,7 +647,7 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
|||||||
],
|
],
|
||||||
)
|
)
|
||||||
else if (isSuggested)
|
else if (isSuggested)
|
||||||
Text('Namnförslag: ${_normalizeProductName(item.suggestedProductName ?? '')}',
|
Text('Namnförslag: ${normalizeProductName(item.suggestedProductName ?? '')}',
|
||||||
style: theme.textTheme.bodySmall?.copyWith(color: Colors.orange.shade700))
|
style: theme.textTheme.bodySmall?.copyWith(color: Colors.orange.shade700))
|
||||||
else
|
else
|
||||||
Text('Ingen matchning ännu — tryck för att välja eller skapa produkt',
|
Text('Ingen matchning ännu — tryck för att välja eller skapa produkt',
|
||||||
@@ -1443,7 +670,7 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
|||||||
OutlinedButton.icon(
|
OutlinedButton.icon(
|
||||||
onPressed: () => _openEditDialog(
|
onPressed: () => _openEditDialog(
|
||||||
i,
|
i,
|
||||||
initialEntryMode: _ProductEntryMode.existing,
|
initialEntryMode: ImportProductEntryMode.existing,
|
||||||
),
|
),
|
||||||
icon: const Icon(Icons.search, size: 16),
|
icon: const Icon(Icons.search, size: 16),
|
||||||
label: const Text('Välj befintlig'),
|
label: const Text('Välj befintlig'),
|
||||||
@@ -1455,7 +682,7 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
|||||||
OutlinedButton.icon(
|
OutlinedButton.icon(
|
||||||
onPressed: () => _openEditDialog(
|
onPressed: () => _openEditDialog(
|
||||||
i,
|
i,
|
||||||
initialEntryMode: _ProductEntryMode.create,
|
initialEntryMode: ImportProductEntryMode.create,
|
||||||
),
|
),
|
||||||
icon: const Icon(Icons.add_box_outlined, size: 16),
|
icon: const Icon(Icons.add_box_outlined, size: 16),
|
||||||
label: const Text('Ny produkt'),
|
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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user