chain-studio/lib/main.dart
flemming-it 753db4fc60 feat(ui): Flows page (5th destination) + keyboard shortcuts
Two operator-DX additions:

1. **Flows** is the new third destination in the sidebar.
   Lists saved flows from the hub via the new HubAdmin
   ListFlows RPC. Each row has a Run button that opens an
   input dialog (key=value pairs, one per line, all values
   sent as text payloads — the binary-input case stays in
   `fai run` CLI). Flow runs in a non-dismissable progress
   dialog; output is shown per-key with monospace
   selectable text. Errors render with the exception detail
   for diagnosis.

2. **Keyboard shortcuts** at the shell level:
   - Cmd+1 / Cmd+2 / Cmd+3 / Cmd+4 / Cmd+5 jump to the
     matching destination (Doctor / Modules / Flows / Audit
     / Approvals).
   - Cmd+, opens the Settings dialog (macOS convention).

   Implemented via Flutter's Shortcuts/Actions/Intent triple
   so the bindings are discoverable in IDE devtools and
   composable with platform-specific overrides later.

Bumps fai_studio 0.7.3 -> 0.8.0.

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
2026-05-05 23:35:48 +02:00

533 lines
15 KiB
Dart

// F∆I Studio — desktop GUI for the F∆I hub.
//
// 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 'package:flutter/services.dart';
import 'data/hub.dart';
import 'pages/approvals.dart';
import 'pages/audit.dart';
import 'pages/doctor.dart';
import 'pages/flows.dart';
import 'pages/modules.dart';
import 'theme/theme.dart';
import 'theme/tokens.dart';
import 'widgets/widgets.dart';
/// Studio's own build version. Bump on every UI commit so the
/// running app self-identifies — visible in the sidebar header
/// and quick-glance proof that you're seeing the current build.
const String kStudioVersion = '0.8.0';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
// Restore the persisted endpoint and theme mode before the
// first frame so neither flicker on startup.
await HubService.instance.loadPersistedEndpoint();
final themeMode = await HubService.instance.loadThemeMode();
runApp(StudioApp(initialThemeMode: themeMode));
}
class StudioApp extends StatefulWidget {
final ThemeModeValue initialThemeMode;
const StudioApp({super.key, required this.initialThemeMode});
/// Lookup helper so descendants can flip the theme without
/// passing callbacks down through every level.
static StudioAppState? of(BuildContext context) =>
context.findAncestorStateOfType<StudioAppState>();
@override
State<StudioApp> createState() => StudioAppState();
}
class StudioAppState extends State<StudioApp> {
/// Exposed so any descendant can listen via
/// `ValueListenableBuilder` and rebuild instantly when the
/// theme flips. setState alone wouldn't propagate through
/// `MaterialApp(home: const StudioShell())` because the
/// const child's element doesn't get a `didUpdateWidget`.
late final ValueNotifier<ThemeModeValue> modeNotifier;
Future<void> setMode(ThemeModeValue m) async {
modeNotifier.value = m;
await HubService.instance.saveThemeMode(m);
}
@override
void initState() {
super.initState();
modeNotifier = ValueNotifier(widget.initialThemeMode);
}
@override
void dispose() {
modeNotifier.dispose();
super.dispose();
}
ThemeMode _flutterMode(ThemeModeValue v) {
switch (v) {
case ThemeModeValue.system:
return ThemeMode.system;
case ThemeModeValue.light:
return ThemeMode.light;
case ThemeModeValue.dark:
return ThemeMode.dark;
}
}
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<ThemeModeValue>(
valueListenable: modeNotifier,
builder: (_, mode, _) => MaterialApp(
title: 'F∆I Studio',
debugShowCheckedModeBanner: false,
theme: FaiTheme.light(),
darkTheme: FaiTheme.dark(),
themeMode: _flutterMode(mode),
home: const StudioShell(),
),
);
}
}
class StudioShell extends StatefulWidget {
const StudioShell({super.key});
@override
State<StudioShell> createState() => _StudioShellState();
}
class _StudioShellState extends State<StudioShell> {
int _selectedIndex = 0;
bool? _connected; // null = unknown, polled on a timer
Timer? _healthPoll;
static const _pages = <_NavPage>[
_NavPage(
label: 'Doctor',
icon: Icons.health_and_safety_outlined,
selectedIcon: Icons.health_and_safety,
page: DoctorPage(),
),
_NavPage(
label: 'Modules',
icon: Icons.extension_outlined,
selectedIcon: Icons.extension,
page: ModulesPage(),
),
_NavPage(
label: 'Flows',
icon: Icons.account_tree_outlined,
selectedIcon: Icons.account_tree,
page: FlowsPage(),
),
_NavPage(
label: 'Audit',
icon: Icons.timeline_outlined,
selectedIcon: Icons.timeline,
page: AuditPage(),
),
_NavPage(
label: 'Approvals',
icon: Icons.inbox_outlined,
selectedIcon: Icons.inbox,
page: ApprovalsPage(),
),
];
@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 Shortcuts(
shortcuts: <ShortcutActivator, Intent>{
// Cmd+1..5 jumps to the matching destination. Numbered
// 1-based to match the visual order in the sidebar.
for (var i = 0; i < _pages.length; i++)
SingleActivator(
LogicalKeyboardKey(LogicalKeyboardKey.digit1.keyId + i),
meta: true,
): _GoToPageIntent(i),
// Cmd+, opens the settings dialog (macOS convention).
const SingleActivator(LogicalKeyboardKey.comma, meta: true):
const _OpenSettingsIntent(),
},
child: Actions(
actions: <Type, Action<Intent>>{
_GoToPageIntent: CallbackAction<_GoToPageIntent>(
onInvoke: (intent) {
setState(() => _selectedIndex = intent.index);
return null;
},
),
_OpenSettingsIntent: CallbackAction<_OpenSettingsIntent>(
onInvoke: (_) {
FaiSettingsDialog.show(context);
return null;
},
),
},
child: Focus(
autofocus: true,
child: Scaffold(
body: Row(
children: [
_Sidebar(
selectedIndex: _selectedIndex,
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,
),
),
),
],
),
),
),
),
);
}
}
/// Intents driven by the Shortcuts/Actions pair on the shell.
class _GoToPageIntent extends Intent {
final int index;
const _GoToPageIntent(this.index);
}
class _OpenSettingsIntent extends Intent {
const _OpenSettingsIntent();
}
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: 2),
Text(
'v$kStudioVersion',
style: FaiTheme.mono(
size: 10,
color: theme.colorScheme.primary,
),
),
const SizedBox(height: FaiSpace.md),
// 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: theme toggle + settings.
Padding(
padding: const EdgeInsets.symmetric(horizontal: FaiSpace.md),
child: Row(
children: [
_ThemeToggle(),
const Spacer(),
IconButton(
icon: const Icon(Icons.settings_outlined, size: 16),
tooltip: 'Hub endpoint…',
visualDensity: VisualDensity.compact,
onPressed: () async {
final saved = await FaiSettingsDialog.show(context);
if (saved && context.mounted) {
// Force a sidebar rebuild so the new endpoint
// shows in the connection pill.
(context as Element).markNeedsBuild();
}
},
),
],
),
),
],
),
);
}
}
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;
final IconData selectedIcon;
final Widget page;
const _NavPage({
required this.label,
required this.icon,
required this.selectedIcon,
required this.page,
});
}
class _ThemeToggle extends StatelessWidget {
@override
Widget build(BuildContext context) {
final app = StudioApp.of(context);
if (app == null) return const SizedBox.shrink();
return ValueListenableBuilder<ThemeModeValue>(
valueListenable: app.modeNotifier,
builder: (_, mode, _) {
final icon = switch (mode) {
ThemeModeValue.system => Icons.brightness_auto_outlined,
ThemeModeValue.light => Icons.light_mode_outlined,
ThemeModeValue.dark => Icons.dark_mode_outlined,
};
final tooltip = switch (mode) {
ThemeModeValue.system => 'theme: follow system',
ThemeModeValue.light => 'theme: light',
ThemeModeValue.dark => 'theme: dark',
};
return IconButton(
icon: Icon(icon, size: 16),
tooltip: tooltip,
visualDensity: VisualDensity.compact,
onPressed: () {
final next = switch (mode) {
ThemeModeValue.system => ThemeModeValue.light,
ThemeModeValue.light => ThemeModeValue.dark,
ThemeModeValue.dark => ThemeModeValue.system,
};
app.setMode(next);
},
);
},
);
}
}