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:
parent
26691b7471
commit
fb7352d182
4 changed files with 256 additions and 96 deletions
|
|
@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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,18 +459,76 @@ 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(
|
||||||
|
// 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(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
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(
|
Text(
|
||||||
'Spawn the platform binary to manage the running daemon. '
|
'Studio shells out to the platform binary — mirrors '
|
||||||
'Mirrors the CLI: `fai daemon …`.',
|
'`fai daemon …` exactly so the CLI and UI stay in lockstep.',
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
|
|
@ -466,18 +541,33 @@ class _DaemonActionsCardState extends State<_DaemonActionsCard> {
|
||||||
FilledButton.tonalIcon(
|
FilledButton.tonalIcon(
|
||||||
onPressed: _busy
|
onPressed: _busy
|
||||||
? null
|
? null
|
||||||
: () =>
|
: () => _run(
|
||||||
_run('daemon restart', () => SystemActions.faiDaemon(['restart'])),
|
'daemon restart',
|
||||||
|
() => SystemActions.faiDaemon(['restart']),
|
||||||
|
),
|
||||||
icon: const Icon(Icons.restart_alt, size: 16),
|
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(
|
OutlinedButton.icon(
|
||||||
onPressed: _busy
|
onPressed: _busy
|
||||||
? null
|
? null
|
||||||
: () =>
|
: () => _run(
|
||||||
_run('daemon stop', () => SystemActions.faiDaemon(['stop'])),
|
'daemon stop',
|
||||||
|
() => SystemActions.faiDaemon(['stop']),
|
||||||
|
),
|
||||||
icon: const Icon(Icons.stop_circle_outlined, size: 16),
|
icon: const Icon(Icons.stop_circle_outlined, size: 16),
|
||||||
label: const Text('Stop daemon'),
|
label: const Text('Stop'),
|
||||||
),
|
),
|
||||||
OutlinedButton.icon(
|
OutlinedButton.icon(
|
||||||
onPressed: _busy
|
onPressed: _busy
|
||||||
|
|
@ -531,6 +621,7 @@ class _DaemonActionsCardState extends State<_DaemonActionsCard> {
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
item.name,
|
item.name,
|
||||||
style: theme.textTheme.titleMedium?.copyWith(
|
style: theme.textTheme.titleLarge?.copyWith(
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
),
|
),
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
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(
|
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,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue