System-AI editor now fetches the detected hardware tier and the hub's curated model database on open. Each model chip's colour + tooltip combines both: green star when curated quality is good/excellent AND min_hw_tier ≤ host; orange when quality is fine but the host is below min_hw_tier; warning for "basic" curated entries. Tooltip exposes the curator's note, parameter count, context window, and licence. Banner above the form shows "Detected: <summary> · curation reviewed <date>". Sidebar footer gains a small monospaced live clock between the theme toggle and the settings icon. Tooltip exposes full local ISO + UTC for cross-checking against audit-log timestamps. Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
631 lines
19 KiB
Dart
631 lines
19 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.14.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: '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 · live wall-clock · settings.
|
||
// Clock sits between the two icons so it reads as a
|
||
// status line — gives the operator a wall-clock
|
||
// reference to cross-check audit-log timestamps
|
||
// without taking sidebar real estate from anything
|
||
// load-bearing.
|
||
Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: FaiSpace.md),
|
||
child: Row(
|
||
children: [
|
||
_ThemeToggle(),
|
||
const Expanded(
|
||
child: Center(child: _SidebarClock()),
|
||
),
|
||
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);
|
||
},
|
||
);
|
||
},
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Small ticking wall-clock for the sidebar. Aligns the on-screen
|
||
/// time with the timestamps the audit log writes — operators
|
||
/// glance here when an event timestamp looks off and want a
|
||
/// reference point. Updates on the second; tooltip exposes the
|
||
/// full ISO + UTC offset for unambiguous comparison.
|
||
class _SidebarClock extends StatefulWidget {
|
||
const _SidebarClock();
|
||
|
||
@override
|
||
State<_SidebarClock> createState() => _SidebarClockState();
|
||
}
|
||
|
||
class _SidebarClockState extends State<_SidebarClock> {
|
||
late DateTime _now;
|
||
Timer? _ticker;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_now = DateTime.now();
|
||
_scheduleNextTick();
|
||
}
|
||
|
||
void _scheduleNextTick() {
|
||
// Align ticks on the second boundary so the seconds digit
|
||
// changes when the wall-clock second changes, not 0–999 ms
|
||
// late depending on when the widget mounted.
|
||
final delay = Duration(milliseconds: 1000 - _now.millisecond);
|
||
_ticker = Timer(delay, () {
|
||
if (!mounted) return;
|
||
setState(() => _now = DateTime.now());
|
||
_ticker = Timer.periodic(const Duration(seconds: 1), (_) {
|
||
if (!mounted) return;
|
||
setState(() => _now = DateTime.now());
|
||
});
|
||
});
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_ticker?.cancel();
|
||
super.dispose();
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final theme = Theme.of(context);
|
||
final hh = _now.hour.toString().padLeft(2, '0');
|
||
final mm = _now.minute.toString().padLeft(2, '0');
|
||
final ss = _now.second.toString().padLeft(2, '0');
|
||
final yyyy = _now.year.toString().padLeft(4, '0');
|
||
final mo = _now.month.toString().padLeft(2, '0');
|
||
final dd = _now.day.toString().padLeft(2, '0');
|
||
|
||
final offsetMin = _now.timeZoneOffset.inMinutes;
|
||
final sign = offsetMin >= 0 ? '+' : '-';
|
||
final absMin = offsetMin.abs();
|
||
final offH = (absMin ~/ 60).toString().padLeft(2, '0');
|
||
final offM = (absMin % 60).toString().padLeft(2, '0');
|
||
final tooltip =
|
||
'Local: $yyyy-$mo-$dd $hh:$mm:$ss $sign$offH:$offM\n'
|
||
'UTC: ${_now.toUtc().toIso8601String()}';
|
||
|
||
return Tooltip(
|
||
message: tooltip,
|
||
child: Text(
|
||
'$hh:$mm:$ss',
|
||
style: FaiTheme.mono(
|
||
size: 11,
|
||
color: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.85),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|