// 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/hub.dart'; import 'data/system_actions.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.37.0'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); // Restore the persisted endpoint, theme mode, and locale // before the first frame so none of them flicker on startup. await HubService.instance.loadPersistedEndpoint(); final themeMode = await HubService.instance.loadThemeMode(); final locale = await HubService.instance.loadLocale(); runApp(StudioApp(initialThemeMode: themeMode, initialLocale: locale)); } class StudioApp extends StatefulWidget { final ThemeModeValue initialThemeMode; final Locale initialLocale; const StudioApp({ super.key, required this.initialThemeMode, required this.initialLocale, }); /// 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; 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); } @override void initState() { super.initState(); modeNotifier = ValueNotifier(widget.initialThemeMode); localeNotifier = ValueNotifier(widget.initialLocale); } @override void dispose() { modeNotifier.dispose(); localeNotifier.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; } } @override Widget build(BuildContext context) { return ValueListenableBuilder( valueListenable: modeNotifier, builder: (_, mode, _) => ValueListenableBuilder( valueListenable: localeNotifier, builder: (_, locale, _) => MaterialApp( title: 'F∆I Studio', debugShowCheckedModeBanner: false, theme: FaiTheme.light(), darkTheme: 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; static const _pages = <_NavPage>[ _NavPage( id: 'welcome', icon: Icons.waving_hand_outlined, selectedIcon: Icons.waving_hand, page: WelcomePage(), ), _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. _NavPage( id: 'store', icon: Icons.storefront_outlined, selectedIcon: Icons.storefront, page: StorePage(), ), _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 StatelessWidget { 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 Widget build(BuildContext context) { final theme = Theme.of(context); final mode = connected == true ? FaiDeltaMode.live : FaiDeltaMode.idle; return Container( width: 220, color: theme.colorScheme.surfaceContainerLow, padding: const EdgeInsets.symmetric(vertical: FaiSpace.xl), child: Column( children: [ // Brand mark. FaiDeltaMark(color: theme.colorScheme.primary, mode: mode), const SizedBox(height: FaiSpace.md), Text( 'F∆I Studio', style: theme.textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w600, ), ), const SizedBox(height: 2), Text( 'v$kStudioVersion', style: FaiTheme.mono( size: 10, color: theme.colorScheme.primary, ), ), const SizedBox(height: FaiSpace.md), // Connection pill. Padding( padding: const EdgeInsets.symmetric(horizontal: FaiSpace.lg), child: _ConnectionPill( connected: connected, label: endpointLabel, onStartRequested: () 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()}', ), ), ); }, ), ), if (activeChannel != null && activeChannel!.isNotEmpty) ...[ const SizedBox(height: FaiSpace.sm), Padding( padding: const EdgeInsets.symmetric(horizontal: FaiSpace.lg), child: _ChannelPill(channel: activeChannel!), ), ], const SizedBox(height: FaiSpace.xxl), // Destinations. Expanded( child: ListView( padding: const EdgeInsets.symmetric(horizontal: FaiSpace.md), children: [ for (var i = 0; i < pages.length; i++) _SidebarItem( page: pages[i], selected: i == selectedIndex, onTap: () => onSelect(i), ), ], ), ), // Footer: theme toggle · live wall-clock · settings. // Clock sits between the two icons so it reads as a // status line — gives the operator a wall-clock // reference to cross-check audit-log timestamps // without taking sidebar real estate from anything // load-bearing. Padding( padding: const EdgeInsets.symmetric(horizontal: FaiSpace.md), child: Row( children: [ _ThemeToggle(), _LanguageToggle(), const Expanded( child: Center(child: _SidebarClock()), ), IconButton( icon: const Icon(Icons.settings_outlined, size: 16), tooltip: 'Settings (⌘;)', visualDensity: VisualDensity.compact, onPressed: () 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); return Tooltip( message: 'Active channel — click to switch in Settings (⌘;).\n' 'production = stable · beta = pre-release · dev = rolling · local = workspace', 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, ), ), ], ), ), ), ), ); } } class _ConnectionPill extends StatelessWidget { final bool? connected; final String label; /// Triggered by the inline "Start hub" button when the /// daemon is unreachable. Runs `fai daemon start` server-side /// (well, locally — Studio shells out). The shell-level /// health poll picks the daemon up on its next tick. final Future Function() onStartRequested; const _ConnectionPill({ required this.connected, required this.label, required this.onStartRequested, }); @override Widget build(BuildContext context) { final theme = Theme.of(context); final dotColor = connected == true ? FaiColors.success : connected == false ? FaiColors.danger : FaiColors.muted; final caption = connected == true ? 'connected' : connected == false ? 'unreachable' : 'connecting…'; return Container( padding: const EdgeInsets.symmetric( horizontal: FaiSpace.sm, vertical: 6, ), decoration: BoxDecoration( color: theme.colorScheme.surfaceContainerHigh, borderRadius: BorderRadius.circular(FaiRadius.sm), border: Border.all(color: theme.colorScheme.outlineVariant), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Row( mainAxisSize: MainAxisSize.min, children: [ FaiStatusDot(color: dotColor, pulsing: connected == true), const SizedBox(width: 6), Text( caption, style: theme.textTheme.labelSmall?.copyWith( color: dotColor == FaiColors.danger ? theme.colorScheme.error : theme.colorScheme.onSurface, fontWeight: FontWeight.w500, ), ), ], ), const SizedBox(height: 2), Text( label, style: FaiTheme.mono( size: 10, color: theme.colorScheme.onSurfaceVariant, ), ), // Inline "Start hub" affordance when the daemon // isn't there. Resolves the chicken-and-egg for // Windows operators who can't run `fai daemon start` // from a shell. if (connected == false) ...[ const SizedBox(height: FaiSpace.sm), SizedBox( width: double.infinity, child: FilledButton.tonalIcon( onPressed: onStartRequested, icon: const Icon(Icons.play_arrow, size: 14), label: const Text( 'Start hub', style: TextStyle(fontSize: 11), ), style: FilledButton.styleFrom( visualDensity: VisualDensity.compact, padding: const EdgeInsets.symmetric( horizontal: FaiSpace.sm, vertical: 4, ), ), ), ), ], ], ), ); } } class _SidebarItem extends StatefulWidget { final _NavPage page; final bool selected; final VoidCallback onTap; const _SidebarItem({ required this.page, required this.selected, 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; return 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, padding: const EdgeInsets.symmetric( horizontal: FaiSpace.md, vertical: FaiSpace.md, ), decoration: BoxDecoration( color: bg, borderRadius: BorderRadius.circular(FaiRadius.sm), ), child: Row( children: [ Icon( widget.selected ? widget.page.selectedIcon : widget.page.icon, size: 18, color: color, ), const SizedBox(width: FaiSpace.md), Text( widget.page.labelOf(context), style: theme.textTheme.labelMedium?.copyWith(color: color), ), ], ), ), ), ), ); } } 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 '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, ), ), ), ); }, ); } }