// 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/modules.dart'; import 'theme/theme.dart'; import 'theme/tokens.dart'; import 'widgets/widgets.dart'; void main() { runApp(const StudioApp()); } class StudioApp extends StatelessWidget { const StudioApp({super.key}); @override Widget build(BuildContext context) { return MaterialApp( title: 'F∆I Studio', debugShowCheckedModeBanner: false, theme: FaiTheme.light(), darkTheme: FaiTheme.dark(), themeMode: ThemeMode.system, 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: '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: FaiSpace.xs), // 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 hint. Padding( padding: const EdgeInsets.symmetric(horizontal: FaiSpace.lg), child: Text( 'platform v0.10.42', style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, fontSize: 10, ), ), ), ], ), ); } } 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, }); }