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:
parent
127c497f73
commit
c94504247f
23 changed files with 1951 additions and 468 deletions
325
lib/main.dart
325
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<StudioShell> {
|
||||
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<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
|
||||
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<Offset>(
|
||||
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<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(
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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<ApprovalsPage> {
|
|||
Future<void> _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<ApprovalsPage> {
|
|||
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<ApprovalsPage> {
|
|||
autofocus: true,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Reason (recorded in audit log)',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
|
|
@ -70,6 +76,10 @@ class _ApprovalsPageState extends State<ApprovalsPage> {
|
|||
),
|
||||
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<ApprovalsPage> {
|
|||
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<List<PendingApproval>>(
|
||||
|
|
@ -99,59 +110,30 @@ class _ApprovalsPageState extends State<ApprovalsPage> {
|
|||
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';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<AuditPage> {
|
|||
}
|
||||
|
||||
Future<void> _refresh() async {
|
||||
final types = _typeFilter == 'all'
|
||||
? <String>[]
|
||||
: <String>[]; // 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<AuditPage> {
|
|||
: _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<String>(
|
||||
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 = <String>[];
|
||||
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<AuditPage> {
|
|||
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<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 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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<ModulesPage> {
|
|||
|
||||
@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<List<ModuleSummary>>(
|
||||
|
|
@ -44,78 +46,32 @@ class _ModulesPageState extends State<ModulesPage> {
|
|||
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 <capability-name>` 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<ModulesPage> {
|
|||
}
|
||||
}
|
||||
|
||||
class _ChipRow extends StatelessWidget {
|
||||
final String label;
|
||||
final List<String> 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(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
236
lib/theme/theme.dart
Normal file
236
lib/theme/theme.dart
Normal 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
71
lib/theme/tokens.dart
Normal file
|
|
@ -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;
|
||||
}
|
||||
83
lib/widgets/fai_card.dart
Normal file
83
lib/widgets/fai_card.dart
Normal 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),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
134
lib/widgets/fai_data_row.dart
Normal file
134
lib/widgets/fai_data_row.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
162
lib/widgets/fai_delta_mark.dart
Normal file
162
lib/widgets/fai_delta_mark.dart
Normal file
|
|
@ -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<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;
|
||||
}
|
||||
75
lib/widgets/fai_empty_state.dart
Normal file
75
lib/widgets/fai_empty_state.dart
Normal 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
90
lib/widgets/fai_pill.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
101
lib/widgets/fai_status_dot.dart
Normal file
101
lib/widgets/fai_status_dot.dart
Normal 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
12
lib/widgets/widgets.dart
Normal file
|
|
@ -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';
|
||||
|
|
@ -6,6 +6,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
|
|||
)
|
||||
|
||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||
jni
|
||||
)
|
||||
|
||||
set(PLUGIN_BUNDLED_LIBRARIES)
|
||||
|
|
|
|||
|
|
@ -1 +1,2 @@
|
|||
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
|
||||
#include "ephemeral/Flutter-Generated.xcconfig"
|
||||
|
|
|
|||
|
|
@ -1 +1,2 @@
|
|||
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
|
||||
#include "ephemeral/Flutter-Generated.xcconfig"
|
||||
|
|
|
|||
42
macos/Podfile
Normal file
42
macos/Podfile
Normal 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
16
macos/Podfile.lock
Normal 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
|
||||
|
|
@ -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 = "<group>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
|
|
@ -76,8 +79,15 @@
|
|||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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 */
|
||||
|
||||
/* 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 = "<group>";
|
||||
};
|
||||
|
|
@ -172,9 +185,25 @@
|
|||
path = Runner;
|
||||
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 */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
5D6BC1A8D7E37B926CF5933E /* Pods_Runner.framework */,
|
||||
770B980D8F15B767C49B23E5 /* Pods_RunnerTests.framework */,
|
||||
);
|
||||
name = Frameworks;
|
||||
sourceTree = "<group>";
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -4,4 +4,7 @@
|
|||
<FileRef
|
||||
location = "group:Runner.xcodeproj">
|
||||
</FileRef>
|
||||
<FileRef
|
||||
location = "group:Pods/Pods.xcodeproj">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
|
|
|
|||
202
pubspec.lock
202
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"
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
|
|||
)
|
||||
|
||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||
jni
|
||||
)
|
||||
|
||||
set(PLUGIN_BUNDLED_LIBRARIES)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue