From 30a5c6376db7d90592ad9571a31bbed44377d6e4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 22:25:51 +0000 Subject: [PATCH 1/9] Computed variables, map/list/text formula functions, URL query in route - A variable whose value is a ${= ...} formula is computed: reads evaluate it against the other variables, and nodes bound to it rebuild when the variables it reads change. - Formulas: obj, set, merge, keys, values (formulas have no {} literals, so these build and update maps, e.g. one item of a list via map + set), range, slice, first, last, concat, indexOf, padLeft, padRight, substring, split, replace, coalesce, daysInMonth. - On the web, the page URL's query parameters are in route, so a published site can link to a state (?date=...); navigation params win. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011fXBKiHRftTsgsVPHEZAWz --- .../passive_transformer_manager.dart | 123 ++++------- .../utils/property_value_delegate.dart | 49 ++++- lib/src/transformers/utils/scoped_values.dart | 15 +- lib/src/utils/formula.dart | 202 +++++++++++++++++- test/computed_variables_test.dart | 150 +++++++++++++ test/formula_maps_text_test.dart | 113 ++++++++++ test/route_url_query_test.dart | 58 +++++ 7 files changed, 613 insertions(+), 97 deletions(-) create mode 100644 test/computed_variables_test.dart create mode 100644 test/formula_maps_text_test.dart create mode 100644 test/route_url_query_test.dart diff --git a/lib/src/transformers/passive_transformer_manager.dart b/lib/src/transformers/passive_transformer_manager.dart index 9d6e473b..d7ba105e 100644 --- a/lib/src/transformers/passive_transformer_manager.dart +++ b/lib/src/transformers/passive_transformer_manager.dart @@ -323,116 +323,75 @@ class PassiveNodeTransformerManager extends WidgetNodeTransformerManager { // Add node values to [listenables]. if (listenableNodeValues != null) listenables.add(listenableNodeValues); - // Traverse through all the variables that the node properties are attached - // to. - // A property bound to a formula listens to the paths the formula reads. - final Set boundPaths = { - for (final String path in node.variables.values) - if (FormulaEvaluator.exactExpression(path) case final String expression) - ...FormulaEvaluator.referencedPaths(expression) - else - path, - }; - for (final String variablePath in boundPaths) { + // Adds what a variable path rebuilds on: its variable, or the storage or + // auth notifier for those predefined variables. A computed variable (one + // whose value is a `${= ...}` formula) also brings in the variables its + // formula reads, so nodes stay live when those change. + final Set seenNames = {}; + void addPath(String variablePath) { final match = VariableMatch.parse(variablePath.wrapWithVariableSyntax()); - if (match == null) continue; + // Storage listens per key, so predefined paths count whole. + if (match == null || + !seenNames.add( + match.isPredefinedVariable ? variablePath : match.name, + )) { + return; + } if (match.isPredefinedVariable) { // If variable is not listenable or doesn't need to create a listenable, // skip it. - if (!predefinedListenableVariableNames.contains(match.name)) continue; + if (!predefinedListenableVariableNames.contains(match.name)) return; switch (match.name) { case 'storage': - // handle storage variable final notifier = getStorageListenerFor(match, context); if (notifier != null) listenables.add(notifier); case 'auth': final notifier = context.read()?.appUser; if (notifier != null) listenables.add(notifier); } + return; } // Get corresponding variable data from codelessly context. final ValueNotifier? listenableVariable = codelesslyContext .findVariableByName(match.name); - // Add variable to [listenables]. - if (listenableVariable != null) listenables.add(listenableVariable); + if (listenableVariable == null) return; + listenables.add(listenableVariable); + if (FormulaEvaluator.exactExpression(listenableVariable.value.value) + case final String expression) { + FormulaEvaluator.referencedPaths(expression).forEach(addPath); + } + } + + // The variables the node's properties are bound to. A property bound to + // a formula listens to the paths the formula reads. + for (final String path in node.variables.values) { + if (FormulaEvaluator.exactExpression(path) case final String expression) { + FormulaEvaluator.referencedPaths(expression).forEach(addPath); + } else { + addPath(path); + } } - // Traverse through all the multi-variables that the node properties are attached - // to. // Text written with inline `${...}` paths or formulas (a text's // characters, a button's label, ...) listens to them too, so it stays // live whether or not its multipleVariables were filled in. - final Set multiplePaths = { - ...node.multipleVariables.values.expand((path) => path), - for (final String template in inlineTextTemplatesOf(node)) ...{ - ...variablePathRegex - .allMatches(template) - .map((match) => match.namedGroup('value')) - .nonNulls, - ...FormulaEvaluator.referencedPathsInText(template), - }, - }; - for (final String variablePath in multiplePaths) { - final match = VariableMatch.parse(variablePath.wrapWithVariableSyntax()); - if (match == null) continue; - - if (match.isPredefinedVariable) { - // If variable is not listenable or doesn't need to create a listenable, - // skip it. - if (!predefinedListenableVariableNames.contains(match.name)) continue; - - switch (match.name) { - case 'storage': - // handle storage variable - final notifier = getStorageListenerFor(match, context); - if (notifier != null) listenables.add(notifier); - case 'auth': - final notifier = context.read()?.appUser; - if (notifier != null) listenables.add(notifier); - } - } - - // Get corresponding variable data from codelessly context. - final ValueNotifier? listenableVariable = codelesslyContext - .findVariableByName(match.name); - // Add variable to [listenables]. - if (listenableVariable != null) listenables.add(listenableVariable); + node.multipleVariables.values.expand((path) => path).forEach(addPath); + for (final String template in inlineTextTemplatesOf(node)) { + variablePathRegex + .allMatches(template) + .map((match) => match.namedGroup('value')) + .nonNulls + .forEach(addPath); + FormulaEvaluator.referencedPathsInText(template).forEach(addPath); } - // Traverse through all the conditions that affects the node. + // The variables of the conditions that affect the node. for (final BaseCondition condition in codelesslyContext.conditions.values) { if (!condition.hasNode(node.id)) continue; - - final Set variableNames = condition - .getReactiveVariables() - .toSet(); - - for (final name in variableNames) { - if (predefinedListenableVariableNames.contains(name)) { - switch (name) { - case 'storage': - final notifier = getStorageListenerFor( - VariableMatch.parse(name.wrapWithVariableSyntax())!, - context, - ); - if (notifier != null) listenables.add(notifier); - case 'auth': - final notifier = context.read()?.appUser; - if (notifier != null) listenables.add(notifier); - } - continue; - } - // Get corresponding variable data from codelessly context. - final ValueNotifier? listenableVariable = - codelesslyContext.variables.values.firstWhereOrNull( - (notifier) => notifier.value.name == name, - ); - // Add variable to [listenables]. - if (listenableVariable != null) listenables.add(listenableVariable); - } + condition.getReactiveVariables().toSet().forEach(addPath); } return listenables; diff --git a/lib/src/transformers/utils/property_value_delegate.dart b/lib/src/transformers/utils/property_value_delegate.dart index 3f4dfd8a..32df14e8 100644 --- a/lib/src/transformers/utils/property_value_delegate.dart +++ b/lib/src/transformers/utils/property_value_delegate.dart @@ -363,9 +363,10 @@ class PropertyValueDelegate { final VariableData? variable = scopedValues.variables[match.name]; if (variable == null) return null; + final Object? current = variableValue(variable, scopedValues: scopedValues); if (match.hasAccessor) { if (variable.type.isText) { - final characters = variable.getValue().toString().characters.toList(); + final characters = current.toString().characters.toList(); final (Object? value, _) = substituteJsonPath(match.fullPath, { match.name: characters, }); @@ -373,23 +374,18 @@ class PropertyValueDelegate { } if (!variable.type.isList) return null; final (Object? value, _) = substituteJsonPath(match.fullPath, { - match.name: variable.getValue(), + match.name: current, }); return value; } - if (!match.hasPath) return variable.getValue(); + if (!match.hasPath) return current; - final variableProps = getVariableProperties( - variable.getValue(), - variable.type, - ); - final Object? variableValue = variable.getValue(); + final variableProps = getVariableProperties(current, variable.type); final Map values = { ...variableProps, - if (variable.type.isMap && variableValue is Map) - ...variableValue, + if (variable.type.isMap && current is Map) ...current, }; final (Object? value, _) = substituteJsonPath(match.fullPath, { match.name: values, @@ -398,6 +394,39 @@ class PropertyValueDelegate { return value; } + /// Variables being evaluated, to stop computed variables that read each + /// other in a loop. + static final Set _computing = {}; + + /// The value of [variable]. A computed variable, whose value is exactly one + /// `${= ...}` formula, evaluates it against the current variables, so it + /// always reflects the variables it reads; others give their stored value. + /// A computed variable that reads itself, directly or through others, is + /// null. + static Object? variableValue( + VariableData variable, { + required ScopedValues scopedValues, + }) { + final String? expression = FormulaEvaluator.exactExpression(variable.value); + if (expression == null) return variable.getValue(); + if (!_computing.add(variable.id)) return null; + try { + final Object? value = evaluateFormula( + expression, + scopedValues: scopedValues, + ); + return switch (variable.type) { + VariableType.text => + value == null ? null : FormulaEvaluator.stringify(value), + VariableType.list => value is Iterable ? value.toList() : null, + VariableType.map => value is Map ? value : null, + _ => value, + }; + } finally { + _computing.remove(variable.id); + } + } + @internal static Object? retrievePredefinedVariableValue( VariableMatch match, diff --git a/lib/src/transformers/utils/scoped_values.dart b/lib/src/transformers/utils/scoped_values.dart index 146ebbac..b8e981b8 100644 --- a/lib/src/transformers/utils/scoped_values.dart +++ b/lib/src/transformers/utils/scoped_values.dart @@ -1,4 +1,5 @@ import 'package:codelessly_api/codelessly_api.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; import 'package:provider/provider.dart'; @@ -114,11 +115,19 @@ class ScopedValues { indexedItem = IndexedItemProvider.maybeOf(context); screenSize = MediaQuery.maybeSizeOf(context); nodeState = NodeStateProvider.maybeOf(context)?.state; - routeParams = - ModalRoute.of(context)?.settings.arguments as Map? ?? - {}; + routeParams = { + // On the web, the page URL's query parameters, so a published site + // can be linked to a state: `?date=2026-10-22` is `${route.date}`. + ...urlQueryParameters(), + ...?ModalRoute.of(context)?.settings.arguments as Map?, + }; final instance = context.read(); _appUser = null; _instanceRef = instance == null ? null : WeakReference(instance); } } + +/// The query parameters of the page URL on the web, else empty. Navigation +/// params of the same name win over them in `route`. Tests replace it. +Map Function() urlQueryParameters = () => + kIsWeb ? Uri.base.queryParameters : const {}; diff --git a/lib/src/utils/formula.dart b/lib/src/utils/formula.dart index 99ac5af5..6b8361f6 100644 --- a/lib/src/utils/formula.dart +++ b/lib/src/utils/formula.dart @@ -27,8 +27,14 @@ typedef FormulaPathResolver = Object? Function(String path); /// `condition ? a : b`. `+` joins text when either side is text. /// - functions: money, fixed, round, floor, ceil, abs, min, max, sum, count, /// length, if, plural, join, upper, lower, trim, contains, startsWith, -/// endsWith, where, without, number, text, isEmpty and isNotEmpty. See +/// endsWith, where, without, number, text, isEmpty, isNotEmpty, padLeft, +/// padRight, substring, split, replace and coalesce. See /// [FormulaEvaluator.functions]. +/// - maps: `obj('id', 1, 'name', n)` builds one (formulas have no `{}` +/// literals), `set(map, key, value)` and `merge(a, b)` return changed +/// copies, `keys` and `values` read them. With `map` this updates one +/// item of a list: `map(units, u => if(u.id == id, set(u, 'hours', h), u))`. +/// - more list functions: range, slice, first, last, concat and indexOf. /// - list functions that take a predicate or key: filter, where, count, /// find, any, all, map, sortBy, unique, sum and join. The predicate is a /// lambda, `item => expression`, or a field name, optionally with a value @@ -40,7 +46,7 @@ typedef FormulaPathResolver = Object? Function(String path); /// function arguments. /// - date functions: now, today, parseDate, formatDate, addMinutes, addHours, /// addDays, addMonths, diffMinutes, diffHours, diffDays, year, month, day, -/// hour, minute and weekday. Dates are ISO 8601 text such as +/// hour, minute, weekday and daysInMonth. Dates are ISO 8601 text such as /// `2026-10-21T14:30:00.000`; a date argument can also be epoch /// milliseconds or a Firestore timestamp. /// - date-aware comparisons: `== != < <= > >=` compare as dates when one @@ -336,6 +342,80 @@ class FormulaEvaluator { 'weekday(date)', 'weekday("2026-10-21") → 3 (Monday is 1)', ), + FormulaFunctionDoc( + 'daysInMonth', + 'daysInMonth(date)', + 'daysInMonth("2026-02-10") → 28', + ), + FormulaFunctionDoc( + 'obj', + 'obj(key, value, key2, value2, ...)', + 'obj("id", "ex1", "hours", 12) → {id: ex1, hours: 12}', + ), + FormulaFunctionDoc( + 'set', + 'set(map, key, value)', + 'map(units, u => if(u.id == id, set(u, "hours", h), u))', + ), + FormulaFunctionDoc( + 'merge', + 'merge(map, map2, ...)', + 'merge(unit, obj("status", "Down"))', + ), + FormulaFunctionDoc('keys', 'keys(map)', 'keys(obj("a", 1)) → [a]'), + FormulaFunctionDoc('values', 'values(map)', 'values(obj("a", 1)) → [1]'), + FormulaFunctionDoc( + 'range', + 'range(end) or range(start, end, step?)', + 'range(2, 5) → [2, 3, 4]', + ), + FormulaFunctionDoc( + 'slice', + 'slice(list, start, end?)', + 'slice(items, 0, 3) → first three', + ), + FormulaFunctionDoc('first', 'first(list)', 'first(units).id'), + FormulaFunctionDoc('last', 'last(list)', 'last(readings).hours'), + FormulaFunctionDoc( + 'concat', + 'concat(list, list2, ...)', + 'concat(range(2), [9]) → [0, 1, 9]', + ), + FormulaFunctionDoc( + 'indexOf', + 'indexOf(list, value | item => test | key, value)', + 'indexOf(units, u => u.id == "ex2") → 1', + ), + FormulaFunctionDoc( + 'padLeft', + 'padLeft(text, width, char?)', + 'padLeft(42, 5, "0") → 00042', + ), + FormulaFunctionDoc( + 'padRight', + 'padRight(text, width, char?)', + 'padRight("ab", 4, ".") → ab..', + ), + FormulaFunctionDoc( + 'substring', + 'substring(text, start, end?)', + 'substring("EX-102", 3) → 102', + ), + FormulaFunctionDoc( + 'split', + 'split(text, separator?)', + 'split("2481") → [2, 4, 8, 1]', + ), + FormulaFunctionDoc( + 'replace', + 'replace(text, from, to)', + 'replace("1,402", ",", "") → 1402', + ), + FormulaFunctionDoc( + 'coalesce', + 'coalesce(value, fallback, ...)', + 'coalesce(route.date, today())', + ), ]; } @@ -876,8 +956,126 @@ final Map _functions = { 'hour': (a) => _toDate(_arg(a, 0))?.hour, 'minute': (a) => _toDate(_arg(a, 0))?.minute, 'weekday': (a) => _toDate(_arg(a, 0))?.weekday, + 'daysInMonth': (a) { + final DateTime? date = _toDate(_arg(a, 0)); + return date == null ? null : DateTime.utc(date.year, date.month + 1, 0).day; + }, + // Maps: formulas can't write `{...}` literals, so these build and change + // maps. Each returns a new map and leaves its input alone. + 'obj': (a) => { + for (int i = 0; i + 1 < a.length; i += 2) + FormulaEvaluator.stringify(a[i]): a[i + 1], + }, + 'set': (a) => switch (_arg(a, 0)) { + final Map map => {...map, _arg(a, 1): _arg(a, 2)}, + null => { + FormulaEvaluator.stringify(_arg(a, 1)): _arg(a, 2), + }, + _ => _arg(a, 0), + }, + 'merge': (a) => { + for (final m in a) + if (m is Map) ...m, + }, + 'keys': (a) => switch (_arg(a, 0)) { + final Map map => map.keys.toList(), + _ => [], + }, + 'values': (a) => switch (_arg(a, 0)) { + final Map map => map.values.toList(), + _ => [], + }, + // Lists. + 'range': _range, + 'slice': (a) { + final List items = _values(_arg(a, 0), null).toList(); + final int start = _clampIndex(_arg(a, 1), items.length, 0); + final int end = _clampIndex(_arg(a, 2), items.length, items.length); + return end <= start ? [] : items.sublist(start, end); + }, + 'first': (a) => _values(_arg(a, 0), null).firstOrNull, + 'last': (a) => _values(_arg(a, 0), null).lastOrNull, + 'concat': (a) => [ + for (final part in a) + if (part is Iterable) ...part else ?part, + ], + 'indexOf': (a) { + final List items = _values(_arg(a, 0), null).toList(); + final Object? target = _arg(a, 1); + for (int i = 0; i < items.length; i++) { + if (target is _Closure + ? _truthy(target(items[i])) + : (a.length > 2 + ? _looseEquals(_select(items[i], target), a[2]) + : _looseEquals(items[i], target))) { + return i; + } + } + return -1; + }, + // Text. + 'padLeft': (a) => + FormulaEvaluator.stringify(_arg(a, 0)) + .padLeft(_intArg(_arg(a, 1)), _padChar(_arg(a, 2))), + 'padRight': (a) => + FormulaEvaluator.stringify(_arg(a, 0)) + .padRight(_intArg(_arg(a, 1)), _padChar(_arg(a, 2))), + 'substring': (a) { + final String text = FormulaEvaluator.stringify(_arg(a, 0)); + final int start = _clampIndex(_arg(a, 1), text.length, 0); + final int end = _clampIndex(_arg(a, 2), text.length, text.length); + return end <= start ? '' : text.substring(start, end); + }, + 'split': (a) { + final String text = FormulaEvaluator.stringify(_arg(a, 0)); + final Object? separator = _arg(a, 1); + if (text.isEmpty) return []; + return text.split( + separator == null ? '' : FormulaEvaluator.stringify(separator), + ); + }, + 'replace': (a) => FormulaEvaluator.stringify(_arg(a, 0)).replaceAll( + FormulaEvaluator.stringify(_arg(a, 1)), + FormulaEvaluator.stringify(_arg(a, 2)), + ), + // The first argument that is neither null nor empty text. + 'coalesce': (a) => a.firstWhere( + (v) => v != null && !(v is String && v.isEmpty), + orElse: () => null, + ), }; +/// `range(end)`, `range(start, end)` or `range(start, end, step)`: whole +/// numbers from start (default 0) up to, not including, end. Capped at +/// 10000 items so a bad argument can't hang the app. +List _range(List a) { + final bool one = a.length < 2; + final int start = one ? 0 : _intArg(_arg(a, 0)); + final int end = one ? _intArg(_arg(a, 0)) : _intArg(_arg(a, 1)); + int step = a.length > 2 ? _intArg(_arg(a, 2)) : (end >= start ? 1 : -1); + if (step == 0) step = 1; + final List out = []; + for (int i = start; step > 0 ? i < end : i > end; i += step) { + out.add(i); + if (out.length >= 10000) break; + } + return out; +} + +/// [value] as an index into a sequence of [length]; negative counts from the +/// end, null is [fallback]. +int _clampIndex(Object? value, int length, int fallback) { + if (value == null) return fallback; + int index = _intArg(value); + if (index < 0) index += length; + return index.clamp(0, length); +} + +String _padChar(Object? value) { + final String text = value == null ? ' ' : FormulaEvaluator.stringify(value); + return text.isEmpty ? ' ' : text; +} + // --------------------------------------------------------------------------- // Syntax tree // --------------------------------------------------------------------------- diff --git a/test/computed_variables_test.dart b/test/computed_variables_test.dart new file mode 100644 index 00000000..596b83fd --- /dev/null +++ b/test/computed_variables_test.dart @@ -0,0 +1,150 @@ +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:codelessly_sdk/codelessly_sdk.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; + +VariableData variable( + String id, + String name, + Object value, + VariableType type, +) => VariableData(id: id, name: name, value: '$value', type: type); + +final VariableData units = variable( + 'v1', + 'units', + '[{"id":"ex1","status":"Available","hours":10},{"id":"ex2","status":"On rent","hours":20}]', + VariableType.list, +); +final VariableData query = variable('v2', 'query', 'ex', VariableType.text); +final VariableData available = variable( + 'v3', + 'available', + r"${= filter(units, u => u.status == 'Available')}", + VariableType.list, +); +final VariableData availableCount = variable( + 'v4', + 'availableCount', + r'${= count(available)}', + VariableType.integer, +); +final VariableData label = variable( + 'v5', + 'label', + r"${= availableCount + ' of ' + count(units) + ' ready'}", + VariableType.text, +); +final VariableData loop = variable( + 'v6', + 'loop', + r'${= loop2 + 1}', + VariableType.integer, +); +final VariableData loop2 = variable( + 'v7', + 'loop2', + r'${= loop + 1}', + VariableType.integer, +); + +ScopedValues scope([List? extra]) => ScopedValues.preview( + variables: { + for (final v in [ + units, + query, + available, + availableCount, + label, + loop, + loop2, + ...?extra, + ]) + v.name: v, + }, +); + +Object? read(String path, [ScopedValues? values]) => + PropertyValueDelegate.getVariableValueFromPath( + path, + scopedValues: values ?? scope(), + ); + +void main() { + test('a computed variable evaluates its formula against other variables', () { + expect(read('available'), [ + {'id': 'ex1', 'status': 'Available', 'hours': 10}, + ]); + expect(read('availableCount'), 1); + expect(read('label'), '1 of 2 ready'); + }); + + test('paths and formulas read through computed variables', () { + expect(read('available[0].id'), 'ex1'); + expect(read(r'${= available[0].hours * 2}'), 20); + expect(read(r'${= map(available, u => u.id)}'), ['ex1']); + }); + + test('it follows the variables it reads', () { + final VariableData moved = variable( + 'v1', + 'units', + '[{"id":"ex1","status":"On rent"},{"id":"ex2","status":"Available"},{"id":"ex3","status":"Available"}]', + VariableType.list, + ); + final ScopedValues values = ScopedValues.preview( + variables: { + for (final v in [moved, available, availableCount, label]) v.name: v, + }, + ); + expect(read('availableCount', values), 2); + expect(read('label', values), '2 of 3 ready'); + }); + + test('variables that read each other in a loop stop, not hang', () { + // loop reads loop2, which reads loop again: that inner read is null (0 in + // arithmetic), so loop2 is 1 and loop is 2. + expect(read('loop'), 2); + }); + + testWidgets('a node bound to a computed variable rebuilds on its inputs', ( + tester, + ) async { + final CodelesslyContext context = CodelesslyContext.empty(); + for (final v in [units, query, available, availableCount, label]) { + context.variables[v.id] = Observable(v); + } + final RectangleNode node = RectangleNode( + id: 'r', + name: 'Badge', + basicBoxLocal: NodeBox(0, 0, 10, 10), + )..variables['visible'] = r'${= availableCount > 0}'; + final manager = PassiveNodeTransformerManager( + (id) => node, + (context, bounds, pageID, layoutID, canvasID) => const SizedBox(), + ); + late List listenables; + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: context, + child: Builder( + builder: (c) { + listenables = manager.listenablesFor(c, node); + return const SizedBox(); + }, + ), + ), + ); + // availableCount -> available -> units. + expect( + listenables, + containsAll([ + context.variables['v4'], + context.variables['v3'], + context.variables['v1'], + ]), + ); + expect(listenables, isNot(contains(context.variables['v2']))); + }); +} diff --git a/test/formula_maps_text_test.dart b/test/formula_maps_text_test.dart new file mode 100644 index 00000000..1701cfaf --- /dev/null +++ b/test/formula_maps_text_test.dart @@ -0,0 +1,113 @@ +import 'package:codelessly_sdk/codelessly_sdk.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + final Map vars = { + 'units': [ + {'id': 'ex1', 'hours': 10.5, 'status': 'Available'}, + {'id': 'ex2', 'hours': 99.0, 'status': 'On rent'}, + ], + 'meter': 2481.6, + 'unitId': 'ex2', + 'start': '2026-10-01', + }; + Object? eval(String expression) => + FormulaEvaluator.evaluate(expression, (path) => vars[path]); + + group('maps', () { + test('obj builds a map from key, value pairs', () { + expect(eval("obj('id', 'ex9', 'hours', 1 + 2)"), { + 'id': 'ex9', + 'hours': 3, + }); + expect(eval('obj()'), {}); + }); + + test('set and merge return changed copies', () { + expect(eval("set(first(units), 'hours', 12)"), { + 'id': 'ex1', + 'hours': 12, + 'status': 'Available', + }); + expect((vars['units']! as List).first, containsPair('hours', 10.5)); + expect( + eval("merge(first(units), obj('status', 'Down'))")! as Map, + containsPair('status', 'Down'), + ); + expect(eval("keys(first(units))"), ['id', 'hours', 'status']); + expect(eval("values(obj('a', 1))"), [1]); + }); + + test('update one item of a list with map + set', () { + final Object? result = eval( + "map(units, u => if(u.id == unitId, set(u, 'hours', u.hours + 5), u))", + ); + expect(result, [ + {'id': 'ex1', 'hours': 10.5, 'status': 'Available'}, + {'id': 'ex2', 'hours': 104, 'status': 'On rent'}, + ]); + }); + }); + + group('lists', () { + test('range counts up to, not including, the end', () { + expect(eval('range(4)'), [0, 1, 2, 3]); + expect(eval('range(2, 5)'), [2, 3, 4]); + expect(eval('range(0, 10, 3)'), [0, 3, 6, 9]); + expect(eval('range(3, 0)'), [3, 2, 1]); + expect(eval('range(0)'), isEmpty); + }); + + test('a month calendar from range and dates', () { + // October 2026 starts on a Thursday (weekday 4): three blank cells. + final Object? cells = eval( + "map(range(weekday(start) - 1 + daysInMonth(start)), i => if(i < weekday(start) - 1, '', addDays(start, i - weekday(start) + 1)))", + ); + final List list = cells! as List; + expect(list.length, 3 + 31); + expect(list.take(3), ['', '', '']); + expect((list[3] as String).substring(0, 10), '2026-10-01'); + expect((list.last as String).substring(0, 10), '2026-10-31'); + }); + + test('slice, first, last, concat, indexOf', () { + expect(eval('slice(range(10), 2, 4)'), [2, 3]); + expect(eval('slice(range(5), -2)'), [3, 4]); + expect(eval('first(units).id'), 'ex1'); + expect(eval('last(units).id'), 'ex2'); + expect(eval('concat(range(2), [9], 7)'), [0, 1, 9, 7]); + expect(eval("indexOf(units, u => u.id == 'ex2')"), 1); + expect(eval("indexOf(units, 'id', 'nope')"), -1); + expect(eval('indexOf([5, 6], 6)'), 1); + }); + }); + + group('text', () { + test('padLeft, padRight, substring, split, replace', () { + expect(eval("padLeft(floor(meter), 5, '0')"), '02481'); + expect(eval("padRight('ab', 4, '.')"), 'ab..'); + expect(eval("substring('EX-102', 3)"), '102'); + expect(eval("substring('EX-102', 0, 2)"), 'EX'); + expect(eval("split(padLeft(floor(meter), 5, '0'))"), [ + '0', + '2', + '4', + '8', + '1', + ]); + expect(eval("split('a,b', ',')"), ['a', 'b']); + expect(eval("replace('1,402.3', ',', '')"), '1402.3'); + }); + + test('coalesce picks the first value that is set', () { + expect(eval("coalesce(missing, '', 'fallback')"), 'fallback'); + expect(eval('coalesce(meter, 1)'), 2481.6); + }); + }); + + test('daysInMonth', () { + expect(eval("daysInMonth('2026-02-10')"), 28); + expect(eval("daysInMonth('2028-02-10')"), 29); + expect(eval('daysInMonth(start)'), 31); + }); +} diff --git a/test/route_url_query_test.dart b/test/route_url_query_test.dart new file mode 100644 index 00000000..93641baf --- /dev/null +++ b/test/route_url_query_test.dart @@ -0,0 +1,58 @@ +import 'package:codelessly_sdk/codelessly_sdk.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +Future> routeIn( + WidgetTester tester, { + Object? arguments, +}) async { + late Map route; + await tester.pumpWidget( + MaterialApp( + onGenerateRoute: (settings) => MaterialPageRoute( + settings: RouteSettings(arguments: arguments), + builder: (context) { + route = ScopedValues.of(context).routeParams; + return const SizedBox(); + }, + ), + ), + ); + return route; +} + +void main() { + tearDown(() => urlQueryParameters = () => const {}); + + testWidgets('route holds the page URL query parameters', (tester) async { + urlQueryParameters = () => {'date': '2026-10-22', 'tab': 'fleet'}; + expect(await routeIn(tester), {'date': '2026-10-22', 'tab': 'fleet'}); + }); + + testWidgets('navigation params win over the URL query', (tester) async { + urlQueryParameters = () => {'date': '2026-10-22', 'tab': 'fleet'}; + expect(await routeIn(tester, arguments: {'tab': 'service'}), { + 'date': '2026-10-22', + 'tab': 'service', + }); + }); + + testWidgets('a formula reads the date or falls back', (tester) async { + urlQueryParameters = () => {'date': '2026-10-22'}; + late Object? value; + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) { + value = PropertyValueDelegate.getVariableValueFromPath( + r"${= coalesce(route.date, 'none')}", + scopedValues: ScopedValues.of(context), + ); + return const SizedBox(); + }, + ), + ), + ); + expect(value, '2026-10-22'); + }); +} From 404c767b19f30a48394dfc4676950411d4e26179 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 22:32:37 +0000 Subject: [PATCH 2/9] Render repeating linear gradients (gradientRepeatEvery) RepeatingGradientTransform squeezes one run of the stops into the period along the gradient's axis, then rotates like GradientRotation; with TileMode.repeated the stops repeat every N px whatever the box size. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011fXBKiHRftTsgsVPHEZAWz --- .../passive_rectangle_transformer.dart | 42 +++++++++- test/gradient_repeat_test.dart | 82 +++++++++++++++++++ 2 files changed, 123 insertions(+), 1 deletion(-) create mode 100644 test/gradient_repeat_test.dart diff --git a/lib/src/transformers/node_transformers/passive_rectangle_transformer.dart b/lib/src/transformers/node_transformers/passive_rectangle_transformer.dart index d60a661b..2b1576da 100644 --- a/lib/src/transformers/node_transformers/passive_rectangle_transformer.dart +++ b/lib/src/transformers/node_transformers/passive_rectangle_transformer.dart @@ -433,12 +433,17 @@ Gradient? retrieveGradient(PaintModel paint) { final radians = rotation * pi / 180; if (paint.type == PaintType.gradientLinear) { + final double? repeatEvery = paint.gradientRepeatEvery; + final bool repeats = repeatEvery != null && repeatEvery > 0; return LinearGradient( colors: gradientStops .map((e) => e.color.multiplyAlpha(paint.opacity).toFlutterColor()) .toList(), stops: gradientStops.map((e) => e.position).toList(), - transform: GradientRotation(radians), + tileMode: repeats ? TileMode.repeated : TileMode.clamp, + transform: repeats + ? RepeatingGradientTransform(radians, repeatEvery) + : GradientRotation(radians), ); } else if (paint.type == PaintType.gradientRadial) { return RadialGradient( @@ -468,6 +473,41 @@ Gradient? retrieveGradient(PaintModel paint) { } } +/// Rotates a linear gradient by [radians] about the box center, like +/// [GradientRotation], after squeezing one run of its stops into [period] +/// logical pixels along its axis. With [TileMode.repeated] the stops then +/// repeat every [period] pixels whatever the box size, e.g. even stripes. +class RepeatingGradientTransform extends GradientTransform { + const RepeatingGradientTransform(this.radians, this.period); + + final double radians; + final double period; + + @override + Matrix4 transform(Rect bounds, {TextDirection? textDirection}) { + // A default LinearGradient runs from the left to the right edge, so one + // run spans the box width. Scale it about the left edge to [period]. + final double scale = bounds.width <= 0 ? 1 : period / bounds.width; + final double cx = bounds.center.dx, cy = bounds.center.dy; + return Matrix4.identity() + ..translateByDouble(cx, cy, 0, 1) + ..rotateZ(radians) + ..translateByDouble(-cx, -cy, 0, 1) + ..translateByDouble(bounds.left, 0, 0, 1) + ..scaleByDouble(scale, 1, 1, 1) + ..translateByDouble(-bounds.left, 0, 0, 1); + } + + @override + bool operator ==(Object other) => + other is RepeatingGradientTransform && + other.radians == radians && + other.period == period; + + @override + int get hashCode => Object.hash(radians, period); +} + // from https://math.stackexchange.com/a/2888105 RelativeTransform decomposeRelativeTransform(List? t1) { t1 ??= defaultGradientTransform(); diff --git a/test/gradient_repeat_test.dart b/test/gradient_repeat_test.dart new file mode 100644 index 00000000..e90f0073 --- /dev/null +++ b/test/gradient_repeat_test.dart @@ -0,0 +1,82 @@ +import 'dart:math'; +import 'dart:ui' as ui; + +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:codelessly_sdk/codelessly_sdk.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:vector_math/vector_math_64.dart' show Vector3; +import 'package:flutter_test/flutter_test.dart'; + +PaintModel stripes({double? every, List? transform}) => PaintModel( + id: 'p', + type: PaintType.gradientLinear, + gradientTransform: transform, + gradientRepeatEvery: every, + gradientStops: const [ + ColorStop(position: 0, color: ColorRGBA(r: 1, g: 1, b: 0, a: 1)), + ColorStop(position: 0.5, color: ColorRGBA(r: 1, g: 1, b: 0, a: 1)), + ColorStop(position: 0.5, color: ColorRGBA.black), + ColorStop(position: 1, color: ColorRGBA.black), + ], +); + +void main() { + test('a repeating gradient tiles at a fixed pixel period', () { + final LinearGradient gradient = + retrieveGradient(stripes(every: 20))! as LinearGradient; + expect(gradient.tileMode, TileMode.repeated); + final Matrix4 m = gradient.transform!.transform( + const Rect.fromLTWH(0, 0, 100, 40), + )!; + // The gradient's end (the right edge, x 100) lands 20 px from the left. + expect(m.transform3(Vector3(100, 20, 0)).x, closeTo(20, 1e-9)); + expect(m.transform3(Vector3(0, 20, 0)).x, closeTo(0, 1e-9)); + }); + + test('without a period it still stretches across the box', () { + final LinearGradient gradient = + retrieveGradient(stripes())! as LinearGradient; + expect(gradient.tileMode, TileMode.clamp); + expect(gradient.transform, isA()); + }); + + testWidgets('paints even stripes at any width', (tester) async { + Future> row(double width) async { + final key = GlobalKey(); + await tester.pumpWidget( + Center( + child: RepaintBoundary( + key: key, + child: Container( + width: width, + height: 10, + decoration: BoxDecoration( + gradient: retrieveGradient(stripes(every: 20)), + ), + ), + ), + ), + ); + late List reds; + await tester.runAsync(() async { + final RenderRepaintBoundary boundary = + key.currentContext!.findRenderObject()! as RenderRepaintBoundary; + final ui.Image image = await boundary.toImage(); + final data = (await image.toByteData( + format: ui.ImageByteFormat.rawRgba, + ))!; + // The red channel along the middle row, sampled mid-band: yellow + // (255) then black (0), every 10 px, at both widths. + reds = [ + for (final int x in const [5, 15, 25, 35, 45, 55]) + data.getUint8(((5 * image.width) + min(x, image.width - 1)) * 4), + ]; + }); + return reds; + } + + expect(await row(60), [255, 0, 255, 0, 255, 0]); + expect(await row(300), [255, 0, 255, 0, 255, 0]); + }); +} From 83a848cc9f2cf2a42ca6cd1332134afe51ff0d08 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 22:41:40 +0000 Subject: [PATCH 3/9] Render arc progress bars (gauges); tolerate APIs without a directory ArcProgressPainter draws the track, zone bands, progress, ticks and a needle that overshoots slightly past the max, fitted to the box, with the bound currentValue and progressColor. HttpApiData.directory defaults to the root folder instead of throwing. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011fXBKiHRftTsgsVPHEZAWz --- lib/src/model/model_http_request.dart | 4 + lib/src/model/model_http_request.g.dart | 2 +- .../passive_progressbar_transformer.dart | 183 ++++++++++++++++++ test/progress_arc_test.dart | 104 ++++++++++ 4 files changed, 292 insertions(+), 1 deletion(-) create mode 100644 test/progress_arc_test.dart diff --git a/lib/src/model/model_http_request.dart b/lib/src/model/model_http_request.dart index 59b9dd7c..c84e790f 100644 --- a/lib/src/model/model_http_request.dart +++ b/lib/src/model/model_http_request.dart @@ -66,6 +66,10 @@ class HttpApiData extends PrivacyBase { bool isDraft; @DateTimeConverter() final DateTime created; + + /// Folder of the API in the editor. Missing in some saved APIs, so it + /// reads as the root folder rather than failing every API's load. + @JsonKey(defaultValue: '') final String directory; HttpApiData({ diff --git a/lib/src/model/model_http_request.g.dart b/lib/src/model/model_http_request.g.dart index 45313e60..00c62000 100644 --- a/lib/src/model/model_http_request.g.dart +++ b/lib/src/model/model_http_request.g.dart @@ -7,7 +7,7 @@ part of 'model_http_request.dart'; // ************************************************************************** HttpApiData _$HttpApiDataFromJson(Map json) => HttpApiData( - directory: json['directory'] as String, + directory: json['directory'] as String? ?? '', method: $enumDecodeNullable(_$HttpMethodEnumMap, json['method']) ?? HttpMethod.get, diff --git a/lib/src/transformers/node_transformers/passive_progressbar_transformer.dart b/lib/src/transformers/node_transformers/passive_progressbar_transformer.dart index e0ede75e..3ab7e688 100644 --- a/lib/src/transformers/node_transformers/passive_progressbar_transformer.dart +++ b/lib/src/transformers/node_transformers/passive_progressbar_transformer.dart @@ -1,3 +1,5 @@ +import 'dart:math' as math; + import 'package:codelessly_api/codelessly_api.dart'; import 'package:flutter/material.dart'; import 'package:flutter_animation_progress_bar/flutter_animation_progress_bar.dart'; @@ -138,6 +140,32 @@ class PassiveProgressBarWidget extends StatelessWidget { )?.toFlutterColor() ?? node.properties.progressColor.toFlutterColor(); + if (node.properties.shape == ProgressBarShape.arc) { + final double maxValue = node.properties.maxValue <= 0 + ? 1 + : node.properties.maxValue; + return AdaptiveNodeBox( + node: node, + child: TweenAnimationBuilder( + tween: Tween(end: currentValue / maxValue), + duration: (animate ?? node.properties.animate) + ? Duration( + milliseconds: node.properties.animationDurationInMillis, + ) + : Duration.zero, + curve: Curves.easeOutCubic, + builder: (context, progress, _) => CustomPaint( + size: Size.infinite, + painter: ArcProgressPainter( + progress: progress, + properties: node.properties, + progressColor: progressColor, + ), + ), + ), + ); + } + return AdaptiveNodeBox( node: node, child: FAProgressBar( @@ -158,3 +186,158 @@ class PassiveProgressBarWidget extends StatelessWidget { ); } } + +/// Paints an arc progress bar: a track, colored zone bands just outside it, +/// the progress arc, tick marks and an optional needle. [progress] is the +/// value as a fraction of the max; over 1 fills the arc and pushes the needle +/// a little past its end. +class ArcProgressPainter extends CustomPainter { + ArcProgressPainter({ + required this.progress, + required this.properties, + required this.progressColor, + }); + + final double progress; + final ProgressBarProperties properties; + final Color progressColor; + + static double _rad(double degrees) => degrees * math.pi / 180; + + @override + void paint(Canvas canvas, Size size) { + final double stroke = properties.arcStrokeWidth; + final double bandWidth = properties.bands.isEmpty ? 0 : stroke / 3; + final double gap = properties.bands.isEmpty ? 0 : stroke / 4; + final double start = _rad(properties.arcStartAngle); + final double sweep = _rad(properties.arcSweepAngle); + + // The largest circle whose arc fits the box. + final Rect unit = _arcBounds(start, sweep); + final double outer = stroke / 2 + gap + bandWidth; + final double radius = math.max( + 0, + math.min( + (size.width - 2 * outer) / unit.width, + (size.height - 2 * outer) / unit.height, + ), + ); + final Offset center = Offset( + size.width / 2 - unit.center.dx * radius, + size.height / 2 - unit.center.dy * radius, + ); + final Rect arcRect = Rect.fromCircle(center: center, radius: radius); + + canvas.drawArc( + arcRect, + start, + sweep, + false, + Paint() + ..color = properties.backgroundColor.toFlutterColor() + ..style = PaintingStyle.stroke + ..strokeWidth = stroke, + ); + + final Rect bandRect = Rect.fromCircle( + center: center, + radius: radius + stroke / 2 + gap + bandWidth / 2, + ); + for (final ProgressBand band in properties.bands) { + final double from = band.start.clamp(0, 1).toDouble(); + final double to = band.end.clamp(0, 1).toDouble(); + if (to <= from) continue; + canvas.drawArc( + bandRect, + start + sweep * from, + sweep * (to - from), + false, + Paint() + ..color = band.color.toFlutterColor() + ..style = PaintingStyle.stroke + ..strokeWidth = bandWidth, + ); + } + + final double filled = progress.clamp(0, 1).toDouble(); + if (filled > 0) { + canvas.drawArc( + arcRect, + start, + sweep * filled, + false, + Paint() + ..color = progressColor + ..style = PaintingStyle.stroke + ..strokeWidth = stroke, + ); + } + + if (properties.tickCount > 1) { + final Paint tick = Paint() + ..color = properties.tickColor.toFlutterColor() + ..strokeWidth = 2; + for (int i = 1; i < properties.tickCount; i++) { + final double a = start + sweep * i / properties.tickCount; + final Offset d = Offset(math.cos(a), math.sin(a)); + canvas.drawLine( + center + d * (radius - stroke / 2), + center + d * (radius + stroke / 2), + tick, + ); + } + } + + final ColorRGBA? needle = properties.needleColor; + if (needle != null) { + final double a = start + sweep * progress.clamp(0, 1.06); + final Offset d = Offset(math.cos(a), math.sin(a)); + final Offset normal = Offset(-d.dy, d.dx); + final double half = math.max(2, stroke * 0.3); + final Offset tip = center + d * (radius + stroke * 0.2); + final Offset base = center + d * (radius - stroke * 1.9); + canvas.drawPath( + Path() + ..moveTo(tip.dx, tip.dy) + ..lineTo((base + normal * half).dx, (base + normal * half).dy) + ..lineTo((base - normal * half).dx, (base - normal * half).dy) + ..close(), + Paint()..color = needle.toFlutterColor(), + ); + } + } + + /// Bounds of an arc of a unit circle from [start] sweeping [sweep], plus + /// the center, so the arc can be fitted to a box. + static Rect _arcBounds(double start, double sweep) { + final List points = [Offset.zero]; + void add(double a) => points.add(Offset(math.cos(a), math.sin(a))); + add(start); + add(start + sweep); + // Each axis extreme the arc passes through. + for (int k = -8; k <= 8; k++) { + final double a = k * math.pi / 2; + final bool inside = sweep >= 0 + ? a >= start && a <= start + sweep + : a <= start && a >= start + sweep; + if (inside) add(a); + } + double left = 0, top = 0, right = 0, bottom = 0; + for (final Offset p in points) { + left = math.min(left, p.dx); + top = math.min(top, p.dy); + right = math.max(right, p.dx); + bottom = math.max(bottom, p.dy); + } + final Rect rect = Rect.fromLTRB(left, top, right, bottom); + return rect.width <= 0 || rect.height <= 0 + ? const Rect.fromLTRB(-1, -1, 1, 1) + : rect; + } + + @override + bool shouldRepaint(ArcProgressPainter old) => + old.progress != progress || + old.properties != properties || + old.progressColor != progressColor; +} diff --git a/test/progress_arc_test.dart b/test/progress_arc_test.dart new file mode 100644 index 00000000..55cdf194 --- /dev/null +++ b/test/progress_arc_test.dart @@ -0,0 +1,104 @@ +import 'dart:ui' as ui; + +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:codelessly_sdk/codelessly_sdk.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; + +ProgressBarNode gauge(double value, {ColorRGBA? needle}) => ProgressBarNode( + id: 'g', + name: 'Gauge', + basicBoxLocal: NodeBox(0, 0, 200, 156), + currentValue: value, + properties: ProgressBarProperties( + shape: ProgressBarShape.arc, + maxValue: 250, + backgroundColor: const ColorRGBA(r: 0.87, g: 0.86, b: 0.83, a: 1), + progressColor: ColorRGBA.black, + arcStrokeWidth: 16, + needleColor: needle, + tickCount: 10, + bands: const [ + ProgressBand( + start: 0, + end: 0.9, + color: ColorRGBA(r: 0, g: 1, b: 0, a: 1), + ), + ProgressBand( + start: 0.9, + end: 1, + color: ColorRGBA(r: 1, g: 0.5, b: 0, a: 1), + ), + ], + ), +); + +Future render(WidgetTester tester, ProgressBarNode node) async { + final key = GlobalKey(); + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: CodelesslyContext.empty(), + child: MaterialApp( + home: Align( + alignment: Alignment.topLeft, + child: RepaintBoundary( + key: key, + child: SizedBox( + width: 200, + height: 156, + child: PassiveProgressBarWidget( + node: node, + settings: const WidgetBuildSettings(debugLabel: 'test'), + ), + ), + ), + ), + ), + ), + ); + late ui.Image image; + await tester.runAsync(() async { + image = + await (key.currentContext!.findRenderObject()! as RenderRepaintBoundary) + .toImage(); + }); + return image; +} + +void main() { + testWidgets('an arc progress bar paints a dial for its value', ( + tester, + ) async { + await render(tester, gauge(125, needle: ColorRGBA.black)); + final painter = tester + .widgetList(find.byType(CustomPaint)) + .map((p) => p.painter) + .whereType() + .single; + expect(painter.progress, 0.5); + expect(tester.takeException(), isNull); + }); + + testWidgets('fills from the start of the arc', (tester) async { + Future redNearStart(double value) async { + final ui.Image image = await render(tester, gauge(value)); + final data = (await tester.runAsync( + () => image.toByteData(format: ui.ImageByteFormat.rawRgba), + ))!; + // The dial opens at the bottom: 150° is lower left. Sample a point on + // the track a little after the start. + int red(int x, int y) => data.getUint8((y * image.width + x) * 4); + return red(25, 120); + } + + // Empty: the light track; nearly full: the black progress. + expect(await redNearStart(0), greaterThan(150)); + expect(await redNearStart(240), lessThan(60)); + }); + + test('a bar is still a bar', () { + expect(ProgressBarProperties().shape, ProgressBarShape.bar); + }); +} From a08a1884febc05c2c8f122a364639469b0b44dfc Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 22:46:05 +0000 Subject: [PATCH 4/9] Open dialog canvases as bottom sheets or aligned dialogs presentCanvas shows a canvas as a dialog at an alignment (e.g. a menu at the top right) or as a modal bottom sheet; adaptive picks the sheet below 600 px wide, as phones expect. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011fXBKiHRftTsgsVPHEZAWz --- lib/src/functions/functions_repository.dart | 65 +++++++++++++-- lib/src/ui/codelessly_dialog_widget.dart | 10 ++- test/dialog_presentation_test.dart | 91 +++++++++++++++++++++ 3 files changed, 156 insertions(+), 10 deletions(-) create mode 100644 test/dialog_presentation_test.dart diff --git a/lib/src/functions/functions_repository.dart b/lib/src/functions/functions_repository.dart index 86de7a90..14a271ee 100644 --- a/lib/src/functions/functions_repository.dart +++ b/lib/src/functions/functions_repository.dart @@ -571,6 +571,8 @@ class FunctionsRepository { barrierDismissible: action.barrierDismissible, barrierColor: action.barrierColor?.toFlutterColor(), showCloseButton: action.showCloseButton, + presentation: action.presentation, + alignment: action.alignment.flutterAlignment ?? Alignment.center, ); } @@ -590,6 +592,8 @@ class FunctionsRepository { bool barrierDismissible = true, Color? barrierColor, bool showCloseButton = false, + DialogPresentation presentation = DialogPresentation.dialog, + AlignmentGeometry alignment = Alignment.center, }) async { final ScopedValues scopedValues = ActionChain.scopedValuesOf(context); final parsedParams = substituteVariablesInMap(params, scopedValues); @@ -632,19 +636,17 @@ class FunctionsRepository { // A chain triggered in the dialog can finish on this layout after the // dialog closes (e.g. Navigate back, then a toast). final List openerHosts = ActionChainOpener.hostsFor(context); - final Object? result = await showDialog( - context: context, - useRootNavigator: false, + final Object? result = await presentCanvas( + context, + presentation: presentation, + alignment: alignment, barrierDismissible: barrierDismissible, barrierColor: barrierColor, + showCloseButton: showCloseButton, routeSettings: RouteSettings(arguments: parsedParams), builder: (context) => ActionChainOpener( openerHosts: openerHosts, - child: CodelesslyDialogWidget( - showCloseButton: showCloseButton, - builder: (context) => - CodelesslyWidget(codelessly: codelessly, layoutID: layoutId), - ), + child: CodelesslyWidget(codelessly: codelessly, layoutID: layoutId), ), ); if (context.mounted) { @@ -657,6 +659,53 @@ class FunctionsRepository { return result; } + /// Opens what [builder] builds as a dialog at [alignment] or as a bottom + /// sheet, per [presentation] (adaptive: a sheet below 600 px wide), and + /// completes with the value it is closed with. + static Future presentCanvas( + BuildContext context, { + required WidgetBuilder builder, + DialogPresentation presentation = DialogPresentation.dialog, + AlignmentGeometry alignment = Alignment.center, + bool barrierDismissible = true, + Color? barrierColor, + bool showCloseButton = false, + RouteSettings? routeSettings, + }) { + final bool asSheet = switch (presentation) { + DialogPresentation.dialog => false, + DialogPresentation.bottomSheet => true, + DialogPresentation.adaptive => + (MediaQuery.maybeSizeOf(context)?.width ?? 1024) < 600, + }; + Widget content(BuildContext context) => CodelesslyDialogWidget( + showCloseButton: showCloseButton, + alignment: asSheet ? Alignment.bottomCenter : alignment, + builder: builder, + ); + if (asSheet) { + return showModalBottomSheet( + context: context, + useRootNavigator: false, + isScrollControlled: true, + isDismissible: barrierDismissible, + barrierColor: barrierColor, + backgroundColor: Colors.transparent, + elevation: 0, + routeSettings: routeSettings, + builder: content, + ); + } + return showDialog( + context: context, + useRootNavigator: false, + barrierDismissible: barrierDismissible, + barrierColor: barrierColor, + routeSettings: routeSettings, + builder: content, + ); + } + static void launchURL(BuildContext context, LinkAction action) { final url = PropertyValueDelegate.substituteVariables( action.url, diff --git a/lib/src/ui/codelessly_dialog_widget.dart b/lib/src/ui/codelessly_dialog_widget.dart index 86a735bc..e9af3071 100644 --- a/lib/src/ui/codelessly_dialog_widget.dart +++ b/lib/src/ui/codelessly_dialog_widget.dart @@ -7,10 +7,15 @@ class CodelesslyDialogWidget extends StatelessWidget { final WidgetBuilder builder; final bool showCloseButton; + /// Where the dialog sits: centered by default, bottom center for a sheet, + /// or anywhere else, e.g. a menu at the top right. + final AlignmentGeometry alignment; + const CodelesslyDialogWidget({ super.key, required this.builder, required this.showCloseButton, + this.alignment = Alignment.center, }); @override @@ -25,7 +30,8 @@ class CodelesslyDialogWidget extends StatelessWidget { Widget _buildDialog(BuildContext context) { if (showCloseButton) { - return Center( + return Align( + alignment: alignment, child: Material( type: MaterialType.transparency, child: Stack( @@ -49,6 +55,6 @@ class CodelesslyDialogWidget extends StatelessWidget { ); } - return Center(child: builder(context)); + return Align(alignment: alignment, child: builder(context)); } } diff --git a/test/dialog_presentation_test.dart b/test/dialog_presentation_test.dart new file mode 100644 index 00000000..ca430ce4 --- /dev/null +++ b/test/dialog_presentation_test.dart @@ -0,0 +1,91 @@ +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:codelessly_sdk/codelessly_sdk.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +Future open( + WidgetTester tester, { + required double width, + required DialogPresentation presentation, + AlignmentGeometry alignment = Alignment.center, +}) async { + tester.view.physicalSize = Size(width, 800); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) => TextButton( + onPressed: () => FunctionsRepository.presentCanvas( + context, + presentation: presentation, + alignment: alignment, + builder: (context) => + const SizedBox(key: Key('content'), width: 200, height: 100), + ), + child: const Text('Open'), + ), + ), + ), + ); + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); +} + +void main() { + testWidgets('a dialog is centered by default', (tester) async { + await open(tester, width: 1024, presentation: DialogPresentation.dialog); + expect(find.byType(BottomSheet), findsNothing); + expect( + tester.getCenter(find.byKey(const Key('content'))), + const Offset(512, 400), + ); + }); + + testWidgets('a dialog can sit elsewhere, like a menu', (tester) async { + await open( + tester, + width: 1024, + presentation: DialogPresentation.dialog, + alignment: Alignment.topRight, + ); + expect( + tester.getTopRight(find.byKey(const Key('content'))), + const Offset(1024, 0), + ); + }); + + testWidgets('a bottom sheet sits on the bottom edge', (tester) async { + await open( + tester, + width: 1024, + presentation: DialogPresentation.bottomSheet, + ); + expect(find.byType(BottomSheet), findsOneWidget); + expect(tester.getBottomLeft(find.byKey(const Key('content'))).dy, 800); + }); + + testWidgets('adaptive is a sheet on phones and a dialog on wider screens', ( + tester, + ) async { + await open(tester, width: 390, presentation: DialogPresentation.adaptive); + expect(find.byType(BottomSheet), findsOneWidget); + }); + + testWidgets('adaptive is a dialog from 600 px', (tester) async { + await open(tester, width: 834, presentation: DialogPresentation.adaptive); + expect(find.byType(BottomSheet), findsNothing); + expect(find.byKey(const Key('content')), findsOneWidget); + }); + + test('the action keeps presentation and alignment in JSON', () { + final action = ShowDialogAction( + destinationId: 'c', + presentation: DialogPresentation.adaptive, + alignment: AlignmentModel.topRight, + ); + final decoded = ShowDialogAction.fromJson(action.toJson()); + expect(decoded.presentation, DialogPresentation.adaptive); + expect(decoded.alignment, AlignmentModel.topRight); + }); +} From c9f5eaf5bae76b38fc338ab3ae028de15ca4cf23 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 22:50:03 +0000 Subject: [PATCH 5/9] Formulas: money digits and formatNumber with thousands separators Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011fXBKiHRftTsgsVPHEZAWz --- lib/src/utils/formula.dart | 30 +++++++++++++++++++++++------- test/formula_maps_text_test.dart | 8 ++++++++ 2 files changed, 31 insertions(+), 7 deletions(-) diff --git a/lib/src/utils/formula.dart b/lib/src/utils/formula.dart index 6b8361f6..9096d816 100644 --- a/lib/src/utils/formula.dart +++ b/lib/src/utils/formula.dart @@ -166,8 +166,13 @@ class FormulaEvaluator { static const List functionDocs = [ FormulaFunctionDoc( 'money', - 'money(value, symbol)', - r'money(price) → $1,234.50', + 'money(value, symbol?, digits?)', + r'money(price) → $1,234.50, money(385, "$", 0) → $385', + ), + FormulaFunctionDoc( + 'formatNumber', + 'formatNumber(value, digits?)', + 'formatNumber(2481.64, 1) → 2,481.6', ), FormulaFunctionDoc( 'fixed', @@ -522,16 +527,26 @@ Object? _member(Object? target, Object? key) { String _fixed(Object? value, Object? digits) => _num(value).toStringAsFixed(_num(digits ?? 2).toInt().clamp(0, 20)); -String _money(Object? value, Object? symbol) { +String _money(Object? value, Object? symbol, [Object? digits]) { final num amount = _num(value); if (amount.isNaN) return ''; final String sign = amount < 0 ? '-' : ''; - final String text = amount.abs().toStringAsFixed(2); - final int dot = text.indexOf('.'); + return '$sign${symbol ?? r'$'}${_grouped(amount.abs(), digits ?? 2)}'; +} + +/// [value] with [digits] decimals (default 0) and thousands separators, +/// e.g. 2481.64 with 1 digit is "2,481.6". +String _grouped(Object? value, Object? digits) { + final num amount = _num(value); + if (amount.isNaN) return ''; + final String text = amount.toStringAsFixed( + _num(digits ?? 0).toInt().clamp(0, 20), + ); + final int dot = text.contains('.') ? text.indexOf('.') : text.length; final String whole = text .substring(0, dot) .replaceAllMapped(RegExp(r'\B(?=(\d{3})+(?!\d))'), (_) => ','); - return '$sign${symbol ?? r'$'}$whole${text.substring(dot)}'; + return '$whole${text.substring(dot)}'; } num _round(Object? value, Object? digits) { @@ -867,7 +882,8 @@ Object? _arg(List args, int index) => index < args.length ? args[index] : null; final Map _functions = { - 'money': (a) => _money(_arg(a, 0), _arg(a, 1)), + 'money': (a) => _money(_arg(a, 0), _arg(a, 1), _arg(a, 2)), + 'formatNumber': (a) => _grouped(_arg(a, 0), _arg(a, 1)), 'fixed': (a) => _fixed(_arg(a, 0), _arg(a, 1)), 'round': (a) => _round(_arg(a, 0), _arg(a, 1)), 'floor': (a) => _num(_arg(a, 0)).floor(), diff --git a/test/formula_maps_text_test.dart b/test/formula_maps_text_test.dart index 1701cfaf..bc80780c 100644 --- a/test/formula_maps_text_test.dart +++ b/test/formula_maps_text_test.dart @@ -110,4 +110,12 @@ void main() { expect(eval("daysInMonth('2028-02-10')"), 29); expect(eval('daysInMonth(start)'), 31); }); + + test('number formatting with thousands separators', () { + expect(eval('formatNumber(meter, 1)'), '2,481.6'); + expect(eval('formatNumber(1234567)'), '1,234,567'); + expect(eval("money(385, '\$', 0)"), r'$385'); + expect(eval('money(1155)'), r'$1,155.00'); + expect(eval('money(-4.5)'), r'-$4.50'); + }); } From d87ede1ce69aea4cc44c4190a8633860ba801d77 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 16:49:38 +0000 Subject: [PATCH 6/9] Tests: single quotes and formatting Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011fXBKiHRftTsgsVPHEZAWz --- test/formula_maps_text_test.dart | 2 +- test/gradient_repeat_test.dart | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/test/formula_maps_text_test.dart b/test/formula_maps_text_test.dart index bc80780c..73220076 100644 --- a/test/formula_maps_text_test.dart +++ b/test/formula_maps_text_test.dart @@ -34,7 +34,7 @@ void main() { eval("merge(first(units), obj('status', 'Down'))")! as Map, containsPair('status', 'Down'), ); - expect(eval("keys(first(units))"), ['id', 'hours', 'status']); + expect(eval('keys(first(units))'), ['id', 'hours', 'status']); expect(eval("values(obj('a', 1))"), [1]); }); diff --git a/test/gradient_repeat_test.dart b/test/gradient_repeat_test.dart index e90f0073..ee8e28c1 100644 --- a/test/gradient_repeat_test.dart +++ b/test/gradient_repeat_test.dart @@ -70,7 +70,9 @@ void main() { // (255) then black (0), every 10 px, at both widths. reds = [ for (final int x in const [5, 15, 25, 35, 45, 55]) - data.getUint8(((5 * image.width) + min(x, image.width - 1)) * 4), + data.getUint8( + ((5 * image.width) + min(x, image.width - 1)) * 4, + ), ]; }); return reds; From cc000a1ae48ec043270c55d9d96a6fb5788acb56 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 18:07:39 +0000 Subject: [PATCH 7/9] Text fields: bindable obscureText, one-line fields stay one line; matches() - obscureText, enabled and readOnly follow variables, conditions and set-property actions, so a show-password toggle works. - A one-line text field with a set height stayed multi-line (expands), which silently turned off obscuring and made Enter insert a newline. It now stays one line and fills its height through its decoration. - Prefix and suffix icon constraints apply when set. - New formula function matches(text, pattern) for regular expressions. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011fXBKiHRftTsgsVPHEZAWz --- .../passive_text_field_transformer.dart | 76 ++++++++---- lib/src/utils/formula.dart | 16 +++ test/formula_matches_test.dart | 24 ++++ test/text_field_bindings_test.dart | 108 ++++++++++++++++++ 4 files changed, 202 insertions(+), 22 deletions(-) create mode 100644 test/formula_matches_test.dart create mode 100644 test/text_field_bindings_test.dart diff --git a/lib/src/transformers/node_transformers/passive_text_field_transformer.dart b/lib/src/transformers/node_transformers/passive_text_field_transformer.dart index 54f7a67d..650d56c6 100644 --- a/lib/src/transformers/node_transformers/passive_text_field_transformer.dart +++ b/lib/src/transformers/node_transformers/passive_text_field_transformer.dart @@ -281,19 +281,25 @@ class _PassiveTextFieldWidgetState extends State { final TextFieldNode node = widget.node; final TextFieldProperties properties = node.properties; - final bool enabled = - context.getNodeValue(node.id, 'enabled') ?? properties.enabled; - final bool obscureText = - context.getNodeValue(node.id, 'obscureText') ?? properties.obscureText; - final bool readOnly = - context.getNodeValue(node.id, 'readOnly') ?? properties.readOnly; + final ScopedValues scopedValues = ScopedValues.of(context); + + // Bindable: a variable, condition or set-property action can change them + // (e.g. a show-password toggle). + bool flag(String property, bool fallback) => + PropertyValueDelegate.getPropertyValue( + node, + property, + scopedValues: scopedValues, + ) ?? + fallback; + final bool enabled = flag('enabled', properties.enabled); + final bool obscureText = flag('obscureText', properties.obscureText); + final bool readOnly = flag('readOnly', properties.readOnly); final bool showCursor = context.getNodeValue(node.id, 'showCursor') ?? properties.showCursor; final int? maxLength = context.getNodeValue(node.id, 'maxLength') ?? properties.maxLength; - final ScopedValues scopedValues = ScopedValues.of(context); - final TextInputType keyboardType = properties.keyboardType == TextInputTypeEnum.number ? TextInputType.numberWithOptions( @@ -310,13 +316,27 @@ class _PassiveTextFieldWidgetState extends State { !widget.settings.isPreview && (widget.autofocus ?? properties.autoFocus); + // A field with a set height fills it. A one-line field stays one line + // (so it can obscure its text and Enter submits it) and fills the height + // through its decoration; only multi-line fields expand. + final bool fillsHeight = !widget.node.isVerticalWrap; + final bool oneLine = + properties.maxLines == 1 && (properties.minLines ?? 1) <= 1; + final bool expands = fillsHeight && !oneLine; final (int? maxLines, int? minLines) = resolveTextFieldLines( maxLines: properties.maxLines, minLines: properties.minLines, - expands: !widget.node.isVerticalWrap, + expands: expands, + ); + final InputDecoration decoration = getDecoration( + context, + node, + properties.decoration, + widget.settings, + scopedValues, ); - Widget field = TextFormField( + Widget buildField(InputDecoration decoration) => TextFormField( focusNode: focusNode, autocorrect: properties.autoCorrect, autofocus: autofocus, @@ -352,7 +372,7 @@ class _PassiveTextFieldWidgetState extends State { ], maxLines: maxLines, minLines: minLines, - expands: !widget.node.isVerticalWrap, + expands: expands, obscuringCharacter: widget.node.properties.obscuringCharacter, style: TextUtils.retrieveTextStyleFromProp( widget.node.properties.inputStyle, @@ -363,15 +383,23 @@ class _PassiveTextFieldWidgetState extends State { onFieldSubmitted: (value) { // handled by the focus listener! }, - decoration: getDecoration( - context, - node, - properties.decoration, - widget.settings, - scopedValues, - ), + decoration: decoration, ); + Widget field = fillsHeight && oneLine + ? LayoutBuilder( + builder: (context, constraints) => buildField( + constraints.hasBoundedHeight + ? decoration.copyWith( + constraints: BoxConstraints.tightFor( + height: constraints.maxHeight, + ), + ) + : decoration, + ), + ) + : buildField(decoration); + if (node.isHorizontalWrap) { field = IntrinsicWidth(child: field); } @@ -519,8 +547,10 @@ class _PassiveTextFieldWidgetState extends State { ), iconModel: decoration.prefixIcon, ), - // prefixIconConstraints: - // decoration.prefixIconConstraints.flutterConstraints, + // Unset constraints keep Flutter's 48 px icon slot. + prefixIconConstraints: decoration.prefixIconConstraints.isEmpty + ? null + : decoration.prefixIconConstraints.flutterConstraints, prefix: prefixText, prefixStyle: TextUtils.retrieveTextStyleFromProp(decoration.prefixStyle), suffixIcon: @@ -536,8 +566,10 @@ class _PassiveTextFieldWidgetState extends State { ), suffix: suffixText, suffixStyle: TextUtils.retrieveTextStyleFromProp(decoration.suffixStyle), - // suffixIconConstraints: - // decoration.suffixIconConstraints.flutterConstraints, + // Unset constraints keep Flutter's 48 px icon slot. + suffixIconConstraints: decoration.suffixIconConstraints.isEmpty + ? null + : decoration.suffixIconConstraints.flutterConstraints, counter: decoration.showCounter ? decoration.counterText?.isNotEmpty == true ? counterText diff --git a/lib/src/utils/formula.dart b/lib/src/utils/formula.dart index 9096d816..1093f491 100644 --- a/lib/src/utils/formula.dart +++ b/lib/src/utils/formula.dart @@ -233,6 +233,11 @@ class FormulaEvaluator { 'endsWith(text, suffix)', 'endsWith("photo.png", ".png") → true', ), + FormulaFunctionDoc( + 'matches', + 'matches(text, pattern)', + 'matches(email, "^[^@ ]+@[^@ ]+[.][^@ .]+\$") → true', + ), FormulaFunctionDoc( 'filter', 'filter(list, item => condition) or filter(list, field, value)', @@ -924,6 +929,17 @@ final Map _functions = { final String s => s.endsWith(FormulaEvaluator.stringify(_arg(a, 1))), _ => false, }, + // A pattern that doesn't compile matches nothing. + 'matches': (a) { + final Object? text = _arg(a, 0); + if (text == null) return false; + try { + return RegExp(FormulaEvaluator.stringify(_arg(a, 1))) + .hasMatch(FormulaEvaluator.stringify(text)); + } on FormatException { + return false; + } + }, 'contains': (a) => switch (_arg(a, 0)) { final String s => s.contains(FormulaEvaluator.stringify(_arg(a, 1))), final Iterable l => l.any((v) => _looseEquals(v, _arg(a, 1))), diff --git a/test/formula_matches_test.dart b/test/formula_matches_test.dart new file mode 100644 index 00000000..8de929aa --- /dev/null +++ b/test/formula_matches_test.dart @@ -0,0 +1,24 @@ +import 'package:codelessly_sdk/codelessly_sdk.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + Object? eval(String expression, [Map vars = const {}]) => + FormulaEvaluator.evaluate(expression, (path) => vars[path]); + + test('matches tests text against a regular expression', () { + const String email = "'^[^@ ]+@[^@ ]+[.][^@ .]+\$'"; + expect(eval('matches(e, $email)', {'e': 'dana@gradeline.co'}), isTrue); + expect(eval('matches(e, $email)', {'e': 'dana@gradeline'}), isFalse); + expect(eval('matches(e, $email)', {'e': 'dana @x.co'}), isFalse); + expect(eval("matches('EX-102', '^[A-Z]{2}-[0-9]+\$')"), isTrue); + }); + + test('matches is false for a missing value or a broken pattern', () { + expect(eval("matches(missing, '.*')"), isFalse); + expect(eval("matches('abc', '([')"), isFalse); + }); + + test('matches reads numbers as text', () { + expect(eval("matches(n, '^[0-9]+[.][0-9]\$')", {'n': 12.5}), isTrue); + }); +} diff --git a/test/text_field_bindings_test.dart b/test/text_field_bindings_test.dart new file mode 100644 index 00000000..f0f0d3dc --- /dev/null +++ b/test/text_field_bindings_test.dart @@ -0,0 +1,108 @@ +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:codelessly_sdk/codelessly_sdk.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; + +TextFieldNode field({ + Map variables = const {}, + bool obscureText = false, + BoxConstraintsModel prefixIconConstraints = const BoxConstraintsModel(), +}) => TextFieldNode( + id: 'field', + name: 'Field', + basicBoxLocal: NodeBox(0, 0, 300, 48), + properties: TextFieldProperties( + obscureText: obscureText, + decoration: InputDecorationModel( + prefixIconConstraints: prefixIconConstraints, + ), + ), +)..variables.addAll(variables); + +Future pump(WidgetTester tester, TextFieldNode node) async { + final registry = NodeRegistry()..setNodes({node.id: node}); + final manager = PassiveNodeTransformerManager( + registry.getNodeByID, + (context, bounds, pageID, layoutID, canvasID) => const SizedBox(), + ); + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: CodelesslyContext.empty(), + child: MaterialApp( + home: Scaffold( + body: Builder( + builder: (context) => manager.buildWidgetFromNode( + node, + context, + settings: const WidgetBuildSettings(debugLabel: 'test'), + ), + ), + ), + ), + ), + ); + return tester.widget(find.byType(TextField)); +} + +void main() { + testWidgets('obscureText follows its binding (a show-password toggle)', ( + WidgetTester tester, + ) async { + expect( + (await pump( + tester, + field(variables: {'obscureText': r'${= 2 > 1}'}), + )).obscureText, + isTrue, + ); + expect( + (await pump( + tester, + field(obscureText: true, variables: {'obscureText': r'${= 1 > 2}'}), + )).obscureText, + isFalse, + ); + }); + + testWidgets('a one-line field with a set height stays one line and fills ' + 'its height', (WidgetTester tester) async { + final TextField built = await pump(tester, field(obscureText: true)); + expect(built.maxLines, 1); + expect(built.expands, isFalse); + expect(built.obscureText, isTrue); + expect(tester.getSize(find.byType(InputDecorator)).height, 48); + }); + + testWidgets('enabled and readOnly follow their bindings', ( + WidgetTester tester, + ) async { + final TextField built = await pump( + tester, + field(variables: {'enabled': r'${= 1 > 2}', 'readOnly': r'${= 2 > 1}'}), + ); + expect(built.enabled, isFalse); + expect(built.readOnly, isTrue); + }); + + testWidgets('prefix icon constraints apply when set', ( + WidgetTester tester, + ) async { + expect( + (await pump(tester, field())).decoration?.prefixIconConstraints, + isNull, + ); + expect( + (await pump( + tester, + field( + prefixIconConstraints: const BoxConstraintsModel( + minWidth: 40, + minHeight: 0, + ), + ), + )).decoration?.prefixIconConstraints?.minWidth, + 40, + ); + }); +} From c35c179963d2ce1295edc8acf2a1611cc6accaa6 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 18:09:06 +0000 Subject: [PATCH 8/9] Delay actions wait as long as their duration formula says Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011fXBKiHRftTsgsVPHEZAWz --- lib/src/functions/functions_repository.dart | 24 ++++++++++- test/delay_duration_test.dart | 45 +++++++++++++++++++++ 2 files changed, 68 insertions(+), 1 deletion(-) create mode 100644 test/delay_duration_test.dart diff --git a/lib/src/functions/functions_repository.dart b/lib/src/functions/functions_repository.dart index 14a271ee..0c1bc3fa 100644 --- a/lib/src/functions/functions_repository.dart +++ b/lib/src/functions/functions_repository.dart @@ -140,7 +140,12 @@ class FunctionsRepository { return runConfirm(context, action as ConfirmAction); case ActionType.delay: return Future.delayed( - Duration(milliseconds: (action as DelayAction).milliseconds), + Duration( + milliseconds: delayMilliseconds( + action as DelayAction, + ActionChain.scopedValuesOf(context), + ), + ), () => true, ); case ActionType.cloudData: @@ -150,6 +155,23 @@ class FunctionsRepository { } } + /// How long [action] waits: its [DelayAction.duration] formula when that + /// gives a number, else its fixed [DelayAction.milliseconds]. + static int delayMilliseconds(DelayAction action, ScopedValues scopedValues) { + final String? duration = action.duration; + if (duration == null || duration.trim().isEmpty) { + return action.milliseconds; + } + final num? value = num.tryParse( + PropertyValueDelegate.substituteVariables( + duration, + scopedValues: scopedValues, + nullSubstitutionMode: NullSubstitutionMode.emptyString, + ).trim(), + ); + return value == null || value < 0 ? action.milliseconds : value.round(); + } + /// Runs a [CloudDataAction] and puts its result in its result variable /// and in `${result}` for the next actions. A failure (a required field /// left empty, a missing permission, ...) stops the chain. diff --git a/test/delay_duration_test.dart b/test/delay_duration_test.dart new file mode 100644 index 00000000..5383f697 --- /dev/null +++ b/test/delay_duration_test.dart @@ -0,0 +1,45 @@ +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:codelessly_sdk/codelessly_sdk.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + ScopedValues scope(String mode) => ScopedValues.preview( + variables: { + 'networkMode': VariableData( + id: 'n', + name: 'networkMode', + value: mode, + type: VariableType.text, + ), + }, + ); + final DelayAction latency = DelayAction( + milliseconds: 900, + duration: r"${= networkMode == 'slow' ? 4000 : 900}", + ); + + test('a delay waits as long as its duration formula says', () { + expect(FunctionsRepository.delayMilliseconds(latency, scope('slow')), 4000); + expect( + FunctionsRepository.delayMilliseconds(latency, scope('normal')), + 900, + ); + }); + + test('a delay without a usable formula waits its fixed time', () { + expect( + FunctionsRepository.delayMilliseconds( + DelayAction(milliseconds: 300), + scope('slow'), + ), + 300, + ); + expect( + FunctionsRepository.delayMilliseconds( + DelayAction(milliseconds: 300, duration: r'${missing}'), + scope('slow'), + ), + 300, + ); + }); +} From a0181b4296451cd2be453c86d0048318ffe4239c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 23:27:18 +0000 Subject: [PATCH 9/9] codelessly_sdk 8.3.0 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011fXBKiHRftTsgsVPHEZAWz --- CHANGELOG.md | 42 ++++++++++++++++++++++++++++++++++++++++++ pubspec.yaml | 4 ++-- 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fc42932b..456309c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,45 @@ +## 8.3.0 + +Requires codelessly_api 6.2.0. + +### Formulas + +- Computed variables: a variable whose value is a `${= ...}` formula is + evaluated when read, and widgets bound to it update when the variables + it uses change. Cycles resolve to null. +- New functions: `obj`, `set`, `merge`, `keys`, `values`, `range`, + `slice`, `first`, `last`, `concat`, `indexOf`, `daysInMonth`, `padLeft`, + `padRight`, `substring`, `split`, `replace`, `coalesce`, `formatNumber` + and `matches` (regular expressions); `money` takes a digit count. +- On the web, the page URL's query parameters are part of `route` + (`${route.date}`); navigation params of the same name win. + +### Rendering + +- Linear gradients with `gradientRepeatEvery` repeat at a fixed pixel + period (`RepeatingGradientTransform`). +- Arc progress bars (gauges) with ticks, needle and zone bands + (`ArcProgressPainter`). +- Dialog canvases open as bottom sheets, adaptively (sheet below 600 px), + or aligned to a side or corner of the screen. + +### Text fields + +- `obscureText`, `enabled` and `readOnly` follow variables, conditions and + set-property actions. +- A one-line field with a set height stays one line (it can obscure its + text and Enter submits it) and fills its height through its decoration. +- Prefix and suffix icon constraints apply when set. + +### Actions + +- Delay actions wait as long as their `duration` formula says. + +### Fixes + +- An API document without a directory no longer fails to load + (`HttpApiData.directory` defaults to the root). + ## 8.2.0 ### States diff --git a/pubspec.yaml b/pubspec.yaml index ea7839c4..fb46ccb2 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: codelessly_sdk description: Codelessly CloudUI™ - Server-driven UI for Flutter. Supercharge your Flutter apps with dynamic UI and real-time updates. Build and publish UI without code! -version: 8.2.0 +version: 8.3.0 homepage: https://codelessly.com/ repository: https://github.com/Codelessly/CodelesslySDK issue_tracker: https://github.com/Codelessly/CodelesslySDK/issues @@ -28,7 +28,7 @@ dependencies: flutter: sdk: flutter # Codelessly - codelessly_api: ^6.1.0 + codelessly_api: ^6.2.0 json_annotation: ^4.12.0 # Firebase