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 '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<void> 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<void> 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,
),
),
),
),
],
],
),
);

View file

@ -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<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(
String label,
@ -442,18 +459,76 @@ 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(
// 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: [
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),
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/<name>.pid',
child: FaiPill(
label: active.running ? 'running' : 'stopped',
tone: active.running
? FaiPillTone.success
: FaiPillTone.neutral,
),
),
],
],
),
const SizedBox(height: FaiSpace.sm),
Text(
'Spawn the platform binary to manage the running daemon. '
'Mirrors the CLI: `fai daemon …`.',
'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,
),
@ -466,18 +541,33 @@ class _DaemonActionsCardState extends State<_DaemonActionsCard> {
FilledButton.tonalIcon(
onPressed: _busy
? null
: () =>
_run('daemon restart', () => SystemActions.faiDaemon(['restart'])),
: () => _run(
'daemon restart',
() => SystemActions.faiDaemon(['restart']),
),
icon: const Icon(Icons.restart_alt, size: 16),
label: const Text('Restart daemon'),
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'])),
: () => _run(
'daemon stop',
() => SystemActions.faiDaemon(['stop']),
),
icon: const Icon(Icons.stop_circle_outlined, size: 16),
label: const Text('Stop daemon'),
label: const Text('Stop'),
),
OutlinedButton.icon(
onPressed: _busy
@ -531,6 +621,7 @@ class _DaemonActionsCardState extends State<_DaemonActionsCard> {
],
],
),
),
);
}
}

View file

@ -64,7 +64,15 @@ class _StorePageState extends State<StorePage> {
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 {
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(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.name,
style: theme.textTheme.titleMedium?.copyWith(
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),
],
),
const SizedBox(height: FaiSpace.sm),
),
Icon(Icons.star, size: 18, color: FaiColors.success),
],
),
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,
),
),
],
),

View file

@ -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