feat(studio): pencil-into-editor + back button + collapsible rail
Some checks failed
Security / Security check (push) Failing after 2s

Three operator-visible improvements per Stefan's review of
the v0.50.0 editor.

1. Pencil icon on Flows page now opens Studio's own flow
   editor (route push) preloaded with the selected flow,
   replacing the previous "open in OS default editor"
   (SystemActions.openInOs) behaviour. The editor's AppBar
   gains a back arrow when reached via route push; reaching
   the editor via the nav rail leaves it bare. Tooltip
   string updated in EN + DE.

2. New FlowEditorPage `initialFlowName` parameter. When set,
   the page loads that flow on first frame (via post-frame
   callback so the BuildContext is mounted before
   _openByName runs). When null (the nav-rail path), the
   editor opens to its empty state as before.

3. Sidebar (_Sidebar) is now collapsed-by-default: shows
   just icons in a 72px-wide rail with per-destination
   tooltips. Hovering the rail expands it to 220px (the
   old width) with brand-mark + labels + the full footer
   row (theme toggle, language toggle, clock, settings).
   Collapsed footer shows the settings icon only. Brand-
   mark (FaiDeltaMark) stays visible in both states so the
   live/idle status dot is always glanceable.

Side-effect: SystemActions import in flows.dart is no
longer needed (the pencil no longer shells out) — removed.

widget_test.dart: dropped the per-destination Text-presence
asserts since labels are now Tooltips when collapsed.
Hover-expand testing triggers RenderFlex-overflow mid-
animation in widget tests (the AnimatedContainer's width
transitions through a constraint slimmer than the Row's
intrinsic min). The booting-without-throwing assertion
remains; per-destination presence stays in the per-page
test suites.

Both arb files updated with the new strings (navFlowEditor
already existed; added flowEditorBackTooltip + retouched
flowsOpenInEditorTooltip).

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
flemming-it 2026-05-30 12:04:05 +02:00
parent 647d93a1dc
commit e522267ec1
9 changed files with 269 additions and 142 deletions

View file

