From fb7352d1826e738a30a8f37d1d33bc1888326f88 Mon Sep 17 00:00:00 2001 From: flemming-it Date: Thu, 7 May 2026 21:27:09 +0200 Subject: [PATCH] fix(studio): live-search exception, daemon-card width, featured tile size; add Start-hub button (v0.20.1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three reported bugs and one onramp gap: - Store live-search threw `setState() callback returned a Future`. The `=>` body of `_runSearch` evaluated to the Future returned by `_load()`. Switched to a block body so the closure resolves to void; the future itself is still awaited by the FutureBuilder. - Doctor's Daemon-control card was visibly narrower than its siblings — its only stretching child was a Wrap of small buttons. Wrapped the content in `SizedBox(width: infinity)` so the card fills the section's width like every other panel. Card now leads with the live `running on local: 127.0.0.1:50051` status row + status pill so operators see state at a glance, then offers Restart / Start (when stopped) / Stop / Status. - Featured tiles were 320×152 against the regular grid's ~360×168, so the editorial picks looked subordinate to the dense grid. Bumped to 480×220 with bigger icon, larger title, larger tagline so the strip reads as a hero band. - ConnectionPill in the sidebar now offers an inline "Start hub" button when the daemon is unreachable. Spawns `fai daemon start` via SystemActions; the shell-level health poll picks the daemon up on its next tick. Closes the chicken-and-egg for Windows users who can't open a shell to bootstrap. Signed-off-by: flemming-it --- lib/main.dart | 52 +++++++++- lib/pages/doctor.dart | 231 +++++++++++++++++++++++++++++------------- lib/pages/store.dart | 67 +++++++----- pubspec.yaml | 2 +- 4 files changed, 256 insertions(+), 96 deletions(-) diff --git a/lib/main.dart b/lib/main.dart index 1ad543c..392e942 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -10,6 +10,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'data/hub.dart'; +import 'data/system_actions.dart'; import 'pages/approvals.dart'; import 'pages/audit.dart'; import 'pages/doctor.dart'; @@ -23,7 +24,7 @@ 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.20.0'; +const String kStudioVersion = '0.20.1'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -338,6 +339,19 @@ class _Sidebar extends StatelessWidget { child: _ConnectionPill( connected: connected, label: endpointLabel, + onStartRequested: () async { + final r = await SystemActions.faiDaemon(['start']); + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + r.ok + ? 'Daemon start requested. Reconnecting…' + : 'Could not start daemon: ${r.stderr.trim().isEmpty ? r.stdout.trim() : r.stderr.trim()}', + ), + ), + ); + }, ), ), if (activeChannel != null && activeChannel!.isNotEmpty) ...[ @@ -482,8 +496,17 @@ class _ChannelPill extends StatelessWidget { class _ConnectionPill extends StatelessWidget { final bool? connected; final String label; + /// Triggered by the inline "Start hub" button when the + /// daemon is unreachable. Runs `fai daemon start` server-side + /// (well, locally — Studio shells out). The shell-level + /// health poll picks the daemon up on its next tick. + final Future Function() onStartRequested; - const _ConnectionPill({required this.connected, required this.label}); + const _ConnectionPill({ + required this.connected, + required this.label, + required this.onStartRequested, + }); @override Widget build(BuildContext context) { @@ -536,6 +559,31 @@ class _ConnectionPill extends StatelessWidget { color: theme.colorScheme.onSurfaceVariant, ), ), + // Inline "Start hub" affordance when the daemon + // isn't there. Resolves the chicken-and-egg for + // Windows operators who can't run `fai daemon start` + // from a shell. + if (connected == false) ...[ + const SizedBox(height: FaiSpace.sm), + SizedBox( + width: double.infinity, + child: FilledButton.tonalIcon( + onPressed: onStartRequested, + icon: const Icon(Icons.play_arrow, size: 14), + label: const Text( + 'Start hub', + style: TextStyle(fontSize: 11), + ), + style: FilledButton.styleFrom( + visualDensity: VisualDensity.compact, + padding: const EdgeInsets.symmetric( + horizontal: FaiSpace.sm, + vertical: 4, + ), + ), + ), + ), + ], ], ), ); diff --git a/lib/pages/doctor.dart b/lib/pages/doctor.dart index 5eadfd0..94b6dc1 100644 --- a/lib/pages/doctor.dart +++ b/lib/pages/doctor.dart @@ -424,6 +424,23 @@ class _DaemonActionsCard extends StatefulWidget { class _DaemonActionsCardState extends State<_DaemonActionsCard> { bool _busy = false; String? _output; + ChannelStatusSnapshot? _channels; + + @override + void initState() { + super.initState(); + _refreshStatus(); + } + + Future _refreshStatus() async { + try { + final snap = await HubService.instance.channelStatus(); + if (!mounted) return; + setState(() => _channels = snap); + } catch (_) { + // Best-effort; the status pill simply renders "unknown". + } + } Future _run( String label, @@ -442,94 +459,168 @@ class _DaemonActionsCardState extends State<_DaemonActionsCard> { : 'Failed · $label\n${r.stderr.isEmpty ? r.stdout : r.stderr}') .trim(); }); + // Refresh the status pill — restart / stop / start all + // change the running flag. + await _refreshStatus(); } @override Widget build(BuildContext context) { final theme = Theme.of(context); + ChannelInfo? active; + if (_channels != null) { + for (final c in _channels!.channels) { + if (c.name == _channels!.active) { + active = c; + break; + } + } + } return FaiCard( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Spawn the platform binary to manage the running daemon. ' - 'Mirrors the CLI: `fai daemon …`.', - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: FaiSpace.md), - Wrap( - spacing: FaiSpace.sm, - runSpacing: FaiSpace.sm, - children: [ - FilledButton.tonalIcon( - onPressed: _busy - ? null - : () => - _run('daemon restart', () => SystemActions.faiDaemon(['restart'])), - icon: const Icon(Icons.restart_alt, size: 16), - label: const Text('Restart daemon'), - ), - OutlinedButton.icon( - onPressed: _busy - ? null - : () => - _run('daemon stop', () => SystemActions.faiDaemon(['stop'])), - icon: const Icon(Icons.stop_circle_outlined, size: 16), - label: const Text('Stop daemon'), - ), - OutlinedButton.icon( - onPressed: _busy - ? null - : () => _run( - 'daemon status', - () => SystemActions.faiDaemon(['status']), - ), - icon: const Icon(Icons.health_and_safety_outlined, size: 16), - label: const Text('Status'), - ), - ], - ), - if (_busy) ...[ - const SizedBox(height: FaiSpace.md), + // Row + Expanded forces the card to take the full width + // its parent offers. Without this, the card hugs the + // intrinsic width of the longest button row and ends up + // visibly narrower than its siblings. + child: SizedBox( + width: double.infinity, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ Row( children: [ - const SizedBox( - width: 14, - height: 14, - child: CircularProgressIndicator(strokeWidth: 2), + Icon( + active?.running == true + ? Icons.check_circle_outline + : Icons.power_off_outlined, + size: 18, + color: active?.running == true + ? FaiColors.success + : theme.colorScheme.onSurfaceVariant, ), const SizedBox(width: FaiSpace.sm), - Text( - 'Working…', - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, + Expanded( + child: Text( + active == null + ? 'Daemon status: …' + : active.running + ? 'Running on ${active.name}: ${active.endpoint}' + : '${active.name} daemon stopped', + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w500, + ), + overflow: TextOverflow.ellipsis, ), ), + if (active != null) ...[ + const SizedBox(width: FaiSpace.sm), + Tooltip( + message: 'PID file at the channel\'s run/.pid', + child: FaiPill( + label: active.running ? 'running' : 'stopped', + tone: active.running + ? FaiPillTone.success + : FaiPillTone.neutral, + ), + ), + ], + ], + ), + const SizedBox(height: FaiSpace.sm), + Text( + 'Studio shells out to the platform binary — mirrors ' + '`fai daemon …` exactly so the CLI and UI stay in lockstep.', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: FaiSpace.md), + Wrap( + spacing: FaiSpace.sm, + runSpacing: FaiSpace.sm, + children: [ + FilledButton.tonalIcon( + onPressed: _busy + ? null + : () => _run( + 'daemon restart', + () => SystemActions.faiDaemon(['restart']), + ), + icon: const Icon(Icons.restart_alt, size: 16), + label: const Text('Restart'), + ), + if (active?.running != true) + FilledButton.icon( + onPressed: _busy + ? null + : () => _run( + 'daemon start', + () => SystemActions.faiDaemon(['start']), + ), + icon: const Icon(Icons.play_arrow, size: 16), + label: const Text('Start'), + ), + OutlinedButton.icon( + onPressed: _busy + ? null + : () => _run( + 'daemon stop', + () => SystemActions.faiDaemon(['stop']), + ), + icon: const Icon(Icons.stop_circle_outlined, size: 16), + label: const Text('Stop'), + ), + OutlinedButton.icon( + onPressed: _busy + ? null + : () => _run( + 'daemon status', + () => SystemActions.faiDaemon(['status']), + ), + icon: const Icon(Icons.health_and_safety_outlined, size: 16), + label: const Text('Status'), + ), ], ), - ], - if (_output != null) ...[ - const SizedBox(height: FaiSpace.md), - Container( - width: double.infinity, - padding: const EdgeInsets.all(FaiSpace.sm), - decoration: BoxDecoration( - color: theme.colorScheme.surfaceContainerHigh, - borderRadius: BorderRadius.circular(FaiRadius.sm), - border: Border.all(color: theme.colorScheme.outlineVariant), + if (_busy) ...[ + const SizedBox(height: FaiSpace.md), + Row( + children: [ + const SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator(strokeWidth: 2), + ), + const SizedBox(width: FaiSpace.sm), + Text( + 'Working…', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], ), - child: SelectableText( - _output!, - style: FaiTheme.mono( - size: 11, - color: theme.colorScheme.onSurface, + ], + if (_output != null) ...[ + const SizedBox(height: FaiSpace.md), + Container( + width: double.infinity, + padding: const EdgeInsets.all(FaiSpace.sm), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(FaiRadius.sm), + border: Border.all(color: theme.colorScheme.outlineVariant), + ), + child: SelectableText( + _output!, + style: FaiTheme.mono( + size: 11, + color: theme.colorScheme.onSurface, + ), ), ), - ), + ], ], - ], + ), ), ); } diff --git a/lib/pages/store.dart b/lib/pages/store.dart index cb3896c..9df49eb 100644 --- a/lib/pages/store.dart +++ b/lib/pages/store.dart @@ -64,7 +64,15 @@ class _StorePageState extends State { return _installedOnly ? all.where((e) => e.installed).toList() : all; } - void _runSearch() => setState(() => _future = _load()); + void _runSearch() { + // Block body, so the closure's return type is `void` + // rather than `Future`. setState() rejects closures that + // resolve to a Future — the assignment must be synchronous + // even though the future itself is awaited later. + setState(() { + _future = _load(); + }); + } Future _openDetail(StoreItem item) async { final changed = await _StoreDetailSheet.show(context, item, _locale); @@ -533,7 +541,11 @@ class _FeaturedStrip extends StatelessWidget { ), const SizedBox(height: FaiSpace.sm), SizedBox( - height: 152, + // Tiles render larger than regular grid cards + // (which are 168 × ~360) so the editorial picks + // visibly stand out. Same hierarchy you see on app + // stores: hero band before the dense list. + height: 220, child: ListView.separated( scrollDirection: Axis.horizontal, itemCount: items.length, @@ -541,7 +553,7 @@ class _FeaturedStrip extends StatelessWidget { itemBuilder: (context, i) { final item = items[i]; return SizedBox( - width: 320, + width: 480, child: _FeaturedTile( item: item, locale: locale, @@ -584,12 +596,12 @@ class _FeaturedTile extends StatelessWidget { onTap: onTap, borderRadius: BorderRadius.circular(FaiRadius.md), child: Container( - padding: const EdgeInsets.all(FaiSpace.md), + padding: const EdgeInsets.all(FaiSpace.lg), decoration: BoxDecoration( borderRadius: BorderRadius.circular(FaiRadius.md), gradient: LinearGradient( colors: [ - theme.colorScheme.primary.withValues(alpha: 0.10), + theme.colorScheme.primary.withValues(alpha: 0.14), theme.colorScheme.surfaceContainer.withValues(alpha: 0.0), ], begin: Alignment.topLeft, @@ -602,34 +614,46 @@ class _FeaturedTile extends StatelessWidget { Row( children: [ CircleAvatar( - radius: 20, + radius: 24, backgroundColor: - theme.colorScheme.primary.withValues(alpha: 0.16), + theme.colorScheme.primary.withValues(alpha: 0.18), child: Icon( _iconForCategory(item.category), - size: 22, + size: 26, color: theme.colorScheme.primary, ), ), - const SizedBox(width: FaiSpace.sm), + const SizedBox(width: FaiSpace.md), Expanded( - child: Text( - item.name, - style: theme.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w700, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.name, + style: theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w700, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (item.category.isNotEmpty) + Text( + item.category, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], ), ), - Icon(Icons.star, size: 14, color: FaiColors.success), + Icon(Icons.star, size: 18, color: FaiColors.success), ], ), - const SizedBox(height: FaiSpace.sm), + const SizedBox(height: FaiSpace.md), Expanded( child: Text( tagline.isEmpty ? '(no tagline)' : tagline, - style: theme.textTheme.bodyMedium, + style: theme.textTheme.bodyLarge, maxLines: 3, overflow: TextOverflow.ellipsis, ), @@ -659,11 +683,8 @@ class _FeaturedTile extends StatelessWidget { else if (installable) FilledButton.icon( onPressed: onInstall, - icon: const Icon(Icons.download, size: 14), + icon: const Icon(Icons.download, size: 16), label: const Text('Install'), - style: FilledButton.styleFrom( - visualDensity: VisualDensity.compact, - ), ), ], ), diff --git a/pubspec.yaml b/pubspec.yaml index d103423..0121ed3 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: fai_studio description: "F∆I Studio — desktop GUI for the F∆I hub" publish_to: 'none' -version: 0.20.0 +version: 0.20.1 environment: sdk: ^3.11.0-200.1.beta