// 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.65.0'; Future 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(); 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(); @override State createState() => StudioAppState(); } class StudioAppState extends State { /// 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 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 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 themePluginNotifier; Future setMode(ThemeModeValue m) async { modeNotifier.value = m; await HubService.instance.saveThemeMode(m); } Future setLocale(Locale l) async { localeNotifier.value = l; await HubService.instance.saveLocale(l); } Future 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( valueListenable: modeNotifier, builder: (_, mode, _) => ValueListenableBuilder( valueListenable: localeNotifier, builder: (_, locale, _) => ValueListenableBuilder( 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 createState() => StudioShellState(); } class StudioShellState extends State { int _selectedIndex = 0; bool? _connected; // null = unknown, polled on a timer String? _activeChannel; // local / dev / beta / production Timer? _healthPoll; /// 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(); 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(), ), ]; @override void initState() { super.initState(); _checkHealth(); _healthPoll = Timer.periodic( const Duration(seconds: 5), (_) => _checkHealth(), ); } Future _checkHealth() async { final ok = await HubService.instance.healthy(); if (!mounted) return; if (_connected != ok) setState(() => _connected = ok); // Refresh the active channel pointer alongside the health // probe — cheap, and keeps the sidebar pill honest if the // operator switched channels from the CLI. if (ok) { try { final snap = await HubService.instance.channelStatus(); if (!mounted) return; if (_activeChannel != snap.active) { setState(() => _activeChannel = snap.active); } } catch (_) { // Best-effort; pill simply doesn't update this tick. } } } @override void dispose() { _healthPoll?.cancel(); super.dispose(); } void _openSearchPalette() { final l = AppLocalizations.of(context)!; final pagesGroup = l.searchGroupPages; final hits = [ 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: { // 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: >{ _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, ), 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( begin: const Offset(0, 0.02), end: Offset.zero, ).animate(anim), child: child, ), ), child: KeyedSubtree( key: ValueKey(_selectedIndex), child: _pages[_selectedIndex].page, ), ), ), ], ), ), ), ), ); } } /// 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 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; const _Sidebar({ required this.selectedIndex, required this.onSelect, required this.pages, required this.connected, required this.endpointLabel, required this.activeChannel, }); @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, duration: FaiMotion.fast, value: 0, ); } @override void dispose() { _ctrl.dispose(); super.dispose(); } Future _startDaemon(BuildContext context) async { final r = await SystemActions.faiDaemon(['start']); if (!context.mounted) return; 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()}', ), ), ); } @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, 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 _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; const _SidebarItem({ required this.page, required this.selected, required this.t, required this.labelsInteractive, required this.iconColumnWidth, required this.onTap, }); @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); final icon = Icon( widget.selected ? widget.page.selectedIcon : widget.page.icon, size: 18, color: color, ); // 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), 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( 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 0–999 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( 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.