import 'dart:async'; import 'package:flutter/material.dart'; import '../data/hub.dart'; import '../theme/theme.dart'; import '../theme/tokens.dart'; import '../widgets/widgets.dart'; class AuditPage extends StatefulWidget { const AuditPage({super.key}); @override State createState() => _AuditPageState(); } class _AuditPageState extends State { String _typeFilter = 'all'; List _events = const []; String? _error; bool _initialLoaded = false; Timer? _poller; @override void initState() { super.initState(); _refresh(); _poller = Timer.periodic( const Duration(seconds: 2), (_) => _refresh(), ); } @override void dispose() { _poller?.cancel(); super.dispose(); } Future _refresh() async { try { final events = await HubService.instance.recentEvents(limit: 100); if (!mounted) return; setState(() { _events = events; _error = null; _initialLoaded = true; }); } catch (e) { if (!mounted) return; setState(() { _error = e.toString(); _initialLoaded = true; }); } } @override Widget build(BuildContext context) { final theme = Theme.of(context); final filtered = _typeFilter == 'all' ? _events : _events.where((e) => e.type.startsWith(_typeFilter)).toList(); return Scaffold( backgroundColor: theme.scaffoldBackgroundColor, appBar: AppBar( title: const Text('Audit'), actions: [ Padding( padding: const EdgeInsets.only(right: FaiSpace.lg), child: _FilterChips( value: _typeFilter, onChanged: (v) => setState(() => _typeFilter = v), ), ), ], ), body: Column( children: [ _LiveStatusBar(eventCount: filtered.length, error: _error), Expanded( child: !_initialLoaded ? const Center(child: CircularProgressIndicator()) : _error != null && _events.isEmpty ? FaiEmptyState( icon: Icons.cloud_off_outlined, iconColor: theme.colorScheme.error, title: 'Hub unreachable', hint: 'Start the hub with `fai serve`.', ) : filtered.isEmpty ? const FaiEmptyState( icon: Icons.timeline_outlined, title: 'No events yet', hint: 'Run a flow to populate the audit stream.\nEvents appear here within seconds.', ) : ListView.separated( padding: const EdgeInsets.all(FaiSpace.xl), itemCount: filtered.length, separatorBuilder: (_, _) => const SizedBox(height: FaiSpace.xs), itemBuilder: (context, i) { final e = filtered[i]; return FaiDataRow( accent: _toneFor(e.type, theme), leading: _formatTime(e.timestamp), title: e.type, subtitle: _contextLine(e), trailing: e.durationMs != null ? '${e.durationMs}ms' : null, ); }, ), ), ], ), ); } String _formatTime(DateTime ts) => '${ts.hour.toString().padLeft(2, '0')}:${ts.minute.toString().padLeft(2, '0')}:${ts.second.toString().padLeft(2, '0')}'; String _contextLine(AuditEvent e) { final parts = []; if (e.flowName != null) parts.add(e.flowName!); if (e.stepId != null) parts.add(' › ${e.stepId}'); if (e.moduleName != null) parts.add(' via ${e.moduleName}'); if (e.error != null) parts.add(' [error: ${e.error}]'); return parts.join(''); } Color _toneFor(String type, ThemeData theme) { if (type.endsWith('.failed')) return theme.colorScheme.error; if (type.endsWith('.completed')) return theme.colorScheme.primary; if (type.endsWith('.started')) return FaiColors.warning; return theme.colorScheme.outline; } } class _FilterChips extends StatelessWidget { final String value; final ValueChanged onChanged; const _FilterChips({required this.value, required this.onChanged}); @override Widget build(BuildContext context) { const items = [ ('all', 'all'), ('flow.', 'flow'), ('step.', 'step'), ('module.', 'module'), ]; return Row( children: [ for (final (v, label) in items) Padding( padding: const EdgeInsets.only(left: FaiSpace.xs), child: _ChipButton( label: label, selected: value == v, onTap: () => onChanged(v), ), ), ], ); } } class _ChipButton extends StatefulWidget { final String label; final bool selected; final VoidCallback onTap; const _ChipButton({ required this.label, required this.selected, required this.onTap, }); @override State<_ChipButton> createState() => _ChipButtonState(); } class _ChipButtonState extends State<_ChipButton> { bool _hovered = false; @override Widget build(BuildContext context) { final theme = Theme.of(context); final fg = widget.selected ? theme.colorScheme.primary : _hovered ? theme.colorScheme.onSurface : theme.colorScheme.onSurfaceVariant; final bg = widget.selected ? theme.colorScheme.primary.withValues(alpha: 0.12) : _hovered ? theme.colorScheme.surfaceContainerHigh : Colors.transparent; return MouseRegion( onEnter: (_) => setState(() => _hovered = true), onExit: (_) => setState(() => _hovered = false), cursor: SystemMouseCursors.click, child: GestureDetector( onTap: widget.onTap, child: AnimatedContainer( duration: FaiMotion.fast, padding: const EdgeInsets.symmetric( horizontal: FaiSpace.md, vertical: 6, ), decoration: BoxDecoration( color: bg, borderRadius: BorderRadius.circular(FaiRadius.sm), border: Border.all( color: widget.selected ? theme.colorScheme.primary.withValues(alpha: 0.3) : Colors.transparent, ), ), child: Text( widget.label, style: FaiTheme.mono( size: 11, weight: widget.selected ? FontWeight.w500 : FontWeight.w400, color: fg, ), ), ), ), ); } } class _LiveStatusBar extends StatelessWidget { final int eventCount; final String? error; const _LiveStatusBar({required this.eventCount, required this.error}); @override Widget build(BuildContext context) { final theme = Theme.of(context); final live = error == null; return Container( width: double.infinity, padding: const EdgeInsets.symmetric( horizontal: FaiSpace.xl, vertical: FaiSpace.sm, ), decoration: BoxDecoration( color: theme.colorScheme.surfaceContainerHigh, border: Border( bottom: BorderSide(color: theme.colorScheme.outlineVariant), ), ), child: Row( children: [ FaiStatusDot( color: live ? FaiColors.success : FaiColors.danger, pulsing: live, ), const SizedBox(width: FaiSpace.sm), Text( live ? 'live · polling 2s · $eventCount event${eventCount == 1 ? '' : 's'}' : 'disconnected · $error', style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), const Spacer(), if (live) Row( children: [ Icon( Icons.shield_outlined, size: 12, color: theme.colorScheme.primary, ), const SizedBox(width: 4), Text( 'hash chain verified', style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.primary, fontWeight: FontWeight.w500, ), ), ], ), ], ), ); } }