Studio is no longer hardcoded to localhost:50051. A gear icon in the sidebar footer opens a small settings dialog where the operator picks host / port / TLS-on-or-off. Saved values persist via shared_preferences and Studio reconnects to the new endpoint immediately. - data/hub.dart: HubService.loadPersistedEndpoint() called once before the first frame; reconnect() persists on every change. - widgets/fai_settings_dialog.dart: typed form with port range validation, live URL preview pill, error surfacing if the reconnect fails. - main.dart: gear icon at the sidebar bottom, replaces the static "platform v0.10.42" footer. shared_preferences added as dependency. flutter analyze clean, flutter test 2/2, macOS debug build succeeds. Bumps fai_studio 0.4.0 -> 0.5.0. Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
389 lines
11 KiB
Dart
389 lines
11 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 'data/hub.dart';
|
|
import 'pages/approvals.dart';
|
|
import 'pages/audit.dart';
|
|
import 'pages/doctor.dart';
|
|
import 'pages/modules.dart';
|
|
import 'theme/theme.dart';
|
|
import 'theme/tokens.dart';
|
|
import 'widgets/widgets.dart';
|
|
|
|
Future<void> main() async {
|
|
WidgetsFlutterBinding.ensureInitialized();
|
|
// Restore the persisted endpoint before the first frame so the
|
|
// sidebar pill doesn't briefly say "127.0.0.1:50051" before
|
|
// switching to the operator's actual hub.
|
|
await HubService.instance.loadPersistedEndpoint();
|
|
runApp(const StudioApp());
|
|
}
|
|
|
|
class StudioApp extends StatelessWidget {
|
|
const StudioApp({super.key});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return MaterialApp(
|
|
title: 'F∆I Studio',
|
|
debugShowCheckedModeBanner: false,
|
|
theme: FaiTheme.light(),
|
|
darkTheme: FaiTheme.dark(),
|
|
themeMode: ThemeMode.system,
|
|
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: '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 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,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
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: settings + version.
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: FaiSpace.md),
|
|
child: Row(
|
|
children: [
|
|
Expanded(
|
|
child: Text(
|
|
'platform target',
|
|
style: theme.textTheme.bodySmall?.copyWith(
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
fontSize: 10,
|
|
),
|
|
),
|
|
),
|
|
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,
|
|
});
|
|
}
|