A 2026-feel pass on the visual language. Three structural
moves that change how the editor reads at a glance.
1. Wires now carry the colour of the data they transport.
Every edge leaving the inputs endpoint takes the
declared input's type accent — blue for text, orange
for bytes, purple for json, green for file, amber for
number. The operator can trace "this is the document
path, this is the prompt" by following colour, no
labels needed. Edges leaving step outputs stay neutral
(we don't have module manifests to look up the output
type yet — once the hub exposes them, this lookup
grows).
Hover / selection still wins the colour treatment so
the "I'm pointing at this one" signal isn't lost in
the type palette.
2. Edges entering a currently-running step animate.
New AnimationController on the canvas loops a 0..1
phase at 1.5 s when ANY step is running, stopped
otherwise (no idle cost). EdgePainter takes the phase
and renders animated edges as marching dashes in the
wire's accent colour over a 35%-alpha base — the
operator literally sees data moving along the wire in
the direction of flow while a step executes. When the
step completes, the animation stops, the wire returns
to a static stroke. Run a flow with a slow step and
the canvas comes alive.
3. Canvas gets depth.
- Background is now a subtle top-left → bottom-right
gradient between surfaceContainer and surface,
giving the working area a "lit centre, recessed
corners" cue instead of one flat dark page.
- Node cards swap their Material widget for an
AnimatedContainer with two-layer shadows: a sharp
short-blur shadow gives the card a real footprint,
a soft long-blur shadow casts depth onto the canvas
behind. Selected nodes get a third accent-coloured
halo so selection state reads from across the canvas.
- 180 ms ease-out-cubic transitions on the
AnimatedContainer mean hover-into-select and
run-status-changes morph smoothly instead of
snapping.
Edge stroke widths bumped slightly (2.1 / 2.8 from 2.0 /
2.6) so the typed wires read at a confident weight against
the new gradient backdrop.
Version 0.6.0 -> 0.7.0 — visible visual language change.
Signed-off-by: flemming-it <sf@flemming.it>
432 lines
15 KiB
Dart
432 lines
15 KiB
Dart
// FlowNode — the visual representation of one step on the
|
||
// canvas. Three visual variants:
|
||
//
|
||
// - module step: rounded card, gear icon, primary border
|
||
// when selected
|
||
// - approval step: tertiary tint, hand-up icon, marks where
|
||
// the flow waits for a human
|
||
// - inputs / outputs sidebar nodes: tall, pinned to edges
|
||
//
|
||
// The card exposes named ports on its left (one per `with:`
|
||
// key) and a single combined port on its right that all
|
||
// downstream edges leave from. Ports are small filled circles
|
||
// just outside the card border so edges can visually anchor
|
||
// to them.
|
||
|
||
import 'package:flutter/material.dart';
|
||
|
||
import '../model/flow_graph.dart';
|
||
import '../tokens.dart';
|
||
|
||
/// Geometry constants the canvas + edge painter rely on to
|
||
/// place ports without measuring widgets at runtime. Every
|
||
/// number here must match the actual widget layout produced
|
||
/// by FlowNode.build — when these drift, the connection
|
||
/// dots stop aligning with the inline port-labels and edges
|
||
/// look like they're attached to nothing in particular.
|
||
///
|
||
/// Layout reads top-to-bottom:
|
||
/// - header band (titleHeight + subtitleHeight + chrome)
|
||
/// - bodyTopPad
|
||
/// - N × portRowHeight (each a row that vertically centres
|
||
/// its content)
|
||
/// - bodyBottomPad
|
||
class NodeGeometry {
|
||
static const double width = 220;
|
||
// Header band is constant whether the node has a subtitle
|
||
// or not. Endpoint nodes (inputs / outputs) render an empty
|
||
// subtitle slot — that keeps port-row Y-offsets identical
|
||
// across every node kind, which is what the port-dot
|
||
// positioning relies on.
|
||
static const double titleLineHeight = 22;
|
||
static const double subtitleLineHeight = 16;
|
||
static const double headerHeight = titleLineHeight + subtitleLineHeight + 10;
|
||
static const double portRowHeight = 22;
|
||
static const double bodyTopPad = 6;
|
||
static const double bodyBottomPad = 10;
|
||
|
||
/// Reserved space on the port-bearing edge inside the body
|
||
/// — leaves room for the canvas-side port dot to land
|
||
/// without overlapping the label text.
|
||
static const double portGutter = 18;
|
||
|
||
/// Diameter of the canvas-side port dot — sized to the
|
||
/// body's labelSmall row (~11 px text + 11 px breathing
|
||
/// room) so the dot reads as the port without crowding.
|
||
static const double portDotSize = 12;
|
||
|
||
/// Total card height for a node with [portCount] inputs.
|
||
static double heightFor(int portCount) {
|
||
return headerHeight +
|
||
bodyTopPad +
|
||
portCount * portRowHeight +
|
||
bodyBottomPad;
|
||
}
|
||
|
||
/// Y offset (from the card's top edge) where the input
|
||
/// port at row [index] is vertically centred. Match this
|
||
/// exactly with the inline 6-px dot inside FlowNode._body.
|
||
static double inputPortY(int index) {
|
||
return headerHeight +
|
||
bodyTopPad +
|
||
index * portRowHeight +
|
||
portRowHeight / 2;
|
||
}
|
||
|
||
/// Y offset of the single output port. Vertically centred
|
||
/// in the header so the edge entry feels balanced and so
|
||
/// "the step's primary output" reads as a peer of the
|
||
/// title rather than buried under the with-fields.
|
||
static double outputPortY() {
|
||
return headerHeight / 2;
|
||
}
|
||
}
|
||
|
||
enum NodeVisualKind { module, approval, inputs, outputs }
|
||
|
||
/// Which side of the card the labels' associated ports sit on.
|
||
/// Drives label text-alignment and informs the canvas where to
|
||
/// drop port-dot widgets.
|
||
///
|
||
/// - `left`: port dots hang off the left edge, labels
|
||
/// left-aligned (used for step inputs, outputs
|
||
/// endpoint inputs).
|
||
/// - `right`: port dots hang off the right edge, labels
|
||
/// right-aligned (used for inputs endpoint —
|
||
/// its body labels represent OUTPUTS of the
|
||
/// node since data flows from the inputs
|
||
/// endpoint OUT to downstream steps).
|
||
enum NodePortSide { left, right }
|
||
|
||
class FlowNode extends StatelessWidget {
|
||
final String id;
|
||
final String title;
|
||
final String? subtitle;
|
||
final List<String> inputPortLabels;
|
||
final NodeVisualKind kind;
|
||
final NodePortSide portSide;
|
||
final bool selected;
|
||
final VoidCallback? onTap;
|
||
final void Function(Offset delta)? onDrag;
|
||
final VoidCallback? onDragEnd;
|
||
|
||
/// Inputs that COULD be set but are intentionally optional
|
||
/// — drawn slightly muted so the operator can see at a
|
||
/// glance which slots are "must wire" vs "may wire". Until
|
||
/// we have module manifests, every step-input defaults to
|
||
/// "must wire" and this is empty.
|
||
final Set<String> optionalLabels;
|
||
|
||
/// How many of [inputPortLabels] are currently wired to an
|
||
/// upstream source. When equal to the total count, the
|
||
/// header paints a "complete" check mark; otherwise it
|
||
/// renders the ratio (e.g. "3/5") so the operator sees
|
||
/// at a glance what's still to wire. Set to null on
|
||
/// endpoint nodes where the ratio doesn't apply.
|
||
final int? wiredCount;
|
||
|
||
/// Right-click handler on the node body. Canvas wires this
|
||
/// to a popup menu at the cursor position (Duplicate /
|
||
/// Delete / Disconnect inputs etc.).
|
||
final void Function(Offset globalPos)? onContextMenu;
|
||
|
||
/// Live status from the most recent run, when this node is
|
||
/// a step. Coloured dot in the header so the operator can
|
||
/// glance at the canvas and see what's running.
|
||
final FlowNodeStatus status;
|
||
|
||
const FlowNode({
|
||
super.key,
|
||
required this.id,
|
||
required this.title,
|
||
this.subtitle,
|
||
this.inputPortLabels = const [],
|
||
this.kind = NodeVisualKind.module,
|
||
this.portSide = NodePortSide.left,
|
||
this.optionalLabels = const {},
|
||
this.wiredCount,
|
||
this.selected = false,
|
||
this.status = FlowNodeStatus.idle,
|
||
this.onTap,
|
||
this.onDrag,
|
||
this.onDragEnd,
|
||
this.onContextMenu,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final theme = Theme.of(context);
|
||
final accent = _accent(theme.colorScheme);
|
||
final height = NodeGeometry.heightFor(inputPortLabels.length);
|
||
return SizedBox(
|
||
width: NodeGeometry.width,
|
||
height: height,
|
||
child: GestureDetector(
|
||
behavior: HitTestBehavior.opaque,
|
||
onTap: onTap,
|
||
onPanUpdate: onDrag == null ? null : (d) => onDrag!(d.delta),
|
||
onPanEnd: onDragEnd == null ? null : (_) => onDragEnd!(),
|
||
onSecondaryTapDown: onContextMenu == null
|
||
? null
|
||
: (details) => onContextMenu!(details.globalPosition),
|
||
// Long-press is the trackpad fallback for the context
|
||
// menu — macOS trackpad users whose "two-finger click"
|
||
// isn't mapped to secondary still get the same menu.
|
||
onLongPressStart: onContextMenu == null
|
||
? null
|
||
: (details) => onContextMenu!(details.globalPosition),
|
||
child: AnimatedContainer(
|
||
duration: const Duration(milliseconds: 180),
|
||
curve: Curves.easeOutCubic,
|
||
decoration: BoxDecoration(
|
||
color: theme.colorScheme.surfaceContainerHigh,
|
||
borderRadius: BorderRadius.circular(FaiRadius.md),
|
||
border: Border.all(
|
||
color: selected ? accent : theme.dividerColor,
|
||
width: selected ? 2 : 1,
|
||
),
|
||
// Layered shadows give nodes real depth: a sharp
|
||
// inner shadow + a softer outer halo. Selected
|
||
// nodes get a coloured accent halo on top so the
|
||
// selection state reads from across the canvas.
|
||
boxShadow: [
|
||
BoxShadow(
|
||
color: Colors.black.withValues(alpha: selected ? 0.32 : 0.18),
|
||
blurRadius: selected ? 18 : 10,
|
||
offset: const Offset(0, 4),
|
||
),
|
||
if (selected)
|
||
BoxShadow(
|
||
color: accent.withValues(alpha: 0.30),
|
||
blurRadius: 24,
|
||
spreadRadius: 1,
|
||
),
|
||
],
|
||
),
|
||
child: ClipRRect(
|
||
borderRadius: BorderRadius.circular(FaiRadius.md),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
_header(theme, accent),
|
||
Expanded(child: _body(theme)),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _header(ThemeData theme, Color accent) {
|
||
return Container(
|
||
// Fixed header height (regardless of whether subtitle
|
||
// is present) — so the body's port-row Y offsets are
|
||
// identical across every node kind. Endpoint nodes
|
||
// simply leave the subtitle line blank.
|
||
height: NodeGeometry.headerHeight,
|
||
padding: const EdgeInsets.symmetric(horizontal: FaiSpace.sm),
|
||
decoration: BoxDecoration(
|
||
color: accent.withValues(alpha: 0.16),
|
||
borderRadius: const BorderRadius.only(
|
||
topLeft: Radius.circular(FaiRadius.md),
|
||
topRight: Radius.circular(FaiRadius.md),
|
||
),
|
||
),
|
||
child: Column(
|
||
mainAxisAlignment: MainAxisAlignment.center,
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
// Title line — icon + step id + optional status dot.
|
||
SizedBox(
|
||
height: NodeGeometry.titleLineHeight,
|
||
child: Row(
|
||
children: [
|
||
Icon(_iconFor(kind), size: 16, color: accent),
|
||
const SizedBox(width: FaiSpace.xs),
|
||
Expanded(
|
||
child: Text(
|
||
title,
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: theme.textTheme.titleSmall?.copyWith(
|
||
fontFamily: 'monospace',
|
||
fontWeight: FontWeight.w600,
|
||
color: accent,
|
||
),
|
||
),
|
||
),
|
||
if (wiredCount != null && inputPortLabels.isNotEmpty)
|
||
_wiredBadge(theme, accent),
|
||
if (status != FlowNodeStatus.idle) ...[
|
||
const SizedBox(width: 4),
|
||
_statusDot(theme),
|
||
],
|
||
],
|
||
),
|
||
),
|
||
// Subtitle line — always reserved space, even for
|
||
// endpoint nodes that have nothing to put here.
|
||
SizedBox(
|
||
height: NodeGeometry.subtitleLineHeight,
|
||
child: subtitle == null
|
||
? const SizedBox.shrink()
|
||
: Padding(
|
||
// Indent to align with title text after the icon.
|
||
padding: const EdgeInsets.only(left: 20),
|
||
child: Text(
|
||
subtitle!,
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: theme.textTheme.bodySmall?.copyWith(
|
||
color: theme.colorScheme.onSurfaceVariant,
|
||
fontFamily: 'monospace',
|
||
fontSize: 10,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _body(ThemeData theme) {
|
||
if (inputPortLabels.isEmpty) {
|
||
return const SizedBox.shrink();
|
||
}
|
||
final align = portSide == NodePortSide.right
|
||
? TextAlign.right
|
||
: TextAlign.left;
|
||
final crossAlign = portSide == NodePortSide.right
|
||
? CrossAxisAlignment.end
|
||
: CrossAxisAlignment.start;
|
||
// Leave a clear gutter on the port side so the canvas
|
||
// dot has somewhere to land. Other side gets normal
|
||
// padding.
|
||
final padding = portSide == NodePortSide.right
|
||
? const EdgeInsets.fromLTRB(
|
||
FaiSpace.sm,
|
||
NodeGeometry.bodyTopPad,
|
||
NodeGeometry.portGutter,
|
||
NodeGeometry.bodyBottomPad,
|
||
)
|
||
: const EdgeInsets.fromLTRB(
|
||
NodeGeometry.portGutter,
|
||
NodeGeometry.bodyTopPad,
|
||
FaiSpace.sm,
|
||
NodeGeometry.bodyBottomPad,
|
||
);
|
||
return Padding(
|
||
padding: padding,
|
||
child: Column(
|
||
crossAxisAlignment: crossAlign,
|
||
children: [
|
||
for (final label in inputPortLabels)
|
||
SizedBox(
|
||
height: NodeGeometry.portRowHeight,
|
||
child: Align(
|
||
alignment: portSide == NodePortSide.right
|
||
? Alignment.centerRight
|
||
: Alignment.centerLeft,
|
||
child: Text(
|
||
label,
|
||
textAlign: align,
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: theme.textTheme.labelSmall?.copyWith(
|
||
color: optionalLabels.contains(label)
|
||
? theme.colorScheme.onSurface.withValues(alpha: 0.55)
|
||
: theme.colorScheme.onSurface,
|
||
fontStyle: optionalLabels.contains(label)
|
||
? FontStyle.italic
|
||
: FontStyle.normal,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _wiredBadge(ThemeData theme, Color accent) {
|
||
final total = inputPortLabels.length;
|
||
final wired = wiredCount ?? 0;
|
||
final complete = wired >= total && total > 0;
|
||
if (complete) {
|
||
return Padding(
|
||
padding: const EdgeInsets.only(left: 4),
|
||
child: Icon(Icons.check_circle, size: 14, color: Colors.green.shade400),
|
||
);
|
||
}
|
||
return Padding(
|
||
padding: const EdgeInsets.only(left: 4),
|
||
child: Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1),
|
||
decoration: BoxDecoration(
|
||
color: accent.withValues(alpha: 0.12),
|
||
borderRadius: BorderRadius.circular(8),
|
||
border: Border.all(color: accent.withValues(alpha: 0.4)),
|
||
),
|
||
child: Text(
|
||
'$wired/$total',
|
||
style: theme.textTheme.labelSmall?.copyWith(
|
||
fontFamily: 'monospace',
|
||
fontSize: 9,
|
||
fontWeight: FontWeight.w600,
|
||
color: accent,
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _statusDot(ThemeData theme) {
|
||
final color = switch (status) {
|
||
FlowNodeStatus.running => theme.colorScheme.primary,
|
||
FlowNodeStatus.done => Colors.green.shade400,
|
||
FlowNodeStatus.failed => theme.colorScheme.error,
|
||
FlowNodeStatus.awaiting => theme.colorScheme.tertiary,
|
||
FlowNodeStatus.idle => Colors.transparent,
|
||
};
|
||
return Container(
|
||
width: 8,
|
||
height: 8,
|
||
decoration: BoxDecoration(
|
||
color: color,
|
||
shape: BoxShape.circle,
|
||
boxShadow: status == FlowNodeStatus.running
|
||
? [BoxShadow(color: color.withValues(alpha: 0.6), blurRadius: 6)]
|
||
: null,
|
||
),
|
||
);
|
||
}
|
||
|
||
IconData _iconFor(NodeVisualKind k) {
|
||
return switch (k) {
|
||
NodeVisualKind.module => Icons.widgets_outlined,
|
||
NodeVisualKind.approval => Icons.pan_tool_outlined,
|
||
NodeVisualKind.inputs => Icons.input,
|
||
NodeVisualKind.outputs => Icons.output,
|
||
};
|
||
}
|
||
|
||
Color _accent(ColorScheme cs) {
|
||
return switch (kind) {
|
||
NodeVisualKind.module => cs.primary,
|
||
NodeVisualKind.approval => cs.tertiary,
|
||
NodeVisualKind.inputs => cs.secondary,
|
||
NodeVisualKind.outputs => cs.secondary,
|
||
};
|
||
}
|
||
}
|
||
|
||
enum FlowNodeStatus { idle, running, done, failed, awaiting }
|
||
|
||
/// Render-time helper: figure out which visual variant to use
|
||
/// for a parsed step.
|
||
NodeVisualKind kindForStep(FlowStep step) {
|
||
if (step.isApproval) return NodeVisualKind.approval;
|
||
return NodeVisualKind.module;
|
||
}
|