feat(ui): warm-minimalism redesign — design tokens + 6 primitives + animated ∆

Replaces the Material-default look with a deliberate visual
language. Single accent (sky-cyan), Inter + JetBrains Mono via
google_fonts, dense rows over puffy cards, micro-interactions
under 250ms. Dark-first, light reaches parity.

New foundation under lib/theme/:
- tokens.dart   FaiColors / FaiSpace / FaiRadius / FaiMotion
- theme.dart    ColorScheme + typography + component themes for
                light and dark, plus FaiTheme.mono helper for
                technical strings (IDs, paths, capability refs).

Six primitives under lib/widgets/:
- FaiCard            flat card, optional accent stripe (top or
                     left). No shadows.
- FaiPill            small inline label with five tones
                     (neutral / accent / success / warning /
                     danger), optional mono and leading icon.
- FaiStatusDot       breathing dot, used as a "live" indicator.
- FaiDataRow         Linear-style dense row for the audit
                     stream — hover-elevation, mono leading,
                     coloured leading stripe.
- FaiEmptyState      gracious icon + title + hint + action,
                     replaces "(no data)" everywhere.
- FaiDeltaMark       the ∆ signature element. Three modes —
                     idle (still), live (gentle pulse), busy
                     (slow rotation). Drawn from primitives,
                     not a font glyph. Lives in the sidebar
                     header so the brand is always visible.

Page-level changes:
- main.dart        custom 220px sidebar replaces the Material
                   NavigationRail. ∆ on top, hub-connection
                   pill below it, hover-animated destinations,
                   page transitions are 200ms slide+fade.
- modules.dart     FaiCard rows with capability pills.
- audit.dart       FaiDataRow stream, segmented filter chips,
                   live status bar with hash-chain badge.
- approvals.dart   FaiCard with accent-top stripe, structured
                   action footer, toast on approve/reject,
                   themed reject-reason dialog.

google_fonts added as dep. flutter analyze clean. flutter test
2/2. flutter build macos --debug succeeds.

Bumps fai_studio 0.2.0 -> 0.3.0.

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
flemming-it 2026-05-05 21:49:37 +02:00
parent 127c497f73
commit c94504247f
23 changed files with 1951 additions and 468 deletions

View file

@ -1,14 +1,19 @@
// FI Studio desktop GUI for the FI hub. // FI Studio desktop GUI for the FI hub.
// //
// MVP scope: three pages (Modules, Audit, Approvals) with mock // Visual language: warm minimalism / deep tech with soul.
// data, sharing one navigation shell. Live gRPC connection // Tokens in lib/theme/, primitives in lib/widgets/.
// arrives in the next iteration via fai_dart_sdk.
import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'data/hub.dart';
import 'pages/approvals.dart'; import 'pages/approvals.dart';
import 'pages/audit.dart'; import 'pages/audit.dart';
import 'pages/modules.dart'; import 'pages/modules.dart';
import 'theme/theme.dart';
import 'theme/tokens.dart';
import 'widgets/widgets.dart';
void main() { void main() {
runApp(const StudioApp()); runApp(const StudioApp());
@ -21,20 +26,10 @@ class StudioApp extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return MaterialApp( return MaterialApp(
title: 'F∆I Studio', title: 'F∆I Studio',
theme: ThemeData( debugShowCheckedModeBanner: false,
useMaterial3: true, theme: FaiTheme.light(),
colorScheme: ColorScheme.fromSeed( darkTheme: FaiTheme.dark(),
seedColor: const Color(0xFF1E3A8A), themeMode: ThemeMode.system,
brightness: Brightness.light,
),
),
darkTheme: ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(
seedColor: const Color(0xFF1E3A8A),
brightness: Brightness.dark,
),
),
home: const StudioShell(), home: const StudioShell(),
); );
} }
@ -49,6 +44,8 @@ class StudioShell extends StatefulWidget {
class _StudioShellState extends State<StudioShell> { class _StudioShellState extends State<StudioShell> {
int _selectedIndex = 0; int _selectedIndex = 0;
bool? _connected; // null = unknown, polled on a timer
Timer? _healthPoll;
static const _pages = <_NavPage>[ static const _pages = <_NavPage>[
_NavPage( _NavPage(
@ -71,53 +68,277 @@ class _StudioShellState extends State<StudioShell> {
), ),
]; ];
@override
void initState() {
super.initState();
_checkHealth();
_healthPoll = Timer.periodic(
const Duration(seconds: 5),
(_) => _checkHealth(),
);
}
Future<void> _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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
return Scaffold( return Scaffold(
body: Row( body: Row(
children: [ children: [
NavigationRail( _Sidebar(
extended: true,
minExtendedWidth: 180,
selectedIndex: _selectedIndex, selectedIndex: _selectedIndex,
onDestinationSelected: (i) => setState(() => _selectedIndex = i), onSelect: (i) => setState(() => _selectedIndex = i),
leading: Padding( pages: _pages,
padding: const EdgeInsets.symmetric(vertical: 16), 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<Offset>(
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<int> 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( child: Column(
children: [ children: [
Icon(Icons.hub_outlined, size: 32, color: theme.colorScheme.primary), // Brand mark.
const SizedBox(height: 8), FaiDeltaMark(color: theme.colorScheme.primary, mode: mode),
const SizedBox(height: FaiSpace.md),
Text( Text(
'F∆I Studio', 'F∆I Studio',
style: theme.textTheme.titleMedium?.copyWith( style: theme.textTheme.titleMedium?.copyWith(
color: theme.colorScheme.primary,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
), ),
), ),
Text( const SizedBox(height: FaiSpace.xs),
'connected: localhost:50051', // 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( style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant, 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,
), ),
destinations: _pages decoration: BoxDecoration(
.map( color: bg,
(p) => NavigationRailDestination( borderRadius: BorderRadius.circular(FaiRadius.sm),
icon: Icon(p.icon),
selectedIcon: Icon(p.selectedIcon),
label: Text(p.label),
), ),
) child: Row(
.toList(), 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),
), ),
const VerticalDivider(width: 1),
Expanded(child: _pages[_selectedIndex].page),
], ],
), ),
),
),
),
); );
} }
} }

View file

@ -1,6 +1,9 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../data/hub.dart'; import '../data/hub.dart';
import '../theme/theme.dart';
import '../theme/tokens.dart';
import '../widgets/widgets.dart';
class ApprovalsPage extends StatefulWidget { class ApprovalsPage extends StatefulWidget {
const ApprovalsPage({super.key}); const ApprovalsPage({super.key});
@ -28,9 +31,10 @@ class _ApprovalsPageState extends State<ApprovalsPage> {
Future<void> _approve(PendingApproval a) async { Future<void> _approve(PendingApproval a) async {
try { try {
await HubService.instance.approve(a.id, _reviewer); await HubService.instance.approve(a.id, _reviewer);
_toast('approved · ${a.flowName} ${a.stepId}');
_refresh(); _refresh();
} catch (e) { } catch (e) {
_showError('approve failed: $e'); _toast('approve failed: $e');
} }
} }
@ -39,13 +43,14 @@ class _ApprovalsPageState extends State<ApprovalsPage> {
if (reason == null || reason.isEmpty) return; if (reason == null || reason.isEmpty) return;
try { try {
await HubService.instance.reject(a.id, _reviewer, reason); await HubService.instance.reject(a.id, _reviewer, reason);
_toast('rejected · ${a.flowName} ${a.stepId}');
_refresh(); _refresh();
} catch (e) { } catch (e) {
_showError('reject failed: $e'); _toast('reject failed: $e');
} }
} }
void _showError(String msg) { void _toast(String msg) {
if (!mounted) return; if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg))); ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg)));
} }
@ -61,6 +66,7 @@ class _ApprovalsPageState extends State<ApprovalsPage> {
autofocus: true, autofocus: true,
decoration: const InputDecoration( decoration: const InputDecoration(
labelText: 'Reason (recorded in audit log)', labelText: 'Reason (recorded in audit log)',
border: OutlineInputBorder(),
), ),
), ),
actions: [ actions: [
@ -70,6 +76,10 @@ class _ApprovalsPageState extends State<ApprovalsPage> {
), ),
FilledButton( FilledButton(
onPressed: () => Navigator.pop(ctx, controller.text), 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'), child: const Text('Reject'),
), ),
], ],
@ -81,15 +91,16 @@ class _ApprovalsPageState extends State<ApprovalsPage> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
return Scaffold( return Scaffold(
backgroundColor: theme.scaffoldBackgroundColor,
appBar: AppBar( appBar: AppBar(
title: const Text('Pending Approvals'), title: const Text('Approvals'),
centerTitle: false,
actions: [ actions: [
IconButton( IconButton(
icon: const Icon(Icons.refresh), icon: const Icon(Icons.refresh, size: 18),
tooltip: 'Reload', tooltip: 'Reload',
onPressed: _refresh, onPressed: _refresh,
), ),
const SizedBox(width: FaiSpace.sm),
], ],
), ),
body: FutureBuilder<List<PendingApproval>>( body: FutureBuilder<List<PendingApproval>>(
@ -99,59 +110,30 @@ class _ApprovalsPageState extends State<ApprovalsPage> {
return const Center(child: CircularProgressIndicator()); return const Center(child: CircularProgressIndicator());
} }
if (snapshot.hasError) { if (snapshot.hasError) {
return Center( return FaiEmptyState(
child: Padding( icon: Icons.cloud_off_outlined,
padding: const EdgeInsets.all(32), iconColor: theme.colorScheme.error,
child: Column( title: 'Hub unreachable',
mainAxisAlignment: MainAxisAlignment.center, hint: 'Start the hub with `fai serve`.',
children: [ action: FilledButton.tonal(
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, onPressed: _refresh,
child: const Text('Retry'), child: const Text('Retry'),
), ),
],
),
),
); );
} }
final pending = snapshot.data ?? []; final pending = snapshot.data ?? [];
if (pending.isEmpty) { if (pending.isEmpty) {
return Center( return const FaiEmptyState(
child: Padding( icon: Icons.task_alt_outlined,
padding: const EdgeInsets.all(32), title: 'Inbox zero',
child: Column( hint:
mainAxisAlignment: MainAxisAlignment.center, 'All caught up. New `system.approval@^0` steps land here automatically.',
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 ListView.separated( return ListView.separated(
padding: const EdgeInsets.all(24), padding: const EdgeInsets.all(FaiSpace.xl),
itemCount: pending.length, itemCount: pending.length,
separatorBuilder: (_, _) => const SizedBox(height: 16), separatorBuilder: (_, _) => const SizedBox(height: FaiSpace.md),
itemBuilder: (context, i) { itemBuilder: (context, i) {
final a = pending[i]; final a = pending[i];
return _ApprovalCard( return _ApprovalCard(
@ -181,90 +163,91 @@ class _ApprovalCard extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
return Card( return FaiCard(
elevation: 0, accentTop: true,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(color: theme.colorScheme.outlineVariant),
),
child: Padding(
padding: const EdgeInsets.all(20),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Row( Row(
children: [ children: [
Icon( FaiPill(
Icons.pending_outlined, label: 'pending',
color: theme.colorScheme.tertiary, tone: FaiPillTone.warning,
size: 20, icon: Icons.pending_outlined,
), ),
const SizedBox(width: 8), const SizedBox(width: FaiSpace.sm),
Text( Expanded(
'${approval.flowName}${approval.stepId}', child: Text(
'${approval.flowName} ${approval.stepId}',
style: theme.textTheme.titleMedium?.copyWith( style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
), ),
), ),
const Spacer(),
if (approval.expiresAt != null)
Text(
_expiresIn(approval.expiresAt!),
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
), ),
if (approval.expiresAt != null)
FaiPill(
label: _expiresIn(approval.expiresAt!),
tone: FaiPillTone.neutral,
icon: Icons.schedule,
), ),
], ],
), ),
const SizedBox(height: 16), const SizedBox(height: FaiSpace.lg),
Text( Text(
approval.prompt, approval.prompt,
style: theme.textTheme.bodyLarge, style: theme.textTheme.bodyLarge,
), ),
if (approval.showPreview != null) ...[ if (approval.showPreview != null) ...[
const SizedBox(height: 12), const SizedBox(height: FaiSpace.md),
Container( Container(
width: double.infinity, width: double.infinity,
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(FaiSpace.md),
decoration: BoxDecoration( decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest, color: theme.colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(FaiRadius.sm),
border: Border.all(color: theme.colorScheme.outlineVariant),
), ),
child: Text( child: Text(
approval.showPreview!, approval.showPreview!,
style: theme.textTheme.bodySmall?.copyWith( style: FaiTheme.mono(
fontFamily: 'monospace', size: 11,
color: theme.colorScheme.onSurface,
), ),
), ),
), ),
], ],
const SizedBox(height: 16), const SizedBox(height: FaiSpace.lg),
Row( Row(
children: [ children: [
Text( Text(
'approval id: ${approval.id}', approval.id,
style: theme.textTheme.bodySmall?.copyWith( style: FaiTheme.mono(
fontFamily: 'monospace', size: 10,
color: theme.colorScheme.onSurfaceVariant, color: theme.colorScheme.onSurfaceVariant,
), ),
), ),
const Spacer(), const Spacer(),
OutlinedButton.icon( OutlinedButton.icon(
onPressed: onReject, onPressed: onReject,
icon: const Icon(Icons.close), icon: const Icon(Icons.close, size: 16),
label: const Text('Reject'), label: const Text('Reject'),
style: OutlinedButton.styleFrom(
foregroundColor: theme.colorScheme.error,
side: BorderSide(
color: theme.colorScheme.error.withValues(alpha: 0.5),
), ),
const SizedBox(width: 8), ),
),
const SizedBox(width: FaiSpace.sm),
FilledButton.icon( FilledButton.icon(
onPressed: onApprove, onPressed: onApprove,
icon: const Icon(Icons.check), icon: const Icon(Icons.check, size: 16),
label: const Text('Approve'), label: const Text('Approve'),
), ),
], ],
), ),
], ],
), ),
),
); );
} }
@ -272,8 +255,8 @@ class _ApprovalCard extends StatelessWidget {
final remaining = t.difference(DateTime.now()); final remaining = t.difference(DateTime.now());
if (remaining.isNegative) return 'expired'; if (remaining.isNegative) return 'expired';
final minutes = remaining.inMinutes; final minutes = remaining.inMinutes;
if (minutes < 60) return 'expires in ${minutes}m'; if (minutes < 60) return '${minutes}m';
final hours = remaining.inHours; final hours = remaining.inHours;
return 'expires in ${hours}h'; return '${hours}h';
} }
} }

View file

@ -3,6 +3,9 @@ import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../data/hub.dart'; import '../data/hub.dart';
import '../theme/theme.dart';
import '../theme/tokens.dart';
import '../widgets/widgets.dart';
class AuditPage extends StatefulWidget { class AuditPage extends StatefulWidget {
const AuditPage({super.key}); const AuditPage({super.key});
@ -35,14 +38,8 @@ class _AuditPageState extends State<AuditPage> {
} }
Future<void> _refresh() async { Future<void> _refresh() async {
final types = _typeFilter == 'all'
? <String>[]
: <String>[]; // server-side filter not yet clientside below
try { try {
final events = await HubService.instance.recentEvents( final events = await HubService.instance.recentEvents(limit: 100);
limit: 100,
types: types,
);
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
_events = events; _events = events;
@ -66,132 +63,54 @@ class _AuditPageState extends State<AuditPage> {
: _events.where((e) => e.type.startsWith(_typeFilter)).toList(); : _events.where((e) => e.type.startsWith(_typeFilter)).toList();
return Scaffold( return Scaffold(
backgroundColor: theme.scaffoldBackgroundColor,
appBar: AppBar( appBar: AppBar(
title: const Text('Audit Stream'), title: const Text('Audit'),
centerTitle: false,
actions: [ actions: [
Padding( Padding(
padding: const EdgeInsets.only(right: 16), padding: const EdgeInsets.only(right: FaiSpace.lg),
child: DropdownButton<String>( child: _FilterChips(
value: _typeFilter, value: _typeFilter,
onChanged: (v) => setState(() => _typeFilter = v ?? 'all'), onChanged: (v) => setState(() => _typeFilter = v),
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.*')),
],
), ),
), ),
], ],
), ),
body: Column( body: Column(
children: [ children: [
_StatusBar(eventCount: filtered.length, error: _error), _LiveStatusBar(eventCount: filtered.length, error: _error),
Expanded( Expanded(
child: !_initialLoaded child: !_initialLoaded
? const Center(child: CircularProgressIndicator()) ? const Center(child: CircularProgressIndicator())
: _error != null && _events.isEmpty : _error != null && _events.isEmpty
? Center( ? FaiEmptyState(
child: Padding( icon: Icons.cloud_off_outlined,
padding: const EdgeInsets.all(32), iconColor: theme.colorScheme.error,
child: Text( title: 'Hub unreachable',
'Hub unreachable: $_error\n\n' hint: 'Start the hub with `fai serve`.',
'Start the hub with `fai serve`.',
textAlign: TextAlign.center,
style: theme.textTheme.bodyMedium,
),
),
) )
: filtered.isEmpty : filtered.isEmpty
? Center( ? const FaiEmptyState(
child: Text( icon: Icons.timeline_outlined,
'(no events yet)', title: 'No events yet',
style: theme.textTheme.bodyMedium?.copyWith( hint:
color: theme.colorScheme.onSurfaceVariant, 'Run a flow to populate the audit stream.\nEvents appear here within seconds.',
),
),
) )
: ListView.separated( : ListView.separated(
padding: const EdgeInsets.all(24), padding: const EdgeInsets.all(FaiSpace.xl),
itemCount: filtered.length, itemCount: filtered.length,
separatorBuilder: (_, _) => separatorBuilder: (_, _) =>
const SizedBox(height: 8), const SizedBox(height: FaiSpace.xs),
itemBuilder: (context, i) { itemBuilder: (context, i) {
final e = filtered[i]; final e = filtered[i];
final ts = e.timestamp; return FaiDataRow(
final tsStr = accent: _toneFor(e.type, theme),
'${ts.hour.toString().padLeft(2, '0')}:${ts.minute.toString().padLeft(2, '0')}:${ts.second.toString().padLeft(2, '0')}'; leading: _formatTime(e.timestamp),
return Card( title: e.type,
elevation: 0, subtitle: _contextLine(e),
shape: RoundedRectangleBorder( trailing: e.durationMs != null
borderRadius: BorderRadius.circular(8), ? '${e.durationMs}ms'
side: BorderSide( : null,
color: theme.colorScheme.outlineVariant,
),
),
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,
),
),
],
),
),
); );
}, },
), ),
@ -201,10 +120,13 @@ class _AuditPageState extends State<AuditPage> {
); );
} }
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) { String _contextLine(AuditEvent e) {
final parts = <String>[]; final parts = <String>[];
if (e.flowName != null) parts.add(e.flowName!); 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.moduleName != null) parts.add(' via ${e.moduleName}');
if (e.error != null) parts.add(' [error: ${e.error}]'); if (e.error != null) parts.add(' [error: ${e.error}]');
return parts.join(''); return parts.join('');
@ -213,16 +135,112 @@ class _AuditPageState extends State<AuditPage> {
Color _toneFor(String type, ThemeData theme) { Color _toneFor(String type, ThemeData theme) {
if (type.endsWith('.failed')) return theme.colorScheme.error; if (type.endsWith('.failed')) return theme.colorScheme.error;
if (type.endsWith('.completed')) return theme.colorScheme.primary; 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; return theme.colorScheme.outline;
} }
} }
class _StatusBar extends StatelessWidget { class _FilterChips extends StatelessWidget {
final String value;
final ValueChanged<String> 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 int eventCount;
final String? error; final String? error;
const _StatusBar({required this.eventCount, required this.error}); const _LiveStatusBar({required this.eventCount, required this.error});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -230,21 +248,49 @@ class _StatusBar extends StatelessWidget {
final live = error == null; final live = error == null;
return Container( return Container(
width: double.infinity, width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8), padding: const EdgeInsets.symmetric(
horizontal: FaiSpace.xl,
vertical: FaiSpace.sm,
),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHigh, color: theme.colorScheme.surfaceContainerHigh,
border: Border(
bottom: BorderSide(color: theme.colorScheme.outlineVariant),
),
),
child: Row( child: Row(
children: [ children: [
Icon( FaiStatusDot(
Icons.fiber_manual_record, color: live ? FaiColors.success : FaiColors.danger,
size: 12, pulsing: live,
color: live ? theme.colorScheme.tertiary : theme.colorScheme.error,
), ),
const SizedBox(width: 8), const SizedBox(width: FaiSpace.sm),
Text( Text(
live live
? 'live (polling 2s) — $eventCount events' ? 'live · polling 2s · $eventCount event${eventCount == 1 ? '' : 's'}'
: 'disconnected — $error', : 'disconnected · $error',
style: theme.textTheme.bodySmall, 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,
),
),
],
), ),
], ],
), ),

View file

@ -1,6 +1,8 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../data/hub.dart'; import '../data/hub.dart';
import '../theme/tokens.dart';
import '../widgets/widgets.dart';
class ModulesPage extends StatefulWidget { class ModulesPage extends StatefulWidget {
const ModulesPage({super.key}); const ModulesPage({super.key});
@ -24,17 +26,17 @@ class _ModulesPageState extends State<ModulesPage> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context);
return Scaffold( return Scaffold(
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
appBar: AppBar( appBar: AppBar(
title: const Text('Installed Modules'), title: const Text('Modules'),
centerTitle: false,
actions: [ actions: [
IconButton( IconButton(
icon: const Icon(Icons.refresh), icon: const Icon(Icons.refresh, size: 18),
tooltip: 'Reload', tooltip: 'Reload',
onPressed: _refresh, onPressed: _refresh,
), ),
const SizedBox(width: FaiSpace.sm),
], ],
), ),
body: FutureBuilder<List<ModuleSummary>>( body: FutureBuilder<List<ModuleSummary>>(
@ -44,178 +46,101 @@ class _ModulesPageState extends State<ModulesPage> {
return const Center(child: CircularProgressIndicator()); return const Center(child: CircularProgressIndicator());
} }
if (snapshot.hasError) { if (snapshot.hasError) {
return _ConnectionError( return FaiEmptyState(
message: 'Hub unreachable: ${snapshot.error}', icon: Icons.cloud_off_outlined,
onRetry: _refresh, 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 ?? []; final modules = snapshot.data ?? [];
if (modules.isEmpty) { if (modules.isEmpty) {
return Center( return const FaiEmptyState(
child: Padding( icon: Icons.extension_outlined,
padding: const EdgeInsets.all(32), title: 'No modules yet',
child: Text( hint:
'No modules installed.\n'
'Run `fai install <capability-name>` or check ~/.fai/modules/.', 'Run `fai install <capability-name>` or check ~/.fai/modules/.',
textAlign: TextAlign.center,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
); );
} }
return ListView.separated( return ListView.separated(
padding: const EdgeInsets.all(24), padding: const EdgeInsets.all(FaiSpace.xl),
itemCount: modules.length, itemCount: modules.length,
separatorBuilder: (_, _) => const SizedBox(height: 12), separatorBuilder: (_, _) => const SizedBox(height: FaiSpace.md),
itemBuilder: (context, i) { itemBuilder: (context, i) => _ModuleCard(module: modules[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), }
}
class _ModuleCard extends StatelessWidget {
final ModuleSummary module;
const _ModuleCard({required this.module});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return FaiCard(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Row( Row(
children: [ children: [
Text( Text(
m.name, module.name,
style: theme.textTheme.titleMedium?.copyWith( style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
), ),
), ),
const SizedBox(width: 8), const SizedBox(width: FaiSpace.sm),
Container( FaiPill(
padding: const EdgeInsets.symmetric( label: 'v${module.version}',
horizontal: 8, tone: FaiPillTone.neutral,
vertical: 2, monospace: true,
),
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), const SizedBox(height: FaiSpace.md),
_ChipRow(label: 'Capabilities', items: m.capabilities), Row(
],
),
),
);
},
);
},
),
);
}
}
class _ChipRow extends StatelessWidget {
final String label;
final List<String> items;
const _ChipRow({required this.label, required this.items});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
SizedBox( SizedBox(
width: 140, width: 110,
child: Text( child: Text(
label, 'Capabilities',
style: theme.textTheme.bodySmall?.copyWith( style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant, color: theme.colorScheme.onSurfaceVariant,
letterSpacing: 0.4,
), ),
), ),
), ),
Expanded( Expanded(
child: Wrap( child: Wrap(
spacing: 6, spacing: FaiSpace.xs,
runSpacing: 6, runSpacing: FaiSpace.xs,
children: items children: module.capabilities
.map( .map(
(s) => Container( (c) => FaiPill(
padding: const EdgeInsets.symmetric( label: c,
horizontal: 8, tone: FaiPillTone.accent,
vertical: 3, monospace: true,
),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(4),
),
child: Text(
s,
style: theme.textTheme.bodySmall?.copyWith(
fontFamily: 'monospace',
),
),
), ),
) )
.toList(), .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,
),
),
const SizedBox(height: 16),
FilledButton.tonal(
onPressed: onRetry,
child: const Text('Retry'),
), ),
], ],
), ),
),
); );
} }
} }

236
lib/theme/theme.dart Normal file
View file

@ -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),
),
),
);
}
}

