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:
parent
b1fe765468
commit
a71f654162
6 changed files with 377 additions and 73 deletions
|
|
@ -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)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue