// 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 'data/hub.dart'; import 'pages/approvals.dart'; import 'pages/audit.dart'; import 'pages/doctor.dart'; import 'pages/modules.dart'; import 'theme/theme.dart'; import 'theme/tokens.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.7.1'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); // Restore the persisted endpoint and theme mode before the // first frame so neither flicker on startup. await HubService.instance.loadPersistedEndpoint(); final themeMode = await HubService.instance.loadThemeMode(); runApp(StudioApp(initialThemeMode: themeMode)); } class StudioApp extends StatefulWidget { final ThemeModeValue initialThemeMode; const StudioApp({super.key, required this.initialThemeMode}); /// 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 { late ThemeModeValue _mode; ThemeModeValue get mode => _mode; Future setMode(ThemeModeValue m) async { setState(() => _mode = m); await HubService.instance.saveThemeMode(m); } @override void initState() { super.initState(); _mode = widget.initialThemeMode; } ThemeMode _flutterMode() { switch (_mode) { case ThemeModeValue.system: return ThemeMode.system; case ThemeModeValue.light: return ThemeMode.light; case ThemeModeValue.dark: return ThemeMode.dark; } } @override Widget build(BuildContext context) { return MaterialApp( title: 'F∆I Studio', debugShowCheckedModeBanner: false, theme: FaiTheme.light(), darkTheme: FaiTheme.dark(), themeMode: _flutterMode(), 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 Timer? _healthPoll; static const _pages = <_NavPage>[ _NavPage( label: 'Doctor', icon: Icons.health_and_safety_outlined, selectedIcon: Icons.health_and_safety, page: DoctorPage(), ), _NavPage( label: 'Modules', icon: Icons.extension_outlined, selectedIcon: Icons.extension, page: ModulesPage(), ), _NavPage( label: 'Audit', icon: Icons.timeline_outlined, selectedIcon: Icons.timeline, page: AuditPage(), ), _NavPage( label: '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); } @override void dispose() { _healthPoll?.cancel(); super.dispose(); } @override Widget build(BuildContext context) { final theme = Theme.of(context); return Scaffold( body: Row( children: [ _Sidebar( selectedIndex: _selectedIndex, onSelect: (i) => setState(() => _selectedIndex = i), pages: _pages, connected: _connected, endpointLabel: HubService.instance.endpointLabel, ), 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, ), ), ), ], ), ); } } class _Sidebar extends StatelessWidget { final int selectedIndex; final ValueChanged onSelect; final List<_NavPage> pages; final bool? connected; final String endpointLabel; const _Sidebar({ required this.selectedIndex, required this.onSelect, required this.pages, required this.connected, required this.endpointLabel, }); @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 · warm-minimalism', 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, ), ), 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 + settings. Padding( padding: const EdgeInsets.symmetric(horizontal: FaiSpace.md), child: Row( children: [ _ThemeToggle(), const Spacer(), IconButton( icon: const Icon(Icons.settings_outlined, size: 16), tooltip: 'Hub endpoint…', 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(); } }, ), ], ), ), ], ), ); } } class _ConnectionPill extends StatelessWidget { final bool? connected; final String label; const _ConnectionPill({required this.connected, required this.label}); @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, ), ), ], ), ); } } 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.label, style: theme.textTheme.labelMedium?.copyWith(color: color), ), ], ), ), ), ), ); } } class _NavPage { final String label; final IconData icon; final IconData selectedIcon; final Widget page; const _NavPage({ required this.label, required this.icon, required this.selectedIcon, required this.page, }); } class _ThemeToggle extends StatelessWidget { @override Widget build(BuildContext context) { final app = StudioApp.of(context); final mode = app?.mode ?? ThemeModeValue.system; 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); }, ); } }