feat(ai): add AI trace tracking and admin panel
- Add AiTrace model to Prisma schema with relations to User - Implement AiTraceService with CRUD operations for AI traces - Add new admin panel for AI traces with filtering and detail views - Integrate trace persistence in receipt import flow - Add API endpoints for listing and retrieving AI traces - Update Flutter admin UI with new AI tab and navigation - Add new domain models for AI traces and details - Add migration for AiTrace table creation BREAKING CHANGE: None
This commit is contained in:
@@ -2,7 +2,7 @@ class AuthApiPaths {
|
||||
static const login = '/auth/login';
|
||||
}
|
||||
|
||||
class ProductApiPaths {
|
||||
class ProductApiPaths {
|
||||
static const list = '/products';
|
||||
static const mine = '/products/mine';
|
||||
static const createPrivate = '/products/private';
|
||||
@@ -13,14 +13,15 @@ class ProductApiPaths {
|
||||
static const deleted = '/products/deleted';
|
||||
static const merge = '/products/merge';
|
||||
static const mergePrivate = '/products/private/merge';
|
||||
static String updateMineCategory(int id) => '/products/mine/$id/category';
|
||||
static const backfillMineCategories = '/products/mine/backfill-categories';
|
||||
static String updateMineCategory(int id) => '/products/mine/$id/category';
|
||||
static const backfillMineCategories = '/products/mine/backfill-categories';
|
||||
static String mergePreview(int sourceProductId, int targetProductId) =>
|
||||
'/products/merge-preview?sourceProductId=$sourceProductId&targetProductId=$targetProductId';
|
||||
static String setStatus(int id) => '/products/$id/status';
|
||||
static String update(int id) => '/products/$id';
|
||||
static String canonicalName(int id) => '/products/$id/canonical-name';
|
||||
static String canonicalNamePrivate(int id) => '/products/private/$id/canonical-name';
|
||||
static String canonicalNamePrivate(int id) =>
|
||||
'/products/private/$id/canonical-name';
|
||||
static String remove(int id) => '/products/$id';
|
||||
static String restore(int id) => '/products/$id/restore';
|
||||
static const bulkUpdate = '/products/bulk-update';
|
||||
@@ -28,37 +29,63 @@ class ProductApiPaths {
|
||||
|
||||
class AiApiPaths {
|
||||
static const models = '/ai/models';
|
||||
static String traces({
|
||||
required String source,
|
||||
int? limit,
|
||||
String? cursor,
|
||||
String? period,
|
||||
bool onlyErrors = false,
|
||||
}) {
|
||||
final params = <String, String>{'source': source};
|
||||
if (limit != null) params['limit'] = '$limit';
|
||||
if (cursor != null && cursor.isNotEmpty) params['cursor'] = cursor;
|
||||
if (period != null && period.isNotEmpty) params['period'] = period;
|
||||
if (onlyErrors) params['onlyErrors'] = 'true';
|
||||
final query = params.entries
|
||||
.map((e) =>
|
||||
'${Uri.encodeQueryComponent(e.key)}=${Uri.encodeQueryComponent(e.value)}')
|
||||
.join('&');
|
||||
return '/ai/traces?$query';
|
||||
}
|
||||
|
||||
static String traceById(String traceId) =>
|
||||
'/ai/traces/${Uri.encodeComponent(traceId)}';
|
||||
}
|
||||
|
||||
class CategoryApiPaths {
|
||||
static const tree = '/categories/tree';
|
||||
}
|
||||
|
||||
class ReceiptImportApiPaths {
|
||||
static const refreshCategories = '/receipt-import/refresh-categories';
|
||||
static const unitMappings = '/receipt-import/unit-mappings';
|
||||
}
|
||||
|
||||
class FlyerImportApiPaths {
|
||||
static const parse = '/flyer-import/parse';
|
||||
static const latestSession = '/flyer-import/sessions/latest';
|
||||
static String bySession(int sessionId) => '/flyer-import/sessions/$sessionId';
|
||||
static String sourceBySession(int sessionId) => '/flyer-import/sessions/$sessionId/source';
|
||||
static String patchItem(int sessionId, int itemId) => '/flyer-import/sessions/$sessionId/items/$itemId';
|
||||
}
|
||||
|
||||
class FlyerSelectionApiPaths {
|
||||
static String bySession(int sessionId) => '/flyer-sessions/$sessionId/selections';
|
||||
static String bulkBySession(int sessionId) => '/flyer-sessions/$sessionId/selections/bulk';
|
||||
static String planToShoppingListBySession(int sessionId) =>
|
||||
'/flyer-sessions/$sessionId/selections/plan-to-shopping-list';
|
||||
static const open = '/flyer-selections/open';
|
||||
}
|
||||
|
||||
class ShoppingListApiPaths {
|
||||
static const items = '/shopping-list/items';
|
||||
static String updateStatus(int itemId) => '/shopping-list/items/$itemId/status';
|
||||
}
|
||||
class ReceiptImportApiPaths {
|
||||
static const refreshCategories = '/receipt-import/refresh-categories';
|
||||
static const unitMappings = '/receipt-import/unit-mappings';
|
||||
}
|
||||
|
||||
class FlyerImportApiPaths {
|
||||
static const parse = '/flyer-import/parse';
|
||||
static const latestSession = '/flyer-import/sessions/latest';
|
||||
static String bySession(int sessionId) => '/flyer-import/sessions/$sessionId';
|
||||
static String sourceBySession(int sessionId) =>
|
||||
'/flyer-import/sessions/$sessionId/source';
|
||||
static String patchItem(int sessionId, int itemId) =>
|
||||
'/flyer-import/sessions/$sessionId/items/$itemId';
|
||||
}
|
||||
|
||||
class FlyerSelectionApiPaths {
|
||||
static String bySession(int sessionId) =>
|
||||
'/flyer-sessions/$sessionId/selections';
|
||||
static String bulkBySession(int sessionId) =>
|
||||
'/flyer-sessions/$sessionId/selections/bulk';
|
||||
static String planToShoppingListBySession(int sessionId) =>
|
||||
'/flyer-sessions/$sessionId/selections/plan-to-shopping-list';
|
||||
static const open = '/flyer-selections/open';
|
||||
}
|
||||
|
||||
class ShoppingListApiPaths {
|
||||
static const items = '/shopping-list/items';
|
||||
static String updateStatus(int itemId) =>
|
||||
'/shopping-list/items/$itemId/status';
|
||||
}
|
||||
|
||||
class HelpTextApiPaths {
|
||||
static String byKey(String key) => '/help-texts/${Uri.encodeComponent(key)}';
|
||||
@@ -77,7 +104,8 @@ class RecipeApiPaths {
|
||||
static String remove(int id) => '/recipes/$id';
|
||||
static String setVisibility(int id) => '/recipes/$id/visibility';
|
||||
static String share(int id) => '/recipes/$id/share';
|
||||
static String unshare(int id, String username) => '/recipes/$id/share/${Uri.encodeComponent(username)}';
|
||||
static String unshare(int id, String username) =>
|
||||
'/recipes/$id/share/${Uri.encodeComponent(username)}';
|
||||
static String inventoryPreview(int id) => '/recipes/$id/inventory-preview';
|
||||
static String analysis(int id) => '/recipes/$id/analysis';
|
||||
static String rematch(int id) => '/recipes/$id/rematch';
|
||||
@@ -92,9 +120,11 @@ class InventoryApiPaths {
|
||||
static String update(int id) => '/inventory/$id';
|
||||
static String remove(int id) => '/inventory/$id';
|
||||
static String moveToPantry(int id) => '/inventory/$id/move-to-pantry';
|
||||
static String moveToPantryAdmin(int id) => '/inventory/admin/$id/move-to-pantry';
|
||||
static String moveToPantryAdmin(int id) =>
|
||||
'/inventory/admin/$id/move-to-pantry';
|
||||
static String consume(int id) => '/inventory/$id/consume';
|
||||
static String consumptionHistory(int id) => '/inventory/$id/consumption-history';
|
||||
static String consumptionHistory(int id) =>
|
||||
'/inventory/$id/consumption-history';
|
||||
}
|
||||
|
||||
class AdminInventoryApiPaths {
|
||||
@@ -105,10 +135,12 @@ class AdminInventoryApiPaths {
|
||||
if (sort != null && sort.isNotEmpty) params['sort'] = sort;
|
||||
if (params.isEmpty) return list;
|
||||
final query = params.entries
|
||||
.map((e) => '${Uri.encodeQueryComponent(e.key)}=${Uri.encodeQueryComponent(e.value)}')
|
||||
.map((e) =>
|
||||
'${Uri.encodeQueryComponent(e.key)}=${Uri.encodeQueryComponent(e.value)}')
|
||||
.join('&');
|
||||
return '$list?$query';
|
||||
}
|
||||
|
||||
static String update(int id) => '/inventory/admin/$id';
|
||||
static String remove(int id) => '/inventory/admin/$id';
|
||||
static String moveToPantry(int id) => '/inventory/admin/$id/move-to-pantry';
|
||||
@@ -121,7 +153,8 @@ class PantryApiPaths {
|
||||
static const list = '/pantry';
|
||||
static String remove(int id) => '/pantry/$id';
|
||||
static String moveToInventory(int id) => '/pantry/$id/move-to-inventory';
|
||||
static String moveToInventoryAdmin(int id) => '/pantry/admin/$id/move-to-inventory';
|
||||
static String moveToInventoryAdmin(int id) =>
|
||||
'/pantry/admin/$id/move-to-inventory';
|
||||
static const adminList = '/pantry/admin';
|
||||
static const adminCreate = '/pantry/admin';
|
||||
static String adminUpdate(int id) => '/pantry/admin/$id';
|
||||
@@ -143,14 +176,14 @@ class MealPlanApiPaths {
|
||||
static const list = '/meal-plan';
|
||||
|
||||
static String listByRange(String from, String to) =>
|
||||
'$list?from=${Uri.encodeQueryComponent(from)}&to=${Uri.encodeQueryComponent(to)}';
|
||||
'$list?from=${Uri.encodeQueryComponent(from)}&to=${Uri.encodeQueryComponent(to)}';
|
||||
|
||||
static String shoppingList(String from, String to) =>
|
||||
'$list/shopping-list?from=${Uri.encodeQueryComponent(from)}&to=${Uri.encodeQueryComponent(to)}';
|
||||
'$list/shopping-list?from=${Uri.encodeQueryComponent(from)}&to=${Uri.encodeQueryComponent(to)}';
|
||||
|
||||
static String inventoryCompare(String from, String to) =>
|
||||
'$list/inventory-compare?from=${Uri.encodeQueryComponent(from)}&to=${Uri.encodeQueryComponent(to)}';
|
||||
'$list/inventory-compare?from=${Uri.encodeQueryComponent(from)}&to=${Uri.encodeQueryComponent(to)}';
|
||||
|
||||
static String removeByDate(String date) =>
|
||||
'$list/${Uri.encodeComponent(date)}';
|
||||
}
|
||||
'$list/${Uri.encodeComponent(date)}';
|
||||
}
|
||||
|
||||
@@ -49,19 +49,19 @@ class AppShell extends ConsumerWidget {
|
||||
icon: Icons.storefront_outlined,
|
||||
label: 'Baslager',
|
||||
),
|
||||
_AppDestination(
|
||||
path: '/import',
|
||||
title: 'Importera',
|
||||
icon: Icons.upload_file_outlined,
|
||||
label: 'Importera',
|
||||
),
|
||||
_AppDestination(
|
||||
path: '/inkopslista',
|
||||
title: 'Inköpslista',
|
||||
icon: Icons.shopping_cart_outlined,
|
||||
label: 'Inköpslista',
|
||||
),
|
||||
];
|
||||
_AppDestination(
|
||||
path: '/import',
|
||||
title: 'Importera',
|
||||
icon: Icons.upload_file_outlined,
|
||||
label: 'Importera',
|
||||
),
|
||||
_AppDestination(
|
||||
path: '/inkopslista',
|
||||
title: 'Inköpslista',
|
||||
icon: Icons.shopping_cart_outlined,
|
||||
label: 'Inköpslista',
|
||||
),
|
||||
];
|
||||
|
||||
List<_AppDestination> _destinations() => _baseDestinations;
|
||||
|
||||
@@ -101,8 +101,8 @@ class AppShell extends ConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
final isRecipesRoute = location.startsWith('/recipes') &&
|
||||
!location.startsWith('/recipes/');
|
||||
final isRecipesRoute =
|
||||
location.startsWith('/recipes') && !location.startsWith('/recipes/');
|
||||
final isImportRoute = location == '/import';
|
||||
final isAdminRoute = location.startsWith('/admin');
|
||||
final adminTab = AdminViewTabX.fromQuery(
|
||||
@@ -133,6 +133,12 @@ class AppShell extends ConsumerWidget {
|
||||
selected: adminTab == AdminViewTab.database,
|
||||
onSelected: (_) => navigateToAdminTab(AdminViewTab.database),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
ChoiceChip(
|
||||
label: const Text('AI'),
|
||||
selected: adminTab == AdminViewTab.ai,
|
||||
onSelected: (_) => navigateToAdminTab(AdminViewTab.ai),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -140,7 +146,8 @@ class AppShell extends ConsumerWidget {
|
||||
|
||||
Widget shell = Scaffold(
|
||||
appBar: AppBar(
|
||||
title: isAdminRoute ? buildAdminTitle() : Text(selectedDestination.title),
|
||||
title:
|
||||
isAdminRoute ? buildAdminTitle() : Text(selectedDestination.title),
|
||||
bottom: isImportRoute
|
||||
? const TabBar(
|
||||
tabs: [
|
||||
@@ -148,17 +155,17 @@ class AppShell extends ConsumerWidget {
|
||||
icon: Icon(Icons.restaurant_menu_outlined),
|
||||
text: 'Recept',
|
||||
),
|
||||
Tab(
|
||||
icon: Icon(Icons.receipt_long_outlined),
|
||||
text: 'Kvitto',
|
||||
),
|
||||
Tab(
|
||||
icon: Icon(Icons.local_offer_outlined),
|
||||
text: 'Flyer',
|
||||
),
|
||||
],
|
||||
)
|
||||
: null,
|
||||
Tab(
|
||||
icon: Icon(Icons.receipt_long_outlined),
|
||||
text: 'Kvitto',
|
||||
),
|
||||
Tab(
|
||||
icon: Icon(Icons.local_offer_outlined),
|
||||
text: 'Flyer',
|
||||
),
|
||||
],
|
||||
)
|
||||
: null,
|
||||
actions: [
|
||||
if (isRecipesRoute)
|
||||
Consumer(
|
||||
@@ -184,8 +191,9 @@ class AppShell extends ConsumerWidget {
|
||||
PopupMenuButton<int>(
|
||||
icon: const Icon(Icons.grid_view),
|
||||
tooltip: 'Välj antal kolumner',
|
||||
onSelected: (columns) =>
|
||||
ref.read(recipesViewProvider.notifier).setColumns(columns),
|
||||
onSelected: (columns) => ref
|
||||
.read(recipesViewProvider.notifier)
|
||||
.setColumns(columns),
|
||||
itemBuilder: (context) => const [
|
||||
PopupMenuItem(value: 2, child: Text('2 kolumner')),
|
||||
PopupMenuItem(value: 4, child: Text('4 kolumner')),
|
||||
@@ -288,9 +296,9 @@ class AppShell extends ConsumerWidget {
|
||||
),
|
||||
);
|
||||
|
||||
if (isImportRoute) {
|
||||
shell = DefaultTabController(length: 3, child: shell);
|
||||
}
|
||||
if (isImportRoute) {
|
||||
shell = DefaultTabController(length: 3, child: shell);
|
||||
}
|
||||
|
||||
return shell;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ import '../domain/admin_category_node.dart';
|
||||
import '../domain/admin_pantry_item.dart';
|
||||
import '../domain/admin_inventory_item.dart';
|
||||
import '../domain/admin_product.dart';
|
||||
import '../domain/admin_ai_trace.dart';
|
||||
import '../domain/admin_ai_trace_detail.dart';
|
||||
import '../domain/ai_model_info.dart';
|
||||
import '../domain/pending_product.dart';
|
||||
import '../domain/receipt_alias.dart';
|
||||
@@ -145,7 +147,8 @@ class AdminRepository {
|
||||
(data['data'] as List<dynamic>?) ??
|
||||
const [];
|
||||
if (raw.isEmpty && data.isNotEmpty) {
|
||||
debugPrint('[AdminRepository] Unexpected API wrapper shape: ${data.keys}');
|
||||
debugPrint(
|
||||
'[AdminRepository] Unexpected API wrapper shape: ${data.keys}');
|
||||
}
|
||||
} else {
|
||||
raw = const [];
|
||||
@@ -172,7 +175,8 @@ class AdminRepository {
|
||||
Future<UserAdmin> setRecipeSharing(int userId,
|
||||
{required bool canShareRecipes}) =>
|
||||
_patch(UserApiPaths.setRecipeSharing(userId),
|
||||
body: {'canShareRecipes': canShareRecipes}, parse: UserAdmin.fromJson);
|
||||
body: {'canShareRecipes': canShareRecipes},
|
||||
parse: UserAdmin.fromJson);
|
||||
|
||||
Future<void> updateEmail(int userId, String email) =>
|
||||
_patchVoid(UserApiPaths.updateEmail(userId), {'email': email});
|
||||
@@ -194,7 +198,8 @@ class AdminRepository {
|
||||
parse: (d) => UserAdmin.fromJson(d as Map<String, dynamic>),
|
||||
);
|
||||
|
||||
Future<void> deleteUser(int userId) => _deleteVoid(UserApiPaths.delete(userId));
|
||||
Future<void> deleteUser(int userId) =>
|
||||
_deleteVoid(UserApiPaths.delete(userId));
|
||||
|
||||
/// Returns `{ temporaryPassword, to, subject, body }`.
|
||||
Future<Map<String, dynamic>> resetPassword(int userId) =>
|
||||
@@ -203,7 +208,8 @@ class AdminRepository {
|
||||
// ── Produkter ──────────────────────────────────────────────────────────────
|
||||
|
||||
Future<List<AdminProduct>> listProducts() =>
|
||||
_getList(ProductApiPaths.list, AdminProduct.fromJson, requiresAuth: false);
|
||||
_getList(ProductApiPaths.list, AdminProduct.fromJson,
|
||||
requiresAuth: false);
|
||||
|
||||
@Deprecated('Use listProducts(). Kept for temporary compatibility.')
|
||||
Future<List<AdminProduct>> listGlobalProducts() => listProducts();
|
||||
@@ -249,7 +255,8 @@ class AdminRepository {
|
||||
|
||||
final list = merged.values.toList();
|
||||
list.sort(
|
||||
(a, b) => a.displayName.toLowerCase().compareTo(b.displayName.toLowerCase()),
|
||||
(a, b) =>
|
||||
a.displayName.toLowerCase().compareTo(b.displayName.toLowerCase()),
|
||||
);
|
||||
_selectableProductsCache = List<AdminProduct>.from(list);
|
||||
_selectableProductsCacheAt = now;
|
||||
@@ -263,7 +270,8 @@ class AdminRepository {
|
||||
_getList(ProductApiPaths.pending, PendingProduct.fromJson);
|
||||
|
||||
Future<void> setProductStatus(int productId, String status) =>
|
||||
_patchVoid(ProductApiPaths.setStatus(productId), {'status': status}).then((_) {
|
||||
_patchVoid(ProductApiPaths.setStatus(productId), {'status': status})
|
||||
.then((_) {
|
||||
_invalidateSelectableProductsCache();
|
||||
});
|
||||
|
||||
@@ -271,14 +279,16 @@ class AdminRepository {
|
||||
_post<AdminProduct>(
|
||||
ProductApiPaths.promotePrivate(productId),
|
||||
body: null,
|
||||
parse: (d) => AdminProduct.fromJson(Map<String, dynamic>.from(d as Map)),
|
||||
parse: (d) =>
|
||||
AdminProduct.fromJson(Map<String, dynamic>.from(d as Map)),
|
||||
).then((value) {
|
||||
_invalidateSelectableProductsCache();
|
||||
return value;
|
||||
});
|
||||
|
||||
Future<void> setProductCategory(int productId, {required int? categoryId}) =>
|
||||
_patchVoid(ProductApiPaths.update(productId), {'categoryId': categoryId}).then((_) {
|
||||
_patchVoid(ProductApiPaths.update(productId), {'categoryId': categoryId})
|
||||
.then((_) {
|
||||
_invalidateSelectableProductsCache();
|
||||
});
|
||||
|
||||
@@ -301,7 +311,8 @@ class AdminRepository {
|
||||
_invalidateSelectableProductsCache();
|
||||
});
|
||||
|
||||
Future<void> updateCanonicalNamePrivate(int productId, String canonicalName) =>
|
||||
Future<void> updateCanonicalNamePrivate(
|
||||
int productId, String canonicalName) =>
|
||||
_patchVoid(
|
||||
ProductApiPaths.canonicalNamePrivate(productId),
|
||||
{'canonicalName': canonicalName.trim()},
|
||||
@@ -336,7 +347,8 @@ class AdminRepository {
|
||||
|
||||
int _parseUpdatedCount(dynamic data) {
|
||||
if (data is! Map) {
|
||||
debugPrint('[AdminRepository] bulkSetCategory unexpected response type: ${data.runtimeType}');
|
||||
debugPrint(
|
||||
'[AdminRepository] bulkSetCategory unexpected response type: ${data.runtimeType}');
|
||||
return 0;
|
||||
}
|
||||
final map = Map<String, dynamic>.from(data);
|
||||
@@ -391,8 +403,7 @@ class AdminRepository {
|
||||
|
||||
// ── Kategorier ─────────────────────────────────────────────────────────────
|
||||
|
||||
Future<List<AdminCategoryNode>> listCategoryTree() =>
|
||||
_getList(
|
||||
Future<List<AdminCategoryNode>> listCategoryTree() => _getList(
|
||||
CategoryApiPaths.tree,
|
||||
AdminCategoryNode.fromJson,
|
||||
requiresAuth: false,
|
||||
@@ -404,6 +415,26 @@ class AdminRepository {
|
||||
Future<List<AiModelInfo>> listAiModels() =>
|
||||
_getList(AiApiPaths.models, AiModelInfo.fromJson);
|
||||
|
||||
Future<AdminAiTraceListResponse> listAiTraces({
|
||||
required AdminAiTraceSource source,
|
||||
int limit = 25,
|
||||
String? cursor,
|
||||
String? period,
|
||||
bool onlyErrors = false,
|
||||
}) =>
|
||||
_getMap(
|
||||
AiApiPaths.traces(
|
||||
source: source.apiValue,
|
||||
limit: limit,
|
||||
cursor: cursor,
|
||||
period: period,
|
||||
onlyErrors: onlyErrors,
|
||||
),
|
||||
).then(AdminAiTraceListResponse.fromJson);
|
||||
|
||||
Future<AdminAiTraceDetail> getAiTraceById(String traceId) =>
|
||||
_getMap(AiApiPaths.traceById(traceId)).then(AdminAiTraceDetail.fromJson);
|
||||
|
||||
// ── Kvittoalias (admin/global fallback) ───────────────────────────────────
|
||||
|
||||
Future<List<ReceiptAlias>> listReceiptAliases() =>
|
||||
@@ -543,7 +574,8 @@ class AdminRepository {
|
||||
if (location != null && location.trim().isNotEmpty)
|
||||
'location': location.trim(),
|
||||
},
|
||||
parse: (d) => AdminPantryItem.fromJson(Map<String, dynamic>.from(d as Map)),
|
||||
parse: (d) =>
|
||||
AdminPantryItem.fromJson(Map<String, dynamic>.from(d as Map)),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -582,6 +614,7 @@ class AdminRepository {
|
||||
required int targetInventoryId,
|
||||
}) =>
|
||||
_getMap(
|
||||
AdminInventoryApiPaths.mergePreview(sourceInventoryId, targetInventoryId),
|
||||
AdminInventoryApiPaths.mergePreview(
|
||||
sourceInventoryId, targetInventoryId),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
enum AdminAiTraceSource { receipt, flyer }
|
||||
|
||||
enum AdminAiTraceStatus { success, warning, error }
|
||||
|
||||
extension AdminAiTraceSourceX on AdminAiTraceSource {
|
||||
String get apiValue =>
|
||||
this == AdminAiTraceSource.receipt ? 'receipt' : 'flyer';
|
||||
|
||||
String get label => this == AdminAiTraceSource.receipt ? 'Kvitto' : 'Flyer';
|
||||
|
||||
static AdminAiTraceSource fromApi(String? value) {
|
||||
if (value == 'receipt') return AdminAiTraceSource.receipt;
|
||||
return AdminAiTraceSource.flyer;
|
||||
}
|
||||
}
|
||||
|
||||
extension AdminAiTraceStatusX on AdminAiTraceStatus {
|
||||
String get label => switch (this) {
|
||||
AdminAiTraceStatus.success => 'OK',
|
||||
AdminAiTraceStatus.warning => 'Varning',
|
||||
AdminAiTraceStatus.error => 'Fel',
|
||||
};
|
||||
|
||||
static AdminAiTraceStatus fromApi(String? value) {
|
||||
return switch (value) {
|
||||
'error' => AdminAiTraceStatus.error,
|
||||
'warning' => AdminAiTraceStatus.warning,
|
||||
_ => AdminAiTraceStatus.success,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class AdminAiTraceListItem {
|
||||
final String id;
|
||||
final AdminAiTraceSource source;
|
||||
final AdminAiTraceStatus status;
|
||||
final DateTime createdAt;
|
||||
final int userId;
|
||||
final String userLabel;
|
||||
final int? sessionId;
|
||||
final String? fileName;
|
||||
final String? model;
|
||||
final int? durationMs;
|
||||
final int warningsCount;
|
||||
final bool hasPrompt;
|
||||
final bool hasOutput;
|
||||
final String? error;
|
||||
|
||||
const AdminAiTraceListItem({
|
||||
required this.id,
|
||||
required this.source,
|
||||
required this.status,
|
||||
required this.createdAt,
|
||||
required this.userId,
|
||||
required this.userLabel,
|
||||
required this.sessionId,
|
||||
required this.fileName,
|
||||
required this.model,
|
||||
required this.durationMs,
|
||||
required this.warningsCount,
|
||||
required this.hasPrompt,
|
||||
required this.hasOutput,
|
||||
required this.error,
|
||||
});
|
||||
|
||||
factory AdminAiTraceListItem.fromJson(Map<String, dynamic> json) {
|
||||
return AdminAiTraceListItem(
|
||||
id: (json['id'] ?? '').toString(),
|
||||
source: AdminAiTraceSourceX.fromApi(json['source']?.toString()),
|
||||
status: AdminAiTraceStatusX.fromApi(json['status']?.toString()),
|
||||
createdAt: DateTime.tryParse((json['createdAt'] ?? '').toString()) ??
|
||||
DateTime.fromMillisecondsSinceEpoch(0),
|
||||
userId: (json['userId'] as num?)?.toInt() ?? 0,
|
||||
userLabel: (json['userLabel'] ?? '').toString(),
|
||||
sessionId: (json['sessionId'] as num?)?.toInt(),
|
||||
fileName: json['fileName']?.toString(),
|
||||
model: json['model']?.toString(),
|
||||
durationMs: (json['durationMs'] as num?)?.toInt(),
|
||||
warningsCount: (json['warningsCount'] as num?)?.toInt() ?? 0,
|
||||
hasPrompt: json['hasPrompt'] == true,
|
||||
hasOutput: json['hasOutput'] == true,
|
||||
error: json['error']?.toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class AdminAiTraceListResponse {
|
||||
final List<AdminAiTraceListItem> items;
|
||||
final String? nextCursor;
|
||||
|
||||
const AdminAiTraceListResponse({
|
||||
required this.items,
|
||||
required this.nextCursor,
|
||||
});
|
||||
|
||||
factory AdminAiTraceListResponse.fromJson(Map<String, dynamic> json) {
|
||||
final rawItems = (json['items'] as List<dynamic>?) ?? const [];
|
||||
return AdminAiTraceListResponse(
|
||||
items: rawItems
|
||||
.whereType<Map>()
|
||||
.map((entry) =>
|
||||
AdminAiTraceListItem.fromJson(Map<String, dynamic>.from(entry)))
|
||||
.toList(),
|
||||
nextCursor: json['nextCursor']?.toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import 'admin_ai_trace.dart';
|
||||
|
||||
class AdminAiTraceDetail {
|
||||
final String id;
|
||||
final AdminAiTraceSource source;
|
||||
final AdminAiTraceStatus status;
|
||||
final DateTime createdAt;
|
||||
final int userId;
|
||||
final String userLabel;
|
||||
final int? sessionId;
|
||||
final String? fileName;
|
||||
final String? model;
|
||||
final int? durationMs;
|
||||
final int? retryCount;
|
||||
final int? chunkCount;
|
||||
final List<String> warnings;
|
||||
final String? error;
|
||||
final String? prompt;
|
||||
final String? rawOutput;
|
||||
final Map<String, dynamic>? normalizedOutput;
|
||||
final Map<String, dynamic> summary;
|
||||
|
||||
const AdminAiTraceDetail({
|
||||
required this.id,
|
||||
required this.source,
|
||||
required this.status,
|
||||
required this.createdAt,
|
||||
required this.userId,
|
||||
required this.userLabel,
|
||||
required this.sessionId,
|
||||
required this.fileName,
|
||||
required this.model,
|
||||
required this.durationMs,
|
||||
required this.retryCount,
|
||||
required this.chunkCount,
|
||||
required this.warnings,
|
||||
required this.error,
|
||||
required this.prompt,
|
||||
required this.rawOutput,
|
||||
required this.normalizedOutput,
|
||||
required this.summary,
|
||||
});
|
||||
|
||||
factory AdminAiTraceDetail.fromJson(Map<String, dynamic> json) {
|
||||
final warningsRaw = (json['warnings'] as List<dynamic>?) ?? const [];
|
||||
final normalizedOutputMap = json['normalizedOutput'] is Map
|
||||
? Map<String, dynamic>.from(json['normalizedOutput'] as Map)
|
||||
: null;
|
||||
final summaryMap = json['summary'] is Map
|
||||
? Map<String, dynamic>.from(json['summary'] as Map)
|
||||
: const <String, dynamic>{};
|
||||
|
||||
return AdminAiTraceDetail(
|
||||
id: (json['id'] ?? '').toString(),
|
||||
source: AdminAiTraceSourceX.fromApi(json['source']?.toString()),
|
||||
status: AdminAiTraceStatusX.fromApi(json['status']?.toString()),
|
||||
createdAt: DateTime.tryParse((json['createdAt'] ?? '').toString()) ??
|
||||
DateTime.fromMillisecondsSinceEpoch(0),
|
||||
userId: (json['userId'] as num?)?.toInt() ?? 0,
|
||||
userLabel: (json['userLabel'] ?? '').toString(),
|
||||
sessionId: (json['sessionId'] as num?)?.toInt(),
|
||||
fileName: json['fileName']?.toString(),
|
||||
model: json['model']?.toString(),
|
||||
durationMs: (json['durationMs'] as num?)?.toInt(),
|
||||
retryCount: (json['retryCount'] as num?)?.toInt(),
|
||||
chunkCount: (json['chunkCount'] as num?)?.toInt(),
|
||||
warnings: warningsRaw.map((entry) => entry.toString()).toList(),
|
||||
error: json['error']?.toString(),
|
||||
prompt: json['prompt']?.toString(),
|
||||
rawOutput: json['rawOutput']?.toString(),
|
||||
normalizedOutput: normalizedOutputMap,
|
||||
summary: summaryMap,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,139 +1,537 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../core/api/api_error_mapper.dart';
|
||||
import '../../../core/l10n/l10n.dart';
|
||||
import '../data/admin_repository.dart';
|
||||
import '../domain/ai_model_info.dart';
|
||||
|
||||
class AdminAiPanel extends ConsumerStatefulWidget {
|
||||
final bool embedded;
|
||||
|
||||
const AdminAiPanel({super.key, this.embedded = false});
|
||||
|
||||
@override
|
||||
ConsumerState<AdminAiPanel> createState() => _AdminAiPanelState();
|
||||
}
|
||||
|
||||
class _AdminAiPanelState extends ConsumerState<AdminAiPanel> {
|
||||
bool _isLoading = true;
|
||||
String? _error;
|
||||
List<AiModelInfo> _models = [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_error = null;
|
||||
});
|
||||
try {
|
||||
final models = await ref.read(adminRepositoryProvider).listAiModels();
|
||||
if (!mounted) return;
|
||||
setState(() => _models = models);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() => _error = mapErrorToUserMessage(e, context));
|
||||
} finally {
|
||||
if (mounted) setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
|
||||
Color _chipColor(String value, ColorScheme scheme) {
|
||||
final lower = value.toLowerCase();
|
||||
if (lower.contains('admin')) return scheme.primaryContainer;
|
||||
if (lower.contains('premium')) return scheme.tertiaryContainer;
|
||||
return scheme.secondaryContainer;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
if (_isLoading) return const Center(child: CircularProgressIndicator());
|
||||
if (_error != null) {
|
||||
return buildCopyableErrorPanel(
|
||||
context: context,
|
||||
message: _error!,
|
||||
onRetry: _load,
|
||||
title: 'Kunde inte läsa AI-modeller',
|
||||
);
|
||||
}
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('AI', style: theme.textTheme.titleMedium),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
context.l10n.adminAiDescription,
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
Chip(label: Text('Models')),
|
||||
Chip(label: Text('Access')),
|
||||
Chip(label: Text('Trigger')),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (_models.isEmpty)
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text(
|
||||
'Inga AI-modeller hittades.',
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
),
|
||||
),
|
||||
..._models.map(
|
||||
(model) => Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(model.name, style: theme.textTheme.titleMedium),
|
||||
const SizedBox(height: 8),
|
||||
Text(model.description),
|
||||
const SizedBox(height: 12),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
Chip(label: Text(model.model)),
|
||||
Chip(
|
||||
label: Text(model.access),
|
||||
backgroundColor: _chipColor(model.access, theme.colorScheme),
|
||||
),
|
||||
Chip(label: Text(model.trigger)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text('${context.l10n.adminPagePrefix}${model.path}', style: theme.textTheme.bodySmall),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../core/api/api_error_mapper.dart';
|
||||
import '../data/admin_repository.dart';
|
||||
import '../domain/admin_ai_trace.dart';
|
||||
import '../domain/admin_ai_trace_detail.dart';
|
||||
|
||||
class AdminAiPanel extends ConsumerStatefulWidget {
|
||||
final bool embedded;
|
||||
|
||||
const AdminAiPanel({super.key, this.embedded = false});
|
||||
|
||||
@override
|
||||
ConsumerState<AdminAiPanel> createState() => _AdminAiPanelState();
|
||||
}
|
||||
|
||||
class _AdminAiPanelState extends ConsumerState<AdminAiPanel> {
|
||||
bool _isLoading = true;
|
||||
String? _error;
|
||||
|
||||
AdminAiTraceSource _source = AdminAiTraceSource.flyer;
|
||||
String _period = '7d';
|
||||
bool _onlyErrors = false;
|
||||
|
||||
List<AdminAiTraceListItem> _items = const [];
|
||||
String? _nextCursor;
|
||||
String? _selectedId;
|
||||
AdminAiTraceDetail? _selected;
|
||||
bool _isDetailLoading = false;
|
||||
bool _promptExpanded = false;
|
||||
String? _cachedOutputTraceId;
|
||||
String? _cachedOutputPrettyJson;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_error = null;
|
||||
});
|
||||
try {
|
||||
final response = await ref.read(adminRepositoryProvider).listAiTraces(
|
||||
source: _source,
|
||||
limit: 30,
|
||||
period: _period,
|
||||
onlyErrors: _onlyErrors,
|
||||
);
|
||||
if (!mounted) return;
|
||||
final selectedId =
|
||||
response.items.isEmpty ? null : response.items.first.id;
|
||||
setState(() {
|
||||
_items = response.items;
|
||||
_nextCursor = response.nextCursor;
|
||||
_selectedId = selectedId;
|
||||
_selected = null;
|
||||
_promptExpanded = false;
|
||||
_cachedOutputTraceId = null;
|
||||
_cachedOutputPrettyJson = null;
|
||||
});
|
||||
if (selectedId != null) {
|
||||
await _loadDetail(selectedId);
|
||||
}
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() => _error = mapErrorToUserMessage(e, context));
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadMore() async {
|
||||
if (_nextCursor == null || _nextCursor!.isEmpty) return;
|
||||
try {
|
||||
final response = await ref.read(adminRepositoryProvider).listAiTraces(
|
||||
source: _source,
|
||||
limit: 30,
|
||||
cursor: _nextCursor,
|
||||
period: _period,
|
||||
onlyErrors: _onlyErrors,
|
||||
);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_items = [..._items, ...response.items];
|
||||
_nextCursor = response.nextCursor;
|
||||
});
|
||||
} catch (_) {
|
||||
// Ignore soft pagination failures.
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadDetail(String id) async {
|
||||
setState(() {
|
||||
_isDetailLoading = true;
|
||||
_selected = null;
|
||||
_cachedOutputTraceId = null;
|
||||
_cachedOutputPrettyJson = null;
|
||||
});
|
||||
try {
|
||||
final detail = await ref.read(adminRepositoryProvider).getAiTraceById(id);
|
||||
if (!mounted) return;
|
||||
setState(() => _selected = detail);
|
||||
} catch (_) {
|
||||
if (!mounted) return;
|
||||
setState(() => _selected = null);
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() => _isDetailLoading = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String _formatDateTime(DateTime value) {
|
||||
final local = value.toLocal();
|
||||
String two(int n) => n.toString().padLeft(2, '0');
|
||||
return '${local.year}-${two(local.month)}-${two(local.day)} ${two(local.hour)}:${two(local.minute)}';
|
||||
}
|
||||
|
||||
Future<void> _copyText(String value, String label) async {
|
||||
await Clipboard.setData(ClipboardData(text: value));
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(SnackBar(content: Text('$label kopierad')));
|
||||
}
|
||||
|
||||
String _prettyJson(Object? data) {
|
||||
if (data == null) return '{}';
|
||||
return const JsonEncoder.withIndent(' ').convert(data);
|
||||
}
|
||||
|
||||
Color _statusColor(AdminAiTraceStatus status, ColorScheme scheme) {
|
||||
return switch (status) {
|
||||
AdminAiTraceStatus.success => Colors.green.shade700,
|
||||
AdminAiTraceStatus.warning => Colors.orange.shade700,
|
||||
AdminAiTraceStatus.error => scheme.error,
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_isLoading) return const Center(child: CircularProgressIndicator());
|
||||
if (_error != null) {
|
||||
return buildCopyableErrorPanel(
|
||||
context: context,
|
||||
message: _error!,
|
||||
onRetry: _load,
|
||||
title: 'Kunde inte läsa AI-spårning',
|
||||
);
|
||||
}
|
||||
|
||||
final content = LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final isWide = constraints.maxWidth >= 980;
|
||||
final listPane = _buildTraceList();
|
||||
final detailPane = _buildTraceDetail();
|
||||
|
||||
if (isWide) {
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Expanded(flex: 2, child: listPane),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(flex: 3, child: detailPane),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
SizedBox(height: 260, child: listPane),
|
||||
const SizedBox(height: 12),
|
||||
Expanded(child: detailPane),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
return Padding(
|
||||
padding: widget.embedded ? EdgeInsets.zero : const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_buildTopFilters(),
|
||||
const SizedBox(height: 12),
|
||||
Expanded(child: content),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTopFilters() {
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
ChoiceChip(
|
||||
label: const Text('Kvitto'),
|
||||
selected: _source == AdminAiTraceSource.receipt,
|
||||
onSelected: (_) {
|
||||
setState(() => _source = AdminAiTraceSource.receipt);
|
||||
_load();
|
||||
},
|
||||
),
|
||||
ChoiceChip(
|
||||
label: const Text('Flyer'),
|
||||
selected: _source == AdminAiTraceSource.flyer,
|
||||
onSelected: (_) {
|
||||
setState(() => _source = AdminAiTraceSource.flyer);
|
||||
_load();
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
FilterChip(
|
||||
label: const Text('24h'),
|
||||
selected: _period == '24h',
|
||||
onSelected: (_) {
|
||||
setState(() => _period = '24h');
|
||||
_load();
|
||||
},
|
||||
),
|
||||
FilterChip(
|
||||
label: const Text('7d'),
|
||||
selected: _period == '7d',
|
||||
onSelected: (_) {
|
||||
setState(() => _period = '7d');
|
||||
_load();
|
||||
},
|
||||
),
|
||||
FilterChip(
|
||||
label: const Text('30d'),
|
||||
selected: _period == '30d',
|
||||
onSelected: (_) {
|
||||
setState(() => _period = '30d');
|
||||
_load();
|
||||
},
|
||||
),
|
||||
FilterChip(
|
||||
label: const Text('Endast fel'),
|
||||
selected: _onlyErrors,
|
||||
onSelected: (value) {
|
||||
setState(() => _onlyErrors = value);
|
||||
_load();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTraceList() {
|
||||
final theme = Theme.of(context);
|
||||
if (_items.isEmpty) {
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text(
|
||||
_source == AdminAiTraceSource.receipt
|
||||
? 'Receipt trace-data saknas i recipe-api i denna fas.'
|
||||
: 'Inga importer matchar valda filter.',
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Card(
|
||||
child: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ListView.separated(
|
||||
itemCount: _items.length,
|
||||
separatorBuilder: (_, __) => const Divider(height: 1),
|
||||
itemBuilder: (context, index) {
|
||||
final item = _items[index];
|
||||
final selected = item.id == _selectedId;
|
||||
return ListTile(
|
||||
selected: selected,
|
||||
onTap: () {
|
||||
setState(() {
|
||||
_selectedId = item.id;
|
||||
_promptExpanded = false;
|
||||
});
|
||||
_loadDetail(item.id);
|
||||
},
|
||||
title: Text(item.fileName ?? item.id),
|
||||
subtitle: Text(
|
||||
'${_formatDateTime(item.createdAt)} • ${item.userLabel}'),
|
||||
trailing: Chip(
|
||||
label: Text(item.status.label),
|
||||
labelStyle: TextStyle(
|
||||
color: _statusColor(item.status, theme.colorScheme)),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
if (_nextCursor != null && _nextCursor!.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: _loadMore,
|
||||
icon: const Icon(Icons.expand_more),
|
||||
label: const Text('Ladda fler'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTraceDetail() {
|
||||
if (_isDetailLoading) {
|
||||
return const Card(child: Center(child: CircularProgressIndicator()));
|
||||
}
|
||||
final detail = _selected;
|
||||
if (detail == null) {
|
||||
return const Card(
|
||||
child: Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Text('Välj en import för detaljer.'),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final prompt = detail.prompt;
|
||||
final outputJson = detail.normalizedOutput ??
|
||||
(detail.rawOutput == null
|
||||
? const <String, dynamic>{}
|
||||
: {'rawOutput': detail.rawOutput});
|
||||
final prettyOutput = _prettyOutputFor(detail.id, outputJson);
|
||||
|
||||
return ListView(
|
||||
children: [
|
||||
_TraceMetaCard(detail: detail, formatDateTime: _formatDateTime),
|
||||
const SizedBox(height: 12),
|
||||
_PromptCard(
|
||||
prompt: prompt,
|
||||
expanded: _promptExpanded,
|
||||
onToggleExpand: () =>
|
||||
setState(() => _promptExpanded = !_promptExpanded),
|
||||
onCopy: prompt == null ? null : () => _copyText(prompt, 'Prompt'),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_OutputJsonCard(
|
||||
jsonText: prettyOutput,
|
||||
onCopy: () => _copyText(prettyOutput, 'Output JSON'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
String _prettyOutputFor(String traceId, Object? outputJson) {
|
||||
if (_cachedOutputTraceId == traceId && _cachedOutputPrettyJson != null) {
|
||||
return _cachedOutputPrettyJson!;
|
||||
}
|
||||
final next = _prettyJson(outputJson);
|
||||
_cachedOutputTraceId = traceId;
|
||||
_cachedOutputPrettyJson = next;
|
||||
return next;
|
||||
}
|
||||
}
|
||||
|
||||
class _TraceMetaCard extends StatelessWidget {
|
||||
final AdminAiTraceDetail detail;
|
||||
final String Function(DateTime value) formatDateTime;
|
||||
|
||||
const _TraceMetaCard({required this.detail, required this.formatDateTime});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Sammanfattning', style: theme.textTheme.titleMedium),
|
||||
const SizedBox(height: 8),
|
||||
Text('Källa: ${detail.source.label}'),
|
||||
Text('Tid: ${formatDateTime(detail.createdAt)}'),
|
||||
Text('Användare: ${detail.userLabel}'),
|
||||
Text('Status: ${detail.status.label}'),
|
||||
Text('Modell: ${detail.model ?? 'okänd'}'),
|
||||
if (detail.durationMs != null)
|
||||
Text('Duration: ${detail.durationMs} ms'),
|
||||
if (detail.chunkCount != null) Text('Chunks: ${detail.chunkCount}'),
|
||||
if (detail.retryCount != null)
|
||||
Text('Retries: ${detail.retryCount}'),
|
||||
if (detail.warnings.isNotEmpty)
|
||||
Text('Warnings: ${detail.warnings.length}'),
|
||||
if (detail.error != null && detail.error!.isNotEmpty)
|
||||
Text('Fel: ${detail.error}',
|
||||
style: TextStyle(color: theme.colorScheme.error)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PromptCard extends StatelessWidget {
|
||||
final String? prompt;
|
||||
final bool expanded;
|
||||
final VoidCallback onToggleExpand;
|
||||
final VoidCallback? onCopy;
|
||||
|
||||
const _PromptCard({
|
||||
required this.prompt,
|
||||
required this.expanded,
|
||||
required this.onToggleExpand,
|
||||
required this.onCopy,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final value = (prompt ?? '').trim();
|
||||
final hasPrompt = value.isNotEmpty;
|
||||
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text('Prompt', style: theme.textTheme.titleMedium)),
|
||||
IconButton(
|
||||
tooltip: 'Expandera/kollapsa',
|
||||
onPressed: hasPrompt ? onToggleExpand : null,
|
||||
icon: Icon(expanded ? Icons.unfold_less : Icons.unfold_more),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Kopiera',
|
||||
onPressed: hasPrompt ? onCopy : null,
|
||||
icon: const Icon(Icons.copy_all),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceContainerHighest
|
||||
.withValues(alpha: 0.35),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
hasPrompt
|
||||
? value
|
||||
: 'Prompt är inte tillgänglig i denna fas för vald källa.',
|
||||
maxLines: expanded ? null : 10,
|
||||
overflow:
|
||||
expanded ? TextOverflow.visible : TextOverflow.ellipsis,
|
||||
style: theme.textTheme.bodySmall
|
||||
?.copyWith(fontFamily: 'monospace'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _OutputJsonCard extends StatelessWidget {
|
||||
final String jsonText;
|
||||
final VoidCallback onCopy;
|
||||
|
||||
const _OutputJsonCard({required this.jsonText, required this.onCopy});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text('Model Output',
|
||||
style: theme.textTheme.titleMedium)),
|
||||
IconButton(
|
||||
tooltip: 'Kopiera JSON',
|
||||
onPressed: onCopy,
|
||||
icon: const Icon(Icons.copy_all),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceContainerHighest
|
||||
.withValues(alpha: 0.35),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Text(
|
||||
jsonText,
|
||||
style: theme.textTheme.bodySmall
|
||||
?.copyWith(fontFamily: 'monospace'),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import 'dart:async';
|
||||
import '../../../core/api/api_error_mapper.dart';
|
||||
import '../../../core/l10n/l10n.dart';
|
||||
import '../../../core/realtime/realtime_sync.dart';
|
||||
import 'admin_ai_panel.dart';
|
||||
import 'admin_aliases_panel.dart';
|
||||
import 'admin_inventory_panel.dart';
|
||||
import 'admin_pantry_panel.dart';
|
||||
@@ -14,7 +13,14 @@ import 'admin_pending_products_panel.dart';
|
||||
import 'admin_products_panel.dart';
|
||||
import '../../profile/data/profile_repository.dart';
|
||||
|
||||
enum _DatabaseTab { inventory, pantry, products, privateProducts, pending, aliases, ai }
|
||||
enum _DatabaseTab {
|
||||
inventory,
|
||||
pantry,
|
||||
products,
|
||||
privateProducts,
|
||||
pending,
|
||||
aliases
|
||||
}
|
||||
|
||||
class _DatabaseTabConfig {
|
||||
final _DatabaseTab tab;
|
||||
@@ -98,11 +104,6 @@ class _AdminDatabasePanelState extends ConsumerState<AdminDatabasePanel> {
|
||||
title: 'Alias',
|
||||
panel: const AdminAliasesPanel(embedded: true),
|
||||
),
|
||||
_DatabaseTabConfig(
|
||||
tab: _DatabaseTab.ai,
|
||||
title: 'AI',
|
||||
panel: const AdminAiPanel(embedded: true),
|
||||
),
|
||||
];
|
||||
|
||||
Future<void> _refreshCategories() async {
|
||||
@@ -125,7 +126,8 @@ class _AdminDatabasePanelState extends ConsumerState<AdminDatabasePanel> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final currentTab = _tabConfigs.firstWhere((config) => config.tab == _activeTab);
|
||||
final currentTab =
|
||||
_tabConfigs.firstWhere((config) => config.tab == _activeTab);
|
||||
|
||||
final header = Card(
|
||||
child: Padding(
|
||||
@@ -146,7 +148,8 @@ class _AdminDatabasePanelState extends ConsumerState<AdminDatabasePanel> {
|
||||
child: ChoiceChip(
|
||||
label: Text(config.title),
|
||||
selected: _activeTab == config.tab,
|
||||
onSelected: (_) => setState(() => _activeTab = config.tab),
|
||||
onSelected: (_) =>
|
||||
setState(() => _activeTab = config.tab),
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -157,7 +160,8 @@ class _AdminDatabasePanelState extends ConsumerState<AdminDatabasePanel> {
|
||||
const SizedBox(width: 8),
|
||||
IconButton(
|
||||
tooltip: 'Uppdatera kategorier',
|
||||
onPressed: _isRefreshingCategories ? null : _refreshCategories,
|
||||
onPressed:
|
||||
_isRefreshingCategories ? null : _refreshCategories,
|
||||
icon: _isRefreshingCategories
|
||||
? const SizedBox(
|
||||
height: 16,
|
||||
@@ -183,7 +187,8 @@ class _AdminDatabasePanelState extends ConsumerState<AdminDatabasePanel> {
|
||||
const SizedBox(height: 12),
|
||||
Expanded(
|
||||
child: KeyedSubtree(
|
||||
key: ValueKey('admin-db-${_activeTab.name}-$_panelRefreshVersion'),
|
||||
key:
|
||||
ValueKey('admin-db-${_activeTab.name}-$_panelRefreshVersion'),
|
||||
child: currentTab.panel,
|
||||
),
|
||||
),
|
||||
@@ -192,4 +197,3 @@ class _AdminDatabasePanelState extends ConsumerState<AdminDatabasePanel> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,36 +1,42 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'admin_database_panel.dart';
|
||||
import 'admin_users_panel.dart';
|
||||
|
||||
enum AdminViewTab { users, database }
|
||||
|
||||
extension AdminViewTabX on AdminViewTab {
|
||||
static AdminViewTab fromQuery(String? value) {
|
||||
return switch (value) {
|
||||
'database' => AdminViewTab.database,
|
||||
_ => AdminViewTab.users,
|
||||
};
|
||||
}
|
||||
|
||||
String get queryValue => this == AdminViewTab.database ? 'database' : 'users';
|
||||
}
|
||||
|
||||
class AdminScreen extends StatelessWidget {
|
||||
final AdminViewTab initialTab;
|
||||
|
||||
const AdminScreen({super.key, this.initialTab = AdminViewTab.users});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final activePanel = switch (initialTab) {
|
||||
AdminViewTab.users => const AdminUsersPanel(embedded: true),
|
||||
AdminViewTab.database => const AdminDatabasePanel(embedded: true),
|
||||
};
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 8, 12, 8),
|
||||
child: activePanel,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'admin_ai_panel.dart';
|
||||
import 'admin_database_panel.dart';
|
||||
import 'admin_users_panel.dart';
|
||||
|
||||
enum AdminViewTab { users, database, ai }
|
||||
|
||||
extension AdminViewTabX on AdminViewTab {
|
||||
static AdminViewTab fromQuery(String? value) {
|
||||
return switch (value) {
|
||||
'database' => AdminViewTab.database,
|
||||
'ai' => AdminViewTab.ai,
|
||||
_ => AdminViewTab.users,
|
||||
};
|
||||
}
|
||||
|
||||
String get queryValue => switch (this) {
|
||||
AdminViewTab.users => 'users',
|
||||
AdminViewTab.database => 'database',
|
||||
AdminViewTab.ai => 'ai',
|
||||
};
|
||||
}
|
||||
|
||||
class AdminScreen extends StatelessWidget {
|
||||
final AdminViewTab initialTab;
|
||||
|
||||
const AdminScreen({super.key, this.initialTab = AdminViewTab.users});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final activePanel = switch (initialTab) {
|
||||
AdminViewTab.users => const AdminUsersPanel(embedded: true),
|
||||
AdminViewTab.database => const AdminDatabasePanel(embedded: true),
|
||||
AdminViewTab.ai => const AdminAiPanel(embedded: true),
|
||||
};
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 8, 12, 8),
|
||||
child: activePanel,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:recipe_flutter/features/admin/data/admin_repository.dart';
|
||||
import 'package:recipe_flutter/features/admin/domain/admin_ai_categorize_result.dart';
|
||||
import 'package:recipe_flutter/features/admin/domain/admin_ai_trace.dart';
|
||||
import 'package:recipe_flutter/features/admin/domain/admin_ai_trace_detail.dart';
|
||||
import 'package:recipe_flutter/features/admin/domain/admin_category_node.dart';
|
||||
import 'package:recipe_flutter/features/admin/domain/admin_inventory_item.dart';
|
||||
import 'package:recipe_flutter/features/admin/domain/admin_pantry_item.dart';
|
||||
@@ -20,99 +22,187 @@ class TestAdminRepositoryWrapper implements AdminRepository {
|
||||
TestAdminRepositoryWrapper(this._fakeRepo);
|
||||
|
||||
@override
|
||||
Future<List<ReceiptAlias>> listReceiptAliases() => _fakeRepo.listReceiptAliases();
|
||||
Future<List<ReceiptAlias>> listReceiptAliases() =>
|
||||
_fakeRepo.listReceiptAliases();
|
||||
|
||||
@override
|
||||
Future<List<AdminProduct>> listGlobalProducts() => _fakeRepo.listGlobalProducts();
|
||||
Future<List<AdminProduct>> listGlobalProducts() =>
|
||||
_fakeRepo.listGlobalProducts();
|
||||
|
||||
@override
|
||||
Future<void> updateReceiptAlias(int id, {String? receiptName, int? productId, bool? isGlobal}) => _fakeRepo.updateReceiptAlias(id, receiptName: receiptName, productId: productId, isGlobal: isGlobal);
|
||||
Future<void> updateReceiptAlias(int id,
|
||||
{String? receiptName, int? productId, bool? isGlobal}) =>
|
||||
_fakeRepo.updateReceiptAlias(id,
|
||||
receiptName: receiptName, productId: productId, isGlobal: isGlobal);
|
||||
|
||||
// Stub implementations for other required methods
|
||||
@override
|
||||
Future<List<AdminAiCategorizeResult>> aiCategorizeBulk({List<int>? productIds}) => throw UnimplementedError();
|
||||
Future<List<AdminAiCategorizeResult>> aiCategorizeBulk(
|
||||
{List<int>? productIds}) =>
|
||||
throw UnimplementedError();
|
||||
@override
|
||||
Future<int> bulkSetCategory(List<int> ids, {required int? categoryId}) => throw UnimplementedError();
|
||||
Future<int> bulkSetCategory(List<int> ids, {required int? categoryId}) =>
|
||||
throw UnimplementedError();
|
||||
@override
|
||||
Future<AdminInventoryItem> createAdminInventory({int? userId, required int productId, required double quantity, required String unit, String? location, String? brand, String? receiptName, String? suitableFor, String? comment}) => throw UnimplementedError();
|
||||
Future<AdminInventoryItem> createAdminInventory(
|
||||
{int? userId,
|
||||
required int productId,
|
||||
required double quantity,
|
||||
required String unit,
|
||||
String? location,
|
||||
String? brand,
|
||||
String? receiptName,
|
||||
String? suitableFor,
|
||||
String? comment}) =>
|
||||
throw UnimplementedError();
|
||||
@override
|
||||
Future<AdminPantryItem> createAdminPantry({int? userId, required int productId, String? location}) => throw UnimplementedError();
|
||||
Future<AdminPantryItem> createAdminPantry(
|
||||
{int? userId, required int productId, String? location}) =>
|
||||
throw UnimplementedError();
|
||||
@override
|
||||
Future<Map<String, dynamic>> createProduct(String name, {int? categoryId}) => throw UnimplementedError();
|
||||
Future<Map<String, dynamic>> createProduct(String name, {int? categoryId}) =>
|
||||
throw UnimplementedError();
|
||||
@override
|
||||
Future<UserAdmin> createUser({required String username, required String email, required String password, String role = 'user'}) => throw UnimplementedError();
|
||||
Future<UserAdmin> createUser(
|
||||
{required String username,
|
||||
required String email,
|
||||
required String password,
|
||||
String role = 'user'}) =>
|
||||
throw UnimplementedError();
|
||||
@override
|
||||
Future<void> deleteUser(int userId) => throw UnimplementedError();
|
||||
@override
|
||||
Future<List<AdminInventoryItem>> listAdminInventory({int? userId, String? sort}) => throw UnimplementedError();
|
||||
Future<List<AdminInventoryItem>> listAdminInventory(
|
||||
{int? userId, String? sort}) =>
|
||||
throw UnimplementedError();
|
||||
@override
|
||||
Future<List<AdminPantryItem>> listAdminPantry({int? userId}) => throw UnimplementedError();
|
||||
Future<List<AdminPantryItem>> listAdminPantry({int? userId}) =>
|
||||
throw UnimplementedError();
|
||||
@override
|
||||
Future<List<AiModelInfo>> listAiModels() => throw UnimplementedError();
|
||||
@override
|
||||
Future<List<AdminCategoryNode>> listCategoryTree() => throw UnimplementedError();
|
||||
Future<AdminAiTraceDetail> getAiTraceById(String traceId) =>
|
||||
throw UnimplementedError();
|
||||
@override
|
||||
Future<List<AdminProduct>> listDeletedProducts() => throw UnimplementedError();
|
||||
Future<AdminAiTraceListResponse> listAiTraces(
|
||||
{required AdminAiTraceSource source,
|
||||
int limit = 25,
|
||||
String? cursor,
|
||||
String? period,
|
||||
bool onlyErrors = false}) =>
|
||||
throw UnimplementedError();
|
||||
@override
|
||||
Future<List<PendingProduct>> listPendingProducts() => throw UnimplementedError();
|
||||
Future<List<AdminCategoryNode>> listCategoryTree() =>
|
||||
throw UnimplementedError();
|
||||
@override
|
||||
Future<List<PendingProduct>> listPrivateProducts() => throw UnimplementedError();
|
||||
Future<List<AdminProduct>> listDeletedProducts() =>
|
||||
throw UnimplementedError();
|
||||
@override
|
||||
Future<List<PendingProduct>> listPendingProducts() =>
|
||||
throw UnimplementedError();
|
||||
@override
|
||||
Future<List<PendingProduct>> listPrivateProducts() =>
|
||||
throw UnimplementedError();
|
||||
@override
|
||||
Future<List<AdminProduct>> listProducts() => throw UnimplementedError();
|
||||
@override
|
||||
Future<List<AdminProduct>> listSelectableProductsForAdmin({bool forceRefresh = false}) => throw UnimplementedError();
|
||||
Future<List<AdminProduct>> listSelectableProductsForAdmin(
|
||||
{bool forceRefresh = false}) =>
|
||||
throw UnimplementedError();
|
||||
@override
|
||||
Future<List<UserAdmin>> listUsers() => throw UnimplementedError();
|
||||
@override
|
||||
Future<void> mergeAdminInventory({required int sourceInventoryId, required int targetInventoryId}) => throw UnimplementedError();
|
||||
Future<void> mergeAdminInventory(
|
||||
{required int sourceInventoryId, required int targetInventoryId}) =>
|
||||
throw UnimplementedError();
|
||||
@override
|
||||
Future<void> mergeProducts({required int sourceProductId, required int targetProductId}) => throw UnimplementedError();
|
||||
Future<void> mergeProducts(
|
||||
{required int sourceProductId, required int targetProductId}) =>
|
||||
throw UnimplementedError();
|
||||
@override
|
||||
Future<void> mergeProductsPrivate({required int sourceProductId, required int targetProductId}) => throw UnimplementedError();
|
||||
Future<void> mergeProductsPrivate(
|
||||
{required int sourceProductId, required int targetProductId}) =>
|
||||
throw UnimplementedError();
|
||||
@override
|
||||
Future<void> moveAdminInventoryToPantry(int inventoryId) => throw UnimplementedError();
|
||||
Future<void> moveAdminInventoryToPantry(int inventoryId) =>
|
||||
throw UnimplementedError();
|
||||
@override
|
||||
Future<void> moveAdminPantryToInventory(int pantryItemId, Map<String, dynamic> body) => throw UnimplementedError();
|
||||
Future<void> moveAdminPantryToInventory(
|
||||
int pantryItemId, Map<String, dynamic> body) =>
|
||||
throw UnimplementedError();
|
||||
@override
|
||||
Future<Map<String, dynamic>> previewAdminInventoryMerge({required int sourceInventoryId, required int targetInventoryId}) => throw UnimplementedError();
|
||||
Future<Map<String, dynamic>> previewAdminInventoryMerge(
|
||||
{required int sourceInventoryId, required int targetInventoryId}) =>
|
||||
throw UnimplementedError();
|
||||
@override
|
||||
Future<Map<String, dynamic>> previewMerge({required int sourceProductId, required int targetProductId}) => throw UnimplementedError();
|
||||
Future<Map<String, dynamic>> previewMerge(
|
||||
{required int sourceProductId, required int targetProductId}) =>
|
||||
throw UnimplementedError();
|
||||
@override
|
||||
Future<AdminProduct> promotePrivateProduct(int productId) => throw UnimplementedError();
|
||||
Future<AdminProduct> promotePrivateProduct(int productId) =>
|
||||
throw UnimplementedError();
|
||||
@override
|
||||
Future<void> removeAdminInventory(int inventoryId) => throw UnimplementedError();
|
||||
Future<void> removeAdminInventory(int inventoryId) =>
|
||||
throw UnimplementedError();
|
||||
@override
|
||||
Future<void> removeAdminPantryItem(int pantryItemId) => throw UnimplementedError();
|
||||
Future<void> removeAdminPantryItem(int pantryItemId) =>
|
||||
throw UnimplementedError();
|
||||
@override
|
||||
Future<void> removeProduct(int productId) => throw UnimplementedError();
|
||||
@override
|
||||
Future<void> removeReceiptAlias(int id) => throw UnimplementedError();
|
||||
@override
|
||||
Future<Map<String, dynamic>> resetPassword(int userId) => throw UnimplementedError();
|
||||
Future<Map<String, dynamic>> resetPassword(int userId) =>
|
||||
throw UnimplementedError();
|
||||
@override
|
||||
Future<void> restoreProduct(int productId) => throw UnimplementedError();
|
||||
@override
|
||||
Future<UserAdmin> setPremium(int userId, {required bool isPremium}) => throw UnimplementedError();
|
||||
Future<UserAdmin> setPremium(int userId, {required bool isPremium}) =>
|
||||
throw UnimplementedError();
|
||||
@override
|
||||
Future<void> setProductCategory(int productId, {required int? categoryId}) => throw UnimplementedError();
|
||||
Future<void> setProductCategory(int productId, {required int? categoryId}) =>
|
||||
throw UnimplementedError();
|
||||
@override
|
||||
Future<void> setProductStatus(int productId, String status) => throw UnimplementedError();
|
||||
Future<void> setProductStatus(int productId, String status) =>
|
||||
throw UnimplementedError();
|
||||
@override
|
||||
Future<UserAdmin> setRecipeSharing(int userId, {required bool canShareRecipes}) => throw UnimplementedError();
|
||||
Future<UserAdmin> setRecipeSharing(int userId,
|
||||
{required bool canShareRecipes}) =>
|
||||
throw UnimplementedError();
|
||||
@override
|
||||
Future<UserAdmin> setRole(int userId, String newRole) => throw UnimplementedError();
|
||||
Future<UserAdmin> setRole(int userId, String newRole) =>
|
||||
throw UnimplementedError();
|
||||
@override
|
||||
Future<AdminInventoryItem> updateAdminInventory(int inventoryId, {int? productId, double? quantity, String? unit, String? location, String? brand, String? receiptName, String? suitableFor, String? comment}) => throw UnimplementedError();
|
||||
Future<AdminInventoryItem> updateAdminInventory(int inventoryId,
|
||||
{int? productId,
|
||||
double? quantity,
|
||||
String? unit,
|
||||
String? location,
|
||||
String? brand,
|
||||
String? receiptName,
|
||||
String? suitableFor,
|
||||
String? comment}) =>
|
||||
throw UnimplementedError();
|
||||
@override
|
||||
Future<AdminPantryItem> updateAdminPantry(int pantryItemId, {int? productId, String? location}) => throw UnimplementedError();
|
||||
Future<AdminPantryItem> updateAdminPantry(int pantryItemId,
|
||||
{int? productId, String? location}) =>
|
||||
throw UnimplementedError();
|
||||
@override
|
||||
Future<void> updateCanonicalName(int productId, String canonicalName) => throw UnimplementedError();
|
||||
Future<void> updateCanonicalName(int productId, String canonicalName) =>
|
||||
throw UnimplementedError();
|
||||
@override
|
||||
Future<void> updateCanonicalNamePrivate(int productId, String canonicalName) => throw UnimplementedError();
|
||||
Future<void> updateCanonicalNamePrivate(
|
||||
int productId, String canonicalName) =>
|
||||
throw UnimplementedError();
|
||||
@override
|
||||
Future<void> updateEmail(int userId, String email) => throw UnimplementedError();
|
||||
Future<void> updateEmail(int userId, String email) =>
|
||||
throw UnimplementedError();
|
||||
@override
|
||||
Future<void> upsertReceiptAlias({required String receiptName, required int productId, bool isGlobal = false}) => throw UnimplementedError();
|
||||
Future<void> upsertReceiptAlias(
|
||||
{required String receiptName,
|
||||
required int productId,
|
||||
bool isGlobal = false}) =>
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
// Simple fake that only implements the methods we need
|
||||
@@ -128,7 +218,8 @@ class FakeAdminRepository {
|
||||
return _products;
|
||||
}
|
||||
|
||||
Future<void> updateReceiptAlias(int id, {String? receiptName, int? productId, bool? isGlobal}) async {
|
||||
Future<void> updateReceiptAlias(int id,
|
||||
{String? receiptName, int? productId, bool? isGlobal}) async {
|
||||
// Find and update alias
|
||||
final index = _aliases.indexWhere((a) => a.id == id);
|
||||
if (index >= 0) {
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:recipe_flutter/core/ui/app_shell.dart';
|
||||
import 'package:recipe_flutter/features/admin/data/admin_repository.dart';
|
||||
import 'package:recipe_flutter/features/admin/domain/admin_ai_trace.dart';
|
||||
import 'package:recipe_flutter/features/admin/domain/admin_ai_trace_detail.dart';
|
||||
import 'package:recipe_flutter/features/admin/presentation/admin_ai_panel.dart';
|
||||
import 'package:recipe_flutter/features/admin/presentation/admin_screen.dart';
|
||||
import 'package:recipe_flutter/features/auth/data/auth_providers.dart';
|
||||
|
||||
class _FakeAdminRepository implements AdminRepository {
|
||||
final AdminAiTraceListResponse flyerList;
|
||||
final AdminAiTraceListResponse receiptList;
|
||||
final Map<String, AdminAiTraceDetail> details;
|
||||
|
||||
_FakeAdminRepository({
|
||||
required this.flyerList,
|
||||
required this.receiptList,
|
||||
required this.details,
|
||||
});
|
||||
|
||||
@override
|
||||
Future<AdminAiTraceListResponse> listAiTraces({
|
||||
required AdminAiTraceSource source,
|
||||
int limit = 25,
|
||||
String? cursor,
|
||||
String? period,
|
||||
bool onlyErrors = false,
|
||||
}) async {
|
||||
return source == AdminAiTraceSource.flyer ? flyerList : receiptList;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<AdminAiTraceDetail> getAiTraceById(String traceId) async {
|
||||
return details[traceId] ?? details.values.first;
|
||||
}
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
|
||||
Widget _buildPanelApp(AdminRepository repo) {
|
||||
return ProviderScope(
|
||||
overrides: [
|
||||
adminRepositoryProvider.overrideWithValue(repo),
|
||||
],
|
||||
child: const MaterialApp(
|
||||
home: Scaffold(
|
||||
body: AdminAiPanel(embedded: true),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void main() {
|
||||
final flyerItem = AdminAiTraceListItem(
|
||||
id: 'flyer-101',
|
||||
source: AdminAiTraceSource.flyer,
|
||||
status: AdminAiTraceStatus.warning,
|
||||
createdAt: DateTime.parse('2026-05-20T12:34:56.000Z'),
|
||||
userId: 7,
|
||||
userLabel: 'admin',
|
||||
sessionId: 101,
|
||||
fileName: 'willys-v20.pdf',
|
||||
model: 'ministral-8b-2512',
|
||||
durationMs: 1880,
|
||||
warningsCount: 2,
|
||||
hasPrompt: true,
|
||||
hasOutput: true,
|
||||
error: null,
|
||||
);
|
||||
|
||||
final flyerDetail = AdminAiTraceDetail(
|
||||
id: 'flyer-101',
|
||||
source: AdminAiTraceSource.flyer,
|
||||
status: AdminAiTraceStatus.warning,
|
||||
createdAt: DateTime.parse('2026-05-20T12:34:56.000Z'),
|
||||
userId: 7,
|
||||
userLabel: 'admin',
|
||||
sessionId: 101,
|
||||
fileName: 'willys-v20.pdf',
|
||||
model: 'ministral-8b-2512',
|
||||
durationMs: 1880,
|
||||
retryCount: 1,
|
||||
chunkCount: 3,
|
||||
warnings: const ['parse:low_confidence'],
|
||||
error: null,
|
||||
prompt: 'Prompttext exempel',
|
||||
rawOutput: '{"ok":true}',
|
||||
normalizedOutput: const {
|
||||
'sessionId': 101,
|
||||
'items': [
|
||||
{'rawName': 'Tomat'}
|
||||
],
|
||||
},
|
||||
summary: const {'itemCount': 1},
|
||||
);
|
||||
|
||||
group('Admin AI tab and panel', () {
|
||||
testWidgets('Admin main route query tab=ai renders AI panel',
|
||||
(tester) async {
|
||||
final fakeRepo = _FakeAdminRepository(
|
||||
flyerList:
|
||||
AdminAiTraceListResponse(items: [flyerItem], nextCursor: null),
|
||||
receiptList:
|
||||
const AdminAiTraceListResponse(items: [], nextCursor: null),
|
||||
details: {'flyer-101': flyerDetail},
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
adminRepositoryProvider.overrideWithValue(fakeRepo),
|
||||
],
|
||||
child: const MaterialApp(
|
||||
home: Scaffold(
|
||||
body: AdminScreen(initialTab: AdminViewTab.ai),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Kvitto'), findsOneWidget);
|
||||
expect(find.text('Flyer'), findsOneWidget);
|
||||
expect(find.text('willys-v20.pdf'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('AppShell shows AI top chip and navigates with query',
|
||||
(tester) async {
|
||||
String? navigatedTo;
|
||||
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
isAdminProvider.overrideWithValue(true),
|
||||
],
|
||||
child: MaterialApp(
|
||||
home: AppShell(
|
||||
location: '/admin?tab=ai',
|
||||
onNavigateToPath: (path) => navigatedTo = path,
|
||||
child: const SizedBox.shrink(),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('AI'), findsOneWidget);
|
||||
await tester.tap(find.text('Databas'));
|
||||
await tester.pump();
|
||||
|
||||
expect(navigatedTo, '/admin?tab=database');
|
||||
});
|
||||
|
||||
testWidgets('Source switching toggles between Flyer and Kvitto views',
|
||||
(tester) async {
|
||||
final fakeRepo = _FakeAdminRepository(
|
||||
flyerList:
|
||||
AdminAiTraceListResponse(items: [flyerItem], nextCursor: null),
|
||||
receiptList:
|
||||
const AdminAiTraceListResponse(items: [], nextCursor: null),
|
||||
details: {'flyer-101': flyerDetail},
|
||||
);
|
||||
|
||||
await tester.pumpWidget(_buildPanelApp(fakeRepo));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('willys-v20.pdf'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('Kvitto'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Receipt trace-data saknas i recipe-api i denna fas.'),
|
||||
findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('Flyer'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('willys-v20.pdf'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('Prompt and output render and copy actions show snackbars',
|
||||
(tester) async {
|
||||
await tester.binding.setSurfaceSize(const Size(1400, 1200));
|
||||
final fakeRepo = _FakeAdminRepository(
|
||||
flyerList:
|
||||
AdminAiTraceListResponse(items: [flyerItem], nextCursor: null),
|
||||
receiptList:
|
||||
const AdminAiTraceListResponse(items: [], nextCursor: null),
|
||||
details: {'flyer-101': flyerDetail},
|
||||
);
|
||||
|
||||
await tester.pumpWidget(_buildPanelApp(fakeRepo));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.pump(const Duration(milliseconds: 500));
|
||||
|
||||
await tester.tap(find.byType(ListTile).first);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.pump(const Duration(milliseconds: 500));
|
||||
|
||||
expect(find.text('Sammanfattning'), findsOneWidget);
|
||||
final detailScroll = find.byType(Scrollable).last;
|
||||
await tester.scrollUntilVisible(
|
||||
find.text('Model Output'),
|
||||
200,
|
||||
scrollable: detailScroll,
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Model Output'), findsOneWidget);
|
||||
expect(find.textContaining('"sessionId": 101'), findsOneWidget);
|
||||
|
||||
final copyPrompt = find.byTooltip('Kopiera');
|
||||
final copyOutput = find.byTooltip('Kopiera JSON');
|
||||
expect(copyPrompt, findsOneWidget);
|
||||
expect(copyOutput, findsOneWidget);
|
||||
|
||||
await tester.tap(copyPrompt);
|
||||
await tester.pumpAndSettle();
|
||||
expect(tester.takeException(), isNull);
|
||||
|
||||
await tester.tap(copyOutput);
|
||||
await tester.pumpAndSettle();
|
||||
expect(tester.takeException(), isNull);
|
||||
|
||||
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user