From e5471a4290c51dc2c3ee1afa0fcca590a819fefe Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 22:25:50 +0000 Subject: [PATCH 1/9] Keep formula values of computed variables A variable whose value is a ${= ...} formula is a computed variable the runtime evaluates; sanitizing it by type wiped it for lists, maps and numbers. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011fXBKiHRftTsgsVPHEZAWz --- lib/src/api/models/variables_model.dart | 8 ++++-- test/computed_variable_value_test.dart | 36 +++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 2 deletions(-) create mode 100644 test/computed_variable_value_test.dart diff --git a/lib/src/api/models/variables_model.dart b/lib/src/api/models/variables_model.dart index eb860565..a6808701 100644 --- a/lib/src/api/models/variables_model.dart +++ b/lib/src/api/models/variables_model.dart @@ -228,8 +228,12 @@ class VariableData String? sanitizeValueForVariableType(Object? value, VariableType type) { if (value == null) return null; // if (value.isEmpty) return ''; - // Do not sanitize if the value contains a variable path. - if (value.toString().contains(variablePathPattern)) return value.toString(); + // Do not sanitize if the value contains a variable path, or is a formula: + // a computed variable stores `${= ...}` and the runtime evaluates it. + if (value.toString().contains(variablePathPattern) || + value.toString().trimLeft().startsWith(r'${=')) { + return value.toString(); + } switch (type) { case VariableType.text: case VariableType.image: diff --git a/test/computed_variable_value_test.dart b/test/computed_variable_value_test.dart new file mode 100644 index 00000000..a8eb5d00 --- /dev/null +++ b/test/computed_variable_value_test.dart @@ -0,0 +1,36 @@ +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:test/test.dart'; + +void main() { + test('a formula value is kept as is for every variable type', () { + const String formula = r"${= filter(units, u => u.status == 'Available')}"; + for (final VariableType type in VariableType.values) { + expect( + VariableData(id: 'v', name: 'v', value: formula, type: type).value, + formula, + reason: '$type', + ); + } + expect( + VariableData( + id: 'v', + name: 'v', + value: '[]', + type: VariableType.list, + ).copyWith(value: r'${= range(3)}').value, + r'${= range(3)}', + ); + }); + + test('other values are still sanitized by type', () { + expect( + VariableData( + id: 'v', + name: 'v', + value: 'abc', + type: VariableType.integer, + ).value, + isNot('abc'), + ); + }); +} From af0603073d3ef856c2f5294bcd1d025132633814 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 22:32:35 +0000 Subject: [PATCH 2/9] PaintModel.gradientRepeatEvery: linear gradients that tile every N px Hard stops repeating at a fixed pixel period make even stripes at any box size (hazard stripes). Null keeps today's stretch-across-the-box. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011fXBKiHRftTsgsVPHEZAWz --- lib/src/api/models/paint.dart | 29 ++++++++++++++++++++++++---- lib/src/api/models/paint.g.dart | 2 ++ test/gradient_repeat_test.dart | 34 +++++++++++++++++++++++++++++++++ 3 files changed, 61 insertions(+), 4 deletions(-) create mode 100644 test/gradient_repeat_test.dart diff --git a/lib/src/api/models/paint.dart b/lib/src/api/models/paint.dart index 85644755..59e9ae04 100644 --- a/lib/src/api/models/paint.dart +++ b/lib/src/api/models/paint.dart @@ -88,6 +88,13 @@ class PaintModel with Equatable, SerializableMixin, FieldsHolder { /// neighboring gradient stops. final List? gradientStops; + /// When set, a linear gradient repeats every this many logical pixels + /// along its axis instead of stretching across the box, at any box size. + /// Hard stops (two colors at the same position) then make even stripes, + /// e.g. 45° hazard stripes: stops `[a 0, a .5, b .5, b 1]` repeating every + /// 16 px. Null (the default) stretches the gradient across the box. + final double? gradientRepeatEvery; + // For image paint: /// Affine transform applied to the image. @@ -263,7 +270,8 @@ class PaintModel with Equatable, SerializableMixin, FieldsHolder { imageRepeat = ImageRepeatEnum.noRepeat, sourceWidth = null, sourceHeight = null, - assetID = null; + assetID = null, + gradientRepeatEvery = null; /// Create an Image Paint with only the required properties. PaintModel.image({ @@ -293,7 +301,8 @@ class PaintModel with Equatable, SerializableMixin, FieldsHolder { type = PaintType.image, gradientTransform = null, gradientStops = null, - color = null; + color = null, + gradientRepeatEvery = null; /// Creates [PaintModel] with linear gradient. PaintModel.linearGradient({ @@ -302,6 +311,7 @@ class PaintModel with Equatable, SerializableMixin, FieldsHolder { this.opacity = 1, this.gradientTransform, this.gradientStops, + this.gradientRepeatEvery, this.blendMode = BlendModeC.srcOver, }) : type = PaintType.gradientLinear, color = null, @@ -345,7 +355,8 @@ class PaintModel with Equatable, SerializableMixin, FieldsHolder { imageRepeat = ImageRepeatEnum.noRepeat, sourceWidth = null, sourceHeight = null, - assetID = null; + assetID = null, + gradientRepeatEvery = null; /// Creates [PaintModel] with angular gradient. PaintModel.angularGradient({ @@ -371,7 +382,8 @@ class PaintModel with Equatable, SerializableMixin, FieldsHolder { croppedImageURL = null, sourceWidth = null, sourceHeight = null, - assetID = null; + assetID = null, + gradientRepeatEvery = null; /// Creates [PaintModel] with given data. PaintModel({ @@ -383,6 +395,7 @@ class PaintModel with Equatable, SerializableMixin, FieldsHolder { this.blendMode = BlendModeC.srcOver, this.gradientTransform, this.gradientStops, + this.gradientRepeatEvery, this.imageTransform, this.hasImageBytes = false, this.downloadUrl, @@ -409,6 +422,8 @@ class PaintModel with Equatable, SerializableMixin, FieldsHolder { BlendModeC? blendMode, List? gradientTransform, List? gradientStops, + double? gradientRepeatEvery, + bool clearGradientRepeat = false, List>? imageTransform, double? scalingFactor, String? downloadUrl, @@ -437,6 +452,9 @@ class PaintModel with Equatable, SerializableMixin, FieldsHolder { blendMode: blendMode ?? this.blendMode, gradientTransform: gradientTransform ?? this.gradientTransform, gradientStops: gradientStops ?? this.gradientStops, + gradientRepeatEvery: clearGradientRepeat + ? null + : gradientRepeatEvery ?? this.gradientRepeatEvery, imageTransform: imageTransform ?? this.imageTransform, hasImageBytes: hasImageBytes ?? this.hasImageBytes, downloadUrl: downloadUrl ?? this.downloadUrl, @@ -487,6 +505,7 @@ class PaintModel with Equatable, SerializableMixin, FieldsHolder { visible: visible, gradientStops: gradientStops, gradientTransform: gradientTransform, + gradientRepeatEvery: gradientRepeatEvery, ); case PaintType.image: return PaintModel.image( @@ -526,6 +545,7 @@ class PaintModel with Equatable, SerializableMixin, FieldsHolder { if (blendMode != other.blendMode) return false; if (gradientTransform != other.gradientTransform) return false; if (gradientStops != other.gradientStops) return false; + if (gradientRepeatEvery != other.gradientRepeatEvery) return false; if (imageTransform != other.imageTransform) return false; if (hasImageBytes != other.hasImageBytes) return false; if (downloadUrl != other.downloadUrl) return false; @@ -554,6 +574,7 @@ class PaintModel with Equatable, SerializableMixin, FieldsHolder { blendMode, gradientTransform, gradientStops, + gradientRepeatEvery, imageTransform, hasImageBytes, downloadUrl, diff --git a/lib/src/api/models/paint.g.dart b/lib/src/api/models/paint.g.dart index e0ee399f..7a65b57f 100644 --- a/lib/src/api/models/paint.g.dart +++ b/lib/src/api/models/paint.g.dart @@ -54,6 +54,7 @@ PaintModel _$PaintModelFromJson(Map json) => PaintModel( gradientStops: (json['gradientStops'] as List?) ?.map((e) => ColorStop.fromJson(e as Map)) .toList(), + gradientRepeatEvery: (json['gradientRepeatEvery'] as num?)?.toDouble(), imageTransform: (json['imageTransform'] as List?) ?.map((e) => (e as List).map((e) => e as num).toList()) .toList(), @@ -88,6 +89,7 @@ Map _$PaintModelToJson(PaintModel instance) => 'blendMode': _$BlendModeCEnumMap[instance.blendMode]!, 'gradientTransform': ?instance.gradientTransform, 'gradientStops': ?instance.gradientStops?.map((e) => e.toJson()).toList(), + 'gradientRepeatEvery': ?instance.gradientRepeatEvery, 'imageTransform': ?instance.imageTransform, 'downloadUrl': ?instance.downloadUrl, 'imageName': ?instance.imageName, diff --git a/test/gradient_repeat_test.dart b/test/gradient_repeat_test.dart new file mode 100644 index 00000000..2c358f84 --- /dev/null +++ b/test/gradient_repeat_test.dart @@ -0,0 +1,34 @@ +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:test/test.dart'; + +void main() { + final PaintModel stripes = PaintModel.linearGradient( + id: 'p', + gradientStops: const [ + ColorStop(position: 0, color: ColorRGBA(r: 1, g: 0.76, b: 0.05, a: 1)), + ColorStop(position: 0.5, color: ColorRGBA(r: 1, g: 0.76, b: 0.05, a: 1)), + ColorStop(position: 0.5, color: ColorRGBA.black), + ColorStop(position: 1, color: ColorRGBA.black), + ], + gradientRepeatEvery: 16, + ); + + test('gradientRepeatEvery survives JSON, copies and sanitizing', () { + final PaintModel decoded = PaintModel.fromJson(stripes.toJson()); + expect(decoded.gradientRepeatEvery, 16); + expect(stripes.sanitize().gradientRepeatEvery, 16); + expect(stripes.copyWith(opacity: 0.5).gradientRepeatEvery, 16); + expect( + stripes.copyWith(clearGradientRepeat: true).gradientRepeatEvery, + isNull, + ); + }); + + test('a stretched gradient omits it from JSON', () { + expect( + PaintModel.linearPaint.toJson().containsKey('gradientRepeatEvery'), + isFalse, + ); + expect(stripes.toJson()['gradientRepeatEvery'], 16); + }); +} From a1c67c306567783975d7ecd6cc5e383cc183631d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 22:41:38 +0000 Subject: [PATCH 3/9] Progress bar arc shape: gauge with ticks, needle and zone bands ProgressBarProperties.shape (bar | arc) with arcStartAngle, arcSweepAngle, arcStrokeWidth, needleColor, tickCount, tickColor and bands (ProgressBand start/end fractions + color). Bars keep their JSON. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011fXBKiHRftTsgsVPHEZAWz --- lib/src/api/nodes/progressbar_node.dart | 148 +++++++++++++++++++++- lib/src/api/nodes/progressbar_node.g.dart | 61 +++++++++ test/progress_arc_test.dart | 39 ++++++ 3 files changed, 247 insertions(+), 1 deletion(-) create mode 100644 test/progress_arc_test.dart diff --git a/lib/src/api/nodes/progressbar_node.dart b/lib/src/api/nodes/progressbar_node.dart index 29dfc7d3..e0321f26 100644 --- a/lib/src/api/nodes/progressbar_node.dart +++ b/lib/src/api/nodes/progressbar_node.dart @@ -1,4 +1,5 @@ import 'package:json_annotation/json_annotation.dart'; +import 'package:equatable/equatable.dart'; import '../field_access.dart'; import '../mixins.dart'; @@ -151,6 +152,35 @@ class ProgressBarProperties extends CustomProperties with FieldsHolder { /// Duration of animation in milliseconds. int animationDurationInMillis; + /// A straight bar, or an arc like a dial or gauge. + @JsonKey(unknownEnumValue: ProgressBarShape.bar) + ProgressBarShape shape; + + /// Arc only: where the arc starts, in degrees clockwise from 3 o'clock. + /// The default 150 with a 240 sweep makes a dial open at the bottom. + double arcStartAngle; + + /// Arc only: how far the arc runs, in degrees. + double arcSweepAngle; + + /// Arc only: thickness of the track and progress. + double arcStrokeWidth; + + /// Arc only: color of a needle pointing at the value, or null for none. + /// The needle goes past the end of the arc a little when the value is + /// over the max, so an overrun shows. + ColorRGBA? needleColor; + + /// Arc only: tick marks dividing the track evenly, drawn in [tickColor]. + int tickCount; + + /// Arc only: color of the tick marks. + ColorRGBA tickColor; + + /// Arc only: colored zones drawn just outside the track, e.g. green up to + /// 90% and amber after. + List bands; + /// Creates a [ProgressBarProperties] instance with the given data. ProgressBarProperties({ this.maxValue = 100, @@ -160,7 +190,15 @@ class ProgressBarProperties extends CustomProperties with FieldsHolder { this.cornerRadius = CornerRadius.zero, this.animate = false, this.animationDurationInMillis = 300, - }); + this.shape = ProgressBarShape.bar, + this.arcStartAngle = 150, + this.arcSweepAngle = 240, + this.arcStrokeWidth = 12, + this.needleColor, + this.tickCount = 0, + this.tickColor = ColorRGBA.white, + List? bands, + }) : bands = bands ?? []; /// Duplicates this [ProgressBarProperties] instance with the given data /// overrides. @@ -172,6 +210,15 @@ class ProgressBarProperties extends CustomProperties with FieldsHolder { CornerRadius? cornerRadius, bool? animate, int? animationDurationInMillis, + ProgressBarShape? shape, + double? arcStartAngle, + double? arcSweepAngle, + double? arcStrokeWidth, + ColorRGBA? needleColor, + bool clearNeedle = false, + int? tickCount, + ColorRGBA? tickColor, + List? bands, }) { return ProgressBarProperties( maxValue: maxValue ?? this.maxValue, @@ -182,6 +229,14 @@ class ProgressBarProperties extends CustomProperties with FieldsHolder { animate: animate ?? this.animate, animationDurationInMillis: animationDurationInMillis ?? this.animationDurationInMillis, + shape: shape ?? this.shape, + arcStartAngle: arcStartAngle ?? this.arcStartAngle, + arcSweepAngle: arcSweepAngle ?? this.arcSweepAngle, + arcStrokeWidth: arcStrokeWidth ?? this.arcStrokeWidth, + needleColor: clearNeedle ? null : needleColor ?? this.needleColor, + tickCount: tickCount ?? this.tickCount, + tickColor: tickColor ?? this.tickColor, + bands: bands ?? this.bands, ); } @@ -235,6 +290,50 @@ class ProgressBarProperties extends CustomProperties with FieldsHolder { () => animationDurationInMillis, (value) => animationDurationInMillis = value, ), + 'shape': EnumFieldAccess( + 'Shape', + 'A straight bar, or an arc like a dial or gauge', + () => shape, + (value) => shape = value, + options: () => ProgressBarShape.values, + defaultValue: () => ProgressBarShape.bar, + ), + 'arcStartAngle': NumFieldAccess( + 'Arc Start', + 'Arc only: start angle in degrees, clockwise from 3 o\'clock', + () => arcStartAngle, + (value) => arcStartAngle = value, + ), + 'arcSweepAngle': NumFieldAccess( + 'Arc Sweep', + 'Arc only: how far the arc runs, in degrees', + () => arcSweepAngle, + (value) => arcSweepAngle = value, + ), + 'arcStrokeWidth': NumFieldAccess( + 'Arc Thickness', + 'Arc only: thickness of the track and progress', + () => arcStrokeWidth, + (value) => arcStrokeWidth = value, + ), + 'needleColor': ColorFieldAccess( + 'Needle Color', + 'Arc only: color of a needle pointing at the value; empty for none', + () => needleColor, + (value) => needleColor = value, + ), + 'tickCount': NumFieldAccess( + 'Ticks', + 'Arc only: how many tick marks divide the track', + () => tickCount, + (value) => tickCount = value, + ), + 'tickColor': ColorFieldAccess( + 'Tick Color', + 'Arc only: color of the tick marks', + () => tickColor, + (value) => tickColor = value, + ), }; @override @@ -246,6 +345,14 @@ class ProgressBarProperties extends CustomProperties with FieldsHolder { cornerRadius, animate, animationDurationInMillis, + shape, + arcStartAngle, + arcSweepAngle, + arcStrokeWidth, + needleColor, + tickCount, + tickColor, + bands, ]; /// Creates a [ProgressBarProperties] instance from a JSON object. @@ -258,3 +365,42 @@ class ProgressBarProperties extends CustomProperties with FieldsHolder { _$ProgressBarPropertiesToJson(this), ); } + +/// The shape of a [ProgressBarNode]. +enum ProgressBarShape { + /// A straight horizontal or vertical bar. + bar, + + /// An arc, like a dial or gauge. + arc, +} + +/// A colored zone of an arc progress bar, from [start] to [end] as fractions +/// of the max value (0 to 1). +@JsonSerializable() +class ProgressBand extends Equatable with SerializableMixin { + /// Where the zone starts, as a fraction of the max value. + final double start; + + /// Where the zone ends, as a fraction of the max value. + final double end; + + /// The zone's color. + final ColorRGBA color; + + /// Creates a [ProgressBand]. + const ProgressBand({ + required this.start, + required this.end, + required this.color, + }); + + /// Creates a [ProgressBand] from JSON. + factory ProgressBand.fromJson(Map json) => _$ProgressBandFromJson(json); + + @override + Map toJson() => _$ProgressBandToJson(this); + + @override + List get props => [start, end, color]; +} diff --git a/lib/src/api/nodes/progressbar_node.g.dart b/lib/src/api/nodes/progressbar_node.g.dart index faaf5a17..9591fbd0 100644 --- a/lib/src/api/nodes/progressbar_node.g.dart +++ b/lib/src/api/nodes/progressbar_node.g.dart @@ -64,6 +64,21 @@ Map _$ProgressBarPropertiesOmitDefaults( if (isJsonDefault(instance.animate, false)) json.remove('animate'); if (isJsonDefault(instance.animationDurationInMillis, 300)) json.remove('animationDurationInMillis'); + if (isJsonDefault(instance.shape, ProgressBarShape.bar)) json.remove('shape'); + if (isJsonDefault(instance.arcStartAngle, 150)) json.remove('arcStartAngle'); + if (isJsonDefault(instance.arcSweepAngle, 240)) json.remove('arcSweepAngle'); + if (isJsonDefault(instance.arcStrokeWidth, 12)) json.remove('arcStrokeWidth'); + if (isJsonDefault(instance.tickCount, 0)) json.remove('tickCount'); + if (isJsonDefault(instance.tickColor, ColorRGBA.white)) + json.remove('tickColor'); + if (isJsonDefault(instance.bands, [])) json.remove('bands'); + return json; +} + +Map _$ProgressBandOmitDefaults( + ProgressBand instance, + Map json, +) { return json; } @@ -240,6 +255,26 @@ ProgressBarProperties _$ProgressBarPropertiesFromJson(Map json) => animate: json['animate'] as bool? ?? false, animationDurationInMillis: (json['animationDurationInMillis'] as num?)?.toInt() ?? 300, + shape: + $enumDecodeNullable( + _$ProgressBarShapeEnumMap, + json['shape'], + unknownValue: ProgressBarShape.bar, + ) ?? + ProgressBarShape.bar, + arcStartAngle: (json['arcStartAngle'] as num?)?.toDouble() ?? 150, + arcSweepAngle: (json['arcSweepAngle'] as num?)?.toDouble() ?? 240, + arcStrokeWidth: (json['arcStrokeWidth'] as num?)?.toDouble() ?? 12, + needleColor: json['needleColor'] == null + ? null + : ColorRGBA.fromJson(json['needleColor']), + tickCount: (json['tickCount'] as num?)?.toInt() ?? 0, + tickColor: json['tickColor'] == null + ? ColorRGBA.white + : ColorRGBA.fromJson(json['tickColor']), + bands: (json['bands'] as List?) + ?.map((e) => ProgressBand.fromJson(e as Map)) + .toList(), ); Map _$ProgressBarPropertiesToJson( @@ -252,4 +287,30 @@ Map _$ProgressBarPropertiesToJson( 'cornerRadius': instance.cornerRadius.toJson(), 'animate': instance.animate, 'animationDurationInMillis': instance.animationDurationInMillis, + 'shape': _$ProgressBarShapeEnumMap[instance.shape]!, + 'arcStartAngle': instance.arcStartAngle, + 'arcSweepAngle': instance.arcSweepAngle, + 'arcStrokeWidth': instance.arcStrokeWidth, + 'needleColor': ?instance.needleColor?.toJson(), + 'tickCount': instance.tickCount, + 'tickColor': instance.tickColor.toJson(), + 'bands': instance.bands.map((e) => e.toJson()).toList(), }; + +const _$ProgressBarShapeEnumMap = { + ProgressBarShape.bar: 'bar', + ProgressBarShape.arc: 'arc', +}; + +ProgressBand _$ProgressBandFromJson(Map json) => ProgressBand( + start: (json['start'] as num).toDouble(), + end: (json['end'] as num).toDouble(), + color: ColorRGBA.fromJson(json['color']), +); + +Map _$ProgressBandToJson(ProgressBand instance) => + { + 'start': instance.start, + 'end': instance.end, + 'color': instance.color.toJson(), + }; diff --git a/test/progress_arc_test.dart b/test/progress_arc_test.dart new file mode 100644 index 00000000..d89e3dbb --- /dev/null +++ b/test/progress_arc_test.dart @@ -0,0 +1,39 @@ +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:test/test.dart'; + +void main() { + test('arc gauge properties survive JSON', () { + final props = ProgressBarProperties( + shape: ProgressBarShape.arc, + arcStrokeWidth: 14, + needleColor: ColorRGBA.black, + tickCount: 10, + bands: const [ + ProgressBand( + start: 0, + end: 0.9, + color: ColorRGBA(r: 0, g: 1, b: 0, a: 1), + ), + ProgressBand( + start: 0.9, + end: 1, + color: ColorRGBA(r: 1, g: 0, b: 0, a: 1), + ), + ], + ); + final decoded = ProgressBarProperties.fromJson(props.toJson()); + expect(decoded, props); + expect(decoded.bands.last.start, 0.9); + expect(decoded.copyWith(clearNeedle: true).needleColor, isNull); + }); + + test('a plain bar keeps its old JSON', () { + final json = ProgressBarProperties().toJson(); + expect(json.containsKey('shape'), isFalse); + expect(json.containsKey('bands'), isFalse); + expect( + ProgressBarProperties.fromJson(const {}).shape, + ProgressBarShape.bar, + ); + }); +} From 96fe2073fe703053afbfda4b0f826cf4a57456b0 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 22:46:02 +0000 Subject: [PATCH 4/9] ShowDialogAction: presentation (dialog, bottom sheet, adaptive) and alignment Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011fXBKiHRftTsgsVPHEZAWz --- .../api/models/action/show_dialog_action.dart | 29 +++++++++++++++++++ .../models/action/show_dialog_action.g.dart | 22 ++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/lib/src/api/models/action/show_dialog_action.dart b/lib/src/api/models/action/show_dialog_action.dart index 80bfdd32..e4cfed84 100644 --- a/lib/src/api/models/action/show_dialog_action.dart +++ b/lib/src/api/models/action/show_dialog_action.dart @@ -24,6 +24,15 @@ class ShowDialogAction extends ActionModel with Equatable, SerializableMixin { /// Whether to show a close button on the dialog. final bool showCloseButton; + /// How the canvas opens: a dialog, a bottom sheet, or adaptive (a bottom + /// sheet on phones, below 600 px wide, and a dialog otherwise). + @JsonKey(unknownEnumValue: DialogPresentation.dialog) + final DialogPresentation presentation; + + /// Where a dialog sits on the screen, e.g. top right for a menu. Center by + /// default. Bottom sheets always sit at the bottom. + final AlignmentModel alignment; + /// Creates a new [ShowDialogAction]. ShowDialogAction({ required this.destinationId, @@ -31,6 +40,8 @@ class ShowDialogAction extends ActionModel with Equatable, SerializableMixin { this.barrierColor = ColorRGBA.black54, this.barrierDismissible = true, this.showCloseButton = false, + this.presentation = DialogPresentation.dialog, + this.alignment = AlignmentModel.center, super.nonBlocking, super.enabled, }) : params = {...params ?? {}}, @@ -43,6 +54,8 @@ class ShowDialogAction extends ActionModel with Equatable, SerializableMixin { barrierDismissible, barrierColor, showCloseButton, + presentation, + alignment, ]; /// Duplicates this [ShowDialogAction] with given data overrides. @@ -52,12 +65,16 @@ class ShowDialogAction extends ActionModel with Equatable, SerializableMixin { bool? barrierDismissible, ColorRGBA? barrierColor, bool? showCloseButton, + DialogPresentation? presentation, + AlignmentModel? alignment, }) => ShowDialogAction( destinationId: destinationId ?? this.destinationId, params: {...this.params, ...params ?? {}}, barrierDismissible: barrierDismissible ?? this.barrierDismissible, barrierColor: barrierColor ?? this.barrierColor, showCloseButton: showCloseButton ?? this.showCloseButton, + presentation: presentation ?? this.presentation, + alignment: alignment ?? this.alignment, ); /// Creates a new [ShowDialogAction] instance from a JSON data. @@ -71,3 +88,15 @@ class ShowDialogAction extends ActionModel with Equatable, SerializableMixin { @override R? accept(ActionVisitor visitor) => visitor.visitShowDialogAction(this); } + +/// How a [ShowDialogAction] presents its canvas. +enum DialogPresentation { + /// A dialog, centered unless the action gives an alignment. + dialog, + + /// A modal sheet that slides up from the bottom edge. + bottomSheet, + + /// A bottom sheet on phones (narrower than 600 px), a dialog otherwise. + adaptive, +} diff --git a/lib/src/api/models/action/show_dialog_action.g.dart b/lib/src/api/models/action/show_dialog_action.g.dart index 4d8db00c..45efe467 100644 --- a/lib/src/api/models/action/show_dialog_action.g.dart +++ b/lib/src/api/models/action/show_dialog_action.g.dart @@ -16,6 +16,10 @@ Map _$ShowDialogActionOmitDefaults( json.remove('barrierColor'); if (isJsonDefault(instance.showCloseButton, false)) json.remove('showCloseButton'); + if (isJsonDefault(instance.presentation, DialogPresentation.dialog)) + json.remove('presentation'); + if (isJsonDefault(instance.alignment, AlignmentModel.center)) + json.remove('alignment'); if (isJsonDefault(instance.nonBlocking, false)) json.remove('nonBlocking'); if (isJsonDefault(instance.enabled, true)) json.remove('enabled'); return json; @@ -35,6 +39,16 @@ ShowDialogAction _$ShowDialogActionFromJson(Map json) => ShowDialogAction( : ColorRGBA.fromJson(json['barrierColor']), barrierDismissible: json['barrierDismissible'] as bool? ?? true, showCloseButton: json['showCloseButton'] as bool? ?? false, + presentation: + $enumDecodeNullable( + _$DialogPresentationEnumMap, + json['presentation'], + unknownValue: DialogPresentation.dialog, + ) ?? + DialogPresentation.dialog, + alignment: json['alignment'] == null + ? AlignmentModel.center + : AlignmentModel.fromJson(json['alignment'] as Map), nonBlocking: json['nonBlocking'] as bool? ?? false, enabled: json['enabled'] as bool? ?? true, )..type = $enumDecode(_$ActionTypeEnumMap, json['type']); @@ -49,8 +63,16 @@ Map _$ShowDialogActionToJson(ShowDialogAction instance) => 'barrierDismissible': instance.barrierDismissible, 'barrierColor': ?instance.barrierColor?.toJson(), 'showCloseButton': instance.showCloseButton, + 'presentation': _$DialogPresentationEnumMap[instance.presentation]!, + 'alignment': instance.alignment.toJson(), }; +const _$DialogPresentationEnumMap = { + DialogPresentation.dialog: 'dialog', + DialogPresentation.bottomSheet: 'bottomSheet', + DialogPresentation.adaptive: 'adaptive', +}; + const _$ActionTypeEnumMap = { ActionType.navigation: 'navigation', ActionType.showDialog: 'showDialog', From 1494722c0c398b693a0c05f9c83553974df828cd Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 22:55:50 +0000 Subject: [PATCH 5/9] Parse list, grid, page and tab views and spacers from minimal JSON Hand-written node JSON (the editor's insertTree command, AI tools) may leave out fields the constructors default, such as isScrollable, useFlutterListView, allowedTypes and deniedTypes. fromJson now fills those in with the constructor defaults instead of throwing. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011fXBKiHRftTsgsVPHEZAWz --- lib/src/api/nodes/grid_view_node.dart | 9 ++++- lib/src/api/nodes/list_view_node.dart | 9 ++++- lib/src/api/nodes/page_view_node.dart | 9 ++++- lib/src/api/nodes/spacer_node.dart | 7 +++- lib/src/api/nodes/tab_bar_node.dart | 8 +++- test/minimal_node_json_test.dart | 53 +++++++++++++++++++++++++++ 6 files changed, 90 insertions(+), 5 deletions(-) create mode 100644 test/minimal_node_json_test.dart diff --git a/lib/src/api/nodes/grid_view_node.dart b/lib/src/api/nodes/grid_view_node.dart index 626d8b38..c9a02eff 100644 --- a/lib/src/api/nodes/grid_view_node.dart +++ b/lib/src/api/nodes/grid_view_node.dart @@ -208,7 +208,14 @@ class GridViewNode extends SinglePlaceholderNode }; /// Creates a [GridViewNode] from a JSON object. - factory GridViewNode.fromJson(Map json) => _$GridViewNodeFromJson(json); + factory GridViewNode.fromJson(Map json) => _$GridViewNodeFromJson({ + // Node JSON may leave out scrolling fields; read the constructor's. + 'isScrollable': true, + 'useFlutterListView': false, + 'allowedTypes': [], + 'deniedTypes': ['GridView'], + ...json, + }); @override Map toJson() => _$GridViewNodeOmitDefaults(this, _$GridViewNodeToJson(this)); diff --git a/lib/src/api/nodes/list_view_node.dart b/lib/src/api/nodes/list_view_node.dart index e1cd40d6..3dcac284 100644 --- a/lib/src/api/nodes/list_view_node.dart +++ b/lib/src/api/nodes/list_view_node.dart @@ -120,7 +120,14 @@ class ListViewNode extends SinglePlaceholderNode } /// Creates a [ListViewNode] from a JSON object. - factory ListViewNode.fromJson(Map json) => _$ListViewNodeFromJson(json); + factory ListViewNode.fromJson(Map json) => _$ListViewNodeFromJson({ + // Node JSON may leave out scrolling fields; read the constructor's. + 'isScrollable': true, + 'useFlutterListView': true, + 'allowedTypes': [], + 'deniedTypes': ['listView'], + ...json, + }); @override Map toJson() => _$ListViewNodeOmitDefaults(this, _$ListViewNodeToJson(this)); diff --git a/lib/src/api/nodes/page_view_node.dart b/lib/src/api/nodes/page_view_node.dart index c4c59574..1bf98026 100644 --- a/lib/src/api/nodes/page_view_node.dart +++ b/lib/src/api/nodes/page_view_node.dart @@ -95,7 +95,14 @@ class PageViewNode extends SinglePlaceholderNode }; /// Creates a [ListViewNode] from a JSON object. - factory PageViewNode.fromJson(Map json) => _$PageViewNodeFromJson(json); + factory PageViewNode.fromJson(Map json) => _$PageViewNodeFromJson({ + // Node JSON may leave out scrolling fields; read the constructor's. + 'isScrollable': true, + 'useFlutterListView': true, + 'allowedTypes': [], + 'deniedTypes': ['pageView'], + ...json, + }); @override Map toJson() => _$PageViewNodeOmitDefaults(this, _$PageViewNodeToJson(this)); diff --git a/lib/src/api/nodes/spacer_node.dart b/lib/src/api/nodes/spacer_node.dart index 7310bb09..5147c62a 100644 --- a/lib/src/api/nodes/spacer_node.dart +++ b/lib/src/api/nodes/spacer_node.dart @@ -44,7 +44,12 @@ class SpacerNode extends SceneNode { }); /// Creates a new [SpacerNode] from a JSON data. - factory SpacerNode.fromJson(Map json) => _$SpacerNodeFromJson(json); + factory SpacerNode.fromJson(Map json) => _$SpacerNodeFromJson({ + 'aspectRatioLock': false, + 'reactions': [], + 'positioningMode': 'align', + ...json, + }); @override Map toJson() => _$SpacerNodeOmitDefaults(this, _$SpacerNodeToJson(this)); diff --git a/lib/src/api/nodes/tab_bar_node.dart b/lib/src/api/nodes/tab_bar_node.dart index 7fde7262..ae81b427 100644 --- a/lib/src/api/nodes/tab_bar_node.dart +++ b/lib/src/api/nodes/tab_bar_node.dart @@ -90,7 +90,13 @@ class TabBarNode extends SceneNode }; /// Creates a [TabBarNode] from a JSON object. - factory TabBarNode.fromJson(Map json) => _$TabBarNodeFromJson(json); + factory TabBarNode.fromJson(Map json) => _$TabBarNodeFromJson({ + // Node JSON may leave out scrolling fields; read the constructor's. + 'reverse': false, + 'primary': false, + 'useFlutterListView': false, + ...json, + }); @override Map toJson() => _$TabBarNodeOmitDefaults(this, _$TabBarNodeToJson(this)); diff --git a/test/minimal_node_json_test.dart b/test/minimal_node_json_test.dart new file mode 100644 index 00000000..03f491dd --- /dev/null +++ b/test/minimal_node_json_test.dart @@ -0,0 +1,53 @@ +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:test/test.dart'; + +/// Nodes built from hand-written JSON (the editor's insertTree command, AI +/// tools) may leave out fields that have constructor defaults. +void main() { + const box = { + 'id': 'n', + 'name': 'n', + 'basicBoxLocal': [0, 0, 100, 100], + }; + + test('a grid view needs only its delegate', () { + final node = GridViewNode.fromJson({ + ...box, + 'type': 'gridView', + 'properties': { + 'gridDelegate': {'type': 'fixedCrossAxisCount', 'crossAxisCount': 3}, + }, + }); + expect(node.isScrollable, isTrue); + expect(node.useFlutterListView, isFalse); + }); + + test('a grid view keeps scrolling fields it is given', () { + final node = GridViewNode.fromJson({ + ...box, + 'type': 'gridView', + 'isScrollable': false, + 'properties': { + 'gridDelegate': {'type': 'fixedCrossAxisCount', 'crossAxisCount': 3}, + }, + }); + expect(node.isScrollable, isFalse); + }); + + test('list, page and tab views and spacers parse from minimal JSON', () { + expect( + ListViewNode.fromJson({...box, 'type': 'listView', 'properties': {}}) + .useFlutterListView, + isTrue, + ); + expect( + PageViewNode.fromJson({...box, 'type': 'pageView', 'properties': {}}) + .isScrollable, + isTrue, + ); + expect( + SpacerNode.fromJson({...box, 'type': 'spacer'}).aspectRatioLock, + isFalse, + ); + }); +} From c1401f9fcd55aa4fc03611f84aa4723a8ba8b3af Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 18:08:34 +0000 Subject: [PATCH 6/9] DelayAction.duration: a formula for the wait Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011fXBKiHRftTsgsVPHEZAWz --- lib/src/api/models/action/delay_action.dart | 22 +++++++++++++--- lib/src/api/models/action/delay_action.g.dart | 2 ++ test/delay_duration_test.dart | 26 +++++++++++++++++++ 3 files changed, 46 insertions(+), 4 deletions(-) create mode 100644 test/delay_duration_test.dart diff --git a/lib/src/api/models/action/delay_action.dart b/lib/src/api/models/action/delay_action.dart index bb70e1df..413d5d21 100644 --- a/lib/src/api/models/action/delay_action.dart +++ b/lib/src/api/models/action/delay_action.dart @@ -13,19 +13,33 @@ class DelayAction extends ActionModel with Equatable, SerializableMixin { /// How long to wait, in milliseconds. final int milliseconds; + /// A variable or formula giving the wait in milliseconds, e.g. + /// `${= networkMode == 'slow' ? 4000 : 900}`. When it gives a number, it is + /// used instead of [milliseconds]. + final String? duration; + /// Creates a [DelayAction]. - DelayAction({this.milliseconds = 500, super.nonBlocking, super.enabled}) - : super(type: ActionType.delay); + DelayAction({ + this.milliseconds = 500, + this.duration, + super.nonBlocking, + super.enabled, + }) : super(type: ActionType.delay); /// Duplicates this action with the given values replaced. - DelayAction copyWith({int? milliseconds}) => DelayAction( + DelayAction copyWith({ + int? milliseconds, + String? duration, + bool clearDuration = false, + }) => DelayAction( milliseconds: milliseconds ?? this.milliseconds, + duration: clearDuration ? null : duration ?? this.duration, nonBlocking: nonBlocking, enabled: enabled, ); @override - List get props => [milliseconds]; + List get props => [milliseconds, duration]; /// Creates an instance from a JSON object. factory DelayAction.fromJson(Map json) => _$DelayActionFromJson(json); diff --git a/lib/src/api/models/action/delay_action.g.dart b/lib/src/api/models/action/delay_action.g.dart index 3e064b68..c7ba0fa1 100644 --- a/lib/src/api/models/action/delay_action.g.dart +++ b/lib/src/api/models/action/delay_action.g.dart @@ -22,6 +22,7 @@ Map _$DelayActionOmitDefaults( DelayAction _$DelayActionFromJson(Map json) => DelayAction( milliseconds: (json['milliseconds'] as num?)?.toInt() ?? 500, + duration: json['duration'] as String?, nonBlocking: json['nonBlocking'] as bool? ?? false, enabled: json['enabled'] as bool? ?? true, )..type = $enumDecode(_$ActionTypeEnumMap, json['type']); @@ -32,6 +33,7 @@ Map _$DelayActionToJson(DelayAction instance) => 'nonBlocking': instance.nonBlocking, 'enabled': instance.enabled, 'milliseconds': instance.milliseconds, + 'duration': ?instance.duration, }; const _$ActionTypeEnumMap = { diff --git a/test/delay_duration_test.dart b/test/delay_duration_test.dart new file mode 100644 index 00000000..d3b51b09 --- /dev/null +++ b/test/delay_duration_test.dart @@ -0,0 +1,26 @@ +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:test/test.dart'; + +void main() { + test('a delay keeps its duration formula through JSON', () { + final DelayAction action = DelayAction( + milliseconds: 900, + duration: r"${= networkMode == 'slow' ? 4000 : 900}", + ); + final DelayAction read = + ActionModel.fromJson(action.toJson()) as DelayAction; + expect(read.duration, action.duration); + expect(read.milliseconds, 900); + }); + + test('a delay without a formula leaves duration out of its JSON', () { + expect( + DelayAction(milliseconds: 300).toJson().containsKey('duration'), + isFalse, + ); + expect( + DelayAction(duration: 'x').copyWith(clearDuration: true).duration, + isNull, + ); + }); +} From 542192b5b7f90986f6ae6bce6e36e9c1b12befd4 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 23:26:50 +0000 Subject: [PATCH 7/9] codelessly_api 6.2.0 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011fXBKiHRftTsgsVPHEZAWz --- CHANGELOG.md | 28 ++++++++++++++++++++++++++++ pubspec.yaml | 2 +- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6cb1e325..ef1b8dfd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,31 @@ +## 6.2.0 + +### Paint and widgets + +- `PaintModel.gradientRepeatEvery`: a linear gradient repeats every N px + instead of stretching across the box (hazard stripes, tick patterns). +- Progress bars take `shape: arc`: a gauge with start and sweep angles, + stroke width, tick count and color, a needle and colored zone `bands` + (`ProgressBand`). + +### Actions + +- `ShowDialogAction.presentation` (dialog, bottom sheet, or adaptive: a + sheet below 600 px) and `alignment` for where a dialog opens. +- `DelayAction.duration`: a variable or formula for the wait, used instead + of `milliseconds` when it gives a number. + +### Data + +- A variable whose value is a `${= ...}` formula keeps it (computed + variables) instead of having it sanitized away. + +### JSON + +- List, grid, page and tab views and spacers parse from minimal node JSON: + fields with constructor defaults (`isScrollable`, `useFlutterListView`, + `allowedTypes`, `deniedTypes`, ...) may be left out. + ## 6.1.0 ### States and conditions diff --git a/pubspec.yaml b/pubspec.yaml index 93e60aa4..4efa4b73 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: codelessly_api description: Flutter's layouts and widgets represented as models and JSON data. This API is used by the Codelessly Editor and CloudUI SDK to render Flutter Widgets. -version: 6.1.0 +version: 6.2.0 homepage: https://codelessly.com/ repository: https://github.com/Codelessly/CodelesslyAPI From 793635bdc41d1cd88e550f376ce5ebf11c0e4aee Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 23:52:59 +0000 Subject: [PATCH 8/9] Format the row/column wrap test Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011fXBKiHRftTsgsVPHEZAWz --- test/row_column_wrap_test.dart | 27 +++++++++++++-------------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/test/row_column_wrap_test.dart b/test/row_column_wrap_test.dart index ded54921..95c7d297 100644 --- a/test/row_column_wrap_test.dart +++ b/test/row_column_wrap_test.dart @@ -3,17 +3,16 @@ import 'dart:convert'; import 'package:codelessly_api/codelessly_api.dart'; import 'package:test/test.dart'; -RowColumnNode _row({bool wrap = false, double runSpacing = 0}) => - RowColumnNode( - id: 'row', - name: 'Row', - basicBoxLocal: NodeBox(0, 0, 330, 100), - children: const ['a', 'b', 'c'], - rowColumnType: RowColumnType.row, - spacing: 8, - wrap: wrap, - runSpacing: runSpacing, - ); +RowColumnNode _row({bool wrap = false, double runSpacing = 0}) => RowColumnNode( + id: 'row', + name: 'Row', + basicBoxLocal: NodeBox(0, 0, 330, 100), + children: const ['a', 'b', 'c'], + rowColumnType: RowColumnType.row, + spacing: 8, + wrap: wrap, + runSpacing: runSpacing, +); void main() { group('RowColumnNode wrap', () { @@ -27,9 +26,9 @@ void main() { }); test('round-trips wrap and runSpacing', () { - final json = - jsonDecode(jsonEncode(_row(wrap: true, runSpacing: 12).toJson())) - as Map; + final json = jsonDecode( + jsonEncode(_row(wrap: true, runSpacing: 12).toJson()), + ) as Map; expect(json['wrap'], isTrue); expect(json['runSpacing'], 12); final decoded = RowColumnNode.fromJson(json); From d06c7fde076383531ffff330be007129da93eb7f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 00:04:28 +0000 Subject: [PATCH 9/9] Regenerate expansion_tile_node.g.dart CI's generated-code check builds it with reactions first; master's copy was stale. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011fXBKiHRftTsgsVPHEZAWz --- lib/src/api/nodes/expansion_tile_node.g.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/src/api/nodes/expansion_tile_node.g.dart b/lib/src/api/nodes/expansion_tile_node.g.dart index e2a4ca38..649183fe 100644 --- a/lib/src/api/nodes/expansion_tile_node.g.dart +++ b/lib/src/api/nodes/expansion_tile_node.g.dart @@ -216,6 +216,7 @@ ExpansionTileNode _$ExpansionTileNodeFromJson(Map json) => Map _$ExpansionTileNodeToJson( ExpansionTileNode instance, ) => { + 'reactions': instance.reactions.map((e) => e.toJson()).toList(), 'variables': instance.variables, 'multipleVariables': instance.multipleVariables, 'componentId': ?instance.componentId, @@ -233,7 +234,6 @@ Map _$ExpansionTileNodeToJson( 'flex': instance.flex, 'aspectRatioLock': instance.aspectRatioLock, 'alignment': instance.alignment.toJson(), - 'reactions': instance.reactions.map((e) => e.toJson()).toList(), 'basicBoxLocal': instance.basicBoxLocal.toJson(), 'margin': instance.margin.toJson(), 'padding': instance.padding.toJson(),