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 // When a step is selected, show the properties panel on
// the right as a fixed sidebar. Selection is owned by // the right as a fixed sidebar. Selection is owned by
// the controller and ChangeNotifier rebuilds drive the // 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( return Row(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ 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) ...[ if (_controller.selectedStepId != null) ...[
const VerticalDivider(width: 1), const VerticalDivider(width: 1),
SizedBox( SizedBox(

View file

@ -37,6 +37,7 @@ import 'package:flutter/material.dart';
import '../editor_controller.dart'; import '../editor_controller.dart';
import '../model/flow_graph.dart'; import '../model/flow_graph.dart';
import '../model/layout_store.dart'; import '../model/layout_store.dart';
import '../tokens.dart';
import 'edge_painter.dart'; import 'edge_painter.dart';
import 'flow_node.dart'; import 'flow_node.dart';
@ -62,6 +63,10 @@ class FlowCanvas extends StatefulWidget {
class _FlowCanvasState extends State<FlowCanvas> { class _FlowCanvasState extends State<FlowCanvas> {
final TransformationController _transform = TransformationController(); final TransformationController _transform = TransformationController();
// Track which flow we last fitted-to-screen for so we
// don't override the operator's manual pan/zoom every
// build. Re-fit when the active flow changes.
String? _fittedFor;
// Active connection-drag state. When non-null, the canvas // Active connection-drag state. When non-null, the canvas
// paints a draft edge from the source port to the cursor // paints a draft edge from the source port to the cursor
@ -91,9 +96,23 @@ class _FlowCanvasState extends State<FlowCanvas> {
final graph = widget.controller.graph; final graph = widget.controller.graph;
final layout = widget.controller.layout; final layout = widget.controller.layout;
final outputsX = _outputsX(graph, layout); final outputsX = _outputsX(graph, layout);
// Auto-fit on first build for each flow so the operator
// sees the whole graph immediately, even on flows whose
// auto-layout pushes nodes past the default viewport.
// Re-fit triggers only when the flow name changes the
// operator's subsequent zooms / pans stay theirs.
final activeName = widget.controller.activeName;
if (activeName != null && activeName != _fittedFor) {
_fittedFor = activeName;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _fitToContent();
});
}
return Container( return Container(
color: theme.colorScheme.surface, color: theme.colorScheme.surface,
child: InteractiveViewer( child: Stack(
children: [
InteractiveViewer(
transformationController: _transform, transformationController: _transform,
constrained: false, constrained: false,
boundaryMargin: const EdgeInsets.all(400), boundaryMargin: const EdgeInsets.all(400),
@ -111,9 +130,8 @@ class _FlowCanvasState extends State<FlowCanvas> {
child: CustomPaint( child: CustomPaint(
painter: EdgePainter( painter: EdgePainter(
segments: _buildSegments(graph, layout, outputsX), segments: _buildSegments(graph, layout, outputsX),
baseColor: theme.colorScheme.onSurfaceVariant.withValues( baseColor: theme.colorScheme.onSurfaceVariant
alpha: 0.55, .withValues(alpha: 0.55),
),
highlightColor: theme.colorScheme.primary, highlightColor: theme.colorScheme.primary,
draftColor: theme.colorScheme.primary, draftColor: theme.colorScheme.primary,
), ),
@ -180,9 +198,83 @@ class _FlowCanvasState extends State<FlowCanvas> {
), ),
), ),
), ),
// Floating "Fit to screen" button recenters the
// canvas so every node is in view at once. Pinned
// to the bottom-right of the viewport rather than
// the canvas surface so it doesn't drift with pan.
Positioned(
right: FaiSpace.md,
bottom: FaiSpace.md,
child: Material(
color: theme.colorScheme.surfaceContainer,
borderRadius: BorderRadius.circular(FaiRadius.sm),
elevation: 2,
child: IconButton(
onPressed: _fitToContent,
icon: const Icon(Icons.fit_screen_outlined, size: 18),
tooltip: 'Fit to screen',
visualDensity: VisualDensity.compact,
),
),
),
],
),
); );
} }
/// Compute the bounding box of every visible node + the
/// inputs / outputs sidebars, then set the
/// TransformationController so the box fills the visible
/// viewport with breathing room. No-op when there's no
/// active flow (nothing to fit).
void _fitToContent() {
final graph = widget.controller.graph;
final layout = widget.controller.layout;
if (widget.controller.activeName == null) return;
if (graph.steps.isEmpty && graph.inputs.isEmpty) return;
// Bounding box: start with the inputs pseudo-node.
double minX = _inputsX;
double minY = _inputsY;
double maxX = _inputsX + NodeGeometry.width;
double maxY =
_inputsY +
NodeGeometry.heightFor(graph.inputs.length).clamp(110.0, 600.0);
// Outputs.
final outputsX = _outputsX(graph, layout);
maxX = outputsX + NodeGeometry.width > maxX
? outputsX + NodeGeometry.width
: maxX;
// Step nodes.
for (final step in graph.steps) {
final pos = layout.positions[step.id];
if (pos == null) continue;
if (pos.x < minX) minX = pos.x;
if (pos.y < minY) minY = pos.y;
final right = pos.x + NodeGeometry.width;
final bottom = pos.y + NodeGeometry.heightFor(step.with_.length);
if (right > maxX) maxX = right;
if (bottom > maxY) maxY = bottom;
}
// Padding so nodes don't kiss the viewport edge.
const pad = 80.0;
minX -= pad;
minY -= pad;
maxX += pad;
maxY += pad;
final boxW = maxX - minX;
final boxH = maxY - minY;
final size = (context.findRenderObject() as RenderBox?)?.size;
if (size == null || size.width <= 0 || size.height <= 0) return;
final scale = (size.width / boxW).clamp(0.0, 2.0).toDouble();
final scale2 = (size.height / boxH).clamp(0.0, 2.0).toDouble();
final finalScale = scale < scale2 ? scale : scale2;
final tx = -minX * finalScale + (size.width - boxW * finalScale) / 2;
final ty = -minY * finalScale + (size.height - boxH * finalScale) / 2;
_transform.value = Matrix4.identity()
..translateByDouble(tx, ty, 0, 1)
..scaleByDouble(finalScale, finalScale, 1, 1);
}
// --- Step positioning + drag --- // --- Step positioning + drag ---
Widget _stepPositioned(FlowStep step, FlowLayout layout, double _) { Widget _stepPositioned(FlowStep step, FlowLayout layout, double _) {