Two operator-felt papercuts on the editor:
* Models only appeared after a manual Refresh click. Now the
editor calls `_refreshModels()` from initState (post-frame)
so chips populate immediately. Provider switch also re-fires
the refresh because endpoint / model set changes too.
* Operator had no signal which model is suitable. Tiny models
fail at instruction following; huge models stall on a
laptop. Adds a heuristic suitability rating (parses
parameter count from common names like `gemma3:4b`,
`llama3.2:70b`; falls back to family hints for OpenAI /
Claude). Each chip now carries:
- a coloured leading dot (green=balanced, orange=small
or large, red=huge, grey=unknown)
- a star icon for the hand-curated "tested with F∆I"
allowlist (gemma3:4b, llama3.2:3b, qwen2.5-coder:7b,
gpt-4o-mini, claude-haiku-4-5, …)
- a tooltip ("balanced", "huge — likely too slow on a
laptop", …)
Sorting also moves recommended → balanced → others to the
front so the operator's eye lands on green first.
* Compact legend below the chip wrap explains the colour
code in place — no lookup needed.
Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
548 lines
16 KiB
Dart
548 lines
16 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 'pages/store.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.13.1';
|
|
|
|
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: 'Store',
|
|
icon: Icons.storefront_outlined,
|
|
selectedIcon: Icons.storefront,
|
|
page: StorePage(),
|
|
),
|
|
_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..6 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),
|
|
// Settings dialog. macOS reserves Cmd+, for the native
|
|
// app-Preferences menu item, which Flutter Desktop
|
|
// doesn't wire up — the keystroke is captured by the OS
|
|
// before it reaches our Shortcuts widget. Cmd+; is the
|
|
// working alternative; we register Cmd+, too for
|
|
// completeness in case a future Flutter version (or a
|
|
// native-menu setup) lets it through.
|
|
const SingleActivator(LogicalKeyboardKey.semicolon, meta: true):
|
|
const _OpenSettingsIntent(),
|
|
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: 'Settings (⌘;)',
|
|
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);
|
|
},
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|