d9f992ca9a
- Added structured warning system with `AdminAiWarning` type in backend and Flutter - Implemented detailed reason descriptors with `FlyerReasonDescriptor` for parse and match operations - Added `legacyWarnings` field to maintain backward compatibility - Enhanced AI trace service to collect and format warnings with item-level context - Updated flyer import services to include detailed reason descriptions in responses - Added Swedish diacritic preservation for cheese variants (Prästost, Herrgårdsost, Grevéost) - Implemented UTF-8 content validation for AI responses - Added new reason code definitions in `reason-codes.ts` - Updated Flutter UI to display structured warnings with severity indicators - Added error report generation and copy functionality in admin panel - Added comprehensive test coverage for new warning system and cheese normalization BREAKING CHANGE: AI trace warnings are now structured objects instead of simple strings
730 lines
22 KiB
Dart
730 lines
22 KiB
Dart
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);
|
|
}
|
|
|
|
String _formatWarningLine(AdminAiWarning warning) {
|
|
final rowSuffix = warning.itemIndex == null ? '' : ' (rad ${warning.itemIndex})';
|
|
return '[${warning.severity}] ${warning.title}$rowSuffix: ${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 <String, dynamic>{}
|
|
: {'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 = <String>[];
|
|
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<AdminAiWarning> 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,
|
|
),
|
|
],
|
|
),
|
|
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;
|
|
}
|
|
}
|
|
}
|