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 createState() => _AdminAiPanelState(); } class _AdminAiPanelState extends ConsumerState { bool _isLoading = true; String? _error; AdminAiTraceSource _source = AdminAiTraceSource.flyer; String _period = '7d'; bool _onlyErrors = false; List _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 _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 _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 _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 _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 {} : {'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'), ), ), ), ], ), ), ); } }