feat(doctor): update banner via HubAdmin.CheckUpdate (v0.9.0)

Doctor page now opens with a slim banner above the summary strip
in two cases: an actually newer version on the channel ("Update
available — v0.x.y", success-tone "new" pill, release-notes URL
when present) or the release host being unreachable
("Release host unreachable", neutral "offline" pill, with the
network reason). When local matches latest the banner stays
hidden — quiet UI is the right default.

Hooks into the existing parallel-fetch in `HubService.doctor` so
the new RPC adds zero latency to the page load.

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
flemming-it 2026-05-06 15:53:52 +02:00
parent 753db4fc60
commit 18db6ff164
5 changed files with 113 additions and 3 deletions

View file

@ -59,9 +59,14 @@ class _DoctorPageState extends State<DoctorPage> {
);
}
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(
@ -431,3 +436,72 @@ class _ServicesPanel extends StatelessWidget {
);
}
}
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,
),
],
),
);
}
}