feat(editor): graph-pulse on errors + store-aware install / add-source

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>
This commit is contained in:
flemming-it 2026-06-09 00:21:48 +02:00
parent f43c1ac6cf
commit efdfa7dd79
10 changed files with 497 additions and 45 deletions

View file

@ -29,8 +29,10 @@ export 'src/l10n.dart' show FlowEditorLocale;
export 'src/quick_fix.dart'
show
InstallCapabilityCallback,
AddModuleSourceCallback,
QuickFix,
InstallCapabilityFix,
AddModuleSourceFix,
ReplaceLineValueFix,
IssueHoverRequest,
IssueHoverSeverity;

View file

@ -17,6 +17,7 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_code_editor/flutter_code_editor.dart' show IssueType;
import 'flow_yaml_controller.dart';
import 'model/auto_layout.dart';
@ -88,6 +89,12 @@ class FlowEditorController extends ChangeNotifier {
codeController.addListener(_onCodeChanged);
}
/// Worst analyzer severity per step / pseudo-node, populated
/// during the last analyzer pass. The canvas reads this to
/// decide which step nodes pulse red / amber.
Map<String, IssueType> get stepIssueSeverity =>
codeController.stepSeverity;
@override
void dispose() {
_reparseTimer?.cancel();

View file

@ -23,12 +23,28 @@ import 'package:yaml/yaml.dart';
import 'quick_fix.dart';
import 'wire_colors.dart';
/// Reserved step-id sentinels the analyzer uses for issues that
/// live in the YAML's pseudo-nodes (`inputs:` and `outputs:`
/// blocks), so the graph canvas can highlight those nodes the
/// same way it highlights a real step.
const String kInputsNodeId = '__inputs__';
const String kOutputsNodeId = '__outputs__';
class FlowAnalyzer extends AbstractAnalyzer {
/// Returns the names of capabilities the operator has installed.
/// A closure so the analyzer always sees the current list
/// without needing a re-create on every Studio rebuild.
/// Returns the names of capabilities the operator has
/// installed. A closure so the analyzer always sees the
/// current list without needing a re-create on every Studio
/// rebuild.
final List<String> Function() availableCapabilities;
/// Returns the names of capabilities the public store knows
/// how to install. Used to decide whether an unknown-cap
/// issue should carry an Install button clicking the
/// button on a capability the hub can't actually fetch would
/// just fail. Null = "no store available" install offered
/// for every unknown cap (legacy behaviour).
final List<String> Function()? storeCapabilities;
/// Quick fixes attached to the most-recent analyze() pass.
/// Keyed by the same `Issue` instances that landed in
/// `analysisResult.issues`; consumers look up fixes per issue
@ -36,16 +52,46 @@ class FlowAnalyzer extends AbstractAnalyzer {
/// disambiguated.
final Map<Issue, List<QuickFix>> _fixesByIssue = {};
FlowAnalyzer({required this.availableCapabilities});
/// Worst-severity per step / pseudo-node from the most-recent
/// pass. The graph canvas reads this to decide which nodes
/// pulse red (error) / amber (warning). Pseudo-nodes for
/// `inputs:` and `outputs:` use [kInputsNodeId] /
/// [kOutputsNodeId] sentinels.
final Map<String, IssueType> _stepSeverity = {};
FlowAnalyzer({
required this.availableCapabilities,
this.storeCapabilities,
});
/// Read-only view of the fixes computed during the last
/// analyze(). Returns an empty list when no fix is known.
List<QuickFix> fixesFor(Issue issue) =>
List.unmodifiable(_fixesByIssue[issue] ?? const []);
/// Worst-severity (`error` beats `warning` beats `info`)
/// recorded per step / pseudo-node during the last analyze().
/// Caller treats absence as "no issue".
Map<String, IssueType> get stepSeverity =>
Map.unmodifiable(_stepSeverity);
void _bumpSeverity(String stepId, IssueType type) {
final current = _stepSeverity[stepId];
if (current == null || _severityRank(type) > _severityRank(current)) {
_stepSeverity[stepId] = type;
}
}
static int _severityRank(IssueType t) => switch (t) {
IssueType.error => 2,
IssueType.warning => 1,
IssueType.info => 0,
};
@override
Future<AnalysisResult> analyze(Code code) async {
_fixesByIssue.clear();
_stepSeverity.clear();
final issues = <Issue>[];
final text = code.text;
if (text.trim().isEmpty) {
@ -54,6 +100,13 @@ class FlowAnalyzer extends AbstractAnalyzer {
final caps = availableCapabilities();
final knownBareCaps = caps.map(_bareCap).toSet();
// Use both bare names AND fully-qualified `<bare>@<version>`
// as Did-you-mean candidates so the suggestion can preserve
// the version constraint when the user already typed one.
final installedFull = caps.toSet();
final storeCaps =
storeCapabilities?.call() ?? const <String>[];
final storeBare = storeCaps.map(_bareCap).toSet();
YamlNode? doc;
try {
@ -85,32 +138,132 @@ class FlowAnalyzer extends AbstractAnalyzer {
if (useValue is! String) continue;
if (knownBareCaps.isEmpty) continue;
if (!knownBareCaps.contains(_bareCap(useValue))) {
final issueLine = useNode.span.start.line;
final stepId = _stepIdFor(step);
final inStore = storeBare.contains(_bareCap(useValue));
// Did-you-mean candidate: scan installed + store bare
// caps for the closest spelling within edit-distance 2.
// Wins over the install button when present typo
// fixes are cheaper than network round-trips.
// Distance-0 (same word) is never a suggestion
// that case means the bare cap IS in the store
// (just not installed yet), so the Install button
// is the right call and the Did-you-mean would
// confusingly say "use the thing you typed".
final didYouMean = _closestCapability(
_bareCap(useValue),
{...knownBareCaps, ...storeBare}
.where((c) => c != _bareCap(useValue))
.toSet(),
);
// Tailor the message: "unknown" reads identical in
// both cases, but the recovery line nudges toward
// the right action.
final message = inStore
? 'Unknown capability "$useValue". '
'Install via the Fix button or check the spelling.'
: didYouMean != null
? 'Unknown capability "$useValue". '
'Did you mean "$didYouMean"?'
: 'Unknown capability "$useValue". '
'Not in the store — install locally with '
'`fai install --link <path>` or check the spelling.';
final issue = Issue(
line: useNode.span.start.line,
message:
'Unknown capability "$useValue". '
'Install the module that provides it, or check the spelling.',
line: issueLine,
message: message,
type: IssueType.error,
);
issues.add(issue);
_fixesByIssue[issue] = [
InstallCapabilityFix(
capability: useValue,
label: 'Install $useValue',
),
];
if (stepId != null) {
_bumpSeverity(stepId, IssueType.error);
}
final fixes = <QuickFix>[];
if (didYouMean != null) {
// Try to preserve the version constraint the user
// already typed.
final versionTail = _versionTail(useValue);
final fullReplacement = installedFull.firstWhere(
(c) => _bareCap(c) == didYouMean,
orElse: () => '$didYouMean$versionTail',
);
fixes.add(
ReplaceLineValueFix(
line: issueLine,
replacement: fullReplacement,
label: 'Use "$fullReplacement"',
),
);
}
if (inStore) {
fixes.add(
InstallCapabilityFix(
capability: useValue,
label: 'Install $useValue',
),
);
} else if (didYouMean == null) {
// Not in store + no near-miss spelling give the
// operator a path to register the module they
// actually have (local clone, internal URL, ).
// Hidden behind the "Did you mean" suggestion when
// present so the suggested fix stays the primary
// action.
fixes.add(
AddModuleSourceFix(
capability: useValue,
label: 'Add source for $useValue',
),
);
}
if (fixes.isNotEmpty) {
_fixesByIssue[issue] = fixes;
}
}
}
}
_checkFieldTypes(doc['inputs'], 'input', issues);
_checkFieldTypes(doc['outputs'], 'output', issues);
_checkFieldTypes(
doc['inputs'],
'input',
issues,
nodeId: kInputsNodeId,
);
_checkFieldTypes(
doc['outputs'],
'output',
issues,
nodeId: kOutputsNodeId,
);
}
return AnalysisResult(issues: issues);
}
void _checkFieldTypes(YamlNode? block, String kind, List<Issue> out) {
/// Resolve the step's `id` field as a string, or null when
/// the step has none (the analyzer doesn't generate
/// synthetic ids a step without an id is itself a
/// validation issue handled elsewhere).
String? _stepIdFor(YamlMap step) {
final id = step.nodes['id']?.value;
return id is String ? id : null;
}
/// Strip the leading `bareCap` so we keep just the
/// `@<version>` part. Caller stitches it back onto the
/// suggested replacement.
String _versionTail(String full) {
final at = full.indexOf('@');
return at < 0 ? '' : full.substring(at);
}
void _checkFieldTypes(
YamlNode? block,
String kind,
List<Issue> out, {
required String nodeId,
}) {
if (block is! YamlMap) return;
for (final entry in block.nodes.entries) {
final value = entry.value.value;
@ -131,6 +284,7 @@ class FlowAnalyzer extends AbstractAnalyzer {
type: IssueType.warning,
);
out.add(issue);
_bumpSeverity(nodeId, IssueType.warning);
// If the typo is within edit-distance two of a known
// type token, offer a one-click replace as the primary
// fix. The strip and tooltip render this as a button.
@ -149,6 +303,23 @@ class FlowAnalyzer extends AbstractAnalyzer {
}
}
/// Find the closest capability in [candidates] within
/// Levenshtein distance 2. Returns null when nothing is close
/// enough matches the same threshold the type-token nudge
/// uses.
String? _closestCapability(String input, Set<String> candidates) {
String? best;
int bestDistance = 3;
for (final c in candidates) {
final d = _levenshtein(input, c);
if (d < bestDistance) {
bestDistance = d;
best = c;
}
}
return best;
}
/// Find the closest type in [kKnownTypes] within Levenshtein
/// distance 2. Returns `null` when nothing is close enough
/// "totally made up word" should leave the operator picking

View file

@ -85,6 +85,21 @@ class FlowEditorPage extends StatefulWidget {
/// action (the diagnostic remains visible without it).
final InstallCapabilityCallback? onInstallCapability;
/// Host-side "register a new module source" handler. Invoked
/// when the operator clicks "Add source for <capability>…" on
/// an unknown-capability issue that isn't in the public store.
/// Same callback contract as [onInstallCapability]; the host
/// is expected to prompt the operator for a path / URL and
/// then call the Hub install API.
final AddModuleSourceCallback? onAddModuleSource;
/// Capabilities the public store knows how to install. The
/// analyzer uses this to decide whether to show "Install …"
/// (in store) or "Add source for …" (not in store) as the
/// quick-fix on an unknown `use:` line. Empty list = store
/// silent no install button offered.
final List<String> storeCapabilities;
const FlowEditorPage({
super.key,
this.initialFlowName,
@ -93,6 +108,8 @@ class FlowEditorPage extends StatefulWidget {
this.availableCapabilities = const [],
this.style,
this.onInstallCapability,
this.onAddModuleSource,
this.storeCapabilities = const [],
});
@override
@ -117,8 +134,9 @@ class _FlowEditorPageState extends State<FlowEditorPage>
super.initState();
_l = FlowEditorStrings(widget.locale);
_controller = FlowEditorController();
_controller.codeController.setAvailableCapabilities(
() => widget.availableCapabilities,
_controller.codeController.setCapabilityProviders(
available: () => widget.availableCapabilities,
store: () => widget.storeCapabilities,
);
_controller.addListener(_onCtrlChanged);
_controller.codeController.hoverRequest.addListener(_onHoverChanged);
@ -184,7 +202,22 @@ class _FlowEditorPageState extends State<FlowEditorPage>
if (handler == null) return;
final newCaps = await handler(capability);
if (newCaps != null && mounted) {
_controller.codeController.setAvailableCapabilities(() => newCaps);
_controller.codeController.setCapabilityProviders(
available: () => newCaps,
store: () => widget.storeCapabilities,
);
await _controller.codeController.reanalyze();
}
break;
case AddModuleSourceFix(:final capability):
final handler = widget.onAddModuleSource;
if (handler == null) return;
final newCaps = await handler(capability);
if (newCaps != null && mounted) {
_controller.codeController.setCapabilityProviders(
available: () => newCaps,
store: () => widget.storeCapabilities,
);
await _controller.codeController.reanalyze();
}
break;

View file

@ -27,15 +27,41 @@ import 'quick_fix.dart';
import 'wire_colors.dart';
class FlowYamlCodeController extends CodeController {
FlowYamlCodeController({List<String> Function()? availableCapabilities})
: super(
text: '',
language: yaml,
analyzer: FlowAnalyzer(
availableCapabilities:
availableCapabilities ?? (() => const <String>[]),
),
);
FlowYamlCodeController({
List<String> Function()? availableCapabilities,
List<String> Function()? storeCapabilities,
}) : super(
text: '',
language: yaml,
analyzer: FlowAnalyzer(
availableCapabilities:
availableCapabilities ?? (() => const <String>[]),
storeCapabilities: storeCapabilities,
),
);
/// Set both capability providers in one go. Re-creating the
/// analyzer is the simplest way to flip both closures without
/// risking a stale storeCapabilities reference inside an old
/// FlowAnalyzer instance.
void setCapabilityProviders({
required List<String> Function() available,
List<String> Function()? store,
}) {
analyzer = FlowAnalyzer(
availableCapabilities: available,
storeCapabilities: store,
);
}
/// Worst-severity per step / pseudo-node from the analyzer's
/// last pass. Graph canvas reads this to decide which step
/// nodes pulse red / amber. Returns an empty map when the
/// active analyzer isn't a [FlowAnalyzer].
Map<String, IssueType> get stepSeverity {
final a = analyzer;
return a is FlowAnalyzer ? a.stepSeverity : const {};
}
/// Published when the pointer enters a wavy-underlined range.
/// The editor host (FlowEditorPage) listens and renders an

View file

@ -50,7 +50,7 @@ class ReplaceLineValueFix extends QuickFix {
}
/// Ask the host (Studio) to install the named capability via
/// the Hub. The editor delegates to the
/// the Hub's store-backed install. The editor delegates to the
/// [InstallCapabilityCallback] passed into `FlowEditorPage`
/// and reanalyzes the document once the host returns.
@immutable
@ -66,6 +66,28 @@ class InstallCapabilityFix extends QuickFix {
}) : super(label);
}
/// Ask the host to register a new module source for an unknown
/// capability used when the capability isn't in the public
/// store. The host's handler typically prompts the operator
/// for a local path (`fai install --link`) or a URL
/// (`fai install <url>`), then installs and reanalyzes.
///
/// This is the recovery path for private modules: the public
/// store doesn't know about `htw.digiscout/onet-lookup`, but
/// the operator can point the hub at the local clone.
@immutable
class AddModuleSourceFix extends QuickFix {
/// The capability the operator wrote the host uses it to
/// pre-fill its prompt ("Where can `<capability>` be
/// installed from?").
final String capability;
const AddModuleSourceFix({
required this.capability,
required String label,
}) : super(label);
}
/// Host-side install handler signature. Returns the new
/// installed-capability list after the install completes (used
/// by the editor to refresh its analyzer without round-tripping
@ -75,6 +97,13 @@ class InstallCapabilityFix extends QuickFix {
typedef InstallCapabilityCallback =
Future<List<String>?> Function(String capability);
/// Host-side "add module source" handler. Receives the bare
/// capability spec the operator typed; expected to prompt the
/// operator for a local path / URL and then install it.
/// Same return contract as [InstallCapabilityCallback].
typedef AddModuleSourceCallback =
Future<List<String>?> Function(String capability);
/// Hover-tooltip request the controller emits one of these
/// via a ValueNotifier when the pointer enters an issue range,
/// and clears it when the pointer leaves both the range and the

View file

@ -33,6 +33,7 @@
// InteractiveViewer.
import 'package:flutter/material.dart';
import 'package:flutter_code_editor/flutter_code_editor.dart' show IssueType;
import '../editor_controller.dart';
import '../editor_style.dart';
@ -875,6 +876,13 @@ class _FlowCanvasState extends State<FlowCanvas>
outputLabels.length,
);
final cardWidth = _stepWidth(step);
// Analyzer severity for this step if its YAML carries an
// unknown-capability error or a typed-input warning, we
// tag the node so FlowNode renders a pulsing halo. The
// running pulse takes priority when both are active; the
// node never combines run-glow + error-halo on the same
// surface (would mush the colour).
final issueSeverity = _issueSeverityFor(step.id);
return Positioned(
left: pos.x,
top: pos.y,
@ -891,14 +899,12 @@ class _FlowCanvasState extends State<FlowCanvas>
selected: selected,
status: status,
elevated: _style.nodeShadows,
// Breathing-pulse on running steps when the active
// style allows flow animation. The canvas's existing
// _flowAnim ticks 0..1 during runs and is gated on
// the same accessibility / style preferences, so we
// can route it straight through.
pulse: _style.flowAnimation && status == FlowNodeStatus.running
? _flowAnim
: null,
issueSeverity: status == FlowNodeStatus.running
? null
: issueSeverity,
onTap: () => widget.controller.selectStep(step.id),
onDrag: (delta) => _applyDrag(step.id, pos, delta, cardHeight),
onContextMenu: (globalPos) => _showStepContextMenu(step, globalPos),
@ -930,10 +936,11 @@ class _FlowCanvasState extends State<FlowCanvas>
portSide: portSide,
inputPortLabels: labels,
selected: selected,
// Endpoints are selectable too selecting opens
// the inputs / outputs editor in the properties
// panel so the operator can rename, retype, or add
// entries graphically instead of editing YAML.
// Endpoints carry the same issue-halo treatment as
// step nodes the analyzer uses `__inputs__` /
// `__outputs__` sentinel ids so an `Unknown input type`
// warning lights up the matching endpoint card.
issueSeverity: _issueSeverityFor(nodeId),
onTap: () => widget.controller.selectStep(nodeId),
onDrag: (delta) => _applyDrag(
nodeId,
@ -945,6 +952,18 @@ class _FlowCanvasState extends State<FlowCanvas>
);
}
/// Map the controller's per-step issue severity (from the
/// YAML analyzer) onto the FlowNode's enum. Returns null
/// when the step has no issues so the node renders normally.
FlowIssueSeverity? _issueSeverityFor(String stepId) {
final raw = widget.controller.stepIssueSeverity[stepId];
return switch (raw) {
IssueType.error => FlowIssueSeverity.error,
IssueType.warning => FlowIssueSeverity.warning,
_ => null,
};
}
/// Single drag entry point used by every node on the
/// canvas. Converts a screen-space delta to canvas-space
/// (via the current InteractiveViewer scale), clamps the

View file

@ -154,6 +154,12 @@ enum NodeVisualKind { module, approval, inputs, outputs }
/// 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;
@ -224,6 +230,14 @@ class FlowNode extends StatelessWidget {
/// 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,
@ -244,6 +258,7 @@ class FlowNode extends StatelessWidget {
this.onContextMenu,
this.elevated = true,
this.pulse,
this.issueSeverity,
this.width = NodeGeometry.width,
});
@ -268,10 +283,6 @@ class FlowNode extends StatelessWidget {
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;
@ -294,6 +305,19 @@ class FlowNode extends StatelessWidget {
),
);
}
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);
}
@ -592,3 +616,80 @@ 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,
);
}
}