feat(studio): first-run UX, recovery affordances, l10n, bundled fonts

- Connection-aware Welcome: when the hub is down, show a hero with a
  primary "Start hub" CTA + install fallback instead of a dead,
  all-unchecked onboarding checklist (the first-run cliff).
- Actionable binary-not-found (file picker + install link, not a
  "set FAI_BIN" dead end) and a connect-failure banner after
  repeated failed health polls.
- Localize six hardcoded English error/toast clusters (DE+EN ARB).
- Bundle Inter + JetBrains Mono as assets; drop the runtime
  google_fonts fetch (air-gap / KRITIS safe, no font-swap flash).

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
flemming-it 2026-06-11 23:52:18 +02:00
parent 7511867774
commit 5313266cc4
25 changed files with 1231 additions and 234 deletions

View file

@ -35,6 +35,7 @@ Future<void> main() async {
// (when installed) the operator's theme-plugin choice
// before the first frame so nothing flickers on startup.
await HubService.instance.loadPersistedEndpoint();
await SystemActions.loadFaiBinaryOverride();
final themeMode = await HubService.instance.loadThemeMode();
final locale = await HubService.instance.loadLocale();
final themePlugin = await loadActiveThemePlugin();
@ -245,6 +246,42 @@ class StudioShellState extends State<StudioShell> {
String? _activeChannel; // local / dev / beta / production
Timer? _healthPoll;
/// Consecutive failed health polls. After [_unreachableThreshold]
/// in a row we stop showing an indefinite silent "connecting…"
/// and surface a one-line "can't reach the hub" banner with a
/// jump to Settings.
int _failedPolls = 0;
static const int _unreachableThreshold = 3;
/// Whether to render the unreachable banner. Distinct from
/// `_connected == false` so a single transient miss doesn't
/// flash the banner only sustained failure does.
bool get _hubUnreachable => _failedPolls >= _unreachableThreshold;
/// Current connection state, for descendants (e.g. WelcomePage)
/// that adapt their content to hub availability.
bool? get connected => _connected;
/// Start the local daemon via the platform binary, then surface
/// the outcome as a snackbar. Shared by the sidebar's connection
/// row and the WelcomePage hero CTA.
Future<void> startDaemon() async {
final l = AppLocalizations.of(context)!;
final r = await SystemActions.faiDaemon(['start']);
if (!mounted) return;
final detail = r.stderr.trim().isEmpty ? r.stdout.trim() : r.stderr.trim();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
r.ok ? l.daemonStartRequested : l.daemonStartFailed(detail),
),
),
);
// Probe again shortly so the UI flips to "connected" without
// waiting for the next 5 s tick.
Future.delayed(const Duration(seconds: 1), _checkHealth);
}
/// Switch the sidebar's selected page by its registered id
/// (`store`, `flows`, `audit`, etc.). Used by the welcome
/// page's onboarding checklist to make each item a real
@ -331,7 +368,17 @@ class StudioShellState extends State<StudioShell> {
Future<void> _checkHealth() async {
final ok = await HubService.instance.healthy();
if (!mounted) return;
if (_connected != ok) setState(() => _connected = ok);
final wasUnreachable = _hubUnreachable;
final nextFailed = ok ? 0 : _failedPolls + 1;
final connectionChanged = _connected != ok;
final bannerChanged =
wasUnreachable != (nextFailed >= _unreachableThreshold);
if (connectionChanged || _failedPolls != nextFailed || bannerChanged) {
setState(() {
_connected = ok;
_failedPolls = nextFailed;
});
}
if (ok) {
try {
final snap = await HubService.instance.channelStatus();
@ -454,24 +501,35 @@ class StudioShellState extends State<StudioShell> {
),
Container(width: 1, color: theme.colorScheme.outlineVariant),
Expanded(
child: AnimatedSwitcher(
duration: FaiMotion.base,
switchInCurve: FaiMotion.easing,
switchOutCurve: FaiMotion.easing,
transitionBuilder: (child, anim) => FadeTransition(
opacity: anim,
child: SlideTransition(
position: Tween<Offset>(
begin: const Offset(0, 0.02),
end: Offset.zero,
).animate(anim),
child: child,
child: Column(
children: [
if (_hubUnreachable)
_HubUnreachableBanner(
endpoint: HubService.instance.endpointLabel,
onOpenSettings: () => FaiSettingsDialog.show(context),
),
Expanded(
child: AnimatedSwitcher(
duration: FaiMotion.base,
switchInCurve: FaiMotion.easing,
switchOutCurve: FaiMotion.easing,
transitionBuilder: (child, anim) => FadeTransition(
opacity: anim,
child: SlideTransition(
position: Tween<Offset>(
begin: const Offset(0, 0.02),
end: Offset.zero,
).animate(anim),
child: child,
),
),
child: KeyedSubtree(
key: ValueKey(_selectedIndex),
child: _pages[_selectedIndex].page,
),
),
),
),
child: KeyedSubtree(
key: ValueKey(_selectedIndex),
child: _pages[_selectedIndex].page,
),
],
),
),
],
@ -483,6 +541,58 @@ class StudioShellState extends State<StudioShell> {
}
}
/// One-line banner shown when the hub has been unreachable for
/// several consecutive health polls. Replaces the indefinite
/// silent "connecting…" with an explicit message + a jump to
/// Settings, where the operator can fix the endpoint.
class _HubUnreachableBanner extends StatelessWidget {
final String endpoint;
final VoidCallback onOpenSettings;
const _HubUnreachableBanner({
required this.endpoint,
required this.onOpenSettings,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l = AppLocalizations.of(context)!;
return Material(
color: theme.colorScheme.errorContainer,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: FaiSpace.lg,
vertical: FaiSpace.sm,
),
child: Row(
children: [
Icon(
Icons.cloud_off_outlined,
size: 18,
color: theme.colorScheme.onErrorContainer,
),
const SizedBox(width: FaiSpace.sm),
Expanded(
child: Text(
l.hubUnreachableBanner(endpoint),
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onErrorContainer,
),
),
),
const SizedBox(width: FaiSpace.sm),
TextButton(
onPressed: onOpenSettings,
child: Text(l.hubUnreachableOpenSettings),
),
],
),
),
);
}
}
/// Intents driven by the Shortcuts/Actions pair on the shell.
class _GoToPageIntent extends Intent {
final int index;
@ -553,7 +663,12 @@ class _SidebarState extends State<_Sidebar>
super.initState();
_ctrl = AnimationController(
vsync: this,
duration: FaiMotion.fast,
// Expand uses the slow (~320 ms) token: the iconlabel
// reveal is a deliberate, eased gesture, not a flicker.
// The collapse runs faster (reverseDuration) so dismissing
// the rail feels crisp rather than sluggish.
duration: FaiMotion.slow,
reverseDuration: FaiMotion.base,
value: 0,
);
}
@ -565,14 +680,14 @@ class _SidebarState extends State<_Sidebar>
}
Future<void> _startDaemon(BuildContext context) async {
final l = AppLocalizations.of(context)!;
final r = await SystemActions.faiDaemon(['start']);
if (!context.mounted) return;
final detail = r.stderr.trim().isEmpty ? r.stdout.trim() : r.stderr.trim();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
r.ok
? 'Daemon start requested. Reconnecting…'
: 'Could not start daemon: ${r.stderr.trim().isEmpty ? r.stdout.trim() : r.stderr.trim()}',
r.ok ? l.daemonStartRequested : l.daemonStartFailed(detail),
),
),
);
@ -1161,7 +1276,16 @@ class _SidebarItemState extends State<_SidebarItem> {
// the left. The bg highlight spans the rail's
// current width (typical nav-rail behaviour).
padding: const EdgeInsets.symmetric(vertical: FaiSpace.md),
decoration: BoxDecoration(color: bg),
decoration: BoxDecoration(
color: bg,
borderRadius: BorderRadius.circular(FaiRadius.sm),
// Lift only the selected item a whisper of depth
// marks "you are here" without competing with the
// accent fill.
boxShadow: widget.selected
? FaiElevation.low(theme.brightness)
: null,
),
child: content,
),
),