feat(editor): reduce-motion clamp + edge gradient + breathing pulse + style sheet

Four polish features in one push.

1. Reduce-motion clamp. FaiEditorStyle.clampedForA11y(
   disableAnimations: ...) returns a style with glass, gradient,
   flow animation, and node shadows forced off when the OS
   reduce-motion preference is set. FlowCanvas + FlowEditorPage
   call it on every build with
   MediaQuery.disableAnimationsOf(context) so operators on
   accessibility settings get a flat, static editor without an
   explicit Studio setting.

2. Edge gradient source→target. EdgePainter now accepts an
   optional colorEnd per segment and paints a linear shader
   along the bezier when both ends carry distinct type accents.
   In practice this draws attention to type mismatches: a
   text→json wire reads as a colour transition; a same-type
   wire stays a solid colour. ModuleSpec lookup at both edge
   endpoints feeds the colour pair.

3. Breathing pulse on running steps. FlowNode accepts a
   Listenable pulse; when status=running AND the canvas's
   _flowAnim is ticking, the node wraps in an AnimatedBuilder
   that drives a sine-wave shadow halo (alpha + blur + spread
   oscillating). Stops when the run completes. Gated by
   _style.flowAnimation so reduce-motion / minimal-style modes
   stay still.

4. In-editor style sheet. Bottom-right canvas chrome gains a
   ⚙ Style button that opens a modal bottom sheet with four
   SwitchListTiles (glass, gradient backdrop, flow animation,
   node shadows) plus 'Reset to default'. Edits live in
   _styleOverride on the canvas state — operator can tweak the
   look for the session without restarting or editing code.
   No persistence in this version; follow-up could pipe the
   override through SharedPreferences via the host.

Bumps fai_studio_flow_editor to 0.10.0.

Signed-off-by: flemming-it <sf@flemming.it>
This commit is contained in:
flemming-it 2026-06-01 22:55:18 +02:00
parent b1fe765468
commit a71f654162
6 changed files with 377 additions and 73 deletions

View file

@ -13,6 +13,8 @@
// 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';
@ -170,6 +172,14 @@ class FlowNode extends StatelessWidget {
/// 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;
const FlowNode({
super.key,
required this.id,
@ -189,6 +199,7 @@ class FlowNode extends StatelessWidget {
this.onDragEnd,
this.onContextMenu,
this.elevated = true,
this.pulse,
});
@override
@ -199,66 +210,109 @@ class FlowNode extends StatelessWidget {
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: NodeGeometry.width,
height: height,
child: AnimatedBuilder(
animation: pulse!,
builder: (context, child) {
// Derive a 0..1 breath value from the controller.
// Use a sine wave so the glow eases in + out
// rather than ramping linearly reads as
// "breathing" instead of "flickering".
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,
),
);
}
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.
// 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: 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,
),
child: ClipRRect(
borderRadius: BorderRadius.circular(FaiRadius.md),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_header(theme, accent),
Expanded(child: _body(theme)),
],
),
// 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)),
],
),
),
),