fix(studio): live-search exception, daemon-card width, featured tile size; add Start-hub button (v0.20.1)

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 <stefan.a.flemming@googlemail.com>
This commit is contained in:
flemming-it 2026-05-07 21:27:09 +02:00
parent 26691b7471
commit fb7352d182
4 changed files with 256 additions and 96 deletions

View file

@ -10,6 +10,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'data/hub.dart'; import 'data/hub.dart';
import 'data/system_actions.dart';
import 'pages/approvals.dart'; import 'pages/approvals.dart';
import 'pages/audit.dart'; import 'pages/audit.dart';
import 'pages/doctor.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 /// Studio's own build version. Bump on every UI commit so the
/// running app self-identifies visible in the sidebar header /// running app self-identifies visible in the sidebar header
/// and quick-glance proof that you're seeing the current build. /// and quick-glance proof that you're seeing the current build.
const String kStudioVersion = '0.20.0'; const String kStudioVersion = '0.20.1';
Future<void> main() async { Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
@ -338,6 +339,19 @@ class _Sidebar extends StatelessWidget {
child: _ConnectionPill( child: _ConnectionPill(
connected: connected, connected: connected,
label: endpointLabel, 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) ...[ if (activeChannel != null && activeChannel!.isNotEmpty) ...[
@ -482,8 +496,17 @@ class _ChannelPill extends StatelessWidget {
class _ConnectionPill extends StatelessWidget { class _ConnectionPill extends StatelessWidget {
final bool? connected; final bool? connected;
final String label; 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<void> Function() onStartRequested;
const _ConnectionPill({required this.connected, required this.label}); const _ConnectionPill({
required this.connected,
required this.label,
required this.onStartRequested,
});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -536,6 +559,31 @@ class _ConnectionPill extends StatelessWidget {
color: theme.colorScheme.onSurfaceVariant, 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,
),
),
),
),
],
], ],
), ),
); );

View file

