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:
parent
7511867774
commit
5313266cc4
25 changed files with 1231 additions and 234 deletions
170
lib/main.dart
170
lib/main.dart
|
|
@ -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 icon→label
|
||||
// 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,
|
||||
),
|
||||
),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue