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';
|
||||
Loading…
Add table
Add a link
Reference in a new issue