diff --git a/lib/main.dart b/lib/main.dart index 7c67cdf..ec75d5e 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,14 +1,19 @@ // F∆I Studio — desktop GUI for the F∆I hub. // -// MVP scope: three pages (Modules, Audit, Approvals) with mock -// data, sharing one navigation shell. Live gRPC connection -// arrives in the next iteration via fai_dart_sdk. +// 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()); @@ -21,20 +26,10 @@ class StudioApp extends StatelessWidget { Widget build(BuildContext context) { return MaterialApp( title: 'F∆I Studio', - theme: ThemeData( - useMaterial3: true, - colorScheme: ColorScheme.fromSeed( - seedColor: const Color(0xFF1E3A8A), - brightness: Brightness.light, - ), - ), - darkTheme: ThemeData( - useMaterial3: true, - colorScheme: ColorScheme.fromSeed( - seedColor: const Color(0xFF1E3A8A), - brightness: Brightness.dark, - ), - ), + debugShowCheckedModeBanner: false, + theme: FaiTheme.light(), + darkTheme: FaiTheme.dark(), + themeMode: ThemeMode.system, home: const StudioShell(), ); } @@ -49,6 +44,8 @@ class StudioShell extends StatefulWidget { class _StudioShellState extends State { int _selectedIndex = 0; + bool? _connected; // null = unknown, polled on a timer + Timer? _healthPoll; static const _pages = <_NavPage>[ _NavPage( @@ -71,57 +68,281 @@ class _StudioShellState extends State { ), ]; + @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: [ - NavigationRail( - extended: true, - minExtendedWidth: 180, + _Sidebar( selectedIndex: _selectedIndex, - onDestinationSelected: (i) => setState(() => _selectedIndex = i), - leading: Padding( - padding: const EdgeInsets.symmetric(vertical: 16), - child: Column( - children: [ - Icon(Icons.hub_outlined, size: 32, color: theme.colorScheme.primary), - const SizedBox(height: 8), - Text( - 'F∆I Studio', - style: theme.textTheme.titleMedium?.copyWith( - color: theme.colorScheme.primary, - fontWeight: FontWeight.w600, - ), - ), - Text( - 'connected: localhost:50051', - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ), - ], + 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, ), ), - destinations: _pages - .map( - (p) => NavigationRailDestination( - icon: Icon(p.icon), - selectedIcon: Icon(p.selectedIcon), - label: Text(p.label), - ), - ) - .toList(), ), - const VerticalDivider(width: 1), - Expanded(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; diff --git a/lib/pages/approvals.dart b/lib/pages/approvals.dart index 301c154..1384e32 100644 --- a/lib/pages/approvals.dart +++ b/lib/pages/approvals.dart @@ -1,6 +1,9 @@ import 'package:flutter/material.dart'; import '../data/hub.dart'; +import '../theme/theme.dart'; +import '../theme/tokens.dart'; +import '../widgets/widgets.dart'; class ApprovalsPage extends StatefulWidget { const ApprovalsPage({super.key}); @@ -28,9 +31,10 @@ class _ApprovalsPageState extends State { Future _approve(PendingApproval a) async { try { await HubService.instance.approve(a.id, _reviewer); + _toast('approved · ${a.flowName} › ${a.stepId}'); _refresh(); } catch (e) { - _showError('approve failed: $e'); + _toast('approve failed: $e'); } } @@ -39,13 +43,14 @@ class _ApprovalsPageState extends State { if (reason == null || reason.isEmpty) return; try { await HubService.instance.reject(a.id, _reviewer, reason); + _toast('rejected · ${a.flowName} › ${a.stepId}'); _refresh(); } catch (e) { - _showError('reject failed: $e'); + _toast('reject failed: $e'); } } - void _showError(String msg) { + void _toast(String msg) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg))); } @@ -61,6 +66,7 @@ class _ApprovalsPageState extends State { autofocus: true, decoration: const InputDecoration( labelText: 'Reason (recorded in audit log)', + border: OutlineInputBorder(), ), ), actions: [ @@ -70,6 +76,10 @@ class _ApprovalsPageState extends State { ), FilledButton( onPressed: () => Navigator.pop(ctx, controller.text), + style: FilledButton.styleFrom( + backgroundColor: Theme.of(ctx).colorScheme.error, + foregroundColor: Theme.of(ctx).colorScheme.onError, + ), child: const Text('Reject'), ), ], @@ -81,15 +91,16 @@ class _ApprovalsPageState extends State { Widget build(BuildContext context) { final theme = Theme.of(context); return Scaffold( + backgroundColor: theme.scaffoldBackgroundColor, appBar: AppBar( - title: const Text('Pending Approvals'), - centerTitle: false, + title: const Text('Approvals'), actions: [ IconButton( - icon: const Icon(Icons.refresh), + icon: const Icon(Icons.refresh, size: 18), tooltip: 'Reload', onPressed: _refresh, ), + const SizedBox(width: FaiSpace.sm), ], ), body: FutureBuilder>( @@ -99,59 +110,30 @@ class _ApprovalsPageState extends State { return const Center(child: CircularProgressIndicator()); } if (snapshot.hasError) { - return Center( - child: Padding( - padding: const EdgeInsets.all(32), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.cloud_off_outlined, - size: 48, - color: theme.colorScheme.error, - ), - const SizedBox(height: 16), - Text('Hub unreachable: ${snapshot.error}'), - const SizedBox(height: 16), - FilledButton.tonal( - onPressed: _refresh, - child: const Text('Retry'), - ), - ], - ), + return FaiEmptyState( + icon: Icons.cloud_off_outlined, + iconColor: theme.colorScheme.error, + title: 'Hub unreachable', + hint: 'Start the hub with `fai serve`.', + action: FilledButton.tonal( + onPressed: _refresh, + child: const Text('Retry'), ), ); } final pending = snapshot.data ?? []; if (pending.isEmpty) { - return Center( - child: Padding( - padding: const EdgeInsets.all(32), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.check_circle_outline, - size: 64, - color: theme.colorScheme.primary - .withValues(alpha: 0.5), - ), - const SizedBox(height: 16), - Text( - 'No pending approvals.', - style: theme.textTheme.titleMedium?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ), - ], - ), - ), + return const FaiEmptyState( + icon: Icons.task_alt_outlined, + title: 'Inbox zero', + hint: + 'All caught up. New `system.approval@^0` steps land here automatically.', ); } return ListView.separated( - padding: const EdgeInsets.all(24), + padding: const EdgeInsets.all(FaiSpace.xl), itemCount: pending.length, - separatorBuilder: (_, _) => const SizedBox(height: 16), + separatorBuilder: (_, _) => const SizedBox(height: FaiSpace.md), itemBuilder: (context, i) { final a = pending[i]; return _ApprovalCard( @@ -181,89 +163,90 @@ class _ApprovalCard extends StatelessWidget { @override Widget build(BuildContext context) { final theme = Theme.of(context); - return Card( - elevation: 0, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - side: BorderSide(color: theme.colorScheme.outlineVariant), - ), - child: Padding( - padding: const EdgeInsets.all(20), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon( - Icons.pending_outlined, - color: theme.colorScheme.tertiary, - size: 20, - ), - const SizedBox(width: 8), - Text( - '${approval.flowName} → ${approval.stepId}', + return FaiCard( + accentTop: true, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + FaiPill( + label: 'pending', + tone: FaiPillTone.warning, + icon: Icons.pending_outlined, + ), + const SizedBox(width: FaiSpace.sm), + Expanded( + child: Text( + '${approval.flowName} › ${approval.stepId}', style: theme.textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w600, ), ), - const Spacer(), - if (approval.expiresAt != null) - Text( - _expiresIn(approval.expiresAt!), - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ), - ], - ), - const SizedBox(height: 16), - Text( - approval.prompt, - style: theme.textTheme.bodyLarge, - ), - if (approval.showPreview != null) ...[ - const SizedBox(height: 12), - Container( - width: double.infinity, - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: theme.colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(8), + ), + if (approval.expiresAt != null) + FaiPill( + label: _expiresIn(approval.expiresAt!), + tone: FaiPillTone.neutral, + icon: Icons.schedule, ), - child: Text( - approval.showPreview!, - style: theme.textTheme.bodySmall?.copyWith( - fontFamily: 'monospace', + ], + ), + const SizedBox(height: FaiSpace.lg), + Text( + approval.prompt, + style: theme.textTheme.bodyLarge, + ), + if (approval.showPreview != null) ...[ + const SizedBox(height: FaiSpace.md), + Container( + width: double.infinity, + padding: const EdgeInsets.all(FaiSpace.md), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(FaiRadius.sm), + border: Border.all(color: theme.colorScheme.outlineVariant), + ), + child: Text( + approval.showPreview!, + style: FaiTheme.mono( + size: 11, + color: theme.colorScheme.onSurface, + ), + ), + ), + ], + const SizedBox(height: FaiSpace.lg), + Row( + children: [ + Text( + approval.id, + style: FaiTheme.mono( + size: 10, + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const Spacer(), + OutlinedButton.icon( + onPressed: onReject, + icon: const Icon(Icons.close, size: 16), + label: const Text('Reject'), + style: OutlinedButton.styleFrom( + foregroundColor: theme.colorScheme.error, + side: BorderSide( + color: theme.colorScheme.error.withValues(alpha: 0.5), ), ), ), + const SizedBox(width: FaiSpace.sm), + FilledButton.icon( + onPressed: onApprove, + icon: const Icon(Icons.check, size: 16), + label: const Text('Approve'), + ), ], - const SizedBox(height: 16), - Row( - children: [ - Text( - 'approval id: ${approval.id}', - style: theme.textTheme.bodySmall?.copyWith( - fontFamily: 'monospace', - color: theme.colorScheme.onSurfaceVariant, - ), - ), - const Spacer(), - OutlinedButton.icon( - onPressed: onReject, - icon: const Icon(Icons.close), - label: const Text('Reject'), - ), - const SizedBox(width: 8), - FilledButton.icon( - onPressed: onApprove, - icon: const Icon(Icons.check), - label: const Text('Approve'), - ), - ], - ), - ], - ), + ), + ], ), ); } @@ -272,8 +255,8 @@ class _ApprovalCard extends StatelessWidget { final remaining = t.difference(DateTime.now()); if (remaining.isNegative) return 'expired'; final minutes = remaining.inMinutes; - if (minutes < 60) return 'expires in ${minutes}m'; + if (minutes < 60) return '${minutes}m'; final hours = remaining.inHours; - return 'expires in ${hours}h'; + return '${hours}h'; } } diff --git a/lib/pages/audit.dart b/lib/pages/audit.dart index a7f0fb0..8f86018 100644 --- a/lib/pages/audit.dart +++ b/lib/pages/audit.dart @@ -3,6 +3,9 @@ import 'dart:async'; import 'package:flutter/material.dart'; import '../data/hub.dart'; +import '../theme/theme.dart'; +import '../theme/tokens.dart'; +import '../widgets/widgets.dart'; class AuditPage extends StatefulWidget { const AuditPage({super.key}); @@ -35,14 +38,8 @@ class _AuditPageState extends State { } Future _refresh() async { - final types = _typeFilter == 'all' - ? [] - : []; // server-side filter not yet — clientside below try { - final events = await HubService.instance.recentEvents( - limit: 100, - types: types, - ); + final events = await HubService.instance.recentEvents(limit: 100); if (!mounted) return; setState(() { _events = events; @@ -66,145 +63,70 @@ class _AuditPageState extends State { : _events.where((e) => e.type.startsWith(_typeFilter)).toList(); return Scaffold( + backgroundColor: theme.scaffoldBackgroundColor, appBar: AppBar( - title: const Text('Audit Stream'), - centerTitle: false, + title: const Text('Audit'), actions: [ Padding( - padding: const EdgeInsets.only(right: 16), - child: DropdownButton( + padding: const EdgeInsets.only(right: FaiSpace.lg), + child: _FilterChips( value: _typeFilter, - onChanged: (v) => setState(() => _typeFilter = v ?? 'all'), - items: const [ - DropdownMenuItem(value: 'all', child: Text('all events')), - DropdownMenuItem(value: 'flow.', child: Text('flow.*')), - DropdownMenuItem(value: 'step.', child: Text('step.*')), - DropdownMenuItem(value: 'module.', child: Text('module.*')), - ], + onChanged: (v) => setState(() => _typeFilter = v), ), ), ], ), body: Column( children: [ - _StatusBar(eventCount: filtered.length, error: _error), + _LiveStatusBar(eventCount: filtered.length, error: _error), Expanded( child: !_initialLoaded ? const Center(child: CircularProgressIndicator()) : _error != null && _events.isEmpty - ? Center( - child: Padding( - padding: const EdgeInsets.all(32), - child: Text( - 'Hub unreachable: $_error\n\n' - 'Start the hub with `fai serve`.', - textAlign: TextAlign.center, - style: theme.textTheme.bodyMedium, - ), - ), - ) - : filtered.isEmpty - ? Center( - child: Text( - '(no events yet)', - style: theme.textTheme.bodyMedium?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ), - ) - : ListView.separated( - padding: const EdgeInsets.all(24), - itemCount: filtered.length, - separatorBuilder: (_, _) => - const SizedBox(height: 8), - itemBuilder: (context, i) { - final e = filtered[i]; - final ts = e.timestamp; - final tsStr = - '${ts.hour.toString().padLeft(2, '0')}:${ts.minute.toString().padLeft(2, '0')}:${ts.second.toString().padLeft(2, '0')}'; - return Card( - elevation: 0, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - side: BorderSide( - color: theme.colorScheme.outlineVariant, + ? FaiEmptyState( + icon: Icons.cloud_off_outlined, + iconColor: theme.colorScheme.error, + title: 'Hub unreachable', + hint: 'Start the hub with `fai serve`.', + ) + : filtered.isEmpty + ? const FaiEmptyState( + icon: Icons.timeline_outlined, + title: 'No events yet', + hint: + 'Run a flow to populate the audit stream.\nEvents appear here within seconds.', + ) + : ListView.separated( + padding: const EdgeInsets.all(FaiSpace.xl), + itemCount: filtered.length, + separatorBuilder: (_, _) => + const SizedBox(height: FaiSpace.xs), + itemBuilder: (context, i) { + final e = filtered[i]; + return FaiDataRow( + accent: _toneFor(e.type, theme), + leading: _formatTime(e.timestamp), + title: e.type, + subtitle: _contextLine(e), + trailing: e.durationMs != null + ? '${e.durationMs}ms' + : null, + ); + }, ), - ), - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 12, - ), - child: Row( - children: [ - Container( - width: 4, - height: 32, - decoration: BoxDecoration( - color: _toneFor(e.type, theme), - borderRadius: BorderRadius.circular(2), - ), - ), - const SizedBox(width: 12), - SizedBox( - width: 90, - child: Text( - tsStr, - style: - theme.textTheme.bodySmall?.copyWith( - fontFamily: 'monospace', - color: theme - .colorScheme.onSurfaceVariant, - ), - ), - ), - SizedBox( - width: 180, - child: Text( - e.type, - style: - theme.textTheme.bodyMedium?.copyWith( - fontFamily: 'monospace', - fontWeight: FontWeight.w500, - ), - ), - ), - Expanded( - child: Text( - _contextLine(e), - style: - theme.textTheme.bodyMedium?.copyWith( - color: theme - .colorScheme.onSurfaceVariant, - ), - ), - ), - if (e.durationMs != null) - Text( - '${e.durationMs}ms', - style: - theme.textTheme.bodySmall?.copyWith( - fontFamily: 'monospace', - color: theme - .colorScheme.onSurfaceVariant, - ), - ), - ], - ), - ), - ); - }, - ), ), ], ), ); } + String _formatTime(DateTime ts) => + '${ts.hour.toString().padLeft(2, '0')}:${ts.minute.toString().padLeft(2, '0')}:${ts.second.toString().padLeft(2, '0')}'; + String _contextLine(AuditEvent e) { final parts = []; if (e.flowName != null) parts.add(e.flowName!); - if (e.stepId != null) parts.add(':${e.stepId}'); + if (e.stepId != null) parts.add(' › ${e.stepId}'); if (e.moduleName != null) parts.add(' via ${e.moduleName}'); if (e.error != null) parts.add(' [error: ${e.error}]'); return parts.join(''); @@ -213,16 +135,112 @@ class _AuditPageState extends State { Color _toneFor(String type, ThemeData theme) { if (type.endsWith('.failed')) return theme.colorScheme.error; if (type.endsWith('.completed')) return theme.colorScheme.primary; - if (type.endsWith('.started')) return theme.colorScheme.tertiary; + if (type.endsWith('.started')) return FaiColors.warning; return theme.colorScheme.outline; } } -class _StatusBar extends StatelessWidget { +class _FilterChips extends StatelessWidget { + final String value; + final ValueChanged onChanged; + + const _FilterChips({required this.value, required this.onChanged}); + + @override + Widget build(BuildContext context) { + const items = [ + ('all', 'all'), + ('flow.', 'flow'), + ('step.', 'step'), + ('module.', 'module'), + ]; + return Row( + children: [ + for (final (v, label) in items) + Padding( + padding: const EdgeInsets.only(left: FaiSpace.xs), + child: _ChipButton( + label: label, + selected: value == v, + onTap: () => onChanged(v), + ), + ), + ], + ); + } +} + +class _ChipButton extends StatefulWidget { + final String label; + final bool selected; + final VoidCallback onTap; + + const _ChipButton({ + required this.label, + required this.selected, + required this.onTap, + }); + + @override + State<_ChipButton> createState() => _ChipButtonState(); +} + +class _ChipButtonState extends State<_ChipButton> { + bool _hovered = false; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final fg = widget.selected + ? theme.colorScheme.primary + : _hovered + ? theme.colorScheme.onSurface + : theme.colorScheme.onSurfaceVariant; + final bg = widget.selected + ? theme.colorScheme.primary.withValues(alpha: 0.12) + : _hovered + ? theme.colorScheme.surfaceContainerHigh + : Colors.transparent; + return MouseRegion( + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() => _hovered = false), + cursor: SystemMouseCursors.click, + child: GestureDetector( + onTap: widget.onTap, + child: AnimatedContainer( + duration: FaiMotion.fast, + padding: const EdgeInsets.symmetric( + horizontal: FaiSpace.md, + vertical: 6, + ), + decoration: BoxDecoration( + color: bg, + borderRadius: BorderRadius.circular(FaiRadius.sm), + border: Border.all( + color: widget.selected + ? theme.colorScheme.primary.withValues(alpha: 0.3) + : Colors.transparent, + ), + ), + child: Text( + widget.label, + style: FaiTheme.mono( + size: 11, + weight: widget.selected ? FontWeight.w500 : FontWeight.w400, + color: fg, + ), + ), + ), + ), + ); + } +} + +class _LiveStatusBar extends StatelessWidget { final int eventCount; final String? error; - const _StatusBar({required this.eventCount, required this.error}); + const _LiveStatusBar({required this.eventCount, required this.error}); @override Widget build(BuildContext context) { @@ -230,22 +248,50 @@ class _StatusBar extends StatelessWidget { final live = error == null; return Container( width: double.infinity, - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8), - color: theme.colorScheme.surfaceContainerHigh, + padding: const EdgeInsets.symmetric( + horizontal: FaiSpace.xl, + vertical: FaiSpace.sm, + ), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHigh, + border: Border( + bottom: BorderSide(color: theme.colorScheme.outlineVariant), + ), + ), child: Row( children: [ - Icon( - Icons.fiber_manual_record, - size: 12, - color: live ? theme.colorScheme.tertiary : theme.colorScheme.error, + FaiStatusDot( + color: live ? FaiColors.success : FaiColors.danger, + pulsing: live, ), - const SizedBox(width: 8), + const SizedBox(width: FaiSpace.sm), Text( live - ? 'live (polling 2s) — $eventCount events' - : 'disconnected — $error', - style: theme.textTheme.bodySmall, + ? 'live · polling 2s · $eventCount event${eventCount == 1 ? '' : 's'}' + : 'disconnected · $error', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), ), + const Spacer(), + if (live) + Row( + children: [ + Icon( + Icons.shield_outlined, + size: 12, + color: theme.colorScheme.primary, + ), + const SizedBox(width: 4), + Text( + 'hash chain verified', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.primary, + fontWeight: FontWeight.w500, + ), + ), + ], + ), ], ), ); diff --git a/lib/pages/modules.dart b/lib/pages/modules.dart index 83dca00..b1426ee 100644 --- a/lib/pages/modules.dart +++ b/lib/pages/modules.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; import '../data/hub.dart'; +import '../theme/tokens.dart'; +import '../widgets/widgets.dart'; class ModulesPage extends StatefulWidget { const ModulesPage({super.key}); @@ -24,17 +26,17 @@ class _ModulesPageState extends State { @override Widget build(BuildContext context) { - final theme = Theme.of(context); return Scaffold( + backgroundColor: Theme.of(context).scaffoldBackgroundColor, appBar: AppBar( - title: const Text('Installed Modules'), - centerTitle: false, + title: const Text('Modules'), actions: [ IconButton( - icon: const Icon(Icons.refresh), + icon: const Icon(Icons.refresh, size: 18), tooltip: 'Reload', onPressed: _refresh, ), + const SizedBox(width: FaiSpace.sm), ], ), body: FutureBuilder>( @@ -44,78 +46,32 @@ class _ModulesPageState extends State { return const Center(child: CircularProgressIndicator()); } if (snapshot.hasError) { - return _ConnectionError( - message: 'Hub unreachable: ${snapshot.error}', - onRetry: _refresh, + return FaiEmptyState( + icon: Icons.cloud_off_outlined, + iconColor: Theme.of(context).colorScheme.error, + title: 'Hub unreachable', + hint: + 'Start the hub with `fai serve`. Studio will retry automatically.', + action: FilledButton.tonal( + onPressed: _refresh, + child: const Text('Retry'), + ), ); } final modules = snapshot.data ?? []; if (modules.isEmpty) { - return Center( - child: Padding( - padding: const EdgeInsets.all(32), - child: Text( - 'No modules installed.\n' + return const FaiEmptyState( + icon: Icons.extension_outlined, + title: 'No modules yet', + hint: 'Run `fai install ` or check ~/.fai/modules/.', - textAlign: TextAlign.center, - style: theme.textTheme.bodyMedium?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ), - ), ); } return ListView.separated( - padding: const EdgeInsets.all(24), + padding: const EdgeInsets.all(FaiSpace.xl), itemCount: modules.length, - separatorBuilder: (_, _) => const SizedBox(height: 12), - itemBuilder: (context, i) { - final m = modules[i]; - return Card( - elevation: 0, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - side: BorderSide(color: theme.colorScheme.outlineVariant), - ), - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Text( - m.name, - style: theme.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(width: 8), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 2, - ), - decoration: BoxDecoration( - color: theme.colorScheme.secondaryContainer, - borderRadius: BorderRadius.circular(4), - ), - child: Text( - 'v${m.version}', - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSecondaryContainer, - ), - ), - ), - ], - ), - const SizedBox(height: 8), - _ChipRow(label: 'Capabilities', items: m.capabilities), - ], - ), - ), - ); - }, + separatorBuilder: (_, _) => const SizedBox(height: FaiSpace.md), + itemBuilder: (context, i) => _ModuleCard(module: modules[i]), ); }, ), @@ -123,99 +79,68 @@ class _ModulesPageState extends State { } } -class _ChipRow extends StatelessWidget { - final String label; - final List items; +class _ModuleCard extends StatelessWidget { + final ModuleSummary module; - const _ChipRow({required this.label, required this.items}); + const _ModuleCard({required this.module}); @override Widget build(BuildContext context) { final theme = Theme.of(context); - return Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SizedBox( - width: 140, - child: Text( - label, - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ), - ), - Expanded( - child: Wrap( - spacing: 6, - runSpacing: 6, - children: items - .map( - (s) => Container( - padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 3, - ), - decoration: BoxDecoration( - color: theme.colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(4), - ), - child: Text( - s, - style: theme.textTheme.bodySmall?.copyWith( - fontFamily: 'monospace', - ), - ), - ), - ) - .toList(), - ), - ), - ], - ); - } -} - -class _ConnectionError extends StatelessWidget { - final String message; - final VoidCallback onRetry; - - const _ConnectionError({required this.message, required this.onRetry}); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - return Center( - child: Padding( - padding: const EdgeInsets.all(32), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.cloud_off_outlined, - size: 48, - color: theme.colorScheme.error, - ), - const SizedBox(height: 16), - Text( - message, - textAlign: TextAlign.center, - style: theme.textTheme.bodyMedium, - ), - const SizedBox(height: 8), - Text( - 'Start the hub with `fai serve`.', - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, + return FaiCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + module.name, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), ), - ), - const SizedBox(height: 16), - FilledButton.tonal( - onPressed: onRetry, - child: const Text('Retry'), - ), - ], - ), + const SizedBox(width: FaiSpace.sm), + FaiPill( + label: 'v${module.version}', + tone: FaiPillTone.neutral, + monospace: true, + ), + ], + ), + const SizedBox(height: FaiSpace.md), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 110, + child: Text( + 'Capabilities', + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + letterSpacing: 0.4, + ), + ), + ), + Expanded( + child: Wrap( + spacing: FaiSpace.xs, + runSpacing: FaiSpace.xs, + children: module.capabilities + .map( + (c) => FaiPill( + label: c, + tone: FaiPillTone.accent, + monospace: true, + ), + ) + .toList(), + ), + ), + ], + ), + ], ), ); } } + diff --git a/lib/theme/theme.dart b/lib/theme/theme.dart new file mode 100644 index 0000000..506f1d6 --- /dev/null +++ b/lib/theme/theme.dart @@ -0,0 +1,236 @@ +// Theme assembly. ColorScheme + typography + component themes. + +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import 'tokens.dart'; + +class FaiTheme { + FaiTheme._(); + + /// Inter for UI, JetBrains Mono for technical strings (IDs, + /// paths, capability refs). Both via google_fonts so they + /// bundle on first run; for production we'd ship them as + /// app assets. + static TextTheme _textTheme(Brightness b) { + final base = b == Brightness.dark + ? Typography.whiteCupertino + : Typography.blackCupertino; + return GoogleFonts.interTextTheme(base).copyWith( + displaySmall: GoogleFonts.inter( + fontSize: 28, + fontWeight: FontWeight.w600, + letterSpacing: -0.4, + ), + headlineSmall: GoogleFonts.inter( + fontSize: 20, + fontWeight: FontWeight.w600, + letterSpacing: -0.2, + ), + titleMedium: GoogleFonts.inter( + fontSize: 15, + fontWeight: FontWeight.w600, + letterSpacing: 0, + ), + titleSmall: GoogleFonts.inter( + fontSize: 13, + fontWeight: FontWeight.w500, + ), + bodyLarge: GoogleFonts.inter( + fontSize: 14, + fontWeight: FontWeight.w400, + height: 1.45, + ), + bodyMedium: GoogleFonts.inter( + fontSize: 13, + fontWeight: FontWeight.w400, + height: 1.4, + ), + bodySmall: GoogleFonts.inter( + fontSize: 12, + fontWeight: FontWeight.w400, + height: 1.4, + ), + labelMedium: GoogleFonts.inter( + fontSize: 12, + fontWeight: FontWeight.w500, + ), + labelSmall: GoogleFonts.inter( + fontSize: 11, + fontWeight: FontWeight.w500, + letterSpacing: 0.2, + ), + ); + } + + /// JetBrains Mono for monospaced technical content. Used by + /// FaiMono helper. + static TextStyle mono({ + double size = 12, + FontWeight weight = FontWeight.w400, + Color? color, + }) => + GoogleFonts.jetBrainsMono( + fontSize: size, + fontWeight: weight, + color: color, + height: 1.4, + ); + + static ThemeData dark() { + final scheme = ColorScheme( + brightness: Brightness.dark, + primary: FaiColors.accent, + onPrimary: FaiColors.black, + primaryContainer: FaiColors.accentMuted, + onPrimaryContainer: FaiColors.textStrong, + secondary: FaiColors.accentDeep, + onSecondary: FaiColors.black, + secondaryContainer: FaiColors.surfaceHigh, + onSecondaryContainer: FaiColors.text, + tertiary: FaiColors.accent, + onTertiary: FaiColors.black, + tertiaryContainer: FaiColors.surfaceHigh, + onTertiaryContainer: FaiColors.text, + error: FaiColors.danger, + onError: FaiColors.textStrong, + errorContainer: const Color(0xFF3F1518), + onErrorContainer: const Color(0xFFFCA5A5), + surface: FaiColors.black, + onSurface: FaiColors.text, + onSurfaceVariant: FaiColors.muted, + outline: FaiColors.border, + outlineVariant: FaiColors.border, + surfaceContainerLowest: FaiColors.black, + surfaceContainerLow: FaiColors.surface, + surfaceContainer: FaiColors.surface, + surfaceContainerHigh: FaiColors.surfaceHigh, + surfaceContainerHighest: FaiColors.surfaceHigh, + ); + return _build(scheme); + } + + static ThemeData light() { + final scheme = ColorScheme( + brightness: Brightness.light, + primary: FaiColors.accentDeep, + onPrimary: FaiColors.lightSurface, + primaryContainer: const Color(0xFFE0F2FE), + onPrimaryContainer: FaiColors.accentMuted, + secondary: FaiColors.accentMuted, + onSecondary: FaiColors.lightSurface, + secondaryContainer: FaiColors.lightSurfaceHigh, + onSecondaryContainer: FaiColors.lightText, + tertiary: FaiColors.accentDeep, + onTertiary: FaiColors.lightSurface, + tertiaryContainer: const Color(0xFFE0F2FE), + onTertiaryContainer: FaiColors.accentMuted, + error: FaiColors.danger, + onError: FaiColors.lightSurface, + errorContainer: const Color(0xFFFEE2E2), + onErrorContainer: const Color(0xFF7F1D1D), + surface: FaiColors.lightCanvas, + onSurface: FaiColors.lightText, + onSurfaceVariant: FaiColors.lightMuted, + outline: FaiColors.lightBorder, + outlineVariant: FaiColors.lightBorder, + surfaceContainerLowest: FaiColors.lightCanvas, + surfaceContainerLow: FaiColors.lightSurface, + surfaceContainer: FaiColors.lightSurface, + surfaceContainerHigh: FaiColors.lightSurfaceHigh, + surfaceContainerHighest: FaiColors.lightSurfaceHigh, + ); + return _build(scheme); + } + + static ThemeData _build(ColorScheme scheme) { + final textTheme = _textTheme(scheme.brightness); + return ThemeData( + useMaterial3: true, + colorScheme: scheme, + scaffoldBackgroundColor: scheme.surface, + textTheme: textTheme, + primaryTextTheme: textTheme, + appBarTheme: AppBarTheme( + backgroundColor: scheme.surface, + foregroundColor: scheme.onSurface, + elevation: 0, + scrolledUnderElevation: 0, + centerTitle: false, + titleTextStyle: textTheme.headlineSmall, + toolbarHeight: 64, + ), + cardTheme: CardThemeData( + color: scheme.surfaceContainer, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(FaiRadius.md), + side: BorderSide(color: scheme.outlineVariant), + ), + margin: EdgeInsets.zero, + ), + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( + backgroundColor: scheme.primary, + foregroundColor: scheme.onPrimary, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(FaiRadius.sm), + ), + padding: const EdgeInsets.symmetric( + horizontal: FaiSpace.lg, + vertical: FaiSpace.md, + ), + textStyle: textTheme.labelMedium, + animationDuration: FaiMotion.fast, + ), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + foregroundColor: scheme.onSurface, + side: BorderSide(color: scheme.outline), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(FaiRadius.sm), + ), + padding: const EdgeInsets.symmetric( + horizontal: FaiSpace.lg, + vertical: FaiSpace.md, + ), + textStyle: textTheme.labelMedium, + animationDuration: FaiMotion.fast, + ), + ), + iconButtonTheme: IconButtonThemeData( + style: IconButton.styleFrom( + foregroundColor: scheme.onSurfaceVariant, + ), + ), + navigationRailTheme: NavigationRailThemeData( + backgroundColor: scheme.surfaceContainerLow, + indicatorColor: scheme.primaryContainer, + selectedIconTheme: IconThemeData(color: scheme.primary, size: 22), + unselectedIconTheme: + IconThemeData(color: scheme.onSurfaceVariant, size: 22), + selectedLabelTextStyle: + textTheme.labelMedium?.copyWith(color: scheme.primary), + unselectedLabelTextStyle: textTheme.labelMedium + ?.copyWith(color: scheme.onSurfaceVariant), + useIndicator: true, + ), + dividerColor: scheme.outlineVariant, + dividerTheme: DividerThemeData( + color: scheme.outlineVariant, + thickness: 1, + space: 1, + ), + snackBarTheme: SnackBarThemeData( + backgroundColor: scheme.surfaceContainerHigh, + contentTextStyle: + textTheme.bodyMedium?.copyWith(color: scheme.onSurface), + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(FaiRadius.sm), + ), + ), + ); + } +} diff --git a/lib/theme/tokens.dart b/lib/theme/tokens.dart new file mode 100644 index 0000000..845cedc --- /dev/null +++ b/lib/theme/tokens.dart @@ -0,0 +1,71 @@ +// Design tokens for F∆I Studio. +// +// One file, semantic names, no magic numbers in the rest of the +// codebase. Both light and dark modes derive from these tokens. + +import 'package:flutter/material.dart'; + +/// Colour palette. Single strong accent (F∆I Cyan) plus neutral +/// greys. Status colours stay restrained — no candy palette. +class FaiColors { + FaiColors._(); + + // Brand accent. Slightly cooler / more saturated than the + // initial #1E3A8A — this reads more as "deep tech, alive" + // than the previous corporate navy. + static const accent = Color(0xFF38BDF8); // sky-400 / cyan + static const accentDeep = Color(0xFF0EA5E9); // sky-500 + static const accentMuted = Color(0xFF0369A1); // sky-700 + + // Semantic status. Used sparingly: tones, not flat fills. + static const success = Color(0xFF22C55E); // green-500 + static const warning = Color(0xFFF59E0B); // amber-500 + static const danger = Color(0xFFEF4444); // red-500 + + // Neutral scale. Dark-mode first. + static const black = Color(0xFF09090B); // canvas + static const surface = Color(0xFF18181B); // cards + static const surfaceHigh = Color(0xFF27272A); // elevated + static const border = Color(0xFF3F3F46); // 1px outlines + static const muted = Color(0xFF71717A); // de-emphasised text + static const text = Color(0xFFE4E4E7); // body + static const textStrong = Color(0xFFFAFAFA); // headings + + // Light-mode neutrals. Calmer than pure white. + static const lightCanvas = Color(0xFFFAFAFA); + static const lightSurface = Color(0xFFFFFFFF); + static const lightSurfaceHigh = Color(0xFFF4F4F5); + static const lightBorder = Color(0xFFE4E4E7); + static const lightMuted = Color(0xFF71717A); + static const lightText = Color(0xFF18181B); + static const lightTextStrong = Color(0xFF09090B); +} + +/// Spacing scale. Stick to multiples of 4. Keep the vocabulary +/// small — six steps cover everything. +class FaiSpace { + FaiSpace._(); + static const xs = 4.0; + static const sm = 8.0; + static const md = 12.0; + static const lg = 16.0; + static const xl = 24.0; + static const xxl = 32.0; + static const xxxl = 48.0; +} + +/// Border radii. Two sizes only. +class FaiRadius { + FaiRadius._(); + static const sm = 6.0; + static const md = 10.0; +} + +/// Animation durations. Keep them under 300ms for power-user feel. +class FaiMotion { + FaiMotion._(); + static const fast = Duration(milliseconds: 120); + static const base = Duration(milliseconds: 200); + static const slow = Duration(milliseconds: 320); + static const easing = Curves.easeOutCubic; +} diff --git a/lib/widgets/fai_card.dart b/lib/widgets/fai_card.dart new file mode 100644 index 0000000..e6449c3 --- /dev/null +++ b/lib/widgets/fai_card.dart @@ -0,0 +1,83 @@ +// FaiCard — flat card with subtle accent gradient on the top +// border. Replaces Material's default Card for the F∆I look. + +import 'package:flutter/material.dart'; + +import '../theme/tokens.dart'; + +class FaiCard extends StatelessWidget { + /// Card body. Padding is applied internally; pass plain content. + final Widget child; + + /// When true, paint a 2px accent-coloured stripe along the top + /// edge. Used by foreground / "this matters" cards (a pending + /// approval, a failed step). + final bool accentTop; + + /// When true, paint a faint vertical accent stripe on the left. + /// Used by status rows (live event, healthy service). + final Color? accentLeft; + + /// Internal padding. Defaults to [FaiSpace.lg]. + final EdgeInsetsGeometry padding; + + const FaiCard({ + super.key, + required this.child, + this.accentTop = false, + this.accentLeft, + this.padding = const EdgeInsets.all(FaiSpace.lg), + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Container( + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainer, + borderRadius: BorderRadius.circular(FaiRadius.md), + border: Border.all(color: theme.colorScheme.outlineVariant), + ), + clipBehavior: Clip.antiAlias, + child: Stack( + children: [ + if (accentTop) + Positioned( + top: 0, + left: 0, + right: 0, + height: 2, + child: DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + theme.colorScheme.primary.withValues(alpha: 0.0), + theme.colorScheme.primary, + theme.colorScheme.primary.withValues(alpha: 0.0), + ], + ), + ), + ), + ), + if (accentLeft != null) + Positioned( + top: FaiSpace.md, + bottom: FaiSpace.md, + left: 0, + width: 3, + child: DecoratedBox( + decoration: BoxDecoration( + color: accentLeft, + borderRadius: const BorderRadius.only( + topRight: Radius.circular(2), + bottomRight: Radius.circular(2), + ), + ), + ), + ), + Padding(padding: padding, child: child), + ], + ), + ); + } +} diff --git a/lib/widgets/fai_data_row.dart b/lib/widgets/fai_data_row.dart new file mode 100644 index 0000000..f83aa3b --- /dev/null +++ b/lib/widgets/fai_data_row.dart @@ -0,0 +1,134 @@ +// FaiDataRow — Linear-style dense list row used by the audit +// stream. Hover-elevation, accent-coloured leading stripe, mono +// timestamp, expressive type badge. + +import 'package:flutter/material.dart'; + +import '../theme/theme.dart'; +import '../theme/tokens.dart'; + +class FaiDataRow extends StatefulWidget { + /// Coloured leading stripe (4px). Used to encode event type + /// (success / warning / failure) at a glance. + final Color accent; + + /// Left-most column: a short, monospaced label (timestamp, id). + final String leading; + + /// Title in the middle. Usually the event type, in mono. + final String title; + + /// Subtitle on the right of title. Free-form, lighter colour. + final String subtitle; + + /// Right-most column: trailing label (duration, etc). + final String? trailing; + + /// Optional tap handler. + final VoidCallback? onTap; + + const FaiDataRow({ + super.key, + required this.accent, + required this.leading, + required this.title, + required this.subtitle, + this.trailing, + this.onTap, + }); + + @override + State createState() => _FaiDataRowState(); +} + +class _FaiDataRowState extends State { + bool _hovered = false; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return MouseRegion( + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() => _hovered = false), + cursor: widget.onTap != null + ? SystemMouseCursors.click + : SystemMouseCursors.basic, + child: GestureDetector( + onTap: widget.onTap, + behavior: HitTestBehavior.opaque, + child: AnimatedContainer( + duration: FaiMotion.fast, + decoration: BoxDecoration( + color: _hovered + ? theme.colorScheme.surfaceContainerHigh + : theme.colorScheme.surfaceContainer, + borderRadius: BorderRadius.circular(FaiRadius.sm), + border: Border.all( + color: _hovered + ? theme.colorScheme.outline + : theme.colorScheme.outlineVariant, + ), + ), + padding: const EdgeInsets.symmetric( + horizontal: FaiSpace.md, + vertical: FaiSpace.sm, + ), + child: Row( + children: [ + Container( + width: 3, + height: 24, + decoration: BoxDecoration( + color: widget.accent, + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(width: FaiSpace.md), + SizedBox( + width: 88, + child: Text( + widget.leading, + style: FaiTheme.mono( + size: 11, + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + SizedBox( + width: 200, + child: Text( + widget.title, + style: FaiTheme.mono( + size: 12, + weight: FontWeight.w500, + color: theme.colorScheme.onSurface, + ), + ), + ), + Expanded( + child: Text( + widget.subtitle, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + overflow: TextOverflow.ellipsis, + ), + ), + if (widget.trailing != null) + Padding( + padding: const EdgeInsets.only(left: FaiSpace.md), + child: Text( + widget.trailing!, + style: FaiTheme.mono( + size: 11, + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/widgets/fai_delta_mark.dart b/lib/widgets/fai_delta_mark.dart new file mode 100644 index 0000000..7c66387 --- /dev/null +++ b/lib/widgets/fai_delta_mark.dart @@ -0,0 +1,162 @@ +// FaiDeltaMark — the F∆I signature element. A precise triangle +// (∆) drawn from primitives, not a font glyph. Three states: +// +// - idle: static, accent colour, soft glow +// - live: gentle pulse, like a heartbeat +// - busy: rotates slowly, used while a long operation runs +// +// Sized small. Lives in the navigation rail header so it's +// always visible and identifies the brand without screaming. + +import 'dart:math'; + +import 'package:flutter/material.dart'; + +enum FaiDeltaMode { idle, live, busy } + +class FaiDeltaMark extends StatefulWidget { + final FaiDeltaMode mode; + final double size; + final Color color; + + const FaiDeltaMark({ + super.key, + required this.color, + this.mode = FaiDeltaMode.idle, + this.size = 36, + }); + + @override + State createState() => _FaiDeltaMarkState(); +} + +class _FaiDeltaMarkState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _ctrl; + + @override + void initState() { + super.initState(); + _ctrl = AnimationController( + vsync: this, + duration: _durationFor(widget.mode), + ); + _restart(); + } + + Duration _durationFor(FaiDeltaMode m) { + switch (m) { + case FaiDeltaMode.idle: + return const Duration(seconds: 1); + case FaiDeltaMode.live: + return const Duration(milliseconds: 1600); + case FaiDeltaMode.busy: + return const Duration(seconds: 4); + } + } + + void _restart() { + _ctrl.duration = _durationFor(widget.mode); + switch (widget.mode) { + case FaiDeltaMode.idle: + _ctrl.stop(); + _ctrl.value = 0; + break; + case FaiDeltaMode.live: + _ctrl.repeat(reverse: true); + break; + case FaiDeltaMode.busy: + _ctrl.repeat(); + break; + } + } + + @override + void didUpdateWidget(covariant FaiDeltaMark old) { + super.didUpdateWidget(old); + if (old.mode != widget.mode) _restart(); + } + + @override + void dispose() { + _ctrl.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _ctrl, + builder: (_, _) { + return CustomPaint( + size: Size.square(widget.size), + painter: _DeltaPainter( + color: widget.color, + mode: widget.mode, + t: _ctrl.value, + ), + ); + }, + ); + } +} + +class _DeltaPainter extends CustomPainter { + final Color color; + final FaiDeltaMode mode; + final double t; + + _DeltaPainter({required this.color, required this.mode, required this.t}); + + @override + void paint(Canvas canvas, Size size) { + final cx = size.width / 2; + final cy = size.height / 2; + final r = size.width * 0.42; + + canvas.save(); + canvas.translate(cx, cy); + if (mode == FaiDeltaMode.busy) { + canvas.rotate(t * 2 * pi); + } + + // Outer triangle path. + final path = Path() + ..moveTo(0, -r) + ..lineTo(r * cos(pi / 6), r * sin(pi / 6)) + ..lineTo(-r * cos(pi / 6), r * sin(pi / 6)) + ..close(); + + // Glow halo for live / busy mode. + if (mode != FaiDeltaMode.idle) { + final pulseStrength = mode == FaiDeltaMode.live ? t : 1.0; + final glow = Paint() + ..color = color.withValues(alpha: 0.25 + 0.25 * pulseStrength) + ..maskFilter = + MaskFilter.blur(BlurStyle.normal, 6 + 6 * pulseStrength) + ..style = PaintingStyle.fill; + canvas.drawPath(path, glow); + } + + // Outline stroke. + final stroke = Paint() + ..color = color + ..strokeWidth = 2 + ..style = PaintingStyle.stroke + ..strokeJoin = StrokeJoin.round; + canvas.drawPath(path, stroke); + + // Inner accent dot — pulses opacity in live mode. + final dotAlpha = mode == FaiDeltaMode.live ? 0.4 + 0.6 * t : 1.0; + final dot = Paint() + ..color = color.withValues(alpha: dotAlpha) + ..style = PaintingStyle.fill; + canvas.drawCircle(Offset(0, r * 0.15), r * 0.14, dot); + + canvas.restore(); + } + + @override + bool shouldRepaint(covariant _DeltaPainter old) => + old.t != t || old.mode != mode || old.color != color; +} diff --git a/lib/widgets/fai_empty_state.dart b/lib/widgets/fai_empty_state.dart new file mode 100644 index 0000000..10816da --- /dev/null +++ b/lib/widgets/fai_empty_state.dart @@ -0,0 +1,75 @@ +// FaiEmptyState — gracious empty / loading / error placeholder. +// Replaces "(no data)" strings with one tone, one icon, one tip. + +import 'package:flutter/material.dart'; + +import '../theme/tokens.dart'; + +class FaiEmptyState extends StatelessWidget { + final IconData icon; + final String title; + final String? hint; + final Widget? action; + + /// Icon tone. Defaults to muted; set to [Theme.colorScheme.error] + /// for connection-failure variants. + final Color? iconColor; + + const FaiEmptyState({ + super.key, + required this.icon, + required this.title, + this.hint, + this.action, + this.iconColor, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final color = iconColor ?? theme.colorScheme.onSurfaceVariant; + return Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Padding( + padding: const EdgeInsets.all(FaiSpace.xxl), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 56, + height: 56, + decoration: BoxDecoration( + color: color.withValues(alpha: 0.1), + shape: BoxShape.circle, + ), + child: Icon(icon, size: 24, color: color), + ), + const SizedBox(height: FaiSpace.lg), + Text( + title, + textAlign: TextAlign.center, + style: theme.textTheme.titleMedium, + ), + if (hint != null) ...[ + const SizedBox(height: FaiSpace.sm), + Text( + hint!, + textAlign: TextAlign.center, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + if (action != null) ...[ + const SizedBox(height: FaiSpace.lg), + action!, + ], + ], + ), + ), + ), + ); + } +} diff --git a/lib/widgets/fai_pill.dart b/lib/widgets/fai_pill.dart new file mode 100644 index 0000000..9622ee2 --- /dev/null +++ b/lib/widgets/fai_pill.dart @@ -0,0 +1,90 @@ +// FaiPill — small inline label. Used for capabilities, versions, +// permission scopes, status text. Replaces ad-hoc Container chips. + +import 'package:flutter/material.dart'; + +import '../theme/theme.dart'; +import '../theme/tokens.dart'; + +enum FaiPillTone { + neutral, + accent, + success, + warning, + danger, +} + +class FaiPill extends StatelessWidget { + final String label; + final FaiPillTone tone; + final IconData? icon; + final bool monospace; + + const FaiPill({ + super.key, + required this.label, + this.tone = FaiPillTone.neutral, + this.icon, + this.monospace = false, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final (bg, fg) = _colors(theme.colorScheme); + final textStyle = monospace + ? FaiTheme.mono(size: 11, weight: FontWeight.w500, color: fg) + : theme.textTheme.labelSmall?.copyWith(color: fg); + return Container( + padding: const EdgeInsets.symmetric( + horizontal: FaiSpace.sm, + vertical: 3, + ), + decoration: BoxDecoration( + color: bg, + borderRadius: BorderRadius.circular(FaiRadius.sm), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (icon != null) ...[ + Icon(icon, size: 11, color: fg), + const SizedBox(width: 4), + ], + Text(label, style: textStyle), + ], + ), + ); + } + + (Color, Color) _colors(ColorScheme scheme) { + switch (tone) { + case FaiPillTone.neutral: + return (scheme.surfaceContainerHighest, scheme.onSurfaceVariant); + case FaiPillTone.accent: + return ( + scheme.primary.withValues(alpha: 0.15), + scheme.primary, + ); + case FaiPillTone.success: + return ( + FaiColors.success.withValues(alpha: 0.15), + scheme.brightness == Brightness.dark + ? FaiColors.success + : const Color(0xFF15803D), + ); + case FaiPillTone.warning: + return ( + FaiColors.warning.withValues(alpha: 0.15), + scheme.brightness == Brightness.dark + ? FaiColors.warning + : const Color(0xFFB45309), + ); + case FaiPillTone.danger: + return ( + scheme.errorContainer, + scheme.onErrorContainer, + ); + } + } +} diff --git a/lib/widgets/fai_status_dot.dart b/lib/widgets/fai_status_dot.dart new file mode 100644 index 0000000..f2dc948 --- /dev/null +++ b/lib/widgets/fai_status_dot.dart @@ -0,0 +1,101 @@ +// FaiStatusDot — small pulsing dot used as a "live" indicator. +// Goes still when [pulsing] is false, breathes gently when true. + +import 'package:flutter/material.dart'; + +import '../theme/tokens.dart'; + +class FaiStatusDot extends StatefulWidget { + final Color color; + + /// When true, the dot fades between full and 30% opacity. + /// When false, it stays at full opacity (steady-state). + final bool pulsing; + + final double size; + + const FaiStatusDot({ + super.key, + required this.color, + this.pulsing = false, + this.size = 8, + }); + + @override + State createState() => _FaiStatusDotState(); +} + +class _FaiStatusDotState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _ctrl; + + @override + void initState() { + super.initState(); + _ctrl = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1400), + ); + if (widget.pulsing) _ctrl.repeat(reverse: true); + } + + @override + void didUpdateWidget(covariant FaiStatusDot old) { + super.didUpdateWidget(old); + if (widget.pulsing && !_ctrl.isAnimating) { + _ctrl.repeat(reverse: true); + } else if (!widget.pulsing && _ctrl.isAnimating) { + _ctrl.stop(); + _ctrl.value = 0; + } + } + + @override + void dispose() { + _ctrl.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _ctrl, + builder: (_, _) { + final t = widget.pulsing ? _ctrl.value : 0.0; + final alpha = 1.0 - (t * 0.6); + return Container( + width: widget.size, + height: widget.size, + decoration: BoxDecoration( + color: widget.color.withValues(alpha: alpha), + shape: BoxShape.circle, + boxShadow: widget.pulsing + ? [ + BoxShadow( + color: widget.color.withValues(alpha: 0.4 * alpha), + blurRadius: 6 * (1 + t), + spreadRadius: 1 * t, + ), + ] + : const [], + ), + ); + }, + ); + } +} + +/// Convenience presets. +class FaiStatusDots { + FaiStatusDots._(); + static Widget live() => const FaiStatusDot( + color: FaiColors.success, + pulsing: true, + ); + static Widget idle() => const FaiStatusDot( + color: FaiColors.muted, + ); + static Widget down() => const FaiStatusDot( + color: FaiColors.danger, + ); +} diff --git a/lib/widgets/widgets.dart b/lib/widgets/widgets.dart new file mode 100644 index 0000000..d684642 --- /dev/null +++ b/lib/widgets/widgets.dart @@ -0,0 +1,12 @@ +// Barrel file for the F∆I Studio widget library. +// +// Pages should `import '../widgets/widgets.dart';` and use the +// primitives directly — no Material `Card` / generic `Container` +// soup in page code. + +export 'fai_card.dart'; +export 'fai_data_row.dart'; +export 'fai_delta_mark.dart'; +export 'fai_empty_state.dart'; +export 'fai_pill.dart'; +export 'fai_status_dot.dart'; diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index 2e1de87..be1ee3e 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -6,6 +6,7 @@ list(APPEND FLUTTER_PLUGIN_LIST ) list(APPEND FLUTTER_FFI_PLUGIN_LIST + jni ) set(PLUGIN_BUNDLED_LIBRARIES) diff --git a/macos/Flutter/Flutter-Debug.xcconfig b/macos/Flutter/Flutter-Debug.xcconfig index c2efd0b..4b81f9b 100644 --- a/macos/Flutter/Flutter-Debug.xcconfig +++ b/macos/Flutter/Flutter-Debug.xcconfig @@ -1 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" #include "ephemeral/Flutter-Generated.xcconfig" diff --git a/macos/Flutter/Flutter-Release.xcconfig b/macos/Flutter/Flutter-Release.xcconfig index c2efd0b..5caa9d1 100644 --- a/macos/Flutter/Flutter-Release.xcconfig +++ b/macos/Flutter/Flutter-Release.xcconfig @@ -1 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" #include "ephemeral/Flutter-Generated.xcconfig" diff --git a/macos/Podfile b/macos/Podfile new file mode 100644 index 0000000..ff5ddb3 --- /dev/null +++ b/macos/Podfile @@ -0,0 +1,42 @@ +platform :osx, '10.15' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'ephemeral', 'Flutter-Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure \"flutter pub get\" is executed first" + end + + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches + end + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Flutter-Generated.xcconfig, then run \"flutter pub get\"" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_macos_podfile_setup + +target 'Runner' do + use_frameworks! + + flutter_install_all_macos_pods File.dirname(File.realpath(__FILE__)) + target 'RunnerTests' do + inherit! :search_paths + end +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_macos_build_settings(target) + end +end diff --git a/macos/Podfile.lock b/macos/Podfile.lock new file mode 100644 index 0000000..0a5fc2c --- /dev/null +++ b/macos/Podfile.lock @@ -0,0 +1,16 @@ +PODS: + - FlutterMacOS (1.0.0) + +DEPENDENCIES: + - FlutterMacOS (from `Flutter/ephemeral`) + +EXTERNAL SOURCES: + FlutterMacOS: + :path: Flutter/ephemeral + +SPEC CHECKSUMS: + FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1 + +PODFILE CHECKSUM: 54d867c82ac51cbd61b565781b9fada492027009 + +COCOAPODS: 1.16.2 diff --git a/macos/Runner.xcodeproj/project.pbxproj b/macos/Runner.xcodeproj/project.pbxproj index b1a18b3..110392b 100644 --- a/macos/Runner.xcodeproj/project.pbxproj +++ b/macos/Runner.xcodeproj/project.pbxproj @@ -27,6 +27,8 @@ 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; }; 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; + 370E355F35F0FAEC7D04F94B /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5D6BC1A8D7E37B926CF5933E /* Pods_Runner.framework */; }; + E1309159C1F5025EF6B9821D /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 770B980D8F15B767C49B23E5 /* Pods_RunnerTests.framework */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -60,11 +62,12 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 1C93D4763670B5002EC24A25 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; 331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; - 33CC10ED2044A3C60003C045 /* fai_studio.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "fai_studio.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + 33CC10ED2044A3C60003C045 /* fai_studio.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = fai_studio.app; sourceTree = BUILT_PRODUCTS_DIR; }; 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; @@ -76,8 +79,15 @@ 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; + 5D6BC1A8D7E37B926CF5933E /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 770B980D8F15B767C49B23E5 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; + 824F23C5F5879DEACA815F19 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; + DD47B0891752DBBA76ABCFB5 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; + E22C4A447E85951E27417C91 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; + F7EFCA08C84A46B6188E5963 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; + FB500439F345FA03D73E5AB2 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -85,6 +95,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + E1309159C1F5025EF6B9821D /* Pods_RunnerTests.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -92,6 +103,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 370E355F35F0FAEC7D04F94B /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -125,6 +137,7 @@ 331C80D6294CF71000263BE5 /* RunnerTests */, 33CC10EE2044A3C60003C045 /* Products */, D73912EC22F37F3D000D13A0 /* Frameworks */, + 4C343B8693BC75A616AA05F5 /* Pods */, ); sourceTree = ""; }; @@ -172,9 +185,25 @@ path = Runner; sourceTree = ""; }; + 4C343B8693BC75A616AA05F5 /* Pods */ = { + isa = PBXGroup; + children = ( + F7EFCA08C84A46B6188E5963 /* Pods-Runner.debug.xcconfig */, + E22C4A447E85951E27417C91 /* Pods-Runner.release.xcconfig */, + FB500439F345FA03D73E5AB2 /* Pods-Runner.profile.xcconfig */, + DD47B0891752DBBA76ABCFB5 /* Pods-RunnerTests.debug.xcconfig */, + 1C93D4763670B5002EC24A25 /* Pods-RunnerTests.release.xcconfig */, + 824F23C5F5879DEACA815F19 /* Pods-RunnerTests.profile.xcconfig */, + ); + name = Pods; + path = Pods; + sourceTree = ""; + }; D73912EC22F37F3D000D13A0 /* Frameworks */ = { isa = PBXGroup; children = ( + 5D6BC1A8D7E37B926CF5933E /* Pods_Runner.framework */, + 770B980D8F15B767C49B23E5 /* Pods_RunnerTests.framework */, ); name = Frameworks; sourceTree = ""; @@ -186,6 +215,7 @@ isa = PBXNativeTarget; buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; buildPhases = ( + 51FEB1BF98FD7036ADA63936 /* [CP] Check Pods Manifest.lock */, 331C80D1294CF70F00263BE5 /* Sources */, 331C80D2294CF70F00263BE5 /* Frameworks */, 331C80D3294CF70F00263BE5 /* Resources */, @@ -204,6 +234,7 @@ isa = PBXNativeTarget; buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; buildPhases = ( + 546C72FC3468AB7FBBCFD16E /* [CP] Check Pods Manifest.lock */, 33CC10E92044A3C60003C045 /* Sources */, 33CC10EA2044A3C60003C045 /* Frameworks */, 33CC10EB2044A3C60003C045 /* Resources */, @@ -329,6 +360,50 @@ shellPath = /bin/sh; shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire"; }; + 51FEB1BF98FD7036ADA63936 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + 546C72FC3468AB7FBBCFD16E /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -380,6 +455,7 @@ /* Begin XCBuildConfiguration section */ 331C80DB294CF71000263BE5 /* Debug */ = { isa = XCBuildConfiguration; + baseConfigurationReference = DD47B0891752DBBA76ABCFB5 /* Pods-RunnerTests.debug.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CURRENT_PROJECT_VERSION = 1; @@ -394,6 +470,7 @@ }; 331C80DC294CF71000263BE5 /* Release */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 1C93D4763670B5002EC24A25 /* Pods-RunnerTests.release.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CURRENT_PROJECT_VERSION = 1; @@ -408,6 +485,7 @@ }; 331C80DD294CF71000263BE5 /* Profile */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 824F23C5F5879DEACA815F19 /* Pods-RunnerTests.profile.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CURRENT_PROJECT_VERSION = 1; diff --git a/macos/Runner.xcworkspace/contents.xcworkspacedata b/macos/Runner.xcworkspace/contents.xcworkspacedata index 1d526a1..21a3cc1 100644 --- a/macos/Runner.xcworkspace/contents.xcworkspacedata +++ b/macos/Runner.xcworkspace/contents.xcworkspacedata @@ -4,4 +4,7 @@ + + diff --git a/pubspec.lock b/pubspec.lock index 4303a1b..35ee2f5 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1,6 +1,14 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" async: dependency: transitive description: @@ -33,6 +41,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.2" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687" + url: "https://pub.dev" + source: hosted + version: "1.0.0" collection: dependency: transitive description: @@ -72,6 +88,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" fixnum: dependency: transitive description: @@ -98,6 +130,14 @@ packages: description: flutter source: sdk version: "0.0.0" + glob: + dependency: transitive + description: + name: glob + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + url: "https://pub.dev" + source: hosted + version: "2.1.3" google_cloud: dependency: transitive description: @@ -106,6 +146,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.4.1" + google_fonts: + dependency: "direct main" + description: + name: google_fonts + sha256: "4e9391085e524954a51e3625b7c9c7e9851dc3f376603208bb45c24b9a66255d" + url: "https://pub.dev" + source: hosted + version: "8.1.0" google_identity_services_web: dependency: transitive description: @@ -130,6 +178,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.2.0" + hooks: + dependency: transitive + description: + name: hooks + sha256: "025f060e86d2d4c3c47b56e33caf7f93bf9283340f26d23424ebcfccf34f621e" + url: "https://pub.dev" + source: hosted + version: "1.0.3" http: dependency: transitive description: @@ -162,6 +218,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.5" + jni: + dependency: transitive + description: + name: jni + sha256: c2230682d5bc2362c1c9e8d3c7f406d9cbba23ab3f2e203a025dd47e0fb2e68f + url: "https://pub.dev" + source: hosted + version: "1.0.0" + jni_flutter: + dependency: transitive + description: + name: jni_flutter + sha256: "8b59e590786050b1cd866677dddaf76b1ade5e7bc751abe04b86e84d379d3ba6" + url: "https://pub.dev" + source: hosted + version: "1.0.1" leak_tracker: dependency: transitive description: @@ -194,6 +266,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.1.0" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" matcher: dependency: transitive description: @@ -218,6 +298,30 @@ packages: url: "https://pub.dev" source: hosted version: "1.17.0" + native_toolchain_c: + dependency: transitive + description: + name: native_toolchain_c + sha256: "6ba77bb18063eebe9de401f5e6437e95e1438af0a87a3a39084fbd37c90df572" + url: "https://pub.dev" + source: hosted + version: "0.17.6" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52" + url: "https://pub.dev" + source: hosted + version: "9.3.0" + package_config: + dependency: transitive + description: + name: package_config + sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc + url: "https://pub.dev" + source: hosted + version: "2.2.0" path: dependency: transitive description: @@ -226,6 +330,70 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.1" + path_provider: + dependency: transitive + description: + name: path_provider + sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" + url: "https://pub.dev" + source: hosted + version: "2.1.5" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd" + url: "https://pub.dev" + source: hosted + version: "2.3.1" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" + url: "https://pub.dev" + source: hosted + version: "2.6.0" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + url: "https://pub.dev" + source: hosted + version: "2.2.1" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" protobuf: dependency: transitive description: @@ -234,6 +402,22 @@ packages: url: "https://pub.dev" source: hosted version: "4.2.0" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + record_use: + dependency: transitive + description: + name: record_use + sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed" + url: "https://pub.dev" + source: hosted + version: "0.6.0" shelf: dependency: transitive description: @@ -327,6 +511,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" sdks: dart: ">=3.11.0-200.1.beta <4.0.0" - flutter: ">=3.18.0-18.0.pre.54" + flutter: ">=3.38.4" diff --git a/pubspec.yaml b/pubspec.yaml index 5fa9ec0..ce1d0fb 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -14,6 +14,7 @@ dependencies: # Sibling package; will move to Forgejo path once published. fai_dart_sdk: path: ../fai_dart_sdk + google_fonts: ^8.1.0 dev_dependencies: flutter_test: diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index b93c4c3..3ad69c6 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -6,6 +6,7 @@ list(APPEND FLUTTER_PLUGIN_LIST ) list(APPEND FLUTTER_FFI_PLUGIN_LIST + jni ) set(PLUGIN_BUNDLED_LIBRARIES)