@ -424,6 +424,23 @@ class _DaemonActionsCard extends StatefulWidget {
class _DaemonActionsCardState extends State<_DaemonActionsCard> { class _DaemonActionsCardState extends State<_DaemonActionsCard> {
bool _busy = false; bool _busy = false;
String? _output; String? _output;
ChannelStatusSnapshot? _channels;
@override
void initState() {
super.initState();
_refreshStatus();
}
Future<void> _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<void> _run( Future<void> _run(
String label, String label,
@ -442,94 +459,168 @@ class _DaemonActionsCardState extends State<_DaemonActionsCard> {
: 'Failed · $label\n${r.stderr.isEmpty ? r.stdout : r.stderr}') : 'Failed · $label\n${r.stderr.isEmpty ? r.stdout : r.stderr}')
.trim(); .trim();
}); });
// Refresh the status pill restart / stop / start all
// change the running flag.
await _refreshStatus();
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(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( return FaiCard(
child: Column( // Row + Expanded forces the card to take the full width
crossAxisAlignment: CrossAxisAlignment.start, // its parent offers. Without this, the card hugs the
children: [ // intrinsic width of the longest button row and ends up
Text( // visibly narrower than its siblings.
'Spawn the platform binary to manage the running daemon. ' child: SizedBox(
'Mirrors the CLI: `fai daemon …`.', width: double.infinity,
style: theme.textTheme.bodySmall?.copyWith( child: Column(
color: theme.colorScheme.onSurfaceVariant, crossAxisAlignment: CrossAxisAlignment.start,
), children: [
),
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( Row(
children: [ children: [
const SizedBox( Icon(
width: 14, active?.running == true
height: 14, ? Icons.check_circle_outline
child: CircularProgressIndicator(strokeWidth: 2), : Icons.power_off_outlined,
size: 18,
color: active?.running == true
? FaiColors.success
: theme.colorScheme.onSurfaceVariant,
), ),
const SizedBox(width: FaiSpace.sm), const SizedBox(width: FaiSpace.sm),
Text( Expanded(
'Working…', child: Text(
style: theme.textTheme.bodySmall?.copyWith( active == null
color: theme.colorScheme.onSurfaceVariant, ? '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/<name>.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 (_busy) ...[
if (_output != null) ...[ const SizedBox(height: FaiSpace.md),
const SizedBox(height: FaiSpace.md), Row(
Container( children: [
width: double.infinity, const SizedBox(
padding: const EdgeInsets.all(FaiSpace.sm), width: 14,
decoration: BoxDecoration( height: 14,
color: theme.colorScheme.surfaceContainerHigh, child: CircularProgressIndicator(strokeWidth: 2),
borderRadius: BorderRadius.circular(FaiRadius.sm), ),
border: Border.all(color: theme.colorScheme.outlineVariant), const SizedBox(width: FaiSpace.sm),
Text(
'Working…',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
), ),
child: SelectableText( ],
_output!, if (_output != null) ...[
style: FaiTheme.mono( const SizedBox(height: FaiSpace.md),
size: 11, Container(
color: theme.colorScheme.onSurface, 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,
),
), ),
), ),
), ],
], ],
], ),
), ),
); );
} }

View file

@ -64,7 +64,15 @@ class _StorePageState extends State<StorePage> {
return _installedOnly ? all.where((e) => e.installed).toList() : all; 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<void> _openDetail(StoreItem item) async { Future<void> _openDetail(StoreItem item) async {
final changed = await _StoreDetailSheet.show(context, item, _locale); final changed = await _StoreDetailSheet.show(context, item, _locale);
@ -533,7 +541,11 @@ class _FeaturedStrip extends StatelessWidget {
), ),
const SizedBox(height: FaiSpace.sm), const SizedBox(height: FaiSpace.sm),
SizedBox( 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( child: ListView.separated(
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
itemCount: items.length, itemCount: items.length,
@ -541,7 +553,7 @@ class _FeaturedStrip extends StatelessWidget {
itemBuilder: (context, i) { itemBuilder: (context, i) {
final item = items[i]; final item = items[i];
return SizedBox( return SizedBox(
width: 320, width: 480,
child: _FeaturedTile( child: _FeaturedTile(
item: item, item: item,
locale: locale, locale: locale,
@ -584,12 +596,12 @@ class _FeaturedTile extends StatelessWidget {
onTap: onTap, onTap: onTap,
borderRadius: BorderRadius.circular(FaiRadius.md), borderRadius: BorderRadius.circular(FaiRadius.md),
child: Container( child: Container(
padding: const EdgeInsets.all(FaiSpace.md), padding: const EdgeInsets.all(FaiSpace.lg),
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: BorderRadius.circular(FaiRadius.md), borderRadius: BorderRadius.circular(FaiRadius.md),
gradient: LinearGradient( gradient: LinearGradient(
colors: [ colors: [
theme.colorScheme.primary.withValues(alpha: 0.10), theme.colorScheme.primary.withValues(alpha: 0.14),
theme.colorScheme.surfaceContainer.withValues(alpha: 0.0), theme.colorScheme.surfaceContainer.withValues(alpha: 0.0),
], ],
begin: Alignment.topLeft, begin: Alignment.topLeft,
@ -602,34 +614,46 @@ class _FeaturedTile extends StatelessWidget {
Row( Row(
children: [ children: [
CircleAvatar( CircleAvatar(
radius: 20, radius: 24,
backgroundColor: backgroundColor:
theme.colorScheme.primary.withValues(alpha: 0.16), theme.colorScheme.primary.withValues(alpha: 0.18),
child: Icon( child: Icon(
_iconForCategory(item.category), _iconForCategory(item.category),
size: 22, size: 26,
color: theme.colorScheme.primary, color: theme.colorScheme.primary,
), ),
), ),
const SizedBox(width: FaiSpace.sm), const SizedBox(width: FaiSpace.md),
Expanded( Expanded(
child: Text( child: Column(
item.name, crossAxisAlignment: CrossAxisAlignment.start,
style: theme.textTheme.titleMedium?.copyWith( children: [
fontWeight: FontWeight.w700, Text(
), item.name,
maxLines: 1, style: theme.textTheme.titleLarge?.copyWith(
overflow: TextOverflow.ellipsis, 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( Expanded(
child: Text( child: Text(
tagline.isEmpty ? '(no tagline)' : tagline, tagline.isEmpty ? '(no tagline)' : tagline,
style: theme.textTheme.bodyMedium, style: theme.textTheme.bodyLarge,
maxLines: 3, maxLines: 3,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
@ -659,11 +683,8 @@ class _FeaturedTile extends StatelessWidget {
else if (installable) else if (installable)
FilledButton.icon( FilledButton.icon(
onPressed: onInstall, onPressed: onInstall,
icon: const Icon(Icons.download, size: 14), icon: const Icon(Icons.download, size: 16),
label: const Text('Install'), label: const Text('Install'),
style: FilledButton.styleFrom(
visualDensity: VisualDensity.compact,
),
), ),
], ],
), ),

View file

@ -1,7 +1,7 @@
name: fai_studio name: fai_studio
description: "F∆I Studio — desktop GUI for the F∆I hub" description: "F∆I Studio — desktop GUI for the F∆I hub"
publish_to: 'none' publish_to: 'none'
version: 0.20.0 version: 0.20.1
environment: environment:
sdk: ^3.11.0-200.1.beta sdk: ^3.11.0-200.1.beta