71
lib/theme/tokens.dart Normal file
View file

@ -0,0 +1,71 @@
// Design tokens for FI 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 (FI 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;
}

83
lib/widgets/fai_card.dart Normal file
View file

@ -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),
],
),
);
}
}

View file

@ -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<FaiDataRow> createState() => _FaiDataRowState();
}
class _FaiDataRowState extends State<FaiDataRow> {
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,
),
),
),
],
),
),
),
);
}
}

View file

@ -0,0 +1,162 @@
// FaiDeltaMark the FI 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<FaiDeltaMark> createState() => _FaiDeltaMarkState();
}
class _FaiDeltaMarkState extends State<FaiDeltaMark>
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;
}

View file

@ -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!,
],
],
),
),
),
);
}
}

90
lib/widgets/fai_pill.dart Normal file
View file

@ -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,
);
}
}
}

View file

@ -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<FaiStatusDot> createState() => _FaiStatusDotState();
}
class _FaiStatusDotState extends State<FaiStatusDot>
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,
);
}

12
lib/widgets/widgets.dart Normal file
View file

@ -0,0 +1,12 @@
// Barrel file for the FI 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';

View file

@ -6,6 +6,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
) )
list(APPEND FLUTTER_FFI_PLUGIN_LIST list(APPEND FLUTTER_FFI_PLUGIN_LIST
jni
) )
set(PLUGIN_BUNDLED_LIBRARIES) set(PLUGIN_BUNDLED_LIBRARIES)

