Three connected improvements to the analyzer-driven diagnostics:
- **Pulsing halo on broken graph nodes**. Every step / pseudo-
node (inputs / outputs) carrying an analyzer issue now
breathes a red (error) or amber (warning) halo on the graph
tab — operator sees the problem on the canvas without
flipping to text. Honours prefers-reduced-motion: reduce-
motion users get a static halo at the same intensity. The
canvas reads severity via a new `stepIssueSeverity` map on
FlowEditorController; pseudo-nodes use `__inputs__` /
`__outputs__` sentinel ids.
- **Store-aware install button**. The analyzer now takes a
second closure, `storeCapabilities`, listing what the public
store can install. Unknown-capability issues only carry the
"Install …" quick-fix when the bare cap is in that list;
otherwise the issue carries an "Add source for …" fix
instead. Resolves the asymmetry the operator reported: the
Store didn't show `htw.digiscout/onet.lookup` but the editor
happily offered to install it (and would have failed). The
install path no longer lies about itself.
- **Did-you-mean suggestion**. When the unknown cap is within
edit-distance two of an installed or store cap (different
spelling — distance-0 stays hidden because that's an install
case, not a typo), the analyzer emits a `ReplaceLineValueFix`
suggesting the closest match. Preserves the version
constraint by reusing the installed spec when present
(e.g. `text.echi@^0.1` → `text.echo@^0.1`).
New public surface:
- `AddModuleSourceFix` + `AddModuleSourceCallback`
- `FlowEditorPage.storeCapabilities` + `onAddModuleSource`
- `FlowAnalyzer.stepSeverity` + the `kInputsNodeId` /
`kOutputsNodeId` sentinels
- `FlowIssueSeverity` enum + `FlowNode.issueSeverity`
Tests:
- Install fix only when in store
- AddModuleSourceFix as fallback when not in store
- Did-you-mean replaces install when a near miss exists
- stepSeverity populated for both step ids and pseudo-nodes
All 36 editor tests green. Bumped to 0.18.0.
Signed-off-by: flemming-it <sf@flemming.it>
695 lines
24 KiB
Dart
695 lines
24 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 'dart:math' as math;
|
||
|
||
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 {
|
||
/// Minimum card width — used when labels are short. Cards
|
||
/// grow beyond this via [widthFor] when their longest port
|
||
/// label wouldn't fit.
|
||
static const double width = 220;
|
||
|
||
/// Approximate render width per character at the
|
||
/// `labelSmall` text style used in port labels. Not exact
|
||
/// — we don't lay out text per build — but close enough
|
||
/// to ensure long labels like `model_endpoint` or
|
||
/// `source_language` aren't ellipsis-clipped on the
|
||
/// stock width.
|
||
static const double _charWidth = 7.5;
|
||
|
||
/// Compute a card width that fits the longest input AND
|
||
/// output label without truncation. The two-column body
|
||
/// layout reserves a left gutter (input port dot), a
|
||
/// middle gap, and a right gutter (output port dot). We
|
||
/// add a small slack so labels don't kiss the column
|
||
/// boundary.
|
||
static double widthFor({
|
||
required int maxInputChars,
|
||
required int maxOutputChars,
|
||
}) {
|
||
const minMiddleGap = 12.0;
|
||
const slack = 6.0;
|
||
final inputW = maxInputChars * _charWidth;
|
||
final outputW = maxOutputChars * _charWidth;
|
||
final needed =
|
||
portGutter + inputW + minMiddleGap + outputW + portGutter + slack;
|
||
return needed > width ? needed : width;
|
||
}
|
||
|
||
// 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 so the
|
||
/// connected-vs-outlined state reads clearly at any zoom:
|
||
/// a 12 px outline tends to disappear into the background
|
||
/// at 50 % zoom; 14 px keeps the ring visible and gives
|
||
/// the filled (connected) variant enough mass to stand out
|
||
/// against the card shadow.
|
||
static const double portDotSize = 14;
|
||
|
||
/// Total card height for a node carrying [inputCount] inputs
|
||
/// on the left + [outputCount] outputs on the right. The
|
||
/// taller side wins; the card always reserves at least one
|
||
/// row of body so the header doesn't sit naked. Adds a
|
||
/// 2 px allowance for the 1 px border on the top + bottom
|
||
/// of the AnimatedContainer wrapping the card, otherwise
|
||
/// the inner Column gets shorted by the border and the
|
||
/// last port row overflows.
|
||
static double heightFor(int inputCount, [int outputCount = 0]) {
|
||
final rows = inputCount > outputCount ? inputCount : outputCount;
|
||
return headerHeight + bodyTopPad + rows * portRowHeight + bodyBottomPad + 2;
|
||
}
|
||
|
||
/// 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 output port at row [index] on the right
|
||
/// side of the card. Same row geometry as inputs so the
|
||
/// rows visually align.
|
||
///
|
||
/// When the node has NO declared outputs the canvas falls
|
||
/// back to a single anchor centred in the header (the
|
||
/// legacy "one output per step" behaviour) — call
|
||
/// [outputAnchorY] for that mode.
|
||
static double outputPortY(int index) {
|
||
return headerHeight +
|
||
bodyTopPad +
|
||
index * portRowHeight +
|
||
portRowHeight / 2;
|
||
}
|
||
|
||
/// Y offset of the legacy single output port — vertically
|
||
/// centred in the header. Used for nodes whose ModuleSpec
|
||
/// the host hasn't resolved yet, so the editor can still
|
||
/// draw edges before the per-field info arrives.
|
||
static double outputAnchorY() {
|
||
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 }
|
||
|
||
/// Severity tag passed to [FlowNode.issueSeverity]. Mirrors
|
||
/// flutter_code_editor's `IssueType` but lives in the editor
|
||
/// package so flow_node.dart doesn't pull the code-editor
|
||
/// transitively into widget tests that don't need it.
|
||
enum FlowIssueSeverity { error, warning }
|
||
|
||
class FlowNode extends StatelessWidget {
|
||
final String id;
|
||
final String title;
|
||
final String? subtitle;
|
||
final List<String> inputPortLabels;
|
||
|
||
/// Output port labels rendered on the RIGHT side of the
|
||
/// card, one per declared output field. Empty for endpoint
|
||
/// nodes (inputs / outputs sidebars) and for step nodes
|
||
/// whose ModuleSpec is still loading — in that case the
|
||
/// canvas draws a single legacy output anchor at the
|
||
/// header midpoint.
|
||
final List<String> outputPortLabels;
|
||
|
||
/// Optional tooltips per port label, keyed by label. Shown
|
||
/// when the operator hovers the label. Used to surface
|
||
/// each field's bilingual `description.en/de` from
|
||
/// schema_version 3 manifests.
|
||
final Map<String, String> portTooltips;
|
||
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;
|
||
|
||
/// Whether to paint the layered drop shadows under the
|
||
/// node. Off = flat-design feel; on = depth. Driven by
|
||
/// the active editor style.
|
||
final bool elevated;
|
||
|
||
/// Explicit card width. Defaults to [NodeGeometry.width].
|
||
/// Callers pass a larger value computed from the longest
|
||
/// port label so labels like `model_endpoint` or
|
||
/// `source_language` don't ellipsis-clip.
|
||
final double width;
|
||
|
||
/// 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;
|
||
|
||
/// 0..1 oscillating value driving the "breathing pulse"
|
||
/// glow on running steps. The canvas hooks this to its
|
||
/// flow-animation controller, so it's already gated on the
|
||
/// active style's `flowAnimation` setting + on whether any
|
||
/// step is actually running. When null (or status != running),
|
||
/// no pulse is rendered.
|
||
final Listenable? pulse;
|
||
|
||
/// When non-null, the node renders a permanently pulsing
|
||
/// halo in the appropriate severity colour — red for an
|
||
/// analyzer-flagged error, amber for a warning — so the
|
||
/// operator scanning the canvas immediately sees which step
|
||
/// the YAML linter is unhappy about, without switching to
|
||
/// the text tab.
|
||
final FlowIssueSeverity? issueSeverity;
|
||
|
||
const FlowNode({
|
||
super.key,
|
||
required this.id,
|
||
required this.title,
|
||
this.subtitle,
|
||
this.inputPortLabels = const [],
|
||
this.outputPortLabels = const [],
|
||
this.portTooltips = 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,
|
||
this.elevated = true,
|
||
this.pulse,
|
||
this.issueSeverity,
|
||
this.width = NodeGeometry.width,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final theme = Theme.of(context);
|
||
final accent = _accent(theme.colorScheme);
|
||
final height = NodeGeometry.heightFor(
|
||
inputPortLabels.length,
|
||
outputPortLabels.length,
|
||
);
|
||
final cardWidget = _card(theme, accent, height);
|
||
// Breathing-pulse glow on running steps. The pulse
|
||
// listenable is the canvas's own flow-animation
|
||
// controller (0..1), so a single host-side tick drives
|
||
// every running step's halo in sync. Skipped when no
|
||
// animation source is wired or the step isn't running.
|
||
if (pulse != null && status == FlowNodeStatus.running) {
|
||
return SizedBox(
|
||
width: width,
|
||
height: height,
|
||
child: AnimatedBuilder(
|
||
animation: pulse!,
|
||
builder: (context, child) {
|
||
final t = (pulse! is Animation<double>)
|
||
? (pulse! as Animation<double>).value
|
||
: 0.0;
|
||
final breath = 0.5 + 0.5 * math.sin(t * 2 * math.pi);
|
||
return DecoratedBox(
|
||
decoration: BoxDecoration(
|
||
borderRadius: BorderRadius.circular(FaiRadius.md),
|
||
boxShadow: [
|
||
BoxShadow(
|
||
color: accent.withValues(alpha: 0.18 + 0.22 * breath),
|
||
blurRadius: 20 + 8 * breath,
|
||
spreadRadius: 0.5 + 1.5 * breath,
|
||
),
|
||
],
|
||
),
|
||
child: child,
|
||
);
|
||
},
|
||
child: cardWidget,
|
||
),
|
||
);
|
||
}
|
||
if (issueSeverity != null) {
|
||
// Always-on breathing halo in the severity colour. Owns
|
||
// its own AnimationController so it ticks even when no
|
||
// step is running (no shared pulse listenable upstream).
|
||
return SizedBox(
|
||
width: width,
|
||
height: height,
|
||
child: _IssueHalo(
|
||
severity: issueSeverity!,
|
||
child: cardWidget,
|
||
),
|
||
);
|
||
}
|
||
return SizedBox(width: width, height: height, child: cardWidget);
|
||
}
|
||
|
||
Widget _card(ThemeData theme, Color accent, double height) {
|
||
return 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.
|
||
// When the active style turns elevation off, we
|
||
// drop the shadows entirely for a flat-design look.
|
||
boxShadow: elevated
|
||
? [
|
||
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,
|
||
),
|
||
]
|
||
: null,
|
||
),
|
||
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 && outputPortLabels.isEmpty) {
|
||
return const SizedBox.shrink();
|
||
}
|
||
// Outer padding matches the side(s) that actually carry
|
||
// ports — the gutter exists so the canvas-side dot has
|
||
// somewhere to land without overlapping the label text.
|
||
final left = inputPortLabels.isNotEmpty || portSide == NodePortSide.left
|
||
? NodeGeometry.portGutter
|
||
: FaiSpace.sm;
|
||
final right = outputPortLabels.isNotEmpty || portSide == NodePortSide.right
|
||
? NodeGeometry.portGutter
|
||
: FaiSpace.sm;
|
||
return Padding(
|
||
padding: EdgeInsets.fromLTRB(
|
||
left,
|
||
NodeGeometry.bodyTopPad,
|
||
right,
|
||
NodeGeometry.bodyBottomPad,
|
||
),
|
||
child: Row(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
if (inputPortLabels.isNotEmpty)
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
for (final label in inputPortLabels)
|
||
_portLabel(theme, label, TextAlign.left),
|
||
],
|
||
),
|
||
),
|
||
if (outputPortLabels.isNotEmpty)
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.end,
|
||
children: [
|
||
for (final label in outputPortLabels)
|
||
_portLabel(theme, label, TextAlign.right),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _portLabel(ThemeData theme, String label, TextAlign align) {
|
||
final muted = optionalLabels.contains(label);
|
||
final widget = SizedBox(
|
||
height: NodeGeometry.portRowHeight,
|
||
child: Align(
|
||
alignment: align == TextAlign.right
|
||
? Alignment.centerRight
|
||
: Alignment.centerLeft,
|
||
child: Text(
|
||
label,
|
||
textAlign: align,
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: theme.textTheme.labelSmall?.copyWith(
|
||
color: muted
|
||
? theme.colorScheme.onSurface.withValues(alpha: 0.55)
|
||
: theme.colorScheme.onSurface,
|
||
fontStyle: muted ? FontStyle.italic : FontStyle.normal,
|
||
),
|
||
),
|
||
),
|
||
);
|
||
final tip = portTooltips[label];
|
||
if (tip == null || tip.isEmpty) return widget;
|
||
return Tooltip(
|
||
message: tip,
|
||
waitDuration: const Duration(milliseconds: 350),
|
||
child: widget,
|
||
);
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
/// Permanently breathing halo painted around a node carrying
|
||
/// an analyzer issue. Owns its own AnimationController so it
|
||
/// ticks regardless of the canvas's run-state. Halo colour
|
||
/// matches severity — error red or warning amber. Honours
|
||
/// `MediaQuery.disableAnimations` so reduce-motion operators
|
||
/// still get a static halo without the breathing oscillation.
|
||
class _IssueHalo extends StatefulWidget {
|
||
final FlowIssueSeverity severity;
|
||
final Widget child;
|
||
const _IssueHalo({required this.severity, required this.child});
|
||
|
||
@override
|
||
State<_IssueHalo> createState() => _IssueHaloState();
|
||
}
|
||
|
||
class _IssueHaloState extends State<_IssueHalo>
|
||
with SingleTickerProviderStateMixin {
|
||
late final AnimationController _controller = AnimationController(
|
||
vsync: this,
|
||
duration: const Duration(milliseconds: 1600),
|
||
)..repeat();
|
||
|
||
@override
|
||
void dispose() {
|
||
_controller.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
Color _tone(BuildContext context) {
|
||
final theme = Theme.of(context);
|
||
return widget.severity == FlowIssueSeverity.error
|
||
? theme.colorScheme.error
|
||
: const Color(0xFFEF6C00);
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final tone = _tone(context);
|
||
final reduceMotion = MediaQuery.disableAnimationsOf(context);
|
||
if (reduceMotion) {
|
||
return DecoratedBox(
|
||
decoration: BoxDecoration(
|
||
borderRadius: BorderRadius.circular(FaiRadius.md),
|
||
boxShadow: [
|
||
BoxShadow(
|
||
color: tone.withValues(alpha: 0.35),
|
||
blurRadius: 18,
|
||
spreadRadius: 1.5,
|
||
),
|
||
],
|
||
),
|
||
child: widget.child,
|
||
);
|
||
}
|
||
return AnimatedBuilder(
|
||
animation: _controller,
|
||
builder: (context, child) {
|
||
final breath = 0.5 + 0.5 * math.sin(_controller.value * 2 * math.pi);
|
||
return DecoratedBox(
|
||
decoration: BoxDecoration(
|
||
borderRadius: BorderRadius.circular(FaiRadius.md),
|
||
boxShadow: [
|
||
BoxShadow(
|
||
color: tone.withValues(alpha: 0.22 + 0.30 * breath),
|
||
blurRadius: 16 + 8 * breath,
|
||
spreadRadius: 0.5 + 2.0 * breath,
|
||
),
|
||
],
|
||
),
|
||
child: child,
|
||
);
|
||
},
|
||
child: widget.child,
|
||
);
|
||
}
|
||
}
|