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