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 '../../../core/api/api_client.dart';
|
||||
import '../../../core/api/api_paths.dart';
|
||||
@@ -20,237 +21,197 @@ class AdminRepository {
|
||||
|
||||
AdminRepository(this._apiClient, this._ref);
|
||||
|
||||
// ── Interna helpers ────────────────────────────────────────────────────────
|
||||
|
||||
Future<String?> _token() => _ref.read(authStateProvider.future);
|
||||
|
||||
Future<List<UserAdmin>> listUsers() async {
|
||||
/// GET-anrop som returnerar en typad lista med [fromJson].
|
||||
Future<List<T>> _getList<T>(
|
||||
String path,
|
||||
T Function(Map<String, dynamic>) fromJson, {
|
||||
bool requiresAuth = true,
|
||||
}) async {
|
||||
final token = requiresAuth ? await _token() : null;
|
||||
final data = await guardedApiCall(
|
||||
_ref,
|
||||
() => _apiClient.getJson(path, token: token),
|
||||
);
|
||||
return _parseList(data, fromJson);
|
||||
}
|
||||
|
||||
/// POST-anrop som returnerar ett typad objekt med [fromJson].
|
||||
Future<T> _post<T>(
|
||||
String path, {
|
||||
required Map<String, dynamic>? body,
|
||||
required T Function(dynamic) parse,
|
||||
bool requiresAuth = true,
|
||||
}) async {
|
||||
final token = requiresAuth ? await _token() : null;
|
||||
final data = await guardedApiCall(
|
||||
_ref,
|
||||
() => _apiClient.postJson(path, body: body, token: token),
|
||||
);
|
||||
return parse(data);
|
||||
}
|
||||
|
||||
/// PATCH-anrop som returnerar ett typad objekt med [fromJson].
|
||||
Future<T> _patch<T>(
|
||||
String path, {
|
||||
required Map<String, dynamic> body,
|
||||
required T Function(dynamic) parse,
|
||||
}) async {
|
||||
final token = await _token();
|
||||
final data = await guardedApiCall(
|
||||
_ref,
|
||||
() => _apiClient.getJson(UserApiPaths.list, token: token),
|
||||
() => _apiClient.patchJson(path, body: body, token: token),
|
||||
);
|
||||
return (data as List<dynamic>).map((e) => UserAdmin.fromJson(e as Map<String, dynamic>)).toList();
|
||||
return parse(data);
|
||||
}
|
||||
|
||||
Future<UserAdmin> setRole(int userId, String newRole) async {
|
||||
final token = await _token();
|
||||
final data = await guardedApiCall(
|
||||
_ref,
|
||||
() => _apiClient.patchJson(UserApiPaths.setRole(userId), body: {'role': newRole}, token: token),
|
||||
);
|
||||
return UserAdmin.fromJson(data);
|
||||
}
|
||||
|
||||
Future<UserAdmin> setPremium(int userId, {required bool isPremium}) async {
|
||||
final token = await _token();
|
||||
final data = await guardedApiCall(
|
||||
_ref,
|
||||
() => _apiClient.patchJson(UserApiPaths.setPremium(userId), body: {'isPremium': isPremium}, token: token),
|
||||
);
|
||||
return UserAdmin.fromJson(data);
|
||||
}
|
||||
|
||||
Future<UserAdmin> setRecipeSharing(int userId, {required bool canShareRecipes}) async {
|
||||
final token = await _token();
|
||||
final data = await guardedApiCall(
|
||||
_ref,
|
||||
() => _apiClient.patchJson(
|
||||
UserApiPaths.setRecipeSharing(userId),
|
||||
body: {'canShareRecipes': canShareRecipes},
|
||||
token: token,
|
||||
),
|
||||
);
|
||||
return UserAdmin.fromJson(data);
|
||||
}
|
||||
|
||||
Future<void> updateEmail(int userId, String email) async {
|
||||
/// Fire-and-forget PATCH.
|
||||
Future<void> _patchVoid(String path, Map<String, dynamic> body) async {
|
||||
final token = await _token();
|
||||
await guardedApiCall(
|
||||
_ref,
|
||||
() => _apiClient.patchJson(
|
||||
UserApiPaths.updateEmail(userId),
|
||||
body: {'email': email},
|
||||
token: token,
|
||||
),
|
||||
() => _apiClient.patchJson(path, body: body, token: token),
|
||||
);
|
||||
}
|
||||
|
||||
/// Fire-and-forget POST.
|
||||
Future<void> _postVoid(String path, [Map<String, dynamic>? body]) async {
|
||||
final token = await _token();
|
||||
await guardedApiCall(
|
||||
_ref,
|
||||
() => _apiClient.postJson(path, body: body, token: token),
|
||||
);
|
||||
}
|
||||
|
||||
/// Fire-and-forget DELETE.
|
||||
Future<void> _deleteVoid(String path) async {
|
||||
final token = await _token();
|
||||
await guardedApiCall(
|
||||
_ref,
|
||||
() => _apiClient.deleteJson(path, token: token),
|
||||
);
|
||||
}
|
||||
|
||||
/// Tolerant listparsning — accepterar ren lista eller wrapper ({items, data}).
|
||||
static List<T> _parseList<T>(
|
||||
dynamic data,
|
||||
T Function(Map<String, dynamic>) fromJson,
|
||||
) {
|
||||
final List<dynamic> raw;
|
||||
if (data is List<dynamic>) {
|
||||
raw = data;
|
||||
} else if (data is Map<String, dynamic>) {
|
||||
raw = (data['items'] as List<dynamic>?) ??
|
||||
(data['data'] as List<dynamic>?) ??
|
||||
const [];
|
||||
if (raw.isEmpty && data.isNotEmpty) {
|
||||
debugPrint('[AdminRepository] Unexpected API wrapper shape: ${data.keys}');
|
||||
}
|
||||
} else {
|
||||
raw = const [];
|
||||
}
|
||||
return raw
|
||||
.whereType<Map>()
|
||||
.map((e) => fromJson(Map<String, dynamic>.from(e)))
|
||||
.toList();
|
||||
}
|
||||
|
||||
// ── Användare ──────────────────────────────────────────────────────────────
|
||||
|
||||
Future<List<UserAdmin>> listUsers() =>
|
||||
_getList(UserApiPaths.list, UserAdmin.fromJson);
|
||||
|
||||
Future<UserAdmin> setRole(int userId, String newRole) =>
|
||||
_patch(UserApiPaths.setRole(userId),
|
||||
body: {'role': newRole}, parse: UserAdmin.fromJson);
|
||||
|
||||
Future<UserAdmin> setPremium(int userId, {required bool isPremium}) =>
|
||||
_patch(UserApiPaths.setPremium(userId),
|
||||
body: {'isPremium': isPremium}, parse: UserAdmin.fromJson);
|
||||
|
||||
Future<UserAdmin> setRecipeSharing(int userId,
|
||||
{required bool canShareRecipes}) =>
|
||||
_patch(UserApiPaths.setRecipeSharing(userId),
|
||||
body: {'canShareRecipes': canShareRecipes}, parse: UserAdmin.fromJson);
|
||||
|
||||
Future<void> updateEmail(int userId, String email) =>
|
||||
_patchVoid(UserApiPaths.updateEmail(userId), {'email': email});
|
||||
|
||||
Future<UserAdmin> createUser({
|
||||
required String username,
|
||||
required String email,
|
||||
required String password,
|
||||
String role = 'user',
|
||||
}) async {
|
||||
final token = await _token();
|
||||
final data = await guardedApiCall(
|
||||
_ref,
|
||||
() => _apiClient.postJson(UserApiPaths.list, body: {
|
||||
'username': username,
|
||||
'email': email,
|
||||
'password': password,
|
||||
'role': role,
|
||||
}, token: token),
|
||||
);
|
||||
return UserAdmin.fromJson(data as Map<String, dynamic>);
|
||||
}
|
||||
}) =>
|
||||
_post(
|
||||
UserApiPaths.list,
|
||||
body: {
|
||||
'username': username,
|
||||
'email': email,
|
||||
'password': password,
|
||||
'role': role,
|
||||
},
|
||||
parse: (d) => UserAdmin.fromJson(d as Map<String, dynamic>),
|
||||
);
|
||||
|
||||
Future<void> deleteUser(int userId) async {
|
||||
final token = await _token();
|
||||
return guardedApiCall(
|
||||
_ref,
|
||||
() => _apiClient.deleteJson(UserApiPaths.delete(userId), token: token),
|
||||
);
|
||||
}
|
||||
Future<void> deleteUser(int userId) => _deleteVoid(UserApiPaths.delete(userId));
|
||||
|
||||
/// Returns `{ temporaryPassword, to, subject, body }`.
|
||||
Future<Map<String, dynamic>> resetPassword(int userId) async {
|
||||
final token = await _token();
|
||||
final result = await guardedApiCall<dynamic>(
|
||||
_ref,
|
||||
() => _apiClient.postJson(UserApiPaths.resetPassword(userId), token: token),
|
||||
);
|
||||
return (result as Map<String, dynamic>);
|
||||
}
|
||||
Future<Map<String, dynamic>> resetPassword(int userId) =>
|
||||
_post<Map<String, dynamic>>(
|
||||
UserApiPaths.resetPassword(userId),
|
||||
body: null,
|
||||
parse: (d) => d as Map<String, dynamic>,
|
||||
);
|
||||
|
||||
Future<List<PendingProduct>> listPendingProducts() async {
|
||||
final token = await _token();
|
||||
final data = await guardedApiCall(
|
||||
_ref,
|
||||
() => _apiClient.getJson(ProductApiPaths.pending, token: token),
|
||||
);
|
||||
return (data as List<dynamic>)
|
||||
.map((e) => PendingProduct.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
// ── Produkter ──────────────────────────────────────────────────────────────
|
||||
|
||||
Future<void> setProductStatus(int productId, String status) async {
|
||||
final token = await _token();
|
||||
await guardedApiCall(
|
||||
_ref,
|
||||
() => _apiClient.patchJson(
|
||||
ProductApiPaths.setStatus(productId),
|
||||
body: {'status': status},
|
||||
token: token,
|
||||
),
|
||||
);
|
||||
}
|
||||
Future<List<AdminProduct>> listProducts() =>
|
||||
_getList(ProductApiPaths.list, AdminProduct.fromJson);
|
||||
|
||||
Future<List<AiModelInfo>> listAiModels() async {
|
||||
final data = await guardedApiCall(
|
||||
_ref,
|
||||
() => _apiClient.getJson(AiApiPaths.models),
|
||||
);
|
||||
return (data as List<dynamic>)
|
||||
.map((e) => AiModelInfo.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
Future<List<AdminProduct>> listDeletedProducts() =>
|
||||
_getList(ProductApiPaths.deleted, AdminProduct.fromJson);
|
||||
|
||||
Future<List<AdminProduct>> listProducts() async {
|
||||
final token = await _token();
|
||||
final data = await guardedApiCall(
|
||||
_ref,
|
||||
() => _apiClient.getJson(ProductApiPaths.list, token: token),
|
||||
);
|
||||
return (data as List<dynamic>)
|
||||
.map((e) => AdminProduct.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
Future<List<PendingProduct>> listPendingProducts() =>
|
||||
_getList(ProductApiPaths.pending, PendingProduct.fromJson);
|
||||
|
||||
Future<void> setProductStatus(int productId, String status) =>
|
||||
_patchVoid(ProductApiPaths.setStatus(productId), {'status': status});
|
||||
|
||||
Future<void> setProductCategory(int productId, {required int? categoryId}) =>
|
||||
_patchVoid(ProductApiPaths.update(productId), {'categoryId': categoryId});
|
||||
|
||||
Future<void> removeProduct(int productId) =>
|
||||
_deleteVoid(ProductApiPaths.remove(productId));
|
||||
|
||||
Future<void> restoreProduct(int productId) =>
|
||||
_postVoid(ProductApiPaths.restore(productId));
|
||||
|
||||
/// Skapar en ny aktiv produkt (kräver admin). Returnerar `{id, name, categoryId?}`.
|
||||
Future<Map<String, dynamic>> createProduct(String name, {int? categoryId}) async {
|
||||
final token = await _token();
|
||||
final data = await guardedApiCall(
|
||||
_ref,
|
||||
() => _apiClient.postJson(
|
||||
Future<Map<String, dynamic>> createProduct(String name, {int? categoryId}) =>
|
||||
_post<Map<String, dynamic>>(
|
||||
ProductApiPaths.list,
|
||||
body: {
|
||||
'name': name.trim(),
|
||||
if (categoryId != null) 'categoryId': categoryId,
|
||||
},
|
||||
token: token,
|
||||
),
|
||||
);
|
||||
return data as Map<String, dynamic>;
|
||||
}
|
||||
parse: (d) => d as Map<String, dynamic>,
|
||||
);
|
||||
|
||||
Future<List<AdminProduct>> listDeletedProducts() async {
|
||||
final token = await _token();
|
||||
final data = await guardedApiCall(
|
||||
_ref,
|
||||
() => _apiClient.getJson(ProductApiPaths.deleted, token: token),
|
||||
);
|
||||
return (data as List<dynamic>)
|
||||
.map((e) => AdminProduct.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
|
||||
Future<List<AdminCategoryNode>> listCategoryTree() async {
|
||||
final token = await _token();
|
||||
final data = await guardedApiCall(
|
||||
_ref,
|
||||
() => _apiClient.getJson(CategoryApiPaths.tree, token: token),
|
||||
);
|
||||
return (data as List<dynamic>)
|
||||
.map((e) => AdminCategoryNode.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
|
||||
Future<void> bulkSetCategory(List<int> ids, {required int? categoryId}) async {
|
||||
final token = await _token();
|
||||
await guardedApiCall(
|
||||
_ref,
|
||||
() => _apiClient.postJson(
|
||||
ProductApiPaths.bulkUpdate,
|
||||
body: {'ids': ids, 'categoryId': categoryId},
|
||||
token: token,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> setProductCategory(int productId, {required int? categoryId}) async {
|
||||
final token = await _token();
|
||||
await guardedApiCall(
|
||||
_ref,
|
||||
() => _apiClient.patchJson(
|
||||
ProductApiPaths.update(productId),
|
||||
body: {'categoryId': categoryId},
|
||||
token: token,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> removeProduct(int productId) async {
|
||||
final token = await _token();
|
||||
await guardedApiCall(
|
||||
_ref,
|
||||
() => _apiClient.deleteJson(ProductApiPaths.remove(productId), token: token),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> restoreProduct(int productId) async {
|
||||
final token = await _token();
|
||||
await guardedApiCall(
|
||||
_ref,
|
||||
() => _apiClient.postJson(ProductApiPaths.restore(productId), token: token),
|
||||
);
|
||||
}
|
||||
Future<void> bulkSetCategory(List<int> ids, {required int? categoryId}) =>
|
||||
_postVoid(ProductApiPaths.bulkUpdate, {'ids': ids, 'categoryId': categoryId});
|
||||
|
||||
Future<void> mergeProducts({
|
||||
required int sourceProductId,
|
||||
required int targetProductId,
|
||||
}) async {
|
||||
final token = await _token();
|
||||
await guardedApiCall(
|
||||
_ref,
|
||||
() => _apiClient.postJson(
|
||||
ProductApiPaths.merge,
|
||||
body: {
|
||||
'sourceProductId': sourceProductId,
|
||||
'targetProductId': targetProductId,
|
||||
},
|
||||
token: token,
|
||||
),
|
||||
);
|
||||
}
|
||||
}) =>
|
||||
_postVoid(ProductApiPaths.merge, {
|
||||
'sourceProductId': sourceProductId,
|
||||
'targetProductId': targetProductId,
|
||||
});
|
||||
|
||||
Future<List<AdminAiCategorizeResult>> aiCategorizeBulk({
|
||||
List<int>? productIds,
|
||||
@@ -260,15 +221,31 @@ class AdminRepository {
|
||||
_ref,
|
||||
() => _apiClient.postJson(
|
||||
ProductApiPaths.aiCategorizeBulk,
|
||||
body: productIds == null || productIds.isEmpty
|
||||
body: (productIds == null || productIds.isEmpty)
|
||||
? null
|
||||
: {'productIds': productIds},
|
||||
token: token,
|
||||
),
|
||||
);
|
||||
return (data as List<dynamic>)
|
||||
.map((e) => AdminAiCategorizeResult.fromJson(e as Map<String, dynamic>))
|
||||
return _parseList(data, AdminAiCategorizeResult.fromJson)
|
||||
.where((e) => e.productId > 0 && e.categoryId > 0)
|
||||
.toList();
|
||||
}
|
||||
|
||||
// ── Kategorier ─────────────────────────────────────────────────────────────
|
||||
|
||||
Future<List<AdminCategoryNode>> listCategoryTree() async {
|
||||
final token = await _token();
|
||||
final data = await guardedApiCall(
|
||||
_ref,
|
||||
() => _apiClient.getJson(CategoryApiPaths.tree, token: token),
|
||||
);
|
||||
return _parseList(data, AdminCategoryNode.fromJson);
|
||||
}
|
||||
|
||||
// ── AI-modeller ────────────────────────────────────────────────────────────
|
||||
|
||||
/// OBS: endpointen /ai/models kräver autentisering.
|
||||
Future<List<AiModelInfo>> listAiModels() =>
|
||||
_getList(AiApiPaths.models, AiModelInfo.fromJson);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user