import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../core/observations/observed_event.dart'; import '../../../shared/widgets/severity_badge.dart'; import '../../observability/presentation/observability_controller.dart'; enum _DiagnosticFilter { all, logs, errors, shutdowns } class DiagnosticsPage extends ConsumerStatefulWidget { const DiagnosticsPage({super.key}); @override ConsumerState createState() => _DiagnosticsPageState(); } class _DiagnosticsPageState extends ConsumerState { _DiagnosticFilter _filter = _DiagnosticFilter.all; String _query = ''; @override Widget build(BuildContext context) { final state = ref.watch(observabilityControllerProvider); final events = state.events.where(_matches).toList(growable: false); return ListView( padding: const EdgeInsets.all(24), children: [ Text('Diagnostics', style: Theme.of(context).textTheme.headlineMedium), const SizedBox(height: 8), const Text( 'Messages remain distinct by protocol class. A configuration result ' 'will remain authoritative when configuration support is added.', ), const SizedBox(height: 16), Wrap( spacing: 8, runSpacing: 8, children: _DiagnosticFilter.values .map( (filter) => ChoiceChip( label: Text(_labelFor(filter)), selected: _filter == filter, onSelected: (_) => setState(() => _filter = filter), ), ) .toList(growable: false), ), const SizedBox(height: 12), TextField( decoration: const InputDecoration( border: OutlineInputBorder(), labelText: 'Filter diagnostics', prefixIcon: Icon(Icons.search), ), onChanged: (value) { setState(() => _query = value.trim().toLowerCase()); }, ), const SizedBox(height: 16), if (events.isEmpty) const Card( child: Padding( padding: EdgeInsets.all(20), child: Text('No diagnostic observations match this filter.'), ), ) else ...events.map((event) => _DiagnosticCard(event: event)), ], ); } bool _matches(ObservedEvent event) { final typeMatches = switch (_filter) { _DiagnosticFilter.all => event is LogEvent || event is ErrorEvent || event is ShutdownEvent, _DiagnosticFilter.logs => event is LogEvent, _DiagnosticFilter.errors => event is ErrorEvent, _DiagnosticFilter.shutdowns => event is ShutdownEvent, }; if (!typeMatches) { return false; } if (_query.isEmpty) { return true; } return _searchableText(event).toLowerCase().contains(_query); } } class _DiagnosticCard extends StatelessWidget { const _DiagnosticCard({required this.event}); final ObservedEvent event; @override Widget build(BuildContext context) { final theme = Theme.of(context); final content = switch (event) { LogEvent(:final level, :final message, :final flags) => _LogContent( level: level, message: message, flags: flags, ), ErrorEvent(:final path, :final additionalInformation, :final flags) => _ErrorContent( path: path, additionalInformation: additionalInformation, flags: flags, ), ShutdownEvent(:final reason) => _ShutdownContent(reason: reason), _ => null, }; if (content == null) { return const SizedBox.shrink(); } return Semantics( container: true, label: content.semanticLabel, child: Card( margin: const EdgeInsets.only(bottom: 12), child: Padding( padding: const EdgeInsets.all(16), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ content.leading, const SizedBox(width: 12), Expanded( child: SelectionArea( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(content.title, style: theme.textTheme.titleMedium), const SizedBox(height: 4), Text(content.detail), if (content.additionalDetail case final detail?) ...[ const SizedBox(height: 8), Text(detail), ], const SizedBox(height: 8), Text( event.receivedAt.toIso8601String(), style: theme.textTheme.bodySmall, ), ], ), ), ), ], ), ), ), ); } } class _DiagnosticContent { const _DiagnosticContent({ required this.leading, required this.title, required this.detail, required this.semanticLabel, this.additionalDetail, }); final Widget leading; final String title; final String detail; final String? additionalDetail; final String semanticLabel; } class _LogContent extends _DiagnosticContent { _LogContent({ required MavSeverity level, required String message, required int flags, }) : super( leading: SeverityBadge(level: level), title: 'Log — ${level.label}', detail: message, additionalDetail: _formatLogFlags(flags), semanticLabel: _semanticLogLabel(level, message, flags), ); } class _ErrorContent extends _DiagnosticContent { _ErrorContent({ required String path, required String additionalInformation, required int flags, }) : super( leading: const SeverityBadge(level: MavSeverity.error), title: 'Error', detail: path, additionalDetail: _formatErrorDetail(additionalInformation, flags), semanticLabel: _semanticErrorLabel(path, additionalInformation, flags), ); } class _ShutdownContent extends _DiagnosticContent { _ShutdownContent({required KaptanShutdownReason reason}) : super( leading: const Icon(Icons.power_settings_new_outlined), title: 'Orderly shutdown observed', detail: 'Reason: ${reason.label}', semanticLabel: 'Orderly shutdown observed: ${reason.label}', ); } String _labelFor(_DiagnosticFilter filter) => switch (filter) { _DiagnosticFilter.all => 'All', _DiagnosticFilter.logs => 'Logs', _DiagnosticFilter.errors => 'Errors', _DiagnosticFilter.shutdowns => 'Shutdowns', }; String _searchableText(ObservedEvent event) => switch (event) { LogEvent(:final level, :final message) => '${level.label} $message', ErrorEvent(:final path, :final additionalInformation) => '$path $additionalInformation', ShutdownEvent(:final reason) => reason.label, _ => '', }; String? _formatLogFlags(int flags) { final labels = [ if (flags & LogEvent.traceFlag != 0) 'Trace', if (flags & LogEvent.truncatedFlag != 0) 'Truncated', if (flags & LogEvent.coalescedFlag != 0) 'Coalesced', if (flags & ~LogEvent.knownFlags != 0) 'Unknown bits 0x${(flags & ~LogEvent.knownFlags).toRadixString(16)}', ]; return labels.isEmpty ? null : 'Flags: ${labels.join(', ')}'; } String? _formatErrorDetail(String additionalInformation, int flags) { final flagDetail = _formatErrorFlags(flags); final details = [ if (additionalInformation.isNotEmpty) additionalInformation, ?flagDetail, ]; return details.isEmpty ? null : details.join('\n'); } String? _formatErrorFlags(int flags) { final labels = [ if (flags & ErrorEvent.truncatedFlag != 0) 'Truncated', if (flags & ErrorEvent.coalescedFlag != 0) 'Coalesced', if (flags & ~ErrorEvent.knownFlags != 0) 'Unknown bits 0x${(flags & ~ErrorEvent.knownFlags).toRadixString(16)}', ]; return labels.isEmpty ? null : 'Flags: ${labels.join(', ')}'; } String _semanticLogLabel(MavSeverity level, String message, int flags) { final flagDetail = _formatLogFlags(flags); return flagDetail == null ? '${level.label} log: $message' : '${level.label} log: $message. $flagDetail'; } String _semanticErrorLabel( String path, String additionalInformation, int flags, ) { final detail = _formatErrorDetail(additionalInformation, flags); return detail == null ? 'Error: $path' : 'Error: $path. $detail'; }