chain-studio/lib/pages/doctor.dart
flemming-it b35bbc1b12 fix(studio): readable logs + in-app config.yaml viewer
- Strip ANSI / control characters when reading log files so daemon
  logs that carry colour escape codes render clean instead of as
  garbled special characters (the source side is fixed in the hub
  too; this is the defensive belt for existing files).
- Generalise the file viewer (read top-down + plain mode) and add an
  in-Studio "View" affordance for config.yaml / .yml / .toml on the
  Doctor paths panel, so config is readable without leaving Studio.

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
2026-06-11 23:52:40 +02:00

1017 lines
33 KiB
Dart

import 'package:flutter/material.dart';
import '../data/error_presentation.dart';
import '../data/fai_log.dart';
import '../data/hub.dart';
import '../data/system_actions.dart';
import '../l10n/app_localizations.dart';
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<DoctorPage> createState() => _DoctorPageState();
}
class _DoctorPageState extends State<DoctorPage> {
late Future<DoctorSnapshot> _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: FaiSpace.sm),
],
),
body: FutureBuilder<DoctorSnapshot>(
future: _future,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError) {
final l = AppLocalizations.of(context)!;
return FaiEmptyState(
icon: Icons.cloud_off_outlined,
iconColor: Theme.of(context).colorScheme.error,
title: l.hubUnreachable,
hint: l.hubUnreachableHint,
action: FilledButton.tonal(
onPressed: _refresh,
child: Text(l.buttonRetry),
),
);
}
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: AppLocalizations.of(context)!.doctorEventLogSection,
child: _EventLogPanel(snapshot: s, onRefresh: _refresh),
),
const SizedBox(height: FaiSpace.lg),
_Section(
title: AppLocalizations.of(
context,
)!.doctorModulesApprovalsSection,
child: _ModulesPanel(snapshot: s),
),
const SizedBox(height: FaiSpace.lg),
_Section(
title: AppLocalizations.of(context)!.doctorHostServicesSection,
child: _ServicesPanel(snapshot: s),
),
const SizedBox(height: FaiSpace.lg),
_Section(
title: AppLocalizations.of(context)!.doctorDaemonFilesSection,
child: _DaemonPathsPanel(paths: s.paths),
),
const SizedBox(height: FaiSpace.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: 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) {
final l = AppLocalizations.of(context)!;
return Row(
children: [
_StatTile(
label: l.doctorSummaryModules,
value: snapshot.moduleCount.toString(),
subtitle: l.doctorSummaryCapabilities(snapshot.capabilityCount),
icon: Icons.extension_outlined,
),
const SizedBox(width: FaiSpace.md),
_StatTile(
label: l.doctorSummaryApprovals,
value: snapshot.pendingApprovals.toString(),
subtitle: l.doctorSummaryPending,
icon: Icons.inbox_outlined,
tone: snapshot.pendingApprovals > 0
? FaiPillTone.warning
: FaiPillTone.neutral,
),
const SizedBox(width: FaiSpace.md),
_StatTile(
label: l.doctorSummaryAudit,
value: snapshot.chainHealthy ? '' : '',
subtitle: l.doctorSummaryChain(
snapshot.eventChainVerified,
snapshot.eventChainTotal,
),
icon: Icons.shield_outlined,
tone: snapshot.chainHealthy
? FaiPillTone.success
: FaiPillTone.danger,
),
const SizedBox(width: FaiSpace.md),
_StatTile(
label: l.doctorSummaryServices,
value: snapshot.services.length.toString(),
subtitle: l.doctorSummaryDeclared,
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;
/// 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;
const _EventLogPanel({required this.snapshot, required this.onRefresh});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final healthy = snapshot.chainHealthy;
final l = AppLocalizations.of(context)!;
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 ? 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,
),
),
],
),
),
OutlinedButton.icon(
onPressed: onRefresh,
icon: const Icon(Icons.fact_check_outlined, size: 16),
label: Text(l.doctorVerifyNow),
),
const SizedBox(width: FaiSpace.sm),
FaiPill(
label: healthy ? l.doctorChainPillOk : l.doctorChainPillTamper,
tone: healthy ? FaiPillTone.success : FaiPillTone.danger,
),
],
),
);
}
}
/// 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,
FaiLog.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 FaiCard(
child: Text(
l.doctorPathsEmpty,
style: Theme.of(context).textTheme.bodySmall,
),
);
}
return FaiCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
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: FaiSpace.sm),
SizedBox(
width: 90,
child: Text(
label,
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
Expanded(
child: SelectableText(
path,
style: FaiTheme.mono(
size: 11,
color: theme.colorScheme.onSurface,
),
),
),
const SizedBox(width: FaiSpace.sm),
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: FaiSpace.xs),
],
OutlinedButton.icon(
onPressed: () async {
final r = await SystemActions.openOrReveal(
path,
isDirectory: isDirectory,
);
if (!context.mounted) return;
if (!r.ok) {
showFaiErrorSnack(
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
/// `~/.fai/bin/fai`). 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 [FaiBinaryRecovery] block.
bool _binaryMissing = false;
ChannelStatusSnapshot? _channels;
@override
void initState() {
super.initState();
_refreshStatus();
}
Future<void> _refreshStatus() async {
try {
final snap = await HubService.instance.channelStatus();
if (!mounted) return;
setState(() => _channels = snap);
} catch (_) {
// Best-effort; the status pill simply renders "unknown".
}
}
Future<void> _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 FaiCard(
// 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
? FaiColors.success
: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(width: FaiSpace.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: FaiSpace.sm),
FaiPill(
label: active.running
? l.doctorPillRunning
: l.doctorPillStopped,
tone: active.running
? FaiPillTone.success
: FaiPillTone.neutral,
),
],
],
),
const SizedBox(height: FaiSpace.sm),
Text(
l.doctorDaemonControlHint,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: FaiSpace.md),
Wrap(
spacing: FaiSpace.sm,
runSpacing: FaiSpace.sm,
children: [
FilledButton.tonalIcon(
onPressed: _busy
? null
: () => _run(
l.daemonActionRestart,
() => SystemActions.faiDaemon(['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.faiDaemon(['start']),
),
icon: const Icon(Icons.play_arrow, size: 16),
label: Text(l.doctorStart),
),
OutlinedButton.icon(
onPressed: _busy
? null
: () => _run(
l.daemonActionStop,
() => SystemActions.faiDaemon(['stop']),
),
icon: const Icon(Icons.stop_circle_outlined, size: 16),
label: Text(l.doctorStop),
),
OutlinedButton.icon(
onPressed: _busy
? null
: () => _run(
l.daemonActionStatus,
() => SystemActions.faiDaemon(['status']),
),
icon: const Icon(Icons.health_and_safety_outlined, size: 16),
label: Text(l.doctorStatusAction),
),
],
),
if (_busy) ...[
const SizedBox(height: FaiSpace.md),
Row(
children: [
const SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(strokeWidth: 2),
),
const SizedBox(width: FaiSpace.sm),
Text(
l.doctorDaemonControlWorking,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
],
if (_binaryMissing) ...[
const SizedBox(height: FaiSpace.md),
FaiBinaryRecovery(
onLocated: () => _run(
l.daemonActionStatus,
() => SystemActions.faiDaemon(['status']),
),
),
],
if (_output != null) ...[
const SizedBox(height: FaiSpace.md),
Container(
width: double.infinity,
padding: const EdgeInsets.all(FaiSpace.sm),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(FaiRadius.sm),
border: Border.all(color: theme.colorScheme.outlineVariant),
),
child: SelectableText(
_output!,
style: FaiTheme.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.
String _sourceBreakdown(Map<String, int> bySource) {
final entries = bySource.entries.toList()
..sort((a, b) => a.key.compareTo(b.key));
return entries.map((e) => '${e.value} ${e.key}').join(' · ');
}
class _ModulesPanel extends StatelessWidget {
final DoctorSnapshot snapshot;
const _ModulesPanel({required this.snapshot});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l = AppLocalizations.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),
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,
),
),
],
],
),
),
FaiPill(
label: snapshot.moduleCount > 0
? l.doctorPillLoaded
: l.doctorPillEmpty,
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
? l.doctorApprovalsNone
: l.doctorApprovalsCount(snapshot.pendingApprovals),
style: theme.textTheme.bodyMedium,
),
const Spacer(),
if (snapshot.pendingApprovals > 0)
FaiPill(
label: l.doctorApprovalsAttentionPill,
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);
final l = AppLocalizations.of(context)!;
if (snapshot.services.isEmpty) {
// Wrap on narrow windows so the mono-spaced hint
// (`add to ~/.fai/config.yaml under services:`) does not
// overflow horizontally past the card's right edge.
return FaiCard(
child: Wrap(
spacing: FaiSpace.sm,
runSpacing: FaiSpace.xs,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.dns_outlined,
size: 18,
color: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(width: FaiSpace.sm),
Text(
l.doctorServicesEmpty,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
Text(
l.doctorServicesEmptyHint,
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 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<void> _applyUpdate() async {
setState(() {
_applying = true;
_applyOutput = null;
});
final r = await SystemActions.faiUpdateApply(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 FaiCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
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(
l.doctorReleaseNotes(status.releaseNotesUrl!),
style: FaiTheme.mono(
size: 11,
color: 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: FaiSpace.sm),
FaiPill(
label: available ? l.doctorPillNew : l.doctorPillOffline,
tone: available ? FaiPillTone.success : FaiPillTone.neutral,
),
],
),
if (_applyOutput != null) ...[
const SizedBox(height: FaiSpace.md),
FaiErrorBox(text: _applyOutput!, maxHeight: 240),
],
],
),
);
}
}