Skip to content

Commit 778f86f

Browse files
committed
Computed variables, motion loops and entrances, formula-safe text split
- ComputedVariables keeps formula variables up to date from their inputs; Set variable leaves them alone. - NodeMotion loops, animates in on first show and honours delays. - Design canvas splits text by formulas before variables. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013oMokUGNW6txknJs7xxTik
1 parent e2f91df commit 778f86f

13 files changed

Lines changed: 907 additions & 8 deletions

‎lib/codelessly_sdk.dart‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ export 'src/ui/feedback_theme.dart';
4040
export 'src/ui/node_motion.dart';
4141
export 'src/ui/swipe_detector.dart';
4242
export 'src/ui/stripe_painter.dart';
43+
export 'src/utils/computed_variables.dart';
4344
export 'src/utils/cupertino_icons_by_codepoint.dart';
4445
export 'src/utils/extensions.dart';
4546
export 'src/utils/flutter_icons_data_map.dart';

‎lib/src/functions/functions_repository.dart‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1083,6 +1083,16 @@ ${response.body.contains('{') ? const JsonEncoder.withIndent(' ').convert(json.
10831083
name: action.variable.name,
10841084
);
10851085
if (variableNotifier == null) return false;
1086+
// A computed variable's value comes from its formula: setting it does
1087+
// nothing.
1088+
if (variableNotifier.value.isComputed) {
1089+
DebugLogger.instance.printInfo(
1090+
'Variable ${variableNotifier.value.name} is computed from a formula '
1091+
'and cannot be set.',
1092+
name: name,
1093+
);
1094+
return false;
1095+
}
10861096

10871097
String newValue = substituteNewValue(
10881098
action.newValue,
@@ -1146,6 +1156,7 @@ ${response.body.contains('{') ? const JsonEncoder.withIndent(' ').convert(json.
11461156

11471157
final ValueNotifier<VariableData>? variableNotifier = payload.variables[id];
11481158
if (variableNotifier == null) return false;
1159+
if (variableNotifier.value.isComputed) return false;
11491160

11501161
variableNotifier.value = variableNotifier.value.copyWith(value: value);
11511162

@@ -1175,6 +1186,7 @@ ${response.body.contains('{') ? const JsonEncoder.withIndent(' ').convert(json.
11751186
.findVariableByName(match.name);
11761187

11771188
if (propertyNotifier == null) return false;
1189+
if (propertyNotifier.value.isComputed) return false;
11781190

11791191
if (match.name != match.fullPath &&
11801192
(propertyNotifier.value.type == VariableType.map ||

‎lib/src/transformers/node_transformers/passive_button_transformer.dart‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,7 @@ class PassiveButtonWidget extends StatelessWidget {
130130
if (settings.replaceVariablesWithSymbols) {
131131
final List<TextSpan> spans = buttonLabelTemplateOf(node)
132132
.splitMap(
133-
variableSyntaxIdentifierRegex,
133+
formulaOrVariableRegex,
134134
onMatch: (match) =>
135135
VariableSpan(variable: match[0]!, node: node, context: context),
136136
onNonMatch: (text) => TextSpan(text: text),

‎lib/src/transformers/utils/text_utils.dart‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ class TextUtils {
9999
if (replaceVariableWithSymbol) {
100100
final spans = characters
101101
.splitMap(
102-
variableSyntaxIdentifierRegex,
102+
formulaOrVariableRegex,
103103
onMatch: (match) => VariableSpan(
104104
variable: match[0]!,
105105
node: node,

‎lib/src/ui/codelessly_context.dart‎

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import 'package:codelessly_api/codelessly_api.dart';
44
import 'package:collection/collection.dart';
55
import 'package:equatable/equatable.dart';
66
import 'package:flutter/material.dart';
7+
import 'package:flutter/scheduler.dart';
78

89
import '../../codelessly_sdk.dart';
910

@@ -85,10 +86,112 @@ class CodelesslyContext with ChangeNotifier, Equatable {
8586

8687
@override
8788
void dispose() {
89+
unbindComputedVariables();
8890
nodeRebuildNotifier.dispose();
8991
super.dispose();
9092
}
9193

94+
/// Listeners that keep computed variables up to date, by the notifier
95+
/// they listen to. See [bindComputedVariables].
96+
final List<(Listenable, VoidCallback)> _computedListeners = [];
97+
98+
/// The computed variables being evaluated, by id, to stop a cycle from
99+
/// evaluating forever.
100+
final Set<String> _computing = {};
101+
102+
/// Computes the value of every computed variable (a variable with a
103+
/// [VariableData.formula]) in [variables] and keeps it up to date: each is
104+
/// computed again when a variable its formula reads changes, or when
105+
/// [data] changes. Their values are cached in their notifiers, so bindings
106+
/// read and listen to them like any other variable.
107+
///
108+
/// Computed variables in a cycle hold an empty value. Call this again when
109+
/// [variables] changes; the previous listeners are removed.
110+
/// See [ComputedVariables].
111+
void bindComputedVariables() {
112+
unbindComputedVariables();
113+
final Map<String, VariableData> byName = variableNamesMap();
114+
if (!byName.values.any((variable) => variable.isComputed)) return;
115+
final Set<String> cyclic = ComputedVariables.cyclicNames(byName);
116+
// The variables as declared: a result can change a computed variable's
117+
// type, but each evaluation starts from the declared one.
118+
final Map<String, VariableData> declared = {
119+
for (final VariableData variable in byName.values)
120+
if (variable.isComputed) variable.name: variable,
121+
};
122+
123+
final List<VoidCallback> recomputeAll = [];
124+
final List<Observable<VariableData>> ordered = [
125+
for (final String name in ComputedVariables.evaluationOrder(byName))
126+
?findVariableByName(name),
127+
];
128+
for (final Observable<VariableData> notifier in ordered) {
129+
final VariableData variable = declared[notifier.value.name]!;
130+
if (cyclic.contains(variable.name)) {
131+
notifier.value = ComputedVariables.withResult(variable, null);
132+
continue;
133+
}
134+
void recompute() => _recompute(notifier, variable);
135+
recompute();
136+
recomputeAll.add(recompute);
137+
// Listeners are added in evaluation order, so of two computed
138+
// variables reading the same input, the one the other reads updates
139+
// first.
140+
for (final String input in ComputedVariables.inputsOf(variable)) {
141+
final Observable<VariableData>? source = findVariableByName(input);
142+
if (source == null || identical(source, notifier)) continue;
143+
source.addListener(recompute);
144+
_computedListeners.add((source, recompute));
145+
}
146+
}
147+
// Formulas can read `data`, which is set while widgets build: compute
148+
// again after the frame, as setting a variable rebuilds its readers.
149+
void onDataChanged() {
150+
void run() {
151+
for (final VoidCallback recompute in recomputeAll) {
152+
recompute();
153+
}
154+
}
155+
156+
if (SchedulerBinding.instance.schedulerPhase ==
157+
SchedulerPhase.persistentCallbacks) {
158+
SchedulerBinding.instance.addPostFrameCallback((_) => run());
159+
} else {
160+
run();
161+
}
162+
}
163+
164+
addListener(onDataChanged);
165+
_computedListeners.add((this, onDataChanged));
166+
}
167+
168+
/// Removes the listeners [bindComputedVariables] added.
169+
void unbindComputedVariables() {
170+
for (final (Listenable listenable, VoidCallback listener)
171+
in _computedListeners) {
172+
listenable.removeListener(listener);
173+
}
174+
_computedListeners.clear();
175+
}
176+
177+
void _recompute(Observable<VariableData> notifier, VariableData declared) {
178+
if (!_computing.add(declared.id)) return;
179+
try {
180+
final Object? result = ComputedVariables.evaluate(
181+
declared,
182+
scopedValues: ScopedValues.preview(
183+
variables: const {},
184+
codelesslyContext: this,
185+
),
186+
);
187+
notifier.value = ComputedVariables.withResult(declared, result);
188+
} catch (error) {
189+
log('[CodelesslyContext] Computed variable ${declared.name}: $error');
190+
} finally {
191+
_computing.remove(declared.id);
192+
}
193+
}
194+
92195
/// Returns a map of all of the [VariableData]s in [variables] mapped by their
93196
/// name.
94197
Map<String, VariableData> variableNamesMap() =>
@@ -218,12 +321,18 @@ class CodelesslyContext with ChangeNotifier, Equatable {
218321
/// Allows to easily [value] of a variable with a given [name].
219322
/// Returns false if the variable does not exist.
220323
/// Returns true if the variable was updated successfully.
324+
/// A computed variable (see [VariableData.formula]) can't be set: this
325+
/// returns false and leaves it unchanged.
221326
bool updateVariable(String name, Object? value) {
222327
final ValueNotifier<VariableData>? variable = findVariableByName(name);
223328
if (variable == null) {
224329
log('[CodelesslyContext] Variable with name $name does not exist.');
225330
return false;
226331
}
332+
if (variable.value.isComputed) {
333+
log('[CodelesslyContext] Variable $name is computed and read-only.');
334+
return false;
335+
}
227336
final String newValue = value == null ? '' : '$value';
228337

229338
// If the value is the same, then the underlying value notifier will not

‎lib/src/ui/codelessly_widget.dart‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -394,6 +394,8 @@ class _CodelesslyWidgetState extends State<CodelesslyWidget> {
394394
SchedulerBinding.instance.addPostFrameCallback((timeStamp) {
395395
if (!mounted) return;
396396
for (final notifier in codelesslyContext.variables.values) {
397+
// Computed variables compute their value from the new data.
398+
if (notifier.value.isComputed) continue;
397399
notifier.value = notifier.value.copyWith(
398400
value: widget.data[notifier.value.name],
399401
);

‎lib/src/ui/layout_builder.dart‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,9 @@ class _CodelesslyLayoutBuilderState extends State<CodelesslyLayoutBuilder> {
289289
loadApisAndItsVariables(context);
290290

291291
setCloudStorageVariables(context);
292+
293+
// Computed variables read the variables loaded above.
294+
codelesslyContext.bindComputedVariables();
292295
}
293296

294297
/// This loads all the apis for the layout as variables to be available

‎lib/src/ui/node_motion.dart‎

Lines changed: 77 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import 'dart:async';
12
import 'dart:math' as math;
23

34
import 'package:codelessly_api/codelessly_api.dart';
@@ -72,31 +73,97 @@ class _NodeMotionState extends State<NodeMotion> with TickerProviderStateMixin {
7273
double _speed = 0;
7374
double _turns = 0;
7475

76+
/// Holds a transition back for [MotionModel.delayMs]; until it fires the
77+
/// node stays where the transition starts.
78+
Timer? _delay;
79+
7580
void _rebuild() => setState(() {});
7681

82+
bool get _loops =>
83+
widget.animate &&
84+
widget.motion.loop != MotionLoop.none &&
85+
widget.motion.durationMs > 0;
86+
7787
@override
7888
void initState() {
7989
super.initState();
90+
final MotionModel motion = widget.motion;
91+
if (_loops) {
92+
_startLoop();
93+
} else if (widget.animate && motion.enter && motion.durationMs > 0) {
94+
_from = motion.atRest;
95+
_run(motion.durationMs);
96+
}
8097
_syncSpin();
8198
}
8299

100+
/// Animates from [_from] to [_to] over [ms], after the motion's delay.
101+
void _run(int ms) {
102+
_delay?.cancel();
103+
_transition
104+
..stop()
105+
..duration = Duration(milliseconds: ms)
106+
..value = 0;
107+
void start() {
108+
if (mounted) _transition.forward(from: 0);
109+
}
110+
111+
final int delay = widget.motion.delayMs;
112+
if (delay > 0) {
113+
_delay = Timer(Duration(milliseconds: delay), start);
114+
} else {
115+
start();
116+
}
117+
}
118+
119+
/// Repeats between rest and the motion's values.
120+
void _startLoop() {
121+
_delay?.cancel();
122+
_from = widget.motion.atRest;
123+
_to = widget.motion;
124+
_transition.duration = Duration(milliseconds: widget.motion.durationMs);
125+
void start() {
126+
if (!mounted) return;
127+
_transition.repeat(reverse: widget.motion.loop == MotionLoop.reverse);
128+
}
129+
130+
final int delay = widget.motion.delayMs;
131+
if (delay > 0) {
132+
_transition.value = 0;
133+
_delay = Timer(Duration(milliseconds: delay), start);
134+
} else {
135+
start();
136+
}
137+
}
138+
83139
@override
84140
void didUpdateWidget(NodeMotion oldWidget) {
85141
super.didUpdateWidget(oldWidget);
86142
final MotionModel next = widget.motion;
87-
if (!_sameTransform(next, _to)) {
143+
if (_loops) {
144+
final MotionModel old = oldWidget.motion;
145+
if (!_sameTransform(next, _to) ||
146+
next.loop != old.loop ||
147+
next.durationMs != old.durationMs ||
148+
!_transition.isAnimating && _delay?.isActive != true) {
149+
_startLoop();
150+
} else {
151+
_to = next;
152+
}
153+
} else if (!_sameTransform(next, _to) ||
154+
oldWidget.motion.loop != MotionLoop.none) {
88155
final ms = widget.animate ? next.durationMs : 0;
89156
if (ms <= 0) {
157+
_delay?.cancel();
90158
_transition.stop();
159+
_transition.value = 0;
91160
_from = next;
92161
_to = next;
93162
} else {
94163
// Start from wherever the running transition has got to.
95164
_from = _current;
96165
_to = next;
97-
_transition
98-
..duration = Duration(milliseconds: ms)
99-
..forward(from: 0);
166+
_run(ms);
100167
}
101168
} else {
102169
// Settings that don't animate (duration, curve, spin) apply at once.
@@ -118,8 +185,11 @@ class _NodeMotionState extends State<NodeMotion> with TickerProviderStateMixin {
118185
a.originX == b.originX &&
119186
a.originY == b.originY;
120187

188+
/// Whether a transition is running or waiting out its delay.
189+
bool get _moving => _transition.isAnimating || _delay?.isActive == true;
190+
121191
MotionModel get _current {
122-
if (!_transition.isAnimating && _transition.value == 0) return _to;
192+
if (!_moving && _transition.value == 0) return _to;
123193
final double t = flutterCurveOf(_to.curve).transform(_transition.value);
124194
double lerp(String p) =>
125195
_from.valueOf(p) + (_to.valueOf(p) - _from.valueOf(p)) * t;
@@ -171,14 +241,15 @@ class _NodeMotionState extends State<NodeMotion> with TickerProviderStateMixin {
171241

172242
@override
173243
void dispose() {
244+
_delay?.cancel();
174245
_transition.dispose();
175246
_spinTicker?.dispose();
176247
super.dispose();
177248
}
178249

179250
@override
180251
Widget build(BuildContext context) {
181-
final MotionModel m = _transition.isAnimating ? _current : _to;
252+
final MotionModel m = _moving ? _current : _to;
182253
if (!m.hasTransform && _turns == 0) return widget.child;
183254
Widget child = widget.child;
184255
if (m.opacity < 1) {

0 commit comments

Comments
 (0)