// 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 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 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: Material( color: theme.colorScheme.surfaceContainerHigh, elevation: selected ? 6 : 2, shadowColor: selected ? accent.withValues(alpha: 0.35) : null, borderRadius: BorderRadius.circular(FaiRadius.md), child: Container( decoration: BoxDecoration( borderRadius: BorderRadius.circular(FaiRadius.md), border: Border.all( color: selected ? accent : theme.dividerColor, width: selected ? 2 : 1, ), ), 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; }