@ -432,7 +432,7 @@ class _OpenSearchIntent extends Intent {
const _OpenSearchIntent();
}
class _Sidebar extends StatelessWidget {
class _Sidebar extends StatefulWidget {
final int selectedIndex;
final ValueChanged<int> onSelect;
final List<_NavPage> pages;
@ -452,112 +452,143 @@ class _Sidebar extends StatelessWidget {
required this.activeChannel,
});
@override
State<_Sidebar> createState() => _SidebarState();
}
class _SidebarState extends State<_Sidebar> {
// Default-collapsed (icons only); expands on hover. Width
// anim mirrors NavigationRail's spec: 72 collapsed, 220
// expanded.
static const double _collapsedWidth = 72;
static const double _expandedWidth = 220;
bool _hovered = false;
@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: 2),
Text(
'v$kStudioVersion',
style: FaiTheme.mono(
size: 10,
color: theme.colorScheme.primary,
),
),
const SizedBox(height: FaiSpace.md),
// Connection pill.
Padding(
padding: const EdgeInsets.symmetric(horizontal: FaiSpace.lg),
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()}',
),
final mode = widget.connected == true ? FaiDeltaMode.live : FaiDeltaMode.idle;
final expanded = _hovered;
final width = expanded ? _expandedWidth : _collapsedWidth;
return MouseRegion(
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: AnimatedContainer(
duration: FaiMotion.fast,
width: width,
color: theme.colorScheme.surfaceContainerLow,
padding: const EdgeInsets.symmetric(vertical: FaiSpace.xl),
child: ClipRect(
child: Column(
children: [
// Brand mark always visible.
FaiDeltaMark(color: theme.colorScheme.primary, mode: mode),
if (expanded) ...[
const SizedBox(height: FaiSpace.md),
Text(
'F∆I Studio',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
);
},
),
),
if (activeChannel != null && activeChannel!.isNotEmpty) ...[
const SizedBox(height: FaiSpace.sm),
Padding(
padding: const EdgeInsets.symmetric(horizontal: FaiSpace.lg),
child: _ChannelPill(channel: activeChannel!),
),
],
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),
),
const SizedBox(height: 2),
Text(
'v$kStudioVersion',
style: FaiTheme.mono(
size: 10,
color: theme.colorScheme.primary,
),
],
),
),
// Footer: theme toggle · live wall-clock · settings.
// Clock sits between the two icons so it reads as a
// status line gives the operator a wall-clock
// reference to cross-check audit-log timestamps
// without taking sidebar real estate from anything
// load-bearing.
Padding(
padding: const EdgeInsets.symmetric(horizontal: FaiSpace.md),
child: Row(
children: [
_ThemeToggle(),
_LanguageToggle(),
const Expanded(
child: Center(child: _SidebarClock()),
),
IconButton(
icon: const Icon(Icons.settings_outlined, size: 16),
tooltip: 'Settings (⌘;)',
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();
}
},
const SizedBox(height: FaiSpace.md),
// Connection pill only when expanded; the
// status-dot is encoded in the delta-mark's mode
// so collapsed users still see at-a-glance state.
Padding(
padding: const EdgeInsets.symmetric(horizontal: FaiSpace.lg),
child: _ConnectionPill(
connected: widget.connected,
label: widget.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 (widget.activeChannel != null && widget.activeChannel!.isNotEmpty) ...[
const SizedBox(height: FaiSpace.sm),
Padding(
padding: const EdgeInsets.symmetric(horizontal: FaiSpace.lg),
child: _ChannelPill(channel: widget.activeChannel!),
),
],
],
),
const SizedBox(height: FaiSpace.xxl),
// Destinations.
Expanded(
child: ListView(
padding: const EdgeInsets.symmetric(horizontal: FaiSpace.md),
children: [
for (var i = 0; i < widget.pages.length; i++)
_SidebarItem(
page: widget.pages[i],
selected: i == widget.selectedIndex,
expanded: expanded,
onTap: () => widget.onSelect(i),
),
],
),
),
// Footer: theme toggle + clock + settings when
// expanded; just the settings icon when collapsed.
Padding(
padding: const EdgeInsets.symmetric(horizontal: FaiSpace.md),
child: expanded
? Row(
children: [
_ThemeToggle(),
_LanguageToggle(),
const Expanded(
child: Center(child: _SidebarClock()),
),
IconButton(
icon: const Icon(Icons.settings_outlined, size: 16),
tooltip: 'Settings (⌘;)',
visualDensity: VisualDensity.compact,
onPressed: () => _openSettings(context),
),
],
)
: IconButton(
icon: const Icon(Icons.settings_outlined, size: 16),
tooltip: 'Settings (⌘;)',
visualDensity: VisualDensity.compact,
onPressed: () => _openSettings(context),
),
),
],
),
],
),
),
);
}
Future<void> _openSettings(BuildContext context) 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();
}
}
}
/// Active-channel indicator. Click to open Settings (where the
@ -741,11 +772,13 @@ class _ConnectionPill extends StatelessWidget {
class _SidebarItem extends StatefulWidget {
final _NavPage page;
final bool selected;
final bool expanded;
final VoidCallback onTap;
const _SidebarItem({
required this.page,
required this.selected,
required this.expanded,
required this.onTap,
});
@ -769,7 +802,34 @@ class _SidebarItemState extends State<_SidebarItem> {
: _hovered
? theme.colorScheme.surfaceContainerHigh
: Colors.transparent;
return Padding(
final label = widget.page.labelOf(context);
final icon = Icon(
widget.selected ? widget.page.selectedIcon : widget.page.icon,
size: 18,
color: color,
);
final Widget content = widget.expanded
? Row(
mainAxisSize: MainAxisSize.min,
children: [
icon,
const SizedBox(width: FaiSpace.md),
// Flexible so the label can ellipsize during the
// collapse animation instead of overflowing.
Flexible(
child: Text(
label,
overflow: TextOverflow.fade,
softWrap: false,
style: theme.textTheme.labelMedium?.copyWith(color: color),
),
),
],
)
: Center(child: icon);
final inner = Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: MouseRegion(
onEnter: (_) => setState(() => _hovered = true),
@ -780,32 +840,28 @@ class _SidebarItemState extends State<_SidebarItem> {
onTap: widget.onTap,
child: AnimatedContainer(
duration: FaiMotion.fast,
padding: const EdgeInsets.symmetric(
horizontal: FaiSpace.md,
padding: EdgeInsets.symmetric(
horizontal: widget.expanded ? FaiSpace.md : FaiSpace.sm,
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.labelOf(context),
style: theme.textTheme.labelMedium?.copyWith(color: color),
),
],
),
child: content,
),
),
),
);
// Tooltip only when collapsed when the rail is expanded
// the label is visible inline + tooltips become noisy.
if (widget.expanded) return inner;
return Tooltip(
message: label,
preferBelow: false,
child: inner,
);
}
}