chain-studio/lib/main.dart
flemming-it 5313266cc4 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>
2026-06-11 23:52:18 +02:00

1563 lines
51 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// F∆I Studio — desktop GUI for the F∆I hub.
//
// Visual language: warm minimalism / deep tech with soul.
// Tokens in lib/theme/, primitives in lib/widgets/.
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'data/fai_log.dart';
import 'data/hub.dart';
import 'data/system_actions.dart';
import 'data/theme_plugin.dart';
import 'l10n/app_localizations.dart';
import 'pages/approvals.dart';
import 'pages/audit.dart';
import 'pages/doctor.dart';
import 'pages/flows.dart';
import 'pages/store.dart';
import 'pages/welcome.dart';
import 'theme/theme.dart';
import 'theme/tokens.dart';
import 'widgets/fai_search_palette.dart';
import 'widgets/widgets.dart';
/// Studio's own build version. Bump on every UI commit so the
/// running app self-identifies — visible in the sidebar header
/// and quick-glance proof that you're seeing the current build.
const String kStudioVersion = '0.69.0';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
// Restore the persisted endpoint, theme mode, locale, and
// (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();
runApp(
StudioApp(
initialThemeMode: themeMode,
initialLocale: locale,
initialThemePlugin: themePlugin,
),
);
}
class StudioApp extends StatefulWidget {
final ThemeModeValue initialThemeMode;
final Locale initialLocale;
/// Capability name of the active theme plugin (e.g.
/// `studio.theme.solarized`) or `null` for the built-in
/// FaiTheme.
final String? initialThemePlugin;
const StudioApp({
super.key,
required this.initialThemeMode,
required this.initialLocale,
this.initialThemePlugin,
});
/// Lookup helper so descendants can flip the theme without
/// passing callbacks down through every level.
static StudioAppState? of(BuildContext context) =>
context.findAncestorStateOfType<StudioAppState>();
@override
State<StudioApp> createState() => StudioAppState();
}
class StudioAppState extends State<StudioApp> {
/// Exposed so any descendant can listen via
/// `ValueListenableBuilder` and rebuild instantly when the
/// theme flips. setState alone wouldn't propagate through
/// `MaterialApp(home: const StudioShell())` because the
/// const child's element doesn't get a `didUpdateWidget`.
late final ValueNotifier<ThemeModeValue> modeNotifier;
/// Active app locale. The sidebar's language toggle flips
/// this, MaterialApp re-renders with the new
/// AppLocalizations, and every translated string updates.
late final ValueNotifier<Locale> localeNotifier;
/// Capability name of the active `studio.theme.*` plugin
/// (e.g. `studio.theme.solarized`) or `null` for
/// FaiTheme's built-in palette. Flipping this triggers a
/// re-fetch of the plugin's ColorSchemes; the MaterialApp
/// rebuilds with the new themes.
late final ValueNotifier<String?> themePluginNotifier;
Future<void> setMode(ThemeModeValue m) async {
modeNotifier.value = m;
await HubService.instance.saveThemeMode(m);
}
Future<void> setLocale(Locale l) async {
localeNotifier.value = l;
await HubService.instance.saveLocale(l);
}
Future<void> setThemePlugin(String? capability) async {
themePluginNotifier.value = capability;
await saveActiveThemePlugin(capability);
}
@override
void initState() {
super.initState();
modeNotifier = ValueNotifier(widget.initialThemeMode);
localeNotifier = ValueNotifier(widget.initialLocale);
themePluginNotifier = ValueNotifier(widget.initialThemePlugin);
}
@override
void dispose() {
modeNotifier.dispose();
localeNotifier.dispose();
themePluginNotifier.dispose();
super.dispose();
}
ThemeMode _flutterMode(ThemeModeValue v) {
switch (v) {
case ThemeModeValue.system:
return ThemeMode.system;
case ThemeModeValue.light:
return ThemeMode.light;
case ThemeModeValue.dark:
return ThemeMode.dark;
}
}
/// Build (or return null) ThemeData pair for the active
/// theme plugin. Returns immediately with `null` when no
/// plugin is selected; otherwise awaits the plugin's
/// `theme_for` round-trip for both brightnesses.
///
/// The capability slot also carries the `custom:#RRGGBB`
/// sigil for free-color themes: when the value starts with
/// `custom:`, the suffix is parsed as a hex seed and the
/// ColorScheme is derived via Material 3's seed algorithm.
/// That single channel covers both "pick from installed
/// plugins" and "pick a seed colour" without a parallel
/// notifier.
Future<({ThemeData? light, ThemeData? dark})> _pluginThemes(
String? capability,
) async {
if (capability == null || capability.isEmpty) {
return (light: null, dark: null);
}
if (capability.startsWith('custom:')) {
final hex = capability.substring('custom:'.length).replaceAll('#', '');
if (hex.length != 6) return (light: null, dark: null);
final raw = int.tryParse(hex, radix: 16);
if (raw == null) return (light: null, dark: null);
final seed = Color(0xFF000000 | raw);
final light = ColorScheme.fromSeed(
seedColor: seed,
brightness: Brightness.light,
);
final dark = ColorScheme.fromSeed(
seedColor: seed,
brightness: Brightness.dark,
);
return (
light: themeDataFromScheme(light),
dark: themeDataFromScheme(dark),
);
}
try {
final schemes = await loadThemePluginSchemes(capability);
return (
light: schemes.light == null
? null
: themeDataFromScheme(schemes.light!),
dark: schemes.dark == null ? null : themeDataFromScheme(schemes.dark!),
);
} catch (e) {
// Plugin unreachable, hub down, manifest schema drift —
// any of these end up here. Log the failure so doctor +
// the in-app log viewer can surface it; the picker's
// own "applied" snackbar is replaced upstream by the
// friendlier failure message via [themePluginFailed].
// ignore: discarded_futures
FaiLog.instance.error(
'theme.plugin.load',
e,
context: 'capability=$capability',
);
return (light: null, dark: null);
}
}
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<ThemeModeValue>(
valueListenable: modeNotifier,
builder: (_, mode, _) => ValueListenableBuilder<Locale>(
valueListenable: localeNotifier,
builder: (_, locale, _) => ValueListenableBuilder<String?>(
valueListenable: themePluginNotifier,
builder: (_, pluginCap, _) =>
FutureBuilder<({ThemeData? light, ThemeData? dark})>(
// Re-key on the capability so a plugin change
// discards the in-flight future for the previous
// plugin and starts fresh.
key: ValueKey('plugin_themes:$pluginCap'),
future: _pluginThemes(pluginCap),
builder: (context, snap) {
final p = snap.data ?? (light: null, dark: null);
return MaterialApp(
title: 'F∆I Studio',
debugShowCheckedModeBanner: false,
theme: p.light ?? FaiTheme.light(),
darkTheme: p.dark ?? FaiTheme.dark(),
themeMode: _flutterMode(mode),
locale: locale,
supportedLocales: AppLocalizations.supportedLocales,
localizationsDelegates:
AppLocalizations.localizationsDelegates,
home: const StudioShell(),
);
},
),
),
),
);
}
}
class StudioShell extends StatefulWidget {
const StudioShell({super.key});
@override
State<StudioShell> createState() => StudioShellState();
}
class StudioShellState extends State<StudioShell> {
int _selectedIndex = 0;
bool? _connected; // null = unknown, polled on a timer
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
/// navigation hop instead of a static hint. Silently
/// ignores unknown ids.
void navigateTo(String pageId) {
final idx = _pages.indexWhere((p) => p.id == pageId);
if (idx < 0) return;
setState(() => _selectedIndex = idx);
}
/// Lookup helper — descendants call this to drive nav
/// without holding a controller reference.
static StudioShellState? of(BuildContext context) =>
context.findAncestorStateOfType<StudioShellState>();
static const _pages = <_NavPage>[
_NavPage(
id: 'welcome',
icon: Icons.waving_hand_outlined,
selectedIcon: Icons.waving_hand,
page: WelcomePage(),
),
// Store moved to slot 2 in v0.39.1 — it's the
// operator's daily-use surface and belongs above Doctor,
// which is a diagnostics page.
_NavPage(
id: 'store',
icon: Icons.storefront_outlined,
selectedIcon: Icons.storefront,
page: StorePage(),
),
_NavPage(
id: 'doctor',
icon: Icons.health_and_safety_outlined,
selectedIcon: Icons.health_and_safety,
page: DoctorPage(),
),
// Modules tab dropped in v0.35.0 — installed-module info
// (permissions, on-disk path, uninstall) now lives inside
// the Store's detail sheet, and the Store with the
// "Installed" filter covers the listing role. Cmd+K still
// opens FaiModuleSheet for quick info.
// Flows destination IS the editor as of 0.52.0 — the
// graph / text / run tabs all live behind one icon. The
// separate "flow-editor" destination is gone; the Cmd+K
// palette still navigates to flows when the operator
// wants the editor surface.
_NavPage(
id: 'flows',
icon: Icons.account_tree_outlined,
selectedIcon: Icons.account_tree,
page: FlowsPage(),
),
_NavPage(
id: 'audit',
icon: Icons.timeline_outlined,
selectedIcon: Icons.timeline,
page: AuditPage(),
),
_NavPage(
id: 'approvals',
icon: Icons.inbox_outlined,
selectedIcon: Icons.inbox,
page: ApprovalsPage(),
),
];
/// Pending-approval count surfaced as a sidebar badge so the
/// operator notices new approvals without polling the page.
/// Refreshed alongside the health probe — same 5 s tick.
int _pendingApprovals = 0;
@override
void initState() {
super.initState();
_checkHealth();
_healthPoll = Timer.periodic(
const Duration(seconds: 5),
(_) => _checkHealth(),
);
}
Future<void> _checkHealth() async {
final ok = await HubService.instance.healthy();
if (!mounted) return;
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();
if (!mounted) return;
if (_activeChannel != snap.active) {
setState(() => _activeChannel = snap.active);
}
} catch (_) {/* best-effort */}
try {
final pending = await HubService.instance.pendingApprovals();
if (!mounted) return;
if (_pendingApprovals != pending.length) {
setState(() => _pendingApprovals = pending.length);
}
} catch (_) {/* best-effort */}
}
}
@override
void dispose() {
_healthPoll?.cancel();
super.dispose();
}
void _openSearchPalette() {
final l = AppLocalizations.of(context)!;
final pagesGroup = l.searchGroupPages;
final hits = <FaiSearchHit>[
for (var i = 0; i < _pages.length; i++)
FaiSearchHit(
label: _pages[i].labelOf(context),
hint: l.searchPageHint(i + 1),
icon: _pages[i].icon,
group: pagesGroup,
onSelect: () {
Navigator.of(context).pop();
setState(() => _selectedIndex = i);
},
),
FaiSearchHit(
label: l.searchSettingsLabel,
hint: l.searchSettingsHint,
icon: Icons.settings_outlined,
group: pagesGroup,
onSelect: () {
Navigator.of(context).pop();
FaiSettingsDialog.show(context);
},
),
];
FaiSearchPalette.show(context, staticHits: hits);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Shortcuts(
shortcuts: <ShortcutActivator, Intent>{
// Cmd+1..6 jumps to the matching destination. Numbered
// 1-based to match the visual order in the sidebar.
for (var i = 0; i < _pages.length; i++)
SingleActivator(
LogicalKeyboardKey(LogicalKeyboardKey.digit1.keyId + i),
meta: true,
): _GoToPageIntent(
i,
),
// Settings dialog. macOS reserves Cmd+, for the native
// app-Preferences menu item, which Flutter Desktop
// doesn't wire up — the keystroke is captured by the OS
// before it reaches our Shortcuts widget. Cmd+; is the
// working alternative; we register Cmd+, too for
// completeness in case a future Flutter version (or a
// native-menu setup) lets it through.
const SingleActivator(LogicalKeyboardKey.semicolon, meta: true):
const _OpenSettingsIntent(),
const SingleActivator(LogicalKeyboardKey.comma, meta: true):
const _OpenSettingsIntent(),
// Universal command palette. Mirrors Cmd+K from VSCode /
// Linear / 1Password — operators jump anywhere by typing.
const SingleActivator(LogicalKeyboardKey.keyK, meta: true):
const _OpenSearchIntent(),
const SingleActivator(LogicalKeyboardKey.keyK, control: true):
const _OpenSearchIntent(),
},
child: Actions(
actions: <Type, Action<Intent>>{
_GoToPageIntent: CallbackAction<_GoToPageIntent>(
onInvoke: (intent) {
setState(() => _selectedIndex = intent.index);
return null;
},
),
_OpenSettingsIntent: CallbackAction<_OpenSettingsIntent>(
onInvoke: (_) {
FaiSettingsDialog.show(context);
return null;
},
),
_OpenSearchIntent: CallbackAction<_OpenSearchIntent>(
onInvoke: (_) {
_openSearchPalette();
return null;
},
),
},
child: Focus(
autofocus: true,
child: Scaffold(
body: Row(
children: [
_Sidebar(
selectedIndex: _selectedIndex,
onSelect: (i) => setState(() => _selectedIndex = i),
pages: _pages,
connected: _connected,
endpointLabel: HubService.instance.endpointLabel,
activeChannel: _activeChannel,
pendingApprovals: _pendingApprovals,
),
Container(width: 1, color: theme.colorScheme.outlineVariant),
Expanded(
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,
),
),
),
],
),
),
],
),
),
),
),
);
}
}
/// 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;
const _GoToPageIntent(this.index);
}
class _OpenSettingsIntent extends Intent {
const _OpenSettingsIntent();
}
class _OpenSearchIntent extends Intent {
const _OpenSearchIntent();
}
class _Sidebar extends StatefulWidget {
final int selectedIndex;
final ValueChanged<int> onSelect;
final List<_NavPage> pages;
final bool? connected;
final String endpointLabel;
/// Active channel name from the running daemon
/// (`local`/`dev`/`beta`/`production`). Null while the
/// initial fetch is in flight or the hub is unreachable.
final String? activeChannel;
/// Number of pending approvals — surfaced as a badge on the
/// matching nav item so the operator notices a new approval
/// without polling the page.
final int pendingApprovals;
const _Sidebar({
required this.selectedIndex,
required this.onSelect,
required this.pages,
required this.connected,
required this.endpointLabel,
required this.activeChannel,
this.pendingApprovals = 0,
});
@override
State<_Sidebar> createState() => _SidebarState();
}
class _SidebarState extends State<_Sidebar>
with SingleTickerProviderStateMixin {
// Default-collapsed (icons only); expands on hover. 72 / 220
// mirrors NavigationRail's spec.
static const double _collapsedWidth = 72;
static const double _expandedWidth = 220;
// Fixed pixel heights for every header row so the layout
// is the SAME total height in collapsed and expanded
// states. Items below the header (destinations list,
// footer) sit at a mathematically constant Y — they
// cannot move when the rail expands because nothing
// above them grows. This is the structural fix; the
// earlier opacity-tied animation only made the broken
// height transition smoother to the eye.
static const double _brandRowH = 48;
static const double _connRowH = 44;
static const double _channelRowH = 28;
static const double _rowGap = 8;
late final AnimationController _ctrl;
@override
void initState() {
super.initState();
_ctrl = AnimationController(
vsync: this,
// 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,
);
}
@override
void dispose() {
_ctrl.dispose();
super.dispose();
}
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 ? l.daemonStartRequested : l.daemonStartFailed(detail),
),
),
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final mode = widget.connected == true
? FaiDeltaMode.live
: FaiDeltaMode.idle;
final hasChannel =
widget.activeChannel != null && widget.activeChannel!.isNotEmpty;
return MouseRegion(
onEnter: (_) => _ctrl.forward(),
onExit: (_) => _ctrl.reverse(),
child: AnimatedBuilder(
animation: _ctrl,
builder: (context, _) {
final t = Curves.easeInOutCubic.transform(_ctrl.value);
final width =
_collapsedWidth + (_expandedWidth - _collapsedWidth) * t;
final labelsInteractive = t > 0.5;
return Container(
width: width,
color: theme.colorScheme.surfaceContainerLow,
padding: const EdgeInsets.symmetric(vertical: FaiSpace.xl),
child: ClipRect(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Brand row — fixed 48px regardless of state.
SizedBox(
height: _brandRowH,
child: _sidebarRow(
leading: FaiDeltaMark(
color: theme.colorScheme.primary,
mode: mode,
),
label: _BrandLabel(),
t: t,
labelsInteractive: labelsInteractive,
),
),
const SizedBox(height: _rowGap),
// Connection row — fixed 44px. Tapping the row
// (anywhere in either icon col or label) when
// disconnected fires the daemon-start action,
// so we can drop the inline "Start hub" button
// that used to make this row's height balloon.
Builder(
builder: (context) {
final l = AppLocalizations.of(context)!;
final caption = widget.connected == true
? l.connectionConnected
: widget.connected == false
? l.connectionTapToStart
: l.connectionConnecting;
return SizedBox(
height: _connRowH,
child: _sidebarRow(
leading: Tooltip(
message: '$caption · ${widget.endpointLabel}',
child: _CollapsedConnectionDot(
connected: widget.connected,
),
),
label: _ConnectionLabel(
connected: widget.connected,
endpoint: widget.endpointLabel,
),
t: t,
labelsInteractive: labelsInteractive,
onTap: widget.connected == false
? () => _startDaemon(context)
: null,
),
);
},
),
const SizedBox(height: _rowGap),
// Channel row — fixed 28px. Always reserved
// (with empty content when there's no channel
// selected) so toggling channels at runtime
// doesn't shift the destinations list below.
SizedBox(
height: _channelRowH,
child: hasChannel
? _sidebarRow(
leading: Tooltip(
message: widget.activeChannel!,
child: _CollapsedChannelChip(
channel: widget.activeChannel!,
),
),
label: _ChannelPill(channel: widget.activeChannel!),
t: t,
labelsInteractive: labelsInteractive,
)
: const SizedBox.shrink(),
),
const SizedBox(height: FaiSpace.md),
Expanded(
child: ListView(
padding: EdgeInsets.zero,
children: [
for (var i = 0; i < widget.pages.length; i++)
_SidebarItem(
page: widget.pages[i],
selected: i == widget.selectedIndex,
t: t,
labelsInteractive: labelsInteractive,
iconColumnWidth: _collapsedWidth,
badge: widget.pages[i].id == 'approvals' &&
widget.pendingApprovals > 0
? widget.pendingApprovals
: null,
onTap: () => widget.onSelect(i),
),
],
),
),
_Footer(
t: t,
labelsInteractive: labelsInteractive,
iconColumnWidth: _collapsedWidth,
onOpenSettings: () => _openSettings(context),
),
],
),
),
);
},
),
);
}
/// Shared row geometry for the brand / connection / channel
/// headers. Leading widget sits in a fixed-width icon column
/// anchored to the rail's left edge. The label slot is in
/// the tree as soon as the animation kicks off (t > 0) and
/// fades in via Opacity tied to the same controller. Skipping
/// layout at t == 0 keeps the row's collapsed height = the
/// leading widget's natural height (rather than including
/// the label's tall "Start hub" affordance even when
/// invisible). The opacity at t ≈ 0.01 is low enough that
/// the layout's first-frame appearance isn't perceived as a
/// pop — the label visually grows in from invisible.
Widget _sidebarRow({
required Widget leading,
required Widget label,
required double t,
required bool labelsInteractive,
VoidCallback? onTap,
}) {
final body = Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
SizedBox(
width: _collapsedWidth,
child: Center(child: leading),
),
Expanded(
child: t > 0
? IgnorePointer(
// The whole row is interactive when onTap is set
// (we don't want the label sub-widgets eating
// the tap), so ignore pointer events on the
// label sub-tree even when the rail is expanded.
ignoring: onTap != null || !labelsInteractive,
child: Opacity(
opacity: t,
child: Padding(
padding: const EdgeInsets.only(right: FaiSpace.md),
child: label,
),
),
)
: const SizedBox.shrink(),
),
],
);
if (onTap == null) return body;
return MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: body,
),
);
}
Future<void> _openSettings(BuildContext context) async {
final saved = await FaiSettingsDialog.show(context);
if (saved && context.mounted) {
// Force a sidebar rebuild so the new endpoint shows in
// the connection pill.
(context as Element).markNeedsBuild();
}
}
}
/// Active-channel indicator. Click to open Settings (where the
/// operator can switch). Color-coded so production deployments
/// look visibly different from local-dev — the kind of thing
/// you want to see before clicking "Clear log".
class _ChannelPill extends StatelessWidget {
final String channel;
const _ChannelPill({required this.channel});
Color _accent(ColorScheme cs) {
switch (channel) {
case 'production':
return cs.error;
case 'beta':
return FaiColors.warning;
case 'dev':
return cs.primary;
case 'local':
default:
return cs.onSurfaceVariant;
}
}
IconData get _icon {
switch (channel) {
case 'production':
return Icons.shield_outlined;
case 'beta':
return Icons.science_outlined;
case 'dev':
return Icons.bolt_outlined;
case 'local':
default:
return Icons.home_work_outlined;
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final accent = _accent(theme.colorScheme);
final l = AppLocalizations.of(context)!;
return Tooltip(
message: l.sidebarChannelTooltip,
child: Material(
color: theme.colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(FaiRadius.sm),
child: InkWell(
borderRadius: BorderRadius.circular(FaiRadius.sm),
onTap: () => FaiSettingsDialog.show(context),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: FaiSpace.sm,
vertical: 4,
),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(FaiRadius.sm),
border: Border.all(color: accent.withValues(alpha: 0.4)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(_icon, size: 11, color: accent),
const SizedBox(width: 4),
Text(
channel,
style: FaiTheme.mono(
size: 10,
weight: FontWeight.w600,
color: accent,
),
),
],
),
),
),
),
);
}
}
/// Mini-version of [_ConnectionLabel] used when the sidebar is
/// collapsed. A 10px dot in the same green/red/amber tonality
/// the FaiDeltaMark uses for live / idle / unknown.
class _CollapsedConnectionDot extends StatelessWidget {
final bool? connected;
const _CollapsedConnectionDot({required this.connected});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final color = connected == true
? FaiColors.success
: connected == false
? theme.colorScheme.error
: FaiColors.warning;
return Container(
width: 10,
height: 10,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: color.withValues(alpha: 0.35),
blurRadius: 4,
spreadRadius: 0.5,
),
],
),
);
}
}
/// Mini-version of [_ChannelPill] used when the sidebar is
/// collapsed. A 16px circular chip with the first letter of
/// the channel (P / B / D / L) in the same accent tone the
/// full pill uses, so production stays unmistakeable at a
/// glance.
class _CollapsedChannelChip extends StatelessWidget {
final String channel;
const _CollapsedChannelChip({required this.channel});
Color _accent(ColorScheme cs) {
switch (channel) {
case 'production':
return cs.error;
case 'beta':
return FaiColors.warning;
case 'dev':
return cs.primary;
case 'local':
default:
return cs.onSurfaceVariant;
}
}
String get _letter {
if (channel.isEmpty) return '·';
return channel.substring(0, 1).toUpperCase();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final accent = _accent(theme.colorScheme);
return Container(
width: 18,
height: 18,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: accent.withValues(alpha: 0.55)),
),
alignment: Alignment.center,
child: Text(
_letter,
style: FaiTheme.mono(size: 10, weight: FontWeight.w700, color: accent),
),
);
}
}
/// Two-line brand label shown in the rail's expanded state.
/// Fits inside a 48-px fixed-height row by design: 16-px title
/// + 2-px gap + 10-px monospaced version stamp = 28-px content
/// with 10 px of vertical breathing room top + bottom.
class _BrandLabel extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'F∆I Studio',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 2),
Text(
'v$kStudioVersion',
style: FaiTheme.mono(size: 10, color: theme.colorScheme.primary),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
);
}
}
/// Single-conceptual-block connection label shown in the
/// rail's expanded state. Two single-line rows: a caption
/// (`connected` / `tap to start` / `connecting…`) and the
/// endpoint label below in mono. Fits inside the 44-px fixed
/// connection-row height — see `_connRowH` in `_SidebarState`.
/// The old inline "Start hub" button is gone; the parent row
/// is tappable when `connected == false` and fires the same
/// daemon-start action.
class _ConnectionLabel extends StatelessWidget {
final bool? connected;
final String endpoint;
const _ConnectionLabel({required this.connected, required this.endpoint});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l = AppLocalizations.of(context)!;
final captionColor = connected == false
? theme.colorScheme.error
: theme.colorScheme.onSurface;
final caption = connected == true
? l.connectionConnected
: connected == false
? l.connectionTapToStart
: l.connectionConnecting;
return Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
caption,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.labelMedium?.copyWith(
color: captionColor,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 2),
Text(
endpoint,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: FaiTheme.mono(
size: 10,
color: theme.colorScheme.onSurfaceVariant,
),
),
],
);
}
}
class _SidebarItem extends StatefulWidget {
final _NavPage page;
final bool selected;
/// 0 = fully collapsed, 1 = fully expanded. Drives label
/// opacity in lockstep with the parent rail's width
/// animation — there is no second timer for the label, so
/// label and rail can't fall out of sync.
final double t;
final bool labelsInteractive;
final double iconColumnWidth;
final VoidCallback onTap;
/// Optional count badge — null hides it, non-null renders a
/// small accent pill on top-right of the icon. Used by the
/// Approvals item for pending count.
final int? badge;
const _SidebarItem({
required this.page,
required this.selected,
required this.t,
required this.labelsInteractive,
required this.iconColumnWidth,
required this.onTap,
this.badge,
});
@override
State<_SidebarItem> createState() => _SidebarItemState();
}
class _SidebarItemState extends State<_SidebarItem> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final color = widget.selected
? theme.colorScheme.primary
: _hovered
? theme.colorScheme.onSurface
: theme.colorScheme.onSurfaceVariant;
final bg = widget.selected
? theme.colorScheme.primary.withValues(alpha: 0.08)
: _hovered
? theme.colorScheme.surfaceContainerHigh
: Colors.transparent;
final label = widget.page.labelOf(context);
Widget icon = Icon(
widget.selected ? widget.page.selectedIcon : widget.page.icon,
size: 18,
color: color,
);
final badge = widget.badge;
if (badge != null) {
icon = Stack(
clipBehavior: Clip.none,
children: [
icon,
Positioned(
right: -6,
top: -4,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 5,
vertical: 1,
),
constraints: const BoxConstraints(minWidth: 16, minHeight: 14),
decoration: BoxDecoration(
color: theme.colorScheme.primary,
borderRadius: BorderRadius.circular(8),
),
child: Text(
badge > 9 ? '9+' : '$badge',
textAlign: TextAlign.center,
style: TextStyle(
color: theme.colorScheme.onPrimary,
fontSize: 9,
fontWeight: FontWeight.w700,
height: 1.1,
),
),
),
),
],
);
}
// Label widget is ALWAYS in the tree — opacity = t makes
// it fade in/out in lockstep with the rail's width
// animation. Expanded's flex space goes from 0 (at t=0,
// rail width = 72) to (rail width 72) at t=1, so the
// label has natural space to fill without any conditional
// tree mutation.
final content = Row(
children: [
SizedBox(
width: widget.iconColumnWidth,
child: Center(child: icon),
),
Expanded(
child: widget.t > 0
? IgnorePointer(
ignoring: !widget.labelsInteractive,
child: Opacity(
opacity: widget.t,
child: Padding(
padding: const EdgeInsets.only(right: FaiSpace.md),
child: Text(
label,
overflow: TextOverflow.fade,
softWrap: false,
style: theme.textTheme.labelMedium?.copyWith(
color: color,
),
),
),
),
)
: const SizedBox.shrink(),
),
],
);
final inner = Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: MouseRegion(
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
cursor: SystemMouseCursors.click,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: widget.onTap,
child: AnimatedContainer(
duration: FaiMotion.fast,
// No horizontal padding — the icon column anchors
// 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,
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,
),
),
),
);
// Tooltip only while the label is hidden — once the label
// is visible inline the tooltip becomes redundant noise.
if (widget.t > 0.5) return inner;
return Tooltip(message: label, preferBelow: false, child: inner);
}
}
/// Bottom-of-rail control strip. Settings icon is always the
/// last item in the icon column so its pixel-X never shifts;
/// theme + language toggles and clock fade in beside it as
/// the rail expands, sharing the same `t` as the rest of the
/// sidebar so motion stays in lockstep.
class _Footer extends StatelessWidget {
final double t;
final bool labelsInteractive;
final double iconColumnWidth;
final VoidCallback onOpenSettings;
const _Footer({
required this.t,
required this.labelsInteractive,
required this.iconColumnWidth,
required this.onOpenSettings,
});
@override
Widget build(BuildContext context) {
final l = AppLocalizations.of(context)!;
return Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
SizedBox(
width: iconColumnWidth,
child: Center(
child: IconButton(
icon: const Icon(Icons.settings_outlined, size: 16),
tooltip: l.sidebarSettingsTooltip,
visualDensity: VisualDensity.compact,
onPressed: onOpenSettings,
),
),
),
Expanded(
child: t > 0
? IgnorePointer(
ignoring: !labelsInteractive,
child: Opacity(
opacity: t,
child: Padding(
padding: const EdgeInsets.only(right: FaiSpace.sm),
child: Row(
children: [
_ThemeToggle(),
_LanguageToggle(),
const Expanded(child: Center(child: _SidebarClock())),
],
),
),
),
)
: const SizedBox.shrink(),
),
],
);
}
}
class _NavPage {
/// Stable id used for the Cmd+K palette and the sidebar
/// shortcut keys; never shown to the operator. The visible
/// label comes from [labelOf] which reads the active
/// AppLocalizations.
final String id;
final IconData icon;
final IconData selectedIcon;
final Widget page;
const _NavPage({
required this.id,
required this.icon,
required this.selectedIcon,
required this.page,
});
/// Resolve the localized label via AppLocalizations.
String labelOf(BuildContext context) {
final l = AppLocalizations.of(context);
if (l == null) return id;
switch (id) {
case 'welcome':
return l.navWelcome;
case 'doctor':
return l.navDoctor;
case 'modules':
return l.navModules;
case 'store':
return l.navStore;
case 'flows':
return l.navFlows;
case 'flow-editor':
return l.navFlowEditor;
case 'audit':
return l.navAudit;
case 'approvals':
return l.navApprovals;
default:
return id;
}
}
}
class _ThemeToggle extends StatelessWidget {
@override
Widget build(BuildContext context) {
final app = StudioApp.of(context);
if (app == null) return const SizedBox.shrink();
return ValueListenableBuilder<ThemeModeValue>(
valueListenable: app.modeNotifier,
builder: (_, mode, _) {
final icon = switch (mode) {
ThemeModeValue.system => Icons.brightness_auto_outlined,
ThemeModeValue.light => Icons.light_mode_outlined,
ThemeModeValue.dark => Icons.dark_mode_outlined,
};
final tooltip = switch (mode) {
ThemeModeValue.system => 'theme: follow system',
ThemeModeValue.light => 'theme: light',
ThemeModeValue.dark => 'theme: dark',
};
return IconButton(
icon: Icon(icon, size: 16),
tooltip: tooltip,
visualDensity: VisualDensity.compact,
onPressed: () {
final next = switch (mode) {
ThemeModeValue.system => ThemeModeValue.light,
ThemeModeValue.light => ThemeModeValue.dark,
ThemeModeValue.dark => ThemeModeValue.system,
};
app.setMode(next);
},
);
},
);
}
}
/// Small ticking wall-clock for the sidebar. Aligns the on-screen
/// time with the timestamps the audit log writes — operators
/// glance here when an event timestamp looks off and want a
/// reference point. Updates on the second; tooltip exposes the
/// full ISO + UTC offset for unambiguous comparison.
class _SidebarClock extends StatefulWidget {
const _SidebarClock();
@override
State<_SidebarClock> createState() => _SidebarClockState();
}
class _SidebarClockState extends State<_SidebarClock> {
late DateTime _now;
Timer? _ticker;
@override
void initState() {
super.initState();
_now = DateTime.now();
_scheduleNextTick();
}
void _scheduleNextTick() {
// Align ticks on the second boundary so the seconds digit
// changes when the wall-clock second changes, not 0999 ms
// late depending on when the widget mounted.
final delay = Duration(milliseconds: 1000 - _now.millisecond);
_ticker = Timer(delay, () {
if (!mounted) return;
setState(() => _now = DateTime.now());
_ticker = Timer.periodic(const Duration(seconds: 1), (_) {
if (!mounted) return;
setState(() => _now = DateTime.now());
});
});
}
@override
void dispose() {
_ticker?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final hh = _now.hour.toString().padLeft(2, '0');
final mm = _now.minute.toString().padLeft(2, '0');
final ss = _now.second.toString().padLeft(2, '0');
final yyyy = _now.year.toString().padLeft(4, '0');
final mo = _now.month.toString().padLeft(2, '0');
final dd = _now.day.toString().padLeft(2, '0');
final offsetMin = _now.timeZoneOffset.inMinutes;
final sign = offsetMin >= 0 ? '+' : '-';
final absMin = offsetMin.abs();
final offH = (absMin ~/ 60).toString().padLeft(2, '0');
final offM = (absMin % 60).toString().padLeft(2, '0');
final tooltip =
'Local: $yyyy-$mo-$dd $hh:$mm:$ss $sign$offH:$offM\n'
'UTC: ${_now.toUtc().toIso8601String()}';
return Tooltip(
message: tooltip,
child: Text(
'$hh:$mm:$ss',
style: FaiTheme.mono(
size: 11,
color: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.85),
),
),
);
}
}
/// Two-state language toggle (DE / EN) parked in the sidebar
/// footer next to the theme button. Flipping it updates the
/// app-wide locale via the parent [`StudioAppState`]; every
/// AppLocalizations consumer rebuilds with the new strings.
/// The Store-page detail sheet still renders bilingual store
/// fields off the same locale, so a single click flips
/// chrome + content together.
class _LanguageToggle extends StatelessWidget {
@override
Widget build(BuildContext context) {
final app = StudioApp.of(context);
if (app == null) return const SizedBox.shrink();
return ValueListenableBuilder<Locale>(
valueListenable: app.localeNotifier,
builder: (context, locale, _) {
final isDe = locale.languageCode == 'de';
return Tooltip(
message: isDe
? 'Sprache: Deutsch — Klick wechselt zu English'
: 'Language: English — click to switch to Deutsch',
child: TextButton(
onPressed: () => app.setLocale(Locale(isDe ? 'en' : 'de')),
style: TextButton.styleFrom(
minimumSize: const Size(36, 32),
padding: const EdgeInsets.symmetric(horizontal: 6),
visualDensity: VisualDensity.compact,
),
child: Text(
isDe ? 'DE' : 'EN',
style: FaiTheme.mono(
size: 11,
weight: FontWeight.w600,
color: Theme.of(context).colorScheme.primary,
),
),
),
);
},
);
}
}
// _FlowEditorAdapter removed in 0.52.0 — FlowsPage now hosts
// the editor directly with the full runDriver bridge. There
// is one Flows destination; the standalone editor route is
// gone.