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); } String _formatWarningLine(AdminAiWarning warning) { final rowSuffix = warning.itemIndex == null ? '' : ' (rad ${warning.itemIndex})'; final productSuffix = warning.productName != null ? ' (${warning.productName})' : ''; return '[${warning.severity}] ${warning.title}$rowSuffix$productSuffix: ${warning.message}'; } String _buildErrorReport({ required AdminAiTraceDetail detail, required String prettyOutput, }) { final warningCount = detail.warnings.length; final buffer = StringBuffer() ..writeln('[AI-trace ${detail.id}]') ..writeln('Modell: ${detail.model ?? 'okänd'}') ..writeln('Status: ${detail.status.name} ($warningCount varningar)') ..writeln('Tid: ${detail.createdAt.toIso8601String()}') ..writeln(); if (detail.warnings.isNotEmpty) { buffer.writeln('Varningar:'); for (final warning in detail.warnings) { buffer.writeln('- ${_formatWarningLine(warning)}'); } buffer.writeln(); } buffer ..writeln('Prompt:') ..writeln((detail.prompt ?? '').trim().isEmpty ? '[saknas]' : detail.prompt!.trim()) ..writeln() ..writeln('Raw output:') ..writeln((detail.rawOutput ?? '').trim().isEmpty ? prettyOutput : detail.rawOutput!.trim()); return buffer.toString().trimRight(); } 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: Tooltip( message: _statusTooltipText(item), child: 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); final errorReport = _buildErrorReport(detail: detail, prettyOutput: prettyOutput); return ListView( children: [ _TraceMetaCard(detail: detail, formatDateTime: _formatDateTime), const SizedBox(height: 8), Align( alignment: Alignment.centerLeft, child: OutlinedButton.icon( onPressed: () => _copyText(errorReport, 'Felrapport'), icon: const Icon(Icons.bug_report_outlined), label: const Text('Kopiera felrapport'), ), ), if (detail.warnings.isNotEmpty) ...[ const SizedBox(height: 12), _WarningsCard( warnings: detail.warnings, onCopyWarning: (warning) => _copyText(_formatWarningLine(warning), 'Varning'), onCopyAll: () => _copyText( detail.warnings.map(_formatWarningLine).join('\n'), 'Varningar', ), ), ], 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; } String _statusTooltipText(AdminAiTraceListItem item) { final parts = []; if (item.status == AdminAiTraceStatus.warning && item.warningsCount > 0) { parts.add('${item.warningsCount} varning(ar). Välj raden för detaljer och kopiering.'); } if (item.error != null && item.error!.trim().isNotEmpty) { parts.add(item.error!.trim()); } if (parts.isEmpty) { return 'Inga ytterligare detaljer.'; } return parts.join('\n'); } } 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: SelectionArea( child: SelectableText( hasPrompt ? value : 'Prompt saknas för detta spår.', maxLines: expanded ? null : 10, style: theme.textTheme.bodySmall ?.copyWith(fontFamily: 'monospace'), ), ), ), ], ), ), ); } } class _OutputJsonCard extends StatefulWidget { final String jsonText; final VoidCallback onCopy; const _OutputJsonCard({required this.jsonText, required this.onCopy}); @override State<_OutputJsonCard> createState() => _OutputJsonCardState(); } class _OutputJsonCardState extends State<_OutputJsonCard> { static const int _maxPreviewChars = 12000; bool _expanded = false; @override Widget build(BuildContext context) { final theme = Theme.of(context); final shouldTruncate = widget.jsonText.length > _maxPreviewChars; final visibleText = !_expanded && shouldTruncate ? '${widget.jsonText.substring(0, _maxPreviewChars)}\n…' : widget.jsonText; 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: widget.onCopy, icon: const Icon(Icons.copy_all), ), ], ), if (shouldTruncate) Align( alignment: Alignment.centerRight, child: TextButton.icon( onPressed: () => setState(() => _expanded = !_expanded), icon: Icon(_expanded ? Icons.unfold_less : Icons.unfold_more), label: Text(_expanded ? 'Visa mindre' : 'Visa hela outputen'), ), ), 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: SelectionArea( child: SingleChildScrollView( scrollDirection: Axis.horizontal, child: SelectableText( visibleText, style: theme.textTheme.bodySmall ?.copyWith(fontFamily: 'monospace'), ), ), ), ), ], ), ), ); } } class _WarningsCard extends StatelessWidget { final List warnings; final void Function(AdminAiWarning warning) onCopyWarning; final VoidCallback onCopyAll; const _WarningsCard({ required this.warnings, required this.onCopyWarning, required this.onCopyAll, }); @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( 'Varningar (${warnings.length})', style: theme.textTheme.titleMedium, ), ), IconButton( tooltip: 'Kopiera alla varningar', onPressed: onCopyAll, icon: const Icon(Icons.copy_all), ), ], ), const SizedBox(height: 8), ...warnings.map( (warning) => ListTile( dense: true, contentPadding: EdgeInsets.zero, leading: Icon(_severityIcon(warning), size: 18, color: _severityColor(warning, theme)), title: Text( warning.title, style: theme.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600), ), subtitle: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(warning.message), if ((warning.location ?? '').trim().isNotEmpty) Text( warning.location!, style: theme.textTheme.bodySmall, ), if (warning.itemIndex != null) Text( 'Rad: ${warning.itemIndex}', style: theme.textTheme.bodySmall, ), if (warning.productName != null) Text( 'Produkt: ${warning.productName}', style: theme.textTheme.bodySmall, ), ], ), trailing: IconButton( tooltip: 'Kopiera varning', onPressed: () => onCopyWarning(warning), icon: const Icon(Icons.copy, size: 18), ), ), ), ], ), ), ); } IconData _severityIcon(AdminAiWarning warning) { switch (warning.severity) { case 'error': return Icons.error_outline; case 'warning': return Icons.warning_amber_rounded; default: return Icons.info_outline; } } Color _severityColor(AdminAiWarning warning, ThemeData theme) { switch (warning.severity) { case 'error': return theme.colorScheme.error; case 'warning': return Colors.orange.shade700; default: return theme.colorScheme.primary; } } }