From 27aa314a4be757b4b137ff63168432a50c5c7a18 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 22:21:01 +0000 Subject: [PATCH 01/13] Formulas: findIndex, obj, set, merge, concat, append, take, skip, group, pad Actions could read list items but not build or change one: there was no way to find an item's index, make a map, or copy a record with one field changed. These let a setVariable update one item of a list variable, e.g. set(defects, findIndex(defects, d => d.id == id), merge(d, obj(...))). group() formats 412908 as 412,908. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011WedhUVWdWR5swqZYMuGfP --- lib/src/utils/formula.dart | 122 +++++++++++++++++++++++++++++++++ test/formula_records_test.dart | 82 ++++++++++++++++++++++ 2 files changed, 204 insertions(+) create mode 100644 test/formula_records_test.dart diff --git a/lib/src/utils/formula.dart b/lib/src/utils/formula.dart index 99ac5af5..e9368762 100644 --- a/lib/src/utils/formula.dart +++ b/lib/src/utils/formula.dart @@ -158,6 +158,49 @@ class FormulaEvaluator { /// How to call each function, with an example, for editors to list. static const List functionDocs = [ + FormulaFunctionDoc( + 'findIndex', + 'findIndex(list, item => test)', + 'findIndex(tasks, t => t.id == id) → 2', + ), + FormulaFunctionDoc( + 'indexOf', + 'indexOf(list, value)', + 'indexOf(["a", "b"], "b") → 1', + ), + FormulaFunctionDoc( + 'obj', + 'obj(key, value, ...)', + 'obj("status", "done", "by", user.name) → {status: done, by: Ann}', + ), + FormulaFunctionDoc( + 'set', + 'set(mapOrList, keyOrIndex, value)', + 'set(task, "done", true) → a copy of task with done true', + ), + FormulaFunctionDoc( + 'merge', + 'merge(map, map, ...)', + 'merge(task, obj("done", true)) → task with done true', + ), + FormulaFunctionDoc( + 'concat', + 'concat(list, list, ...)', + 'concat([1], [2, 3]) → [1, 2, 3]', + ), + FormulaFunctionDoc( + 'append', + 'append(list, item, ...)', + 'append(tags, "new") → tags plus "new"', + ), + FormulaFunctionDoc( + 'take', + 'take(list, count)', + 'take([1, 2, 3], 2) → [1, 2]', + ), + FormulaFunctionDoc('skip', 'skip(list, count)', 'skip([1, 2, 3], 2) → [3]'), + FormulaFunctionDoc('group', 'group(number)', 'group(412908) → 412,908'), + FormulaFunctionDoc('pad', 'pad(value, width, fill)', 'pad(7, 2) → 07'), FormulaFunctionDoc( 'money', 'money(value, symbol)', @@ -786,6 +829,63 @@ typedef _Function = Object? Function(List args); Object? _arg(List args, int index) => index < args.length ? args[index] : null; +/// Index of the first item of the list matching the predicate in [args] +/// (as in `find`), or of the item equal to the value when the second +/// argument is neither a lambda nor followed by a value. -1 when none. +int _findIndex(List args) { + final Object? list = _arg(args, 0); + if (list is! Iterable) return -1; + final List rest = args.skip(1).toList(); + int i = 0; + for (final Object? item in list) { + final bool hit = rest.length == 1 && rest.first is! _Closure + ? _looseEquals(item, rest.first) + : _matches(item, rest); + if (hit) return i; + i++; + } + return -1; +} + +/// A copy of [target] with [key] set to [value]: a map key, or a list index +/// (an index equal to the length appends). Other targets come back as is. +Object? _setIn(Object? target, Object? key, Object? value) { + if (target is Map) { + return { + ...target.map((k, v) => MapEntry(k.toString(), v)), + FormulaEvaluator.stringify(key): value, + }; + } + if (target is Iterable) { + final List list = List.of(target); + final int i = _intArg(key); + if (i >= 0 && i < list.length) { + list[i] = value; + } else if (i == list.length) { + list.add(value); + } + return list; + } + return target; +} + +/// [value] with thousands separators: 412908 → "412,908". +String _group(Object? value) { + final num? n = _toNum(value); + if (n == null) return FormulaEvaluator.stringify(value); + final String sign = n < 0 ? '-' : ''; + final String text = n.abs().toString(); + final int dot = text.indexOf('.'); + final String whole = dot < 0 ? text : text.substring(0, dot); + final String fraction = dot < 0 ? '' : text.substring(dot); + final StringBuffer out = StringBuffer(); + for (int i = 0; i < whole.length; i++) { + if (i > 0 && (whole.length - i) % 3 == 0) out.write(','); + out.write(whole[i]); + } + return '$sign$out$fraction'; +} + final Map _functions = { 'money': (a) => _money(_arg(a, 0), _arg(a, 1)), 'fixed': (a) => _fixed(_arg(a, 0), _arg(a, 1)), @@ -850,6 +950,28 @@ final Map _functions = { if (!_values(_arg(a, 1), null).any((other) => _looseEquals(item, other))) item, ], + 'findIndex': (a) => _findIndex(a), + 'indexOf': (a) => _findIndex(a), + 'obj': (a) => { + for (int i = 0; i + 1 < a.length; i += 2) + FormulaEvaluator.stringify(a[i]): a[i + 1], + }, + 'set': (a) => _setIn(_arg(a, 0), _arg(a, 1), _arg(a, 2)), + 'merge': (a) => { + for (final Object? m in a) + if (m is Map) ...m.map((k, v) => MapEntry(k.toString(), v)), + }, + 'concat': (a) => [ + for (final Object? l in a) + if (l is Iterable) ...l else if (l != null) l, + ], + 'append': (a) => [..._values(_arg(a, 0), null), ...a.skip(1)], + 'take': (a) => _values(_arg(a, 0), null).take(_intArg(_arg(a, 1))).toList(), + 'skip': (a) => _values(_arg(a, 0), null).skip(_intArg(_arg(a, 1))).toList(), + 'group': (a) => _group(_arg(a, 0)), + 'pad': (a) => FormulaEvaluator.stringify( + _arg(a, 0), + ).padLeft(_intArg(_arg(a, 1)), FormulaEvaluator.stringify(_arg(a, 2) ?? '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..859aee71 --- /dev/null +++ b/test/formula_records_test.dart @@ -0,0 +1,82 @@ +import 'package:codelessly_sdk/codelessly_sdk.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Functions that build and update records (maps) and lists, so actions +/// can change one item of a list variable: findIndex, obj, set, merge, +/// concat, append, take, skip, group, pad. +void main() { + final Map vars = { + 'defects': [ + {'id': 'DF-1', 'status': 'open', 'timeline': []}, + { + 'id': 'DF-2', + 'status': 'repaired', + 'timeline': ['a'], + }, + ], + 'id': 'DF-2', + 'user': {'id': 'u1', 'name': 'Luis'}, + 'answers': {'1a': 'pass'}, + }; + Object? eval(String expression) => FormulaEvaluator.evaluate( + expression, + (path) => path + .split('.') + .fold(vars, (value, key) => value is Map ? value[key] : null), + ); + + test('findIndex finds by lambda, field and value, or plain value', () { + expect(eval('findIndex(defects, d => d.id == id)'), 1); + expect(eval('findIndex(defects, "status", "open")'), 0); + expect(eval('indexOf(["a", "b"], "b")'), 1); + expect(eval('findIndex(defects, d => d.id == "nope")'), -1); + expect(eval('findIndex(null, d => true)'), -1); + }); + + test('obj builds a map from key/value pairs', () { + expect(eval('obj("t", 3, "by", user.name)'), {'t': 3, 'by': 'Luis'}); + expect(eval('obj()'), {}); + }); + + test('set copies a map or list with one key or index changed', () { + expect(eval('set(answers, "1b", "fail")'), {'1a': 'pass', '1b': 'fail'}); + expect(vars['answers'], { + '1a': 'pass', + }, reason: 'the original is unchanged'); + expect(eval('set([1, 2, 3], 1, 9)'), [1, 9, 3]); + expect(eval('set([1, 2], 2, 3)'), [1, 2, 3]); + final Object? updated = eval( + 'set(defects, findIndex(defects, d => d.id == id), ' + 'set(find(defects, d => d.id == id), "status", "verified"))', + ); + expect((updated! as List)[1], { + 'id': 'DF-2', + 'status': 'verified', + 'timeline': ['a'], + }); + }); + + test('merge, concat and append combine records and lists', () { + expect(eval('merge(user, obj("role", "mechanic"))'), { + 'id': 'u1', + 'name': 'Luis', + 'role': 'mechanic', + }); + expect(eval('concat([1], [2, 3], 4)'), [1, 2, 3, 4]); + expect(eval('append([1], 2, 3)'), [1, 2, 3]); + expect(eval('append(null, 1)'), [1]); + }); + + test('take and skip slice lists', () { + expect(eval('take([1, 2, 3], 2)'), [1, 2]); + expect(eval('skip([1, 2, 3], 2)'), [3]); + }); + + test('group adds thousands separators and pad left-pads', () { + expect(eval('group(412908)'), '412,908'); + expect(eval('group(-1234.5)'), '-1,234.5'); + expect(eval('group(12)'), '12'); + expect(eval('pad(7, 2)'), '07'); + expect(eval('pad("5", 3, " ")'), ' 5'); + }); +} From 3217a694583276ad752774cb14fd03fe86d82897 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 22:30:01 +0000 Subject: [PATCH 02/13] Fills: paint repeating gradients Gradient fills use the paint's tileMode and scale the gradient so that one run is gradientExtent pixels long (the radius of a radial gradient), so a short gradient with hard stops paints stripes on rectangles, frames and anything else that builds its fills with buildFills. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 11 + .../passive_rectangle_transformer.dart | 110 ++++++++-- test/repeating_gradient_test.dart | 197 ++++++++++++++++++ 3 files changed, 303 insertions(+), 15 deletions(-) create mode 100644 test/repeating_gradient_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index fc42932b..4c99bd77 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,14 @@ +## Unreleased + +### Repeating gradients + +- Gradient fills honour `PaintModel.tileMode` (clamp, repeated, mirror) and + `gradientExtent`, the length in pixels of one run of the gradient (the + radius of a radial one), so a short gradient with hard stops paints + stripes. `GradientExtentTransform` rotates and scales the gradient about + the center of its box. Needs the unreleased `codelessly_api` change that + adds these fields. + ## 8.2.0 ### States diff --git a/lib/src/transformers/node_transformers/passive_rectangle_transformer.dart b/lib/src/transformers/node_transformers/passive_rectangle_transformer.dart index d60a661b..d37818d8 100644 --- a/lib/src/transformers/node_transformers/passive_rectangle_transformer.dart +++ b/lib/src/transformers/node_transformers/passive_rectangle_transformer.dart @@ -431,30 +431,36 @@ Gradient? retrieveGradient(PaintModel paint) { (g1, g2) => g1.position.compareTo(g2.position), ); final radians = rotation * pi / 180; + final colors = gradientStops + .map((e) => e.color.multiplyAlpha(paint.opacity).toFlutterColor()) + .toList(); + final stops = gradientStops.map((e) => e.position).toList(); + final TileMode tileMode = paint.tileMode.flutterTileMode; + final GradientTransform transform = retrieveGradientTransform( + paint, + radians, + ); if (paint.type == PaintType.gradientLinear) { return LinearGradient( - colors: gradientStops - .map((e) => e.color.multiplyAlpha(paint.opacity).toFlutterColor()) - .toList(), - stops: gradientStops.map((e) => e.position).toList(), - transform: GradientRotation(radians), + colors: colors, + stops: stops, + tileMode: tileMode, + transform: transform, ); } else if (paint.type == PaintType.gradientRadial) { return RadialGradient( - colors: gradientStops - .map((e) => e.color.multiplyAlpha(paint.opacity).toFlutterColor()) - .toList(), - stops: gradientStops.map((e) => e.position).toList(), - transform: GradientRotation(radians), + colors: colors, + stops: stops, + tileMode: tileMode, + transform: transform, ); } else if (paint.type == PaintType.gradientAngular) { return SweepGradient( - colors: gradientStops - .map((e) => e.color.multiplyAlpha(paint.opacity).toFlutterColor()) - .toList(), - stops: gradientStops.map((e) => e.position).toList(), - transform: GradientRotation(radians), + colors: colors, + stops: stops, + tileMode: tileMode, + transform: transform, ); } @@ -468,6 +474,80 @@ Gradient? retrieveGradient(PaintModel paint) { } } +/// The transform of [paint]'s gradient: its rotation and, when the paint has +/// a [PaintModel.gradientExtent], the scale that makes one run of the +/// gradient that many pixels long. +GradientTransform retrieveGradientTransform(PaintModel paint, double radians) { + final double? extent = paint.gradientExtent; + if (extent == null || + extent <= 0 || + paint.type == PaintType.gradientAngular) { + return GradientRotation(radians); + } + return GradientExtentTransform( + radians: radians, + extent: extent, + radial: paint.type == PaintType.gradientRadial, + ); +} + +extension GradientTileModeExtension on GradientTileMode { + /// The Flutter [TileMode] of this tile mode. + TileMode get flutterTileMode => switch (this) { + GradientTileMode.clamp => TileMode.clamp, + GradientTileMode.repeated => TileMode.repeated, + GradientTileMode.mirror => TileMode.mirror, + }; +} + +/// Rotates a gradient about the center of its box, like [GradientRotation], +/// and scales it about that center so that one run of the gradient is +/// [extent] pixels long: the length of a linear gradient, or the radius of a +/// radial one. With a repeating [TileMode] the gradient then tiles across the +/// box, for example into stripes. +class GradientExtentTransform extends GradientTransform { + /// Creates a [GradientExtentTransform]. + const GradientExtentTransform({ + required this.radians, + required this.extent, + this.radial = false, + }); + + /// The angle of rotation in radians in the clockwise direction. + final double radians; + + /// The length in pixels of one run of the gradient. + final double extent; + + /// Whether the gradient is radial, whose run is its radius rather than the + /// width of the box. + final bool radial; + + @override + Matrix4 transform(Rect bounds, {TextDirection? textDirection}) { + // Unscaled, a linear gradient runs across the box's width and a radial + // one's radius is half the box's shortest side. + final double natural = radial ? bounds.shortestSide / 2 : bounds.width; + final double scale = natural > 0 ? extent / natural : 1; + final Offset center = bounds.center; + return Matrix4.identity() + ..translateByDouble(center.dx, center.dy, 0, 1) + ..rotateZ(radians) + ..scaleByDouble(scale, scale, 1, 1) + ..translateByDouble(-center.dx, -center.dy, 0, 1); + } + + @override + bool operator ==(Object other) => + other is GradientExtentTransform && + other.radians == radians && + other.extent == extent && + other.radial == radial; + + @override + int get hashCode => Object.hash(radians, extent, radial); +} + // from https://math.stackexchange.com/a/2888105 RelativeTransform decomposeRelativeTransform(List? t1) { t1 ??= defaultGradientTransform(); diff --git a/test/repeating_gradient_test.dart b/test/repeating_gradient_test.dart new file mode 100644 index 00000000..8700d8f0 --- /dev/null +++ b/test/repeating_gradient_test.dart @@ -0,0 +1,197 @@ +import 'dart:math'; +import 'dart:typed_data'; +import 'dart:ui' as ui; + +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:codelessly_sdk/codelessly_sdk.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; + +const ColorRGBA yellow = ColorRGBA(r: 1, g: 0.8, b: 0, a: 1); + +/// 10px yellow and black stripes at 45°. +PaintModel stripes({ + GradientTileMode tileMode = GradientTileMode.repeated, + double? extent = 20, + PaintType type = PaintType.gradientLinear, +}) => PaintModel( + id: 'stripes', + type: type, + gradientTransform: applyGradientRotation(null, 45), + gradientStops: const [ + ColorStop(color: yellow, position: 0), + ColorStop(color: yellow, position: 0.5), + ColorStop(color: ColorRGBA.black, position: 0.5), + ColorStop(color: ColorRGBA.black, position: 1), + ], + tileMode: tileMode, + gradientExtent: extent, +); + +Future pumpNode(WidgetTester tester, BaseNode node) async { + final registry = NodeRegistry()..setNodes({node.id: node}); + final manager = PassiveNodeTransformerManager( + registry.getNodeByID, + (context, bounds, pageID, layoutID, canvasID) => const SizedBox(), + ); + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: CodelesslyContext.empty(), + child: MaterialApp( + home: Align( + alignment: Alignment.topLeft, + child: Builder( + builder: (context) => manager.buildWidgetFromNode( + node, + context, + settings: const WidgetBuildSettings(debugLabel: 'test'), + ), + ), + ), + ), + ), + ); +} + +/// The gradients of the fills painted on screen. +List paintedGradients(WidgetTester tester) => [ + for (final Widget widget in tester.allWidgets) + if (widget is Ink && widget.decoration is BoxDecoration) + (widget.decoration as BoxDecoration).gradient + else if (widget is DecoratedBox && widget.decoration is BoxDecoration) + (widget.decoration as BoxDecoration).gradient, +].nonNulls.toList(); + +void main() { + test('a repeating gradient tiles over its extent', () { + final gradient = retrieveGradient(stripes()) as LinearGradient; + expect(gradient.tileMode, TileMode.repeated); + expect(gradient.stops, [0, 0.5, 0.5, 1]); + expect( + gradient.transform, + const GradientExtentTransform(radians: pi / 4, extent: 20), + ); + + // One run of the gradient, from the box's left to its right edge before + // the transform, is 20px long after it, whatever the box's size. + const Rect bounds = Rect.fromLTWH(0, 0, 300, 8); + final matrix = gradient.transform!.transform(bounds)!; + final Offset start = MatrixUtils.transformPoint(matrix, bounds.centerLeft); + final Offset end = MatrixUtils.transformPoint(matrix, bounds.centerRight); + expect((end - start).distance, closeTo(20, 1e-9)); + expect((end - start).direction, closeTo(pi / 4, 1e-9)); + expect((start + end) / 2, bounds.center); + }); + + test('a radial gradient repeats over its radius', () { + final gradient = retrieveGradient( + stripes(type: PaintType.gradientRadial), + ) as RadialGradient; + expect(gradient.tileMode, TileMode.repeated); + const Rect bounds = Rect.fromLTWH(0, 0, 100, 40); + final matrix = gradient.transform!.transform(bounds)!; + // The unscaled radius is half the shortest side, 20px; one run is 20px. + final Offset edge = MatrixUtils.transformPoint( + matrix, + bounds.center + const Offset(20, 0), + ); + expect((edge - bounds.center).distance, closeTo(20, 1e-9)); + }); + + test('a gradient without an extent keeps spanning its box', () { + final gradient = retrieveGradient( + stripes(tileMode: GradientTileMode.clamp, extent: null), + ) as LinearGradient; + expect(gradient.tileMode, TileMode.clamp); + expect(gradient.transform, const GradientRotation(pi / 4)); + + final mirrored = retrieveGradient( + stripes(tileMode: GradientTileMode.mirror), + ) as LinearGradient; + expect(mirrored.tileMode, TileMode.mirror); + }); + + testWidgets('a rectangle paints its fill as repeating stripes', ( + tester, + ) async { + await pumpNode( + tester, + RectangleNode( + id: 'bar', + name: 'Hazard Bar', + basicBoxLocal: NodeBox(0, 0, 300, 8), + fills: [stripes()], + )..parentID = kRootNode, + ); + final gradients = paintedGradients(tester); + expect(gradients, hasLength(1)); + expect((gradients.single as LinearGradient).tileMode, TileMode.repeated); + expect(tester.takeException(), isNull); + }); + + testWidgets('a frame paints its fill as repeating stripes', (tester) async { + await pumpNode( + tester, + FrameNode( + id: 'frame', + name: 'Hazard Frame', + basicBoxLocal: NodeBox(0, 0, 300, 10), + fills: [stripes()], + children: const [], + )..parentID = kRootNode, + ); + final gradients = paintedGradients(tester); + expect(gradients, hasLength(1)); + expect( + gradients.single.transform, + const GradientExtentTransform(radians: pi / 4, extent: 20), + ); + }); + + testWidgets('hard stops paint crisp stripes every extent', (tester) async { + final GlobalKey key = GlobalKey(); + await tester.pumpWidget( + Align( + alignment: Alignment.topLeft, + child: RepaintBoundary( + key: key, + child: SizedBox( + width: 100, + height: 4, + child: DecoratedBox( + decoration: BoxDecoration( + // Upright stripes: left to right, 10px yellow then 10px black. + gradient: retrieveGradient( + stripes().copyWith( + gradientTransform: applyGradientRotation(null, 0), + ), + ), + ), + ), + ), + ), + ), + ); + final ByteData bytes = (await tester.runAsync(() async { + final boundary = + key.currentContext!.findRenderObject()! as RenderRepaintBoundary; + final ui.Image image = await boundary.toImage(); + return (await image.toByteData())!; + }))!; + Color pixel(int x) => Color( + // RGBA bytes to ARGB. + bytes.getUint32(x * 4, Endian.big) >> 8 | bytes.getUint8(x * 4 + 3) << 24, + ); + const Color black = Color(0xFF000000); + final Color yellowColor = yellow.toFlutterColor(); + // One run, 20px, is centered on the box: it starts at x = 40. + for (final int start in [0, 20, 40, 60, 80]) { + expect(pixel(start + 2), yellowColor, reason: 'x = ${start + 2}'); + expect(pixel(start + 8), yellowColor, reason: 'x = ${start + 8}'); + expect(pixel(start + 12), black, reason: 'x = ${start + 12}'); + expect(pixel(start + 18), black, reason: 'x = ${start + 18}'); + } + }); +} From 3b0bd83107eb9e23d5fd310fdd4bf12c754a76fb Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 22:32:43 +0000 Subject: [PATCH 03/13] Add the signature pad transformer PassiveSignatureTransformer draws a SignatureNode: background, border (error border when its error binding is true), sign line with an x, the placeholder while empty, and the strokes in round-cap ink smoothed with quadratic curves. Drawing claims the pointer at once, so a scrolling parent does not take a stroke. A finished stroke writes all strokes, as [[[x, y], ...], ...], to the bound value variable (or the node value) and fires changed; setting the variable to [] clears the pad. Also fixes the isEmpty and isNotEmpty accessors of list variables, which read the list as a map and gave null, so ${signature.isEmpty} works. Co-Authored-By: Claude Opus 5.5 --- .../node_transformers/node_transformers.dart | 1 + .../passive_signature_transformer.dart | 411 ++++++++++++++++++ .../passive_transformer_manager.dart | 1 + .../utils/property_value_delegate.dart | 4 +- test/signature_test.dart | 233 ++++++++++ test/transformers_test.dart | 1 + 6 files changed, 649 insertions(+), 2 deletions(-) create mode 100644 lib/src/transformers/node_transformers/passive_signature_transformer.dart create mode 100644 test/signature_test.dart diff --git a/lib/src/transformers/node_transformers/node_transformers.dart b/lib/src/transformers/node_transformers/node_transformers.dart index 8421a1e2..e01bc058 100644 --- a/lib/src/transformers/node_transformers/node_transformers.dart +++ b/lib/src/transformers/node_transformers/node_transformers.dart @@ -26,6 +26,7 @@ export 'passive_radio_transformer.dart'; export 'passive_rectangle_transformer.dart'; export 'passive_row_column_transformer.dart'; export 'passive_segmented_button_transformer.dart'; +export 'passive_signature_transformer.dart'; export 'passive_single_placeholder_transformer.dart'; export 'passive_slider_transformer.dart'; export 'passive_spacer_transformer.dart'; 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..13b33b9b --- /dev/null +++ b/lib/src/transformers/node_transformers/passive_signature_transformer.dart @@ -0,0 +1,411 @@ +import 'dart:convert'; + +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:collection/collection.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../../../codelessly_sdk.dart'; +import '../utils/node_state_provider.dart'; + +/// A stroke of a signature: its points, in the pad's coordinates. +typedef SignatureStroke = 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); + } + + /// Builds a signature pad that keeps its strokes to itself, for previews + /// such as the settings panel. + Widget buildPreview({ + SignatureProperties? properties, + SignatureNode? node, + double? height, + double? width, + }) { + final SignatureNode previewNode = SignatureNode( + id: '', + name: 'Signature', + basicBoxLocal: NodeBox( + 0, + 0, + width ?? node?.basicBoxLocal.width ?? kSignatureDefaultWidth, + height ?? node?.basicBoxLocal.height ?? kSignatureDefaultHeight, + ), + properties: properties ?? node?.properties ?? SignatureProperties(), + ); + return TransformerSignature( + node: previewNode, + strokes: const [], + error: previewNode.properties.error, + ); + } +} + +/// A signature pad whose strokes live in the node's bound `value` variable, +/// or its node value when nothing is bound. +/// +/// Each finished stroke writes all the strokes to it, as a list of strokes +/// of `[x, y]` points, and fires [TriggerType.changed]. Setting the +/// variable to `[]` clears the pad. +class PassiveSignatureWidget extends StatelessWidget { + final SignatureNode node; + final WidgetBuildSettings settings; + final List variablesOverrides; + + const PassiveSignatureWidget({ + super.key, + required this.node, + required this.settings, + this.variablesOverrides = const [], + }); + + /// Reads strokes from a node value or a variable: a list of strokes, or + /// its JSON text, such as `[[[10, 20], [12, 24]]]`. Returns null when + /// [value] holds no strokes, e.g. an unset variable. + static List? parseStrokes(Object? value) { + Object? json = value; + if (json is String) { + final String text = json.trim(); + if (text.isEmpty) return const []; + try { + json = jsonDecode(text); + } on FormatException { + return null; + } + } + if (json is! List) return null; + return [ + for (final List> stroke in strokesFromJson(json)) + [for (final List point in stroke) Offset(point[0], point[1])], + ]; + } + + /// [strokes] as JSON: a list of strokes of `[x, y]` points. + static List>> strokesToJson( + List strokes, + ) => [ + for (final SignatureStroke stroke in strokes) + [ + for (final Offset point in stroke) [point.dx, point.dy], + ], + ]; + + void onStrokesChanged(BuildContext context, List strokes) { + // Without a runtime, e.g. on a design canvas, the pad keeps its strokes. + if (context.read() == null) return; + final List>> value = strokesToJson(strokes); + NodeStateProvider.setState(context, value); + FunctionsRepository.setPropertyValue( + context, + node: node, + property: 'value', + value: value, + ); + FunctionsRepository.triggerAction( + context, + node: node, + TriggerType.changed, + value: value, + ); + } + + @override + Widget build(BuildContext context) { + final ScopedValues scopedValues = ScopedValues.of( + context, + variablesOverrides: variablesOverrides, + ); + final List strokes = + parseStrokes( + PropertyValueDelegate.getPropertyValue( + node, + 'value', + scopedValues: scopedValues, + ), + ) ?? + parseStrokes(node.value) ?? + const []; + final bool error = + PropertyValueDelegate.getPropertyValue( + node, + 'error', + scopedValues: scopedValues, + ) ?? + node.properties.error; + final bool enabled = + PropertyValueDelegate.getPropertyValue( + node, + 'enabled', + scopedValues: scopedValues, + ) ?? + node.properties.enabled; + + return TransformerSignature( + key: ValueKey( + 'signature ${node.id}-${IndexedItemProvider.maybeOf(context)?.index ?? ''}', + ), + node: node, + strokes: strokes, + error: error, + onChanged: enabled ? onStrokesChanged : null, + ); + } +} + +/// Draws a signature pad and records the strokes drawn on it. +/// +/// It shows [strokes] until the user draws: a finished stroke is added to +/// the ones shown and reported with [onChanged]. When [strokes] changes from +/// outside, e.g. a variable is cleared, it shows the new strokes. Without +/// [onChanged], it does not take input. +class TransformerSignature extends StatefulWidget { + final SignatureNode node; + final List strokes; + final bool error; + final void Function(BuildContext context, List strokes)? + onChanged; + + const TransformerSignature({ + super.key, + required this.node, + required this.strokes, + this.error = false, + this.onChanged, + }); + + @override + State createState() => _TransformerSignatureState(); +} + +class _TransformerSignatureState extends State { + static const ListEquality _strokesEquality = ListEquality( + ListEquality(), + ); + + late List strokes = [...widget.strokes]; + SignatureStroke? current; + + @override + void didUpdateWidget(covariant TransformerSignature oldWidget) { + super.didUpdateWidget(oldWidget); + if (!_strokesEquality.equals(oldWidget.strokes, widget.strokes) && + !_strokesEquality.equals(strokes, widget.strokes)) { + strokes = [...widget.strokes]; + current = null; + } + } + + /// Rounds [point] to a tenth of a pixel, so the strokes read back from a + /// variable's text equal the ones drawn. + Offset _round(Offset point) => Offset( + (point.dx * 10).roundToDouble() / 10, + (point.dy * 10).roundToDouble() / 10, + ); + + void onStart(DragStartDetails details) => + setState(() => current = [_round(details.localPosition)]); + + void onUpdate(DragUpdateDetails details) { + final SignatureStroke? stroke = current; + if (stroke == null) return; + final Offset point = _round(details.localPosition); + if (stroke.isNotEmpty && stroke.last == point) return; + setState(() => stroke.add(point)); + } + + void onEnd([Object? _]) { + final SignatureStroke? stroke = current; + if (stroke == null) return; + setState(() { + strokes = [...strokes, stroke]; + current = null; + }); + widget.onChanged?.call(context, strokes); + } + + @override + Widget build(BuildContext context) { + final SignatureProperties properties = widget.node.properties; + final BorderRadius borderRadius = properties.cornerRadius.borderRadius; + final Widget pad = DecoratedBox( + decoration: BoxDecoration( + color: properties.backgroundColor.toFlutterColor(), + borderRadius: borderRadius, + border: Border.all( + color: widget.error + ? properties.errorBorderColor.toFlutterColor() + : properties.borderColor.toFlutterColor(), + width: widget.error + ? properties.errorBorderWidth + : properties.borderWidth, + ), + ), + child: ClipRRect( + borderRadius: borderRadius, + child: CustomPaint( + painter: SignaturePainter( + strokes: [...strokes, ?current], + properties: properties, + textDirection: Directionality.maybeOf(context) ?? TextDirection.ltr, + ), + child: const SizedBox.expand(), + ), + ), + ); + + final Widget sized = SizedBox( + width: widget.node.basicBoxLocal.width, + height: widget.node.basicBoxLocal.height, + child: pad, + ); + + if (widget.onChanged == null) return sized; + + return RawGestureDetector( + behavior: HitTestBehavior.opaque, + gestures: { + _EagerPanGestureRecognizer: + GestureRecognizerFactoryWithHandlers<_EagerPanGestureRecognizer>( + _EagerPanGestureRecognizer.new, + (recognizer) => recognizer + ..dragStartBehavior = DragStartBehavior.down + ..onStart = onStart + ..onUpdate = onUpdate + ..onEnd = onEnd + ..onCancel = onEnd, + ), + }, + child: sized, + ); + } +} + +/// A pan recognizer that wins the gesture arena as soon as a pointer goes +/// down, so a scrolling parent does not take a stroke that starts +/// vertically. +class _EagerPanGestureRecognizer extends PanGestureRecognizer { + @override + void addAllowedPointer(PointerDownEvent event) { + super.addAllowedPointer(event); + resolvePointer(event.pointer, GestureDisposition.accepted); + } +} + +/// Paints the sign line, the placeholder while there are no strokes, and the +/// strokes, smoothed with quadratic curves through their midpoints. +class SignaturePainter extends CustomPainter { + final List strokes; + final SignatureProperties properties; + final TextDirection textDirection; + + SignaturePainter({ + required this.strokes, + required this.properties, + required this.textDirection, + }); + + @override + void paint(Canvas canvas, Size size) { + if (properties.showSignLine) paintSignLine(canvas, size); + if (strokes.isEmpty && properties.placeholder.isNotEmpty) { + paintPlaceholder(canvas, size); + } + + final Paint ink = Paint() + ..color = properties.inkColor.toFlutterColor() + ..strokeWidth = properties.inkWidth + ..strokeCap = StrokeCap.round + ..strokeJoin = StrokeJoin.round + ..style = PaintingStyle.stroke + ..isAntiAlias = true; + + for (final SignatureStroke stroke in strokes) { + if (stroke.isEmpty) continue; + if (stroke.length == 1) { + canvas.drawCircle( + stroke.first, + properties.inkWidth / 2, + Paint()..color = ink.color, + ); + continue; + } + canvas.drawPath(smoothPath(stroke), ink); + } + } + + /// A path through [points] that bends at each point with a quadratic curve + /// from the midpoint before it to the midpoint after it. + static Path smoothPath(SignatureStroke points) { + final Path path = Path()..moveTo(points.first.dx, points.first.dy); + if (points.length == 2) { + return path..lineTo(points.last.dx, points.last.dy); + } + for (int i = 1; i < points.length - 1; i++) { + final Offset point = points[i]; + final Offset mid = (point + points[i + 1]) / 2; + path.quadraticBezierTo(point.dx, point.dy, mid.dx, mid.dy); + } + return path..lineTo(points.last.dx, points.last.dy); + } + + void paintSignLine(Canvas canvas, Size size) { + const double inset = 20; + final double y = size.height - size.height * 0.24; + if (size.width <= inset * 2 || y <= 0) return; + final Paint line = Paint() + ..color = properties.signLineColor.toFlutterColor() + ..strokeWidth = 1; + canvas.drawLine(Offset(inset, y), Offset(size.width - inset, y), line); + + final TextPainter cross = TextPainter( + text: TextSpan( + text: '×', + style: TextStyle( + color: properties.signLineColor.toFlutterColor(), + fontSize: 16, + height: 1, + ), + ), + textDirection: textDirection, + )..layout(); + cross.paint(canvas, Offset(inset, y - cross.height - 4)); + cross.dispose(); + } + + void paintPlaceholder(Canvas canvas, Size size) { + final TextPainter text = TextPainter( + text: TextSpan( + text: properties.placeholder, + style: TextStyle( + color: properties.placeholderColor.toFlutterColor(), + fontSize: properties.placeholderFontSize, + fontWeight: FontWeight.w600, + letterSpacing: properties.placeholderFontSize * 0.12, + ), + ), + textAlign: TextAlign.center, + textDirection: textDirection, + maxLines: 1, + ellipsis: '…', + )..layout(maxWidth: size.width); + text.paint( + canvas, + Offset((size.width - text.width) / 2, (size.height - text.height) / 2), + ); + text.dispose(); + } + + // Strokes grow in place while the user draws. + @override + bool shouldRepaint(covariant SignaturePainter oldDelegate) => true; +} diff --git a/lib/src/transformers/passive_transformer_manager.dart b/lib/src/transformers/passive_transformer_manager.dart index 9d6e473b..845bc849 100644 --- a/lib/src/transformers/passive_transformer_manager.dart +++ b/lib/src/transformers/passive_transformer_manager.dart @@ -62,6 +62,7 @@ class PassiveNodeTransformerManager extends WidgetNodeTransformerManager { 'badge': PassiveBadgeTransformer(getNode, this), 'chip': PassiveChipTransformer(getNode, this), 'segmentedButton': PassiveSegmentedButtonTransformer(getNode, this), + 'signature': PassiveSignatureTransformer(getNode, this), 'external': PassiveExternalComponentTransformer(getNode, this), }); } diff --git a/lib/src/transformers/utils/property_value_delegate.dart b/lib/src/transformers/utils/property_value_delegate.dart index 3f4dfd8a..58114a89 100644 --- a/lib/src/transformers/utils/property_value_delegate.dart +++ b/lib/src/transformers/utils/property_value_delegate.dart @@ -894,7 +894,7 @@ List getAccessorsForType(VariableType type) { name: 'isEmpty', type: VariableType.boolean, getValue: (value) { - final newValue = value?.toMap(); + final newValue = value?.toList(); if (newValue == null) return null; return newValue.isEmpty; }, @@ -903,7 +903,7 @@ List getAccessorsForType(VariableType type) { name: 'isNotEmpty', type: VariableType.boolean, getValue: (value) { - final newValue = value?.toMap(); + final newValue = value?.toList(); if (newValue == null) return null; return newValue.isNotEmpty; }, diff --git a/test/signature_test.dart b/test/signature_test.dart new file mode 100644 index 00000000..93524bd3 --- /dev/null +++ b/test/signature_test.dart @@ -0,0 +1,233 @@ +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:codelessly_sdk/codelessly_sdk.dart'; +import 'package:codelessly_sdk/src/functions/action_chain.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; + +SignatureNode signature({ + Map variables = const {}, + List reactions = const [], +}) => SignatureNode( + id: 'signature', + name: 'Signature', + basicBoxLocal: NodeBox(0, 0, 320, 140), + properties: SignatureProperties(), + reactions: reactions, +)..variables.addAll(variables); + +Observable listVariable(String name, [String value = '[]']) => + Observable( + VariableData(id: name, name: name, value: value, type: VariableType.list), + ); + +Future pumpNode( + WidgetTester tester, + BaseNode node, + CodelesslyContext codelesslyContext, +) async { + final registry = NodeRegistry()..setNodes({node.id: node}); + final manager = PassiveNodeTransformerManager( + registry.getNodeByID, + (context, bounds, pageID, layoutID, canvasID) => const SizedBox(), + ); + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: codelesslyContext, + child: MaterialApp( + home: ActionChainHost( + child: Scaffold( + body: Center( + child: Builder( + builder: (context) => manager.buildWidgetFromNode( + node, + context, + settings: const WidgetBuildSettings(debugLabel: 'test'), + ), + ), + ), + ), + ), + ), + ), + ); +} + +/// The strokes the pad paints. +List> paintedStrokes(WidgetTester tester) { + final CustomPaint paint = tester.widget( + find.byWidgetPredicate( + (widget) => widget is CustomPaint && widget.painter is SignaturePainter, + ), + ); + return (paint.painter! as SignaturePainter).strokes; +} + +void main() { + testWidgets('drawing on the pad fills its variable and fires changed', ( + tester, + ) async { + final sig = listVariable('signature'); + final codelesslyContext = CodelesslyContext.empty() + ..variables['signature'] = sig + ..variables['changes'] = Observable( + VariableData( + id: 'changes', + name: 'changes', + value: '0', + type: VariableType.integer, + ), + ); + final node = signature( + variables: {'value': r'${signature}'}, + reactions: [ + Reaction( + trigger: TriggerModel(type: TriggerType.changed), + action: SetVariableAction( + variable: VariableData( + id: 'changes', + name: 'changes', + type: VariableType.integer, + ), + newValue: r'${= changes + 1}', + ), + ), + ], + ); + await pumpNode(tester, node, codelesslyContext); + + expect(find.byType(TransformerSignature), findsOneWidget); + expect(paintedStrokes(tester), isEmpty); + + final Offset start = tester.getTopLeft(find.byType(TransformerSignature)); + await tester.dragFrom(start + const Offset(40, 40), const Offset(120, 30)); + await tester.pumpAndSettle(); + + final Object? value = sig.value.getValue(); + expect(value, isA()); + expect(value as List, hasLength(1)); + expect((value.single as List).length, greaterThan(1)); + expect((value.single as List).first, [40.0, 40.0]); + expect( + codelesslyContext.variables['changes']!.value.getValue(), + 1, + reason: 'changed fires once per stroke', + ); + expect(paintedStrokes(tester), hasLength(1)); + + // A second stroke adds to the first. + await tester.dragFrom(start + const Offset(60, 90), const Offset(0, -40)); + await tester.pumpAndSettle(); + expect(sig.value.getValue() as List, hasLength(2)); + expect(codelesslyContext.variables['changes']!.value.getValue(), 2); + + // Formulas see the signature as a list. + final scoped = ScopedValues.preview(variables: {'signature': sig.value}); + expect( + PropertyValueDelegate.substituteVariables( + r'${signature.isEmpty} ${= length(signature) > 0}', + scopedValues: scoped, + nullSubstitutionMode: NullSubstitutionMode.nullValue, + ), + 'false true', + ); + + // Clearing the variable clears the pad. + sig.value = sig.value.copyWith(value: '[]'); + await tester.pumpAndSettle(); + expect(paintedStrokes(tester), isEmpty); + expect(sig.value.getValue(), isEmpty); + }); + + testWidgets('a set variable action with [] clears the pad', (tester) async { + final sig = listVariable('signature', '[[[1,2],[3,4]]]'); + final codelesslyContext = CodelesslyContext.empty() + ..variables['signature'] = sig; + await pumpNode( + tester, + signature(variables: {'value': r'${signature}'}), + codelesslyContext, + ); + expect(paintedStrokes(tester), [ + [const Offset(1, 2), const Offset(3, 4)], + ]); + + final BuildContext context = tester.element( + find.byType(TransformerSignature), + ); + await FunctionsRepository.performAction( + context, + SetVariableAction( + variable: VariableData( + id: 'signature', + name: 'signature', + type: VariableType.list, + ), + newValue: '[]', + ), + ); + await tester.pumpAndSettle(); + expect(paintedStrokes(tester), isEmpty); + }); + + testWidgets('a pad whose enabled binding is false takes no strokes', ( + tester, + ) async { + final sig = listVariable('signature'); + final node = signature( + variables: {'value': r'${signature}', 'enabled': r'${locked.toggled}'}, + ); + await pumpNode( + tester, + node, + CodelesslyContext.empty() + ..variables['signature'] = sig + ..variables['locked'] = Observable( + VariableData( + id: 'locked', + name: 'locked', + value: 'true', + type: VariableType.boolean, + ), + ), + ); + final Offset start = tester.getTopLeft(find.byType(TransformerSignature)); + await tester.dragFrom(start + const Offset(40, 40), const Offset(120, 30)); + await tester.pumpAndSettle(); + expect(sig.value.getValue(), isEmpty); + expect(paintedStrokes(tester), isEmpty); + }); + + testWidgets('without a binding the pad keeps its strokes itself', ( + tester, + ) async { + await pumpNode(tester, signature(), CodelesslyContext.empty()); + final Offset start = tester.getTopLeft(find.byType(TransformerSignature)); + await tester.dragFrom(start + const Offset(40, 40), const Offset(120, 30)); + await tester.pumpAndSettle(); + expect(paintedStrokes(tester), hasLength(1)); + }); + + testWidgets('without a runtime the pad keeps its strokes itself', ( + tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Center( + child: PassiveSignatureWidget( + node: signature(variables: {'value': r'${signature}'}), + settings: const WidgetBuildSettings( + debugLabel: 'test', + isPreview: true, + ), + ), + ), + ), + ); + final Offset start = tester.getTopLeft(find.byType(TransformerSignature)); + await tester.dragFrom(start + const Offset(40, 40), const Offset(120, 30)); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + expect(paintedStrokes(tester), hasLength(1)); + }); +} diff --git a/test/transformers_test.dart b/test/transformers_test.dart index 3b21e502..1f7bf55a 100644 --- a/test/transformers_test.dart +++ b/test/transformers_test.dart @@ -38,6 +38,7 @@ List standaloneNodes() => [ for (final type in ChipType.values) ChipNode.fromProperties(ChipProperties(chipType: type)), SegmentedButtonNode.empty(), + SignatureNode.empty(), AccordionNode( id: 'accordion', name: 'Accordion', From 95049edb334db0a8db82b4840d992e2125f5254c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 22:31:28 +0000 Subject: [PATCH 04/13] Run loop animations and fire cursor enter, exit and hover triggers Nodes with a loopAnimation build inside a LoopAnimationBox, which repeats a pulse, fade, scale or spin on its own ticker and disposes it with the node. A `loopAnimationEnabled` condition or variable turns it on and off without remounting the node, and under a disabled TickerMode (the editor's design canvas) it holds still at its start. Nodes with cursorEnter, cursorExit or hover reactions get a translucent MouseRegion that runs them, so hover state can be kept with set variable actions. Taps and regions behind still get through. Co-Authored-By: Claude Opus 5.5 --- .../passive_transformer_manager.dart | 2 + lib/src/transformers/transformers.dart | 1 + .../utils/loop_animation_box.dart | 150 +++++++++ .../widget_node_transformer_manager.dart | 84 +++++- test/loop_animation_test.dart | 285 ++++++++++++++++++ 5 files changed, 521 insertions(+), 1 deletion(-) create mode 100644 lib/src/transformers/utils/loop_animation_box.dart create mode 100644 test/loop_animation_test.dart diff --git a/lib/src/transformers/passive_transformer_manager.dart b/lib/src/transformers/passive_transformer_manager.dart index 845bc849..818d370f 100644 --- a/lib/src/transformers/passive_transformer_manager.dart +++ b/lib/src/transformers/passive_transformer_manager.dart @@ -123,9 +123,11 @@ class PassiveNodeTransformerManager extends WidgetNodeTransformerManager { if (settings.withOpacity) { widget = applyWidgetOpacity(styled, widget); } + widget = applyLoopAnimation(context, styled, widget); if (settings.withReactions) { widget = wrapWithReaction(context, styled, widget); widget = wrapWithSemantics(styled, widget); + widget = wrapWithCursorReactions(context, styled, widget); widget = applyTooltipAndZoom(context, styled, widget); } if (settings.withRotation) { diff --git a/lib/src/transformers/transformers.dart b/lib/src/transformers/transformers.dart index 84cd7172..84004ee5 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/indexed_item_provider.dart'; +export 'utils/loop_animation_box.dart'; export 'utils/node_geometry.dart'; export 'utils/property_overrides.dart'; export 'utils/property_value_delegate.dart'; diff --git a/lib/src/transformers/utils/loop_animation_box.dart b/lib/src/transformers/utils/loop_animation_box.dart new file mode 100644 index 00000000..ad97f2aa --- /dev/null +++ b/lib/src/transformers/utils/loop_animation_box.dart @@ -0,0 +1,150 @@ +import 'dart:async'; + +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:flutter/widgets.dart'; + +/// Runs a node's [LoopAnimation] on [child] over and over: it scales and +/// fades [child] for [LoopAnimationType.pulse], fades it for +/// [LoopAnimationType.fade], scales it for [LoopAnimationType.scale] and +/// rotates it for [LoopAnimationType.spin], about its center. +/// +/// The animation runs on this widget's ticker, so it holds still at its +/// `from` values under a disabled [TickerMode], as on the editor's design +/// canvas. When [enabled] is false, [child] shows as designed; the widgets +/// around it stay the same, so turning the animation on and off keeps the +/// state of [child]. +class LoopAnimationBox extends StatefulWidget { + /// The animation to run. + final LoopAnimation animation; + + /// Whether the animation runs. Defaults to [LoopAnimation.enabled]. + final bool? enabled; + + /// The widget to animate. + final Widget child; + + /// Creates a [LoopAnimationBox]. + const LoopAnimationBox({ + super.key, + required this.animation, + this.enabled, + required this.child, + }); + + /// The Flutter curve of [curve]. + static Curve curveOf(LoopAnimationCurve curve) => switch (curve) { + LoopAnimationCurve.linear => Curves.linear, + LoopAnimationCurve.easeIn => Curves.easeIn, + LoopAnimationCurve.easeOut => Curves.easeOut, + LoopAnimationCurve.easeInOut => Curves.easeInOut, + LoopAnimationCurve.fastOutSlowIn => Curves.fastOutSlowIn, + LoopAnimationCurve.decelerate => Curves.decelerate, + }; + + @override + State createState() => _LoopAnimationBoxState(); +} + +class _LoopAnimationBoxState extends State + with SingleTickerProviderStateMixin { + late final AnimationController controller = AnimationController(vsync: this); + + /// Waits for [LoopAnimation.delayMs] before the first cycle. + Timer? delay; + + late Animation scale; + late Animation opacity; + late Animation turns; + + bool get enabled => widget.enabled ?? widget.animation.enabled; + + @override + void initState() { + super.initState(); + update(); + } + + @override + void didUpdateWidget(covariant LoopAnimationBox oldWidget) { + super.didUpdateWidget(oldWidget); + final bool wasEnabled = oldWidget.enabled ?? oldWidget.animation.enabled; + if (oldWidget.animation != widget.animation || wasEnabled != enabled) { + update(); + } + } + + /// Builds the animations of [LoopAnimationBox.animation] and starts it + /// over, or stops it when it is disabled. + void update() { + delay?.cancel(); + delay = null; + controller.reset(); + + final LoopAnimation animation = widget.animation; + if (!enabled) { + scale = opacity = const AlwaysStoppedAnimation(1); + turns = const AlwaysStoppedAnimation(0); + return; + } + + final Animation curved = CurvedAnimation( + parent: controller, + curve: LoopAnimationBox.curveOf(animation.curve), + ); + final LoopAnimationType type = animation.type; + scale = type.scales + ? Tween( + begin: animation.scaleFrom, + end: animation.scaleTo, + ).animate(curved) + : const AlwaysStoppedAnimation(1); + opacity = type.fades + ? Tween( + begin: animation.opacityFrom.clamp(0, 1), + end: animation.opacityTo.clamp(0, 1), + ).animate(curved) + : const AlwaysStoppedAnimation(1); + turns = type == LoopAnimationType.spin + ? Tween(begin: 0, end: animation.turns).animate(curved) + : const AlwaysStoppedAnimation(0); + + controller.duration = Duration( + milliseconds: animation.durationMs.clamp(1, 1 << 31), + ); + if (animation.delayMs > 0) { + delay = Timer(Duration(milliseconds: animation.delayMs), start); + } else { + start(); + } + } + + void start() { + if (!mounted) return; + controller.repeat(reverse: widget.animation.reverse); + } + + @override + void dispose() { + delay?.cancel(); + controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + // Only the transitions of the animation's type wrap the child, so a + // node that just spins doesn't pay for an opacity layer. + Widget child = widget.child; + final LoopAnimationType type = widget.animation.type; + if (type == LoopAnimationType.spin) { + child = RotationTransition(turns: turns, child: child); + } + if (type.scales) { + child = ScaleTransition(scale: scale, child: child); + } + if (type.fades) { + child = FadeTransition(opacity: opacity, child: child); + } + return child; + } +} diff --git a/lib/src/transformers/widget_node_transformer_manager.dart b/lib/src/transformers/widget_node_transformer_manager.dart index 87c40bd2..700d751f 100644 --- a/lib/src/transformers/widget_node_transformer_manager.dart +++ b/lib/src/transformers/widget_node_transformer_manager.dart @@ -59,6 +59,32 @@ abstract class WidgetNodeTransformerManager return Opacity(opacity: opacity, child: widget); } + /// Runs the [BaseNode.loopAnimation] of [node] on [widget], if it has one. + /// + /// A `loopAnimationEnabled` condition or variable turns it on and off at + /// runtime; otherwise [LoopAnimation.enabled] does. The animation holds + /// still under a disabled [TickerMode], as on the editor's design canvas. + Widget applyLoopAnimation( + BuildContext context, + BaseNode node, + Widget widget, { + ScopedValues? scopedValues, + }) { + final LoopAnimation? animation = node.loopAnimation; + if (animation == null) return widget; + final bool? enabled = PropertyValueDelegate.getPropertyValue( + node, + 'loopAnimationEnabled', + scopedValues: scopedValues ?? ScopedValues.of(context), + ); + return LoopAnimationBox( + key: ValueKey('loop animation ${node.id}'), + animation: animation, + enabled: enabled, + child: widget, + ); + } + /// Returns the visibility a condition or variable gives [node] at runtime, /// or null when nothing overrides the node's own visibility. /// @@ -293,7 +319,7 @@ abstract class WidgetNodeTransformerManager if (onClickReactions.isEmpty && onLongPressReactions.isEmpty) { return widget; } - // TODO: should handle TriggerType.hover and TriggerType.unhover too. + // Cursor triggers are handled by [wrapWithCursorReactions]. return MouseRegion( cursor: SystemMouseCursors.click, hitTestBehavior: HitTestBehavior.opaque, @@ -317,6 +343,62 @@ abstract class WidgetNodeTransformerManager } } + /// Runs the [TriggerType.cursorEnter], [TriggerType.cursorExit] and + /// [TriggerType.hover] reactions of [node] when a mouse cursor enters, + /// leaves or moves over it. + /// + /// The region covers the node's whole box, transparent parts included, and + /// lets the nodes behind it get the cursor and taps too. Touch input fires + /// none of these triggers. + Widget wrapWithCursorReactions( + BuildContext context, + BaseNode node, + Widget widget, + ) { + if (node is! ReactionMixin) return widget; + if (node case CanvasNode() || SpacerNode()) return widget; + + List reactionsOf(TriggerType type) => (node as ReactionMixin) + .reactions + .where( + (reaction) => + reaction.trigger.type == type && reaction.action.enabled, + ) + .toList(); + + final List onEnter = reactionsOf(TriggerType.cursorEnter); + final List onExit = reactionsOf(TriggerType.cursorExit); + final List onHover = reactionsOf(TriggerType.hover); + if (onEnter.isEmpty && onExit.isEmpty && onHover.isEmpty) return widget; + + return MouseRegion( + opaque: false, + hitTestBehavior: HitTestBehavior.translucent, + onEnter: onEnter.isEmpty + ? null + : (_) => FunctionsRepository.triggerAction( + context, + TriggerType.cursorEnter, + reactions: onEnter, + ), + onExit: onExit.isEmpty + ? null + : (_) => FunctionsRepository.triggerAction( + context, + TriggerType.cursorExit, + reactions: onExit, + ), + onHover: onHover.isEmpty + ? null + : (_) => FunctionsRepository.triggerAction( + context, + TriggerType.hover, + reactions: onHover, + ), + child: widget, + ); + } + BorderRadius? getBorderRadius(BaseNode node) { if (node is CornerMixin) { if (node.cornerRadius.linked && diff --git a/test/loop_animation_test.dart b/test/loop_animation_test.dart new file mode 100644 index 00000000..ba1609f2 --- /dev/null +++ b/test/loop_animation_test.dart @@ -0,0 +1,285 @@ +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'; + +/// The pulsing halo behind a selected walkaround marker: 58 px, growing +/// from 38 px (scale 0.655) while fading from 35% to nothing every 1.4 s. +RectangleNode halo({Map variables = const {}}) { + final RectangleNode node = RectangleNode.fromJson({ + ...RectangleNode( + id: 'halo', + name: 'Halo', + basicBoxLocal: NodeBox(0, 0, 58, 58), + fills: [PaintModel.solid(id: 'fill', color: ColorRGB.fromHex('#FFC20E'))], + ).toJson(), + 'loopAnimation': { + 'type': 'pulse', + 'durationMs': 1400, + 'scaleFrom': 0.655, + 'scaleTo': 1.0, + 'opacityFrom': 0.35, + 'opacityTo': 0.0, + }, + 'variables': variables, + }); + return node..parentID = kRootNode; +} + +Future pumpNode( + WidgetTester tester, + BaseNode node, { + CodelesslyContext? codelesslyContext, + bool tickers = true, +}) async { + final registry = NodeRegistry()..setNodes({node.id: node}); + final manager = PassiveNodeTransformerManager( + registry.getNodeByID, + (context, bounds, pageID, layoutID, canvasID) => const SizedBox(), + ); + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: codelesslyContext ?? CodelesslyContext.empty(), + child: MaterialApp( + home: TickerMode( + enabled: tickers, + child: Align( + alignment: Alignment.topLeft, + child: Builder( + builder: (context) => manager.buildWidgetFromNode( + node, + context, + settings: const WidgetBuildSettings(debugLabel: 'test'), + ), + ), + ), + ), + ), + ), + ); +} + +/// Finds the transitions [LoopAnimationBox] builds, not the route's. +Finder inBox(Type type) => find.descendant( + of: find.byType(LoopAnimationBox), + matching: find.byType(type), +); + +double scaleOf(WidgetTester tester) => + tester.widget(inBox(ScaleTransition)).scale.value; + +double opacityOf(WidgetTester tester) => + tester.widget(inBox(FadeTransition)).opacity.value; + +Observable variable(String name, Object? value) => + Observable(VariableData(id: name, name: name, value: value)); + +void main() { + group('Loop animation', () { + testWidgets('a pulse grows and fades, then starts over', (tester) async { + await pumpNode(tester, halo()); + + expect(scaleOf(tester), closeTo(0.655, 1e-6)); + expect(opacityOf(tester), closeTo(0.35, 1e-6)); + + await tester.pump(const Duration(milliseconds: 700)); + expect(scaleOf(tester), closeTo(0.8275, 1e-3)); + expect(opacityOf(tester), closeTo(0.175, 1e-3)); + + await tester.pump(const Duration(milliseconds: 650)); + expect(scaleOf(tester), greaterThan(0.95)); + expect(opacityOf(tester), lessThan(0.02)); + + // It repeats from the start rather than running back. + await tester.pump(const Duration(milliseconds: 100)); + expect(scaleOf(tester), lessThan(0.7)); + expect(opacityOf(tester), greaterThan(0.3)); + + // The layout size is the design size: the scale is paint only. + expect(tester.getSize(inBox(ScaleTransition)), const Size(58, 58)); + }); + + testWidgets('a reversing fade breathes back and forth', (tester) async { + final RectangleNode node = halo() + ..loopAnimation = const LoopAnimation( + type: LoopAnimationType.fade, + durationMs: 900, + reverse: true, + opacityFrom: 0.45, + opacityTo: 1, + ); + await pumpNode(tester, node); + expect(inBox(ScaleTransition), findsNothing); + + expect(opacityOf(tester), closeTo(0.45, 1e-6)); + await tester.pump(const Duration(milliseconds: 900)); + expect(opacityOf(tester), closeTo(1, 1e-6)); + await tester.pump(const Duration(milliseconds: 450)); + expect(opacityOf(tester), closeTo(0.725, 1e-3)); + await tester.pump(const Duration(milliseconds: 450)); + expect(opacityOf(tester), closeTo(0.45, 1e-6)); + }); + + testWidgets('a spin turns continuously after its delay', (tester) async { + final RectangleNode node = halo() + ..loopAnimation = const LoopAnimation( + type: LoopAnimationType.spin, + durationMs: 1000, + delayMs: 200, + ); + await pumpNode(tester, node); + double turns() => tester + .widget(inBox(RotationTransition)) + .turns + .value; + + await tester.pump(const Duration(milliseconds: 200)); + expect(turns(), 0); + await tester.pump(const Duration(milliseconds: 250)); + expect(turns(), closeTo(0.25, 1e-3)); + }); + + testWidgets('a disabled animation leaves the node as designed', ( + tester, + ) async { + final RectangleNode node = halo(); + node.loopAnimation = node.loopAnimation!.copyWith(enabled: false); + await pumpNode(tester, node); + + await tester.pump(const Duration(milliseconds: 700)); + expect(scaleOf(tester), 1); + expect(opacityOf(tester), 1); + expect(tester.hasRunningAnimations, isFalse); + }); + + testWidgets('a bound variable turns the animation on and off', ( + tester, + ) async { + final Observable selected = variable('selected', false); + final CodelesslyContext codelesslyContext = CodelesslyContext.empty() + ..variables['selected'] = selected; + await pumpNode( + tester, + halo(variables: {'loopAnimationEnabled': r'${selected}'}), + codelesslyContext: codelesslyContext, + ); + + await tester.pump(const Duration(milliseconds: 700)); + expect(scaleOf(tester), 1); + expect(tester.hasRunningAnimations, isFalse); + + selected.set(selected.value.copyWith(value: true)); + await tester.pump(); + expect(scaleOf(tester), closeTo(0.655, 1e-6)); + await tester.pump(const Duration(milliseconds: 700)); + expect(scaleOf(tester), closeTo(0.8275, 1e-3)); + + selected.set(selected.value.copyWith(value: false)); + await tester.pump(); + expect(scaleOf(tester), 1); + expect(opacityOf(tester), 1); + expect(tester.hasRunningAnimations, isFalse); + }); + + testWidgets('it holds still at its start under a disabled TickerMode', ( + tester, + ) async { + await pumpNode(tester, halo(), tickers: false); + + await tester.pump(const Duration(milliseconds: 700)); + expect(scaleOf(tester), closeTo(0.655, 1e-6)); + expect(opacityOf(tester), closeTo(0.35, 1e-6)); + expect(tester.binding.hasScheduledFrame, isFalse); + }); + }); + + group('Cursor triggers', () { + Reaction on(TriggerType type, String value) => Reaction( + trigger: TriggerModel(type: type), + action: SetVariableAction( + variable: VariableData(id: 'hoveredId', name: 'hoveredId'), + newValue: value, + ), + ); + + testWidgets('cursor enter and exit run their reactions', (tester) async { + final Observable hoveredId = variable('hoveredId', ''); + final CodelesslyContext codelesslyContext = CodelesslyContext.empty() + ..variables['hoveredId'] = hoveredId; + final RectangleNode card = RectangleNode( + id: 'card', + name: 'Card', + basicBoxLocal: NodeBox(0, 0, 200, 80), + reactions: [ + on(TriggerType.cursorEnter, 'truck-7'), + on(TriggerType.cursorExit, ''), + ], + )..parentID = kRootNode; + await pumpNode(tester, card, codelesslyContext: codelesslyContext); + + final TestGesture mouse = await tester.createGesture( + kind: PointerDeviceKind.mouse, + ); + addTearDown(mouse.removePointer); + await mouse.addPointer(location: const Offset(400, 400)); + await tester.pump(); + expect(hoveredId.value.value, ''); + + await mouse.moveTo(const Offset(100, 40)); + await tester.pumpAndSettle(); + expect(hoveredId.value.value, 'truck-7'); + + // Moving within the card doesn't fire again. + hoveredId.set(hoveredId.value.copyWith(value: 'other')); + await mouse.moveTo(const Offset(120, 50)); + await tester.pumpAndSettle(); + expect(hoveredId.value.value, 'other'); + + await mouse.moveTo(const Offset(400, 400)); + await tester.pumpAndSettle(); + expect(hoveredId.value.value, ''); + }); + + testWidgets('a hover region still lets taps through to click reactions', ( + tester, + ) async { + final Observable hoveredId = variable('hoveredId', ''); + final CodelesslyContext codelesslyContext = CodelesslyContext.empty() + ..variables['hoveredId'] = hoveredId; + final RectangleNode card = RectangleNode( + id: 'card', + name: 'Card', + basicBoxLocal: NodeBox(0, 0, 200, 80), + reactions: [ + on(TriggerType.cursorEnter, 'hover'), + on(TriggerType.click, 'clicked'), + ], + )..parentID = kRootNode; + await pumpNode(tester, card, codelesslyContext: codelesslyContext); + + await tester.tapAt(const Offset(100, 40)); + await tester.pumpAndSettle(); + expect(hoveredId.value.value, 'clicked'); + expect(find.byType(MouseRegion), findsWidgets); + }); + + testWidgets('nodes without cursor reactions get no extra region', ( + tester, + ) async { + final RectangleNode plain = RectangleNode( + id: 'plain', + name: 'Plain', + basicBoxLocal: NodeBox(0, 0, 200, 80), + )..parentID = kRootNode; + await pumpNode(tester, plain); + expect( + find.byWidgetPredicate( + (widget) => widget is MouseRegion && widget.onEnter != null, + ), + findsNothing, + ); + }); + }); +} From 08d64ad882920ac4771bd5ecff7258e2ffa3a6dd Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 22:58:53 +0000 Subject: [PATCH 05/13] Conditions: an empty text variable is empty; obscure fixed-height password fields isEmpty on a text variable holding "" was false: the empty text reads as null and isEmpty(null) returned false, so a form couldn't check that its error messages were cleared before submitting. A fixed-height text field expands, which leaves maxLines null, and only single-line fields were obscured, so a PIN or password field in a fixed box showed its text. An obscured field that isn't explicitly multi-line now keeps one line. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011WedhUVWdWR5swqZYMuGfP --- .../passive_text_field_transformer.dart | 21 ++++-- .../utils/condition_evaluator.dart | 3 + test/condition_is_empty_test.dart | 74 +++++++++++++++++++ test/text_field_lines_test.dart | 22 ++++++ 4 files changed, 114 insertions(+), 6 deletions(-) create mode 100644 test/condition_is_empty_test.dart diff --git a/lib/src/transformers/node_transformers/passive_text_field_transformer.dart b/lib/src/transformers/node_transformers/passive_text_field_transformer.dart index 54f7a67d..8f11bb2f 100644 --- a/lib/src/transformers/node_transformers/passive_text_field_transformer.dart +++ b/lib/src/transformers/node_transformers/passive_text_field_transformer.dart @@ -310,11 +310,20 @@ class _PassiveTextFieldWidgetState extends State { !widget.settings.isPreview && (widget.autofocus ?? properties.autoFocus); - final (int? maxLines, int? minLines) = resolveTextFieldLines( - maxLines: properties.maxLines, - minLines: properties.minLines, - expands: !widget.node.isVerticalWrap, - ); + // Flutter only obscures single-line fields, so an obscured field that + // isn't explicitly multi-line (a PIN or password in a fixed-height box) + // keeps one line instead of expanding to the box. + final bool singleLineObscured = + obscureText && + (properties.maxLines ?? 1) <= 1 && + (properties.minLines ?? 1) <= 1; + final (int? maxLines, int? minLines) = singleLineObscured + ? (1, null) + : resolveTextFieldLines( + maxLines: properties.maxLines, + minLines: properties.minLines, + expands: !widget.node.isVerticalWrap, + ); Widget field = TextFormField( focusNode: focusNode, @@ -352,7 +361,7 @@ class _PassiveTextFieldWidgetState extends State { ], maxLines: maxLines, minLines: minLines, - expands: !widget.node.isVerticalWrap, + expands: !singleLineObscured && !widget.node.isVerticalWrap, obscuringCharacter: widget.node.properties.obscuringCharacter, style: TextUtils.retrieveTextStyleFromProp( widget.node.properties.inputStyle, diff --git a/lib/src/transformers/utils/condition_evaluator.dart b/lib/src/transformers/utils/condition_evaluator.dart index 8f757e97..c66be679 100644 --- a/lib/src/transformers/utils/condition_evaluator.dart +++ b/lib/src/transformers/utils/condition_evaluator.dart @@ -201,6 +201,9 @@ class ConditionEvaluator @override bool visitIsEmptyOperator(Object? left) { + // An empty text variable reads as null (see _visitRawValue), and an unset + // one is null too: both are empty. + if (left == null) return true; if (left is List) return left.isEmpty; if (left is String) return left.isEmpty; if (left is Map) return left.isEmpty; diff --git a/test/condition_is_empty_test.dart b/test/condition_is_empty_test.dart new file mode 100644 index 00000000..f8780b94 --- /dev/null +++ b/test/condition_is_empty_test.dart @@ -0,0 +1,74 @@ +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:codelessly_sdk/codelessly_sdk.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// isEmpty / isNotEmpty on text variables, the way a form validates its +/// error messages before submitting. +void main() { + bool check(Map vars, String path, String operator) { + final BaseExpression expression = BaseExpression.fromJson({ + 'type': 'Expression', + 'leftPart': {'type': 'Variable', 'valueString': '\${$path}'}, + 'operator': operator, + 'rightPart': {'type': 'RawValue', 'value': ''}, + }); + return expression.accept( + ConditionEvaluator( + scopedValues: ScopedValues.preview( + variables: { + for (final MapEntry(:key, :value) in vars.entries) + key: VariableData(id: key, name: key, value: value), + }, + ), + ), + ) == + true; + } + + test('an empty text variable is empty', () { + expect(check({'error': ''}, 'error', 'isEmpty'), isTrue); + expect(check({'error': ''}, 'error', 'isNotEmpty'), isFalse); + }); + + test('a text variable with a value is not empty', () { + expect(check({'error': 'Incorrect PIN'}, 'error', 'isEmpty'), isFalse); + expect(check({'error': 'Incorrect PIN'}, 'error', 'isNotEmpty'), isTrue); + }); + + test('a missing variable is empty', () { + expect(check({}, 'missing', 'isEmpty'), isTrue); + expect(check({}, 'missing', 'isNotEmpty'), isFalse); + }); + + test('both errors empty, joined with and', () { + final BaseExpression expression = BaseExpression.fromJson({ + 'type': 'ExpressionGroup', + 'join': 'and', + 'leftExpression': { + 'type': 'Expression', + 'leftPart': {'type': 'Variable', 'valueString': r'${idError}'}, + 'operator': 'isEmpty', + 'rightPart': {'type': 'RawValue', 'value': ''}, + }, + 'rightExpression': { + 'type': 'Expression', + 'leftPart': {'type': 'Variable', 'valueString': r'${pinError}'}, + 'operator': 'isEmpty', + 'rightPart': {'type': 'RawValue', 'value': ''}, + }, + }); + expect( + expression.accept( + ConditionEvaluator( + scopedValues: ScopedValues.preview( + variables: { + 'idError': VariableData(id: 'a', name: 'idError', value: ''), + 'pinError': VariableData(id: 'b', name: 'pinError', value: ''), + }, + ), + ), + ), + isTrue, + ); + }); +} diff --git a/test/text_field_lines_test.dart b/test/text_field_lines_test.dart index 308b2207..243063f4 100644 --- a/test/text_field_lines_test.dart +++ b/test/text_field_lines_test.dart @@ -142,6 +142,28 @@ void main() { _expectOnScreen(tester, size); }); + testWidgets('fixed-height password field is obscured at $label', ( + tester, + ) async { + await _pump( + tester, + _field( + maxLines: 1, + minLines: null, + verticalFit: SizeFit.fixed, + obscureText: true, + ), + size, + ); + expect(tester.takeException(), isNull); + final TextField field = tester.widget(find.byType(TextField)); + expect(field.obscureText, isTrue); + expect(field.expands, isFalse); + expect(field.maxLines, 1); + expect(tester.getSize(find.byType(TextField)).height, 120); + _expectOnScreen(tester, size); + }); + testWidgets('fixed-height field expands with lines set at $label', ( tester, ) async { From 3d9795ab4b4b92d89ef644286b46344d08d33c24 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 23:09:53 +0000 Subject: [PATCH 06/13] Present show-dialog canvases as bottom sheets and side sheets showLayoutDialog pushes a LayoutPresentationRoute that presents the canvas per ShowDialogAction.presentation: - dialog: centered at its design size, now scaling and fading in (0.96 to 1, 320 ms easeOutCubic). - bottomSheet: full width at the bottom, top corners radius 10, a 40x4 grab handle 8 px from the top, as tall as the canvas (its content when it shrink-wraps) up to maxHeightFactor of the screen; slides up. - sideSheet: full height on the right, sheetWidth (or the canvas's design width) wide, with a shadow; slides in from the right. The barrier color, barrier dismissal, route params and Navigate back results work as before. A new CanvasSizing.fillWidth sizes the bottom sheet's canvas. What a show-dialog's canvas passes back on Navigate back becomes the chain's result for the actions after it. Co-Authored-By: Claude Opus 5.5 --- lib/src/functions/functions_repository.dart | 59 ++- .../passive_canvas_transformer.dart | 12 +- lib/src/ui/layout_presentation_route.dart | 316 +++++++++++++ test/sheet_presentation_test.dart | 436 ++++++++++++++++++ 4 files changed, 804 insertions(+), 19 deletions(-) create mode 100644 lib/src/ui/layout_presentation_route.dart create mode 100644 test/sheet_presentation_test.dart diff --git a/lib/src/functions/functions_repository.dart b/lib/src/functions/functions_repository.dart index 86de7a90..28d518a6 100644 --- a/lib/src/functions/functions_repository.dart +++ b/lib/src/functions/functions_repository.dart @@ -12,7 +12,7 @@ import 'package:url_launcher/url_launcher.dart'; import '../../codelessly_sdk.dart'; import '../logging/stat_tracker.dart'; -import '../ui/codelessly_dialog_widget.dart'; +import '../ui/layout_presentation_route.dart'; import 'action_chain.dart'; import 'app_auth.dart'; import 'cloud_files.dart'; @@ -560,18 +560,27 @@ class FunctionsRepository { } } + /// Shows [action]'s canvas. What its "Navigate back" passes back, e.g. the + /// values of a form in a sheet, is the `${result}` of the actions after it. static Future showDialogAction( BuildContext context, ShowDialogAction action, ) async { - await showLayoutDialog( + final Object? result = await showLayoutDialog( context, canvasId: action.destinationId, params: action.params, barrierDismissible: action.barrierDismissible, barrierColor: action.barrierColor?.toFlutterColor(), showCloseButton: action.showCloseButton, + presentation: action.presentation, + sheetWidth: action.sheetWidth, + maxHeightFactor: action.maxHeightFactor, + showDragHandle: action.showDragHandle, ); + if (result case final Map values when values.isNotEmpty) { + ActionChain.setResult(values); + } } /// The id of the published layout that holds the canvas [canvasId], if any. @@ -580,9 +589,10 @@ class FunctionsRepository { .firstWhereOrNull((layout) => layout.canvasIds.contains(canvasId)) ?.id; - /// Shows the canvas [canvasId] as a dialog and completes with what it was - /// closed with: the params of the "Navigate back" action that closed it, or - /// null when it was dismissed. + /// Shows the canvas [canvasId] as a dialog, a bottom sheet or a side sheet + /// (see [presentation]) and completes with what it was closed with: the + /// params of the "Navigate back" action that closed it, or null when it was + /// dismissed. static Future showLayoutDialog( BuildContext context, { required String canvasId, @@ -590,6 +600,10 @@ class FunctionsRepository { bool barrierDismissible = true, Color? barrierColor, bool showCloseButton = false, + DialogPresentation presentation = DialogPresentation.dialog, + double? sheetWidth, + double maxHeightFactor = 0.92, + bool showDragHandle = true, }) async { final ScopedValues scopedValues = ActionChain.scopedValuesOf(context); final parsedParams = substituteVariablesInMap(params, scopedValues); @@ -632,18 +646,29 @@ 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), + final BaseNode? canvas = codelessly + .dataManager + .publishModel + ?.layouts[layoutId] + ?.canvases[canvasId]?[canvasId]; + final Object? result = await Navigator.of(context).push( + LayoutPresentationRoute( + context: context, + presentation: presentation, + barrierDismissible: barrierDismissible, + barrierColor: + barrierColor ?? + Theme.of(context).dialogTheme.barrierColor ?? + Colors.black54, + showCloseButton: showCloseButton, + sheetWidth: sheetWidth, + designWidth: canvas?.basicBoxLocal.width, + maxHeightFactor: maxHeightFactor, + showDragHandle: showDragHandle, + settings: RouteSettings(arguments: parsedParams), + builder: (context) => ActionChainOpener( + openerHosts: openerHosts, + child: CodelesslyWidget(codelessly: codelessly, layoutID: layoutId), ), ), ); diff --git a/lib/src/transformers/node_transformers/passive_canvas_transformer.dart b/lib/src/transformers/node_transformers/passive_canvas_transformer.dart index 9fe25753..24baca1d 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 fills the width it is given and keeps its design height (or + /// its content's), within the height it is given. Used by a bottom sheet, + /// which spans the screen and is as tall as its canvas. + fillWidth, } /// 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.fill) { + final double width = + sizing == CanvasSizing.fillWidth && 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/layout_presentation_route.dart b/lib/src/ui/layout_presentation_route.dart new file mode 100644 index 00000000..d81e4e37 --- /dev/null +++ b/lib/src/ui/layout_presentation_route.dart @@ -0,0 +1,316 @@ +import 'dart:math'; + +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:flutter/material.dart'; + +import '../../codelessly_sdk.dart'; +import 'codelessly_dialog_widget.dart'; + +/// The route a show-dialog action presents its canvas in: a centered dialog, +/// a bottom sheet or a side sheet (see [DialogPresentation]). +/// +/// Like a dialog route it has a barrier that [barrierDismissible] lets the +/// user tap to close it, and a "Navigate back" in the canvas pops it with its +/// params as the result. +class LayoutPresentationRoute extends PopupRoute { + /// How the canvas is presented. + final DialogPresentation presentation; + + /// Builds the canvas. + final WidgetBuilder builder; + + /// Whether to show a close button in the corner. + final bool showCloseButton; + + /// Width of a side sheet. Null takes [designWidth]. + final double? sheetWidth; + + /// The width the destination canvas was designed at. + final double? designWidth; + + /// The most of the screen's height a bottom sheet takes. + final double maxHeightFactor; + + /// Whether a bottom sheet shows a grab handle. + final bool showDragHandle; + + final CapturedThemes? _themes; + + /// Creates a [LayoutPresentationRoute]. + LayoutPresentationRoute({ + required this.presentation, + required this.builder, + required BuildContext context, + this.showCloseButton = false, + this.sheetWidth, + this.designWidth, + this.maxHeightFactor = 0.92, + this.showDragHandle = true, + this.barrierDismissible = true, + this.barrierColor = Colors.black54, + super.settings, + }) : _barrierLabel = MaterialLocalizations.of(context) + .modalBarrierDismissLabel, + _themes = InheritedTheme.capture( + from: context, + to: Navigator.of(context).context, + ); + + /// How long the canvas takes to come in. + static const Duration enterDuration = Duration(milliseconds: 320); + + /// How long the canvas takes to go away. + static const Duration exitDuration = Duration(milliseconds: 200); + + /// The width of a side sheet when neither [sheetWidth] nor [designWidth] + /// is known. + static const double defaultSheetWidth = 400; + + /// Corner radius of a bottom sheet's top corners. + static const double bottomSheetRadius = 10; + + /// Size of a bottom sheet's grab handle. + static const Size dragHandleSize = Size(40, 4); + + /// Distance of a bottom sheet's grab handle from its top edge. + static const double dragHandleTop = 8; + + /// Color of a bottom sheet's grab handle. + static const Color dragHandleColor = Color(0xFFA9ADA5); + + @override + final bool barrierDismissible; + + @override + final Color? barrierColor; + + final String _barrierLabel; + + @override + String? get barrierLabel => _barrierLabel; + + @override + Duration get transitionDuration => enterDuration; + + @override + Duration get reverseTransitionDuration => exitDuration; + + @override + Widget buildPage( + BuildContext context, + Animation animation, + Animation secondaryAnimation, + ) { + final Widget page = Semantics( + scopesRoute: true, + explicitChildNodes: true, + child: switch (presentation) { + DialogPresentation.dialog => SafeArea( + child: CodelesslyDialogWidget( + showCloseButton: showCloseButton, + builder: builder, + ), + ), + DialogPresentation.bottomSheet => _BottomSheet( + maxHeightFactor: maxHeightFactor, + showDragHandle: showDragHandle, + showCloseButton: showCloseButton, + builder: builder, + ), + DialogPresentation.sideSheet => _SideSheet( + width: sheetWidth ?? designWidth ?? defaultSheetWidth, + showCloseButton: showCloseButton, + builder: builder, + ), + }, + ); + return _themes?.wrap(page) ?? page; + } + + @override + Widget buildTransitions( + BuildContext context, + Animation animation, + Animation secondaryAnimation, + Widget child, + ) { + final Animation curved = CurvedAnimation( + parent: animation, + curve: Curves.easeOutCubic, + reverseCurve: Curves.easeInCubic, + ); + return switch (presentation) { + DialogPresentation.dialog => FadeTransition( + opacity: curved, + child: ScaleTransition( + scale: Tween(begin: 0.96, end: 1).animate(curved), + child: child, + ), + ), + DialogPresentation.bottomSheet => SlideTransition( + position: Tween( + begin: const Offset(0, 1), + end: Offset.zero, + ).animate(curved), + child: child, + ), + DialogPresentation.sideSheet => SlideTransition( + position: Tween( + begin: const Offset(1, 0), + end: Offset.zero, + ).animate(curved), + child: child, + ), + }; + } +} + +/// A close button in the top right corner of a dialog or sheet. +class _CloseButton extends StatelessWidget { + const _CloseButton(); + + @override + Widget build(BuildContext context) => Positioned( + top: 8, + right: 8, + child: Material( + type: MaterialType.transparency, + child: IconButton( + onPressed: () => Navigator.maybePop(context), + splashRadius: 16, + icon: const Icon(Icons.close), + ), + ), + ); +} + +/// A canvas anchored to the bottom of the screen, as wide as the screen and +/// as tall as the canvas, up to [maxHeightFactor] of the screen. +class _BottomSheet extends StatelessWidget { + final double maxHeightFactor; + final bool showDragHandle; + final bool showCloseButton; + final WidgetBuilder builder; + + const _BottomSheet({ + required this.maxHeightFactor, + required this.showDragHandle, + required this.showCloseButton, + required this.builder, + }); + + @override + Widget build(BuildContext context) { + final MediaQueryData media = MediaQuery.of(context); + // The keyboard pushes the sheet up. + final double keyboard = media.viewInsets.bottom; + final double maxHeight = + max(0, media.size.height - keyboard) * maxHeightFactor.clamp(0, 1); + return Padding( + padding: EdgeInsets.only(bottom: keyboard), + child: Align( + alignment: Alignment.bottomCenter, + child: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: media.size.width, + maxHeight: maxHeight, + ), + child: ClipRRect( + key: const ValueKey('Codelessly bottom sheet'), + borderRadius: const BorderRadius.vertical( + top: Radius.circular(LayoutPresentationRoute.bottomSheetRadius), + ), + child: MediaQuery.removePadding( + context: context, + removeTop: true, + child: MediaQuery.removeViewInsets( + context: context, + removeBottom: true, + child: CanvasSizingScope( + sizing: CanvasSizing.fillWidth, + child: Stack( + children: [ + Builder(builder: builder), + if (showDragHandle) + const Positioned( + top: LayoutPresentationRoute.dragHandleTop, + left: 0, + right: 0, + child: Center(child: _DragHandle()), + ), + if (showCloseButton) const _CloseButton(), + ], + ), + ), + ), + ), + ), + ), + ), + ); + } +} + +/// The grab handle at the top of a bottom sheet. +class _DragHandle extends StatelessWidget { + const _DragHandle(); + + @override + Widget build(BuildContext context) => Container( + key: const ValueKey('Codelessly sheet drag handle'), + width: LayoutPresentationRoute.dragHandleSize.width, + height: LayoutPresentationRoute.dragHandleSize.height, + decoration: BoxDecoration( + color: LayoutPresentationRoute.dragHandleColor, + borderRadius: BorderRadius.circular(2), + ), + ); +} + +/// A canvas anchored to the right of the screen, as tall as the screen and +/// [width] wide. +class _SideSheet extends StatelessWidget { + final double width; + final bool showCloseButton; + final WidgetBuilder builder; + + const _SideSheet({ + required this.width, + required this.showCloseButton, + required this.builder, + }); + + @override + Widget build(BuildContext context) { + final Size screen = MediaQuery.sizeOf(context); + return Align( + alignment: Alignment.centerRight, + child: DecoratedBox( + key: const ValueKey('Codelessly side sheet'), + decoration: const BoxDecoration( + boxShadow: [ + BoxShadow( + color: Color(0x33000000), + blurRadius: 24, + offset: Offset(-4, 0), + ), + ], + ), + child: SizedBox( + width: min(width, screen.width), + height: screen.height, + child: CanvasSizingScope( + sizing: CanvasSizing.fill, + child: Stack( + fit: StackFit.expand, + children: [ + Builder(builder: builder), + if (showCloseButton) const _CloseButton(), + ], + ), + ), + ), + ), + ); + } +} diff --git a/test/sheet_presentation_test.dart b/test/sheet_presentation_test.dart new file mode 100644 index 00000000..a61212c4 --- /dev/null +++ b/test/sheet_presentation_test.dart @@ -0,0 +1,436 @@ +import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:codelessly_sdk/codelessly_sdk.dart'; +import 'package:codelessly_sdk/src/ui/layout_builder.dart'; +import 'package:codelessly_sdk/src/ui/layout_presentation_route.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; + +const Size phone = Size(390, 844); +const Size tablet = Size(834, 1194); +const Size desktop = Size(1440, 900); + +PaintModel paint(ColorRGBA color) => PaintModel.solid( + id: 'paint', + color: color.toRGB(), + opacity: color.a, + visible: true, +); + +CanvasNode canvas( + String id, + String bodyId, { + double width = 390, + double height = 844, + SizeFit verticalFit = SizeFit.fixed, +}) => CanvasNode( + id: id, + name: id, + basicBoxLocal: NodeBox(0, 0, width, height), + children: [bodyId], + scaleMode: ScaleMode.responsive, + verticalFit: verticalFit, + fills: [paint(ColorRGBA.white)], + properties: CanvasProperties( + bodyId: bodyId, + safeArea: const SafeAreaModel.all(false), + ), +)..parentID = kRootNode; + +AutoPlaceholderNode body( + String id, + String canvasId, + List children, { + double width = 390, + double height = 844, +}) => AutoPlaceholderNode( + id: id, + name: id, + basicBoxLocal: NodeBox(0, 0, width, height), + children: children, + mainAxisAlignment: MainAxisAlignmentC.start, + horizontalFit: SizeFit.expanded, + verticalFit: SizeFit.expanded, +)..parentID = canvasId; + +RectangleNode box(String id, String parent, double height) => + RectangleNode(id: id, name: id, basicBoxLocal: NodeBox(0, 0, 390, height)) + ..parentID = parent; + +ButtonNode button( + String id, + String parent, + String label, + List actions, +) => ButtonNode( + id: id, + name: id, + basicBoxLocal: NodeBox(0, 0, 160, 44), + properties: ButtonProperties( + label: label, + buttonType: ButtonTypeEnum.filled, + buttonColor: ColorRGBA.black, + ), + reactions: [ + for (final action in actions) + Reaction( + action: action, + trigger: TriggerModel(type: TriggerType.click), + ), + ], +)..parentID = parent; + +SDKPublishLayout layoutOf(String id, List nodes) => SDKPublishLayout( + id: id, + pageId: 'page', + projectId: 'project', + canvases: { + nodes.first.id: {for (final node in nodes) node.id: node}, + }, + lastUpdated: DateTime(2026), + owner: 'owner', + teams: const {}, + users: const {}, + roles: const {}, + public: false, +); + +/// The main screen: a button that runs [actions]. +SDKPublishLayout mainLayout(List actions) => layoutOf('main', [ + canvas('main', 'mainBody'), + body('mainBody', 'main', ['go']), + button('go', 'mainBody', 'Go', actions), +]); + +/// A sheet canvas designed at 390 wide whose height is its content: a +/// [contentHeight] tall box and a Done button that navigates back with +/// `{flag: yes}`. +SDKPublishLayout sheetLayout({ + double contentHeight = 300, + SizeFit verticalFit = SizeFit.shrinkWrap, +}) => layoutOf('sheet', [ + canvas('sheetCanvas', 'sheetBody', verticalFit: verticalFit), + body('sheetBody', 'sheetCanvas', ['content', 'done']), + box('content', 'sheetBody', contentHeight), + button('done', 'sheetBody', 'Done', [ + NavigationAction( + navigationType: NavigationType.pop, + destinationId: '', + params: {'flag': 'yes'}, + ), + ]), +]); + +class _FakeCache extends Fake implements CacheManager {} + +class _FakeAuthManager extends Fake implements AuthManager { + @override + AuthData? get authData => AuthData( + authToken: 'token', + projectId: 'project', + timestamp: DateTime(2026), + owner: 'owner', + teams: const {}, + users: const {}, + roles: const {}, + ); +} + +class _FakeLocalRepository extends Fake implements LocalDataRepository {} + +class _FakeNetworkRepository extends Fake implements NetworkDataRepository {} + +class _FakeFirestore extends Fake implements FirebaseFirestore {} + +class _FakeLocalDatabase extends Fake implements LocalDatabase {} + +class _TestDataManager extends DataManager { + _TestDataManager(SDKPublishModel model) + : super( + 'test', + config: CodelesslyConfig(authToken: 'token'), + cacheManager: _FakeCache(), + authManager: _FakeAuthManager(), + networkDataRepository: _FakeNetworkRepository(), + localDataRepository: _FakeLocalRepository(), + firebaseFirestore: _FakeFirestore(), + errorLogger: ErrorLogger.instance, + publishModel: model, + ); + + @override + LocalDatabase get localDatabase => _FakeLocalDatabase(); +} + +/// Pumps [layouts.first] on a screen of [size]. +Future pumpLayouts( + WidgetTester tester, + List layouts, { + required Size size, +}) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = size; + addTearDown(tester.view.reset); + + final model = SDKPublishModel( + projectId: 'project', + owner: 'owner', + teams: const {}, + users: const {}, + roles: const {}, + public: false, + ); + for (final layout in layouts) { + model.layouts[layout.id] = layout; + } + final codelessly = Codelessly( + config: CodelesslyConfig(authToken: 'token'), + publishDataManager: _TestDataManager(model)..status = CStatus.loaded(), + )..status = CStatus.loaded(); + final SDKPublishLayout main = layouts.first; + await tester.pumpWidget( + MultiProvider( + providers: [ + ChangeNotifierProvider.value( + value: CodelesslyContext.empty(layoutID: main.id), + ), + Provider.value(value: codelessly), + ], + child: MaterialApp( + home: CodelesslyLayoutBuilder( + controller: CodelesslyWidgetController( + layoutID: main.id, + codelessly: codelessly, + ), + layout: main, + canvasId: main.canvases.keys.first, + ), + ), + ), + ); + await tester.pump(); +} + +final Finder bottomSheet = find.byKey( + const ValueKey('Codelessly bottom sheet'), +); +final Finder sideSheet = find.byKey(const ValueKey('Codelessly side sheet')); +final Finder dragHandle = find.byKey( + const ValueKey('Codelessly sheet drag handle'), +); + +void main() { + for (final Size size in [phone, tablet]) { + testWidgets('a bottom sheet spans the screen at the bottom, hugs its ' + 'content and slides up at ${size.width.toInt()}', (tester) async { + await pumpLayouts(tester, [ + mainLayout([ + ShowDialogAction( + destinationId: 'sheetCanvas', + presentation: DialogPresentation.bottomSheet, + ), + ]), + sheetLayout(), + ], size: size); + + await tester.tap(find.text('Go')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + // Part way in, the sheet is below its resting place. + final double midTop = tester.getRect(bottomSheet).top; + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + + final Rect sheet = tester.getRect(bottomSheet); + expect(midTop, greaterThan(sheet.top)); + expect(sheet.left, 0); + expect(sheet.width, size.width); + expect(sheet.bottom, size.height); + // The content box and the Done button. + expect(sheet.height, 344); + expect( + tester.widget(bottomSheet).borderRadius, + const BorderRadius.vertical(top: Radius.circular(10)), + ); + + final Rect handle = tester.getRect(dragHandle); + expect(handle.size, const Size(40, 4)); + expect(handle.top, sheet.top + 8); + expect(handle.center.dx, size.width / 2); + }); + } + + testWidgets('a bottom sheet is at most 92% of the screen tall', ( + tester, + ) async { + await pumpLayouts(tester, [ + mainLayout([ + ShowDialogAction( + destinationId: 'sheetCanvas', + presentation: DialogPresentation.bottomSheet, + showDragHandle: false, + ), + ]), + sheetLayout(contentHeight: 1200, verticalFit: SizeFit.fixed), + ], size: phone); + await tester.tap(find.text('Go')); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + final Rect sheet = tester.getRect(bottomSheet); + expect(sheet.height, closeTo(844 * 0.92, 0.01)); + expect(sheet.bottom, 844); + expect(dragHandle, findsNothing); + }); + + for (final (Size size, double? width) in [ + (tablet, 440.0), + (desktop, 480.0), + (desktop, null), + ]) { + testWidgets('a side sheet is ${width ?? 'the canvas'} wide on the right ' + 'at ${size.width.toInt()}', (tester) async { + await pumpLayouts(tester, [ + mainLayout([ + ShowDialogAction( + destinationId: 'sheetCanvas', + presentation: DialogPresentation.sideSheet, + sheetWidth: width, + ), + ]), + sheetLayout(), + ], size: size); + await tester.tap(find.text('Go')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + final double midLeft = tester.getRect(sideSheet).left; + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + + final Rect sheet = tester.getRect(sideSheet); + expect(midLeft, greaterThan(sheet.left)); + expect(sheet.width, width ?? 390); + expect(sheet.right, size.width); + expect(sheet.top, 0); + expect(sheet.height, size.height); + expect(dragHandle, findsNothing); + }); + } + + for (final presentation in DialogPresentation.values) { + testWidgets('tapping the scrim dismisses a ${presentation.name}', ( + tester, + ) async { + await pumpLayouts(tester, [ + mainLayout([ + ShowDialogAction( + destinationId: 'sheetCanvas', + presentation: presentation, + sheetWidth: 440, + ), + ShowToastAction(message: r'Closed with ${result.flag}'), + ]), + sheetLayout(), + ], size: tablet); + await tester.tap(find.text('Go')); + await tester.pumpAndSettle(); + expect(find.text('Done'), findsOneWidget); + + await tester.tapAt(const Offset(10, 10)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + expect(find.text('Done'), findsNothing); + // Dismissed, it passes nothing back. + expect(find.text('Closed with '), findsOneWidget); + await tester.pump(const Duration(seconds: 3)); + await tester.pumpAndSettle(); + }); + + testWidgets('a ${presentation.name} that is not barrier dismissible ' + 'stays open', (tester) async { + await pumpLayouts(tester, [ + mainLayout([ + ShowDialogAction( + destinationId: 'sheetCanvas', + presentation: presentation, + sheetWidth: 440, + barrierDismissible: false, + ), + ]), + sheetLayout(), + ], size: tablet); + await tester.tap(find.text('Go')); + await tester.pumpAndSettle(); + await tester.tapAt(const Offset(10, 10)); + await tester.pumpAndSettle(); + expect(find.text('Done'), findsOneWidget); + }); + + testWidgets('navigating back from a ${presentation.name} passes its params ' + 'to the next action', (tester) async { + await pumpLayouts(tester, [ + mainLayout([ + ShowDialogAction( + destinationId: 'sheetCanvas', + presentation: presentation, + ), + ShowToastAction(message: r'Flagged: ${result.flag}'), + ]), + sheetLayout(), + ], size: phone); + await tester.tap(find.text('Go')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Done')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + expect(tester.takeException(), isNull); + expect(find.text('Done'), findsNothing); + expect(find.text('Flagged: yes'), findsOneWidget); + await tester.pump(const Duration(seconds: 3)); + await tester.pumpAndSettle(); + }); + } + + testWidgets('a saved show-dialog action stays a centered dialog that ' + 'scales and fades in', (tester) async { + final action = ActionModel.fromJson({ + 'type': 'showDialog', + 'destinationId': 'dialogCanvas', + }) as ShowDialogAction; + await pumpLayouts(tester, [ + mainLayout([action]), + layoutOf('dialog', [ + canvas('dialogCanvas', 'dialogBody', width: 300, height: 200), + body('dialogBody', 'dialogCanvas', ['ok'], width: 300, height: 200), + button('ok', 'dialogBody', 'OK', [ + NavigationAction( + navigationType: NavigationType.pop, + destinationId: '', + ), + ]), + ]), + ], size: desktop); + await tester.tap(find.text('Go')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + final Finder scale = find + .ancestor(of: find.text('OK'), matching: find.byType(ScaleTransition)) + .first; + final double midScale = tester.widget(scale).scale.value; + expect(midScale, greaterThanOrEqualTo(0.96)); + expect(midScale, lessThan(1)); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + expect(bottomSheet, findsNothing); + expect(sideSheet, findsNothing); + final Finder card = find.byKey( + const ValueKey('Rectangle Transformer of dialogCanvas [dialogCanvas]'), + ); + expect(tester.getSize(card), const Size(300, 200)); + expect(tester.getCenter(card), desktop.center(Offset.zero)); + expect( + LayoutPresentationRoute.enterDuration, + const Duration(milliseconds: 320), + ); + }); +} From 8137743d3a2663d0bdf29990029fdd4c8bf1e0ba Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 16:51:55 +0000 Subject: [PATCH 07/13] Formulas: use a null-aware element in concat Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011WedhUVWdWR5swqZYMuGfP --- lib/src/utils/formula.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/src/utils/formula.dart b/lib/src/utils/formula.dart index e9368762..39848650 100644 --- a/lib/src/utils/formula.dart +++ b/lib/src/utils/formula.dart @@ -963,7 +963,7 @@ final Map _functions = { }, 'concat': (a) => [ for (final Object? l in a) - if (l is Iterable) ...l else if (l != null) l, + if (l is Iterable) ...l else ?l, ], 'append': (a) => [..._values(_arg(a, 0), null), ...a.skip(1)], 'take': (a) => _values(_arg(a, 0), null).take(_intArg(_arg(a, 1))).toList(), From 37c00ff3c2645b1806d07c854d923ab4bee8e991 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 17:03:38 +0000 Subject: [PATCH 08/13] Design canvas: list item text reads the item it is built in A text's ${...} preview on the design canvas (VariableSpan) read item through the editor's scope resolver, which always picks the list's first item, while the same row's fill, stroke and visible bindings read the row's own item from IndexedItemProvider. On the render canvas each preview row painted item 0's text with its own bindings, and ${= flags[item.id].note} showed the placeholder "Flags" in rows whose own item had a flag but item 0 had none. VariablePreview.textFor takes the IndexedItem of the build context; when it carries data, item reads it. Lists built without item data keep the resolver's first item. Co-Authored-By: Claude Opus 5.5 --- .../passive_button_transformer.dart | 4 +- lib/src/transformers/utils/text_utils.dart | 12 +++- lib/src/transformers/utils/variable_span.dart | 34 ++++++++-- test/design_canvas_bindings_test.dart | 63 +++++++++++++++++++ 4 files changed, 106 insertions(+), 7 deletions(-) diff --git a/lib/src/transformers/node_transformers/passive_button_transformer.dart b/lib/src/transformers/node_transformers/passive_button_transformer.dart index 4e3337d2..e3dcebd8 100644 --- a/lib/src/transformers/node_transformers/passive_button_transformer.dart +++ b/lib/src/transformers/node_transformers/passive_button_transformer.dart @@ -128,10 +128,12 @@ class PassiveButtonWidget extends StatelessWidget { // TODO: refactor into TextUtils where it can be built without a style. if (settings.replaceVariablesWithSymbols) { + final IndexedItem? item = IndexedItemProvider.maybeOf(context); final List spans = buttonLabelTemplateOf(node) .splitMap( variableSyntaxIdentifierRegex, - onMatch: (match) => VariableSpan(variable: match[0]!, node: node), + onMatch: (match) => + VariableSpan(variable: match[0]!, node: node, item: item), onNonMatch: (text) => TextSpan(text: text), ) .toList(); diff --git a/lib/src/transformers/utils/text_utils.dart b/lib/src/transformers/utils/text_utils.dart index b677d159..16779ae7 100644 --- a/lib/src/transformers/utils/text_utils.dart +++ b/lib/src/transformers/utils/text_utils.dart @@ -97,11 +97,19 @@ class TextUtils { // On a design canvas, show defaults or placeholders for variables. if (replaceVariableWithSymbol) { + // In a list item built with its data, `item` is that item. + final IndexedItem? item = context == null + ? null + : IndexedItemProvider.maybeOf(context); final spans = characters .splitMap( variableSyntaxIdentifierRegex, - onMatch: (match) => - VariableSpan(variable: match[0]!, node: node, style: style), + onMatch: (match) => VariableSpan( + variable: match[0]!, + node: node, + item: item, + style: style, + ), onNonMatch: (text) => TextSpan(text: text, style: style), ) .toList(); diff --git a/lib/src/transformers/utils/variable_span.dart b/lib/src/transformers/utils/variable_span.dart index c1feb7df..850291c5 100644 --- a/lib/src/transformers/utils/variable_span.dart +++ b/lib/src/transformers/utils/variable_span.dart @@ -37,8 +37,27 @@ abstract final class VariablePreview { static const Set _literals = {'true', 'false', 'null'}; /// The text the design canvas shows for [expression], one `${...}` match. - static String textFor(String expression, {BaseNode? node, TextStyle? style}) { - final ScopedValues? scopedValues = scopeResolver?.call(node); + /// + /// [item] is the list, grid or page item the text is built in, when the + /// canvas builds items with their data: `item` then reads that item, as + /// the item's other bindings do, instead of the one [scopeResolver] picks + /// for [node]. + static String textFor( + String expression, { + BaseNode? node, + TextStyle? style, + IndexedItem? item, + }) { + ScopedValues? scopedValues = scopeResolver?.call(node); + if (scopedValues != null && item?.item != null) { + scopedValues = ScopedValues.preview( + variables: scopedValues.variables, + codelesslyContext: scopedValues.codelesslyContext, + screenSize: scopedValues.screenSize, + data: scopedValues.data, + indexedItem: item, + ); + } if (scopedValues != null) { final String value = PropertyValueDelegate.substituteVariables( expression, @@ -131,15 +150,22 @@ class VariableSpan extends TextSpan { /// The variable expression, such as `${user.name}`. final String variable; - /// Creates a [VariableSpan] for the [variable] expression of [node]. + /// Creates a [VariableSpan] for the [variable] expression of [node], + /// built in the list, grid or page [item] if any. VariableSpan({ required this.variable, BaseNode? node, + IndexedItem? item, super.style, super.mouseCursor, super.onEnter, super.onExit, }) : super( - text: VariablePreview.textFor(variable, node: node, style: style), + text: VariablePreview.textFor( + variable, + node: node, + style: style, + item: item, + ), ); } diff --git a/test/design_canvas_bindings_test.dart b/test/design_canvas_bindings_test.dart index 526378fc..f8226f23 100644 --- a/test/design_canvas_bindings_test.dart +++ b/test/design_canvas_bindings_test.dart @@ -168,4 +168,67 @@ void main() { expect(find.byKey(const Key('email')), findsNothing); }); }); + + group('a text in a list item', () { + const String note = r'${= flags[item.id].note}'; + final TextNode text = TextNode( + id: 'note', + name: 'Note', + basicBoxLocal: NodeBox(0, 0, 200, 20), + characters: note, + textMixedProps: [StartEndProp(start: 0, end: note.length)], + )..parentID = kRootNode; + + // The editor's values for the canvas: the first item of the list. + setUp( + () => VariablePreview.scopeResolver = (_) => ScopedValues.preview( + variables: { + 'flags': VariableData( + id: 'flags', + name: 'flags', + value: { + '1a': {'note': 'Tread worn'}, + '1b': {'note': 'Brake hose leak'}, + }, + type: VariableType.map, + ), + }, + indexedItem: IndexedItem(0, {'id': '1a'}), + ), + ); + + Future pumpItem(WidgetTester tester, IndexedItem? item) => + pumpHost(tester, (context) { + Widget build(BuildContext context) => TextUtils.buildTextForProps( + context, + note, + node: text, + props: text.textMixedProps, + variablesOverrides: const [], + nullSubstitutionMode: NullSubstitutionMode.noChange, + replaceVariablesWithSymbol: true, + ); + if (item == null) return build(context); + return IndexedItemProvider( + item: item, + child: Builder(builder: build), + ); + }); + + testWidgets('reads the item it is built in, as its bindings do', ( + tester, + ) async { + await pumpItem(tester, IndexedItem(1, {'id': '1b'})); + expect(find.text('Brake hose leak', findRichText: true), findsOne); + }); + + testWidgets('reads the first item when built without item data', ( + tester, + ) async { + await pumpItem(tester, IndexedItem(1, null)); + expect(find.text('Tread worn', findRichText: true), findsOne); + await pumpItem(tester, null); + expect(find.text('Tread worn', findRichText: true), findsOne); + }); + }); } From e79b78d4a2a6a380e68e27db46ed7b3868b2e2b3 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 17:00:45 +0000 Subject: [PATCH 09/13] Spacers flex under host wrappers; fixed-count grids; rows sized to content The spacer transformer returned its own Expanded, and the row/column passed it through untouched. The editor's render canvas wraps every node in GeometryProbes (render objects), so the Expanded no longer sat directly under the Flex: its flex was dropped and the spacer kept its 100 px design size. The row/column now wraps the spacer like any other flexing child, outside everything built for it, and the transformer only builds its box. DesignCellGridDelegate turned a fixed cross-axis count into "as many design-size cells as fit", with the design cell taken from the node box, so a 100 wide grid from JSON got a dozen columns. A fixed count is now SliverGridDelegateWithFixedCrossAxisCount: the count is kept and the grid's width divided between cells. GridDelegateProperties.shrinkWrapRows builds the grid as a list of rows of Expanded cells in an IntrinsicHeight (IntrinsicWidth when horizontal), each as tall as its tallest item; items that can't report intrinsics keep their own size in a row as tall as the tallest. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011WedhUVWdWR5swqZYMuGfP --- CHANGELOG.md | 13 ++ .../passive_grid_view_transformer.dart | 128 ++++++++++++--- .../passive_row_column_transformer.dart | 16 +- .../passive_spacer_transformer.dart | 46 +++--- .../transformers/utils/viewport_sizing.dart | 76 --------- test/flex_layout_test.dart | 98 ++++++++++- test/viewport_layout_test.dart | 153 +++++++++++++----- 7 files changed, 361 insertions(+), 169 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c99bd77..8a33e042 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,19 @@ the center of its box. Needs the unreleased `codelessly_api` change that adds these fields. +### Spacers and grids + +- A spacer that flexes on its parent's main axis is wrapped in its + `Expanded`/`Flexible` by the row or column, outside every wrapper built + around it, so it takes the free space like Flutter's `Spacer` also under + a `NodeBuildDecorator` (the editor's render canvas drew it at its design + size). +- A `fixedCrossAxisCount` grid keeps its count at every width and divides + the grid's width between the cells (`DesignCellGridDelegate` is gone). +- `GridDelegateProperties.shrinkWrapRows`: rows as tall as their tallest + item, built as a list of rows of `Expanded` cells in an `IntrinsicHeight` + (`GridViewBuilder.intrinsicRows`). + ## 8.2.0 ### States diff --git a/lib/src/transformers/node_transformers/passive_grid_view_transformer.dart b/lib/src/transformers/node_transformers/passive_grid_view_transformer.dart index 3d4fa996..2c7fa6a4 100644 --- a/lib/src/transformers/node_transformers/passive_grid_view_transformer.dart +++ b/lib/src/transformers/node_transformers/passive_grid_view_transformer.dart @@ -1,4 +1,5 @@ import 'dart:core'; +import 'dart:math' as math; import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:codelessly_api/codelessly_api.dart'; @@ -83,19 +84,18 @@ class PassiveGridViewWidget extends StatelessWidget { builder: builder, ); - /// The cross extent of one cell as the editor drew it, the same size the - /// editor constrains the item node to. - double get designCellExtent => node.calculateCrossAxisExtent( - node.scrollDirection, - node.innerBoxLocal.size, - ); - @override Widget build(BuildContext context) { if (node.children.isEmpty) { return box(canShrinkWrap: true, (context, _) => const SizedBox()); } final itemNode = node.children.first; + // Cells of a row sized to its content stretch to the tallest one, unless + // the item can't report its intrinsic size. + final bool intrinsicRows = !subtreeBlocksIntrinsics( + manager.getNode(itemNode), + manager.getNode, + ); final bool useCloudDatabase = node.useCloudDatabase; if (useCloudDatabase && !settings.isPreview) { @@ -196,7 +196,7 @@ class PassiveGridViewWidget extends StatelessWidget { return GridViewBuilder( primary: node.primary, gridDelegate: node.properties.gridDelegate, - designCellExtent: designCellExtent, + intrinsicRows: intrinsicRows, shrinkWrap: shrinkWrap, itemCount: itemCount, padding: node.padding.flutterEdgeInsets, @@ -261,7 +261,7 @@ class PassiveGridViewWidget extends StatelessWidget { (context, shrinkWrap) => GridViewBuilder( primary: node.primary, gridDelegate: node.properties.gridDelegate, - designCellExtent: designCellExtent, + intrinsicRows: intrinsicRows, shrinkWrap: shrinkWrap, itemCount: itemCount, padding: node.padding.flutterEdgeInsets, @@ -309,16 +309,17 @@ class GridViewBuilder extends StatelessWidget { final ScrollViewKeyboardDismissBehavior keyboardDismissBehavior; final GridDelegateProperties gridDelegate; - /// The cross extent of one cell at the design size. With a fixed cross - /// axis count, the grid keeps cells at least this wide; see - /// [DesignCellGridDelegate]. Null keeps the count at every size. - final double? designCellExtent; + /// With [GridDelegateProperties.shrinkWrapRows], whether the cells of a row + /// stretch to its tallest item, like a [Row] in an [IntrinsicHeight]. + /// False lays each row out at its tallest item and leaves the others at + /// their own size, for items that can't report intrinsic sizes. + final bool intrinsicRows; const GridViewBuilder({ super.key, required this.shrinkWrap, required this.gridDelegate, - this.designCellExtent, + this.intrinsicRows = true, this.itemCount, this.padding, this.physics, @@ -333,6 +334,35 @@ class GridViewBuilder extends StatelessWidget { @override Widget build(BuildContext context) { + if (gridDelegate.shrinkWrapRows) { + return DraggableScrollConfiguration( + child: switch (gridDelegate) { + FixedCrossAxisCountGridDelegateProperties props => _rows( + props.crossAxisCount, + ), + MaxCrossAxisExtentGridDelegateProperties props => LayoutBuilder( + builder: (context, constraints) { + final bool vertical = scrollDirection == Axis.vertical; + final double extent = + (vertical ? constraints.maxWidth : constraints.maxHeight) - + (vertical + ? padding?.horizontal ?? 0 + : padding?.vertical ?? 0); + // As SliverGridDelegateWithMaxCrossAxisExtent counts. + return _rows( + extent.isFinite + ? (extent / + (props.maxCrossAxisExtent + + props.crossAxisSpacing)) + .ceil() + : 1, + ); + }, + ), + }, + ); + } + return DraggableScrollConfiguration( child: GridView.builder( primary: primary, @@ -350,13 +380,12 @@ class GridViewBuilder extends StatelessWidget { keyboardDismissBehavior: keyboardDismissBehavior, gridDelegate: switch (gridDelegate) { FixedCrossAxisCountGridDelegateProperties props => - DesignCellGridDelegate( - crossAxisCount: props.crossAxisCount, - designCellExtent: designCellExtent ?? 0, + SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: math.max(1, props.crossAxisCount), + mainAxisExtent: props.mainAxisExtent, + childAspectRatio: props.childAspectRatio, crossAxisSpacing: props.crossAxisSpacing, mainAxisSpacing: props.mainAxisSpacing, - childAspectRatio: props.childAspectRatio, - mainAxisExtent: props.mainAxisExtent, ), MaxCrossAxisExtentGridDelegateProperties props => SliverGridDelegateWithMaxCrossAxisExtent( @@ -370,4 +399,65 @@ class GridViewBuilder extends StatelessWidget { ), ); } + + /// A grid whose rows (columns when it scrolls horizontally) are as long as + /// their longest item: a list of rows of [crossAxisCount] equal cells. + Widget _rows(int crossAxisCount) { + final int count = math.max(1, crossAxisCount); + final bool vertical = scrollDirection == Axis.vertical; + final int? rowCount = itemCount == null + ? null + : (itemCount! / count).ceil(); + // The spacing goes before every row but the first, on the side that + // faces the row before it. + final double spacing = gridDelegate.mainAxisSpacing; + final EdgeInsetsGeometry leading = switch ((vertical, reverse)) { + (true, false) => EdgeInsets.only(top: spacing), + (true, true) => EdgeInsets.only(bottom: spacing), + (false, false) => EdgeInsetsDirectional.only(start: spacing), + (false, true) => EdgeInsetsDirectional.only(end: spacing), + }; + + return ListView.builder( + primary: primary, + shrinkWrap: shrinkWrap, + itemCount: rowCount, + padding: padding, + physics: physics, + scrollDirection: scrollDirection, + scrollCacheExtent: cacheExtent == null + ? null + : ScrollCacheExtent.pixels(cacheExtent!), + reverse: reverse, + clipBehavior: clipBehavior, + keyboardDismissBehavior: keyboardDismissBehavior, + itemBuilder: (context, row) { + Widget line = Flex( + direction: vertical ? Axis.horizontal : Axis.vertical, + crossAxisAlignment: intrinsicRows + ? CrossAxisAlignment.stretch + : CrossAxisAlignment.start, + spacing: gridDelegate.crossAxisSpacing, + children: [ + for (int column = 0; column < count; column++) + Expanded( + child: switch (row * count + column) { + final int index + when itemCount == null || index < itemCount! => + itemBuilder!(context, index), + _ => const SizedBox.shrink(), + }, + ), + ], + ); + if (intrinsicRows) { + line = vertical + ? IntrinsicHeight(child: line) + : IntrinsicWidth(child: line); + } + if (row == 0 || spacing <= 0) return line; + return Padding(padding: leading, child: line); + }, + ); + } } 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..46bbb404 100644 --- a/lib/src/transformers/node_transformers/passive_row_column_transformer.dart +++ b/lib/src/transformers/node_transformers/passive_row_column_transformer.dart @@ -192,10 +192,6 @@ class PassiveRowColumnTransformer extends NodeWidgetTransformer { }) { assert(parentRowColumn is RowColumnMixin); - if (node is SpacerNode) { - return childWidget; - } - final AxisC mainAxis = ((parentRowColumn as RowColumnMixin).rowColumnType == RowColumnType.row ? AxisC.horizontal @@ -204,6 +200,18 @@ class PassiveRowColumnTransformer extends NodeWidgetTransformer { ? node.horizontalFit : node.verticalFit); + // A spacer flexes like Flutter's Spacer. The flex goes around everything + // built for it, as for any other child: a host's wrappers (the editor's + // geometry probes) would otherwise sit between the Flex and an Expanded + // inside, and the Flex would lay the spacer out at its design size. + if (node is SpacerNode) { + return switch (mainAxisFit) { + SizeFit.expanded => Expanded(flex: flex, child: childWidget), + SizeFit.flexible => Flexible(flex: flex, child: childWidget), + _ => childWidget, + }; + } + final SizeFit crossAxisFit = (mainAxis == AxisC.horizontal ? node.verticalFit : node.horizontalFit); diff --git a/lib/src/transformers/node_transformers/passive_spacer_transformer.dart b/lib/src/transformers/node_transformers/passive_spacer_transformer.dart index 2aa3138d..d9803dfd 100644 --- a/lib/src/transformers/node_transformers/passive_spacer_transformer.dart +++ b/lib/src/transformers/node_transformers/passive_spacer_transformer.dart @@ -6,7 +6,13 @@ import '../../../codelessly_sdk.dart'; class PassiveSpacerTransformer extends NodeWidgetTransformer { PassiveSpacerTransformer(super.getNode, super.manager); - Widget buildSpacer(BaseNode node, int flex) { + /// The spacer's own box. In a Row or Column, a spacer that flexes on the + /// main axis fills what it is given there: its parent wraps everything + /// built for it in an [Expanded] or [Flexible] (see + /// [PassiveRowColumnTransformer.wrapChildWithSizeFits]), like Flutter's + /// [Spacer]. Where the main axis is unbounded there is nothing to share, + /// and it keeps its design size instead (see CodelesslyFlex and FitBox). + Widget buildSpacer(BaseNode node) { final BaseNode parentNode = getNode(node.parentID); if (parentNode is! RowColumnMixin) { @@ -14,34 +20,22 @@ class PassiveSpacerTransformer extends NodeWidgetTransformer { } final AxisC mainAxis = parentNode.mainAxis; - final SizeFit horizontalFit = node.horizontalFit; - final SizeFit verticalFit = node.verticalFit; - final SizeFit mainFit = (mainAxis == AxisC.horizontal - ? horizontalFit - : verticalFit); + final SizeFit mainFit = mainAxis == AxisC.horizontal + ? node.horizontalFit + : node.verticalFit; - final Widget spacerWidget; if (mainFit.isFlex) { - // Fills its share of the free space. Where the main axis is unbounded - // there is none, and it keeps its design size instead (see - // CodelesslyFlex and FitBox). - spacerWidget = Expanded( - flex: flex, - child: FitBox( - width: mainAxis.isHorizontal ? node.outerBoxLocal.width : null, - height: mainAxis.isVertical ? node.outerBoxLocal.height : null, - fillWidth: mainAxis.isHorizontal, - fillHeight: mainAxis.isVertical, - ), - ); - } else { - spacerWidget = SizedBox( - width: mainAxis.isVertical ? null : node.outerBoxLocal.width, - height: mainAxis.isHorizontal ? null : node.outerBoxLocal.height, + return FitBox( + width: mainAxis.isHorizontal ? node.outerBoxLocal.width : null, + height: mainAxis.isVertical ? node.outerBoxLocal.height : null, + fillWidth: mainAxis.isHorizontal, + fillHeight: mainAxis.isVertical, ); } - - return spacerWidget; + return SizedBox( + width: mainAxis.isVertical ? null : node.outerBoxLocal.width, + height: mainAxis.isHorizontal ? null : node.outerBoxLocal.height, + ); } @override @@ -50,6 +44,6 @@ class PassiveSpacerTransformer extends NodeWidgetTransformer { BuildContext context, WidgetBuildSettings settings, ) { - return buildSpacer(node, node.flex); + return buildSpacer(node); } } diff --git a/lib/src/transformers/utils/viewport_sizing.dart b/lib/src/transformers/utils/viewport_sizing.dart index 74083b90..513769fa 100644 --- a/lib/src/transformers/utils/viewport_sizing.dart +++ b/lib/src/transformers/utils/viewport_sizing.dart @@ -224,82 +224,6 @@ class ListItemBox extends StatelessWidget { } } -/// A grid delegate for a fixed cross axis count that keeps cells at least as -/// wide as the editor drew them. -/// -/// The editor divides the grid's design cross extent by [crossAxisCount]. A -/// plain [SliverGridDelegateWithFixedCrossAxisCount] divides the runtime -/// extent instead, so a grid tuned to 4 columns at 1440 gets 90 px cells at -/// 390 and its items, laid out for ~350 px, overflow. This delegate fits as -/// many cells of [designCellExtent] (plus spacing) as the runtime cross -/// extent holds, at least one, and shares the extent between them. At the -/// design size that is exactly [crossAxisCount]; narrower screens get fewer -/// columns and wider ones more, and a cell is never narrower than designed -/// unless the grid itself is. -class DesignCellGridDelegate extends SliverGridDelegate { - /// The column count at the design size. - final int crossAxisCount; - - /// The cross extent of one cell at the design size. Zero or less falls - /// back to [crossAxisCount] at every size. - final double designCellExtent; - - /// See [SliverGridDelegateWithFixedCrossAxisCount.mainAxisSpacing]. - final double mainAxisSpacing; - - /// See [SliverGridDelegateWithFixedCrossAxisCount.crossAxisSpacing]. - final double crossAxisSpacing; - - /// See [SliverGridDelegateWithFixedCrossAxisCount.childAspectRatio]. - final double childAspectRatio; - - /// See [SliverGridDelegateWithFixedCrossAxisCount.mainAxisExtent]. - final double? mainAxisExtent; - - /// Creates a [DesignCellGridDelegate]. - const DesignCellGridDelegate({ - required this.crossAxisCount, - required this.designCellExtent, - this.mainAxisSpacing = 0, - this.crossAxisSpacing = 0, - this.childAspectRatio = 1, - this.mainAxisExtent, - }); - - /// The column count for a runtime cross extent of [crossAxisExtent]. - int crossAxisCountFor(double crossAxisExtent) { - if (!(designCellExtent > 0) || !crossAxisExtent.isFinite) { - return math.max(1, crossAxisCount); - } - // The epsilon keeps the design size at exactly [crossAxisCount] despite - // rounding in the design cell extent. - final double fit = - (crossAxisExtent + crossAxisSpacing) / - (designCellExtent + crossAxisSpacing); - return math.max(1, (fit + 1e-6).floor()); - } - - @override - SliverGridLayout getLayout(SliverConstraints constraints) { - return SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: crossAxisCountFor(constraints.crossAxisExtent), - mainAxisSpacing: mainAxisSpacing, - crossAxisSpacing: crossAxisSpacing, - childAspectRatio: childAspectRatio, - mainAxisExtent: mainAxisExtent, - ).getLayout(constraints); - } - - @override - bool shouldRelayout(DesignCellGridDelegate oldDelegate) => - oldDelegate.crossAxisCount != crossAxisCount || - oldDelegate.designCellExtent != designCellExtent || - oldDelegate.mainAxisSpacing != mainAxisSpacing || - oldDelegate.crossAxisSpacing != crossAxisSpacing || - oldDelegate.childAspectRatio != childAspectRatio || - oldDelegate.mainAxisExtent != mainAxisExtent; -} - /// Whether [node] or any node below it builds a widget that cannot report /// intrinsic sizes: a list, grid or page view (a viewport behind a /// [LayoutBuilder]), a scrolling row, column or frame (a [LayoutBuilder] diff --git a/test/flex_layout_test.dart b/test/flex_layout_test.dart index c8c75001..d24cfb50 100644 --- a/test/flex_layout_test.dart +++ b/test/flex_layout_test.dart @@ -90,6 +90,7 @@ Future> pump( required Size screen, required Host host, CodelesslyContext? codelesslyContext, + NodeBuildDecorator? decorator, }) async { tester.view.physicalSize = screen; tester.view.devicePixelRatio = 1; @@ -102,13 +103,20 @@ Future> pump( final manager = PassiveNodeTransformerManager( registry.getNodeByID, (context, bounds, pageID, layoutID, canvasID) => const SizedBox(), - ); + )..decorator = decorator; Widget built(BuildContext context) => manager.buildWidgetFromNode( root, context, settings: const WidgetBuildSettings(debugLabel: 'test'), ); - final Widget body = Builder(builder: built); + // A decorator's probes only wrap nodes under a geometry scope, as on the + // editor's render canvas. + final Widget body = decorator == null + ? Builder(builder: built) + : NodeGeometryScope( + registry: NodeGeometryRegistry(), + child: Builder(builder: built), + ); await tester.pumpWidget( ChangeNotifierProvider.value( value: codelesslyContext ?? CodelesslyContext.empty(), @@ -135,11 +143,19 @@ Future everywhere( WidgetTester tester, BaseNode root, List nodes, - void Function(Map sizes, Size screen, Host host) check, -) async { + void Function(Map sizes, Size screen, Host host) check, { + NodeBuildDecorator? decorator, +}) async { for (final Size screen in screens) { for (final Host host in Host.values) { - final sizes = await pump(tester, root, nodes, screen: screen, host: host); + final sizes = await pump( + tester, + root, + nodes, + screen: screen, + host: host, + decorator: decorator, + ); check(sizes, screen, host); } } @@ -620,4 +636,76 @@ void main() { expect(sizes['c']!.width, 100); }); }); + + // The spacer JSON the build scripts send: flex on both axes, so it works + // in a row and in a column, and none of the keys other nodes default. + SpacerNode spacerFromJson(String id) => + NodeJsonConverter().fromJson({ + 'type': 'spacer', + 'id': id, + 'name': id, + 'flex': 1, + 'basicBoxLocal': [0, 0, 100, 100], + 'horizontalFit': 'expanded', + 'verticalFit': 'expanded', + })! + as SpacerNode; + + for (final NodeBuildDecorator? decorator in [ + null, + const GeometryProbeDecorator(), + ]) { + final String host = decorator == null ? 'an app' : 'the editor'; + testWidgets('a spacer takes the free space of a row in $host', ( + tester, + ) async { + final l = rect('l', '', width: 40, height: 40); + final gap = spacerFromJson('gap'); + final r = rect('r', '', width: 40, height: 40); + final row = flex( + 'row', + kRootNode, + [l, gap, r], + type: RowColumnType.row, + width: 390, + height: 56, + ); + await everywhere(tester, row, [l, gap, r], (sizes, screen, host) { + // A tight host forces its size on the root; the others keep 390. + if (host == Host.tight) return; + expect( + tester.getTopLeft(rectOf('r')).dx - + tester.getTopLeft(rectOf('row')).dx, + 390 - 40, + reason: '$host $screen', + ); + }, decorator: decorator); + }); + + testWidgets('a spacer takes the free space of a column in $host', ( + tester, + ) async { + final top = rect('top', '', width: 40, height: 44); + final gap = spacerFromJson('gap'); + final bottom = rect('bottom', '', width: 40, height: 14); + final tile = flex( + 'tile', + kRootNode, + [top, gap, bottom], + type: RowColumnType.column, + width: 160, + height: 112, + ); + await everywhere(tester, tile, [top, gap, bottom], (sizes, screen, host) { + if (host == Host.tight) return; + expect(sizes['tile']!.height, 112, reason: '$host $screen'); + expect( + tester.getTopLeft(rectOf('bottom')).dy - + tester.getTopLeft(rectOf('tile')).dy, + 112 - 14, + reason: '$host $screen', + ); + }, decorator: decorator); + }); + } } diff --git a/test/viewport_layout_test.dart b/test/viewport_layout_test.dart index 4c04e155..50a31254 100644 --- a/test/viewport_layout_test.dart +++ b/test/viewport_layout_test.dart @@ -260,31 +260,6 @@ void main() { }); }); - group('DesignCellGridDelegate', () { - test('keeps the design count at the design size', () { - // 4 columns over 1000 with 8 spacing: cells of 244. - const delegate = DesignCellGridDelegate( - crossAxisCount: 4, - designCellExtent: (1000 - 3 * 8) / 4, - crossAxisSpacing: 8, - ); - expect(delegate.crossAxisCountFor(1000), 4); - expect(delegate.crossAxisCountFor(999), 3); - expect(delegate.crossAxisCountFor(390), 1); - expect(delegate.crossAxisCountFor(100), 1); - expect(delegate.crossAxisCountFor(2000), 7); - }); - - test('falls back to the count without a design cell', () { - const delegate = DesignCellGridDelegate( - crossAxisCount: 3, - designCellExtent: 0, - ); - expect(delegate.crossAxisCountFor(390), 3); - expect(delegate.crossAxisCountFor(1440), 3); - }); - }); - group('subtreeBlocksIntrinsics', () { test('finds a viewport or scroll view below a node', () { final row = RowColumnNode( @@ -468,7 +443,7 @@ void main() { expectVisible(tester, rectOf('item'), screen); }); - forEachLayout('cells tuned at 1440 stay at least as wide as designed', ( + forEachLayout('a fixed cross axis count is kept at every width', ( tester, screen, host, @@ -476,31 +451,131 @@ void main() { await pump(tester, screen, host, grid(), [ rect('item', 'grid', width: 360, height: 360), ]); - // 4 columns of 360 at 1440; 2 at 834; 1 at 390. - final int columns = (screen.width / 360).floor(); final Rect cell = tester.getRect(rectOf('item').first); - expect(cell.width, closeTo(screen.width / columns, 0.01)); - expect(cell.width, greaterThanOrEqualTo(360)); + expect(cell.width, closeTo(screen.width / 4, 0.01)); expect(cell.height, cell.width); }); - forEachLayout('cells tuned at 390 add columns on wider screens', ( + forEachLayout('cells divide the grid width, not the design box', ( tester, screen, host, ) async { - await pump( - tester, - screen, - host, - grid(crossAxisCount: 2, width: 390, height: 844), - [rect('item', 'grid', width: 195, height: 195)], + // The grid the build scripts send: a 100 wide design box. + final GridViewNode node = + NodeJsonConverter().fromJson({ + 'type': 'gridView', + 'id': 'grid', + 'name': 'Vehicle grid', + 'basicBoxLocal': [0, 0, 100, 100], + 'horizontalFit': 'expanded', + 'verticalFit': 'shrinkWrap', + 'isScrollable': false, + 'allowedTypes': [], + 'deniedTypes': ['gridView'], + 'children': ['item'], + 'properties': { + 'itemCount': 4, + 'gridDelegate': { + 'type': 'fixedCrossAxisCount', + 'crossAxisCount': 2, + 'mainAxisSpacing': 16, + 'crossAxisSpacing': 16, + 'mainAxisExtent': 130, + }, + }, + })! + as GridViewNode + ..parentID = kRootNode; + await pump(tester, screen, host, node, [ + rect('item', 'grid', horizontalFit: SizeFit.expanded), + ]); + final List cells = [ + for (final element in rectOf('item').evaluate()) + tester.getRect(find.byWidget(element.widget)), + ]; + expect(cells, hasLength(4)); + for (final Rect cell in cells) { + expect(cell.width, closeTo((screen.width - 16) / 2, 0.01)); + expect(cell.height, 130); + } + expect(cells[1].left - cells[0].right, closeTo(16, 0.01)); + }); + + forEachLayout('a grid node with rows sized to its items', ( + tester, + screen, + host, + ) async { + final GridViewNode node = grid( + crossAxisCount: 2, + width: 390, + height: 300, + verticalFit: SizeFit.shrinkWrap, + itemCount: 4, ); - final int columns = (screen.width / 195).floor(); + node.properties.gridDelegate + ..mainAxisExtent = 130 + ..shrinkWrapRows = true; + await pump(tester, screen, host, node, [ + rect('item', 'grid', height: 70, horizontalFit: SizeFit.expanded), + ]); final Rect cell = tester.getRect(rectOf('item').first); - expect(cell.width, closeTo(screen.width / columns, 0.01)); + expect(cell.width, closeTo(screen.width / 2, 0.01)); + expect(cell.height, 70); + if (host != Host.tight) { + expect(viewportSize(tester), Size(screen.width, 140)); + } }); + for (final bool intrinsicRows in [true, false]) { + testWidgets('rows sized to their content' + '${intrinsicRows ? ' stretch their cells' : ''}', (tester) async { + const List heights = [100, 150, 80, 60, 120]; + await tester.pumpWidget( + MaterialApp( + home: Center( + child: SizedBox( + width: 316, + child: GridViewBuilder( + shrinkWrap: true, + itemCount: heights.length, + gridDelegate: FixedCrossAxisCountGridDelegateProperties( + crossAxisCount: 2, + mainAxisSpacing: 10, + crossAxisSpacing: 16, + mainAxisExtent: 130, + shrinkWrapRows: true, + ), + intrinsicRows: intrinsicRows, + scrollDirection: Axis.vertical, + reverse: false, + clipBehavior: Clip.none, + keyboardDismissBehavior: + ScrollViewKeyboardDismissBehavior.manual, + itemBuilder: (context, index) => + SizedBox(key: ValueKey(index), height: heights[index]), + ), + ), + ), + ), + ); + expect(tester.takeException(), isNull); + Rect cell(int index) => tester.getRect(find.byKey(ValueKey(index))); + // Each row is as tall as its tallest item: 150, 80, 120. + expect( + tester.getSize(find.byType(GridViewBuilder)).height, + 150 + 10 + 80 + 10 + 120, + ); + expect(cell(0).width, 150); + expect(cell(1).left - cell(0).right, 16); + expect(cell(2).top, cell(0).top + 150 + 10); + expect(cell(4).top, cell(2).top + 80 + 10); + expect(cell(0).height, intrinsicRows ? 150 : 100); + expect(cell(3).height, intrinsicRows ? 80 : 60); + }); + } + forEachLayout('shrinkWrap grid sizes to its rows', ( tester, screen, From 9c64c17ecce7a75ebaf639ffb7efe8798ae94819 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 17:53:13 +0000 Subject: [PATCH 10/13] RigCheck runtime: field errors, formulas, sheet confirms, toasts, wrap, scaled frames, parent size - Text fields evaluate their errorText binding (formulas included); an empty message is no error, so the error border shows only while there is one. An error binding (bool) gives the error borders without a message. enabled, obscureText, readOnly, showCursor, maxLength, isCollapsed and isDense follow their bindings too. - Formulas: reverse, substring (negative indexes count from the end), left, right and toMap(list, key, value). Lambdas take an optional index parameter, (item, i) => ..., in every list function. All documented in functionDocs. - ConfirmAction.presentation: the built-in dialog and a dialogId canvas show as a dialog, bottom sheet or side sheet through LayoutPresentationRoute, like showDialog. - Toasts draw an accentColor bar down the left edge and take iconColor, fontFamily and cornerRadius. - A node disabled through its disabled binding, with no Disabled state of its own, fades to 40 %; it already ignored taps. - RowColumnNode.wrap builds a Wrap with wrapSpacing and wrapRunSpacing. Spacers and children hidden at runtime are left out. - FrameNode.contentFit lays the children out at the frame's design size and scales them into the frame (ScaledContent). - The parent variable: ${parent.width} and ${parent.height} are the size a node's parent gives it (ParentSizeScope), so a binding can switch a layout on its container's width. Nodes that read it are built in a LayoutBuilder and count as blocking intrinsics. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011WedhUVWdWR5swqZYMuGfP --- CHANGELOG.md | 29 +++ lib/src/functions/app_confirm_dialog.dart | 232 ++++++++++++------ lib/src/functions/app_toast.dart | 92 +++++-- .../passive_row_column_transformer.dart | 161 ++++++++---- .../passive_stack_transformer.dart | 101 ++++++-- .../passive_text_field_transformer.dart | 123 ++++++++-- .../passive_transformer_manager.dart | 21 ++ lib/src/transformers/transformers.dart | 1 + lib/src/transformers/utils/parent_size.dart | 87 +++++++ .../utils/property_overrides.dart | 20 +- .../utils/property_value_delegate.dart | 1 + lib/src/transformers/utils/scoped_values.dart | 9 +- lib/src/transformers/utils/variable_span.dart | 1 + .../transformers/utils/viewport_sizing.dart | 6 + lib/src/utils/formula.dart | 139 +++++++++-- lib/src/utils/utils.dart | 2 + test/confirm_dialog_test.dart | 119 +++++++++ test/formula_text_list_test.dart | 159 ++++++++++++ test/interaction_states_test.dart | 83 +++++++ test/parent_size_test.dart | 135 ++++++++++ test/scaled_content_test.dart | 145 +++++++++++ test/sheet_presentation_test.dart | 40 +++ test/text_field_error_test.dart | 172 +++++++++++++ test/toast_style_test.dart | 129 ++++++++++ test/wrap_layout_test.dart | 186 ++++++++++++++ 25 files changed, 1976 insertions(+), 217 deletions(-) create mode 100644 lib/src/transformers/utils/parent_size.dart create mode 100644 test/formula_text_list_test.dart create mode 100644 test/parent_size_test.dart create mode 100644 test/scaled_content_test.dart create mode 100644 test/text_field_error_test.dart create mode 100644 test/toast_style_test.dart create mode 100644 test/wrap_layout_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a33e042..391dcfc1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,34 @@ ## Unreleased +### RigCheck gaps + +- Text fields: `variables['errorText']` is evaluated (formulas included) and + an empty result means no error, so the field shows its error border only + while the message is non-empty. `variables['error']` (bool) shows the + error borders without a message. `enabled`, `obscureText`, `readOnly`, + `showCursor`, `maxLength`, `isCollapsed` and `isDense` follow their + bindings too (`resolveTextFieldError`). +- Formulas: `reverse(list or text)`, `substring(text, start, end)` (negative + indexes count from the end), `left(text, n)`, `right(text, n)` and + `toMap(list, key, value)`. A lambda can take the item's index as a second + parameter: `map(steps, (s, i) => i == length(steps) - 1)`, also in filter, + find, count, any, all, findIndex, sortBy, unique, sum, join and toMap. +- `ConfirmAction.presentation`: the built-in dialog and a `dialogId` canvas + show as a dialog, bottom sheet or side sheet, like `showDialog`. +- Toasts: `accentColor`/`accentWidth` draw a bar down the left edge, + `iconColor` colors the icon, `fontFamily` sets the font and + `cornerRadius` the corners (default still a pill). +- A node whose `disabled` binding is true and that has no Disabled + interaction state of its own fades to 40 % (it already ignored taps). +- `RowColumnNode.wrap` lays the children out in a `Wrap` with + `wrapSpacing` and `wrapRunSpacing`; children hidden at runtime and spacers + take no space. +- `FrameNode.contentFit` lays the frame's children out at its design size + and scales them into the frame (`ScaledContent`, a filling `FittedBox`). +- The `parent` variable: `${parent.width}` and `${parent.height}` are the + size a node's parent gives it (`ParentSizeScope`), for layouts that change + with their container's width rather than the screen's. + ### Repeating gradients - Gradient fills honour `PaintModel.tileMode` (clamp, repeated, mirror) and diff --git a/lib/src/functions/app_confirm_dialog.dart b/lib/src/functions/app_confirm_dialog.dart index 76f330fe..73a6385e 100644 --- a/lib/src/functions/app_confirm_dialog.dart +++ b/lib/src/functions/app_confirm_dialog.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../../codelessly_sdk.dart'; +import '../ui/layout_presentation_route.dart'; import 'action_chain.dart'; /// How a [ConfirmAction]'s dialog closed. @@ -45,6 +46,10 @@ class AppConfirmDialog { params: action.params, barrierDismissible: action.barrierDismissible, barrierColor: action.barrierColor?.toFlutterColor(), + presentation: action.presentation, + sheetWidth: action.sheetWidth, + maxHeightFactor: action.maxHeightFactor, + showDragHandle: action.showDragHandle, ); final bool confirmed = ConfirmAction.isConfirmResult(result); return ( @@ -79,6 +84,39 @@ class AppConfirmDialog { // the layout. final ResolvedFeedbackTheme theme = FeedbackTheme.of(context); + Widget buildDialog(BuildContext context) => ThemedConfirmDialog( + theme: theme, + presentation: action.presentation, + title: title, + message: message, + confirmLabel: confirmLabel.isEmpty ? 'OK' : confirmLabel, + cancelLabel: cancelLabel.isEmpty ? 'Cancel' : cancelLabel, + destructive: action.destructive, + backgroundColor: action.backgroundColor?.toFlutterColor(), + textColor: action.textColor?.toFlutterColor(), + confirmColor: action.confirmColor?.toFlutterColor(), + cornerRadius: action.cornerRadius, + ); + + if (action.presentation != DialogPresentation.dialog) { + final Object? answer = await Navigator.of(context).push( + LayoutPresentationRoute( + context: context, + presentation: action.presentation, + barrierDismissible: action.barrierDismissible, + barrierColor: + action.barrierColor?.toFlutterColor() ?? + Theme.of(context).dialogTheme.barrierColor ?? + Colors.black54, + sheetWidth: action.sheetWidth, + maxHeightFactor: action.maxHeightFactor, + showDragHandle: action.showDragHandle, + builder: buildDialog, + ), + ); + return (confirmed: answer == true, values: null); + } + final bool? confirmed = await showDialog( context: context, useRootNavigator: false, @@ -108,6 +146,7 @@ class ThemedConfirmDialog extends StatelessWidget { const ThemedConfirmDialog({ super.key, required this.theme, + this.presentation = DialogPresentation.dialog, required this.title, required this.message, required this.confirmLabel, @@ -122,6 +161,10 @@ class ThemedConfirmDialog extends StatelessWidget { /// The app's look. final ResolvedFeedbackTheme theme; + /// Whether it is a centered dialog or fills a bottom or side sheet. A + /// sheet's route ([LayoutPresentationRoute]) places and clips it. + final DialogPresentation presentation; + /// Title; hidden when empty. final String title; @@ -171,93 +214,122 @@ class ThemedConfirmDialog extends StatelessWidget { const Size buttonSize = Size(64, 44); const EdgeInsets buttonPadding = EdgeInsets.symmetric(horizontal: 18); + final Widget content = Semantics( + scopesRoute: true, + namesRoute: true, + explicitChildNodes: true, + label: title.isEmpty ? null : title, + child: Padding( + padding: const EdgeInsets.fromLTRB(24, 24, 24, 16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (title.isNotEmpty) + Text( + title, + style: theme.titleStyle.copyWith( + fontSize: 18, + height: 1.3, + color: onSurface, + ), + ), + if (title.isNotEmpty && message.isNotEmpty) + const SizedBox(height: 8), + if (message.isNotEmpty) + Flexible( + child: SingleChildScrollView( + child: Text( + message, + style: theme.bodyStyle.copyWith( + fontSize: 15, + height: 1.45, + color: onSurface.withValues(alpha: 0.74), + ), + ), + ), + ), + const SizedBox(height: 20), + OverflowBar( + alignment: MainAxisAlignment.end, + overflowAlignment: OverflowBarAlignment.end, + spacing: 8, + overflowSpacing: 8, + children: [ + TextButton( + key: const ValueKey('codelessly-confirm-cancel'), + onPressed: () => Navigator.of(context).pop(false), + style: TextButton.styleFrom( + foregroundColor: onSurface, + textStyle: buttonText, + shape: buttonShape, + minimumSize: buttonSize, + padding: buttonPadding, + ), + child: Text(cancelLabel), + ), + FilledButton( + key: const ValueKey('codelessly-confirm-ok'), + onPressed: () => Navigator.of(context).pop(true), + style: FilledButton.styleFrom( + backgroundColor: confirmFill, + foregroundColor: onConfirm, + textStyle: buttonText, + shape: buttonShape, + minimumSize: buttonSize, + padding: buttonPadding, + ), + child: Text(confirmLabel), + ), + ], + ), + ], + ), + ), + ); + return Theme( data: theme.apply(Theme.of(context)), - child: Dialog( - backgroundColor: surface, - surfaceTintColor: Colors.transparent, - elevation: 8, - insetPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 24), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(radius), + child: switch (presentation) { + DialogPresentation.dialog => Dialog( + backgroundColor: surface, + surfaceTintColor: Colors.transparent, + elevation: 8, + insetPadding: const EdgeInsets.symmetric( + horizontal: 24, + vertical: 24, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(radius), + ), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 400), + child: content, + ), ), - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 400), - child: Semantics( - scopesRoute: true, - namesRoute: true, - explicitChildNodes: true, - label: title.isEmpty ? null : title, - child: Padding( - padding: const EdgeInsets.fromLTRB(24, 24, 24, 16), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (title.isNotEmpty) - Text( - title, - style: theme.titleStyle.copyWith( - fontSize: 18, - height: 1.3, - color: onSurface, - ), - ), - if (title.isNotEmpty && message.isNotEmpty) - const SizedBox(height: 8), - if (message.isNotEmpty) - Flexible( - child: SingleChildScrollView( - child: Text( - message, - style: theme.bodyStyle.copyWith( - fontSize: 15, - height: 1.45, - color: onSurface.withValues(alpha: 0.74), - ), - ), - ), - ), - const SizedBox(height: 20), - OverflowBar( - alignment: MainAxisAlignment.end, - overflowAlignment: OverflowBarAlignment.end, - spacing: 8, - overflowSpacing: 8, - children: [ - TextButton( - key: const ValueKey('codelessly-confirm-cancel'), - onPressed: () => Navigator.of(context).pop(false), - style: TextButton.styleFrom( - foregroundColor: onSurface, - textStyle: buttonText, - shape: buttonShape, - minimumSize: buttonSize, - padding: buttonPadding, - ), - child: Text(cancelLabel), - ), - FilledButton( - key: const ValueKey('codelessly-confirm-ok'), - onPressed: () => Navigator.of(context).pop(true), - style: FilledButton.styleFrom( - backgroundColor: confirmFill, - foregroundColor: onConfirm, - textStyle: buttonText, - shape: buttonShape, - minimumSize: buttonSize, - padding: buttonPadding, - ), - child: Text(confirmLabel), - ), - ], - ), - ], + // A sheet is as wide as its route makes it; its content keeps clear + // of the home indicator and the bottom sheet's grab handle. + DialogPresentation.bottomSheet => SizedBox( + width: double.infinity, + child: Material( + color: surface, + child: SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.only(top: 8), + child: content, ), ), ), ), - ), + DialogPresentation.sideSheet => Material( + color: surface, + child: SafeArea( + left: false, + child: Align(alignment: Alignment.topLeft, child: content), + ), + ), + }, ); } } diff --git a/lib/src/functions/app_toast.dart b/lib/src/functions/app_toast.dart index 51faed5e..3675e69d 100644 --- a/lib/src/functions/app_toast.dart +++ b/lib/src/functions/app_toast.dart @@ -76,6 +76,19 @@ class AppToast { if (entry != null && entry.mounted) entry.remove(); } + /// The font of [family], e.g. `Barlow`: a Google font or one the app + /// loaded. Null when [family] is null or empty. + static TextStyle? fontOf(String? family) { + if (family == null || family.trim().isEmpty) return null; + final TextStyle style = TextUtils.retrieveTextStyle( + fontName: FontName(family: family.trim(), weight: FontWeightNumeric.w600), + ); + return TextStyle( + fontFamily: style.fontFamily, + fontFamilyFallback: style.fontFamilyFallback, + ); + } + /// The background and text colors of a toast of [style] in [theme]. static (Color background, Color foreground) colorsOf( ToastStyle style, @@ -171,38 +184,51 @@ class _ToastViewState extends State<_ToastView> { (action.backgroundColor == null ? styleForeground : ResolvedFeedbackTheme.onColor(background)); - final Widget? icon = action.icon == null + final Color? accent = action.accentColor?.toFlutterColor(); + // The icon's own color, unless [ShowToastAction.iconColor] is set, else + // the message color. + final MultiSourceIconModel? iconModel = action.iconColor == null + ? action.icon + : action.icon?.copyWith(color: action.iconColor); + final Widget? icon = iconModel == null ? null - : IconTheme.merge( - data: IconThemeData(color: textColor), - child: retrieveIconWidget(action.icon!, 18) ?? const SizedBox(), - ); + : retrieveIconWidget(iconModel, iconModel.size ?? 18, textColor); final bool hasButton = widget.onAction != null; final bool top = action.position == ToastPosition.top; final EdgeInsets safe = MediaQuery.paddingOf(context); - final TextStyle messageStyle = theme.bodyStyle.copyWith( - color: textColor, - fontSize: 14, - height: 1.35, - fontWeight: FontWeight.w600, - decoration: TextDecoration.none, - ); + final TextStyle? font = AppToast.fontOf(action.fontFamily); + final TextStyle messageStyle = theme.bodyStyle + .merge(font) + .copyWith( + color: textColor, + fontSize: 14, + height: 1.35, + fontWeight: FontWeight.w600, + decoration: TextDecoration.none, + ); + final BorderRadius radius = BorderRadius.circular( + action.cornerRadius ?? + (hasButton ? theme.buttonRadius.clamp(8, 999) : 999), + ); Widget toast = Container( + key: const ValueKey('codelessly-toast'), constraints: const BoxConstraints(minHeight: 44, maxWidth: 560), - padding: EdgeInsets.fromLTRB(18, 10, hasButton ? 8 : 18, 10), + padding: EdgeInsets.fromLTRB( + accent == null ? 18 : 14, + 10, + hasButton ? 8 : 18, + 10, + ), + // The accent bar is a left border. A border on one side can't have a + // radius, so the clip below rounds the corners. decoration: BoxDecoration( color: background, - borderRadius: BorderRadius.circular( - hasButton ? theme.buttonRadius.clamp(8, 999) : 999, - ), - boxShadow: const [ - BoxShadow( - color: Color(0x33000000), - blurRadius: 18, - offset: Offset(0, 6), - ), - ], + border: accent == null + ? null + : Border( + left: BorderSide(color: accent, width: action.accentWidth), + ), ), child: Row( mainAxisSize: MainAxisSize.min, @@ -218,10 +244,9 @@ class _ToastViewState extends State<_ToastView> { foregroundColor: textColor, minimumSize: const Size(48, 36), padding: const EdgeInsets.symmetric(horizontal: 12), - textStyle: theme.buttonStyle.copyWith( - fontSize: 14, - fontWeight: FontWeight.w700, - ), + textStyle: theme.buttonStyle + .merge(font) + .copyWith(fontSize: 14, fontWeight: FontWeight.w700), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(theme.buttonRadius), ), @@ -232,6 +257,19 @@ class _ToastViewState extends State<_ToastView> { ], ), ); + toast = DecoratedBox( + decoration: BoxDecoration( + borderRadius: radius, + boxShadow: const [ + BoxShadow( + color: Color(0x33000000), + blurRadius: 18, + offset: Offset(0, 6), + ), + ], + ), + child: ClipRRect(borderRadius: radius, child: toast), + ); toast = Semantics( liveRegion: true, container: true, 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 46bbb404..0f59f6e3 100644 --- a/lib/src/transformers/node_transformers/passive_row_column_transformer.dart +++ b/lib/src/transformers/node_transformers/passive_row_column_transformer.dart @@ -90,18 +90,33 @@ class PassiveRowColumnTransformer extends NodeWidgetTransformer { } } - Widget res = CodelesslyFlex( - direction: isRow ? Axis.horizontal : Axis.vertical, - mainAxisAlignment: rowColumnNode.mainAxisAlignment.flutterAxis, - crossAxisAlignment: stretchFallsBack - ? CrossAxisAlignment.start - : rowColumnNode.crossAxisAlignment.flutterAxis, - mainAxisSize: mainFit == SizeFit.shrinkWrap - ? MainAxisSize.min - : MainAxisSize.max, - key: ValueKey(rowColumnNode.id), - children: childrenWidgets, - ); + final bool wraps = rowColumnNode is RowColumnNode && rowColumnNode.wrap; + if (wraps) measureCross = false; + + Widget res = wraps + ? Wrap( + key: ValueKey(rowColumnNode.id), + direction: isRow ? Axis.horizontal : Axis.vertical, + spacing: rowColumnNode.wrapSpacing, + runSpacing: rowColumnNode.wrapRunSpacing, + alignment: wrapAlignmentOf(rowColumnNode.mainAxisAlignment), + crossAxisAlignment: wrapCrossAlignmentOf( + rowColumnNode.crossAxisAlignment, + ), + children: childrenWidgets, + ) + : CodelesslyFlex( + direction: isRow ? Axis.horizontal : Axis.vertical, + mainAxisAlignment: rowColumnNode.mainAxisAlignment.flutterAxis, + crossAxisAlignment: stretchFallsBack + ? CrossAxisAlignment.start + : rowColumnNode.crossAxisAlignment.flutterAxis, + mainAxisSize: mainFit == SizeFit.shrinkWrap + ? MainAxisSize.min + : MainAxisSize.max, + key: ValueKey(rowColumnNode.id), + children: childrenWidgets, + ); if (measureCross) { res = isRow ? IntrinsicHeight(child: res) : IntrinsicWidth(child: res); @@ -146,6 +161,28 @@ class PassiveRowColumnTransformer extends NodeWidgetTransformer { ); } + /// How a wrapping Row/Column places each line's children along the main + /// axis. + static WrapAlignment wrapAlignmentOf(MainAxisAlignmentC alignment) => + switch (alignment) { + MainAxisAlignmentC.start => WrapAlignment.start, + MainAxisAlignmentC.center => WrapAlignment.center, + MainAxisAlignmentC.end => WrapAlignment.end, + MainAxisAlignmentC.spaceBetween => WrapAlignment.spaceBetween, + MainAxisAlignmentC.spaceAround => WrapAlignment.spaceAround, + MainAxisAlignmentC.spaceEvenly => WrapAlignment.spaceEvenly, + }; + + /// How a wrapping Row/Column places children within their line. A line + /// can't stretch its children, so stretch and baseline read as start. + static WrapCrossAlignment wrapCrossAlignmentOf( + CrossAxisAlignmentC alignment, + ) => switch (alignment) { + CrossAxisAlignmentC.center => WrapCrossAlignment.center, + CrossAxisAlignmentC.end => WrapCrossAlignment.end, + _ => WrapCrossAlignment.start, + }; + /// The design extent of a Row/Column's own box on one axis, or null when it /// hugs its children. /// @@ -348,6 +385,32 @@ class PassiveRowColumnWidget extends StatelessWidget { final NodeTransformerManager manager; final WidgetBuildSettings settings; + /// Whether [child] isn't hidden by a condition or binding at runtime. + bool _runtimeVisible(BuildContext context, BaseNode child) { + final NodeTransformerManager manager = this.manager; + if (manager is! WidgetNodeTransformerManager) return true; + // Its parent's size is only known as it is laid out; it hides itself. + if (readsParentSize(child)) return true; + return manager.runtimeVisibility(context, child) != false; + } + + Widget _buildChild(BuildContext context, BaseNode child, bool wraps) { + final Widget builtWidget = manager.buildWidgetByID( + child.id, + context, + settings: settings, + ); + if (wraps) return builtWidget; + final Widget sizedWidget = + PassiveRowColumnTransformer.wrapChildWithSizeFits( + child, + node, + builtWidget, + child.flex, + ); + return _collapseWhenHidden(context, child, sizedWidget); + } + /// A flexing child hidden at runtime gives up its share of the free space, /// so a visible sibling (an empty state next to a hidden spinner) can take /// all of it. The flex drops to zero rather than removing the [Flexible] so @@ -358,7 +421,11 @@ class PassiveRowColumnWidget extends StatelessWidget { Widget sizedWidget, ) { final NodeTransformerManager manager = this.manager; - if (sizedWidget is! Flexible || manager is! PassiveNodeTransformerManager) { + // A child that reads its parent's size can only tell whether it is + // hidden as it is laid out, so it keeps its flex. + if (sizedWidget is! Flexible || + manager is! PassiveNodeTransformerManager || + readsParentSize(child)) { return sizedWidget; } @@ -389,22 +456,46 @@ class PassiveRowColumnWidget extends StatelessWidget { @override Widget build(BuildContext context) { - final List widgetChildren = children.map((child) { - final Widget builtWidget = manager.buildWidgetByID( - child.id, - context, - settings: settings, + // A wrapping Row/Column lays its children out at their own sizes: a + // Wrap can't flex them, and a spacer has no free space to take. + final bool wraps = node is RowColumnNode && (node as RowColumnNode).wrap; + + Widget layout(BuildContext context) { + // A wrapping Row/Column leaves out the children hidden at runtime, so + // they take no spacing either. + final List shown = [ + for (final BaseNode child in children) + if (!wraps || + (child is! SpacerNode && _runtimeVisible(context, child))) + child, + ]; + return PassiveRowColumnTransformer.buildRowColumnWidget( + node, + childrenWidgets: [ + for (final BaseNode child in shown) + _buildChild(context, child, wraps), + ], + childrenNodes: shown, + withScroll: true, + getNode: manager.getNode, ); + } - final Widget sizedWidget = - PassiveRowColumnTransformer.wrapChildWithSizeFits( - child, - node, - builtWidget, - child.flex, + final NodeTransformerManager passive = manager; + final List listenables = + wraps && passive is PassiveNodeTransformerManager + ? [ + for (final BaseNode child in children) + ...passive.listenablesFor(context, child), + ] + : const []; + final Widget rowColumn = listenables.isEmpty + ? layout(context) + : ManagedListenableBuilder( + key: ValueKey('${node.id}-wrap'), + listenables: listenables, + builder: layout, ); - return _collapseWhenHidden(context, child, sizedWidget); - }).toList(); final Widget child = (node is DefaultShapeNode) ? manager.getTransformer().buildRectangle( @@ -412,24 +503,10 @@ class PassiveRowColumnWidget extends StatelessWidget { applyPadding: node is! ScrollableMixin || !(node as ScrollableMixin).isScrollable, - children: [ - PassiveRowColumnTransformer.buildRowColumnWidget( - node, - childrenWidgets: widgetChildren, - childrenNodes: children, - withScroll: true, - getNode: manager.getNode, - ), - ], + children: [rowColumn], settings: settings, ) - : PassiveRowColumnTransformer.buildRowColumnWidget( - node, - childrenWidgets: widgetChildren, - childrenNodes: children, - withScroll: true, - getNode: manager.getNode, - ); + : rowColumn; if (kIsTestLayout) { return DecoratedBox( diff --git a/lib/src/transformers/node_transformers/passive_stack_transformer.dart b/lib/src/transformers/node_transformers/passive_stack_transformer.dart index 6bfee511..4d51db41 100644 --- a/lib/src/transformers/node_transformers/passive_stack_transformer.dart +++ b/lib/src/transformers/node_transformers/passive_stack_transformer.dart @@ -261,24 +261,37 @@ class PassiveStackTransformer extends NodeWidgetTransformer { ]; final AxisC? scrollAxis = scrollAxisFor(node); - final List childrenWidgets = [ - FreeformLayout( - wrapsWidth: node.isHorizontalWrap, - wrapsHeight: node.isVerticalWrap, + final Fit? contentFit = node is FrameNode ? node.contentFit : null; + Widget content = FreeformLayout( + // Scaled content is laid out at its design size. + wrapsWidth: contentFit == null && node.isHorizontalWrap, + wrapsHeight: contentFit == null && node.isVerticalWrap, + designSize: node.innerBoxLocal.size.flutterSize, + children: [ + for (final BaseNode childNode in children) + buildStackChild( + childNode, + node, + context, + manager: manager, + scrollAxis: contentFit == null ? scrollAxis : null, + settings: settings, + ), + ], + ); + if (contentFit != null) { + content = ScaledContent( designSize: node.innerBoxLocal.size.flutterSize, - children: [ - for (final BaseNode childNode in children) - buildStackChild( - childNode, - node, - context, - manager: manager, - scrollAxis: scrollAxis, - settings: settings, - ), - ], - ), - ]; + fit: contentFit.boxFit, + alignment: + (node as FrameNode).contentAlignment.flutterAlignment ?? + Alignment.center, + hugsWidth: node.isHorizontalWrap, + hugsHeight: node.isVerticalWrap, + child: content, + ); + } + final List childrenWidgets = [content]; Widget stack = manager .getTransformer() @@ -297,3 +310,57 @@ class PassiveStackTransformer extends NodeWidgetTransformer { } } } + +/// Lays [child] out at [designSize] and scales it by [fit] into the space it +/// gets, like a [FittedBox] that fills that space: a frame's content with +/// [FrameNode.contentFit]. On an axis it hugs (or that is unbounded) it +/// takes the design size. +class ScaledContent extends StatelessWidget { + /// The size [child] is laid out at. + final Size designSize; + + /// How [child] is scaled into the space. + final BoxFit fit; + + /// Where [child] sits when [fit] leaves space. + final AlignmentGeometry alignment; + + /// Whether the width hugs: it is then the design width. + final bool hugsWidth; + + /// Whether the height hugs: it is then the design height. + final bool hugsHeight; + + /// The content. + final Widget child; + + /// Creates a [ScaledContent]. + const ScaledContent({ + super.key, + required this.designSize, + required this.fit, + required this.alignment, + required this.child, + this.hugsWidth = false, + this.hugsHeight = false, + }); + + @override + Widget build(BuildContext context) => LayoutBuilder( + builder: (context, constraints) => SizedBox( + width: hugsWidth || !constraints.hasBoundedWidth + ? designSize.width + : constraints.maxWidth, + height: hugsHeight || !constraints.hasBoundedHeight + ? designSize.height + : constraints.maxHeight, + child: ClipRect( + child: FittedBox( + fit: fit, + alignment: alignment, + child: SizedBox.fromSize(size: designSize, child: child), + ), + ), + ), + ); +} diff --git a/lib/src/transformers/node_transformers/passive_text_field_transformer.dart b/lib/src/transformers/node_transformers/passive_text_field_transformer.dart index 8f11bb2f..ac17cff3 100644 --- a/lib/src/transformers/node_transformers/passive_text_field_transformer.dart +++ b/lib/src/transformers/node_transformers/passive_text_field_transformer.dart @@ -158,6 +158,43 @@ class PassiveTextFieldWidget extends StatefulWidget { State createState() => _PassiveTextFieldWidgetState(); } +/// The error message a text field shows, or null when it shows none. +/// +/// It comes from the field's `errorText` binding in [BaseNode.variables] when +/// there is one (a `${= formula}` is evaluated), else from a value a set-value +/// action gave `errorText`, else from the decoration's `errorText`. Variables +/// in the text are substituted, and a message that comes out empty is no +/// error, so `${= showErrors && length(note) < 10 ? "Too short" : ""}` only +/// puts the field in its error state while the condition holds. +String? resolveTextFieldError( + TextFieldNode node, { + required ScopedValues scopedValues, + NullSubstitutionMode nullSubstitutionMode = NullSubstitutionMode.emptyString, +}) { + final bool bound = node.variables['errorText'] != null; + final String? raw = bound + ? PropertyValueDelegate.getPropertyValue( + node, + 'errorText', + scopedValues: scopedValues, + ) + : PropertyValueDelegate.getPropertyValueFromNodeValues( + node, + 'errorText', + scopedValues: scopedValues, + ) ?? + node.properties.decoration.errorText; + if (raw == null || raw.trim().isEmpty) return null; + final String text = PropertyValueDelegate.substituteVariables( + raw, + scopedValues: scopedValues, + nullSubstitutionMode: nullSubstitutionMode == NullSubstitutionMode.noChange + ? NullSubstitutionMode.emptyString + : nullSubstitutionMode, + ); + return text.trim().isEmpty ? null : text; +} + class _PassiveTextFieldWidgetState extends State { late final TextEditingController controller = TextEditingController( text: boundValue() ?? getInitialText(widget.node.initialText), @@ -281,18 +318,21 @@ class _PassiveTextFieldWidgetState extends State { final TextFieldNode node = widget.node; final TextFieldProperties properties = node.properties; + final ScopedValues scopedValues = ScopedValues.of(context); + + // Each of these follows its binding in `variables` when it has one, else + // a value set by a set-value action, else the property. final bool enabled = - context.getNodeValue(node.id, 'enabled') ?? properties.enabled; + _boundValue('enabled', scopedValues) ?? properties.enabled; final bool obscureText = - context.getNodeValue(node.id, 'obscureText') ?? properties.obscureText; + _boundValue('obscureText', scopedValues) ?? + properties.obscureText; final bool readOnly = - context.getNodeValue(node.id, 'readOnly') ?? properties.readOnly; + _boundValue('readOnly', scopedValues) ?? properties.readOnly; final bool showCursor = - context.getNodeValue(node.id, 'showCursor') ?? properties.showCursor; + _boundValue('showCursor', scopedValues) ?? properties.showCursor; final int? maxLength = - context.getNodeValue(node.id, 'maxLength') ?? properties.maxLength; - - final ScopedValues scopedValues = ScopedValues.of(context); + _boundValue('maxLength', scopedValues) ?? properties.maxLength; final TextInputType keyboardType = properties.keyboardType == TextInputTypeEnum.number @@ -401,9 +441,18 @@ class _PassiveTextFieldWidgetState extends State { ScopedValues scopedValues, ) { final bool isCollapsed = - context.getNodeValue(node.id, 'isCollapsed') ?? decoration.isCollapsed; + _boundValue('isCollapsed', scopedValues) ?? + decoration.isCollapsed; final bool isDense = - context.getNodeValue(node.id, 'isDense') ?? decoration.isDense; + _boundValue('isDense', scopedValues) ?? decoration.isDense; + final String? errorMessage = resolveTextFieldError( + node, + scopedValues: scopedValues, + nullSubstitutionMode: settings.nullSubstitutionMode, + ); + final bool hasError = + errorMessage != null || + (_boundValue('error', scopedValues) ?? false); final Widget? labelText = buildText( context, node, @@ -435,21 +484,22 @@ class _PassiveTextFieldWidgetState extends State { scopedValues: scopedValues, settings: settings, ); - final Widget? errorText = buildText( - context, - node, - property: 'errorText', - defaultValue: decoration.errorText, - scopedValues: scopedValues, - settings: settings, - variablesOverrides: widget.variablesOverrides, - color: decoration.errorStyle.fills.first.toFlutterColor(), - fontSize: decoration.errorStyle.fontSize, - letterSpacing: decoration.errorStyle.letterSpacing, - lineHeight: decoration.errorStyle.lineHeight, - fontName: decoration.errorStyle.fontName, - textDecoration: decoration.errorStyle.textDecoration, - ); + final Widget? errorText = errorMessage == null + ? null + : TextUtils.buildText( + context, + errorMessage, + node: node, + variablesOverrides: widget.variablesOverrides, + nullSubstitutionMode: settings.nullSubstitutionMode, + replaceVariablesWithSymbol: settings.replaceVariablesWithSymbols, + color: decoration.errorStyle.fills.first.toFlutterColor(), + fontSize: decoration.errorStyle.fontSize, + letterSpacing: decoration.errorStyle.letterSpacing, + lineHeight: decoration.errorStyle.lineHeight, + fontName: decoration.errorStyle.fontName, + textDecoration: decoration.errorStyle.textDecoration, + ); final Widget? prefixText = buildText( context, node, @@ -566,10 +616,20 @@ class _PassiveTextFieldWidgetState extends State { focusColor: decoration.focusColor.toFlutterColor(), hoverColor: decoration.hoverColor.toFlutterColor(), errorBorder: getInputBorder(decoration.errorBorder), - focusedBorder: getInputBorder(decoration.focusedBorder), + // An `error` binding without a message has no error line to put the + // field in Flutter's error state, so it gets the error borders here. + focusedBorder: getInputBorder( + hasError && errorText == null + ? decoration.focusedErrorBorder + : decoration.focusedBorder, + ), focusedErrorBorder: getInputBorder(decoration.focusedErrorBorder), disabledBorder: getInputBorder(decoration.disabledBorder), - enabledBorder: getInputBorder(decoration.enabledBorder), + enabledBorder: getInputBorder( + hasError && errorText == null + ? decoration.errorBorder + : decoration.enabledBorder, + ), border: getInputBorder(decoration.border), enabled: decoration.enabled, semanticCounterText: decoration.semanticCounterText, @@ -578,6 +638,17 @@ class _PassiveTextFieldWidgetState extends State { ); } + /// The value of [property]: its binding in [BaseNode.variables] when it has + /// one, else the value a set-value action gave it. + T? _boundValue( + String property, + ScopedValues scopedValues, + ) => PropertyValueDelegate.getPropertyValue( + widget.node, + property, + scopedValues: scopedValues, + ); + InputBorder? getInputBorder(InputBorderModel? inputBorder) { if (inputBorder == null) return null; switch (inputBorder.borderType) { diff --git a/lib/src/transformers/passive_transformer_manager.dart b/lib/src/transformers/passive_transformer_manager.dart index 818d370f..369b0add 100644 --- a/lib/src/transformers/passive_transformer_manager.dart +++ b/lib/src/transformers/passive_transformer_manager.dart @@ -84,6 +84,27 @@ class PassiveNodeTransformerManager extends WidgetNodeTransformerManager { ); } + // A node that reads `parent` is built with the size its parent gives + // it, so its bindings can lay it out by that size. + if (readsParentSize(node)) { + return LayoutBuilder( + key: ValueKey('${node.id}-parent-size'), + builder: (context, constraints) => ParentSizeScope.fromConstraints( + constraints, + child: Builder( + builder: (context) => _buildNode(context, node, settings), + ), + ), + ); + } + return _buildNode(context, node, settings); + } + + Widget _buildNode( + BuildContext context, + BaseNode node, + WidgetBuildSettings settings, + ) { return _wrapWithListener( context, node: node, diff --git a/lib/src/transformers/transformers.dart b/lib/src/transformers/transformers.dart index 84004ee5..1a934d26 100644 --- a/lib/src/transformers/transformers.dart +++ b/lib/src/transformers/transformers.dart @@ -12,6 +12,7 @@ export 'utils/fit_layout.dart'; export 'utils/indexed_item_provider.dart'; export 'utils/loop_animation_box.dart'; export 'utils/node_geometry.dart'; +export 'utils/parent_size.dart'; export 'utils/property_overrides.dart'; export 'utils/property_value_delegate.dart'; export 'utils/scoped_values.dart'; diff --git a/lib/src/transformers/utils/parent_size.dart b/lib/src/transformers/utils/parent_size.dart new file mode 100644 index 00000000..a35ce77a --- /dev/null +++ b/lib/src/transformers/utils/parent_size.dart @@ -0,0 +1,87 @@ +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:flutter/widgets.dart'; + +import '../../../codelessly_sdk.dart'; + +/// Gives the nodes below it the size their parent gives them, which the +/// `parent` variable reads: `${parent.width}` and `${parent.height}`. +/// +/// It is the space a node is laid out in: the width of a column's content +/// box for a child that fills it, the width of a frame's content box for a +/// child in it. An axis that is unbounded (a row's main axis for a child +/// that doesn't flex, a scroll view's scroll axis) has no value. +class ParentSizeScope extends InheritedWidget { + /// The width the parent gives, or null when it is unbounded. + final double? width; + + /// The height the parent gives, or null when it is unbounded. + final double? height; + + /// Creates a [ParentSizeScope]. + const ParentSizeScope({ + super.key, + required this.width, + required this.height, + required super.child, + }); + + /// A scope with the bounded sides of [constraints]. + factory ParentSizeScope.fromConstraints( + BoxConstraints constraints, { + required Widget child, + }) => ParentSizeScope( + width: constraints.hasBoundedWidth ? constraints.maxWidth : null, + height: constraints.hasBoundedHeight ? constraints.maxHeight : null, + child: child, + ); + + /// The value of the `parent` variable at [context], or null when no node + /// above reads it. + static Map? valueOf(BuildContext context) { + final ParentSizeScope? scope = context + .dependOnInheritedWidgetOfExactType(); + if (scope == null) return null; + return parentVariableValue(scope.width, scope.height); + } + + @override + bool updateShouldNotify(ParentSizeScope oldWidget) => + width != oldWidget.width || height != oldWidget.height; +} + +/// The value of the `parent` variable for a parent that gives [width] and +/// [height]: `width` and `height`, each null when unbounded. +Map parentVariableValue(double? width, double? height) => { + 'width': width, + 'height': height, +}; + +/// Whether [node] reads the `parent` variable in a binding, a formula or an +/// inline `${...}` text, so it is built inside a [ParentSizeScope]. +bool readsParentSize(BaseNode node) { + bool isParent(String path) => + VariableMatch.parse(path.wrapWithVariableSyntax())?.name == 'parent'; + for (final String path in node.variables.values) { + final String? expression = FormulaEvaluator.exactExpression(path); + if (expression != null) { + if (FormulaEvaluator.referencedPaths(expression).any(isParent)) { + return true; + } + } else if (isParent(path)) { + return true; + } + } + for (final List paths in node.multipleVariables.values) { + if (paths.any(isParent)) return true; + } + for (final String template in inlineTextTemplatesOf(node)) { + if (FormulaEvaluator.referencedPathsInText(template).any(isParent)) { + return true; + } + for (final RegExpMatch match in variablePathRegex.allMatches(template)) { + final String? path = match.namedGroup('value'); + if (path != null && isParent(path)) return true; + } + } + return false; +} diff --git a/lib/src/transformers/utils/property_overrides.dart b/lib/src/transformers/utils/property_overrides.dart index e6e2e10e..66f9ca48 100644 --- a/lib/src/transformers/utils/property_overrides.dart +++ b/lib/src/transformers/utils/property_overrides.dart @@ -67,8 +67,16 @@ bool usesInteractionStates(BaseNode node) => /// states: puts the overrides of every active state (in /// [InteractionState] order, the later winning) in a [PropertyOverrideScope] /// over [builder]. A disabled node ignores the pointer, so its actions do -/// not run. +/// not run, and the cursor over it is the basic one. +/// +/// A node disabled without a [InteractionState.disabled] state of its own +/// fades to [defaultDisabledOpacity], so binding `variables['disabled']` +/// (e.g. to `${= busy != ""}`) is enough to dim it. A node with that state +/// shows the state's overrides instead. class InteractionStatesBox extends StatefulWidget { + /// The opacity of a disabled node that has no disabled state of its own. + static const double defaultDisabledOpacity = 0.4; + /// The node whose states are shown. final BaseNode node; @@ -176,6 +184,14 @@ class _InteractionStatesBoxState extends State { // A disabled node runs no actions. Always wrapped so that switching it // keeps the state below. child = IgnorePointer(ignoring: disabled, child: child); - return child; + // Without a look of its own for the disabled state, a disabled node + // fades. Always wrapped, like the IgnorePointer. + final bool dims = + disabled && !states.containsKey(InteractionState.disabled); + child = Opacity( + opacity: dims ? InteractionStatesBox.defaultDisabledOpacity : 1, + child: child, + ); + return Semantics(enabled: disabled ? false : null, child: child); } } diff --git a/lib/src/transformers/utils/property_value_delegate.dart b/lib/src/transformers/utils/property_value_delegate.dart index 58114a89..12581bd9 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), + 'parent' => scopedValues.parentSize, _ => null, }; diff --git a/lib/src/transformers/utils/scoped_values.dart b/lib/src/transformers/utils/scoped_values.dart index 146ebbac..f28584f4 100644 --- a/lib/src/transformers/utils/scoped_values.dart +++ b/lib/src/transformers/utils/scoped_values.dart @@ -31,6 +31,10 @@ class ScopedValues { /// The size of the screen, which the `screen` variable describes. late final Size? screenSize; + /// The value of the `parent` variable: the `width` and `height` the + /// node's parent gives it (see [ParentSizeScope]). + late final Map? parentSize; + // private fields. late final WeakReference? _codelesslyContextRef; // The Codelessly instance, read live: its cloud database can become @@ -77,7 +81,8 @@ class ScopedValues { nodeState = null, routeParams = {}, _appUser = null, - screenSize = null; + screenSize = null, + parentSize = null; /// Creates scoped values for a design canvas, where [variables] (by name) /// hold their default values and nothing is read from a running app. @@ -88,6 +93,7 @@ class ScopedValues { this.indexedItem, this._appUser, this.screenSize, + this.parentSize, Map? data, }) : _codelesslyContextRef = codelesslyContext == null ? null @@ -113,6 +119,7 @@ class ScopedValues { : WeakReference(codelesslyContext); indexedItem = IndexedItemProvider.maybeOf(context); screenSize = MediaQuery.maybeSizeOf(context); + parentSize = ParentSizeScope.valueOf(context); nodeState = NodeStateProvider.maybeOf(context)?.state; routeParams = ModalRoute.of(context)?.settings.arguments as Map? ?? diff --git a/lib/src/transformers/utils/variable_span.dart b/lib/src/transformers/utils/variable_span.dart index 850291c5..ead1b78d 100644 --- a/lib/src/transformers/utils/variable_span.dart +++ b/lib/src/transformers/utils/variable_span.dart @@ -54,6 +54,7 @@ abstract final class VariablePreview { variables: scopedValues.variables, codelesslyContext: scopedValues.codelesslyContext, screenSize: scopedValues.screenSize, + parentSize: scopedValues.parentSize, data: scopedValues.data, indexedItem: item, ); diff --git a/lib/src/transformers/utils/viewport_sizing.dart b/lib/src/transformers/utils/viewport_sizing.dart index 513769fa..37b76831 100644 --- a/lib/src/transformers/utils/viewport_sizing.dart +++ b/lib/src/transformers/utils/viewport_sizing.dart @@ -4,6 +4,8 @@ import 'package:codelessly_api/codelessly_api.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/widgets.dart'; +import 'parent_size.dart'; + /// Builds a scroll view. [shrinkWrap] says whether the view must size itself /// to its items on its scroll axis. typedef ViewportContentBuilder = Widget Function( @@ -243,6 +245,10 @@ bool subtreeBlocksIntrinsics(BaseNode node, GetNode getNode) { if (node is ScrollableMixin && node is! CanvasNode && node.isScrollable) { return true; } + // Scaled content and nodes that read their parent's size are built with + // a LayoutBuilder. + if (node is FrameNode && node.contentFit != null) return true; + if (readsParentSize(node)) return true; if (node is ChildrenMixin) { for (final String id in node.children) { if (subtreeBlocksIntrinsics(getNode(id), getNode)) return true; diff --git a/lib/src/utils/formula.dart b/lib/src/utils/formula.dart index 39848650..ed4043e2 100644 --- a/lib/src/utils/formula.dart +++ b/lib/src/utils/formula.dart @@ -37,7 +37,10 @@ typedef FormulaPathResolver = Object? Function(String path); /// parameter is read like a variable inside its body (`t.title`, /// `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. +/// function arguments. A lambda can take a second parameter for the +/// item's index: `map(steps, (s, i) => i == length(steps) - 1)`. +/// - text and list helpers: reverse, substring, left, right and toMap (a +/// map built from a list, `toMap(items, i => i.id, i => "pass")`). /// - 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 @@ -193,6 +196,27 @@ class FormulaEvaluator { 'append(list, item, ...)', 'append(tags, "new") → tags plus "new"', ), + FormulaFunctionDoc( + 'reverse', + 'reverse(list or text)', + 'reverse([1, 2, 3]) → [3, 2, 1]', + ), + FormulaFunctionDoc( + 'substring', + 'substring(text, start, end)', + 'substring("MX4821", -4, -2) → 48', + ), + FormulaFunctionDoc('left', 'left(text, count)', 'left("MX4821", 2) → MX'), + FormulaFunctionDoc( + 'right', + 'right(text, count)', + 'right("1FUJGLDR5MX4821", 6) → MX4821', + ), + FormulaFunctionDoc( + 'toMap', + 'toMap(list, item => key, item => value)', + 'toMap(items, i => i.id, i => "pass") → {1a: pass, 1b: pass}', + ), FormulaFunctionDoc( 'take', 'take(list, count)', @@ -292,7 +316,7 @@ class FormulaEvaluator { ), FormulaFunctionDoc( 'map', - 'map(list, field or item => value)', + 'map(list, field or item => value or (item, index) => value)', 'map(cart, i => i.price * i.qty) → [8, 16]', ), FormulaFunctionDoc( @@ -510,15 +534,16 @@ num _round(Object? value, Object? digits) { Iterable _values(Object? list, Object? selector) { if (list is! Iterable) return const []; if (selector == null) return list; - return list.map((item) => _select(item, selector)); + return list.indexed.map((entry) => _select(entry.$2, selector, entry.$1)); } /// [item]'s value for [selector]: the item itself when null, the lambda's /// result for a lambda, else the field [selector] names. A field name with -/// dots, such as `author.name`, reads nested fields. -Object? _select(Object? item, Object? selector) { +/// dots, such as `author.name`, reads nested fields. A lambda with a second +/// parameter gets [index], the item's position in its list. +Object? _select(Object? item, Object? selector, [int? index]) { if (selector == null) return item; - if (selector is _Closure) return selector(item); + if (selector is _Closure) return selector(item, index); final Object? value = _member(item, selector); if (value != null || selector is! String || !selector.contains('.')) { return value; @@ -529,14 +554,16 @@ Object? _select(Object? item, Object? selector) { /// Whether [item] matches a predicate given as function arguments [rest]: /// `(lambda)` or `(field)` match when truthy, `(lambda or field, value)` /// when the selected value equals value. No predicate matches truthy items. -bool _matches(Object? item, List rest) => rest.length < 2 - ? _truthy(_select(item, _arg(rest, 0))) - : _looseEquals(_select(item, rest[0]), rest[1]); +bool _matches(Object? item, List rest, [int? index]) => rest.length < 2 + ? _truthy(_select(item, _arg(rest, 0), index)) + : _looseEquals(_select(item, rest[0], index), rest[1]); /// Items of `args[0]` that match the predicate in the rest of [args]. Iterable _matching(List args) { final List rest = args.skip(1).toList(); - return _values(_arg(args, 0), null).where((item) => _matches(item, rest)); + return _values(_arg(args, 0), null).indexed + .where((entry) => _matches(entry.$2, rest, entry.$1)) + .map((entry) => entry.$2); } /// Items of `args[0]` sorted by the key `args[1]`, descending when `args[2]` @@ -550,7 +577,7 @@ List _sortBy(List args) { }; final List<(int, Object?, Object?)> keyed = [ for (int i = 0; i < items.length; i++) - (i, items[i], _select(items[i], selector)), + (i, items[i], _select(items[i], selector, i)), ]; keyed.sort((x, y) { final bool xMissing = x.$3 == null || x.$3 == ''; @@ -570,8 +597,9 @@ List _sortBy(List args) { List _unique(List args) { final List keys = []; final List items = []; + int index = 0; for (final item in _values(_arg(args, 0), null)) { - final Object? key = _select(item, _arg(args, 1)); + final Object? key = _select(item, _arg(args, 1), index++); if (keys.any((seen) => _looseEquals(seen, key))) continue; keys.add(key); items.add(item); @@ -579,17 +607,20 @@ List _unique(List args) { return items; } -/// A lambda such as `t => t.price`, ready to call with an item. +/// A lambda such as `t => t.price`, ready to call with an item. A lambda +/// written `(t, i) => ...` also gets the item's index as [indexParam]. class _Closure { final String param; + final String? indexParam; final _Node body; final FormulaPathResolver outer; - const _Closure(this.param, this.body, this.outer); + const _Closure(this.param, this.indexParam, this.body, this.outer); static final RegExp _part = RegExp(r'\.([a-zA-Z_][a-zA-Z0-9_]*)|\[(\d+)\]'); - Object? call(Object? value) => body.eval((path) { + Object? call(Object? value, [int? index]) => body.eval((path) { + if (indexParam != null && path == indexParam) return index; if (path == param) return value; if (path.startsWith(param) && (path.startsWith('.', param.length) || @@ -840,7 +871,7 @@ int _findIndex(List args) { for (final Object? item in list) { final bool hit = rest.length == 1 && rest.first is! _Closure ? _looseEquals(item, rest.first) - : _matches(item, rest); + : _matches(item, rest, i); if (hit) return i; i++; } @@ -869,6 +900,49 @@ Object? _setIn(Object? target, Object? key, Object? value) { return target; } +/// The part of [text] from [start] up to, not including, [end] (the end of +/// the text when null). A negative index counts from the end, so +/// `substring(vin, -6)` is the last six characters. Indexes past either end +/// are clamped. +String _substring(Object? text, Object? start, Object? end) { + final String value = FormulaEvaluator.stringify(text); + final int length = value.length; + int clamp(Object? index, int fallback) { + if (index == null) return fallback; + int i = _intArg(index); + if (i < 0) i += length; + return i.clamp(0, length); + } + + final int from = clamp(start, 0); + final int to = clamp(end, length); + return to <= from ? '' : value.substring(from, to); +} + +/// A map from each item of `args[0]` to a value: the key is the item's +/// `args[1]` (a field name or lambda) and the value its `args[2]`, or the +/// item itself when that's missing. A later item with the same key wins. +Map _toMap(List args) { + final Object? list = _arg(args, 0); + if (list is! Iterable) return {}; + final Object? keySelector = _arg(args, 1); + final Object? valueSelector = _arg(args, 2); + final Map map = {}; + int index = 0; + for (final Object? item in list) { + final Object? key = _select(item, keySelector, index); + if (key != null) { + map[FormulaEvaluator.stringify(key)] = _select( + item, + valueSelector, + index, + ); + } + index++; + } + return map; +} + /// [value] with thousands separators: 412908 → "412,908". String _group(Object? value) { final num? n = _toNum(value); @@ -940,7 +1014,10 @@ final Map _functions = { 'any': (a) => _matching(a).isNotEmpty, 'all': (a) { final List rest = a.skip(1).toList(); - return _values(_arg(a, 0), null).every((item) => _matches(item, rest)); + return _values( + _arg(a, 0), + null, + ).indexed.every((entry) => _matches(entry.$2, rest, entry.$1)); }, 'map': (a) => _values(_arg(a, 0), _arg(a, 1)).toList(), 'sortBy': _sortBy, @@ -966,6 +1043,19 @@ final Map _functions = { if (l is Iterable) ...l else ?l, ], 'append': (a) => [..._values(_arg(a, 0), null), ...a.skip(1)], + 'reverse': (a) => switch (_arg(a, 0)) { + final String text => String.fromCharCodes(text.runes.toList().reversed), + final Iterable list => list.toList().reversed.toList(), + _ => null, + }, + 'substring': (a) => _substring(_arg(a, 0), _arg(a, 1), _arg(a, 2)), + 'left': (a) => + _substring(_arg(a, 0), 0, _intArg(_arg(a, 1)).clamp(0, 1 << 30)), + 'right': (a) { + final int n = _intArg(_arg(a, 1)); + return n <= 0 ? '' : _substring(_arg(a, 0), -n, null); + }, + 'toMap': _toMap, 'take': (a) => _values(_arg(a, 0), null).take(_intArg(_arg(a, 1))).toList(), 'skip': (a) => _values(_arg(a, 0), null).skip(_intArg(_arg(a, 1))).toList(), 'group': (a) => _group(_arg(a, 0)), @@ -1098,12 +1188,14 @@ class _Call extends _Node { /// functions call once per item. class _Lambda extends _Node { final String param; + final String? indexParam; final _Node body; - const _Lambda(this.param, this.body); + const _Lambda(this.param, this.body, [this.indexParam]); @override - Object? eval(FormulaPathResolver resolve) => _Closure(param, body, resolve); + Object? eval(FormulaPathResolver resolve) => + _Closure(param, indexParam, body, resolve); /// The body's paths, except those of the parameter, which aren't /// variables. @@ -1115,6 +1207,7 @@ class _Lambda extends _Node { bodyPaths.where( (path) => path != param && + path != indexParam && !path.startsWith('$param.') && !path.startsWith('$param['), ), @@ -1224,7 +1317,8 @@ class _Parser { static final RegExp _number = RegExp(r'\d+(\.\d+)?|\.\d+'); static final RegExp _identifier = RegExp(r'[a-zA-Z_][a-zA-Z0-9_]*'); static final RegExp _lambdaHead = RegExp( - r'(?:([a-zA-Z_][a-zA-Z0-9_]*)|\(\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\))\s*=>', + r'(?:([a-zA-Z_][a-zA-Z0-9_]*)|\(\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*' + r'(?:,\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*)?\))\s*=>', ); _Node parseAll() { @@ -1259,13 +1353,14 @@ class _Parser { } /// A function argument: an expression, or a lambda `item => expression` - /// (also written `(item) => expression`). + /// (also written `(item) => expression`, or `(item, index) => expression` + /// to read the item's index too). _Node _argument() { _skip(); final Match? head = _lambdaHead.matchAsPrefix(source, pos); if (head == null) return _ternary(); pos = head.end; - return _Lambda(head[1] ?? head[2]!, _ternary()); + return _Lambda(head[1] ?? head[2]!, _ternary(), head[3]); } _Node _ternary() { diff --git a/lib/src/utils/utils.dart b/lib/src/utils/utils.dart index 6a8d9b0f..bf9e1828 100644 --- a/lib/src/utils/utils.dart +++ b/lib/src/utils/utils.dart @@ -17,6 +17,7 @@ const Set predefinedVariableNames = { 'auth', 'result', 'screen', + 'parent', }; /// 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: 'parent', type: VariableType.map), }; /// Widths at which the `screen` variable's breakpoint changes: below 600 is diff --git a/test/confirm_dialog_test.dart b/test/confirm_dialog_test.dart index c30a88ed..9d21b773 100644 --- a/test/confirm_dialog_test.dart +++ b/test/confirm_dialog_test.dart @@ -1,5 +1,6 @@ import 'package:codelessly_api/codelessly_api.dart'; import 'package:codelessly_sdk/codelessly_sdk.dart'; +import 'package:codelessly_sdk/src/ui/layout_presentation_route.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -59,4 +60,122 @@ void main() { expect(find.text('Leave album?'), findsOneWidget); expect(find.text('You can join again later.'), findsOneWidget); }); + + Future open( + WidgetTester tester, + ConfirmAction action, + Size size, + ) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = size; + addTearDown(tester.view.reset); + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) => TextButton( + onPressed: () => AppConfirmDialog.show(context, action), + child: const Text('Open'), + ), + ), + ), + ); + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + } + + final Finder bottomSheet = find.byKey( + const ValueKey('Codelessly bottom sheet'), + ); + final Finder sideSheet = find.byKey(const ValueKey('Codelessly side sheet')); + + testWidgets('the built-in confirm can be a bottom sheet', (tester) async { + bool? result; + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(390, 844); + addTearDown(tester.view.reset); + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) => TextButton( + onPressed: () async => result = await AppConfirmDialog.show( + context, + action.copyWith(presentation: DialogPresentation.bottomSheet), + ), + child: const Text('Open'), + ), + ), + ), + ); + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + expect(find.byType(Dialog), findsNothing); + final Rect sheet = tester.getRect(bottomSheet); + expect(sheet.left, 0); + expect(sheet.width, 390); + expect(sheet.bottom, 844); + expect(sheet.height, lessThan(400)); + expect(find.text('Leave album?'), findsOneWidget); + + await tester.tap(find.text('Leave')); + await tester.pumpAndSettle(); + expect(result, isTrue); + expect(bottomSheet, findsNothing); + }); + + testWidgets('dismissing a bottom sheet confirm cancels', (tester) async { + bool? result; + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) => TextButton( + onPressed: () async => result = await AppConfirmDialog.show( + context, + action.copyWith(presentation: DialogPresentation.bottomSheet), + ), + child: const Text('Open'), + ), + ), + ), + ); + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + await tester.tapAt(const Offset(10, 10)); + await tester.pumpAndSettle(); + expect(result, isFalse); + }); + + testWidgets('the built-in confirm can be a side sheet', (tester) async { + await open( + tester, + action.copyWith( + presentation: DialogPresentation.sideSheet, + sheetWidth: 440, + ), + const Size(1440, 900), + ); + expect(tester.takeException(), isNull); + final Rect sheet = tester.getRect(sideSheet); + expect(sheet.right, 1440); + expect(sheet.width, 440); + expect(sheet.height, 900); + expect(find.text('You can join again later.'), findsOneWidget); + }); + + test('presentation round-trips through JSON and defaults to dialog', () { + final ConfirmAction sheet = ConfirmAction.fromJson({ + 'type': 'confirm', + 'title': 'Reopen?', + 'dialogId': 'reopen', + 'presentation': 'bottomSheet', + 'maxHeightFactor': 0.8, + }); + expect(sheet.presentation, DialogPresentation.bottomSheet); + expect(sheet.maxHeightFactor, 0.8); + expect(ConfirmAction.fromJson(sheet.toJson()), sheet); + final ConfirmAction plain = ConfirmAction(title: 'Sure?'); + expect(plain.presentation, DialogPresentation.dialog); + expect(plain.toJson().containsKey('presentation'), isFalse); + expect(LayoutPresentationRoute.defaultSheetWidth, 400); + }); } diff --git a/test/formula_text_list_test.dart b/test/formula_text_list_test.dart new file mode 100644 index 00000000..b385047f --- /dev/null +++ b/test/formula_text_list_test.dart @@ -0,0 +1,159 @@ +import 'package:codelessly_sdk/codelessly_sdk.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + final Map vars = { + 'timeline': [ + {'t': '10:00', 'label': 'Reported'}, + {'t': '10:05', 'label': 'Assigned'}, + {'t': '10:05', 'label': 'Repair started'}, + ], + 'items': [ + {'id': '1a', 'name': 'Tyres'}, + {'id': '1b', 'name': 'Lights'}, + {'id': '1c', 'name': 'Mirrors'}, + ], + 'answers': {'1b': 'fail'}, + 'vin': '1FUJGLDR5LLMX4821', + 'zone': 1, + }; + Object? eval(String expression) => + FormulaEvaluator.evaluate(expression, (path) => vars[path]); + + group('reverse', () { + test('reverses a list, keeping equal items in reverse order', () { + expect(eval('map(reverse(timeline), e => e.label)'), [ + 'Repair started', + 'Assigned', + 'Reported', + ]); + }); + + test('reverses text', () => expect(eval('reverse("abc")'), 'cba')); + + test('does not change the list it reads', () { + eval('reverse(timeline)'); + expect((vars['timeline']! as List).first, { + 't': '10:00', + 'label': 'Reported', + }); + }); + + test('is null for other values', () => expect(eval('reverse(5)'), isNull)); + }); + + group('lambda index', () { + test('map passes the index as the second parameter', () { + expect(eval('map(items, (i, n) => n + 1 + ". " + i.name)'), [ + '1. Tyres', + '2. Lights', + '3. Mirrors', + ]); + }); + + test('marks the last item', () { + expect(eval('map(timeline, (e, i) => i == length(timeline) - 1)'), [ + false, + false, + true, + ]); + }); + + test('filter, find, count, any, all and findIndex get the index', () { + expect(eval('map(filter(items, (x, i) => i > 0), x => x.id)'), [ + '1b', + '1c', + ]); + expect(eval('find(items, (x, i) => i == 2).name'), 'Mirrors'); + expect(eval('count(items, (x, i) => i % 2 == 0)'), 2); + expect(eval('any(items, (x, i) => i == 5)'), false); + expect(eval('all(items, (x, i) => i < 3)'), true); + expect(eval('findIndex(items, (x, i) => x.id == "1c" && i == 2)'), 2); + }); + + test('a one-parameter lambda still works', () { + expect(eval('map(items, (x) => x.id)'), ['1a', '1b', '1c']); + }); + + test('the index parameter is not read as a variable', () { + expect( + FormulaEvaluator.referencedPaths('map(items, (x, i) => i + zone)'), + {'items', 'zone'}, + ); + }); + }); + + group('substring, left and right', () { + test('substring takes start and an optional end', () { + expect(eval('substring("MX4821", 2, 4)'), '48'); + expect(eval('substring("MX4821", 2)'), '4821'); + }); + + test('negative indexes count from the end', () { + expect(eval('substring(vin, -6)'), 'MX4821'); + expect(eval('substring("MX4821", 0, -4)'), 'MX'); + }); + + test('indexes past the ends are clamped', () { + expect(eval('substring("abc", 1, 99)'), 'bc'); + expect(eval('substring("abc", 5)'), ''); + expect(eval('substring("abc", 2, 1)'), ''); + expect(eval('substring(missing, 0, 2)'), ''); + }); + + test('left and right', () { + expect(eval('left(vin, 4)'), '1FUJ'); + expect(eval('right(vin, 6)'), 'MX4821'); + expect(eval('right("ab", 6)'), 'ab'); + expect(eval('left("ab", 0)'), ''); + expect(eval('right("ab", 0)'), ''); + expect(eval('right(1234, 2)'), '34'); + }); + }); + + group('toMap', () { + test('builds a map from key and value lambdas', () { + expect(eval('toMap(items, i => i.id, i => "pass")'), { + '1a': 'pass', + '1b': 'pass', + '1c': 'pass', + }); + }); + + test('keeps the item when there is no value selector', () { + expect(eval('toMap(items, "id")["1b"].name'), 'Lights'); + }); + + test('marks unanswered items pass and keeps answers (Pass N)', () { + expect( + eval( + 'merge(toMap(filter(items, i => !answers[i.id]), i => i.id, ' + 'i => "pass"), answers)', + ), + {'1a': 'pass', '1c': 'pass', '1b': 'fail'}, + ); + }); + + test('lambdas get the index', () { + expect(eval('toMap(items, (i, n) => n, i => i.name)'), { + '0': 'Tyres', + '1': 'Lights', + '2': 'Mirrors', + }); + }); + + test('is empty for a value that is not a list', () { + expect(eval('toMap(missing, i => i)'), {}); + }); + }); + + test('the new functions are documented', () { + final Set documented = { + for (final doc in FormulaEvaluator.functionDocs) doc.name, + }; + expect( + documented, + containsAll(['reverse', 'substring', 'left', 'right', 'toMap']), + ); + }); +} diff --git a/test/interaction_states_test.dart b/test/interaction_states_test.dart index 46cbe088..d81aec76 100644 --- a/test/interaction_states_test.dart +++ b/test/interaction_states_test.dart @@ -205,6 +205,89 @@ void main() { expect(harness.taps, ['tap']); }); + double boxOpacity(WidgetTester tester) => tester + .widget( + find + .descendant( + of: find.byType(InteractionStatesBox), + matching: find.byType(Opacity), + ) + .first, + ) + .opacity; + + testWidgets('a node disabled by a formula, with no disabled state, dims ' + 'and runs no actions', (tester) async { + final (:card, :dot) = cardWithDot(); + card.variables = {'disabled': r'${= busy == "DF-3104"}'}; + final harness = Harness(); + final Observable busy = Observable( + VariableData(id: 'b', name: 'busy', type: VariableType.text, value: ''), + ); + harness.codelesslyContext.variables['b'] = busy; + await harness.pump(tester, [card, dot]); + expect(boxOpacity(tester), 1); + + busy.set(busy.value.copyWith(value: 'DF-3104')); + await tester.pump(); + expect(boxOpacity(tester), InteractionStatesBox.defaultDisabledOpacity); + await tester.tapAt(const Offset(150, 50)); + await tester.pumpAndSettle(); + expect(harness.taps, isEmpty); + + busy.set(busy.value.copyWith(value: '')); + await tester.pump(); + expect(boxOpacity(tester), 1); + await tester.tapAt(const Offset(150, 50)); + await tester.pumpAndSettle(); + expect(harness.taps, ['tap']); + }); + + testWidgets('a node with its own disabled state does not also dim', ( + tester, + ) async { + final (:card, :dot) = cardWithDot(); + card.interactionStates = { + InteractionState.disabled: {'card': fillPatch('#808080')}, + }; + card.variables = {'disabled': r'${= true}'}; + await Harness().pump(tester, [card, dot]); + expect(boxOpacity(tester), 1); + expect(colorOf(tester, 200), grey); + }); + + testWidgets('a button disabled by a formula dims and ignores taps', ( + tester, + ) async { + final ButtonNode button = ButtonNode( + id: 'button', + name: 'Reopen', + basicBoxLocal: NodeBox(0, 0, 160, 48), + properties: ButtonProperties(label: 'Reopen'), + reactions: [ + Reaction( + action: CallFunctionAction(name: 'tap'), + trigger: TriggerModel(type: TriggerType.click), + ), + ], + )..parentID = kRootNode; + button.variables['disabled'] = r'${= busy != ""}'; + final harness = Harness(); + harness.codelesslyContext.variables['b'] = Observable( + VariableData( + id: 'b', + name: 'busy', + type: VariableType.text, + value: 'DF-3104', + ), + ); + await harness.pump(tester, [button]); + expect(boxOpacity(tester), InteractionStatesBox.defaultDisabledOpacity); + await tester.tap(find.text('Reopen'), warnIfMissed: false); + await tester.pumpAndSettle(); + expect(harness.taps, isEmpty); + }); + testWidgets('focused follows focus of an input inside the node', ( tester, ) async { diff --git a/test/parent_size_test.dart b/test/parent_size_test.dart new file mode 100644 index 00000000..018a47c5 --- /dev/null +++ b/test/parent_size_test.dart @@ -0,0 +1,135 @@ +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'; + +Finder _of(String id) => + find.byKey(ValueKey('Rectangle Transformer of $id [$id]')); + +RectangleNode _rect(String id, Map variables) => + RectangleNode(id: id, name: id, basicBoxLocal: NodeBox(0, 0, 100, 40)) + ..parentID = 'panel' + ..variables.addAll(variables); + +/// A specs panel, 24 padding, with a 4-column and a 2-column grid stand-in +/// that show by the panel's content width, and a bar half as wide. +List _panel() { + final List children = [ + _rect('four', {'visible': r'${= parent.width > 520}'}), + _rect('two', {'visible': r'${= parent.width <= 520}'}), + _rect('half', {'width': r'${= parent.width / 2}'}), + ]; + return [ + RowColumnNode( + id: 'panel', + name: 'panel', + basicBoxLocal: NodeBox(0, 0, 600, 300), + children: [for (final child in children) child.id], + rowColumnType: RowColumnType.column, + horizontalFit: SizeFit.expanded, + verticalFit: SizeFit.shrinkWrap, + crossAxisAlignment: CrossAxisAlignmentC.start, + padding: const EdgeInsetsModel.all(24), + )..parentID = kRootNode, + ...children, + ]; +} + +Future _pump(WidgetTester tester, double width) async { + tester.view.physicalSize = Size(width, 800); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final List nodes = _panel(); + 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: Scaffold( + body: Align( + alignment: Alignment.topLeft, + child: Builder( + builder: (context) => manager.buildWidgetFromNode( + nodes.first, + context, + settings: const WidgetBuildSettings(debugLabel: 'test'), + ), + ), + ), + ), + ), + ), + ); + expect(tester.takeException(), isNull); +} + +void main() { + testWidgets('a panel wider than 520 shows four columns', (tester) async { + await _pump(tester, 600); + expect(_of('four'), findsOneWidget); + expect(_of('two'), findsNothing); + // 600 - 2 * 24 padding. + expect(tester.getSize(_of('half')).width, 276); + }); + + testWidgets('a panel 520 or narrower shows two columns', (tester) async { + await _pump(tester, 560); + expect(_of('four'), findsNothing); + expect(_of('two'), findsOneWidget); + expect(tester.getSize(_of('half')).width, 256); + }); + + testWidgets('the layout follows the panel as the screen resizes', ( + tester, + ) async { + await _pump(tester, 600); + expect(_of('four'), findsOneWidget); + tester.view.physicalSize = const Size(500, 800); + await tester.pump(); + expect(tester.takeException(), isNull); + expect(_of('four'), findsNothing); + expect(_of('two'), findsOneWidget); + }); + + test('readsParentSize finds parent in bindings, formulas and text', () { + expect(readsParentSize(_rect('a', {'visible': r'${parent.width}'})), true); + expect( + readsParentSize(_rect('b', {'visible': r'${= parent.width > 9}'})), + true, + ); + expect(readsParentSize(_rect('c', {'visible': r'${screen.width}'})), false); + final TextNode text = TextNode( + id: 't', + name: 't', + basicBoxLocal: NodeBox(0, 0, 10, 10), + characters: r'Width ${= round(parent.width)}', + ); + expect(readsParentSize(text), true); + }); + + test('the design canvas can give parent a size', () { + final ScopedValues values = ScopedValues.preview( + variables: const {}, + parentSize: parentVariableValue(698, null), + ); + expect( + PropertyValueDelegate.getVariableValueFromPath( + r'${= parent.width > 520}', + scopedValues: values, + ), + isTrue, + ); + expect( + PropertyValueDelegate.getVariableValueFromPath( + r'${parent.height}', + scopedValues: values, + ), + isNull, + ); + }); +} diff --git a/test/scaled_content_test.dart b/test/scaled_content_test.dart new file mode 100644 index 00000000..45213a60 --- /dev/null +++ b/test/scaled_content_test.dart @@ -0,0 +1,145 @@ +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'; + +Finder _of(String id) => + find.byKey(ValueKey('Rectangle Transformer of $id [$id]')); + +/// A 300x660 truck diagram whose wheel sits in its bottom-right quarter. +({FrameNode truck, RectangleNode wheel}) _truck({ + Fit? fit = Fit.contain, + AlignmentModel alignment = AlignmentModel.center, + SizeFit fits = SizeFit.expanded, +}) { + final RectangleNode wheel = RectangleNode( + id: 'wheel', + name: 'wheel', + basicBoxLocal: NodeBox(150, 330, 150, 330), + positioningMode: PositioningMode.pin, + edgePins: const EdgePinsModel( + left: 150, + top: 330, + right: null, + bottom: null, + ), + )..parentID = 'truck'; + final FrameNode truck = FrameNode( + id: 'truck', + name: 'truck', + basicBoxLocal: NodeBox(0, 0, 300, 660), + children: ['wheel'], + horizontalFit: fits, + verticalFit: fits, + contentFit: fit, + contentAlignment: alignment, + )..parentID = kRootNode; + return (truck: truck, wheel: wheel); +} + +Future _pump( + WidgetTester tester, + List nodes, { + required Size box, +}) async { + tester.view.physicalSize = const Size(1440, 1600); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + 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: Align( + alignment: Alignment.topLeft, + child: SizedBox.fromSize( + size: box, + child: Align( + alignment: Alignment.topLeft, + child: Builder( + builder: (context) => manager.buildWidgetFromNode( + nodes.first, + context, + settings: const WidgetBuildSettings(debugLabel: 'test'), + ), + ), + ), + ), + ), + ), + ), + ); + expect(tester.takeException(), isNull); +} + +void main() { + testWidgets('contain scales the design into a short wide box, centered', ( + tester, + ) async { + final (:truck, :wheel) = _truck(); + await _pump(tester, [truck, wheel], box: const Size(600, 330)); + // Scale 0.5: the 150x330 content sits centered in 600 wide. + expect(tester.getRect(_of('truck')), const Rect.fromLTWH(0, 0, 600, 330)); + expect( + tester.getRect(_of('wheel')), + const Rect.fromLTWH(225 + 75, 165, 75, 165), + ); + }); + + testWidgets('contain scales the design up into a bigger box', (tester) async { + final (:truck, :wheel) = _truck(); + await _pump(tester, [truck, wheel], box: const Size(600, 1320)); + expect( + tester.getRect(_of('wheel')), + const Rect.fromLTWH(300, 660, 300, 660), + ); + }); + + testWidgets('scaleDown never scales up', (tester) async { + final (:truck, :wheel) = _truck( + fit: Fit.scaleDown, + alignment: AlignmentModel.topLeft, + ); + await _pump(tester, [truck, wheel], box: const Size(600, 1320)); + expect( + tester.getRect(_of('wheel')), + const Rect.fromLTWH(150, 330, 150, 330), + ); + }); + + testWidgets('a fixed-size frame shows its content unscaled', (tester) async { + final (:truck, :wheel) = _truck(fits: SizeFit.fixed); + await _pump(tester, [truck, wheel], box: const Size(600, 1320)); + expect(tester.getRect(_of('truck')), const Rect.fromLTWH(0, 0, 300, 660)); + expect( + tester.getRect(_of('wheel')), + const Rect.fromLTWH(150, 330, 150, 330), + ); + }); + + testWidgets('without a content fit the children are not scaled', ( + tester, + ) async { + final (:truck, :wheel) = _truck(fit: null); + await _pump(tester, [truck, wheel], box: const Size(600, 330)); + expect(tester.getSize(_of('wheel')), const Size(150, 330)); + }); + + test('contentFit round-trips through JSON and is omitted when unset', () { + final (:truck, wheel: _) = _truck( + fit: Fit.scaleDown, + alignment: AlignmentModel.topCenter, + ); + final FrameNode copy = FrameNode.fromJson(truck.toJson()); + expect(copy.contentFit, Fit.scaleDown); + expect(copy.contentAlignment, AlignmentModel.topCenter); + final Map plain = _truck(fit: null).truck.toJson(); + expect(plain.containsKey('contentFit'), isFalse); + expect(plain.containsKey('contentAlignment'), isFalse); + }); +} diff --git a/test/sheet_presentation_test.dart b/test/sheet_presentation_test.dart index a61212c4..97b23725 100644 --- a/test/sheet_presentation_test.dart +++ b/test/sheet_presentation_test.dart @@ -391,6 +391,46 @@ void main() { }); } + testWidgets('a confirm with a canvas dialog shows it as a bottom sheet and ' + 'confirms from it', (tester) async { + await pumpLayouts(tester, [ + mainLayout([ + ConfirmAction( + title: 'Reopen?', + dialogId: 'confirmCanvas', + presentation: DialogPresentation.bottomSheet, + actions: [ShowToastAction(message: 'Reopened')], + ), + ]), + layoutOf('confirm', [ + canvas('confirmCanvas', 'confirmBody', verticalFit: SizeFit.shrinkWrap), + body('confirmBody', 'confirmCanvas', ['yes']), + button('yes', 'confirmBody', 'Reopen', [ + NavigationAction( + navigationType: NavigationType.pop, + destinationId: '', + params: {ConfirmAction.resultKey: true}, + ), + ]), + ]), + ], size: phone); + await tester.tap(find.text('Go')); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + final Rect sheet = tester.getRect(bottomSheet); + expect(sheet.width, phone.width); + expect(sheet.bottom, phone.height); + expect(dragHandle, findsOneWidget); + + await tester.tap(find.text('Reopen')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + expect(bottomSheet, findsNothing); + expect(find.text('Reopened'), findsOneWidget); + await tester.pump(const Duration(seconds: 3)); + await tester.pumpAndSettle(); + }); + testWidgets('a saved show-dialog action stays a centered dialog that ' 'scales and fades in', (tester) async { final action = ActionModel.fromJson({ diff --git a/test/text_field_error_test.dart b/test/text_field_error_test.dart new file mode 100644 index 00000000..93692d4a --- /dev/null +++ b/test/text_field_error_test.dart @@ -0,0 +1,172 @@ +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'; + +const ColorRGBA _red = ColorRGBA(r: 1, g: 0, b: 0, a: 1); +const ColorRGBA _grey = ColorRGBA(r: 0.5, g: 0.5, b: 0.5, a: 1); + +TextFieldNode _field(Map variables, {String? errorText}) => + TextFieldNode( + id: 'field', + name: 'Field', + basicBoxLocal: NodeBox(0, 0, 300, 56), + properties: TextFieldProperties( + decoration: InputDecorationModel( + errorText: errorText, + enabledBorder: const InputBorderModel( + borderType: BorderTypeEnum.outline, + borderSide: BorderSideModel(color: _grey, width: 1), + ), + errorBorder: const InputBorderModel( + borderType: BorderTypeEnum.outline, + borderSide: BorderSideModel(color: _red, width: 2), + ), + ), + ), + )..variables.addAll(variables); + +Observable _variable( + String name, + String value, + VariableType type, +) => Observable(VariableData(id: name, name: name, value: value, type: type)); + +CodelesslyContext _context({required bool showErrors, required String note}) => + CodelesslyContext.empty() + ..variables['showErrors'] = _variable( + 'showErrors', + '$showErrors', + VariableType.boolean, + ) + ..variables['note'] = _variable('note', note, VariableType.text); + +const String _errorFormula = + r'${= showErrors && length(trim(note)) < 10 ? "Describe the repair" : ""}'; + +Future _pump( + WidgetTester tester, + TextFieldNode node, + CodelesslyContext codelesslyContext, +) async { + final registry = NodeRegistry()..setNodes({node.id: node}); + final manager = PassiveNodeTransformerManager( + registry.getNodeByID, + (context, bounds, pageID, layoutID, canvasID) => const SizedBox(), + ); + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: codelesslyContext, + child: MaterialApp( + home: Scaffold( + body: Center( + child: Builder( + builder: (context) => manager.buildWidgetFromNode( + node, + context, + settings: const WidgetBuildSettings(debugLabel: 'test'), + ), + ), + ), + ), + ), + ), + ); + await tester.pump(); + return tester.widget(find.byType(InputDecorator)).decoration; +} + +Color? _enabledBorderColor(InputDecoration decoration) => + (decoration.enabledBorder as OutlineInputBorder?)?.borderSide.color; + +void main() { + testWidgets('an errorText formula that comes out empty is no error', ( + tester, + ) async { + final decoration = await _pump( + tester, + _field({'errorText': _errorFormula}), + _context(showErrors: false, note: ''), + ); + expect(decoration.error, isNull); + expect(decoration.errorText, isNull); + expect(find.text('Describe the repair'), findsNothing); + expect(_enabledBorderColor(decoration), _grey.toFlutterColor()); + }); + + testWidgets('an errorText formula with a message shows the error', ( + tester, + ) async { + final decoration = await _pump( + tester, + _field({'errorText': _errorFormula}), + _context(showErrors: true, note: 'short'), + ); + expect(decoration.error, isNotNull); + expect(find.text('Describe the repair'), findsOneWidget); + expect(find.textContaining(r'${'), findsNothing); + }); + + testWidgets('the error follows its variables as they change', (tester) async { + final codelesslyContext = _context(showErrors: true, note: 'short'); + await _pump( + tester, + _field({'errorText': _errorFormula}), + codelesslyContext, + ); + expect(find.text('Describe the repair'), findsOneWidget); + + codelesslyContext.variables['note']!.value = codelesslyContext + .variables['note']! + .value + .copyWith(value: 'Replaced the brake pads'); + await tester.pump(); + expect(find.text('Describe the repair'), findsNothing); + }); + + testWidgets('an error binding gives the error border without a message', ( + tester, + ) async { + final decoration = await _pump( + tester, + _field({'error': r'${= showErrors && length(trim(note)) < 10}'}), + _context(showErrors: true, note: ''), + ); + expect(decoration.error, isNull); + final OutlineInputBorder border = + decoration.enabledBorder! as OutlineInputBorder; + expect(border.borderSide.color, const Color(0xFFFF0000)); + expect(border.borderSide.width, 2); + }); + + testWidgets('a false error binding keeps the normal border', (tester) async { + final decoration = await _pump( + tester, + _field({'error': r'${= showErrors && length(trim(note)) < 10}'}), + _context(showErrors: false, note: ''), + ); + expect(_enabledBorderColor(decoration), _grey.toFlutterColor()); + }); + + testWidgets('a static errorText still shows', (tester) async { + final decoration = await _pump( + tester, + _field(const {}, errorText: 'Required'), + CodelesslyContext.empty(), + ); + expect(decoration.error, isNotNull); + expect(find.text('Required'), findsOneWidget); + }); + + testWidgets('enabled and readOnly follow their bindings', (tester) async { + await _pump( + tester, + _field({'enabled': r'${= !showErrors}', 'readOnly': r'${showErrors}'}), + _context(showErrors: true, note: ''), + ); + final TextField field = tester.widget(find.byType(TextField)); + expect(field.enabled, isFalse); + expect(field.readOnly, isTrue); + }); +} diff --git a/test/toast_style_test.dart b/test/toast_style_test.dart new file mode 100644 index 00000000..b6cab603 --- /dev/null +++ b/test/toast_style_test.dart @@ -0,0 +1,129 @@ +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'; + +const ColorRGBA _yellow = ColorRGBA(r: 1, g: 0.76, b: 0.055, a: 1); + +Future _show(WidgetTester tester, ShowToastAction action) async { + late BuildContext context; + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: CodelesslyContext.empty(), + child: MaterialApp( + home: Builder( + builder: (c) { + context = c; + return const SizedBox(); + }, + ), + ), + ), + ); + AppToast.show(context, action); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); +} + +Future _finish(WidgetTester tester) async { + await tester.pump(const Duration(seconds: 5)); + await tester.pumpAndSettle(); +} + +final Finder _toast = find.byKey(const ValueKey('codelessly-toast')); + +BoxDecoration _decoration(WidgetTester tester) => + tester.widget(_toast).decoration! as BoxDecoration; + +void main() { + testWidgets('an accent color draws a bar down the left edge', (tester) async { + await _show( + tester, + ShowToastAction( + message: 'DF-3104 marked repaired', + backgroundColor: ColorRGBA.fromHex('#16191D'), + textColor: ColorRGBA.fromHex('#E8EAEC'), + accentColor: _yellow, + cornerRadius: 4, + duration: 1000, + ), + ); + final Border border = _decoration(tester).border! as Border; + expect(border.left.color, _yellow.toFlutterColor()); + expect(border.left.width, 4); + expect(border.top, BorderSide.none); + final ClipRRect clip = tester.widget( + find.ancestor(of: _toast, matching: find.byType(ClipRRect)).first, + ); + expect(clip.borderRadius, BorderRadius.circular(4)); + expect(tester.takeException(), isNull); + await _finish(tester); + }); + + testWidgets('without an accent there is no bar and the toast is a pill', ( + tester, + ) async { + await _show(tester, ShowToastAction(message: 'Saved', duration: 1000)); + expect(_decoration(tester).border, isNull); + final ClipRRect clip = tester.widget( + find.ancestor(of: _toast, matching: find.byType(ClipRRect)).first, + ); + expect(clip.borderRadius, BorderRadius.circular(999)); + await _finish(tester); + }); + + testWidgets('the icon takes the icon color and the text the font', ( + tester, + ) async { + await _show( + tester, + ShowToastAction( + message: 'Saved', + textColor: ColorRGBA.white, + iconColor: _yellow, + icon: MultiSourceIconModel.icon( + icon: CupertinoIcon(codepoint: 0xf3fb, name: 'check_mark_circled'), + size: 20, + ), + fontFamily: 'Fleet Sans', + duration: 1000, + ), + ); + final Icon icon = tester.widget( + find.descendant(of: _toast, matching: find.byType(Icon)), + ); + expect(icon.color, _yellow.toFlutterColor()); + expect(icon.size, 20); + final Text text = tester.widget(find.text('Saved')); + expect(text.style!.fontFamily, AppToast.fontOf('Fleet Sans')!.fontFamily); + expect(text.style!.color, Colors.white); + await _finish(tester); + }); + + test('the new fields round-trip through JSON and are omitted by default', () { + final ShowToastAction toast = ShowToastAction.fromJson({ + 'type': 'showToast', + 'message': 'Hi', + 'accentColor': {'r': 1, 'g': 0, 'b': 0, 'a': 1}, + 'accentWidth': 3, + 'iconColor': {'r': 0, 'g': 1, 'b': 0, 'a': 1}, + 'fontFamily': 'Barlow', + 'cornerRadius': 4, + }); + expect(toast.accentWidth, 3); + expect(toast.fontFamily, 'Barlow'); + expect(toast.cornerRadius, 4); + expect(ShowToastAction.fromJson(toast.toJson()), toast); + final Map json = ShowToastAction(message: 'Hi').toJson(); + for (final key in [ + 'accentColor', + 'accentWidth', + 'iconColor', + 'fontFamily', + 'cornerRadius', + ]) { + expect(json.containsKey(key), isFalse, reason: key); + } + }); +} diff --git a/test/wrap_layout_test.dart b/test/wrap_layout_test.dart new file mode 100644 index 00000000..0930246b --- /dev/null +++ b/test/wrap_layout_test.dart @@ -0,0 +1,186 @@ +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 _rect( + String id, { + double width = 140, + double height = 40, + SizeFit horizontalFit = SizeFit.fixed, +}) => RectangleNode( + id: id, + name: id, + basicBoxLocal: NodeBox(0, 0, width, height), + horizontalFit: horizontalFit, +)..parentID = 'specs'; + +RowColumnNode _specs( + List children, { + RowColumnType type = RowColumnType.row, + MainAxisAlignmentC mainAxisAlignment = MainAxisAlignmentC.start, + CrossAxisAlignmentC crossAxisAlignment = CrossAxisAlignmentC.start, + NodeBox? box, + SizeFit horizontalFit = SizeFit.expanded, + SizeFit verticalFit = SizeFit.shrinkWrap, +}) => RowColumnNode( + id: 'specs', + name: 'specs', + basicBoxLocal: box ?? NodeBox(0, 0, 342, 88), + children: [for (final child in children) child.id], + rowColumnType: type, + horizontalFit: horizontalFit, + verticalFit: verticalFit, + mainAxisAlignment: mainAxisAlignment, + crossAxisAlignment: crossAxisAlignment, + wrap: true, + wrapSpacing: 24, + wrapRunSpacing: 8, +)..parentID = kRootNode; + +Finder _of(String id) => + find.byKey(ValueKey('Rectangle Transformer of $id [$id]')); + +Future _pump( + WidgetTester tester, + RowColumnNode root, + List children, { + required double width, + CodelesslyContext? codelesslyContext, +}) async { + tester.view.physicalSize = Size(width, 800); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final registry = NodeRegistry() + ..setNodes({ + for (final node in [root, ...children]) node.id: node, + }); + final manager = PassiveNodeTransformerManager( + registry.getNodeByID, + (context, bounds, pageID, layoutID, canvasID) => const SizedBox(), + ); + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: codelesslyContext ?? CodelesslyContext.empty(), + child: MaterialApp( + home: Scaffold( + body: Align( + alignment: Alignment.topLeft, + child: Builder( + builder: (context) => manager.buildWidgetFromNode( + root, + context, + settings: const WidgetBuildSettings(debugLabel: 'test'), + ), + ), + ), + ), + ), + ), + ); + expect(tester.takeException(), isNull); +} + +void main() { + final List specs = [_rect('odo'), _rect('loc'), _rect('pre')]; + + testWidgets('children that do not fit wrap onto the next line at 390', ( + tester, + ) async { + await _pump(tester, _specs(specs), specs, width: 390); + expect(tester.getTopLeft(_of('odo')), Offset.zero); + expect(tester.getTopLeft(_of('loc')), const Offset(164, 0)); + // 140 + 24 + 140 + 24 + 140 = 468 > 390: the third starts a new line. + expect(tester.getTopLeft(_of('pre')), const Offset(0, 48)); + // The row hugs its two lines. + expect(tester.getSize(find.byType(Wrap)).height, 88); + }); + + for (final double width in [600, 834, 1440]) { + testWidgets('all children fit on one line at $width', (tester) async { + await _pump(tester, _specs(specs), specs, width: width); + expect(tester.getTopLeft(_of('pre')), const Offset(328, 0)); + expect(tester.getSize(find.byType(Wrap)).height, 40); + }); + } + + testWidgets('a column wraps into new columns', (tester) async { + final List tall = [ + for (final id in ['a', 'b', 'c']) _rect(id, width: 60, height: 300), + ]; + final RowColumnNode column = _specs( + tall, + type: RowColumnType.column, + box: NodeBox(0, 0, 200, 700), + horizontalFit: SizeFit.shrinkWrap, + verticalFit: SizeFit.fixed, + ); + await _pump(tester, column, tall, width: 390); + expect(tester.getTopLeft(_of('b')), const Offset(0, 324)); + expect(tester.getTopLeft(_of('c')), const Offset(68, 0)); + }); + + testWidgets('main and cross axis alignment place the lines', (tester) async { + final List mixed = [ + _rect('odo'), + _rect('loc', height: 20), + _rect('pre'), + ]; + await _pump( + tester, + _specs( + mixed, + mainAxisAlignment: MainAxisAlignmentC.end, + crossAxisAlignment: CrossAxisAlignmentC.center, + ), + mixed, + width: 390, + ); + // Line one ends at the right edge; the short child is centered in it. + expect(tester.getTopLeft(_of('loc')), const Offset(390 - 140, 10)); + expect(tester.getTopLeft(_of('pre')), const Offset(390 - 140, 48)); + }); + + testWidgets('spacers and children hidden at runtime take no space', ( + tester, + ) async { + final SpacerNode spacer = SpacerNode( + id: 'gap', + name: 'gap', + basicBoxLocal: NodeBox(0, 0, 100, 10), + horizontalFit: SizeFit.expanded, + )..parentID = 'specs'; + final RectangleNode hidden = _rect('hidden') + ..variables['visible'] = r'${= false}'; + final List children = [specs[0], spacer, hidden, specs[1]]; + await _pump(tester, _specs(children), children, width: 390); + expect(_of('hidden'), findsNothing); + expect(tester.getTopLeft(_of('loc')), const Offset(164, 0)); + }); + + testWidgets('a child that expands on the main axis fills its own line', ( + tester, + ) async { + final RectangleNode wide = _rect( + 'wide', + width: 100, + horizontalFit: SizeFit.expanded, + ); + final List children = [specs[0], wide]; + await _pump(tester, _specs(children), children, width: 390); + expect(tester.getRect(_of('wide')), const Rect.fromLTWH(0, 48, 390, 40)); + }); + + test('wrap round-trips through JSON and is omitted when off', () { + final RowColumnNode node = _specs(specs); + final RowColumnNode copy = RowColumnNode.fromJson(node.toJson()); + expect(copy.wrap, isTrue); + expect(copy.wrapSpacing, 24); + expect(copy.wrapRunSpacing, 8); + final Map json = (RowColumnNode.fromJson( + node.toJson(), + )..wrap = false).toJson(); + expect(json.containsKey('wrap'), isFalse); + }); +} From 9f472cc964d5ea63796508d2769e77d9b212b0b8 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 17:57:40 +0000 Subject: [PATCH 11/13] Test an app whose screens come from several editor pages Navigate, replace and showDialog find a canvas in any published layout, whatever page it was designed on; a layout group on another page picks the canvas for the width; app variables are shared across pages. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011WedhUVWdWR5swqZYMuGfP --- test/cross_page_layouts_test.dart | 303 ++++++++++++++++++++++++++++++ 1 file changed, 303 insertions(+) create mode 100644 test/cross_page_layouts_test.dart diff --git a/test/cross_page_layouts_test.dart b/test/cross_page_layouts_test.dart new file mode 100644 index 00000000..b0f37099 --- /dev/null +++ b/test/cross_page_layouts_test.dart @@ -0,0 +1,303 @@ +// An app whose screens were designed on several editor pages: each page's +// canvases publish as layouts carrying that page's id, and the runtime finds +// a screen by its canvas id in any published layout. Navigating, replacing +// and showing a dialog across pages, layout groups on another page, and app +// variables shared between pages all have to work. +import 'dart:convert'; + +import 'package:cloud_firestore/cloud_firestore.dart'; +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'; + +PaintModel paint(ColorRGBA color) => + PaintModel.solid(id: 'paint', color: color.toRGB(), opacity: color.a); + +CanvasNode canvas(String id, String bodyId, {double width = 390}) => CanvasNode( + id: id, + name: id, + basicBoxLocal: NodeBox(0, 0, width, 844), + children: [bodyId], + scaleMode: ScaleMode.responsive, + fills: [paint(ColorRGBA.white)], + properties: CanvasProperties( + bodyId: bodyId, + safeArea: const SafeAreaModel.all(false), + ), +)..parentID = kRootNode; + +AutoPlaceholderNode body(String id, String canvasId, List children) => + AutoPlaceholderNode( + id: id, + name: id, + basicBoxLocal: NodeBox(0, 0, 390, 844), + children: children, + mainAxisAlignment: MainAxisAlignmentC.start, + horizontalFit: SizeFit.expanded, + verticalFit: SizeFit.expanded, + )..parentID = canvasId; + +TextNode text(String id, String parent, String characters) => TextNode( + id: id, + name: id, + basicBoxLocal: NodeBox(0, 0, 300, 24), + characters: characters, + textMixedProps: [ + StartEndProp( + start: 0, + end: characters.length, + fontSize: 16, + fills: [paint(ColorRGBA.black)], + ), + ], +)..parentID = parent; + +ButtonNode button( + String id, + String parent, + String label, + List actions, +) => ButtonNode( + id: id, + name: id, + basicBoxLocal: NodeBox(0, 0, 160, 44), + properties: ButtonProperties( + label: label, + buttonType: ButtonTypeEnum.filled, + buttonColor: ColorRGBA.black, + ), + reactions: [ + for (final action in actions) + Reaction( + action: action, + trigger: TriggerModel(type: TriggerType.click), + ), + ], +)..parentID = parent; + +/// The app variable every screen shares. +final VariableData driver = VariableData( + id: 'driverVar', + name: 'driver', + value: 'nobody', +); + +/// A layout of [pageId] holding [canvases] (each a list of nodes, canvas +/// first), round-tripped through JSON as the SDK downloads it. +SDKPublishLayout layoutOf( + String id, + String pageId, + List> canvases, { + List breakpoints = const [], +}) => SDKPublishLayout.fromJson( + jsonDecode( + jsonEncode( + SDKPublishLayout( + id: id, + pageId: pageId, + projectId: 'project', + canvases: { + for (final nodes in canvases) + nodes.first.id: {for (final node in nodes) node.id: node}, + }, + breakpoints: breakpoints, + lastUpdated: DateTime(2026), + owner: 'owner', + teams: const {}, + users: const {}, + roles: const {}, + public: false, + ).toJson(), + ), + ) as Map, +); + +/// What the editor publishes for a layout's variables: the app variables +/// under [kAppVariablesScope], copied into every layout. +SDKLayoutVariables variablesOf(SDKPublishLayout layout) => SDKLayoutVariables( + id: layout.id, + variables: { + kAppVariablesScope: {driver.id: driver}, + }, + owner: 'owner', + teams: const {}, + users: const {}, + roles: const {}, + public: false, +); + +/// Page A: the home screen. Sign in sets the app variable and goes to the +/// detail screen on page B; Replace swaps itself for it; Help shows a +/// dialog on page C. +SDKPublishLayout home() => layoutOf('homeLayout', 'pageA', [ + [ + canvas('home', 'homeBody'), + body('homeBody', 'home', ['signIn', 'replace', 'help']), + button('signIn', 'homeBody', 'Sign in', [ + SetVariableAction(variable: driver, newValue: 'Dana'), + NavigationAction( + navigationType: NavigationType.push, + destinationId: 'detailM', + ), + ]), + button('replace', 'homeBody', 'Replace', [ + NavigationAction( + navigationType: NavigationType.replace, + destinationId: 'detailM', + ), + ]), + button('help', 'homeBody', 'Help', [ + ShowDialogAction(destinationId: 'helpDialog'), + ]), + ], +]); + +/// Page B: the detail screen as a layout group, a canvas per width. +SDKPublishLayout detail() => layoutOf( + 'detailGroup', + 'pageB', + [ + [ + canvas('detailM', 'detailMBody'), + body('detailMBody', 'detailM', ['detailMText']), + text('detailMText', 'detailMBody', r'Mobile detail for ${driver}'), + ], + [ + canvas('detailD', 'detailDBody', width: 1440), + body('detailDBody', 'detailD', ['detailDText']), + text('detailDText', 'detailDBody', r'Desktop detail for ${driver}'), + ], + ], + breakpoints: const [ + Breakpoint(nodeId: 'detailM', upperBound: 1024), + Breakpoint(nodeId: 'detailD', lowerBound: 1024), + ], +); + +/// Page C: a dialog. +SDKPublishLayout help() => layoutOf('helpLayout', 'pageC', [ + [ + canvas('helpDialog', 'helpBody'), + body('helpBody', 'helpDialog', ['helpText']), + text('helpText', 'helpBody', r'Help for ${driver}'), + ], +]); + +class _FakeCache extends Fake implements CacheManager {} + +class _FakeAuthManager extends Fake implements AuthManager { + @override + AuthData? get authData => AuthData( + authToken: 'token', + projectId: 'project', + timestamp: DateTime(2026), + owner: 'owner', + teams: const {}, + users: const {}, + roles: const {}, + ); +} + +class _FakeLocalRepository extends Fake implements LocalDataRepository {} + +class _FakeNetworkRepository extends Fake implements NetworkDataRepository {} + +class _FakeFirestore extends Fake implements FirebaseFirestore {} + +class _FakeLocalDatabase extends Fake implements LocalDatabase {} + +class _TestDataManager extends DataManager { + _TestDataManager(SDKPublishModel model) + : super( + 'test', + config: CodelesslyConfig(authToken: 'token'), + cacheManager: _FakeCache(), + authManager: _FakeAuthManager(), + networkDataRepository: _FakeNetworkRepository(), + localDataRepository: _FakeLocalRepository(), + firebaseFirestore: _FakeFirestore(), + errorLogger: ErrorLogger.instance, + publishModel: model, + ); + + @override + LocalDatabase get localDatabase => _FakeLocalDatabase(); +} + +/// Pumps the home screen, with every page's layouts published, on a +/// [size] screen. +Future pumpApp(WidgetTester tester, Size size) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = size; + addTearDown(tester.view.reset); + + final model = SDKPublishModel( + projectId: 'project', + owner: 'owner', + teams: const {}, + users: const {}, + roles: const {}, + public: false, + pages: const ['pageA', 'pageB', 'pageC'], + ); + for (final layout in [home(), detail(), help()]) { + model.layouts[layout.id] = layout; + model.variables[layout.id] = variablesOf(layout); + } + final codelessly = Codelessly( + config: CodelesslyConfig(authToken: 'token'), + publishDataManager: _TestDataManager(model)..status = CStatus.loaded(), + )..status = CStatus.loaded(); + await tester.pumpWidget( + MaterialApp( + home: CodelesslyWidget(codelessly: codelessly, layoutID: 'homeLayout'), + ), + ); + await tester.pump(); +} + +void main() { + testWidgets('navigating to a canvas on another page shows the canvas of ' + 'its layout group for the width, with the app variable set on the ' + 'first page', (tester) async { + await pumpApp(tester, const Size(390, 844)); + await tester.tap(find.text('Sign in')); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + expect(find.text('Mobile detail for Dana'), findsOneWidget); + + // Back on the first page's screen. + Navigator.of(tester.element(find.text('Mobile detail for Dana'))).pop(); + await tester.pumpAndSettle(); + expect(find.text('Sign in'), findsOneWidget); + }); + + testWidgets('a layout group on another page picks its desktop canvas on ' + 'a wide screen', (tester) async { + await pumpApp(tester, const Size(1440, 900)); + await tester.tap(find.text('Sign in')); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + expect(find.text('Desktop detail for Dana'), findsOneWidget); + expect(find.textContaining('Mobile detail'), findsNothing); + }); + + testWidgets('replace swaps in a screen from another page', (tester) async { + await pumpApp(tester, const Size(390, 844)); + await tester.tap(find.text('Replace')); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + expect(find.text('Mobile detail for nobody'), findsOneWidget); + expect(find.text('Sign in'), findsNothing); + }); + + testWidgets('a dialog designed on another page opens, sharing the app ' + 'variables', (tester) async { + await pumpApp(tester, const Size(390, 844)); + await tester.tap(find.text('Help')); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + expect(find.text('Help for nobody'), findsOneWidget); + }); +} From 4f2f8c5308dcd7d76680ffef82eb8d8f4e8924f6 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 23:27:25 +0000 Subject: [PATCH 12/13] Changelog: the RigCheck additions under Unreleased Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011WedhUVWdWR5swqZYMuGfP --- CHANGELOG.md | 38 +++++++++++++++++++++++++++++++++++--- 1 file changed, 35 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 391dcfc1..b1ea512b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,39 @@ ## Unreleased -### RigCheck gaps +Needs the unreleased `codelessly_api` that adds these fields. + +### Formulas + +- Record helpers: `findIndex`, `indexOf`, `obj`, `set`, `merge`, + `concat`, `append`, `take`, `skip`, `group` (412908 → 412,908) and `pad`, + so a set variable action can change one item of a list: + `set(defects, findIndex(defects, d => d.id == id), merge(d, obj(...)))`. +- `isEmpty` treats null as empty, so a text variable holding "" is empty. +- The `isEmpty` and `isNotEmpty` accessors of list variables work (they + read the list as a map and gave null). + +### Nodes and actions + +- `PassiveSignatureTransformer` draws a `SignatureNode`: strokes in + round-cap ink, the sign line and placeholder, the error border when its + `error` binding is true. A finished stroke writes all strokes to the + bound value and fires `changed`; setting the value to `[]` clears it. +- Loop animations: nodes with a `loopAnimation` build inside a + `LoopAnimationBox` (pulse, fade, scale or spin) that `loopAnimationEnabled` + turns on and off without remounting, and that holds still under a + disabled `TickerMode` (the editor's design canvas). +- `cursorEnter`, `cursorExit` and `hover` reactions run, through a + translucent `MouseRegion`. +- `showDialog` presents its canvas per `ShowDialogAction.presentation` + (`LayoutPresentationRoute`): a centered dialog that scales and fades in, + a bottom sheet sized to its content with a grab handle, or a side sheet. + What the canvas passes back on Navigate back becomes the chain's result. +- An obscured text field that isn't explicitly multi-line keeps one line, + so a fixed-height PIN field no longer shows its text. +- On the design canvas, text inside a list reads the row's own item, like + its fill, stroke and visibility bindings already did. + +### Forms, layout and feedback - Text fields: `variables['errorText']` is evaluated (formulas included) and an empty result means no error, so the field shows its error border only @@ -35,8 +68,7 @@ `gradientExtent`, the length in pixels of one run of the gradient (the radius of a radial one), so a short gradient with hard stops paints stripes. `GradientExtentTransform` rotates and scales the gradient about - the center of its box. Needs the unreleased `codelessly_api` change that - adds these fields. + the center of its box. ### Spacers and grids From 8e749237faebca5a3b6c69e42f40bf58366c914b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 00:38:20 +0000 Subject: [PATCH 13/13] A child filling across a hug row or column stretches to its siblings Measured with IntrinsicHeight / IntrinsicWidth when a non-spacer child fills the cross axis and another non-spacer child gives it a size, as the editor's solver lays it out. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011WedhUVWdWR5swqZYMuGfP --- CHANGELOG.md | 4 + .../passive_row_column_transformer.dart | 25 ++++- test/flex_layout_test.dart | 98 +++++++++++++++++++ 3 files changed, 126 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a865a5e5..e3e5b41e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,6 +52,10 @@ side sheet, confirm presentation, toast styling, `contentFit` and around it, so it takes the free space like Flutter's `Spacer` also under a `NodeBuildDecorator` (the editor's render canvas drew it at its design size). +- A child that fills across a row or column that hugs that axis stretches + to the size its other children give it (the row is measured with + `IntrinsicHeight` / `IntrinsicWidth`), like a card's colored edge as tall + as its text. - A `fixedCrossAxisCount` grid keeps its count at every width and divides the grid's width between the cells (`DesignCellGridDelegate` is gone). - `GridDelegateProperties.shrinkWrapRows`: rows as tall as their tallest 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 a2815054..fcb7111b 100644 --- a/lib/src/transformers/node_transformers/passive_row_column_transformer.dart +++ b/lib/src/transformers/node_transformers/passive_row_column_transformer.dart @@ -8,6 +8,27 @@ typedef WidgetInserter = Widget Function(Widget child); class PassiveRowColumnTransformer extends NodeWidgetTransformer { PassiveRowColumnTransformer(super.getNode, super.manager); + /// Whether a child of [rowColumn], which hugs its cross axis, fills that + /// axis while another child gives it a thickness: a card's severity edge + /// as tall as its text, `IntrinsicHeight(child: Row(...))` in Flutter. + /// The row is measured to its other children's thickness first, which the + /// filling child then fills. With no such other child there is nothing to + /// fill, and the child keeps its design size (see FitBox), as the editor + /// lays it out. A spacer's thickness is not used, so it gives none. + static bool fillsAcrossWrappingRowColumn( + BaseNode rowColumn, + List childrenNodes, + ) { + final bool isRow = + (rowColumn as RowColumnMixin).rowColumnType == RowColumnType.row; + SizeFit crossFit(BaseNode child) => + isRow ? child.verticalFit : child.horizontalFit; + bool fills(BaseNode child) => + child is! SpacerNode && crossFit(child) == SizeFit.expanded; + return childrenNodes.any(fills) && + childrenNodes.any((child) => child is! SpacerNode && !fills(child)); + } + static Widget buildRowColumnWidget( BaseNode rowColumnNode, { required List childrenWidgets, @@ -88,7 +109,9 @@ class PassiveRowColumnTransformer extends NodeWidgetTransformer { final bool anyChildNeedsAlignment = childrenNodes.any( (child) => child.alignment != AlignmentModel.none, ); - if (stretches || anyChildNeedsAlignment) { + if (stretches || + anyChildNeedsAlignment || + fillsAcrossWrappingRowColumn(rowColumnNode, childrenNodes)) { final bool blocked = childrenNodes.any( (child) => subtreeBlocksIntrinsics(child, getNode), ); diff --git a/test/flex_layout_test.dart b/test/flex_layout_test.dart index d24cfb50..4e2353d5 100644 --- a/test/flex_layout_test.dart +++ b/test/flex_layout_test.dart @@ -323,6 +323,104 @@ void main() { }); }); + testWidgets( + 'a child filling across a row that wraps its height stretches to the ' + 'height the other children give the row', + (tester) async { + // A card's severity edge, as tall as its text: in Flutter, + // IntrinsicHeight(child: Row(crossAxisAlignment: stretch)). + final edge = rect( + 'edge', + '', + width: 4, + height: 10, + verticalFit: SizeFit.expanded, + ); + final title = rect('title', '', width: 120, height: 20); + final subtitle = rect('subtitle', '', width: 160, height: 30); + final text = flex( + 'text', + '', + [title, subtitle], + type: RowColumnType.column, + width: 200, + height: 50, + horizontalFit: SizeFit.expanded, + verticalFit: SizeFit.shrinkWrap, + ); + final card = flex( + 'card', + '', + [edge, text], + type: RowColumnType.row, + width: 300, + height: 40, + horizontalFit: SizeFit.expanded, + verticalFit: SizeFit.shrinkWrap, + ); + final column = flex( + 'column', + kRootNode, + [card], + type: RowColumnType.column, + width: 300, + height: 400, + isScrollable: true, + ); + await everywhere(tester, column, [card, edge, text, title, subtitle], ( + sizes, + screen, + host, + ) { + expect(sizes['edge'], const Size(4, 50), reason: '$host $screen'); + }); + }, + ); + + testWidgets( + 'a spacer filling across a row that wraps its height leaves the row as ' + 'tall as its other children', + (tester) async { + final label = rect('label', '', width: 90, height: 24); + final gap = spacer('gap', width: 10, height: 10); + final edge = rect( + 'edge', + '', + width: 4, + height: 10, + verticalFit: SizeFit.expanded, + ); + final row = flex( + 'row', + '', + [label, gap..parentID = 'row', edge], + type: RowColumnType.row, + width: 300, + height: 40, + horizontalFit: SizeFit.expanded, + verticalFit: SizeFit.shrinkWrap, + ); + final column = flex( + 'column', + kRootNode, + [row], + type: RowColumnType.column, + width: 300, + height: 400, + isScrollable: true, + ); + await everywhere(tester, column, [row, label, gap, edge], ( + sizes, + screen, + host, + ) { + // The edge shows the row's height. + expect(sizes['edge'], const Size(4, 24), reason: '$host $screen'); + expect(sizes['label'], const Size(90, 24), reason: '$host $screen'); + }); + }, + ); + testWidgets('flexible column takes its design size like a leaf (A6)', ( tester, ) async {