From 897a781f4c315e445ecf1794942864e414876406 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 22:19:39 +0000 Subject: [PATCH 01/12] Add record functions to formulas obj, set, unset, merge, append, prepend, concat, updateWhere, removeWhere, findIndex, indexOf, slice, take, skip, first, last, range and newId let a setVariable change a record inside a list variable, which formulas couldn't express because they can't contain braces. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0147gqWiq3xoEdAGwWSpq9JK --- CHANGELOG.md | 12 ++ lib/src/utils/formula.dart | 200 +++++++++++++++++++++++++++++++++ test/formula_records_test.dart | 108 ++++++++++++++++++ 3 files changed, 320 insertions(+) create mode 100644 test/formula_records_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index fc42932b..1b688dab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,15 @@ +## Unreleased + +### Formulas + +- Record functions build and change lists and maps, which formulas couldn't + do because they can't contain braces: `obj`, `set`, `unset`, `merge`, + `append`, `prepend`, `concat`, `updateWhere`, `removeWhere`, `findIndex`, + `indexOf`, `slice`, `take`, `skip`, `first`, `last`, `range` and `newId`. + They return new values, so a setVariable can write a nested change back, + e.g. `updateWhere(logs, l => l.id == logId, l => set(l, 'crew', + append(l.crew, entry)))`. + ## 8.2.0 ### States diff --git a/lib/src/utils/formula.dart b/lib/src/utils/formula.dart index 99ac5af5..87484919 100644 --- a/lib/src/utils/formula.dart +++ b/lib/src/utils/formula.dart @@ -38,6 +38,13 @@ typedef FormulaPathResolver = Object? Function(String path); /// `t.tags[0]`, `t.tags.length`) and hides a variable of the same name; /// the body can read every other variable. Lambdas are only allowed as /// function arguments. +/// - record functions that build and change lists and maps without braces +/// (which formulas can't contain): obj, set, unset, merge, append, +/// prepend, concat, updateWhere, removeWhere, findIndex, indexOf, slice, +/// take, skip, first, last, range and newId. They return new values and +/// never change their arguments, so a setVariable can write the result +/// back, e.g. `updateWhere(logs, l => l.id == logId, l => set(l, 'crew', +/// append(l.crew, entry)))`. /// - 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 @@ -267,6 +274,69 @@ class FormulaEvaluator { 'without(list, other)', 'without(all, picked)', ), + FormulaFunctionDoc( + 'obj', + 'obj(key, value, key, value, …)', + 'obj("name", name, "qty", 1) → {name: Tea, qty: 1}', + ), + FormulaFunctionDoc( + 'set', + 'set(map, key, value) or set(list, index, value)', + 'set(task, "done", true)', + ), + FormulaFunctionDoc('unset', 'unset(map, key)', 'unset(task, "draft")'), + FormulaFunctionDoc( + 'merge', + 'merge(map, other, …)', + 'merge(task, obj("done", true, "doneAt", now()))', + ), + FormulaFunctionDoc( + 'append', + 'append(list, item, …)', + 'append(tasks, obj("title", draft))', + ), + FormulaFunctionDoc( + 'prepend', + 'prepend(list, item)', + 'prepend(notes, note)', + ), + FormulaFunctionDoc('concat', 'concat(list, other, …)', 'concat(a, b)'), + FormulaFunctionDoc( + 'updateWhere', + 'updateWhere(list, item => condition, item => newItem) or ' + 'updateWhere(list, field, value, item => newItem or map to merge)', + 'updateWhere(tasks, "id", id, t => set(t, "done", !t.done))', + ), + FormulaFunctionDoc( + 'removeWhere', + 'removeWhere(list, item => condition) or removeWhere(list, field, value)', + 'removeWhere(tasks, "id", id)', + ), + FormulaFunctionDoc( + 'findIndex', + 'findIndex(list, item => condition) or findIndex(list, field, value)', + 'findIndex(tasks, "id", id) → 2 (-1 when missing)', + ), + FormulaFunctionDoc( + 'indexOf', + 'indexOf(list or text, value)', + 'indexOf(["a", "b"], "b") → 1', + ), + FormulaFunctionDoc( + 'slice', + 'slice(list or text, start, end)', + 'slice(logs, 0, 10)', + ), + FormulaFunctionDoc('take', 'take(list, count)', 'take(logs, pageSize)'), + FormulaFunctionDoc('skip', 'skip(list, count)', 'skip(logs, 10)'), + FormulaFunctionDoc('first', 'first(list)', 'first(tasks)'), + FormulaFunctionDoc('last', 'last(list)', 'last(tasks)'), + FormulaFunctionDoc( + 'range', + 'range(count) or range(start, end)', + 'range(3) → [0, 1, 2]', + ), + FormulaFunctionDoc('newId', 'newId(prefix)', 'newId("task") → task_k3x9f2'), FormulaFunctionDoc('number', 'number(value)', 'number("42") → 42'), FormulaFunctionDoc('text', 'text(value)', 'text(42) → "42"'), FormulaFunctionDoc( @@ -783,6 +853,86 @@ String? _formatDate(Object? value, Object? pattern) { typedef _Function = Object? Function(List args); +/// [value] as a list: a copy of a list, an empty list for null, else a list +/// of that one value. +List _listOf(Object? value) => switch (value) { + null => [], + final Iterable l => [...l], + _ => [value], +}; + +/// [value] as a map with text keys: a copy of a map, else empty. +Map _mapOf(Object? value) => switch (value) { + final Map m => {for (final e in m.entries) '${e.key}': e.value}, + _ => {}, +}; + +/// A copy of [target] with [key] set to [value]: a map key, or a list index +/// (an index past the end appends). +Object? _set(Object? target, Object? key, Object? value) { + if (target is Iterable) { + final List list = [...target]; + final int? index = _toNum(key)?.toInt(); + if (index == null || index < 0) return list; + if (index >= list.length) return list..add(value); + return list..[index] = value; + } + return _mapOf(target)..[FormulaEvaluator.stringify(key)] = value; +} + +/// `updateWhere(list, predicate…, update)`: items matching the predicate +/// (a lambda, or a field and value) are replaced by `update(item)` when it +/// is a lambda, or merged with it when it is a map. +List _updateWhere(List args) { + if (args.length < 3) return _listOf(_arg(args, 0)); + final Object? update = args.last; + final List predicate = args.sublist(1, args.length - 1); + return [ + for (final item in _values(_arg(args, 0), null)) + if (!_matches(item, predicate)) + item + else if (update is _Closure) + update(item) + else if (update is Map) + {..._mapOf(item), ..._mapOf(update)} + else + update, + ]; +} + +int _clampIndex(Object? value, int length, int fallback) { + if (value == null) return fallback; + final num? n = _toNum(value); + if (n == null || !n.isFinite) return fallback; + final int i = n.toInt(); + return (i < 0 ? length + i : i).clamp(0, length); +} + +Object? _slice(Object? target, Object? start, Object? end) { + if (target is String) { + final int from = _clampIndex(start, target.length, 0); + final int to = _clampIndex(end, target.length, target.length); + return to <= from ? '' : target.substring(from, to); + } + final List list = _listOf(target); + final int from = _clampIndex(start, list.length, 0); + final int to = _clampIndex(end, list.length, list.length); + return to <= from ? [] : list.sublist(from, to); +} + +final math.Random _idRandom = math.Random(); + +/// A short random id, such as `log_k3x9f2a1`, for new records. +String _newId(Object? prefix) { + const String chars = 'abcdefghijklmnopqrstuvwxyz0123456789'; + final String id = String.fromCharCodes([ + for (int i = 0; i < 8; i++) + chars.codeUnitAt(_idRandom.nextInt(chars.length)), + ]); + final String p = FormulaEvaluator.stringify(prefix); + return p.isEmpty ? id : '${p}_$id'; +} + Object? _arg(List args, int index) => index < args.length ? args[index] : null; @@ -850,6 +1000,56 @@ final Map _functions = { if (!_values(_arg(a, 1), null).any((other) => _looseEquals(item, other))) item, ], + 'obj': (a) => { + for (int i = 0; i + 1 < a.length; i += 2) + FormulaEvaluator.stringify(a[i]): a[i + 1], + }, + 'set': (a) => _set(_arg(a, 0), _arg(a, 1), _arg(a, 2)), + 'unset': (a) => + _mapOf(_arg(a, 0))..remove(FormulaEvaluator.stringify(_arg(a, 1))), + 'merge': (a) => {for (final m in a) ..._mapOf(m)}, + 'append': (a) => [..._listOf(_arg(a, 0)), ...a.skip(1)], + 'prepend': (a) => [_arg(a, 1), ..._listOf(_arg(a, 0))], + 'concat': (a) => [for (final l in a) ..._listOf(l)], + 'updateWhere': _updateWhere, + 'removeWhere': (a) { + final List rest = a.skip(1).toList(); + return [ + for (final item in _values(_arg(a, 0), null)) + if (!_matches(item, rest)) item, + ]; + }, + 'findIndex': (a) { + final List rest = a.skip(1).toList(); + final List list = _listOf(_arg(a, 0)); + return list.indexWhere((item) => _matches(item, rest)); + }, + 'indexOf': (a) => switch (_arg(a, 0)) { + final String s => s.indexOf(FormulaEvaluator.stringify(_arg(a, 1))), + final Iterable l => l.toList().indexWhere( + (v) => _looseEquals(v, _arg(a, 1)), + ), + _ => -1, + }, + 'slice': (a) => _slice(_arg(a, 0), _arg(a, 1), _arg(a, 2)), + 'take': (a) => _slice( + _arg(a, 0), + 0, + _num(_arg(a, 1)).isFinite ? _num(_arg(a, 1)).clamp(0, double.infinity) : 0, + ), + 'skip': (a) => _slice( + _arg(a, 0), + _num(_arg(a, 1)).isFinite ? _num(_arg(a, 1)).clamp(0, double.infinity) : 0, + null, + ), + 'first': (a) => _listOf(_arg(a, 0)).firstOrNull, + 'last': (a) => _listOf(_arg(a, 0)).lastOrNull, + 'range': (a) { + final int start = a.length < 2 ? 0 : _intArg(_arg(a, 0)); + final int end = _intArg(a.length < 2 ? _arg(a, 0) : _arg(a, 1)); + return [for (int i = start; i < end; i++) i]; + }, + 'newId': (a) => _newId(_arg(a, 0)), 'number': (a) => _toNum(_arg(a, 0)), 'text': (a) => FormulaEvaluator.stringify(_arg(a, 0)), 'isEmpty': (a) => !_truthy(_functions['length']!(a)), diff --git a/test/formula_records_test.dart b/test/formula_records_test.dart new file mode 100644 index 00000000..786e2d8c --- /dev/null +++ b/test/formula_records_test.dart @@ -0,0 +1,108 @@ +import 'package:codelessly_sdk/codelessly_sdk.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + final Map vars = { + 'logId': 'l2', + 'crewId': 'c2', + 'logs': [ + { + 'id': 'l1', + 'crew': [ + {'id': 'c1', 'n': 3}, + ], + }, + { + 'id': 'l2', + 'crew': [ + {'id': 'c1', 'n': 2}, + {'id': 'c2', 'n': 5}, + ], + }, + ], + 'tags': ['a', 'b', 'c', 'd'], + 'name': 'Keystone', + }; + + Object? eval(String expression) => FormulaEvaluator.evaluate(expression, (path) { + Object? value = vars; + for (final part in path.split('.')) { + value = value is Map ? value[part] : null; + } + return value; + }); + + test('obj, set, unset and merge build maps without braces', () { + expect(eval('obj("company", name, "n", 4)'), {'company': 'Keystone', 'n': 4}); + expect(eval('set(obj("a", 1), "b", 2)'), {'a': 1, 'b': 2}); + expect(eval('unset(obj("a", 1, "b", 2), "a")'), {'b': 2}); + expect(eval('merge(obj("a", 1, "b", 2), obj("b", 3), obj("c", 4))'), {'a': 1, 'b': 3, 'c': 4}); + expect(eval('set(tags, 1, "x")'), ['a', 'x', 'c', 'd']); + expect(eval('set(tags, 9, "z")'), ['a', 'b', 'c', 'd', 'z']); + }); + + test('append, prepend and concat return new lists', () { + expect(eval('append(tags, "e", "f")'), ['a', 'b', 'c', 'd', 'e', 'f']); + expect(eval('prepend(tags, "z")'), ['z', 'a', 'b', 'c', 'd']); + expect(eval('concat(tags, ["e"], null)'), ['a', 'b', 'c', 'd', 'e']); + expect(eval('append(null, 1)'), [1]); + expect(vars['tags'], ['a', 'b', 'c', 'd'], reason: 'arguments are not changed'); + }); + + test('updateWhere and removeWhere change matching records', () { + expect( + eval('updateWhere(logs, "id", logId, obj("signed", true))'), + [ + (vars['logs'] as List)[0], + {...((vars['logs'] as List)[1] as Map), 'signed': true}, + ], + ); + // A nested update: one crew entry inside one log. + final Object? result = eval( + 'updateWhere(logs, l => l.id == logId, l => set(l, "crew", ' + 'updateWhere(l.crew, c => c.id == crewId, c => set(c, "n", c.n + 1))))', + ); + expect(((result as List)[1] as Map)['crew'], [ + {'id': 'c1', 'n': 2}, + {'id': 'c2', 'n': 6}, + ]); + expect((result[0] as Map)['crew'], [ + {'id': 'c1', 'n': 3}, + ]); + expect(eval('length(removeWhere(logs, "id", "l1"))'), 1); + expect(eval('removeWhere(tags, t => t == "b" || t == "c")'), ['a', 'd']); + }); + + test('findIndex, indexOf, slice, take, skip, first, last, range', () { + expect(eval('findIndex(logs, "id", "l2")'), 1); + expect(eval('findIndex(logs, l => l.id == "nope")'), -1); + expect(eval('indexOf(tags, "c")'), 2); + expect(eval('indexOf("hardhat", "hat")'), 4); + expect(eval('slice(tags, 1, 3)'), ['b', 'c']); + expect(eval('slice(tags, -2)'), ['c', 'd']); + expect(eval('slice("Thursday", 0, 3)'), 'Thu'); + expect(eval('take(tags, 2)'), ['a', 'b']); + expect(eval('take(tags, 10)'), ['a', 'b', 'c', 'd']); + expect(eval('skip(tags, 3)'), ['d']); + expect(eval('first(tags)'), 'a'); + expect(eval('last(tags)'), 'd'); + expect(eval('first(null)'), isNull); + expect(eval('range(3)'), [0, 1, 2]); + expect(eval('range(2, 5)'), [2, 3, 4]); + }); + + test('newId makes distinct prefixed ids', () { + final Object? a = eval('newId("crew")'); + final Object? b = eval('newId("crew")'); + expect(a, matches(RegExp(r'^crew_[a-z0-9]{8}$'))); + expect(a, isNot(b)); + expect(eval('newId()'), matches(RegExp(r'^[a-z0-9]{8}$'))); + }); + + test('every function is documented', () { + expect( + FormulaEvaluator.functions.toSet(), + FormulaEvaluator.functionDocs.map((d) => d.name).toSet(), + ); + }); +} From 454ff0ab223a23503b0a34f00d709127d7503a66 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 23:12:39 +0000 Subject: [PATCH 02/12] Bind which icon an icon node shows; add thousands() iconName (bound, from a condition or set by an action) swaps the icon for the Material Symbol of that name, keeping its style, so list items can each show their own icon. thousands(value, decimals) groups digits without money's symbol and cents. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0147gqWiq3xoEdAGwWSpq9JK --- CHANGELOG.md | 9 +++ .../passive_icon_transformer.dart | 42 +++++++++- lib/src/utils/formula.dart | 25 ++++-- test/formula_records_test.dart | 9 +++ test/icon_name_binding_test.dart | 76 +++++++++++++++++++ 5 files changed, 154 insertions(+), 7 deletions(-) create mode 100644 test/icon_name_binding_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b688dab..2766e5f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ ## Unreleased +### Icons + +- An icon's `iconName` can be bound to data, set by a condition or changed by + an action. It names a Material Symbol, so one icon node can show the + icon each list item asks for; a Material Icon becomes the symbol of the + same style. + ### Formulas - Record functions build and change lists and maps, which formulas couldn't @@ -9,6 +16,8 @@ They return new values, so a setVariable can write a nested change back, e.g. `updateWhere(logs, l => l.id == logId, l => set(l, 'crew', append(l.crew, entry)))`. +- `thousands(value, decimals)` writes a number with thousands separators, + such as `12,500`. ## 8.2.0 diff --git a/lib/src/transformers/node_transformers/passive_icon_transformer.dart b/lib/src/transformers/node_transformers/passive_icon_transformer.dart index e1fddacf..d907079a 100644 --- a/lib/src/transformers/node_transformers/passive_icon_transformer.dart +++ b/lib/src/transformers/node_transformers/passive_icon_transformer.dart @@ -35,15 +35,24 @@ class PassiveIconWidget extends StatelessWidget { @override Widget build(BuildContext context) { + final ScopedValues scopedValues = ScopedValues.of(context); final ColorRGBA? iconColor = PropertyValueDelegate.getPropertyValue( node, 'iconColor', - scopedValues: ScopedValues.of(context), + scopedValues: scopedValues, ); - final MultiSourceIconModel icon = iconColor == null + final String? iconName = PropertyValueDelegate.getPropertyValue( + node, + 'iconName', + scopedValues: scopedValues, + ); + MultiSourceIconModel icon = iconColor == null ? node.properties.icon : node.properties.icon.copyWith(color: iconColor); + if (iconName != null && iconName.trim().isNotEmpty) { + icon = withIconName(icon, iconName.trim()); + } return retrieveIconWidget(icon, node.basicBoxLocal.width) ?? SizedBox( width: node.basicBoxLocal.width, @@ -51,3 +60,32 @@ class PassiveIconWidget extends StatelessWidget { ); } } + +/// [icon] showing the Material Symbol [name] instead, for an `iconName` +/// bound to data. Material Symbols load by name alone (Material Icons need +/// each icon's version), so a Material Icon becomes the Material Symbol of +/// the same style, filled when it was filled. +MultiSourceIconModel withIconName(MultiSourceIconModel icon, String name) { + final IconModel? current = icon.icon; + if (current is MaterialSymbol) { + return icon.copyWith(icon: current.copyWith(name: name), show: true); + } + final MaterialIconStyle? style = current is MaterialIcon + ? current.style + : null; + return icon.copyWith( + type: IconTypeEnum.icon, + show: true, + icon: MaterialSymbol( + codepoint: 0, + fontFamily: 'MaterialSymbols', + name: name, + style: switch (style) { + MaterialIconStyle.rounded => MaterialSymbolStyle.rounded, + MaterialIconStyle.sharp => MaterialSymbolStyle.sharp, + _ => MaterialSymbolStyle.outlined, + }, + ), + fill: style == MaterialIconStyle.filled ? 1 : icon.fill, + ); +} diff --git a/lib/src/utils/formula.dart b/lib/src/utils/formula.dart index 87484919..9b0a7079 100644 --- a/lib/src/utils/formula.dart +++ b/lib/src/utils/formula.dart @@ -25,7 +25,7 @@ typedef FormulaPathResolver = Object? Function(String path); /// the same rules as `${path}`, followed by `[index]` or `.key`. /// - `+ - * / %`, comparisons `== != < <= > >=`, `&& || !` and /// `condition ? a : b`. `+` joins text when either side is text. -/// - functions: money, fixed, round, floor, ceil, abs, min, max, sum, count, +/// - functions: money, fixed, thousands, 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 /// [FormulaEvaluator.functions]. @@ -175,6 +175,11 @@ class FormulaEvaluator { 'fixed(value, decimals)', 'fixed(4.5, 2) → 4.50', ), + FormulaFunctionDoc( + 'thousands', + 'thousands(value, decimals)', + 'thousands(12500) → 12,500', + ), FormulaFunctionDoc( 'round', 'round(value, decimals)', @@ -513,15 +518,24 @@ String _fixed(Object? value, Object? digits) => _num(value).toStringAsFixed(_num(digits ?? 2).toInt().clamp(0, 20)); String _money(Object? value, Object? symbol) { + final String? text = _grouped(value, 2); + if (text == null) return ''; + final bool negative = text.startsWith('-'); + return '${negative ? '-' : ''}${symbol ?? r'$'}${negative ? text.substring(1) : text}'; +} + +/// [value] with [decimals] digits and a comma between thousands, such as +/// `1,234` or `-12,500.50`. Null when it isn't a number. +String? _grouped(Object? value, int decimals) { final num amount = _num(value); - if (amount.isNaN) return ''; + if (amount.isNaN || amount.isInfinite) return null; final String sign = amount < 0 ? '-' : ''; - final String text = amount.abs().toStringAsFixed(2); - final int dot = text.indexOf('.'); + final String text = amount.abs().toStringAsFixed(decimals.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 '$sign$whole${text.substring(dot)}'; } num _round(Object? value, Object? digits) { @@ -939,6 +953,7 @@ Object? _arg(List args, int index) => final Map _functions = { 'money': (a) => _money(_arg(a, 0), _arg(a, 1)), 'fixed': (a) => _fixed(_arg(a, 0), _arg(a, 1)), + 'thousands': (a) => _grouped(_arg(a, 0), _intArg(_arg(a, 1))), 'round': (a) => _round(_arg(a, 0), _arg(a, 1)), 'floor': (a) => _num(_arg(a, 0)).floor(), 'ceil': (a) => _num(_arg(a, 0)).ceil(), diff --git a/test/formula_records_test.dart b/test/formula_records_test.dart index 786e2d8c..dc9b7f37 100644 --- a/test/formula_records_test.dart +++ b/test/formula_records_test.dart @@ -99,6 +99,15 @@ void main() { expect(eval('newId()'), matches(RegExp(r'^[a-z0-9]{8}$'))); }); + test('thousands groups digits; money still shows cents', () { + expect(eval('thousands(921)'), '921'); + expect(eval('thousands(12500)'), '12,500'); + expect(eval('thousands(-1234567.891, 2)'), '-1,234,567.89'); + expect(eval('thousands("x")'), isNull); + expect(eval('money(1234.5)'), r'$1,234.50'); + expect(eval('money(-5, "€")'), '-€5.00'); + }); + test('every function is documented', () { expect( FormulaEvaluator.functions.toSet(), diff --git a/test/icon_name_binding_test.dart b/test/icon_name_binding_test.dart new file mode 100644 index 00000000..d1727bcb --- /dev/null +++ b/test/icon_name_binding_test.dart @@ -0,0 +1,76 @@ +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:codelessly_sdk/codelessly_sdk.dart'; +import 'package:codelessly_sdk/src/transformers/node_transformers/passive_icon_transformer.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + MultiSourceIconModel model(IconModel icon) => + MultiSourceIconModel(icon: icon, show: true, size: 18); + + test('a Material Icon shows the named Material Symbol in the same style', () { + final MultiSourceIconModel icon = withIconName( + model(MaterialIcon( + codepoint: 0xe0c3, + fontFamily: 'MaterialIcons', + name: 'help_outline', + style: MaterialIconStyle.rounded, + version: 12, + )), + 'water_drop', + ); + expect(icon.icon, isA()); + expect(icon.icon!.toSvgUrl(), contains('/materialsymbolsrounded/water_drop/')); + expect(icon.size, 18); + expect(icon.show, isTrue); + }); + + test('a filled Material Icon becomes a filled outlined symbol', () { + final MultiSourceIconModel icon = withIconName( + model(MaterialIcon( + codepoint: 0, + fontFamily: 'MaterialIcons', + name: 'add', + style: MaterialIconStyle.filled, + )), + 'thunderstorm', + ); + expect(icon.fill, 1); + expect( + icon.icon!.toSvgUrl(fill: icon.fill), + contains('/materialsymbolsoutlined/thunderstorm/fill1/'), + ); + }); + + test('a Material Symbol keeps its style and swaps its name', () { + final MultiSourceIconModel icon = withIconName( + model(MaterialSymbol( + codepoint: 0, + fontFamily: 'MaterialSymbols', + name: 'home', + style: MaterialSymbolStyle.sharp, + )), + 'air', + ); + expect(icon.icon!.toSvgUrl(), contains('/materialsymbolssharp/air/')); + }); + + test('icon nodes declare iconName and iconColor as bindable', () { + final IconNode node = IconNode( + id: 'i', + name: 'Sky', + basicBoxLocal: NodeBox(0, 0, 18, 18), + properties: IconProperties( + icon: model(MaterialIcon( + codepoint: 0, + fontFamily: 'MaterialIcons', + name: 'help', + style: MaterialIconStyle.outlined, + )), + ), + ); + expect( + node.propertyVariables.map((v) => v.name), + containsAll(['iconName', 'iconColor']), + ); + }); +} From 7196cc0114558df70a943b06f0996269b913016e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 23:13:33 +0000 Subject: [PATCH 03/12] Add reverse() to formulas Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0147gqWiq3xoEdAGwWSpq9JK --- CHANGELOG.md | 2 +- lib/src/utils/formula.dart | 7 ++++++- test/formula_records_test.dart | 2 ++ 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2766e5f8..fc7077fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,7 @@ - Record functions build and change lists and maps, which formulas couldn't do because they can't contain braces: `obj`, `set`, `unset`, `merge`, `append`, `prepend`, `concat`, `updateWhere`, `removeWhere`, `findIndex`, - `indexOf`, `slice`, `take`, `skip`, `first`, `last`, `range` and `newId`. + `indexOf`, `slice`, `take`, `skip`, `reverse`, `first`, `last`, `range` and `newId`. They return new values, so a setVariable can write a nested change back, e.g. `updateWhere(logs, l => l.id == logId, l => set(l, 'crew', append(l.crew, entry)))`. diff --git a/lib/src/utils/formula.dart b/lib/src/utils/formula.dart index 9b0a7079..57aaf6da 100644 --- a/lib/src/utils/formula.dart +++ b/lib/src/utils/formula.dart @@ -41,7 +41,7 @@ typedef FormulaPathResolver = Object? Function(String path); /// - record functions that build and change lists and maps without braces /// (which formulas can't contain): obj, set, unset, merge, append, /// prepend, concat, updateWhere, removeWhere, findIndex, indexOf, slice, -/// take, skip, first, last, range and newId. They return new values and +/// take, skip, reverse, first, last, range and newId. They return new values and /// never change their arguments, so a setVariable can write the result /// back, e.g. `updateWhere(logs, l => l.id == logId, l => set(l, 'crew', /// append(l.crew, entry)))`. @@ -334,6 +334,7 @@ class FormulaEvaluator { ), FormulaFunctionDoc('take', 'take(list, count)', 'take(logs, pageSize)'), FormulaFunctionDoc('skip', 'skip(list, count)', 'skip(logs, 10)'), + FormulaFunctionDoc('reverse', 'reverse(list or text)', 'reverse(notes)'), FormulaFunctionDoc('first', 'first(list)', 'first(tasks)'), FormulaFunctionDoc('last', 'last(list)', 'last(tasks)'), FormulaFunctionDoc( @@ -1057,6 +1058,10 @@ final Map _functions = { _num(_arg(a, 1)).isFinite ? _num(_arg(a, 1)).clamp(0, double.infinity) : 0, null, ), + 'reverse': (a) => switch (_arg(a, 0)) { + final String s => String.fromCharCodes(s.runes.toList().reversed), + final Object? other => _listOf(other).reversed.toList(), + }, 'first': (a) => _listOf(_arg(a, 0)).firstOrNull, 'last': (a) => _listOf(_arg(a, 0)).lastOrNull, 'range': (a) { diff --git a/test/formula_records_test.dart b/test/formula_records_test.dart index dc9b7f37..63cbb754 100644 --- a/test/formula_records_test.dart +++ b/test/formula_records_test.dart @@ -84,6 +84,8 @@ void main() { expect(eval('take(tags, 2)'), ['a', 'b']); expect(eval('take(tags, 10)'), ['a', 'b', 'c', 'd']); expect(eval('skip(tags, 3)'), ['d']); + expect(eval('reverse(tags)'), ['d', 'c', 'b', 'a']); + expect(eval('reverse("abc")'), 'cba'); expect(eval('first(tags)'), 'a'); expect(eval('last(tags)'), 'd'); expect(eval('first(null)'), isNull); From 231776ac8f9a72b8472cb2073cd099cf8df2b1ae Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 23:16:51 +0000 Subject: [PATCH 04/12] Bind an expanded child's flex to data A flex bound to a variable or formula, or set by a condition, replaces the node's own when a row or column shares out its free space. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0147gqWiq3xoEdAGwWSpq9JK --- CHANGELOG.md | 5 ++ .../passive_row_column_transformer.dart | 13 ++- test/flex_binding_test.dart | 86 +++++++++++++++++++ 3 files changed, 103 insertions(+), 1 deletion(-) create mode 100644 test/flex_binding_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index fc7077fd..b9d51ecd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ ## Unreleased +### Layout + +- An expanded child's `flex` can be bound to data or set by a condition, so + bars share a row by value (a gauge, a progress split). + ### Icons - An icon's `iconName` can be bound to data, set by a condition or changed by diff --git a/lib/src/transformers/node_transformers/passive_row_column_transformer.dart b/lib/src/transformers/node_transformers/passive_row_column_transformer.dart index ec502800..615281ab 100644 --- a/lib/src/transformers/node_transformers/passive_row_column_transformer.dart +++ b/lib/src/transformers/node_transformers/passive_row_column_transformer.dart @@ -1,3 +1,5 @@ +import 'dart:math' as math; + import 'package:codelessly_api/codelessly_api.dart'; import 'package:flutter/material.dart'; @@ -356,11 +358,20 @@ class PassiveRowColumnWidget extends StatelessWidget { Widget buildFlexible(BuildContext context) { final bool hidden = manager.runtimeVisibility(context, child) == false; + // A flex bound to data (a bar's share of a gauge) or set by a + // condition replaces the node's own. + final int? boundFlex = child.variables.containsKey('flex') + ? PropertyValueDelegate.getPropertyValue( + child, + 'flex', + scopedValues: ScopedValues.of(context), + ) + : null; // Without flex the hidden child would be laid out with unbounded // main-axis constraints, which a scrollable inside it can't take // (a hidden grid computed NaN offsets), so it gets no space at all. return Flexible( - flex: hidden ? 0 : sizedWidget.flex, + flex: hidden ? 0 : math.max(0, boundFlex ?? sizedWidget.flex), fit: sizedWidget.fit, child: SizedBox( width: hidden ? 0 : null, diff --git a/test/flex_binding_test.dart b/test/flex_binding_test.dart new file mode 100644 index 00000000..d37e0269 --- /dev/null +++ b/test/flex_binding_test.dart @@ -0,0 +1,86 @@ +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'; + +RectangleNode bar(String id, {String? flex}) => + RectangleNode( + id: id, + name: id, + basicBoxLocal: NodeBox(0, 0, 10, 20), + horizontalFit: SizeFit.expanded, + ) + ..parentID = 'row' + ..variables.addAll({if (flex != null) 'flex': flex}); + +Future> pumpRow( + WidgetTester tester, + List bars, +) async { + final RowColumnNode row = RowColumnNode( + id: 'row', + name: 'row', + basicBoxLocal: NodeBox(0, 0, 300, 20), + children: [for (final b in bars) b.id], + rowColumnType: RowColumnType.row, + )..parentID = kRootNode; + final registry = NodeRegistry() + ..setNodes({ + for (final node in [row, ...bars]) 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: Material( + child: Align( + alignment: Alignment.topLeft, + child: Builder( + builder: (context) => manager.buildWidgetFromNode( + row, + context, + settings: const WidgetBuildSettings(debugLabel: 'test'), + ), + ), + ), + ), + ), + ), + ); + expect(tester.takeException(), isNull); + return { + for (final b in bars) + b.id: tester + .getSize( + find.byKey(ValueKey('Rectangle Transformer of ${b.id} [${b.id}]')), + ) + .width, + }; +} + +void main() { + testWidgets('expanded children share the free space by their bound flex', ( + tester, + ) async { + // A temperature gauge: 52 below the band, 16 in it, 32 above it. + final widths = await pumpRow(tester, [ + bar('low', flex: r'${= 52}'), + bar('band', flex: r'${= 68 - 52}'), + bar('high', flex: r'${= 100 - 68}'), + ]); + expect(widths['low'], closeTo(156, 0.5)); + expect(widths['band'], closeTo(48, 0.5)); + expect(widths['high'], closeTo(96, 0.5)); + }); + + testWidgets('without a binding a child keeps its own flex', (tester) async { + final widths = await pumpRow(tester, [bar('a'), bar('b', flex: r'${= 2}')]); + expect(widths['a'], closeTo(100, 0.5)); + expect(widths['b'], closeTo(200, 0.5)); + }); +} From a2f551383f035aa1fa79776dc5a63382cbd3b4ee Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 23:21:09 +0000 Subject: [PATCH 05/12] Present dialogs as bottom sheets, adaptively or full screen showLayoutDialog follows the action's presentation: showModalBottomSheet with the canvas spanning the width at its content's height, a sheet on mobile widths for adaptive, or a full-screen route. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0147gqWiq3xoEdAGwWSpq9JK --- CHANGELOG.md | 7 ++ lib/src/functions/functions_repository.dart | 73 +++++++++++++---- .../passive_canvas_transformer.dart | 12 ++- lib/src/ui/codelessly_dialog_widget.dart | 17 +++- test/dialog_presentation_test.dart | 80 +++++++++++++++++++ 5 files changed, 170 insertions(+), 19 deletions(-) create mode 100644 test/dialog_presentation_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index b9d51ecd..65d76836 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ ## Unreleased +### Dialogs + +- Show dialog actions present their canvas by `presentation`: a centered + dialog, a bottom sheet (full width, as tall as its content up to 90% of + the screen), a sheet on mobile and a dialog on larger screens, or full + screen. `CanvasSizing.sheet` sizes a canvas for a sheet. + ### Layout - An expanded child's `flex` can be bound to data or set by a condition, so diff --git a/lib/src/functions/functions_repository.dart b/lib/src/functions/functions_repository.dart index 86de7a90..111c454f 100644 --- a/lib/src/functions/functions_repository.dart +++ b/lib/src/functions/functions_repository.dart @@ -571,6 +571,7 @@ class FunctionsRepository { barrierDismissible: action.barrierDismissible, barrierColor: action.barrierColor?.toFlutterColor(), showCloseButton: action.showCloseButton, + presentation: action.presentation, ); } @@ -590,6 +591,7 @@ class FunctionsRepository { bool barrierDismissible = true, Color? barrierColor, bool showCloseButton = false, + DialogPresentation presentation = DialogPresentation.dialog, }) async { final ScopedValues scopedValues = ActionChain.scopedValuesOf(context); final parsedParams = substituteVariablesInMap(params, scopedValues); @@ -632,21 +634,62 @@ 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, - barrierDismissible: barrierDismissible, - barrierColor: barrierColor, - routeSettings: RouteSettings(arguments: parsedParams), - builder: (context) => ActionChainOpener( - openerHosts: openerHosts, - child: CodelesslyDialogWidget( - showCloseButton: showCloseButton, - builder: (context) => - CodelesslyWidget(codelessly: codelessly, layoutID: layoutId), - ), - ), - ); + Widget content(BuildContext context, CanvasSizing sizing) => + ActionChainOpener( + openerHosts: openerHosts, + child: CodelesslyDialogWidget( + showCloseButton: showCloseButton, + sizing: sizing, + builder: (context) => + CodelesslyWidget(codelessly: codelessly, layoutID: layoutId), + ), + ); + final RouteSettings settings = RouteSettings(arguments: parsedParams); + final Size screen = MediaQuery.sizeOf(context); + final bool sheet = + presentation == DialogPresentation.bottomSheet || + (presentation == DialogPresentation.adaptive && screen.width < 600); + final Object? result; + if (sheet) { + // As wide as the screen and as tall as its content, up to 90% of the + // screen; the canvas draws its own background and corners. + result = await showModalBottomSheet( + context: context, + useRootNavigator: false, + isScrollControlled: true, + isDismissible: barrierDismissible, + enableDrag: barrierDismissible, + barrierColor: barrierColor, + backgroundColor: Colors.transparent, + elevation: 0, + constraints: BoxConstraints(maxHeight: screen.height * 0.9), + routeSettings: settings, + builder: (context) => content(context, CanvasSizing.sheet), + ); + } else if (presentation == DialogPresentation.fullScreen) { + result = await showGeneralDialog( + context: context, + useRootNavigator: false, + barrierDismissible: barrierDismissible, + barrierLabel: MaterialLocalizations.of(context) + .modalBarrierDismissLabel, + barrierColor: barrierColor ?? Colors.black, + routeSettings: settings, + transitionDuration: const Duration(milliseconds: 200), + transitionBuilder: (context, animation, _, child) => + FadeTransition(opacity: animation, child: child), + pageBuilder: (context, _, _) => content(context, CanvasSizing.fill), + ); + } else { + result = await showDialog( + context: context, + useRootNavigator: false, + barrierDismissible: barrierDismissible, + barrierColor: barrierColor, + routeSettings: settings, + builder: (context) => content(context, CanvasSizing.design), + ); + } if (context.mounted) { codelessly.notifyNavigationListeners( context, diff --git a/lib/src/transformers/node_transformers/passive_canvas_transformer.dart b/lib/src/transformers/node_transformers/passive_canvas_transformer.dart index 9fe25753..485443ae 100644 --- a/lib/src/transformers/node_transformers/passive_canvas_transformer.dart +++ b/lib/src/transformers/node_transformers/passive_canvas_transformer.dart @@ -440,6 +440,11 @@ enum CanvasSizing { /// given. Used where the host only centers the layout and lets it choose /// its size, such as a dialog. design, + + /// The canvas spans the width it is given and keeps its design height (or + /// its content's height when it shrink-wraps vertically), within the + /// height it is given. Used for bottom sheets. + sheet, } /// Sets the [CanvasSizing] of the canvases below it. Without one, canvases @@ -514,8 +519,11 @@ class CanvasViewport extends StatelessWidget { node.verticalFit == SizeFit.shrinkWrap && node.scaleMode == ScaleMode.responsive && !node.isAScaffold; - if (sizing == CanvasSizing.design) { - final double width = constraints.constrainWidth(node.basicBoxLocal.width); + if (sizing == CanvasSizing.design || sizing == CanvasSizing.sheet) { + final double width = + sizing == CanvasSizing.sheet && constraints.hasBoundedWidth + ? constraints.maxWidth + : constraints.constrainWidth(node.basicBoxLocal.width); if (contentHeight) return (width: width, height: null); final double height = node.scaleMode == ScaleMode.autoScale ? autoScaleHeight(node, width) diff --git a/lib/src/ui/codelessly_dialog_widget.dart b/lib/src/ui/codelessly_dialog_widget.dart index 86a735bc..a5692a69 100644 --- a/lib/src/ui/codelessly_dialog_widget.dart +++ b/lib/src/ui/codelessly_dialog_widget.dart @@ -7,10 +7,16 @@ class CodelesslyDialogWidget extends StatelessWidget { final WidgetBuilder builder; final bool showCloseButton; + /// How the canvas is sized: [CanvasSizing.design] for a centered dialog, + /// [CanvasSizing.sheet] for a bottom sheet, [CanvasSizing.fill] for a + /// full-screen dialog. + final CanvasSizing sizing; + const CodelesslyDialogWidget({ super.key, required this.builder, required this.showCloseButton, + this.sizing = CanvasSizing.design, }); @override @@ -18,7 +24,7 @@ class CodelesslyDialogWidget extends StatelessWidget { // A dialog is centered and sized by its layout, so its canvas keeps its // design size instead of filling the screen. return CanvasSizingScope( - sizing: CanvasSizing.design, + sizing: sizing, child: Builder(builder: _buildDialog), ); } @@ -49,6 +55,13 @@ class CodelesslyDialogWidget extends StatelessWidget { ); } - return Center(child: builder(context)); + return switch (sizing) { + CanvasSizing.sheet => Align( + alignment: Alignment.bottomCenter, + child: builder(context), + ), + CanvasSizing.fill => builder(context), + CanvasSizing.design => Center(child: builder(context)), + }; } } diff --git a/test/dialog_presentation_test.dart b/test/dialog_presentation_test.dart new file mode 100644 index 00000000..5a710131 --- /dev/null +++ b/test/dialog_presentation_test.dart @@ -0,0 +1,80 @@ +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:codelessly_sdk/src/ui/codelessly_dialog_widget.dart'; + +CanvasNode canvas({SizeFit verticalFit = SizeFit.fixed}) => CanvasNode( + id: 'sheet', + name: 'Sheet', + basicBoxLocal: NodeBox(0, 0, 520, 600), + verticalFit: verticalFit, + scaleMode: ScaleMode.responsive, + properties: CanvasProperties(bodyId: 'body'), + children: const ['body'], +); + +void main() { + const BoxConstraints phone = BoxConstraints(maxWidth: 390, maxHeight: 760); + + test('a sheet spans the width and keeps its design height', () { + final box = CanvasViewport.resolve( + canvas(), + phone, + sizing: CanvasSizing.sheet, + ); + expect(box.width, 390); + expect(box.height, 600); + }); + + test('a shrink-wrapping sheet is as tall as its content', () { + final box = CanvasViewport.resolve( + canvas(verticalFit: SizeFit.shrinkWrap), + phone, + sizing: CanvasSizing.sheet, + ); + expect(box.width, 390); + expect(box.height, isNull); + }); + + test('a dialog keeps its design width; full screen fills', () { + const BoxConstraints desktop = BoxConstraints( + maxWidth: 1440, + maxHeight: 900, + ); + expect( + CanvasViewport.resolve( + canvas(), + desktop, + sizing: CanvasSizing.design, + ).width, + 520, + ); + final full = CanvasViewport.resolve(canvas(), desktop); + expect((full.width, full.height), (1440, 900)); + }); + + testWidgets('a sheet sits at the bottom, a dialog in the middle', ( + tester, + ) async { + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + Future place(CanvasSizing sizing) async { + await tester.pumpWidget( + MaterialApp( + home: CodelesslyDialogWidget( + showCloseButton: false, + sizing: sizing, + builder: (_) => + const SizedBox(key: ValueKey('box'), width: 390, height: 200), + ), + ), + ); + return tester.getTopLeft(find.byKey(const ValueKey('box'))); + } + + expect((await place(CanvasSizing.sheet)).dy, 844 - 200); + expect((await place(CanvasSizing.design)).dy, (844 - 200) / 2); + }); +} From 20ffc4bcc1e4c4a1d1472a5994e94c1fc157be7e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 02:41:50 +0000 Subject: [PATCH 06/12] Sign in a signature box; hold to confirm; collapse zero-flex children The hold trigger exposes its progress to the nodes inside as ${hold}. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0147gqWiq3xoEdAGwWSpq9JK --- CHANGELOG.md | 13 + .../node_transformers/node_transformers.dart | 1 + .../passive_row_column_transformer.dart | 15 +- .../passive_signature_transformer.dart | 229 ++++++++++++++++++ .../passive_transformer_manager.dart | 1 + lib/src/transformers/transformers.dart | 1 + .../transformers/utils/hold_to_confirm.dart | 141 +++++++++++ .../utils/property_value_delegate.dart | 1 + lib/src/transformers/utils/scoped_values.dart | 7 + .../widget_node_transformer_manager.dart | 26 ++ lib/src/utils/utils.dart | 2 + test/hold_to_confirm_test.dart | 112 +++++++++ test/signature_node_test.dart | 161 ++++++++++++ 13 files changed, 705 insertions(+), 5 deletions(-) create mode 100644 lib/src/transformers/node_transformers/passive_signature_transformer.dart create mode 100644 lib/src/transformers/utils/hold_to_confirm.dart create mode 100644 test/hold_to_confirm_test.dart create mode 100644 test/signature_node_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 65d76836..8146e3d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ ## Unreleased +### Signing + +- Signature nodes take a signature by finger, pen or mouse and write its + strokes to the bound `value` as the pen goes down and lifts, firing + `changed`. Setting the variable redraws the box: `[]` clears it, and a + saved signature replays it. Read-only boxes only show. +- The hold trigger runs its actions once the node has been held for its + duration. Letting go or moving off drains it. While it is held, the nodes + inside read the progress, 0 to 1, as `${hold}`, to fill a bar or change a + label. +- An expanded child whose bound `flex` is 0 takes no space, where it used to + keep its design size. + ### Dialogs - Show dialog actions present their canvas by `presentation`: a centered diff --git a/lib/src/transformers/node_transformers/node_transformers.dart b/lib/src/transformers/node_transformers/node_transformers.dart index 8421a1e2..aa4979b4 100644 --- a/lib/src/transformers/node_transformers/node_transformers.dart +++ b/lib/src/transformers/node_transformers/node_transformers.dart @@ -27,6 +27,7 @@ export 'passive_rectangle_transformer.dart'; export 'passive_row_column_transformer.dart'; export 'passive_segmented_button_transformer.dart'; export 'passive_single_placeholder_transformer.dart'; +export 'passive_signature_transformer.dart'; export 'passive_slider_transformer.dart'; export 'passive_spacer_transformer.dart'; export 'passive_stack_transformer.dart'; diff --git a/lib/src/transformers/node_transformers/passive_row_column_transformer.dart b/lib/src/transformers/node_transformers/passive_row_column_transformer.dart index 615281ab..98670551 100644 --- a/lib/src/transformers/node_transformers/passive_row_column_transformer.dart +++ b/lib/src/transformers/node_transformers/passive_row_column_transformer.dart @@ -1,4 +1,3 @@ -import 'dart:math' as math; import 'package:codelessly_api/codelessly_api.dart'; import 'package:flutter/material.dart'; @@ -367,15 +366,21 @@ class PassiveRowColumnWidget extends StatelessWidget { scopedValues: ScopedValues.of(context), ) : null; - // Without flex the hidden child would be laid out with unbounded + // A child bound to no share (an empty progress fill) collapses too. + final bool empty = boundFlex != null && boundFlex <= 0; + final bool collapsed = hidden || empty; + final bool isRow = + node is RowColumnMixin && + (node as RowColumnMixin).rowColumnType == RowColumnType.row; + // Without flex the collapsed child would be laid out with unbounded // main-axis constraints, which a scrollable inside it can't take // (a hidden grid computed NaN offsets), so it gets no space at all. return Flexible( - flex: hidden ? 0 : math.max(0, boundFlex ?? sizedWidget.flex), + flex: collapsed ? 0 : boundFlex ?? sizedWidget.flex, fit: sizedWidget.fit, child: SizedBox( - width: hidden ? 0 : null, - height: hidden ? 0 : null, + width: hidden || (empty && isRow) ? 0 : null, + height: hidden || (empty && !isRow) ? 0 : null, child: sizedWidget.child, ), ); diff --git a/lib/src/transformers/node_transformers/passive_signature_transformer.dart b/lib/src/transformers/node_transformers/passive_signature_transformer.dart new file mode 100644 index 00000000..162068cc --- /dev/null +++ b/lib/src/transformers/node_transformers/passive_signature_transformer.dart @@ -0,0 +1,229 @@ +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:flutter/material.dart'; + +import '../../../codelessly_sdk.dart'; + +/// A signature drawn as strokes of `[x, y]` points normalized to the box. +typedef SignatureStrokes = List>>; + +class PassiveSignatureTransformer extends NodeWidgetTransformer { + PassiveSignatureTransformer(super.getNode, super.manager); + + @override + Widget buildWidget( + SignatureNode node, + BuildContext context, + WidgetBuildSettings settings, + ) { + return PassiveSignatureWidget(node: node, settings: settings); + } +} + +/// Reads a signature from a bound value: a list of strokes, each a list of +/// `[x, y]` pairs. Anything else reads as no signature. +SignatureStrokes parseSignatureStrokes(Object? value) { + if (value is! List) return []; + final SignatureStrokes strokes = []; + for (final Object? stroke in value) { + if (stroke is! List) continue; + final List> points = []; + for (final Object? point in stroke) { + if (point is List && point.length >= 2) { + final Object? x = point[0]; + final Object? y = point[1]; + if (x is num && y is num) points.add([x.toDouble(), y.toDouble()]); + } + } + if (points.isNotEmpty) strokes.add(points); + } + return strokes; +} + +class PassiveSignatureWidget extends StatefulWidget { + final SignatureNode node; + final WidgetBuildSettings settings; + + const PassiveSignatureWidget({ + super.key, + required this.node, + required this.settings, + }); + + @override + State createState() => _PassiveSignatureWidgetState(); +} + +class _PassiveSignatureWidgetState extends State { + /// The stroke being drawn, kept here until the finger lifts so a moving + /// pen repaints only this box. + List>? _stroke; + + /// The strokes there were when the current stroke began. + SignatureStrokes _base = []; + + /// The strokes this box last wrote, for a box with no bound value. + SignatureStrokes? _written; + + SignatureNode get node => widget.node; + + SignatureStrokes _boundStrokes(ScopedValues scopedValues) { + if (!node.variables.containsKey('value')) return _written ?? node.strokes; + return parseSignatureStrokes( + PropertyValueDelegate.getPropertyValue( + node, + 'value', + scopedValues: scopedValues, + ), + ); + } + + List _normalize(Offset p, Size size) => [ + double.parse((p.dx / size.width).clamp(0, 1).toStringAsFixed(4)), + double.parse((p.dy / size.height).clamp(0, 1).toStringAsFixed(4)), + ]; + + void _write(SignatureStrokes strokes) { + _written = strokes; + FunctionsRepository.setPropertyValue( + context, + node: node, + property: 'value', + value: strokes, + ); + FunctionsRepository.triggerAction( + context, + node: node, + TriggerType.changed, + value: strokes, + ); + } + + @override + Widget build(BuildContext context) { + final ScopedValues scopedValues = ScopedValues.of(context); + final SignatureStrokes strokes = _boundStrokes(scopedValues); + final bool readOnly = + PropertyValueDelegate.getPropertyValue( + node, + 'readOnly', + scopedValues: scopedValues, + ) ?? + node.properties.readOnly; + final Color ink = + (PropertyValueDelegate.getPropertyValue( + node, + 'inkColor', + scopedValues: scopedValues, + ) ?? + node.properties.inkColor) + .toFlutterColor(); + final bool interactive = + !readOnly && node.enabled && !widget.settings.isPreview; + + return SizedBox( + width: node.basicBoxLocal.width, + height: node.basicBoxLocal.height, + child: LayoutBuilder( + builder: (context, constraints) { + final Size size = constraints.biggest; + final Widget paint = CustomPaint( + size: size, + painter: SignaturePainter( + strokes: [...strokes, if (_stroke case final stroke?) stroke], + color: ink, + strokeWidth: node.properties.strokeWidth, + ), + ); + final String label = readOnly + ? 'Signature' + : strokes.isEmpty + ? 'Signature pad, empty' + : 'Signature pad, signed'; + if (!interactive) { + return Semantics(container: true, label: label, child: paint); + } + return Semantics( + container: true, + label: label, + child: MouseRegion( + cursor: SystemMouseCursors.precise, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onPanStart: (details) { + _base = strokes; + setState( + () => _stroke = [_normalize(details.localPosition, size)], + ); + // Writing the first point at once lets a "sign here" hint + // and the sign button react as the pen goes down. + _write([...strokes, _stroke!]); + }, + onPanUpdate: (details) => setState( + () => _stroke?.add(_normalize(details.localPosition, size)), + ), + onPanEnd: (_) => _finishStroke(), + onPanCancel: _finishStroke, + child: paint, + ), + ), + ); + }, + ), + ); + } + + void _finishStroke() { + final List>? stroke = _stroke; + if (stroke == null) return; + setState(() => _stroke = null); + _write([..._base, stroke]); + } + + @override + void didUpdateWidget(PassiveSignatureWidget oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.node.id != node.id) { + _stroke = null; + _written = null; + } + } +} + +/// Paints signature strokes with round ink, points normalized to the size. +class SignaturePainter extends CustomPainter { + final SignatureStrokes strokes; + final Color color; + final double strokeWidth; + + SignaturePainter({ + required this.strokes, + required this.color, + required this.strokeWidth, + }); + + @override + void paint(Canvas canvas, Size size) { + final Paint ink = Paint() + ..color = color + ..strokeWidth = strokeWidth + ..style = PaintingStyle.stroke + ..strokeCap = StrokeCap.round + ..strokeJoin = StrokeJoin.round; + for (final List> stroke in strokes) { + if (stroke.isEmpty) continue; + Offset at(List p) => Offset(p[0] * size.width, p[1] * size.height); + final Offset first = at(stroke.first); + final Path path = Path()..moveTo(first.dx, first.dy); + // A tap leaves a dot. + if (stroke.length == 1) path.lineTo(first.dx + 0.1, first.dy); + for (final List p in stroke.skip(1)) { + final Offset o = at(p); + path.lineTo(o.dx, o.dy); + } + canvas.drawPath(path, ink); + } + } + + @override + bool shouldRepaint(SignaturePainter oldDelegate) => true; +} diff --git a/lib/src/transformers/passive_transformer_manager.dart b/lib/src/transformers/passive_transformer_manager.dart index 9d6e473b..d079c5f3 100644 --- a/lib/src/transformers/passive_transformer_manager.dart +++ b/lib/src/transformers/passive_transformer_manager.dart @@ -30,6 +30,7 @@ class PassiveNodeTransformerManager extends WidgetNodeTransformerManager { 'appBar': PassiveAppBarTransformer(getNode, this), 'navigationBar': PassiveNavigationBarTransformer(getNode, this), 'switch': PassiveSwitchTransformer(getNode, this), + 'signature': PassiveSignatureTransformer(getNode, this), 'slider': PassiveSliderTransformer(getNode, this), 'placeholder': PassivePlaceholderTransformer(getNode, this), 'singlePlaceholder': PassiveSinglePlaceholderTransformer(getNode, this), diff --git a/lib/src/transformers/transformers.dart b/lib/src/transformers/transformers.dart index 84cd7172..b5bbe90e 100644 --- a/lib/src/transformers/transformers.dart +++ b/lib/src/transformers/transformers.dart @@ -9,6 +9,7 @@ export 'utils/condition_evaluator.dart'; export 'utils/condition_visitors.dart'; export 'utils/dash_path.dart'; export 'utils/fit_layout.dart'; +export 'utils/hold_to_confirm.dart'; export 'utils/indexed_item_provider.dart'; export 'utils/node_geometry.dart'; export 'utils/property_overrides.dart'; diff --git a/lib/src/transformers/utils/hold_to_confirm.dart b/lib/src/transformers/utils/hold_to_confirm.dart new file mode 100644 index 00000000..e4ba29e8 --- /dev/null +++ b/lib/src/transformers/utils/hold_to_confirm.dart @@ -0,0 +1,141 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/widgets.dart'; + +/// Shares how far a [HoldToConfirm] has been held, 0 to 1, with the nodes +/// inside it, which read it as the `hold` variable. +class HoldProgressProvider extends InheritedWidget { + /// How far the hold has progressed, from 0 to 1. + final double progress; + + /// Creates a [HoldProgressProvider]. + const HoldProgressProvider({ + super.key, + required this.progress, + required super.child, + }); + + /// The progress of the nearest [HoldToConfirm] above [context], if any. + static double? maybeOf(BuildContext context) => context + .dependOnInheritedWidgetOfExactType() + ?.progress; + + @override + bool updateShouldNotify(HoldProgressProvider oldWidget) => + progress != oldWidget.progress; +} + +/// Calls [onConfirmed] once [child] has been pressed for [duration]. +/// +/// The progress fills from 0 to 1 while the pointer is down and drains back +/// in [drainDuration] when it is released early or moves away (a scroll, for +/// example). Assistive technologies confirm with a long press. +class HoldToConfirm extends StatefulWidget { + /// How long the hold must last. + final Duration duration; + + /// How long an unfinished hold takes to drain back to 0. + final Duration drainDuration; + + /// Called when a hold completes. + final VoidCallback onConfirmed; + + /// The widget being held. + final Widget child; + + /// Creates a [HoldToConfirm]. + const HoldToConfirm({ + super.key, + required this.duration, + required this.onConfirmed, + required this.child, + this.drainDuration = const Duration(milliseconds: 250), + }); + + @override + State createState() => _HoldToConfirmState(); +} + +class _HoldToConfirmState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _controller = AnimationController( + vsync: this, + duration: widget.duration, + )..addStatusListener(_onStatus); + + int? _pointer; + Offset _downAt = Offset.zero; + + @override + void didUpdateWidget(HoldToConfirm oldWidget) { + super.didUpdateWidget(oldWidget); + _controller.duration = widget.duration; + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + void _onStatus(AnimationStatus status) { + if (status != AnimationStatus.completed) return; + _pointer = null; + _controller.value = 0; + widget.onConfirmed(); + } + + void _down(PointerDownEvent event) { + if (_pointer != null) return; + if (event.kind == PointerDeviceKind.mouse && + event.buttons != kPrimaryMouseButton) { + return; + } + _pointer = event.pointer; + _downAt = event.position; + _controller.forward(); + } + + void _move(PointerMoveEvent event) { + if (event.pointer != _pointer) return; + final double slop = event.kind == PointerDeviceKind.touch + ? kTouchSlop + : kPrecisePointerHitSlop * 4; + if ((event.position - _downAt).distance > slop) _release(); + } + + void _up(PointerEvent event) { + if (event.pointer == _pointer) _release(); + } + + void _release() { + _pointer = null; + if (_controller.isCompleted || _controller.value == 0) return; + _controller.animateBack( + 0, + duration: widget.drainDuration * _controller.value, + curve: Curves.easeOut, + ); + } + + @override + Widget build(BuildContext context) { + return Semantics( + onLongPress: widget.onConfirmed, + child: Listener( + behavior: HitTestBehavior.opaque, + onPointerDown: _down, + onPointerMove: _move, + onPointerUp: _up, + onPointerCancel: _up, + child: AnimatedBuilder( + animation: _controller, + child: widget.child, + builder: (context, child) => HoldProgressProvider( + progress: _controller.value, + child: child!, + ), + ), + ), + ); + } +} diff --git a/lib/src/transformers/utils/property_value_delegate.dart b/lib/src/transformers/utils/property_value_delegate.dart index 3f4dfd8a..ad1a7ae5 100644 --- a/lib/src/transformers/utils/property_value_delegate.dart +++ b/lib/src/transformers/utils/property_value_delegate.dart @@ -413,6 +413,7 @@ class PropertyValueDelegate { 'auth' => scopedValues.appUser, 'result' => ActionChain.current?.result, 'screen' => scopedValues.screenSize?.let(screenVariableValue), + 'hold' => scopedValues.holdProgress ?? 0.0, _ => null, }; diff --git a/lib/src/transformers/utils/scoped_values.dart b/lib/src/transformers/utils/scoped_values.dart index 146ebbac..fcef35fd 100644 --- a/lib/src/transformers/utils/scoped_values.dart +++ b/lib/src/transformers/utils/scoped_values.dart @@ -17,6 +17,10 @@ class ScopedValues { /// The exposed internal value of a node in the widget tree. late final Object? nodeState; + /// How far the nearest hold-to-confirm above this context has been held, + /// 0 to 1, or null outside one. + late final double? holdProgress; + /// The route params for this route/page for given context. late final Map routeParams; @@ -75,6 +79,7 @@ class ScopedValues { _dataOverrides = null, indexedItem = null, nodeState = null, + holdProgress = null, routeParams = {}, _appUser = null, screenSize = null; @@ -97,6 +102,7 @@ class ScopedValues { _dataOverrides = data, _previewVariables = variables, nodeState = null, + holdProgress = null, routeParams = {}; /// Creates a new scoped values instance from a given context. @@ -114,6 +120,7 @@ class ScopedValues { indexedItem = IndexedItemProvider.maybeOf(context); screenSize = MediaQuery.maybeSizeOf(context); nodeState = NodeStateProvider.maybeOf(context)?.state; + holdProgress = HoldProgressProvider.maybeOf(context); routeParams = ModalRoute.of(context)?.settings.arguments as Map? ?? {}; diff --git a/lib/src/transformers/widget_node_transformer_manager.dart b/lib/src/transformers/widget_node_transformer_manager.dart index 87c40bd2..477764fb 100644 --- a/lib/src/transformers/widget_node_transformer_manager.dart +++ b/lib/src/transformers/widget_node_transformer_manager.dart @@ -227,6 +227,32 @@ abstract class WidgetNodeTransformerManager /// Convenience method to handle widget reactions. Widget wrapWithReaction(BuildContext context, BaseNode node, Widget widget) { + final Widget wrapped = _wrapWithTapReactions(context, node, widget); + if (node is! ReactionMixin) return wrapped; + final List onHoldReactions = (node as ReactionMixin).reactions + .where( + (reaction) => + reaction.trigger.type == TriggerType.hold && + reaction.action.enabled, + ) + .toList(); + if (onHoldReactions.isEmpty) return wrapped; + return HoldToConfirm( + duration: Duration(milliseconds: onHoldReactions.first.trigger.duration), + onConfirmed: () => FunctionsRepository.triggerAction( + context, + TriggerType.hold, + reactions: onHoldReactions, + ), + child: wrapped, + ); + } + + Widget _wrapWithTapReactions( + BuildContext context, + BaseNode node, + Widget widget, + ) { if (node is! ReactionMixin) return widget; if (node case CanvasNode() || SpacerNode()) return widget; if (node is CustomPropertiesMixin && diff --git a/lib/src/utils/utils.dart b/lib/src/utils/utils.dart index 6a8d9b0f..e18ca26f 100644 --- a/lib/src/utils/utils.dart +++ b/lib/src/utils/utils.dart @@ -17,6 +17,7 @@ const Set predefinedVariableNames = { 'auth', 'result', 'screen', + 'hold', }; /// The key under which a layout's published variables hold the project's @@ -52,6 +53,7 @@ final Set predefinedVariables = { PredefinedVariableData(name: 'auth', type: VariableType.map), PredefinedVariableData(name: 'result'), PredefinedVariableData(name: 'screen', type: VariableType.map), + PredefinedVariableData(name: 'hold', type: VariableType.decimal), }; /// Widths at which the `screen` variable's breakpoint changes: below 600 is diff --git a/test/hold_to_confirm_test.dart b/test/hold_to_confirm_test.dart new file mode 100644 index 00000000..4d7bd8ef --- /dev/null +++ b/test/hold_to_confirm_test.dart @@ -0,0 +1,112 @@ +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'; + +RectangleNode bar(String id, String flex) => + RectangleNode( + id: id, + name: id, + basicBoxLocal: NodeBox(0, 0, 10, 40), + horizontalFit: SizeFit.expanded, + ) + ..parentID = 'button' + ..variables['flex'] = flex; + +/// A 400 wide hold-to-sign button: its `fill` bar grows with `${hold}`. +Future pumpButton(WidgetTester tester) async { + final RowColumnNode button = RowColumnNode( + id: 'button', + name: 'button', + basicBoxLocal: NodeBox(0, 0, 400, 40), + children: const ['fill', 'rest'], + rowColumnType: RowColumnType.row, + reactions: [ + Reaction( + action: ShowToastAction(message: 'Log signed', duration: 1000), + trigger: TriggerModel(type: TriggerType.hold, duration: 1000), + ), + ], + )..parentID = kRootNode; + final nodes = [ + button, + bar('fill', r'${= round(hold * 1000)}'), + bar('rest', r'${= 1000 - round(hold * 1000)}'), + ]; + final registry = NodeRegistry()..setNodes({for (final n in nodes) n.id: n}); + final manager = PassiveNodeTransformerManager( + registry.getNodeByID, + (context, bounds, pageID, layoutID, canvasID) => const SizedBox(), + ); + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: CodelesslyContext.empty(), + child: MaterialApp( + home: Material( + child: Align( + alignment: Alignment.topLeft, + child: Builder( + builder: (context) => manager.buildWidgetFromNode( + button, + context, + settings: const WidgetBuildSettings(debugLabel: 'test'), + ), + ), + ), + ), + ), + ), + ); +} + +double fillWidth(WidgetTester tester) => tester + .getSize(find.byKey(const ValueKey('Rectangle Transformer of fill [fill]'))) + .width; + +void main() { + test('the hold trigger round-trips its duration', () { + final trigger = TriggerModel(type: TriggerType.hold, duration: 800); + expect(trigger.toJson(), {'type': 'hold', 'duration': 800}); + expect(TriggerModel.fromJson(trigger.toJson()), trigger); + expect( + TriggerModel.fromJson({'type': 'hold'}).duration, + kDefaultHoldDuration, + ); + }); + + testWidgets('holding fills, releasing drains, and a full hold confirms', ( + tester, + ) async { + await pumpButton(tester); + expect(fillWidth(tester), 0); + + final gesture = await tester.startGesture(const Offset(200, 20)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + expect(fillWidth(tester), closeTo(200, 2)); + + // Let go halfway: it drains back and nothing fires. + await gesture.up(); + await tester.pumpAndSettle(); + expect(fillWidth(tester), 0); + expect(find.text('Log signed'), findsNothing); + + // Moving off while holding cancels too, like a scroll would. + final drag = await tester.startGesture(const Offset(200, 20)); + await tester.pump(const Duration(milliseconds: 300)); + await drag.moveBy(const Offset(0, 60)); + await tester.pumpAndSettle(); + expect(fillWidth(tester), 0); + await drag.up(); + + final hold = await tester.startGesture(const Offset(200, 20)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 1050)); + await tester.pump(const Duration(milliseconds: 300)); + expect(find.text('Log signed'), findsOneWidget); + expect(fillWidth(tester), 0); + await hold.up(); + await tester.pumpAndSettle(const Duration(seconds: 2)); + }); +} diff --git a/test/signature_node_test.dart b/test/signature_node_test.dart new file mode 100644 index 00000000..5e7ce012 --- /dev/null +++ b/test/signature_node_test.dart @@ -0,0 +1,161 @@ +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'; + +/// A 300×100 signature box bound to the `sig` variable. +Future pumpPad( + WidgetTester tester, { + Object? initial = const [], + bool readOnly = false, +}) async { + final ctx = CodelesslyContext.empty() + ..variables['sig'] = Observable( + VariableData(id: 'sig', name: 'sig', type: VariableType.list), + ); + if (initial != null) { + ctx.variables['sig']!.value = ctx.variables['sig']!.value.copyWith( + value: initial, + ); + } + final SignatureNode pad = + SignatureNode( + id: 'pad', + name: 'pad', + basicBoxLocal: NodeBox(0, 0, 300, 100), + properties: SignatureProperties(readOnly: readOnly), + ) + ..parentID = kRootNode + ..variables['value'] = r'${sig}'; + final registry = NodeRegistry()..setNodes({'pad': pad}); + final manager = PassiveNodeTransformerManager( + registry.getNodeByID, + (context, bounds, pageID, layoutID, canvasID) => const SizedBox(), + ); + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: ctx, + child: MaterialApp( + home: Material( + child: Align( + alignment: Alignment.topLeft, + child: Builder( + builder: (context) => manager.buildWidgetFromNode( + pad, + context, + settings: const WidgetBuildSettings(debugLabel: 'test'), + ), + ), + ), + ), + ), + ), + ); + return ctx; +} + +SignatureStrokes painted(WidgetTester tester) => (tester + .widget( + find.descendant( + of: find.byType(PassiveSignatureWidget), + matching: find.byType(CustomPaint), + ), + ) + .painter as SignaturePainter) + .strokes; + +void main() { + test('strokes parse from bound data and ignore junk', () { + expect( + parseSignatureStrokes([ + [ + [0.1, 0.2], + [1, 0], + ], + 'junk', + [], + [ + ['x', 1], + ], + ]), + [ + [ + [0.1, 0.2], + [1.0, 0.0], + ], + ], + ); + expect(parseSignatureStrokes(null), isEmpty); + }); + + test('a signature node round-trips through JSON', () { + final node = SignatureNode( + id: 'pad', + name: 'Signature', + basicBoxLocal: NodeBox(0, 0, 300, 100), + properties: SignatureProperties( + inkColor: ColorRGBA.white, + readOnly: true, + ), + ); + final json = node.toJson(); + expect(json['type'], 'signature'); + final back = NodeJsonConverter.instance.fromJson(json)! as SignatureNode; + expect(back.properties.readOnly, isTrue); + expect(back.properties.inkColor, ColorRGBA.white); + expect(back.properties.strokeWidth, 2.6); + }); + + testWidgets('drawing writes normalized strokes to the bound variable', ( + tester, + ) async { + final ctx = await pumpPad(tester); + await tester.dragFrom(const Offset(30, 50), const Offset(150, 20)); + await tester.pumpAndSettle(); + + final strokes = parseSignatureStrokes(ctx.variables['sig']!.value.getValue()); + expect(strokes, hasLength(1)); + expect(strokes.first.first[0], closeTo(0.1, 0.01)); + expect(strokes.first.first[1], closeTo(0.5, 0.01)); + expect(strokes.first.last[0], closeTo(0.6, 0.02)); + expect(strokes.first.length, greaterThan(2)); + expect(painted(tester), hasLength(1)); + + await tester.dragFrom(const Offset(200, 80), const Offset(40, 0)); + await tester.pumpAndSettle(); + expect( + parseSignatureStrokes(ctx.variables['sig']!.value.getValue()), + hasLength(2), + ); + + // Clearing the variable clears the box. + ctx.variables['sig']!.value = ctx.variables['sig']!.value.copyWith( + value: const [], + ); + await tester.pumpAndSettle(); + expect(painted(tester), isEmpty); + }); + + testWidgets('a read-only box replays a signature and ignores the pen', ( + tester, + ) async { + final ctx = await pumpPad( + tester, + readOnly: true, + initial: [ + [ + [0.1, 0.5], + [0.9, 0.5], + ], + ], + ); + expect(painted(tester), hasLength(1)); + await tester.dragFrom(const Offset(30, 20), const Offset(150, 20)); + await tester.pumpAndSettle(); + expect( + parseSignatureStrokes(ctx.variables['sig']!.value.getValue()), + hasLength(1), + ); + }); +} From 569467292bb0d2c9df2aa63f9de7f6eb94cdd9b3 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 02:43:53 +0000 Subject: [PATCH 07/12] Pick a dialog's responsive layout by the screen Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0147gqWiq3xoEdAGwWSpq9JK --- CHANGELOG.md | 3 ++ .../passive_canvas_transformer.dart | 10 ++++-- test/dialog_presentation_test.dart | 36 +++++++++++++++++++ 3 files changed, 46 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8146e3d4..f9dcdf98 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,9 @@ ### Dialogs +- A responsive layout in a centered dialog shows the layout for the screen, + as a layout group in a dialog picks its canvas. It went by the dialog's + own width, so a 520 wide dialog always showed its mobile layout. - Show dialog actions present their canvas by `presentation`: a centered dialog, a bottom sheet (full width, as tall as its content up to 90% of the screen), a sheet on mobile and a dialog on larger screens, or full diff --git a/lib/src/transformers/node_transformers/passive_canvas_transformer.dart b/lib/src/transformers/node_transformers/passive_canvas_transformer.dart index 485443ae..19f7704b 100644 --- a/lib/src/transformers/node_transformers/passive_canvas_transformer.dart +++ b/lib/src/transformers/node_transformers/passive_canvas_transformer.dart @@ -552,11 +552,15 @@ class CanvasViewport extends StatelessWidget { return LayoutBuilder( builder: (context, constraints) { final box = resolve(node, constraints, sizing: sizing); - // The width the canvas content is laid out at: the canvas box for a + // The width that picks responsive layouts: the canvas box for a // responsive canvas, the design width for an autoScale one (its - // content is laid out at design size and then scaled). + // content is laid out at design size and then scaled). A centered + // dialog keeps its design width on every screen, so it goes by the + // screen, as a layout group in a dialog picks its canvas. final Widget content = CanvasWidthScope( - width: node.scaleMode == ScaleMode.autoScale + width: sizing == CanvasSizing.design + ? MediaQuery.sizeOf(context).width + : node.scaleMode == ScaleMode.autoScale ? node.basicBoxLocal.width : box.width, child: child, diff --git a/test/dialog_presentation_test.dart b/test/dialog_presentation_test.dart index 5a710131..14236ee3 100644 --- a/test/dialog_presentation_test.dart +++ b/test/dialog_presentation_test.dart @@ -77,4 +77,40 @@ void main() { expect((await place(CanvasSizing.sheet)).dy, 844 - 200); expect((await place(CanvasSizing.design)).dy, (844 - 200) / 2); }); + + testWidgets('responsive layouts in a dialog go by the screen, in a sheet ' + 'by the sheet', (tester) async { + tester.view.physicalSize = const Size(1440, 900); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + Future widthIn(CanvasSizing sizing) async { + double? width; + await tester.pumpWidget( + MaterialApp( + home: CanvasSizingScope( + sizing: sizing, + child: Center( + child: SizedBox( + width: sizing == CanvasSizing.sheet ? 600 : 1440, + child: CanvasViewport( + node: canvas(), + child: Builder( + builder: (context) { + width = CanvasWidthScope.maybeOf(context); + return const SizedBox(); + }, + ), + ), + ), + ), + ), + ), + ); + return width; + } + + // The 520 wide dialog shows the desktop layout on a desktop screen. + expect(await widthIn(CanvasSizing.design), 1440); + expect(await widthIn(CanvasSizing.sheet), 600); + }); } From ffbf94b66190e805644e5916d78be7957e2d3b9d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 02:51:55 +0000 Subject: [PATCH 08/12] Share hover state as ${hovered}; scope hold above the node's build Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0147gqWiq3xoEdAGwWSpq9JK --- CHANGELOG.md | 4 + .../passive_transformer_manager.dart | 5 +- lib/src/transformers/transformers.dart | 1 + lib/src/transformers/utils/hover_scope.dart | 50 +++++++++ .../utils/property_value_delegate.dart | 1 + lib/src/transformers/utils/scoped_values.dart | 7 ++ .../widget_node_transformer_manager.dart | 56 +++++++--- lib/src/utils/utils.dart | 2 + test/hovered_variable_test.dart | 105 ++++++++++++++++++ 9 files changed, 213 insertions(+), 18 deletions(-) create mode 100644 lib/src/transformers/utils/hover_scope.dart create mode 100644 test/hovered_variable_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index f9dcdf98..4439d40b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,10 @@ duration. Letting go or moving off drains it. While it is held, the nodes inside read the progress, 0 to 1, as `${hold}`, to fill a bar or change a label. +- A tappable node (one with click, long-press or hold actions) shares + whether a mouse is over it as `${hovered}`, which it and the nodes inside + it can bind for hover borders, fills and colors. The hold progress reaches + the held node's own properties too. - An expanded child whose bound `flex` is 0 takes no space, where it used to keep its design size. diff --git a/lib/src/transformers/passive_transformer_manager.dart b/lib/src/transformers/passive_transformer_manager.dart index d079c5f3..911c21d2 100644 --- a/lib/src/transformers/passive_transformer_manager.dart +++ b/lib/src/transformers/passive_transformer_manager.dart @@ -84,7 +84,7 @@ class PassiveNodeTransformerManager extends WidgetNodeTransformerManager { ); } - return _wrapWithListener( + final Widget built = _wrapWithListener( context, node: node, builder: (context) { @@ -97,6 +97,9 @@ class PassiveNodeTransformerManager extends WidgetNodeTransformerManager { return _buildDecorated(context, node, settings); }, ); + return settings.withReactions + ? wrapWithInteractionScopes(context, node, built) + : built; } /// Whether [node] has interaction states, or its `selected` or `disabled` diff --git a/lib/src/transformers/transformers.dart b/lib/src/transformers/transformers.dart index b5bbe90e..2dd2e4dc 100644 --- a/lib/src/transformers/transformers.dart +++ b/lib/src/transformers/transformers.dart @@ -10,6 +10,7 @@ export 'utils/condition_visitors.dart'; export 'utils/dash_path.dart'; export 'utils/fit_layout.dart'; export 'utils/hold_to_confirm.dart'; +export 'utils/hover_scope.dart'; export 'utils/indexed_item_provider.dart'; export 'utils/node_geometry.dart'; export 'utils/property_overrides.dart'; diff --git a/lib/src/transformers/utils/hover_scope.dart b/lib/src/transformers/utils/hover_scope.dart new file mode 100644 index 00000000..87643f77 --- /dev/null +++ b/lib/src/transformers/utils/hover_scope.dart @@ -0,0 +1,50 @@ +import 'package:flutter/widgets.dart'; + +/// Tells the nodes inside a tappable node whether a mouse is over it; they +/// read it as the `hovered` variable, for hover borders and colors. +class HoverProvider extends InheritedWidget { + /// Whether the pointer is over the node. + final bool hovered; + + /// Creates a [HoverProvider]. + const HoverProvider({super.key, required this.hovered, required super.child}); + + /// Whether the nearest [HoverScope] above [context] is hovered, if any. + static bool? maybeOf(BuildContext context) => + context.dependOnInheritedWidgetOfExactType()?.hovered; + + @override + bool updateShouldNotify(HoverProvider oldWidget) => + hovered != oldWidget.hovered; +} + +/// Tracks whether a mouse is over [child] and shares it through a +/// [HoverProvider]. +class HoverScope extends StatefulWidget { + /// The widget hovered. + final Widget child; + + /// Creates a [HoverScope]. + const HoverScope({super.key, required this.child}); + + @override + State createState() => _HoverScopeState(); +} + +class _HoverScopeState extends State { + bool _hovered = false; + + void _set(bool hovered) { + if (hovered != _hovered) setState(() => _hovered = hovered); + } + + @override + Widget build(BuildContext context) { + return MouseRegion( + opaque: false, + onEnter: (_) => _set(true), + onExit: (_) => _set(false), + child: HoverProvider(hovered: _hovered, child: widget.child), + ); + } +} diff --git a/lib/src/transformers/utils/property_value_delegate.dart b/lib/src/transformers/utils/property_value_delegate.dart index ad1a7ae5..c0731658 100644 --- a/lib/src/transformers/utils/property_value_delegate.dart +++ b/lib/src/transformers/utils/property_value_delegate.dart @@ -414,6 +414,7 @@ class PropertyValueDelegate { 'result' => ActionChain.current?.result, 'screen' => scopedValues.screenSize?.let(screenVariableValue), 'hold' => scopedValues.holdProgress ?? 0.0, + 'hovered' => scopedValues.hovered ?? false, _ => null, }; diff --git a/lib/src/transformers/utils/scoped_values.dart b/lib/src/transformers/utils/scoped_values.dart index fcef35fd..02ac6708 100644 --- a/lib/src/transformers/utils/scoped_values.dart +++ b/lib/src/transformers/utils/scoped_values.dart @@ -21,6 +21,10 @@ class ScopedValues { /// 0 to 1, or null outside one. late final double? holdProgress; + /// Whether the nearest tappable node above this context is hovered, or + /// null outside one. + late final bool? hovered; + /// The route params for this route/page for given context. late final Map routeParams; @@ -80,6 +84,7 @@ class ScopedValues { indexedItem = null, nodeState = null, holdProgress = null, + hovered = null, routeParams = {}, _appUser = null, screenSize = null; @@ -103,6 +108,7 @@ class ScopedValues { _previewVariables = variables, nodeState = null, holdProgress = null, + hovered = null, routeParams = {}; /// Creates a new scoped values instance from a given context. @@ -121,6 +127,7 @@ class ScopedValues { screenSize = MediaQuery.maybeSizeOf(context); nodeState = NodeStateProvider.maybeOf(context)?.state; holdProgress = HoldProgressProvider.maybeOf(context); + hovered = HoverProvider.maybeOf(context); routeParams = ModalRoute.of(context)?.settings.arguments as Map? ?? {}; diff --git a/lib/src/transformers/widget_node_transformer_manager.dart b/lib/src/transformers/widget_node_transformer_manager.dart index 477764fb..23380d47 100644 --- a/lib/src/transformers/widget_node_transformer_manager.dart +++ b/lib/src/transformers/widget_node_transformer_manager.dart @@ -225,29 +225,51 @@ abstract class WidgetNodeTransformerManager } } - /// Convenience method to handle widget reactions. - Widget wrapWithReaction(BuildContext context, BaseNode node, Widget widget) { - final Widget wrapped = _wrapWithTapReactions(context, node, widget); - if (node is! ReactionMixin) return wrapped; - final List onHoldReactions = (node as ReactionMixin).reactions - .where( - (reaction) => - reaction.trigger.type == TriggerType.hold && - reaction.action.enabled, - ) + /// Wraps the whole build of a tappable [node] so it and the nodes inside + /// it can read `hovered`, and, when it has hold reactions, run them once it + /// has been held, sharing the progress as `hold`. [child] builds the node + /// below these scopes so the node's own bindings see them. + Widget wrapWithInteractionScopes( + BuildContext context, + BaseNode node, + Widget child, + ) { + if (node is! ReactionMixin) return child; + if (node case CanvasNode() || SpacerNode()) return child; + final List reactions = (node as ReactionMixin).reactions + .where((reaction) => reaction.action.enabled) .toList(); - if (onHoldReactions.isEmpty) return wrapped; - return HoldToConfirm( - duration: Duration(milliseconds: onHoldReactions.first.trigger.duration), - onConfirmed: () => FunctionsRepository.triggerAction( - context, + final bool tappable = reactions.any( + (reaction) => const { + TriggerType.click, + TriggerType.longPress, TriggerType.hold, - reactions: onHoldReactions, + }.contains(reaction.trigger.type), + ); + if (!tappable) return child; + final List onHoldReactions = reactions + .where((reaction) => reaction.trigger.type == TriggerType.hold) + .toList(); + if (onHoldReactions.isEmpty) return HoverScope(child: child); + return HoverScope( + child: HoldToConfirm( + duration: Duration( + milliseconds: onHoldReactions.first.trigger.duration, + ), + onConfirmed: () => FunctionsRepository.triggerAction( + context, + TriggerType.hold, + reactions: onHoldReactions, + ), + child: child, ), - child: wrapped, ); } + /// Convenience method to handle widget reactions. + Widget wrapWithReaction(BuildContext context, BaseNode node, Widget widget) => + _wrapWithTapReactions(context, node, widget); + Widget _wrapWithTapReactions( BuildContext context, BaseNode node, diff --git a/lib/src/utils/utils.dart b/lib/src/utils/utils.dart index e18ca26f..d39baa9d 100644 --- a/lib/src/utils/utils.dart +++ b/lib/src/utils/utils.dart @@ -18,6 +18,7 @@ const Set predefinedVariableNames = { 'result', 'screen', 'hold', + 'hovered', }; /// The key under which a layout's published variables hold the project's @@ -54,6 +55,7 @@ final Set predefinedVariables = { PredefinedVariableData(name: 'result'), PredefinedVariableData(name: 'screen', type: VariableType.map), PredefinedVariableData(name: 'hold', type: VariableType.decimal), + PredefinedVariableData(name: 'hovered', type: VariableType.boolean), }; /// Widths at which the `screen` variable's breakpoint changes: below 600 is diff --git a/test/hovered_variable_test.dart b/test/hovered_variable_test.dart new file mode 100644 index 00000000..3870901e --- /dev/null +++ b/test/hovered_variable_test.dart @@ -0,0 +1,105 @@ +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:codelessly_sdk/codelessly_sdk.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; + +RectangleNode bar(String id, String flex) => + RectangleNode( + id: id, + name: id, + basicBoxLocal: NodeBox(0, 0, 10, 40), + horizontalFit: SizeFit.expanded, + ) + ..parentID = 'tile' + ..variables['flex'] = flex; + +/// A 300 wide tappable tile whose `a` bar takes 2/3 of it when hovered. +Future pumpTile(WidgetTester tester, {bool tappable = true}) async { + final RowColumnNode tile = RowColumnNode( + id: 'tile', + name: 'tile', + basicBoxLocal: NodeBox(0, 0, 300, 40), + children: const ['a', 'b'], + rowColumnType: RowColumnType.row, + reactions: [ + if (tappable) + Reaction( + action: ShowToastAction(message: 'Opened'), + trigger: TriggerModel(type: TriggerType.click), + ), + ], + ) + ..parentID = kRootNode + ..variables['opacity'] = r'${= if(hovered, 0.5, 1)}'; + final nodes = [ + tile, + bar('a', r'${= if(hovered, 2, 1)}'), + bar('b', r'${= 1}'), + ]; + final registry = NodeRegistry()..setNodes({for (final n in nodes) n.id: n}); + final manager = PassiveNodeTransformerManager( + registry.getNodeByID, + (context, bounds, pageID, layoutID, canvasID) => const SizedBox(), + ); + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: CodelesslyContext.empty(), + child: MaterialApp( + home: Material( + child: Align( + alignment: Alignment.topLeft, + child: Builder( + builder: (context) => manager.buildWidgetFromNode( + tile, + context, + settings: const WidgetBuildSettings(debugLabel: 'test'), + ), + ), + ), + ), + ), + ), + ); +} + +double barWidth(WidgetTester tester) => tester + .getSize(find.byKey(const ValueKey('Rectangle Transformer of a [a]'))) + .width; + +void main() { + testWidgets('nodes inside a tappable node read whether it is hovered', ( + tester, + ) async { + await pumpTile(tester); + expect(barWidth(tester), closeTo(150, 0.5)); + + final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse); + await mouse.addPointer(location: const Offset(500, 500)); + await mouse.moveTo(const Offset(100, 20)); + await tester.pump(); + expect(barWidth(tester), closeTo(200, 0.5)); + // The tile's own properties see it too: a hover border or fill. + expect( + tester + .widgetList(find.byType(Opacity)) + .any((o) => o.opacity == 0.5), + isTrue, + ); + + await mouse.moveTo(const Offset(500, 500)); + await tester.pump(); + expect(barWidth(tester), closeTo(150, 0.5)); + await mouse.removePointer(); + }); + + testWidgets('outside a tappable node hovered is false', (tester) async { + await pumpTile(tester, tappable: false); + final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse); + await mouse.addPointer(location: const Offset(100, 20)); + await tester.pump(); + expect(barWidth(tester), closeTo(150, 0.5)); + await mouse.removePointer(); + }); +} From afef14b9dcf9021431c0c359bc59fe19bcf1d1c8 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 02:52:59 +0000 Subject: [PATCH 09/12] Bind a node's border weight Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0147gqWiq3xoEdAGwWSpq9JK --- CHANGELOG.md | 2 + .../passive_transformer_manager.dart | 8 +++- test/style_overrides_test.dart | 47 +++++++++++++++++++ 3 files changed, 55 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4439d40b..1321f911 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,8 @@ whether a mouse is over it as `${hovered}`, which it and the nodes inside it can bind for hover borders, fills and colors. The hold progress reaches the held node's own properties too. +- `strokeWeight` can be bound, set by a condition or changed by an action, + for a border that thickens when picked or hovered. - An expanded child whose bound `flex` is 0 takes no space, where it used to keep its design size. diff --git a/lib/src/transformers/passive_transformer_manager.dart b/lib/src/transformers/passive_transformer_manager.dart index 911c21d2..01d52614 100644 --- a/lib/src/transformers/passive_transformer_manager.dart +++ b/lib/src/transformers/passive_transformer_manager.dart @@ -179,8 +179,8 @@ class PassiveNodeTransformerManager extends WidgetNodeTransformerManager { /// Returns [node] with the style properties that conditions, bound /// variables or formulas, or set value actions give it at runtime: `fill` - /// (a text's color for text nodes), `stroke`, `opacity`, `width` and - /// `height`. Returns [node] itself when + /// (a text's color for text nodes), `stroke`, `strokeWeight`, `opacity`, + /// `width` and `height`. Returns [node] itself when /// nothing changes it. BaseNode applyStyleOverrides(BuildContext context, BaseNode node) { final ScopedValues scopedValues = ScopedValues.of(context); @@ -208,6 +208,10 @@ class PassiveNodeTransformerManager extends WidgetNodeTransformerManager { for (final paint in geometry.strokes.skip(1)) paint.toJson(), ]; } + final double? strokeWeight = value('strokeWeight'); + if (strokeWeight != null && strokeWeight != geometry.strokeWeight) { + patch['strokeWeight'] = math.max(0, strokeWeight).toDouble(); + } } if (node is TextNode) { // A text's fill is its color: it replaces the color of every span. diff --git a/test/style_overrides_test.dart b/test/style_overrides_test.dart index b82eb8cd..b3a40044 100644 --- a/test/style_overrides_test.dart +++ b/test/style_overrides_test.dart @@ -149,4 +149,51 @@ void main() { ), ); }); + + testWidgets('a bound strokeWeight thickens the border', (tester) async { + final RectangleNode node = RectangleNode.fromJson({ + ...RectangleNode( + id: 'tile', + name: 'Tile', + basicBoxLocal: NodeBox(0, 0, 40, 40), + strokes: [ + PaintModel.solid(id: 's', color: ColorRGB.fromHex('#FFC21A')), + ], + strokeWeight: 1, + ).toJson(), + 'variables': {'strokeWeight': r'${= if(picked, 3, 1)}'}, + })..parentID = kRootNode; + final manager = PassiveNodeTransformerManager( + (id) => node, + (context, bounds, pageID, layoutID, canvasID) => const SizedBox(), + ); + final Observable picked = Observable( + VariableData( + id: 'picked', + name: 'picked', + type: VariableType.boolean, + value: false, + ), + ); + late double weight; + Future pump() => tester.pumpWidget( + ChangeNotifierProvider.value( + value: CodelesslyContext.empty()..variables['picked'] = picked, + child: Builder( + builder: (context) { + weight = (manager.applyStyleOverrides( + context, + node, + ) as GeometryMixin).strokeWeight; + return const SizedBox(); + }, + ), + ), + ); + await pump(); + expect(weight, 1); + picked.set(picked.value.copyWith(value: true)); + await pump(); + expect(weight, 3); + }); } From 82092376e3ce80c3b01934e5dddb64631f0183e6 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 03:29:32 +0000 Subject: [PATCH 10/12] Wrap a row or column's children onto new runs Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0147gqWiq3xoEdAGwWSpq9JK --- CHANGELOG.md | 7 + .../passive_row_column_transformer.dart | 103 +++++++++++ lib/src/utils/extensions.dart | 26 +++ test/wrap_row_column_test.dart | 162 ++++++++++++++++++ 4 files changed, 298 insertions(+) create mode 100644 test/wrap_row_column_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 1321f911..ba9b472d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ ## Unreleased +### Layout + +- A row or column with `wrap` builds a `Wrap`: its alignment places + children within and across runs, `spacing` and `runSpacing` set the gaps, + and expanded children take their own size. A child hidden at runtime + leaves no gap. + ### Signing - Signature nodes take a signature by finger, pen or mouse and write its diff --git a/lib/src/transformers/node_transformers/passive_row_column_transformer.dart b/lib/src/transformers/node_transformers/passive_row_column_transformer.dart index 98670551..80dbea1a 100644 --- a/lib/src/transformers/node_transformers/passive_row_column_transformer.dart +++ b/lib/src/transformers/node_transformers/passive_row_column_transformer.dart @@ -1,6 +1,8 @@ +import 'dart:math' as math; import 'package:codelessly_api/codelessly_api.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart' show OverflowBoxFit; import '../../../codelessly_sdk.dart'; @@ -9,6 +11,11 @@ typedef WidgetInserter = Widget Function(Widget child); class PassiveRowColumnTransformer extends NodeWidgetTransformer { PassiveRowColumnTransformer(super.getNode, super.manager); + /// Whether [node] is a row/column that wraps its children onto new runs + /// (a [Wrap]) instead of laying them out on one line (a [Flex]). + static bool wrapsChildren(BaseNode node) => + node is RowColumnNode && node.wrap; + static Widget buildRowColumnWidget( BaseNode rowColumnNode, { required List childrenWidgets, @@ -57,6 +64,35 @@ class PassiveRowColumnTransformer extends NodeWidgetTransformer { ), ); + // A wrapping row/column is a [Wrap]: the children are broken into runs + // that fit its main axis. It hugs its runs on an axis it shrink-wraps and + // takes the outer box's size on the others. + if (wrapsChildren(rowColumnNode)) { + final RowColumnNode wrapNode = rowColumnNode as RowColumnNode; + Widget wrap = Wrap( + key: ValueKey(rowColumnNode.id), + direction: isRow ? Axis.horizontal : Axis.vertical, + alignment: wrapNode.mainAxisAlignment.flutterWrapAlignment, + runAlignment: wrapNode.mainAxisAlignment.flutterWrapAlignment, + crossAxisAlignment: + wrapNode.crossAxisAlignment.flutterWrapCrossAlignment, + spacing: math.max(0, wrapNode.spacing), + runSpacing: math.max(0, wrapNode.runSpacing), + children: childrenWidgets, + ); + if (withScroll && wrapNode.isScrollable) { + wrap = wrapWithPaddedScrollable( + padding: rowColumnNode.resolvedPadding().flutterEdgeInsets, + node: wrapNode, + fillViewport: + rowColumnNode.mainAxisAlignment != MainAxisAlignmentC.start, + flexContent: false, + child: wrap, + ); + } + return _sizeOuterBox(wrap, outerBox); + } + final SizeFit mainFit = isRow ? rowColumnNode.horizontalFit : rowColumnNode.verticalFit; @@ -121,6 +157,11 @@ class PassiveRowColumnTransformer extends NodeWidgetTransformer { ); } + return _sizeOuterBox(res, outerBox); + } + + /// Sizes a Row/Column's content [res] with its own box, [outerBox]. + static Widget _sizeOuterBox(Widget res, FitBox outerBox) { if (outerBox.width == null && outerBox.height == null && !outerBox.fillWidth && @@ -209,6 +250,31 @@ class PassiveRowColumnTransformer extends NodeWidgetTransformer { ? node.verticalFit : node.horizontalFit); + // A child of a [Wrap] can't flex: there is no free space to share in a + // run. An expanded child takes its own (solved) size on the main axis, + // as the editor lays it out: its box gets an unbounded main axis, where + // a fill falls back to its design size. The cross axis of a run is + // already unbounded. A child's own alignment is not applied: an [Align] + // would fill the run's main axis. + if (wrapsChildren(parentRowColumn)) { + if (flexibleSpaceBackground != null) { + childWidget = flexibleSpaceBackground(childWidget); + } + if (mainAxisFit == SizeFit.expanded) { + final bool isRow = mainAxis == AxisC.horizontal; + childWidget = OverflowBox( + alignment: AlignmentDirectional.topStart, + fit: OverflowBoxFit.deferToChild, + minWidth: isRow ? 0 : null, + maxWidth: isRow ? double.infinity : null, + minHeight: isRow ? null : 0, + maxHeight: isRow ? null : double.infinity, + child: childWidget, + ); + } + return childWidget; + } + // A parent that stretches its children across gives each one the full // cross axis, like the editor's solver, except a child that hugs on that // axis: the solver keeps its own size there and places it at the start. @@ -395,8 +461,42 @@ class PassiveRowColumnWidget extends StatelessWidget { ); } + /// A child of a wrapping row/column that a condition or variable hides at + /// runtime is left out of the [Wrap], so it takes no spacing either. The + /// Wrap rebuilds when what decides a child's visibility changes; each + /// child is keyed so the others keep their state. + Widget _buildWrap(BuildContext context) { + final NodeTransformerManager manager = this.manager; + if (manager is! PassiveNodeTransformerManager) { + return _buildContent(context, children); + } + Widget build(BuildContext context) => _buildContent(context, [ + for (final BaseNode child in children) + if (manager.runtimeVisibility(context, child) != false) child, + ]); + + final List listenables = [ + for (final BaseNode child in children) + ...manager.listenablesFor(context, child), + ]; + if (listenables.isEmpty) return build(context); + return ManagedListenableBuilder( + key: ValueKey('${node.id}-wrap'), + listenables: listenables, + builder: build, + ); + } + @override Widget build(BuildContext context) { + if (PassiveRowColumnTransformer.wrapsChildren(node)) { + return _buildWrap(context); + } + return _buildContent(context, children); + } + + Widget _buildContent(BuildContext context, List children) { + final bool wraps = PassiveRowColumnTransformer.wrapsChildren(node); final List widgetChildren = children.map((child) { final Widget builtWidget = manager.buildWidgetByID( child.id, @@ -411,6 +511,9 @@ class PassiveRowColumnWidget extends StatelessWidget { builtWidget, child.flex, ); + if (wraps) { + return KeyedSubtree(key: ValueKey(child.id), child: sizedWidget); + } return _collapseWhenHidden(context, child, sizedWidget); }).toList(); diff --git a/lib/src/utils/extensions.dart b/lib/src/utils/extensions.dart index 8cc1c817..b373801e 100644 --- a/lib/src/utils/extensions.dart +++ b/lib/src/utils/extensions.dart @@ -493,6 +493,32 @@ extension CMainAxisAlignmentHelper on MainAxisAlignmentC { } } +extension CWrapMainAxisAlignmentHelper on MainAxisAlignmentC { + /// The [flutter.WrapAlignment] of a wrapping row/column: used both for the + /// children within a run and for the runs within the cross axis. + flutter.WrapAlignment get flutterWrapAlignment => switch (this) { + MainAxisAlignmentC.start => flutter.WrapAlignment.start, + MainAxisAlignmentC.end => flutter.WrapAlignment.end, + MainAxisAlignmentC.center => flutter.WrapAlignment.center, + MainAxisAlignmentC.spaceBetween => flutter.WrapAlignment.spaceBetween, + MainAxisAlignmentC.spaceAround => flutter.WrapAlignment.spaceAround, + MainAxisAlignmentC.spaceEvenly => flutter.WrapAlignment.spaceEvenly, + }; +} + +extension CWrapCrossAxisAlignmentHelper on CrossAxisAlignmentC { + /// The [flutter.WrapCrossAlignment] of a wrapping row/column. A run is as + /// thick as its thickest child, so there is nothing to stretch to; stretch + /// and baseline fall back to start. + flutter.WrapCrossAlignment get flutterWrapCrossAlignment => switch (this) { + CrossAxisAlignmentC.center => flutter.WrapCrossAlignment.center, + CrossAxisAlignmentC.end => flutter.WrapCrossAlignment.end, + CrossAxisAlignmentC.start || + CrossAxisAlignmentC.stretch || + CrossAxisAlignmentC.baseline => flutter.WrapCrossAlignment.start, + }; +} + extension CCrossAxisAlignmentHelper on CrossAxisAlignmentC { flutter.CrossAxisAlignment get flutterAxis { switch (this) { diff --git a/test/wrap_row_column_test.dart b/test/wrap_row_column_test.dart new file mode 100644 index 00000000..5da0b214 --- /dev/null +++ b/test/wrap_row_column_test.dart @@ -0,0 +1,162 @@ +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'; + +RectangleNode box(String id, {double width = 60, double height = 30}) => + RectangleNode(id: id, name: id, basicBoxLocal: NodeBox(0, 0, width, height)) + ..parentID = 'row'; + +Finder findBox(String id) => + find.byKey(ValueKey('Rectangle Transformer of $id [$id]')); + +/// Pumps [row] holding [boxes] at the top left of the screen. +Future pumpRow( + WidgetTester tester, + RowColumnNode row, + List boxes, +) async { + row + ..parentID = kRootNode + ..children = [for (final b in boxes) b.id]; + final registry = NodeRegistry() + ..setNodes({ + for (final node in [row, ...boxes]) 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: Material( + child: Align( + alignment: Alignment.topLeft, + child: Builder( + builder: (context) => manager.buildWidgetFromNode( + row, + context, + settings: const WidgetBuildSettings(debugLabel: 'test'), + ), + ), + ), + ), + ), + ), + ); + expect(tester.takeException(), isNull); +} + +RowColumnNode wrapRow({ + double width = 200, + double height = 30, + MainAxisAlignmentC mainAxisAlignment = MainAxisAlignmentC.start, + CrossAxisAlignmentC crossAxisAlignment = CrossAxisAlignmentC.start, + SizeFit verticalFit = SizeFit.shrinkWrap, +}) => RowColumnNode( + id: 'row', + name: 'row', + basicBoxLocal: NodeBox(0, 0, width, height), + children: [], + rowColumnType: RowColumnType.row, + mainAxisAlignment: mainAxisAlignment, + crossAxisAlignment: crossAxisAlignment, + verticalFit: verticalFit, + wrap: true, + spacing: 10, + runSpacing: 8, +); + +void main() { + testWidgets('a wrapping row breaks its children into runs', (tester) async { + final row = wrapRow(); + final boxes = [for (int i = 0; i < 6; i++) box('b$i')]; + await pumpRow(tester, row, boxes); + + expect(find.byType(Wrap), findsOneWidget); + // 3 * 60 + 2 * 10 = 200: three boxes fit each run. + const expected = [ + Offset(0, 0), + Offset(70, 0), + Offset(140, 0), + Offset(0, 38), + Offset(70, 38), + Offset(140, 38), + ]; + for (int i = 0; i < boxes.length; i++) { + expect( + tester.getTopLeft(findBox('b$i')), + expected[i], + reason: 'b$i in run ${i ~/ 3}', + ); + expect(tester.getSize(findBox('b$i')), const Size(60, 30)); + } + // The row hugs both runs vertically and keeps its width. + expect(tester.getSize(find.byType(Wrap)), const Size(200, 68)); + }); + + testWidgets('main axis alignment aligns the children in each run and the ' + 'runs, cross axis alignment aligns children in their run', ( + tester, + ) async { + final row = wrapRow( + height: 100, + verticalFit: SizeFit.fixed, + mainAxisAlignment: MainAxisAlignmentC.center, + crossAxisAlignment: CrossAxisAlignmentC.end, + ); + await pumpRow(tester, row, [ + box('a', width: 80, height: 20), + box('b'), + box('c'), + ]); + + // Runs take 30 + 8 + 30 = 68 of 100 and start at 16. Run 1 takes 150 of + // 200 and starts at 25; a is 20 tall in a 30 tall run. + expect(tester.getTopLeft(findBox('a')), const Offset(25, 26)); + expect(tester.getTopLeft(findBox('b')), const Offset(115, 16)); + expect(tester.getTopLeft(findBox('c')), const Offset(70, 54)); + }); + + testWidgets('expanded children are not flexed and take their own size', ( + tester, + ) async { + final row = wrapRow(); + await pumpRow(tester, row, [ + box('a'), + box('wide', width: 100)..horizontalFit = SizeFit.expanded, + box('c'), + ]); + expect(find.byType(Flexible), findsNothing); + expect(tester.getSize(findBox('wide')), const Size(100, 30)); + expect(tester.getTopLeft(findBox('wide')), const Offset(70, 0)); + expect(tester.getTopLeft(findBox('c')), const Offset(0, 38)); + }); + + testWidgets('a child hidden at runtime takes no room or spacing', ( + tester, + ) async { + final row = wrapRow(); + await pumpRow(tester, row, [ + box('a'), + box('hidden')..variables['visible'] = r'${= false}', + box('b'), + box('c'), + ]); + expect(findBox('hidden'), findsNothing); + expect(tester.getTopLeft(findBox('a')), Offset.zero); + expect(tester.getTopLeft(findBox('b')), const Offset(70, 0)); + expect(tester.getTopLeft(findBox('c')), const Offset(140, 0)); + expect(tester.getSize(find.byType(Wrap)), const Size(200, 30)); + }); + + testWidgets('without wrap the row is a single line', (tester) async { + final row = wrapRow()..wrap = false; + await pumpRow(tester, row, [box('a'), box('b')]); + expect(find.byType(Wrap), findsNothing); + expect(tester.getTopLeft(findBox('b')), const Offset(60, 0)); + }); +} From 7c216fc738a8e95627675ae2567c3df9bca1befb Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 16:59:03 +0000 Subject: [PATCH 11/12] Drop ${hovered}: interaction states cover hover Master's per-node interaction states style a node and the nodes inside it when hovered, pressed, focused, selected or disabled, so a second hover mechanism only adds confusion. The hold trigger keeps its scope above the node's build, so the held node's own bindings read ${hold} too. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0147gqWiq3xoEdAGwWSpq9JK --- CHANGELOG.md | 20 ++-- .../passive_signature_transformer.dart | 3 +- lib/src/transformers/transformers.dart | 1 - .../transformers/utils/hold_to_confirm.dart | 6 +- lib/src/transformers/utils/hover_scope.dart | 50 --------- .../utils/property_value_delegate.dart | 1 - lib/src/transformers/utils/scoped_values.dart | 7 -- .../widget_node_transformer_manager.dart | 44 +++----- lib/src/utils/utils.dart | 2 - test/hovered_variable_test.dart | 105 ------------------ 10 files changed, 28 insertions(+), 211 deletions(-) delete mode 100644 lib/src/transformers/utils/hover_scope.dart delete mode 100644 test/hovered_variable_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index ba9b472d..15973395 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,12 +1,5 @@ ## Unreleased -### Layout - -- A row or column with `wrap` builds a `Wrap`: its alignment places - children within and across runs, `spacing` and `runSpacing` set the gaps, - and expanded children take their own size. A child hidden at runtime - leaves no gap. - ### Signing - Signature nodes take a signature by finger, pen or mouse and write its @@ -17,12 +10,10 @@ duration. Letting go or moving off drains it. While it is held, the nodes inside read the progress, 0 to 1, as `${hold}`, to fill a bar or change a label. -- A tappable node (one with click, long-press or hold actions) shares - whether a mouse is over it as `${hovered}`, which it and the nodes inside - it can bind for hover borders, fills and colors. The hold progress reaches - the held node's own properties too. +- The hold progress reaches the held node's own properties as well as + the nodes inside it. - `strokeWeight` can be bound, set by a condition or changed by an action, - for a border that thickens when picked or hovered. + for a border that thickens when picked. - An expanded child whose bound `flex` is 0 takes no space, where it used to keep its design size. @@ -38,6 +29,11 @@ ### Layout +- A row or column with `wrap` builds a `Wrap`: its alignment places + children within and across runs, `spacing` and `runSpacing` set the gaps, + and expanded children take their own size. A child hidden at runtime + leaves no gap. + - An expanded child's `flex` can be bound to data or set by a condition, so bars share a row by value (a gauge, a progress split). diff --git a/lib/src/transformers/node_transformers/passive_signature_transformer.dart b/lib/src/transformers/node_transformers/passive_signature_transformer.dart index 162068cc..ab4366e7 100644 --- a/lib/src/transformers/node_transformers/passive_signature_transformer.dart +++ b/lib/src/transformers/node_transformers/passive_signature_transformer.dart @@ -211,7 +211,8 @@ class SignaturePainter extends CustomPainter { ..strokeJoin = StrokeJoin.round; for (final List> stroke in strokes) { if (stroke.isEmpty) continue; - Offset at(List p) => Offset(p[0] * size.width, p[1] * size.height); + Offset at(List p) => + Offset(p[0] * size.width, p[1] * size.height); final Offset first = at(stroke.first); final Path path = Path()..moveTo(first.dx, first.dy); // A tap leaves a dot. diff --git a/lib/src/transformers/transformers.dart b/lib/src/transformers/transformers.dart index 2dd2e4dc..b5bbe90e 100644 --- a/lib/src/transformers/transformers.dart +++ b/lib/src/transformers/transformers.dart @@ -10,7 +10,6 @@ export 'utils/condition_visitors.dart'; export 'utils/dash_path.dart'; export 'utils/fit_layout.dart'; export 'utils/hold_to_confirm.dart'; -export 'utils/hover_scope.dart'; export 'utils/indexed_item_provider.dart'; export 'utils/node_geometry.dart'; export 'utils/property_overrides.dart'; diff --git a/lib/src/transformers/utils/hold_to_confirm.dart b/lib/src/transformers/utils/hold_to_confirm.dart index e4ba29e8..8ea3143e 100644 --- a/lib/src/transformers/utils/hold_to_confirm.dart +++ b/lib/src/transformers/utils/hold_to_confirm.dart @@ -130,10 +130,8 @@ class _HoldToConfirmState extends State child: AnimatedBuilder( animation: _controller, child: widget.child, - builder: (context, child) => HoldProgressProvider( - progress: _controller.value, - child: child!, - ), + builder: (context, child) => + HoldProgressProvider(progress: _controller.value, child: child!), ), ), ); diff --git a/lib/src/transformers/utils/hover_scope.dart b/lib/src/transformers/utils/hover_scope.dart deleted file mode 100644 index 87643f77..00000000 --- a/lib/src/transformers/utils/hover_scope.dart +++ /dev/null @@ -1,50 +0,0 @@ -import 'package:flutter/widgets.dart'; - -/// Tells the nodes inside a tappable node whether a mouse is over it; they -/// read it as the `hovered` variable, for hover borders and colors. -class HoverProvider extends InheritedWidget { - /// Whether the pointer is over the node. - final bool hovered; - - /// Creates a [HoverProvider]. - const HoverProvider({super.key, required this.hovered, required super.child}); - - /// Whether the nearest [HoverScope] above [context] is hovered, if any. - static bool? maybeOf(BuildContext context) => - context.dependOnInheritedWidgetOfExactType()?.hovered; - - @override - bool updateShouldNotify(HoverProvider oldWidget) => - hovered != oldWidget.hovered; -} - -/// Tracks whether a mouse is over [child] and shares it through a -/// [HoverProvider]. -class HoverScope extends StatefulWidget { - /// The widget hovered. - final Widget child; - - /// Creates a [HoverScope]. - const HoverScope({super.key, required this.child}); - - @override - State createState() => _HoverScopeState(); -} - -class _HoverScopeState extends State { - bool _hovered = false; - - void _set(bool hovered) { - if (hovered != _hovered) setState(() => _hovered = hovered); - } - - @override - Widget build(BuildContext context) { - return MouseRegion( - opaque: false, - onEnter: (_) => _set(true), - onExit: (_) => _set(false), - child: HoverProvider(hovered: _hovered, child: widget.child), - ); - } -} diff --git a/lib/src/transformers/utils/property_value_delegate.dart b/lib/src/transformers/utils/property_value_delegate.dart index c0731658..ad1a7ae5 100644 --- a/lib/src/transformers/utils/property_value_delegate.dart +++ b/lib/src/transformers/utils/property_value_delegate.dart @@ -414,7 +414,6 @@ class PropertyValueDelegate { 'result' => ActionChain.current?.result, 'screen' => scopedValues.screenSize?.let(screenVariableValue), 'hold' => scopedValues.holdProgress ?? 0.0, - 'hovered' => scopedValues.hovered ?? false, _ => null, }; diff --git a/lib/src/transformers/utils/scoped_values.dart b/lib/src/transformers/utils/scoped_values.dart index 02ac6708..fcef35fd 100644 --- a/lib/src/transformers/utils/scoped_values.dart +++ b/lib/src/transformers/utils/scoped_values.dart @@ -21,10 +21,6 @@ class ScopedValues { /// 0 to 1, or null outside one. late final double? holdProgress; - /// Whether the nearest tappable node above this context is hovered, or - /// null outside one. - late final bool? hovered; - /// The route params for this route/page for given context. late final Map routeParams; @@ -84,7 +80,6 @@ class ScopedValues { indexedItem = null, nodeState = null, holdProgress = null, - hovered = null, routeParams = {}, _appUser = null, screenSize = null; @@ -108,7 +103,6 @@ class ScopedValues { _previewVariables = variables, nodeState = null, holdProgress = null, - hovered = null, routeParams = {}; /// Creates a new scoped values instance from a given context. @@ -127,7 +121,6 @@ class ScopedValues { screenSize = MediaQuery.maybeSizeOf(context); nodeState = NodeStateProvider.maybeOf(context)?.state; holdProgress = HoldProgressProvider.maybeOf(context); - hovered = HoverProvider.maybeOf(context); routeParams = ModalRoute.of(context)?.settings.arguments as Map? ?? {}; diff --git a/lib/src/transformers/widget_node_transformer_manager.dart b/lib/src/transformers/widget_node_transformer_manager.dart index 23380d47..4e6b72c6 100644 --- a/lib/src/transformers/widget_node_transformer_manager.dart +++ b/lib/src/transformers/widget_node_transformer_manager.dart @@ -225,10 +225,9 @@ abstract class WidgetNodeTransformerManager } } - /// Wraps the whole build of a tappable [node] so it and the nodes inside - /// it can read `hovered`, and, when it has hold reactions, run them once it - /// has been held, sharing the progress as `hold`. [child] builds the node - /// below these scopes so the node's own bindings see them. + /// Wraps the whole build of a [node] with hold reactions so they run once + /// it has been held, sharing the progress as `hold`. [child] builds the + /// node below the scope so the node's own bindings see it too. Widget wrapWithInteractionScopes( BuildContext context, BaseNode node, @@ -236,33 +235,22 @@ abstract class WidgetNodeTransformerManager ) { if (node is! ReactionMixin) return child; if (node case CanvasNode() || SpacerNode()) return child; - final List reactions = (node as ReactionMixin).reactions - .where((reaction) => reaction.action.enabled) + final List onHoldReactions = (node as ReactionMixin).reactions + .where( + (reaction) => + reaction.trigger.type == TriggerType.hold && + reaction.action.enabled, + ) .toList(); - final bool tappable = reactions.any( - (reaction) => const { - TriggerType.click, - TriggerType.longPress, + if (onHoldReactions.isEmpty) return child; + return HoldToConfirm( + duration: Duration(milliseconds: onHoldReactions.first.trigger.duration), + onConfirmed: () => FunctionsRepository.triggerAction( + context, TriggerType.hold, - }.contains(reaction.trigger.type), - ); - if (!tappable) return child; - final List onHoldReactions = reactions - .where((reaction) => reaction.trigger.type == TriggerType.hold) - .toList(); - if (onHoldReactions.isEmpty) return HoverScope(child: child); - return HoverScope( - child: HoldToConfirm( - duration: Duration( - milliseconds: onHoldReactions.first.trigger.duration, - ), - onConfirmed: () => FunctionsRepository.triggerAction( - context, - TriggerType.hold, - reactions: onHoldReactions, - ), - child: child, + reactions: onHoldReactions, ), + child: child, ); } diff --git a/lib/src/utils/utils.dart b/lib/src/utils/utils.dart index d39baa9d..e18ca26f 100644 --- a/lib/src/utils/utils.dart +++ b/lib/src/utils/utils.dart @@ -18,7 +18,6 @@ const Set predefinedVariableNames = { 'result', 'screen', 'hold', - 'hovered', }; /// The key under which a layout's published variables hold the project's @@ -55,7 +54,6 @@ final Set predefinedVariables = { PredefinedVariableData(name: 'result'), PredefinedVariableData(name: 'screen', type: VariableType.map), PredefinedVariableData(name: 'hold', type: VariableType.decimal), - PredefinedVariableData(name: 'hovered', type: VariableType.boolean), }; /// Widths at which the `screen` variable's breakpoint changes: below 600 is diff --git a/test/hovered_variable_test.dart b/test/hovered_variable_test.dart deleted file mode 100644 index 3870901e..00000000 --- a/test/hovered_variable_test.dart +++ /dev/null @@ -1,105 +0,0 @@ -import 'package:codelessly_api/codelessly_api.dart'; -import 'package:codelessly_sdk/codelessly_sdk.dart'; -import 'package:flutter/gestures.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:provider/provider.dart'; - -RectangleNode bar(String id, String flex) => - RectangleNode( - id: id, - name: id, - basicBoxLocal: NodeBox(0, 0, 10, 40), - horizontalFit: SizeFit.expanded, - ) - ..parentID = 'tile' - ..variables['flex'] = flex; - -/// A 300 wide tappable tile whose `a` bar takes 2/3 of it when hovered. -Future pumpTile(WidgetTester tester, {bool tappable = true}) async { - final RowColumnNode tile = RowColumnNode( - id: 'tile', - name: 'tile', - basicBoxLocal: NodeBox(0, 0, 300, 40), - children: const ['a', 'b'], - rowColumnType: RowColumnType.row, - reactions: [ - if (tappable) - Reaction( - action: ShowToastAction(message: 'Opened'), - trigger: TriggerModel(type: TriggerType.click), - ), - ], - ) - ..parentID = kRootNode - ..variables['opacity'] = r'${= if(hovered, 0.5, 1)}'; - final nodes = [ - tile, - bar('a', r'${= if(hovered, 2, 1)}'), - bar('b', r'${= 1}'), - ]; - final registry = NodeRegistry()..setNodes({for (final n in nodes) n.id: n}); - final manager = PassiveNodeTransformerManager( - registry.getNodeByID, - (context, bounds, pageID, layoutID, canvasID) => const SizedBox(), - ); - await tester.pumpWidget( - ChangeNotifierProvider.value( - value: CodelesslyContext.empty(), - child: MaterialApp( - home: Material( - child: Align( - alignment: Alignment.topLeft, - child: Builder( - builder: (context) => manager.buildWidgetFromNode( - tile, - context, - settings: const WidgetBuildSettings(debugLabel: 'test'), - ), - ), - ), - ), - ), - ), - ); -} - -double barWidth(WidgetTester tester) => tester - .getSize(find.byKey(const ValueKey('Rectangle Transformer of a [a]'))) - .width; - -void main() { - testWidgets('nodes inside a tappable node read whether it is hovered', ( - tester, - ) async { - await pumpTile(tester); - expect(barWidth(tester), closeTo(150, 0.5)); - - final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse); - await mouse.addPointer(location: const Offset(500, 500)); - await mouse.moveTo(const Offset(100, 20)); - await tester.pump(); - expect(barWidth(tester), closeTo(200, 0.5)); - // The tile's own properties see it too: a hover border or fill. - expect( - tester - .widgetList(find.byType(Opacity)) - .any((o) => o.opacity == 0.5), - isTrue, - ); - - await mouse.moveTo(const Offset(500, 500)); - await tester.pump(); - expect(barWidth(tester), closeTo(150, 0.5)); - await mouse.removePointer(); - }); - - testWidgets('outside a tappable node hovered is false', (tester) async { - await pumpTile(tester, tappable: false); - final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse); - await mouse.addPointer(location: const Offset(100, 20)); - await tester.pump(); - expect(barWidth(tester), closeTo(150, 0.5)); - await mouse.removePointer(); - }); -} From 1dcea7c17d549f341d1e127f594c66ad8786efb3 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 23:37:09 +0000 Subject: [PATCH 12/12] Format tests and clear analyzer infos for CI Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0147gqWiq3xoEdAGwWSpq9JK --- .../passive_signature_transformer.dart | 2 +- test/flex_binding_test.dart | 2 +- test/formula_records_test.dart | 44 ++++++++----- test/icon_name_binding_test.dart | 64 +++++++++++-------- test/signature_node_test.dart | 24 ++++--- 5 files changed, 80 insertions(+), 56 deletions(-) diff --git a/lib/src/transformers/node_transformers/passive_signature_transformer.dart b/lib/src/transformers/node_transformers/passive_signature_transformer.dart index ab4366e7..843dc552 100644 --- a/lib/src/transformers/node_transformers/passive_signature_transformer.dart +++ b/lib/src/transformers/node_transformers/passive_signature_transformer.dart @@ -129,7 +129,7 @@ class _PassiveSignatureWidgetState extends State { final Widget paint = CustomPaint( size: size, painter: SignaturePainter( - strokes: [...strokes, if (_stroke case final stroke?) stroke], + strokes: [...strokes, ?_stroke], color: ink, strokeWidth: node.properties.strokeWidth, ), diff --git a/test/flex_binding_test.dart b/test/flex_binding_test.dart index d37e0269..a85f6ef2 100644 --- a/test/flex_binding_test.dart +++ b/test/flex_binding_test.dart @@ -12,7 +12,7 @@ RectangleNode bar(String id, {String? flex}) => horizontalFit: SizeFit.expanded, ) ..parentID = 'row' - ..variables.addAll({if (flex != null) 'flex': flex}); + ..variables.addAll({'flex': ?flex}); Future> pumpRow( WidgetTester tester, diff --git a/test/formula_records_test.dart b/test/formula_records_test.dart index 63cbb754..c1321b7b 100644 --- a/test/formula_records_test.dart +++ b/test/formula_records_test.dart @@ -24,19 +24,27 @@ void main() { 'name': 'Keystone', }; - Object? eval(String expression) => FormulaEvaluator.evaluate(expression, (path) { - Object? value = vars; - for (final part in path.split('.')) { - value = value is Map ? value[part] : null; - } - return value; - }); + Object? eval(String expression) => + FormulaEvaluator.evaluate(expression, (path) { + Object? value = vars; + for (final part in path.split('.')) { + value = value is Map ? value[part] : null; + } + return value; + }); test('obj, set, unset and merge build maps without braces', () { - expect(eval('obj("company", name, "n", 4)'), {'company': 'Keystone', 'n': 4}); + expect(eval('obj("company", name, "n", 4)'), { + 'company': 'Keystone', + 'n': 4, + }); expect(eval('set(obj("a", 1), "b", 2)'), {'a': 1, 'b': 2}); expect(eval('unset(obj("a", 1, "b", 2), "a")'), {'b': 2}); - expect(eval('merge(obj("a", 1, "b", 2), obj("b", 3), obj("c", 4))'), {'a': 1, 'b': 3, 'c': 4}); + expect(eval('merge(obj("a", 1, "b", 2), obj("b", 3), obj("c", 4))'), { + 'a': 1, + 'b': 3, + 'c': 4, + }); expect(eval('set(tags, 1, "x")'), ['a', 'x', 'c', 'd']); expect(eval('set(tags, 9, "z")'), ['a', 'b', 'c', 'd', 'z']); }); @@ -46,17 +54,19 @@ void main() { expect(eval('prepend(tags, "z")'), ['z', 'a', 'b', 'c', 'd']); expect(eval('concat(tags, ["e"], null)'), ['a', 'b', 'c', 'd', 'e']); expect(eval('append(null, 1)'), [1]); - expect(vars['tags'], ['a', 'b', 'c', 'd'], reason: 'arguments are not changed'); + expect(vars['tags'], [ + 'a', + 'b', + 'c', + 'd', + ], reason: 'arguments are not changed'); }); test('updateWhere and removeWhere change matching records', () { - expect( - eval('updateWhere(logs, "id", logId, obj("signed", true))'), - [ - (vars['logs'] as List)[0], - {...((vars['logs'] as List)[1] as Map), 'signed': true}, - ], - ); + expect(eval('updateWhere(logs, "id", logId, obj("signed", true))'), [ + (vars['logs'] as List)[0], + {...((vars['logs'] as List)[1] as Map), 'signed': true}, + ]); // A nested update: one crew entry inside one log. final Object? result = eval( 'updateWhere(logs, l => l.id == logId, l => set(l, "crew", ' diff --git a/test/icon_name_binding_test.dart b/test/icon_name_binding_test.dart index d1727bcb..9f030d6f 100644 --- a/test/icon_name_binding_test.dart +++ b/test/icon_name_binding_test.dart @@ -1,6 +1,5 @@ import 'package:codelessly_api/codelessly_api.dart'; import 'package:codelessly_sdk/codelessly_sdk.dart'; -import 'package:codelessly_sdk/src/transformers/node_transformers/passive_icon_transformer.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { @@ -9,29 +8,36 @@ void main() { test('a Material Icon shows the named Material Symbol in the same style', () { final MultiSourceIconModel icon = withIconName( - model(MaterialIcon( - codepoint: 0xe0c3, - fontFamily: 'MaterialIcons', - name: 'help_outline', - style: MaterialIconStyle.rounded, - version: 12, - )), + model( + const MaterialIcon( + codepoint: 0xe0c3, + fontFamily: 'MaterialIcons', + name: 'help_outline', + style: MaterialIconStyle.rounded, + version: 12, + ), + ), 'water_drop', ); expect(icon.icon, isA()); - expect(icon.icon!.toSvgUrl(), contains('/materialsymbolsrounded/water_drop/')); + expect( + icon.icon!.toSvgUrl(), + contains('/materialsymbolsrounded/water_drop/'), + ); expect(icon.size, 18); expect(icon.show, isTrue); }); test('a filled Material Icon becomes a filled outlined symbol', () { final MultiSourceIconModel icon = withIconName( - model(MaterialIcon( - codepoint: 0, - fontFamily: 'MaterialIcons', - name: 'add', - style: MaterialIconStyle.filled, - )), + model( + const MaterialIcon( + codepoint: 0, + fontFamily: 'MaterialIcons', + name: 'add', + style: MaterialIconStyle.filled, + ), + ), 'thunderstorm', ); expect(icon.fill, 1); @@ -43,12 +49,14 @@ void main() { test('a Material Symbol keeps its style and swaps its name', () { final MultiSourceIconModel icon = withIconName( - model(MaterialSymbol( - codepoint: 0, - fontFamily: 'MaterialSymbols', - name: 'home', - style: MaterialSymbolStyle.sharp, - )), + model( + MaterialSymbol( + codepoint: 0, + fontFamily: 'MaterialSymbols', + name: 'home', + style: MaterialSymbolStyle.sharp, + ), + ), 'air', ); expect(icon.icon!.toSvgUrl(), contains('/materialsymbolssharp/air/')); @@ -60,12 +68,14 @@ void main() { name: 'Sky', basicBoxLocal: NodeBox(0, 0, 18, 18), properties: IconProperties( - icon: model(MaterialIcon( - codepoint: 0, - fontFamily: 'MaterialIcons', - name: 'help', - style: MaterialIconStyle.outlined, - )), + icon: model( + const MaterialIcon( + codepoint: 0, + fontFamily: 'MaterialIcons', + name: 'help', + style: MaterialIconStyle.outlined, + ), + ), ), ); expect( diff --git a/test/signature_node_test.dart b/test/signature_node_test.dart index 5e7ce012..99296190 100644 --- a/test/signature_node_test.dart +++ b/test/signature_node_test.dart @@ -55,15 +55,17 @@ Future pumpPad( return ctx; } -SignatureStrokes painted(WidgetTester tester) => (tester - .widget( - find.descendant( - of: find.byType(PassiveSignatureWidget), - matching: find.byType(CustomPaint), - ), - ) - .painter as SignaturePainter) - .strokes; +SignatureStrokes painted(WidgetTester tester) => + (tester + .widget( + find.descendant( + of: find.byType(PassiveSignatureWidget), + matching: find.byType(CustomPaint), + ), + ) + .painter + as SignaturePainter) + .strokes; void main() { test('strokes parse from bound data and ignore junk', () { @@ -114,7 +116,9 @@ void main() { await tester.dragFrom(const Offset(30, 50), const Offset(150, 20)); await tester.pumpAndSettle(); - final strokes = parseSignatureStrokes(ctx.variables['sig']!.value.getValue()); + final strokes = parseSignatureStrokes( + ctx.variables['sig']!.value.getValue(), + ); expect(strokes, hasLength(1)); expect(strokes.first.first[0], closeTo(0.1, 0.01)); expect(strokes.first.first[1], closeTo(0.5, 0.01));