feat(ai): add AI trace tracking and admin panel
Test Suite / backend-pr-quick (push) Has been skipped
Test Suite / quick-import-pr-quick (push) Has been skipped
Test Suite / backend-full (push) Successful in 12m45s
Test Suite / flutter-quality (push) Failing after 7m24s

- 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:
Nils-Johan Gynther
2026-05-21 17:33:21 +02:00
parent c3520b5ad4
commit 67a7590525
21 changed files with 2477 additions and 509 deletions
+72 -39
View File
@@ -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)}';
}
+40 -32
View File
@@ -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,
);
}
}
+130 -39
View File
@@ -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));
});
});
}