import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import '../data/error_presentation.dart'; import '../data/chain_log.dart'; import '../data/hub.dart'; import '../data/hub_auth_token.dart'; import '../data/system_actions.dart'; import '../l10n/app_localizations.dart'; import '../main.dart' show StudioShellState; import '../theme/theme.dart'; import '../theme/tokens.dart'; import '../widgets/widgets.dart'; import 'welcome.dart' show showFaiDoc; 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: Text(AppLocalizations.of(context)!.doctorTitle), actions: [ IconButton( icon: const Icon(Icons.help_outline, size: 18), tooltip: AppLocalizations.of(context)!.helpTooltip, onPressed: () => showFaiDoc(context, 'architecture'), ), IconButton( icon: const Icon(Icons.refresh, size: 18), tooltip: AppLocalizations.of(context)!.doctorRecheckTooltip, onPressed: _refresh, ), const SizedBox(width: ChainSpace.sm), ], ), body: FutureBuilder( future: _future, builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState.waiting) { return const Center(child: CircularProgressIndicator()); } if (snapshot.hasError) { return HubLoadErrorView(error: snapshot.error!, onRetry: _refresh); } final s = snapshot.data!; final showUpdate = s.update.updateAvailable || (!s.update.manifestReachable && s.update.localVersion.isNotEmpty); // Findings that have a dedicated page link straight to it — // "approvals waiting for review" must be one tap away from // the approvals inbox, not a dead-end statement. final shell = StudioShellState.of(context); final openStore = shell == null ? null : () => shell.navigateTo('store'); final openApprovals = shell == null ? null : () => shell.navigateTo('approvals'); final openAudit = shell == null ? null : () => shell.navigateTo('audit'); return ListView( padding: const EdgeInsets.all(ChainSpace.xl), children: [ if (showUpdate) _UpdateBanner(status: s.update), if (showUpdate) const SizedBox(height: ChainSpace.lg), _SummaryStrip( snapshot: s, onOpenStore: openStore, onOpenApprovals: openApprovals, onOpenAudit: openAudit, ), const SizedBox(height: ChainSpace.xl), _Section( title: AppLocalizations.of(context)!.doctorEventLogSection, child: _EventLogPanel( snapshot: s, onRefresh: _refresh, onOpenAudit: openAudit, ), ), const SizedBox(height: ChainSpace.lg), _Section( title: AppLocalizations.of( context, )!.doctorModulesApprovalsSection, child: DoctorModulesPanel( snapshot: s, onOpenStore: openStore, onOpenApprovals: openApprovals, ), ), const SizedBox(height: ChainSpace.lg), _Section( title: AppLocalizations.of(context)!.doctorHostServicesSection, child: _ServicesPanel(snapshot: s), ), const SizedBox(height: ChainSpace.lg), _Section( title: AppLocalizations.of(context)!.doctorDaemonFilesSection, child: _DaemonPathsPanel(paths: s.paths), ), const SizedBox(height: ChainSpace.lg), _Section( title: AppLocalizations.of(context)!.doctorDaemonControlSection, child: _DaemonActionsCard(), ), ], ); }, ), ); } } 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: ChainSpace.xs, bottom: ChainSpace.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; final VoidCallback? onOpenStore; final VoidCallback? onOpenApprovals; final VoidCallback? onOpenAudit; const _SummaryStrip({ required this.snapshot, this.onOpenStore, this.onOpenApprovals, this.onOpenAudit, }); @override Widget build(BuildContext context) { final l = AppLocalizations.of(context)!; return Row( children: [ DoctorStatTile( label: l.doctorSummaryModules, value: snapshot.moduleCount.toString(), subtitle: l.doctorSummaryCapabilities(snapshot.capabilityCount), icon: Icons.extension_outlined, onTap: onOpenStore, linkLabel: l.doctorLinkStore, ), const SizedBox(width: ChainSpace.md), DoctorStatTile( label: l.doctorSummaryApprovals, value: snapshot.pendingApprovals.toString(), subtitle: l.doctorSummaryPending, icon: Icons.inbox_outlined, tone: snapshot.pendingApprovals > 0 ? ChainPillTone.warning : ChainPillTone.neutral, onTap: onOpenApprovals, linkLabel: l.doctorLinkApprovals, ), const SizedBox(width: ChainSpace.md), DoctorStatTile( label: l.doctorSummaryAudit, value: snapshot.chainHealthy ? '✓' : '⚠', subtitle: l.doctorSummaryChain( snapshot.eventChainVerified, snapshot.eventChainTotal, ), icon: Icons.shield_outlined, tone: snapshot.chainHealthy ? ChainPillTone.success : ChainPillTone.danger, onTap: onOpenAudit, linkLabel: l.doctorLinkAudit, ), const SizedBox(width: ChainSpace.md), DoctorStatTile( label: l.doctorSummaryServices, value: snapshot.services.length.toString(), subtitle: l.doctorSummaryDeclared, icon: Icons.dns_outlined, ), ], ); } } /// One summary tile of the doctor page. Public + callback-driven so /// the deep-link widget test can pump it directly: when [onTap] is /// set, the whole tile becomes a button (chevron affordance, /// tooltip + semantics from [linkLabel]) that jumps to the page /// where the finding can be acted on. class DoctorStatTile extends StatelessWidget { final String label; final String value; final String subtitle; final IconData icon; final ChainPillTone tone; final VoidCallback? onTap; /// Human-readable tap target ("Open approvals"). Required when /// [onTap] is set; doubles as tooltip and semantics label. final String? linkLabel; const DoctorStatTile({ super.key, required this.label, required this.value, required this.subtitle, required this.icon, this.tone = ChainPillTone.neutral, this.onTap, this.linkLabel, }); @override Widget build(BuildContext context) { final theme = Theme.of(context); final accentColor = switch (tone) { ChainPillTone.success => ChainColors.success, ChainPillTone.warning => ChainColors.warning, ChainPillTone.danger => theme.colorScheme.error, _ => theme.colorScheme.primary, }; Widget card = ChainCard( accentLeft: accentColor, padding: const EdgeInsets.all(ChainSpace.lg), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Icon(icon, size: 14, color: theme.colorScheme.onSurfaceVariant), const SizedBox(width: ChainSpace.xs), Flexible( child: Text( label.toUpperCase(), overflow: TextOverflow.ellipsis, style: theme.textTheme.labelSmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, letterSpacing: 0.5, fontSize: 10, ), ), ), if (onTap != null) ...[ const Spacer(), Icon( Icons.chevron_right, size: 16, color: theme.colorScheme.onSurfaceVariant, ), ], ], ), const SizedBox(height: ChainSpace.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, ), ), ], ), ); if (onTap != null) { // ChainCard is a plain Container, so the InkWell needs its own // transparent Material to paint the hover/ripple feedback. card = Tooltip( message: linkLabel ?? '', waitDuration: const Duration(milliseconds: 400), child: Material( color: Colors.transparent, child: InkWell( onTap: onTap, borderRadius: BorderRadius.circular(ChainRadius.md), child: Semantics(button: true, label: linkLabel, child: card), ), ), ); } return Expanded(child: card); } } class _EventLogPanel extends StatelessWidget { final DoctorSnapshot snapshot; /// Re-runs the doctor() call which re-verifies the chain. /// Wired to a "Verify now" button so operators can re-check /// after import / restore without restarting the daemon. final VoidCallback onRefresh; /// Jumps to the audit page — the finding's detail view (per-event /// list, forensic exports) lives there. final VoidCallback? onOpenAudit; const _EventLogPanel({ required this.snapshot, required this.onRefresh, this.onOpenAudit, }); @override Widget build(BuildContext context) { final theme = Theme.of(context); final healthy = snapshot.chainHealthy; final l = AppLocalizations.of(context)!; Widget headline = Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( healthy ? l.doctorChainIntact : l.doctorChainTampered, style: theme.textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w600, ), ), const SizedBox(height: 2), Text( healthy ? l.doctorChainIntactDetail(snapshot.eventChainTotal) : l.doctorChainTamperedDetail( snapshot.eventChainVerified, snapshot.eventChainTotal, snapshot.eventChainTamperedAt ?? '', ), style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), ], ); if (onOpenAudit != null) { headline = Tooltip( message: l.doctorLinkAudit, waitDuration: const Duration(milliseconds: 400), child: Material( color: Colors.transparent, child: InkWell( onTap: onOpenAudit, borderRadius: BorderRadius.circular(ChainRadius.sm), child: Semantics(button: true, label: l.doctorLinkAudit, child: headline), ), ), ); } return ChainCard( child: Row( children: [ Icon( healthy ? Icons.verified_outlined : Icons.gpp_bad_outlined, size: 24, color: healthy ? ChainColors.success : theme.colorScheme.error, ), const SizedBox(width: ChainSpace.lg), Expanded(child: headline), OutlinedButton.icon( onPressed: onRefresh, icon: const Icon(Icons.fact_check_outlined, size: 16), label: Text(l.doctorVerifyNow), ), const SizedBox(width: ChainSpace.sm), ChainPill( label: healthy ? l.doctorChainPillOk : l.doctorChainPillTamper, tone: healthy ? ChainPillTone.success : ChainPillTone.danger, ), ], ), ); } } /// True when [path] points into an OS-managed temporary /// directory (macOS `/var/folders`, Unix `/tmp`, the Windows /// user/system temp folders) — locations the OS may clean up /// at any time. The doctor page warns when the audit DB lives /// in one: an evidence log the OS can silently delete is not /// evidence. Top-level so the unit test can exercise the /// classification directly. bool isVolatilePath(String path) { if (path.isEmpty) return false; final p = path.toLowerCase().replaceAll('\\', '/'); return p.startsWith('/tmp/') || p.startsWith('/private/tmp/') || p.startsWith('/var/folders/') || p.startsWith('/private/var/folders/') || p.contains('/appdata/local/temp/') || p.contains('/windows/temp/'); } /// Lists the daemon's filesystem paths with one "Open" button /// per row. Windows operators who never touch a shell still /// need a way to inspect the audit DB or edit the operator /// config; this panel is that escape hatch. class _DaemonPathsPanel extends StatelessWidget { final DaemonPathsSnapshot paths; const _DaemonPathsPanel({required this.paths}); @override Widget build(BuildContext context) { final l = AppLocalizations.of(context)!; // Per-row layout: (label, path, icon, isDirectory). The // isDirectory bit drives `openOrReveal` so binary files // (the SQLite audit DB, the PID file) reveal in Finder / // Explorer instead of failing the OS "open" handler. final entries = <(String, String, IconData, bool)>[ (l.doctorPathLog, paths.logPath, Icons.description_outlined, false), ( l.doctorPathStudioErrors, ChainLog.instance.path, Icons.report_problem_outlined, false, ), (l.doctorPathConfig, paths.configPath, Icons.settings_outlined, false), (l.doctorPathDb, paths.dbPath, Icons.storage_outlined, false), (l.doctorPathModules, paths.modulesDir, Icons.extension_outlined, true), (l.doctorPathFlows, paths.flowsDir, Icons.account_tree_outlined, true), (l.doctorPathPid, paths.pidPath, Icons.fingerprint, false), ].where((e) => e.$2.isNotEmpty).toList(); if (entries.isEmpty) { return ChainCard( child: Text( l.doctorPathsEmpty, style: Theme.of(context).textTheme.bodySmall, ), ); } final theme = Theme.of(context); return ChainCard( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (isVolatilePath(paths.dbPath)) ...[ Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Icon( Icons.warning_amber_outlined, size: 16, color: ChainColors.warning, ), const SizedBox(width: ChainSpace.sm), Expanded( child: Text( l.doctorDbVolatileWarning, style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), ), ], ), const SizedBox(height: ChainSpace.sm), ], for (final e in entries) _PathRow(label: e.$1, path: e.$2, icon: e.$3, isDirectory: e.$4), ], ), ); } } class _PathRow extends StatelessWidget { final String label; final String path; final IconData icon; final bool isDirectory; const _PathRow({ required this.label, required this.path, required this.icon, required this.isDirectory, }); @override Widget build(BuildContext context) { final theme = Theme.of(context); final l = AppLocalizations.of(context)!; final lower = path.toLowerCase(); final isLog = !isDirectory && lower.endsWith('.log'); // Text config files get an in-Studio viewer too (read top-down, // no log colouring) so the operator can read config.yaml // without leaving Studio or hunting for an external editor. final isConfig = !isDirectory && (lower.endsWith('.yaml') || lower.endsWith('.yml') || lower.endsWith('.toml')); return Padding( padding: const EdgeInsets.symmetric(vertical: 4), child: Row( children: [ Icon(icon, size: 16, color: theme.colorScheme.onSurfaceVariant), const SizedBox(width: ChainSpace.sm), SizedBox( width: 90, child: Text( label, style: theme.textTheme.labelSmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), ), Expanded( child: SelectableText( path, style: ChainTheme.mono( size: 11, color: theme.colorScheme.onSurface, ), ), ), const SizedBox(width: ChainSpace.sm), IconButton( icon: const Icon(Icons.copy_outlined, size: 14), tooltip: l.buttonCopy, visualDensity: VisualDensity.compact, onPressed: () async { await Clipboard.setData(ClipboardData(text: path)); if (!context.mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(l.aboutCopiedToast)), ); }, ), const SizedBox(width: ChainSpace.xs), if (isLog || isConfig) ...[ OutlinedButton.icon( onPressed: () => isConfig ? showFaiConfigViewer(context, path: path, title: label) : showFaiLogViewer(context, path: path, title: label), icon: const Icon(Icons.visibility_outlined, size: 14), label: Text(l.buttonView), style: OutlinedButton.styleFrom( visualDensity: VisualDensity.compact, ), ), const SizedBox(width: ChainSpace.xs), ], OutlinedButton.icon( onPressed: () async { final r = await SystemActions.openOrReveal( path, isDirectory: isDirectory, ); if (!context.mounted) return; if (!r.ok) { showChainErrorSnack( context, 'doctor.open-path', r.stderr.isEmpty ? 'open failed' : r.stderr, ); } }, icon: Icon( isDirectory ? Icons.folder_open : Icons.open_in_new, size: 14, ), label: Text(l.buttonOpen), style: OutlinedButton.styleFrom( visualDensity: VisualDensity.compact, ), ), ], ), ); } } /// Daemon control affordances: Restart, Apply update, Stop. /// Each shells out to the `fai` binary (resolved via PATH or /// `~/.chain/bin/chain`). Studio is the GUI shell; the binary owns /// the supervisor logic so the daemon and CLI stay in lockstep. class _DaemonActionsCard extends StatefulWidget { @override State<_DaemonActionsCard> createState() => _DaemonActionsCardState(); } class _DaemonActionsCardState extends State<_DaemonActionsCard> { bool _busy = false; String? _output; /// True when the last daemon action failed because no `fai` /// binary could be located. Swaps the raw output line for the /// actionable [ChainBinaryRecovery] block. bool _binaryMissing = false; ChannelStatusSnapshot? _channels; /// Trimmed length of `~/.chain/hub-auth-token`, or null when /// Studio talks to the hub anonymously. Length only — the /// secret itself never reaches the UI. int? _tokenChars; @override void initState() { super.initState(); _refreshStatus(); HubAuthToken.charCount() .then((n) { if (mounted) setState(() => _tokenChars = n); }) .catchError((_) {}); } Future _refreshStatus() async { try { final snap = await HubService.instance.channelStatus(); if (!mounted) return; setState(() => _channels = snap); } catch (_) { // Best-effort; the status pill simply renders "unknown". } } String _connectionLine(AppLocalizations l) { final ep = HubService.instance.currentEndpoint; final isLocal = ep.host == '127.0.0.1' || ep.host == 'localhost' || ep.host == '::1'; final transport = ep.secure ? l.doctorConnTls : isLocal ? l.doctorConnPlainLocal : l.doctorConnPlainRemote; final auth = _tokenChars == null ? l.doctorConnAnonymous : l.doctorConnToken(_tokenChars!); return l.doctorConnLine(ep.toString(), transport, auth); } Future _run( String label, Future<({bool ok, String stdout, String stderr})> Function() action, ) async { setState(() { _busy = true; _output = null; }); final r = await action(); if (!mounted) return; final l = AppLocalizations.of(context)!; final binaryMissing = !r.ok && r.stderr.trim() == kFaiBinaryNotFound; setState(() { _busy = false; _binaryMissing = binaryMissing; // When the binary is missing we render the actionable // recovery block instead of a raw output line — the // sentinel is not a user-facing string. _output = binaryMissing ? null : (r.ok ? '${l.daemonActionResultOk(label)}\n${r.stdout}' : '${l.daemonActionResultFailed(label)}\n${r.stderr.isEmpty ? r.stdout : r.stderr}') .trim(); }); // Refresh the status pill — restart / stop / start all // change the running flag. await _refreshStatus(); } @override Widget build(BuildContext context) { final theme = Theme.of(context); final l = AppLocalizations.of(context)!; ChannelInfo? active; if (_channels != null) { for (final c in _channels!.channels) { if (c.name == _channels!.active) { active = c; break; } } } return ChainCard( // Row + Expanded forces the card to take the full width // its parent offers. Without this, the card hugs the // intrinsic width of the longest button row and ends up // visibly narrower than its siblings. child: SizedBox( width: double.infinity, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Icon( active?.running == true ? Icons.check_circle_outline : Icons.power_off_outlined, size: 18, color: active?.running == true ? ChainColors.success : theme.colorScheme.onSurfaceVariant, ), const SizedBox(width: ChainSpace.sm), Expanded( child: Text( active == null ? l.doctorStatusUnknown : active.running ? l.doctorRunningOn(active.name, active.endpoint) : l.doctorStoppedOn(active.name), style: theme.textTheme.bodyMedium?.copyWith( fontWeight: FontWeight.w500, ), overflow: TextOverflow.ellipsis, ), ), if (active != null) ...[ const SizedBox(width: ChainSpace.sm), ChainPill( label: active.running ? l.doctorPillRunning : l.doctorPillStopped, tone: active.running ? ChainPillTone.success : ChainPillTone.neutral, ), ], ], ), const SizedBox(height: ChainSpace.sm), Text( l.doctorDaemonControlHint, style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), const SizedBox(height: 2), // Who-talks-to-whom-how in one line: endpoint, // transport security, and whether a bearer token is // attached — the auditor's baseline questions the // green "connected" dot alone cannot answer. SelectableText( _connectionLine(l), style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), const SizedBox(height: ChainSpace.md), Wrap( spacing: ChainSpace.sm, runSpacing: ChainSpace.sm, children: [ FilledButton.tonalIcon( onPressed: _busy ? null : () => _run( l.daemonActionRestart, () => SystemActions.chainDaemon(['restart']), ), icon: const Icon(Icons.restart_alt, size: 16), label: Text(l.doctorRestart), ), if (active?.running != true) FilledButton.icon( onPressed: _busy ? null : () => _run( l.daemonActionStart, () => SystemActions.chainDaemon(['start']), ), icon: const Icon(Icons.play_arrow, size: 16), label: Text(l.doctorStart), ), OutlinedButton.icon( onPressed: _busy ? null : () => _run( l.daemonActionStop, () => SystemActions.chainDaemon(['stop']), ), icon: const Icon(Icons.stop_circle_outlined, size: 16), label: Text(l.doctorStop), ), OutlinedButton.icon( onPressed: _busy ? null : () => _run( l.daemonActionStatus, () => SystemActions.chainDaemon(['status']), ), icon: const Icon(Icons.health_and_safety_outlined, size: 16), label: Text(l.doctorStatusAction), ), ], ), if (_busy) ...[ const SizedBox(height: ChainSpace.md), Row( children: [ const SizedBox( width: 14, height: 14, child: CircularProgressIndicator(strokeWidth: 2), ), const SizedBox(width: ChainSpace.sm), Text( l.doctorDaemonControlWorking, style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), ], ), ], if (_binaryMissing) ...[ const SizedBox(height: ChainSpace.md), ChainBinaryRecovery( onLocated: () => _run( l.daemonActionStatus, () => SystemActions.chainDaemon(['status']), ), ), ], if (_output != null) ...[ const SizedBox(height: ChainSpace.md), Container( width: double.infinity, padding: const EdgeInsets.all(ChainSpace.sm), decoration: BoxDecoration( color: theme.colorScheme.surfaceContainerHigh, borderRadius: BorderRadius.circular(ChainRadius.sm), border: Border.all(color: theme.colorScheme.outlineVariant), ), child: SelectableText( _output!, style: ChainTheme.mono( size: 11, color: theme.colorScheme.onSurface, ), ), ), ], ], ), ), ); } } /// Format the per-source-kind capability count map as a /// human-readable line like "6 Bundle · 3 MCP · 1 System". /// Sorted alphabetically for deterministic rendering. The hub /// reports lowercase wire identifiers; render them as display /// names so no raw English tokens leak into the localized UI. String _sourceBreakdown(Map bySource) { final entries = bySource.entries.toList() ..sort((a, b) => a.key.compareTo(b.key)); return entries .map((e) => '${e.value} ${_sourceKindLabel(e.key)}') .join(' · '); } String _sourceKindLabel(String kind) { switch (kind) { case 'mcp': return 'MCP'; case 'bundle': return 'Bundle'; case 'system': return 'System'; default: return kind.isEmpty ? kind : kind[0].toUpperCase() + kind.substring(1); } } /// The modules + approvals findings card. Public + callback-driven /// so the deep-link widget test can pump it without a hub: each row /// that has a dedicated page is a tap target ("approvals waiting /// for review" jumps to the approvals inbox, the modules line to /// the store). class DoctorModulesPanel extends StatelessWidget { final DoctorSnapshot snapshot; final VoidCallback? onOpenStore; final VoidCallback? onOpenApprovals; const DoctorModulesPanel({ super.key, required this.snapshot, this.onOpenStore, this.onOpenApprovals, }); Widget _linkRow( BuildContext context, { required Widget child, required VoidCallback? onTap, required String linkLabel, }) { if (onTap == null) return child; return Tooltip( message: linkLabel, waitDuration: const Duration(milliseconds: 400), child: Material( color: Colors.transparent, child: InkWell( onTap: onTap, borderRadius: BorderRadius.circular(ChainRadius.sm), child: Semantics(button: true, label: linkLabel, child: child), ), ), ); } @override Widget build(BuildContext context) { final theme = Theme.of(context); final l = AppLocalizations.of(context)!; return ChainCard( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _linkRow( context, onTap: onOpenStore, linkLabel: l.doctorLinkStore, child: Row( children: [ Icon( Icons.extension_outlined, size: 18, color: theme.colorScheme.onSurfaceVariant, ), const SizedBox(width: ChainSpace.sm), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( l.doctorModulesPanelSummary( snapshot.moduleCount, snapshot.capabilityCount, ), style: theme.textTheme.bodyMedium, ), if (snapshot.capabilitiesBySource.isNotEmpty) ...[ const SizedBox(height: 2), Text( _sourceBreakdown(snapshot.capabilitiesBySource), style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), ], ], ), ), ChainPill( label: snapshot.moduleCount > 0 ? l.doctorPillLoaded : l.doctorPillEmpty, tone: snapshot.moduleCount > 0 ? ChainPillTone.success : ChainPillTone.neutral, ), if (onOpenStore != null) ...[ const SizedBox(width: ChainSpace.xs), Icon( Icons.chevron_right, size: 16, color: theme.colorScheme.onSurfaceVariant, ), ], ], ), ), const Divider(height: ChainSpace.xl), _linkRow( context, onTap: onOpenApprovals, linkLabel: l.doctorLinkApprovals, child: Row( children: [ Icon( Icons.inbox_outlined, size: 18, color: theme.colorScheme.onSurfaceVariant, ), const SizedBox(width: ChainSpace.sm), Text( snapshot.pendingApprovals == 0 ? l.doctorApprovalsNone : l.doctorApprovalsCount(snapshot.pendingApprovals), style: theme.textTheme.bodyMedium, ), const Spacer(), if (snapshot.pendingApprovals > 0) ChainPill( label: l.doctorApprovalsAttentionPill, tone: ChainPillTone.warning, ), if (onOpenApprovals != null) ...[ const SizedBox(width: ChainSpace.xs), Icon( Icons.chevron_right, size: 16, color: theme.colorScheme.onSurfaceVariant, ), ], ], ), ), ], ), ); } } class _ServicesPanel extends StatelessWidget { final DoctorSnapshot snapshot; const _ServicesPanel({required this.snapshot}); @override Widget build(BuildContext context) { final theme = Theme.of(context); final l = AppLocalizations.of(context)!; if (snapshot.services.isEmpty) { // Wrap on narrow windows so the mono-spaced hint // (`add to ~/.chain/config.yaml under services:`) does not // overflow horizontally past the card's right edge. return ChainCard( child: Wrap( spacing: ChainSpace.sm, runSpacing: ChainSpace.xs, crossAxisAlignment: WrapCrossAlignment.center, children: [ Row( mainAxisSize: MainAxisSize.min, children: [ Icon( Icons.dns_outlined, size: 18, color: theme.colorScheme.onSurfaceVariant, ), const SizedBox(width: ChainSpace.sm), Text( l.doctorServicesEmpty, style: theme.textTheme.bodyMedium?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), ], ), Text( l.doctorServicesEmptyHint, style: ChainTheme.mono( size: 11, color: theme.colorScheme.onSurfaceVariant, ), ), // The hint names config.yaml — put the file one tap away // instead of making the operator hunt for it. if (snapshot.paths.configPath.isNotEmpty) OutlinedButton.icon( onPressed: () => showFaiConfigViewer( context, path: snapshot.paths.configPath, title: l.doctorPathConfig, ), icon: const Icon(Icons.settings_outlined, size: 14), label: Text(l.doctorLinkConfig), style: OutlinedButton.styleFrom( visualDensity: VisualDensity.compact, ), ), ], ), ); } return ChainCard( 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(ChainSpace.lg), child: Row( children: [ Icon( Icons.dns_outlined, size: 16, color: theme.colorScheme.primary, ), const SizedBox(width: ChainSpace.sm), Text( snapshot.services[i].name, style: theme.textTheme.titleSmall?.copyWith( fontWeight: FontWeight.w600, ), ), const SizedBox(width: ChainSpace.md), Text( snapshot.services[i].endpoint, style: ChainTheme.mono( size: 11, color: theme.colorScheme.onSurfaceVariant, ), ), const Spacer(), _ServiceExposurePill( exposure: snapshot.services[i].exposure, ), for (final tag in snapshot.services[i].tags) ...[ ChainPill(label: tag, tone: ChainPillTone.neutral), const SizedBox(width: ChainSpace.xs), ], ], ), ), ], ], ), ); } } /// Network-reach pill per declared service, from the hub's /// endpoint classification (`DeclaredService.exposure`). Public /// endpoints get the warning tone — a host service reachable from /// outside is the one thing an operator should notice here. /// Empty (pre-0.23 hub) renders nothing. class _ServiceExposurePill extends StatelessWidget { final String exposure; const _ServiceExposurePill({required this.exposure}); @override Widget build(BuildContext context) { final l = AppLocalizations.of(context)!; final (label, tone, hint) = switch (exposure) { 'loopback' => (l.svcExposureLoopback, ChainPillTone.neutral, ''), 'private' => (l.svcExposurePrivate, ChainPillTone.neutral, ''), 'public' => ( l.svcExposurePublic, ChainPillTone.warning, l.svcExposurePublicHint, ), 'unknown' => ( l.svcExposureUnknown, ChainPillTone.neutral, l.svcExposureUnknownHint, ), _ => ('', ChainPillTone.neutral, ''), }; if (label.isEmpty) return const SizedBox.shrink(); final pill = ChainPill(label: label, tone: tone); return Padding( padding: const EdgeInsets.only(right: ChainSpace.xs), child: hint.isEmpty ? pill : Tooltip(message: hint, child: pill), ); } } class _UpdateBanner extends StatefulWidget { final UpdateStatus status; const _UpdateBanner({required this.status}); @override State<_UpdateBanner> createState() => _UpdateBannerState(); } class _UpdateBannerState extends State<_UpdateBanner> { bool _applying = false; String? _applyOutput; Future _applyUpdate() async { setState(() { _applying = true; _applyOutput = null; }); final r = await SystemActions.chainUpdateApply(widget.status.channel); if (!mounted) return; setState(() { _applying = false; _applyOutput = r.ok ? AppLocalizations.of(context)!.doctorApplyDone : (r.stderr.isEmpty ? r.stdout : r.stderr).trim(); }); } @override Widget build(BuildContext context) { final theme = Theme.of(context); final l = AppLocalizations.of(context)!; final status = widget.status; 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 ? l.doctorUpdateAvailable(status.latestVersion) : l.doctorUpdateUnreachable; final body = available ? l.doctorUpdateBody(status.channel, status.latestVersion) : (status.reason ?? l.doctorUpdateUnreachableBody(status.channel)); return ChainCard( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Icon(iconData, size: 20, color: iconColor), const SizedBox(width: ChainSpace.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: ChainTheme.mono( size: 11, color: theme.colorScheme.onSurfaceVariant, ), ), if (available && status.releaseNotesUrl != null) ...[ const SizedBox(height: 4), // A URL the operator cannot click is a finding // without a link — open it in the browser. Tooltip( message: l.doctorLinkReleaseNotes, waitDuration: const Duration(milliseconds: 400), child: Material( color: Colors.transparent, child: InkWell( onTap: () => SystemActions.openInOs( status.releaseNotesUrl!, ), child: Semantics( button: true, label: l.doctorLinkReleaseNotes, child: Text( l.doctorReleaseNotes(status.releaseNotesUrl!), style: ChainTheme.mono( size: 11, color: theme.colorScheme.primary, ).copyWith( decoration: TextDecoration.underline, decorationColor: theme.colorScheme.primary, ), ), ), ), ), ), ], ], ), ), if (available) FilledButton.icon( onPressed: _applying ? null : _applyUpdate, icon: _applying ? const SizedBox( width: 14, height: 14, child: CircularProgressIndicator(strokeWidth: 2), ) : const Icon(Icons.system_update_alt, size: 16), label: Text( _applying ? l.doctorApplying : l.doctorApplyUpdate, ), ), const SizedBox(width: ChainSpace.sm), ChainPill( label: available ? l.doctorPillNew : l.doctorPillOffline, tone: available ? ChainPillTone.success : ChainPillTone.neutral, ), ], ), if (_applyOutput != null) ...[ const SizedBox(height: ChainSpace.md), ChainErrorBox(text: _applyOutput!, maxHeight: 240), ], ], ), ); } }