// 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. Changing /// any of these requires reviewing edge_painter.dart. class NodeGeometry { static const double width = 220; static const double headerHeight = 36; static const double portRowHeight = 22; static const double bottomPadding = 12; static const double portRadius = 6; // Offset from the card's outer edge for the port's center. // Half-out so the dot visually "attaches" to the side. static const double portInset = 0; /// Total height for a step node given its with-field count. /// Approval and inputs/outputs variants use the same maths /// so the canvas can place every node identically. static double heightFor(int portCount) { final base = headerHeight + bottomPadding; final ports = portCount * portRowHeight; return base + ports + 8; } /// Y offset of an input port's centre, measured from the /// top of the card. The first input port sits below the /// header with a small gap. static double inputPortY(int index) { return headerHeight + 6 + index * portRowHeight + portRowHeight / 2; } /// Y offset of the single output port. Vertically centred /// in the header band so the edge entry feels balanced. static double outputPortY() { return headerHeight / 2; } } enum NodeVisualKind { module, approval, inputs, outputs } class FlowNode extends StatelessWidget { final String id; final String title; final String? subtitle; final List inputPortLabels; final NodeVisualKind kind; final bool selected; final VoidCallback? onTap; final void Function(Offset delta)? onDrag; final VoidCallback? onDragEnd; /// 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.selected = false, this.status = FlowNodeStatus.idle, this.onTap, this.onDrag, this.onDragEnd, }); @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!(), 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( 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: 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 (status != FlowNodeStatus.idle) _statusDot(theme), ], ), ); } Widget _body(ThemeData theme) { return Padding( padding: const EdgeInsets.fromLTRB( FaiSpace.sm, 4, FaiSpace.sm, FaiSpace.sm, ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (subtitle != null) Padding( padding: const EdgeInsets.only(bottom: 4), child: Text( subtitle!, maxLines: 1, overflow: TextOverflow.ellipsis, style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, fontFamily: 'monospace', ), ), ), for (final label in inputPortLabels) SizedBox( height: NodeGeometry.portRowHeight, child: Row( children: [ Container( width: 6, height: 6, decoration: BoxDecoration( color: theme.colorScheme.onSurfaceVariant, shape: BoxShape.circle, ), ), const SizedBox(width: FaiSpace.xs), Flexible( child: Text( label, maxLines: 1, overflow: TextOverflow.ellipsis, style: theme.textTheme.labelSmall?.copyWith( color: theme.colorScheme.onSurface, ), ), ), ], ), ), ], ), ); } 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; }