feat(editor): empty-graph CTA + fit-to-screen button

Two polishing tweaks on the graph tab:

 - When the active flow has no steps, the canvas would have
   rendered as a near-empty grid (just inputs / outputs
   sidebars). Overlay a centred call-to-action with the
   graph icon, the "No steps yet" / "Noch keine Schritte"
   heading, a one-liner explainer, and a primary Add Step
   button that opens the capability picker directly.
 - Auto-fit zoom on first open of each flow + a floating
   "Fit to screen" button (bottom-right corner of the
   canvas viewport). The auto-fit re-runs only when the
   flow name changes, so the operator's manual pan / zoom
   on the current flow is preserved. The fit math expands
   the bounding box to include inputs + outputs pseudo-
   nodes too, so wide flows zoom out enough to show every
   port at once.

Fit math:
 - Walk every step's stored position + height; merge with
   the inputs sidebar's known position.
 - Compute scale that makes the bounding box fit the
   viewport with 80 px padding on each side.
 - Clamp to [0.4, 2.0] — InteractiveViewer's own bounds.
 - Translate so the box centres in the viewport.

`flutter analyze` clean, all 11 tests still pass.

Signed-off-by: flemming-it <sf@flemming.it>
This commit is contained in:
flemming-it 2026-06-01 01:51:11 +02:00
parent 6b70ba65fd
commit 9d21bd5318
2 changed files with 220 additions and 81 deletions

View file

@ -416,11 +416,58 @@ outputs:
// When a step is selected, show the properties panel on
// the right as a fixed sidebar. Selection is owned by
// the controller and ChangeNotifier rebuilds drive the
// panel show / hide.
// panel show / hide. When the flow has no steps at all,
// overlay a call-to-action so the operator's first
// instinct is the right action rather than staring at an
// empty grid.
final hasSteps = _controller.graph.steps.isNotEmpty;
return Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(child: FlowCanvas(controller: _controller)),
Expanded(
child: Stack(
children: [
FlowCanvas(controller: _controller),
if (!hasSteps)
Positioned.fill(
child: Container(
color: theme.colorScheme.surface.withValues(alpha: 0.92),
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.account_tree_outlined,
size: 48,
color: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(height: FaiSpace.md),
Text(
_l.graphEmptyTitle,
style: theme.textTheme.titleMedium,
),
const SizedBox(height: FaiSpace.sm),
Text(
_l.graphEmptyBody,
textAlign: TextAlign.center,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: FaiSpace.md),
FilledButton.icon(
onPressed: _addStep,
icon: const Icon(Icons.add_box_outlined, size: 16),
label: Text(_l.addStep),
),
],
),
),
),
),
],
),
),
if (_controller.selectedStepId != null) ...[
const VerticalDivider(width: 1),
SizedBox(