import 'package:flutter/material.dart'; import '../data/hub.dart'; import '../theme/theme.dart'; import '../theme/tokens.dart'; import '../widgets/widgets.dart'; class DoctorPage extends StatefulWidget { const DoctorPage({super.key}); @override State createState() => _DoctorPageState(); } class _DoctorPageState extends State { late Future _future; @override void initState() { super.initState(); _future = HubService.instance.doctor(); } void _refresh() => setState(() { _future = HubService.instance.doctor(); }); @override Widget build(BuildContext context) { return Scaffold( backgroundColor: Theme.of(context).scaffoldBackgroundColor, appBar: AppBar( title: const Text('Doctor'), actions: [ IconButton( icon: const Icon(Icons.refresh, size: 18), tooltip: 'Re-check', onPressed: _refresh, ), const SizedBox(width: FaiSpace.sm), ], ), body: FutureBuilder( future: _future, builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState.waiting) { return const Center(child: CircularProgressIndicator()); } if (snapshot.hasError) { return FaiEmptyState( icon: Icons.cloud_off_outlined, iconColor: Theme.of(context).colorScheme.error, title: 'Hub unreachable', hint: 'Start the hub with `fai serve`.', action: FilledButton.tonal( onPressed: _refresh, child: const Text('Retry'), ), ); } final s = snapshot.data!; final showUpdate = s.update.updateAvailable || (!s.update.manifestReachable && s.update.localVersion.isNotEmpty); return ListView( padding: const EdgeInsets.all(FaiSpace.xl), children: [ if (showUpdate) _UpdateBanner(status: s.update), if (showUpdate) const SizedBox(height: FaiSpace.lg), _SummaryStrip(snapshot: s), const SizedBox(height: FaiSpace.xl), _Section( title: 'Event log', child: _EventLogPanel(snapshot: s), ), const SizedBox(height: FaiSpace.lg), _Section( title: 'Modules & approvals', child: _ModulesPanel(snapshot: s), ), const SizedBox(height: FaiSpace.lg), _Section( title: 'Host services', child: _ServicesPanel(snapshot: s), ), ], ); }, ), ); } } class _Section extends StatelessWidget { final String title; final Widget child; const _Section({required this.title, required this.child}); @override Widget build(BuildContext context) { final theme = Theme.of(context); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( padding: const EdgeInsets.only(left: FaiSpace.xs, bottom: FaiSpace.sm), child: Text( title.toUpperCase(), style: theme.textTheme.labelSmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, letterSpacing: 0.6, fontSize: 10, ), ), ), child, ], ); } } class _SummaryStrip extends StatelessWidget { final DoctorSnapshot snapshot; const _SummaryStrip({required this.snapshot}); @override Widget build(BuildContext context) { return Row( children: [ _StatTile( label: 'Modules', value: snapshot.moduleCount.toString(), subtitle: '${snapshot.capabilityCount} capabilities', icon: Icons.extension_outlined, ), const SizedBox(width: FaiSpace.md), _StatTile( label: 'Approvals', value: snapshot.pendingApprovals.toString(), subtitle: 'pending', icon: Icons.inbox_outlined, tone: snapshot.pendingApprovals > 0 ? FaiPillTone.warning : FaiPillTone.neutral, ), const SizedBox(width: FaiSpace.md), _StatTile( label: 'Audit', value: snapshot.chainHealthy ? '✓' : '⚠', subtitle: '${snapshot.eventChainVerified}/${snapshot.eventChainTotal} chain', icon: Icons.shield_outlined, tone: snapshot.chainHealthy ? FaiPillTone.success : FaiPillTone.danger, ), const SizedBox(width: FaiSpace.md), _StatTile( label: 'Services', value: snapshot.services.length.toString(), subtitle: 'declared', icon: Icons.dns_outlined, ), ], ); } } class _StatTile extends StatelessWidget { final String label; final String value; final String subtitle; final IconData icon; final FaiPillTone tone; const _StatTile({ required this.label, required this.value, required this.subtitle, required this.icon, this.tone = FaiPillTone.neutral, }); @override Widget build(BuildContext context) { final theme = Theme.of(context); final accentColor = switch (tone) { FaiPillTone.success => FaiColors.success, FaiPillTone.warning => FaiColors.warning, FaiPillTone.danger => theme.colorScheme.error, _ => theme.colorScheme.primary, }; return Expanded( child: FaiCard( accentLeft: accentColor, padding: const EdgeInsets.all(FaiSpace.lg), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Icon(icon, size: 14, color: theme.colorScheme.onSurfaceVariant), const SizedBox(width: FaiSpace.xs), Flexible( child: Text( label.toUpperCase(), overflow: TextOverflow.ellipsis, style: theme.textTheme.labelSmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, letterSpacing: 0.5, fontSize: 10, ), ), ), ], ), const SizedBox(height: FaiSpace.sm), Text( value, style: theme.textTheme.displaySmall?.copyWith( color: accentColor, fontWeight: FontWeight.w700, ), ), const SizedBox(height: 2), Text( subtitle, style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), ], ), ), ); } } class _EventLogPanel extends StatelessWidget { final DoctorSnapshot snapshot; const _EventLogPanel({required this.snapshot}); @override Widget build(BuildContext context) { final theme = Theme.of(context); final healthy = snapshot.chainHealthy; return FaiCard( child: Row( children: [ Icon( healthy ? Icons.verified_outlined : Icons.gpp_bad_outlined, size: 24, color: healthy ? FaiColors.success : theme.colorScheme.error, ), const SizedBox(width: FaiSpace.lg), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( healthy ? 'Hash chain intact' : 'Tampering detected', style: theme.textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w600, ), ), const SizedBox(height: 2), Text( healthy ? '${snapshot.eventChainTotal} events · prev_event_sha256 verified end-to-end' : '${snapshot.eventChainVerified} of ${snapshot.eventChainTotal} verified before mismatch at ${snapshot.eventChainTamperedAt}', style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), ], ), ), FaiPill( label: healthy ? 'WORM-1' : 'TAMPER', tone: healthy ? FaiPillTone.success : FaiPillTone.danger, ), ], ), ); } } class _ModulesPanel extends StatelessWidget { final DoctorSnapshot snapshot; const _ModulesPanel({required this.snapshot}); @override Widget build(BuildContext context) { final theme = Theme.of(context); return FaiCard( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Icon( Icons.extension_outlined, size: 18, color: theme.colorScheme.onSurfaceVariant, ), const SizedBox(width: FaiSpace.sm), Text( '${snapshot.moduleCount} modules · ${snapshot.capabilityCount} capabilities', style: theme.textTheme.bodyMedium, ), const Spacer(), FaiPill( label: snapshot.moduleCount > 0 ? 'loaded' : 'empty', tone: snapshot.moduleCount > 0 ? FaiPillTone.success : FaiPillTone.neutral, ), ], ), const Divider(height: FaiSpace.xl), Row( children: [ Icon( Icons.inbox_outlined, size: 18, color: theme.colorScheme.onSurfaceVariant, ), const SizedBox(width: FaiSpace.sm), Text( snapshot.pendingApprovals == 0 ? 'No pending approvals' : '${snapshot.pendingApprovals} approval${snapshot.pendingApprovals == 1 ? '' : 's'} awaiting review', style: theme.textTheme.bodyMedium, ), const Spacer(), if (snapshot.pendingApprovals > 0) FaiPill( label: 'attention', tone: FaiPillTone.warning, ), ], ), ], ), ); } } class _ServicesPanel extends StatelessWidget { final DoctorSnapshot snapshot; const _ServicesPanel({required this.snapshot}); @override Widget build(BuildContext context) { final theme = Theme.of(context); if (snapshot.services.isEmpty) { return FaiCard( child: Row( children: [ Icon( Icons.dns_outlined, size: 18, color: theme.colorScheme.onSurfaceVariant, ), const SizedBox(width: FaiSpace.sm), Text( 'No host services declared', style: theme.textTheme.bodyMedium?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), const Spacer(), Text( 'add to ~/.fai/config.yaml under services:', style: FaiTheme.mono( size: 11, color: theme.colorScheme.onSurfaceVariant, ), ), ], ), ); } return FaiCard( padding: EdgeInsets.zero, child: Column( children: [ for (var i = 0; i < snapshot.services.length; i++) ...[ if (i > 0) Divider( height: 1, color: theme.colorScheme.outlineVariant, ), Padding( padding: const EdgeInsets.all(FaiSpace.lg), child: Row( children: [ Icon( Icons.dns_outlined, size: 16, color: theme.colorScheme.primary, ), const SizedBox(width: FaiSpace.sm), Text( snapshot.services[i].name, style: theme.textTheme.titleSmall?.copyWith( fontWeight: FontWeight.w600, ), ), const SizedBox(width: FaiSpace.md), Text( snapshot.services[i].endpoint, style: FaiTheme.mono( size: 11, color: theme.colorScheme.onSurfaceVariant, ), ), const Spacer(), for (final tag in snapshot.services[i].tags) ...[ FaiPill(label: tag, tone: FaiPillTone.neutral), const SizedBox(width: FaiSpace.xs), ], ], ), ), ], ], ), ); } } class _UpdateBanner extends StatelessWidget { final UpdateStatus status; const _UpdateBanner({required this.status}); @override Widget build(BuildContext context) { final theme = Theme.of(context); final available = status.updateAvailable; final iconData = available ? Icons.system_update_alt_outlined : Icons.cloud_off_outlined; final iconColor = available ? theme.colorScheme.primary : theme.colorScheme.onSurfaceVariant; final title = available ? 'Update available — ${status.latestVersion}' : 'Release host unreachable'; final body = available ? 'Channel ${status.channel} now offers ${status.latestVersion}. ' 'Apply with: fai update apply --channel ${status.channel}' : (status.reason ?? 'Could not contact the release feed for ${status.channel}.'); return FaiCard( child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Icon(iconData, size: 20, color: iconColor), const SizedBox(width: FaiSpace.md), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( title, style: theme.textTheme.titleSmall?.copyWith( fontWeight: FontWeight.w600, ), ), const SizedBox(height: 2), Text( body, style: FaiTheme.mono( size: 11, color: theme.colorScheme.onSurfaceVariant, ), ), if (available && status.releaseNotesUrl != null) ...[ const SizedBox(height: 4), Text( 'notes: ${status.releaseNotesUrl}', style: FaiTheme.mono( size: 11, color: theme.colorScheme.primary, ), ), ], ], ), ), FaiPill( label: available ? 'new' : 'offline', tone: available ? FaiPillTone.success : FaiPillTone.neutral, ), ], ), ); } }