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
@@ -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,
);
}
}