Two reports:
1. "In Diagnose ist viel nicht übersetzt, wie pending, chain
etc." The Doctor summary strip (Modules / Approvals / Audit
/ Services tiles) and the modules panel still rendered
English wire labels: "pending", "chain", "declared",
"loaded", "empty", "attention", "No pending approvals",
"No host services declared", and the matching "{n} modules
· {m} capabilities" / "{n} approvals awaiting review"
strings.
Fix: 13 new ARB keys covering the summary tiles, modules
panel, and services panel; doctor.dart now reads them via
AppLocalizations. German operators no longer see English
labels on Diagnose.
2. "Im Light mode ist der kontrast zur überschrift falsch,
store ist weiß auf weiß." The AppBar title rendered
white-on-white in light mode.
Cause: `appBarTheme.titleTextStyle = textTheme.headlineSmall`
passed a TextStyle built fresh from GoogleFonts.inter(...)
with `color: null`. Material's "merge foregroundColor at
draw time" path didn't always populate it — depended on
build configuration. The colour fell through to whatever
the surrounding DefaultTextStyle had, which on the
light-mode AppBar was the surface colour.
Fix: bake the foreground colour straight into the title
style via `copyWith(color: scheme.onSurface)`. Also pin
`iconTheme: IconThemeData(color: scheme.onSurface)` so
actions-row icons get the same treatment defensively.
Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
919 lines
29 KiB
Dart
919 lines
29 KiB
Dart
import 'package:flutter/material.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';
|
|
|
|
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.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)!;
|
|
final entries = <(String, String, IconData)>[
|
|
(l.doctorPathLog, paths.logPath, Icons.description_outlined),
|
|
(l.doctorPathConfig, paths.configPath, Icons.settings_outlined),
|
|
(l.doctorPathDb, paths.dbPath, Icons.storage_outlined),
|
|
(l.doctorPathModules, paths.modulesDir, Icons.extension_outlined),
|
|
(l.doctorPathFlows, paths.flowsDir, Icons.account_tree_outlined),
|
|
(l.doctorPathPid, paths.pidPath, Icons.fingerprint),
|
|
].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),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _PathRow extends StatelessWidget {
|
|
final String label;
|
|
final String path;
|
|
final IconData icon;
|
|
|
|
const _PathRow({required this.label, required this.path, required this.icon});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
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),
|
|
OutlinedButton.icon(
|
|
onPressed: () async {
|
|
final r = await SystemActions.openInOs(path);
|
|
if (!context.mounted) return;
|
|
if (!r.ok) {
|
|
final l = AppLocalizations.of(context)!;
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text(l.doctorOpenError(r.stderr))),
|
|
);
|
|
}
|
|
},
|
|
icon: const Icon(Icons.open_in_new, size: 14),
|
|
label: Text(AppLocalizations.of(context)!.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;
|
|
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;
|
|
setState(() {
|
|
_busy = false;
|
|
_output = (r.ok
|
|
? 'OK · $label\n${r.stdout}'
|
|
: 'Failed · $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(
|
|
'daemon restart',
|
|
() => 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(
|
|
'daemon start',
|
|
() => SystemActions.faiDaemon(['start']),
|
|
),
|
|
icon: const Icon(Icons.play_arrow, size: 16),
|
|
label: Text(l.doctorStart),
|
|
),
|
|
OutlinedButton.icon(
|
|
onPressed: _busy
|
|
? null
|
|
: () => _run(
|
|
'daemon stop',
|
|
() => SystemActions.faiDaemon(['stop']),
|
|
),
|
|
icon: const Icon(Icons.stop_circle_outlined, size: 16),
|
|
label: Text(l.doctorStop),
|
|
),
|
|
OutlinedButton.icon(
|
|
onPressed: _busy
|
|
? null
|
|
: () => _run(
|
|
'daemon status',
|
|
() => 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 (_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,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
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),
|
|
Text(
|
|
l.doctorModulesPanelSummary(
|
|
snapshot.moduleCount,
|
|
snapshot.capabilityCount,
|
|
),
|
|
style: theme.textTheme.bodyMedium,
|
|
),
|
|
const Spacer(),
|
|
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) {
|
|
return FaiCard(
|
|
child: Row(
|
|
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,
|
|
),
|
|
),
|
|
const Spacer(),
|
|
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),
|
|
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(
|
|
_applyOutput!,
|
|
style: FaiTheme.mono(
|
|
size: 11,
|
|
color: theme.colorScheme.onSurface,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|