View file

@ -1 +1,2 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
#include "ephemeral/Flutter-Generated.xcconfig" #include "ephemeral/Flutter-Generated.xcconfig"

View file

@ -1 +1,2 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
#include "ephemeral/Flutter-Generated.xcconfig" #include "ephemeral/Flutter-Generated.xcconfig"

42
macos/Podfile Normal file
View file

@ -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

16
macos/Podfile.lock Normal file
View file

@ -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

View file

@ -27,6 +27,8 @@
33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; }; 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; };
33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; };
33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; 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 */ /* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */ /* Begin PBXContainerItemProxy section */
@ -60,11 +62,12 @@
/* End PBXCopyFilesBuildPhase section */ /* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference 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 = "<group>"; };
331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 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 = "<group>"; }; 331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = "<group>"; }; 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = "<group>"; };
335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = "<group>"; }; 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = "<group>"; };
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 = "<group>"; }; 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = "<group>"; }; 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = "<group>"; };
33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = "<group>"; }; 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = "<group>"; };
@ -76,8 +79,15 @@
33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = "<group>"; }; 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = "<group>"; };
33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = "<group>"; }; 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = "<group>"; };
33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = "<group>"; }; 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = "<group>"; };
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 = "<group>"; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = "<group>"; };
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 = "<group>"; };
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = "<group>"; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = "<group>"; };
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 = "<group>"; };
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 = "<group>"; };
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 = "<group>"; };
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 = "<group>"; };
/* End PBXFileReference section */ /* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */ /* Begin PBXFrameworksBuildPhase section */
@ -85,6 +95,7 @@
isa = PBXFrameworksBuildPhase; isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
E1309159C1F5025EF6B9821D /* Pods_RunnerTests.framework in Frameworks */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@ -92,6 +103,7 @@
isa = PBXFrameworksBuildPhase; isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
370E355F35F0FAEC7D04F94B /* Pods_Runner.framework in Frameworks */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@ -125,6 +137,7 @@
331C80D6294CF71000263BE5 /* RunnerTests */, 331C80D6294CF71000263BE5 /* RunnerTests */,
33CC10EE2044A3C60003C045 /* Products */, 33CC10EE2044A3C60003C045 /* Products */,
D73912EC22F37F3D000D13A0 /* Frameworks */, D73912EC22F37F3D000D13A0 /* Frameworks */,
4C343B8693BC75A616AA05F5 /* Pods */,
); );
sourceTree = "<group>"; sourceTree = "<group>";
}; };
@ -172,9 +185,25 @@
path = Runner; path = Runner;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
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 = "<group>";
};
D73912EC22F37F3D000D13A0 /* Frameworks */ = { D73912EC22F37F3D000D13A0 /* Frameworks */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
5D6BC1A8D7E37B926CF5933E /* Pods_Runner.framework */,
770B980D8F15B767C49B23E5 /* Pods_RunnerTests.framework */,
); );
name = Frameworks; name = Frameworks;
sourceTree = "<group>"; sourceTree = "<group>";
@ -186,6 +215,7 @@
isa = PBXNativeTarget; isa = PBXNativeTarget;
buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
buildPhases = ( buildPhases = (
51FEB1BF98FD7036ADA63936 /* [CP] Check Pods Manifest.lock */,
331C80D1294CF70F00263BE5 /* Sources */, 331C80D1294CF70F00263BE5 /* Sources */,
331C80D2294CF70F00263BE5 /* Frameworks */, 331C80D2294CF70F00263BE5 /* Frameworks */,
331C80D3294CF70F00263BE5 /* Resources */, 331C80D3294CF70F00263BE5 /* Resources */,
@ -204,6 +234,7 @@
isa = PBXNativeTarget; isa = PBXNativeTarget;
buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */;
buildPhases = ( buildPhases = (
546C72FC3468AB7FBBCFD16E /* [CP] Check Pods Manifest.lock */,
33CC10E92044A3C60003C045 /* Sources */, 33CC10E92044A3C60003C045 /* Sources */,
33CC10EA2044A3C60003C045 /* Frameworks */, 33CC10EA2044A3C60003C045 /* Frameworks */,
33CC10EB2044A3C60003C045 /* Resources */, 33CC10EB2044A3C60003C045 /* Resources */,
@ -329,6 +360,50 @@
shellPath = /bin/sh; shellPath = /bin/sh;
shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire"; 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 */ /* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */
@ -380,6 +455,7 @@
/* Begin XCBuildConfiguration section */ /* Begin XCBuildConfiguration section */
331C80DB294CF71000263BE5 /* Debug */ = { 331C80DB294CF71000263BE5 /* Debug */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
baseConfigurationReference = DD47B0891752DBBA76ABCFB5 /* Pods-RunnerTests.debug.xcconfig */;
buildSettings = { buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)"; BUNDLE_LOADER = "$(TEST_HOST)";
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
@ -394,6 +470,7 @@
}; };
331C80DC294CF71000263BE5 /* Release */ = { 331C80DC294CF71000263BE5 /* Release */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
baseConfigurationReference = 1C93D4763670B5002EC24A25 /* Pods-RunnerTests.release.xcconfig */;
buildSettings = { buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)"; BUNDLE_LOADER = "$(TEST_HOST)";
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
@ -408,6 +485,7 @@
}; };
331C80DD294CF71000263BE5 /* Profile */ = { 331C80DD294CF71000263BE5 /* Profile */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
baseConfigurationReference = 824F23C5F5879DEACA815F19 /* Pods-RunnerTests.profile.xcconfig */;
buildSettings = { buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)"; BUNDLE_LOADER = "$(TEST_HOST)";
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;

View file

@ -4,4 +4,7 @@
<FileRef <FileRef
location = "group:Runner.xcodeproj"> location = "group:Runner.xcodeproj">
</FileRef> </FileRef>
<FileRef
location = "group:Pods/Pods.xcodeproj">
</FileRef>
</Workspace> </Workspace>

View file

@ -1,6 +1,14 @@
# Generated by pub # Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile # See https://dart.dev/tools/pub/glossary#lockfile
packages: packages:
args:
dependency: transitive
description:
name: args
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
url: "https://pub.dev"
source: hosted
version: "2.7.0"
async: async:
dependency: transitive dependency: transitive
description: description:
@ -33,6 +41,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.2" 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: collection:
dependency: transitive dependency: transitive
description: description:
@ -72,6 +88,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.3.3" 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: fixnum:
dependency: transitive dependency: transitive
description: description:
@ -98,6 +130,14 @@ packages:
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" version: "0.0.0"
glob:
dependency: transitive
description:
name: glob
sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de
url: "https://pub.dev"
source: hosted
version: "2.1.3"
google_cloud: google_cloud:
dependency: transitive dependency: transitive
description: description:
@ -106,6 +146,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.4.1" 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: google_identity_services_web:
dependency: transitive dependency: transitive
description: description:
@ -130,6 +178,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.2.0" version: "4.2.0"
hooks:
dependency: transitive
description:
name: hooks
sha256: "025f060e86d2d4c3c47b56e33caf7f93bf9283340f26d23424ebcfccf34f621e"
url: "https://pub.dev"
source: hosted
version: "1.0.3"
http: http:
dependency: transitive dependency: transitive
description: description:
@ -162,6 +218,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.5" 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: leak_tracker:
dependency: transitive dependency: transitive
description: description:
@ -194,6 +266,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.1.0" version: "6.1.0"
logging:
dependency: transitive
description:
name: logging
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
url: "https://pub.dev"
source: hosted
version: "1.3.0"
matcher: matcher:
dependency: transitive dependency: transitive
description: description:
@ -218,6 +298,30 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.17.0" 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: path:
dependency: transitive dependency: transitive
description: description:
@ -226,6 +330,70 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.9.1" 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: protobuf:
dependency: transitive dependency: transitive
description: description:
@ -234,6 +402,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.2.0" 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: shelf:
dependency: transitive dependency: transitive
description: description:
@ -327,6 +511,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.1" 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: sdks:
dart: ">=3.11.0-200.1.beta <4.0.0" dart: ">=3.11.0-200.1.beta <4.0.0"
flutter: ">=3.18.0-18.0.pre.54" flutter: ">=3.38.4"

View file

@ -14,6 +14,7 @@ dependencies:
# Sibling package; will move to Forgejo path once published. # Sibling package; will move to Forgejo path once published.
fai_dart_sdk: fai_dart_sdk:
path: ../fai_dart_sdk path: ../fai_dart_sdk
google_fonts: ^8.1.0
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:

View file

@ -6,6 +6,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
) )
list(APPEND FLUTTER_FFI_PLUGIN_LIST list(APPEND FLUTTER_FFI_PLUGIN_LIST
jni
) )
set(PLUGIN_BUNDLED_LIBRARIES) set(PLUGIN_BUNDLED_LIBRARIES)