From ace08f3de06275c6b045442c353d5240dc246bef Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 22:34:24 +0000 Subject: [PATCH 1/9] Let SpacerNode JSON leave out base fields Its constructor didn't take positioningMode, aspectRatioLock or reactions, so the generated fromJson cast them as required and a spacer written without them (as other nodes allow) failed to parse. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0147gqWiq3xoEdAGwWSpq9JK --- CHANGELOG.md | 6 ++++ lib/src/api/nodes/spacer_node.dart | 10 +++++- lib/src/api/nodes/spacer_node.g.dart | 35 ++++++++++++------- test/minimal_node_json_test.dart | 52 ++++++++++++++++++++++++++++ 4 files changed, 89 insertions(+), 14 deletions(-) create mode 100644 test/minimal_node_json_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 6cb1e325..20b6147a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## Unreleased + +- `SpacerNode` parses JSON without `positioningMode`, `aspectRatioLock` or + `reactions`, like other nodes. Its constructor didn't take them, so the + generated `fromJson` required them. + ## 6.1.0 ### States and conditions diff --git a/lib/src/api/nodes/spacer_node.dart b/lib/src/api/nodes/spacer_node.dart index 7310bb09..4f232b0b 100644 --- a/lib/src/api/nodes/spacer_node.dart +++ b/lib/src/api/nodes/spacer_node.dart @@ -41,7 +41,15 @@ class SpacerNode extends SceneNode { super.edgePins, super.parentID, super.constraints, - }); + super.aspectRatioLock, + super.positioningMode, + List reactions = const [], + }) { + // Without these the generated fromJson required them in the JSON, so + // a spacer written without positioningMode, aspectRatioLock or + // reactions (as other nodes allow) failed to parse. + setReactionMixin([...reactions]); + } /// Creates a new [SpacerNode] from a JSON data. factory SpacerNode.fromJson(Map json) => _$SpacerNodeFromJson(json); diff --git a/lib/src/api/nodes/spacer_node.g.dart b/lib/src/api/nodes/spacer_node.g.dart index 9d7b529c..6fd5346c 100644 --- a/lib/src/api/nodes/spacer_node.g.dart +++ b/lib/src/api/nodes/spacer_node.g.dart @@ -16,11 +16,15 @@ Map _$SpacerNodeOmitDefaults( json.remove('constraints'); if (isJsonDefault(instance.edgePins, EdgePinsModel.standard)) json.remove('edgePins'); + if (isJsonDefault(instance.positioningMode, PositioningMode.align)) + json.remove('positioningMode'); if (isJsonDefault(instance.horizontalFit, SizeFit.fixed)) json.remove('horizontalFit'); if (isJsonDefault(instance.verticalFit, SizeFit.fixed)) json.remove('verticalFit'); if (isJsonDefault(instance.flex, 1)) json.remove('flex'); + if (isJsonDefault(instance.aspectRatioLock, false)) + json.remove('aspectRatioLock'); if (isJsonDefault(instance.interactionStates, {})) json.remove('interactionStates'); if (isJsonDefault(instance.tooltip, '')) json.remove('tooltip'); @@ -33,6 +37,7 @@ Map _$SpacerNodeOmitDefaults( json.remove('componentVersion'); if (isJsonDefault(instance.componentSchema, {})) json.remove('componentSchema'); + if (isJsonDefault(instance.reactions, const [])) json.remove('reactions'); return json; } @@ -62,6 +67,18 @@ SpacerNode _$SpacerNodeFromJson(Map json) => constraints: json['constraints'] == null ? const BoxConstraintsModel() : BoxConstraintsModel.fromJson(json['constraints'] as Map), + aspectRatioLock: json['aspectRatioLock'] as bool? ?? false, + positioningMode: + $enumDecodeNullable( + _$PositioningModeEnumMap, + json['positioningMode'], + ) ?? + PositioningMode.align, + reactions: + (json['reactions'] as List?) + ?.map((e) => Reaction.fromJson(e as Map)) + .toList() ?? + const [], ) ..variables = (json['variables'] as Map?)?.map( @@ -87,14 +104,6 @@ SpacerNode _$SpacerNodeFromJson(Map json) => (k, e) => MapEntry(k as String, e), ) ?? {} - ..positioningMode = $enumDecode( - _$PositioningModeEnumMap, - json['positioningMode'], - ) - ..aspectRatioLock = json['aspectRatioLock'] as bool - ..reactions = (json['reactions'] as List) - .map((e) => Reaction.fromJson(e as Map)) - .toList() ..widthFactor = (json['widthFactor'] as num?)?.toDouble() ..heightFactor = (json['heightFactor'] as num?)?.toDouble() ..interactionStates = @@ -151,16 +160,16 @@ const _$SizeFitEnumMap = { SizeFit.shrinkWrap: 'shrinkWrap', }; -const _$ComponentMarkerTypeEnumMap = { - ComponentMarkerType.component: 'component', - ComponentMarkerType.instance: 'instance', -}; - const _$PositioningModeEnumMap = { PositioningMode.align: 'align', PositioningMode.pin: 'pin', }; +const _$ComponentMarkerTypeEnumMap = { + ComponentMarkerType.component: 'component', + ComponentMarkerType.instance: 'instance', +}; + const _$InteractionStateEnumMap = { InteractionState.hovered: 'hovered', InteractionState.focused: 'focused', diff --git a/test/minimal_node_json_test.dart b/test/minimal_node_json_test.dart new file mode 100644 index 00000000..bb6b3be2 --- /dev/null +++ b/test/minimal_node_json_test.dart @@ -0,0 +1,52 @@ +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:test/test.dart'; + +/// Layout and widget nodes parse from JSON that leaves out the base node fields +/// (positioning mode, aspect ratio lock, reactions, fits, pins, ...), as +/// tools and older documents write it. Only what the type itself needs is +/// given: its properties, a text's characters, a container's children, a +/// row or column's direction. +const List _types = [ + 'frame', + 'text', + 'rectangle', + 'spacer', + 'rowColumn', + 'autoPlaceholder', + 'freeformPlaceholder', + 'button', + 'textField', + 'checkbox', + 'switch', + 'radio', + 'accordion', + 'listTile', + 'divider', + 'progressBar', + 'badge', + 'chip', + 'segmentedButton', + 'floatingActionButton', + 'external', +]; + +void main() { + for (final String type in _types) { + test('$type parses without base node fields', () { + final BaseNode? node = NodeJsonConverter().fromJson({ + 'id': 'n', + 'name': type, + 'type': type, + 'basicBoxLocal': [0, 0, 10, 10], + 'properties': {}, + 'characters': '', + 'children': [], + 'rowColumnType': 'column', + }); + expect(node?.type, type); + expect(node!.positioningMode, isNotNull); + // And what it writes back parses the same way. + expect(NodeJsonConverter().fromJson(node.toJson())?.type, type); + }); + } +} From d7a0b58cca17ccd3b7c7d5873e11b965ddc76a9d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 23:12:36 +0000 Subject: [PATCH 2/9] Let icon nodes bind their color and which icon they show IconNode lists iconColor (already read by the runtime) and iconName among its property variables, so the editor can bind them and conditions and actions can set them. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0147gqWiq3xoEdAGwWSpq9JK --- CHANGELOG.md | 3 +++ lib/src/api/nodes/icon_node.dart | 11 +++++++++++ 2 files changed, 14 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 20b6147a..9e2e3d39 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ ## Unreleased +- `IconNode` declares `iconColor` and `iconName` as properties that can be + bound, set by conditions or changed by actions; `iconName` names the + Material Symbol to show. - `SpacerNode` parses JSON without `positioningMode`, `aspectRatioLock` or `reactions`, like other nodes. Its constructor didn't take them, so the generated `fromJson` required them. diff --git a/lib/src/api/nodes/icon_node.dart b/lib/src/api/nodes/icon_node.dart index c217b72d..b7efa4d2 100644 --- a/lib/src/api/nodes/icon_node.dart +++ b/lib/src/api/nodes/icon_node.dart @@ -106,6 +106,17 @@ class IconNode extends SceneNode TriggerType.longPress, ]; + /// Besides the shared style properties, an icon's color and which icon it + /// shows can be bound, set by a condition or changed by an action: + /// `iconName` is a Material Symbols name such as `wb_sunny` (the node's + /// style is kept), so one icon node can show the icon its data asks for. + @override + late final List propertyVariables = [ + ...super.propertyVariables, + ColorValue(name: 'iconColor', value: properties.icon.color), + StringValue(name: 'iconName', value: properties.icon.icon?.name ?? ''), + ]; + /// Creates an [IconNode] from a JSON data. factory IconNode.fromJson(Map json) { // backwards compatibility: before properties were added to IconNode. From 6a7269bbb89752c293f87276a74b82d4955d84c2 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 23:16:54 +0000 Subject: [PATCH 3/9] Let every node bind its flex Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0147gqWiq3xoEdAGwWSpq9JK --- CHANGELOG.md | 2 ++ lib/src/api/mixins.dart | 7 +++++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e2e3d39..db4b792d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ ## Unreleased +- `flex` is one of the properties every node can bind, set by a condition + or change with an action. - `IconNode` declares `iconColor` and `iconName` as properties that can be bound, set by conditions or changed by actions; `iconName` names the Material Symbol to show. diff --git a/lib/src/api/mixins.dart b/lib/src/api/mixins.dart index b182ef92..83942320 100644 --- a/lib/src/api/mixins.dart +++ b/lib/src/api/mixins.dart @@ -1045,8 +1045,8 @@ mixin PropertyVariableMixin on BaseNode { /// Properties of the node that can be used in actions or can be changed by /// an action. /// - /// Style properties (fill, stroke, opacity, width, height; a text's fill is - /// its color) can be set by + /// Style properties (fill, stroke, opacity, width, height, flex; a text's + /// fill is its color) can be set by /// conditions, bound to variables or formulas, or changed by a set value /// action, on any node that has them. late final List propertyVariables = [ @@ -1065,6 +1065,9 @@ mixin PropertyVariableMixin on BaseNode { DoubleValue(name: 'opacity', value: blend.opacity), DoubleValue(name: 'width', value: basicBoxLocal.width), DoubleValue(name: 'height', value: basicBoxLocal.height), + // A child's share of its row or column's free space when it expands, + // e.g. the bars of a gauge sized by data. + IntValue(name: 'flex', value: flex), ]; } From 2a2c463127f7fcbafa52c1951e30528343d8f672 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 23:21:07 +0000 Subject: [PATCH 4/9] Show dialog actions can present as a bottom sheet or full screen DialogPresentation picks a dialog, a bottom sheet, a sheet on mobile and a dialog elsewhere, or a full-screen page. Older actions read as dialogs and leave the field out of their JSON. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0147gqWiq3xoEdAGwWSpq9JK --- CHANGELOG.md | 3 +++ .../api/models/action/show_dialog_action.dart | 27 ++++++++++++++++++- .../models/action/show_dialog_action.g.dart | 13 +++++++++ test/dialog_presentation_test.dart | 24 +++++++++++++++++ 4 files changed, 66 insertions(+), 1 deletion(-) create mode 100644 test/dialog_presentation_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index db4b792d..ebd82b42 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ ## Unreleased +- `ShowDialogAction.presentation` (`DialogPresentation`): a dialog (the + default), a bottom sheet, `adaptive` (a sheet below 600 wide, a dialog + above), or full screen. - `flex` is one of the properties every node can bind, set by a condition or change with an action. - `IconNode` declares `iconColor` and `iconName` as properties that can be diff --git a/lib/src/api/models/action/show_dialog_action.dart b/lib/src/api/models/action/show_dialog_action.dart index 80bfdd32..7f256075 100644 --- a/lib/src/api/models/action/show_dialog_action.dart +++ b/lib/src/api/models/action/show_dialog_action.dart @@ -6,7 +6,24 @@ import '../models.dart'; part 'show_dialog_action.g.dart'; -/// An action that navigates to a new page. +/// How a [ShowDialogAction] presents its canvas. +enum DialogPresentation { + /// Centered over the page at the canvas's size (its content's height when + /// the canvas shrink-wraps vertically). + dialog, + + /// Slides up from the bottom, as wide as the screen and as tall as its + /// content (up to 90% of the screen), with rounded top corners. + bottomSheet, + + /// A bottom sheet on mobile widths (below 600), a dialog on wider screens. + adaptive, + + /// Covers the whole screen, like a photo viewer. + fullScreen, +} + +/// An action that shows a canvas over the current page. @JsonSerializable() class ShowDialogAction extends ActionModel with Equatable, SerializableMixin { /// Destination canvas node's ID. @@ -24,6 +41,10 @@ class ShowDialogAction extends ActionModel with Equatable, SerializableMixin { /// Whether to show a close button on the dialog. final bool showCloseButton; + /// How the canvas is presented: a dialog, a bottom sheet, either by screen + /// width, or full screen. + final DialogPresentation presentation; + /// Creates a new [ShowDialogAction]. ShowDialogAction({ required this.destinationId, @@ -31,6 +52,7 @@ class ShowDialogAction extends ActionModel with Equatable, SerializableMixin { this.barrierColor = ColorRGBA.black54, this.barrierDismissible = true, this.showCloseButton = false, + this.presentation = DialogPresentation.dialog, super.nonBlocking, super.enabled, }) : params = {...params ?? {}}, @@ -43,6 +65,7 @@ class ShowDialogAction extends ActionModel with Equatable, SerializableMixin { barrierDismissible, barrierColor, showCloseButton, + presentation, ]; /// Duplicates this [ShowDialogAction] with given data overrides. @@ -52,12 +75,14 @@ class ShowDialogAction extends ActionModel with Equatable, SerializableMixin { bool? barrierDismissible, ColorRGBA? barrierColor, bool? showCloseButton, + DialogPresentation? presentation, }) => 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, ); /// Creates a new [ShowDialogAction] instance from a JSON data. 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..53c38057 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,8 @@ 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.nonBlocking, false)) json.remove('nonBlocking'); if (isJsonDefault(instance.enabled, true)) json.remove('enabled'); return json; @@ -35,6 +37,9 @@ 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']) ?? + DialogPresentation.dialog, nonBlocking: json['nonBlocking'] as bool? ?? false, enabled: json['enabled'] as bool? ?? true, )..type = $enumDecode(_$ActionTypeEnumMap, json['type']); @@ -49,8 +54,16 @@ Map _$ShowDialogActionToJson(ShowDialogAction instance) => 'barrierDismissible': instance.barrierDismissible, 'barrierColor': ?instance.barrierColor?.toJson(), 'showCloseButton': instance.showCloseButton, + 'presentation': _$DialogPresentationEnumMap[instance.presentation]!, }; +const _$DialogPresentationEnumMap = { + DialogPresentation.dialog: 'dialog', + DialogPresentation.bottomSheet: 'bottomSheet', + DialogPresentation.adaptive: 'adaptive', + DialogPresentation.fullScreen: 'fullScreen', +}; + const _$ActionTypeEnumMap = { ActionType.navigation: 'navigation', ActionType.showDialog: 'showDialog', diff --git a/test/dialog_presentation_test.dart b/test/dialog_presentation_test.dart new file mode 100644 index 00000000..9fc374d6 --- /dev/null +++ b/test/dialog_presentation_test.dart @@ -0,0 +1,24 @@ +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:test/test.dart'; + +void main() { + test('a dialog action keeps its presentation through JSON', () { + for (final DialogPresentation presentation in DialogPresentation.values) { + final ShowDialogAction action = ShowDialogAction( + destinationId: 'sheet', + presentation: presentation, + ); + final ShowDialogAction read = ActionModel.fromJson(action.toJson()) as ShowDialogAction; + expect(read.presentation, presentation); + } + }); + + test('actions written before presentations existed open as dialogs', () { + final ShowDialogAction read = ActionModel.fromJson({ + 'type': 'showDialog', + 'destinationId': 'old', + }) as ShowDialogAction; + expect(read.presentation, DialogPresentation.dialog); + expect(read.toJson().containsKey('presentation'), isFalse); + }); +} From 97c6734209875dc71734bd584efa05f6b0f10703 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 02:41:50 +0000 Subject: [PATCH 5/9] Add a signature node and a hold-to-confirm trigger SignatureNode stores strokes as normalized [x, y] points so a signature replays at any size. TriggerType.hold fires after its duration; rows and columns now declare the triggers they run. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0147gqWiq3xoEdAGwWSpq9JK --- CHANGELOG.md | 8 + lib/src/api/models/trigger.dart | 30 ++- lib/src/api/models/trigger.g.dart | 5 + lib/src/api/node_json_converter.dart | 1 + lib/src/api/nodes/frame_node.dart | 1 + lib/src/api/nodes/nodes.dart | 1 + lib/src/api/nodes/rectangle_node.dart | 1 + lib/src/api/nodes/row_column_node.dart | 9 + lib/src/api/nodes/signature_node.dart | 176 +++++++++++++++++ lib/src/api/nodes/signature_node.g.dart | 247 ++++++++++++++++++++++++ test/json_defaults_test.dart | 1 + 11 files changed, 477 insertions(+), 3 deletions(-) create mode 100644 lib/src/api/nodes/signature_node.dart create mode 100644 lib/src/api/nodes/signature_node.g.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index ebd82b42..c07f8f97 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ ## Unreleased +- `SignatureNode` (`signature`): a box to sign in. Its `value` is a list of + strokes, each a list of `[x, y]` points normalized to the box; `inkColor`, + `strokeWidth` and `readOnly` are in `SignatureProperties`. +- `TriggerType.hold` fires once the target has been held for the trigger's + `duration` (milliseconds, 1200 by default). Frames, rectangles, rows and + columns offer it. +- Rows and columns declare the click and long-press triggers they already + ran, so the actions panel offers them. - `ShowDialogAction.presentation` (`DialogPresentation`): a dialog (the default), a bottom sheet, `adaptive` (a sheet below 600 wide, a dialog above), or full screen. diff --git a/lib/src/api/models/trigger.dart b/lib/src/api/models/trigger.dart index 8b75dbdd..86b721c5 100644 --- a/lib/src/api/models/trigger.dart +++ b/lib/src/api/models/trigger.dart @@ -47,7 +47,13 @@ enum TriggerType { /// Triggered when the variable named by [TriggerModel.variable] changes, /// while the screen it is set on shows. - valueChanged; + valueChanged, + + /// Triggered when the user presses and holds the target for the trigger's + /// [TriggerModel.duration]. Releasing early cancels it. While held, the + /// target's descendants can read the progress, 0 to 1, as `${hold}`, for + /// example to fill a hold-to-confirm button. + hold; /// Displayable string representation of the trigger type. String get prettify { @@ -78,10 +84,16 @@ enum TriggerType { return 'Load'; case TriggerType.valueChanged: return 'Value Changed'; + case TriggerType.hold: + return 'Hold to Confirm'; } } } +/// How long a [TriggerType.hold] trigger is held by default, in +/// milliseconds. +const int kDefaultHoldDuration = 1200; + /// A model representing a trigger event data. @JsonSerializable() class TriggerModel with Equatable, SerializableMixin { @@ -97,21 +109,33 @@ class TriggerModel with Equatable, SerializableMixin { /// For [TriggerType.valueChanged], the name of the variable to watch. final String? variable; + /// How long, in milliseconds, a [TriggerType.hold] trigger must be held + /// before it fires. + @JsonKey(defaultValue: kDefaultHoldDuration) + final int duration; + /// Creates a [TriggerModel] with given data. - TriggerModel({required this.type, this.debounceMs = 0, this.variable}); + TriggerModel({ + required this.type, + this.debounceMs = 0, + this.variable, + this.duration = kDefaultHoldDuration, + }); @override - List get props => [type, debounceMs, variable]; + List get props => [type, debounceMs, variable, duration]; /// Duplicate this [TriggerModel] with given data overrides. TriggerModel copyWith({ TriggerType? type, int? debounceMs, String? variable, + int? duration, }) => TriggerModel( type: type ?? this.type, debounceMs: debounceMs ?? this.debounceMs, variable: variable ?? this.variable, + duration: duration ?? this.duration, ); /// Factory constructor for creating a new [TriggerModel] instance from diff --git a/lib/src/api/models/trigger.g.dart b/lib/src/api/models/trigger.g.dart index d522deee..8fd23525 100644 --- a/lib/src/api/models/trigger.g.dart +++ b/lib/src/api/models/trigger.g.dart @@ -11,6 +11,8 @@ Map _$TriggerModelOmitDefaults( Map json, ) { if (isJsonDefault(instance.debounceMs, 0)) json.remove('debounceMs'); + if (isJsonDefault(instance.duration, kDefaultHoldDuration)) + json.remove('duration'); return json; } @@ -22,6 +24,7 @@ TriggerModel _$TriggerModelFromJson(Map json) => TriggerModel( type: $enumDecode(_$TriggerTypeEnumMap, json['type']), debounceMs: (json['debounceMs'] as num?)?.toInt() ?? 0, variable: json['variable'] as String?, + duration: (json['duration'] as num?)?.toInt() ?? 1200, ); Map _$TriggerModelToJson(TriggerModel instance) => @@ -29,6 +32,7 @@ Map _$TriggerModelToJson(TriggerModel instance) => 'type': _$TriggerTypeEnumMap[instance.type]!, 'debounceMs': instance.debounceMs, 'variable': ?instance.variable, + 'duration': instance.duration, }; const _$TriggerTypeEnumMap = { @@ -45,4 +49,5 @@ const _$TriggerTypeEnumMap = { TriggerType.pointerUp: 'pointerUp', TriggerType.load: 'load', TriggerType.valueChanged: 'valueChanged', + TriggerType.hold: 'hold', }; diff --git a/lib/src/api/node_json_converter.dart b/lib/src/api/node_json_converter.dart index 311acd7c..3bbe50da 100644 --- a/lib/src/api/node_json_converter.dart +++ b/lib/src/api/node_json_converter.dart @@ -57,6 +57,7 @@ class NodeJsonConverter implements JsonConverter { 'switch': SwitchNode.fromJson, 'radio': RadioNode.fromJson, 'slider': SliderNode.fromJson, + 'signature': SignatureNode.fromJson, 'expansionTile': ExpansionTileNode.fromJson, 'accordion': AccordionNode.fromJson, 'listTile': ListTileNode.fromJson, diff --git a/lib/src/api/nodes/frame_node.dart b/lib/src/api/nodes/frame_node.dart index 6bf1a6f9..2cb10c12 100644 --- a/lib/src/api/nodes/frame_node.dart +++ b/lib/src/api/nodes/frame_node.dart @@ -107,6 +107,7 @@ class FrameNode extends DefaultShapeNode List get triggerTypes => [ TriggerType.click, TriggerType.longPress, + TriggerType.hold, ]; /// Creates a [FrameNode] from a JSON data. diff --git a/lib/src/api/nodes/nodes.dart b/lib/src/api/nodes/nodes.dart index 440e5adc..06fd5503 100644 --- a/lib/src/api/nodes/nodes.dart +++ b/lib/src/api/nodes/nodes.dart @@ -31,6 +31,7 @@ export 'row_column_node.dart'; export 'scene_node.dart'; export 'segmented_button_node.dart'; export 'single_placeholder_node.dart'; +export 'signature_node.dart'; export 'slider_node.dart'; export 'spacer_node.dart'; export 'switch_node.dart'; diff --git a/lib/src/api/nodes/rectangle_node.dart b/lib/src/api/nodes/rectangle_node.dart index 4875b198..2e99f1e4 100644 --- a/lib/src/api/nodes/rectangle_node.dart +++ b/lib/src/api/nodes/rectangle_node.dart @@ -69,6 +69,7 @@ class RectangleNode extends DefaultShapeNode with CornerMixin, ClipMixin { List get triggerTypes => [ TriggerType.click, TriggerType.longPress, + TriggerType.hold, ]; @override diff --git a/lib/src/api/nodes/row_column_node.dart b/lib/src/api/nodes/row_column_node.dart index 2094d2f7..e7564962 100644 --- a/lib/src/api/nodes/row_column_node.dart +++ b/lib/src/api/nodes/row_column_node.dart @@ -102,6 +102,15 @@ class RowColumnNode extends ParentNode } /// Creates a [RowColumnNode] from a JSON data. + /// Rows and columns are the usual buttons and cards of a layout, so they + /// take taps, long presses and holds. + @override + List get triggerTypes => [ + TriggerType.click, + TriggerType.longPress, + TriggerType.hold, + ]; + factory RowColumnNode.fromJson(Map json) => _$RowColumnNodeFromJson(json); @override diff --git a/lib/src/api/nodes/signature_node.dart b/lib/src/api/nodes/signature_node.dart new file mode 100644 index 00000000..32e35850 --- /dev/null +++ b/lib/src/api/nodes/signature_node.dart @@ -0,0 +1,176 @@ +import 'package:json_annotation/json_annotation.dart'; + +import '../field_access.dart'; +import '../mixins.dart'; +import '../models/models.dart'; +import 'nodes.dart'; + +part 'signature_node.g.dart'; + +/// Default width of a new [SignatureNode]. +const double kSignatureDefaultWidth = 360; + +/// Default height of a new [SignatureNode]. +const double kSignatureDefaultHeight = 120; + +/// A box the user signs in with a finger, pen or mouse. +/// +/// The signature is a list of strokes, each a list of `[x, y]` points +/// normalized to the box (0 to 1), so it redraws at any size. Bind `value` +/// to a list variable to keep it: drawing writes the variable and fires +/// [TriggerType.changed] with the strokes, and setting the variable (to `[]` +/// to clear it, or to a saved signature to replay it) redraws the box. +/// +/// The box paints only the ink: put it in a frame for a background and +/// border, with a watermark or signing line behind it. +@JsonSerializable() +class SignatureNode extends SceneNode with CustomPropertiesMixin { + @override + final String type = 'signature'; + + /// The strokes shown when `value` isn't bound. + List>> strokes; + + /// The ink and whether the box takes input. + @override + covariant SignatureProperties properties; + + /// Creates a [SignatureNode] with default values. + SignatureNode.empty() + : properties = SignatureProperties(), + strokes = [], + super( + id: 'signature', + name: 'Signature', + basicBoxLocal: NodeBox( + 0, + 0, + kSignatureDefaultWidth, + kSignatureDefaultHeight, + ), + ); + + /// Creates a [SignatureNode] with the given data. + SignatureNode({ + required super.id, + required super.name, + required super.basicBoxLocal, + this.strokes = const [], + super.outerBoxLocal, + super.retainedOuterBoxLocal, + super.visible, + super.enabled, + super.alignment, + super.padding, + super.margin, + super.rotationDegrees, + super.reactions, + super.horizontalFit, + super.verticalFit, + super.flex, + super.aspectRatioLock, + super.constraints, + super.parentID, + super.positioningMode, + super.edgePins, + required this.properties, + super.variables, + super.multipleVariables, + }); + + @override + FieldsMap generateFields() => { + ...super.generateFields(), + 'properties': ObjectFieldAccess( + 'Properties', + 'The ink and whether the box takes input.', + () => properties, + (value) => properties = value, + ), + }; + + @override + List get triggerTypes => [TriggerType.changed]; + + @override + late final List propertyVariables = [ + ...super.propertyVariables, + ObjectValue(name: 'value', value: strokes), + BoolValue(name: 'readOnly', value: properties.readOnly), + ColorValue(name: 'inkColor', value: properties.inkColor), + ]; + + /// Creates a [SignatureNode] from JSON. + factory SignatureNode.fromJson(Map json) => _$SignatureNodeFromJson(json); + + @override + Map toJson() => + _$SignatureNodeOmitDefaults(this, _$SignatureNodeToJson(this)); +} + +/// The ink of a [SignatureNode] and whether it takes input. +@JsonSerializable() +class SignatureProperties extends CustomProperties with FieldsHolder { + /// The ink's color. + ColorRGBA inkColor; + + /// The ink's width in logical pixels. + double strokeWidth; + + /// Whether the signature only shows, for replaying a saved one. + bool readOnly; + + /// Creates [SignatureProperties]. + SignatureProperties({ + this.inkColor = ColorRGBA.black, + this.strokeWidth = 2.6, + this.readOnly = false, + }); + + /// A copy with the given values replaced. + SignatureProperties copyWith({ + ColorRGBA? inkColor, + double? strokeWidth, + bool? readOnly, + }) => SignatureProperties( + inkColor: inkColor ?? this.inkColor, + strokeWidth: strokeWidth ?? this.strokeWidth, + readOnly: readOnly ?? this.readOnly, + ); + + @override + FieldsMap generateFields() => { + ...super.generateFields(), + 'inkColor': ColorFieldAccess( + 'Ink color', + 'The color of the ink.', + () => inkColor, + (value) => inkColor = value, + ), + 'strokeWidth': NumFieldAccess( + 'Ink width', + 'The width of the ink in logical pixels.', + () => strokeWidth, + (double value) => strokeWidth = value, + ), + 'readOnly': BoolFieldAccess( + 'Read only', + 'Whether the signature only shows, for replaying a saved one.', + () => readOnly, + (value) => readOnly = value, + ), + }; + + @override + List get props => [inkColor, strokeWidth, readOnly]; + + /// Creates [SignatureProperties] from JSON. + factory SignatureProperties.fromJson(Map json) => + _$SignaturePropertiesFromJson(json); + + @override + Map toJson() => _$SignaturePropertiesOmitDefaults( + this, + _$SignaturePropertiesToJson(this), + ); +} diff --git a/lib/src/api/nodes/signature_node.g.dart b/lib/src/api/nodes/signature_node.g.dart new file mode 100644 index 00000000..79c1b6bb --- /dev/null +++ b/lib/src/api/nodes/signature_node.g.dart @@ -0,0 +1,247 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'signature_node.dart'; + +// ************************************************************************** +// JsonDefaultsGenerator +// ************************************************************************** + +Map _$SignatureNodeOmitDefaults( + SignatureNode instance, + Map json, +) { + if (isJsonDefault(instance.strokes, const [])) json.remove('strokes'); + if (isJsonDefault(instance.visible, true)) json.remove('visible'); + if (excludeConstraintsIf(instance)) json.remove('constraints'); + if (isJsonDefault(instance.constraints, const BoxConstraintsModel())) + json.remove('constraints'); + if (isJsonDefault(instance.edgePins, EdgePinsModel.standard)) + json.remove('edgePins'); + if (isJsonDefault(instance.positioningMode, PositioningMode.align)) + json.remove('positioningMode'); + if (isJsonDefault(instance.horizontalFit, SizeFit.fixed)) + json.remove('horizontalFit'); + if (isJsonDefault(instance.verticalFit, SizeFit.fixed)) + json.remove('verticalFit'); + if (isJsonDefault(instance.flex, 1)) json.remove('flex'); + if (isJsonDefault(instance.aspectRatioLock, false)) + json.remove('aspectRatioLock'); + if (isJsonDefault(instance.alignment, AlignmentModel.none)) + json.remove('alignment'); + if (isJsonDefault(instance.margin, EdgeInsetsModel.zero)) + json.remove('margin'); + if (isJsonDefault(instance.padding, EdgeInsetsModel.zero)) + json.remove('padding'); + if (isJsonDefault(instance.rotationDegrees, 0)) json.remove('rotation'); + if (isJsonDefault(instance.interactionStates, {})) + json.remove('interactionStates'); + if (isJsonDefault(instance.tooltip, '')) json.remove('tooltip'); + if (isJsonDefault(instance.zoomable, false)) json.remove('zoomable'); + if (isJsonDefault(instance.enabled, true)) json.remove('enabled'); + if (isJsonDefault(instance.variables, {})) json.remove('variables'); + if (isJsonDefault(instance.multipleVariables, {})) + json.remove('multipleVariables'); + if (isJsonDefault(instance.componentVersion, 1)) + json.remove('componentVersion'); + if (isJsonDefault(instance.componentSchema, {})) + json.remove('componentSchema'); + if (isJsonDefault(instance.reactions, const [])) json.remove('reactions'); + return json; +} + +Map _$SignaturePropertiesOmitDefaults( + SignatureProperties instance, + Map json, +) { + if (isJsonDefault(instance.inkColor, ColorRGBA.black)) + json.remove('inkColor'); + if (isJsonDefault(instance.strokeWidth, 2.6)) json.remove('strokeWidth'); + if (isJsonDefault(instance.readOnly, false)) json.remove('readOnly'); + return json; +} + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +SignatureNode _$SignatureNodeFromJson(Map json) => + SignatureNode( + id: json['id'] as String, + name: json['name'] as String, + basicBoxLocal: NodeBox.fromJson(json['basicBoxLocal']), + strokes: + (json['strokes'] as List?) + ?.map( + (e) => (e as List) + .map( + (e) => (e as List) + .map((e) => (e as num).toDouble()) + .toList(), + ) + .toList(), + ) + .toList() ?? + const [], + visible: json['visible'] as bool? ?? true, + enabled: json['enabled'] as bool? ?? true, + alignment: json['alignment'] == null + ? AlignmentModel.none + : AlignmentModel.fromJson(json['alignment'] as Map), + padding: json['padding'] == null + ? EdgeInsetsModel.zero + : EdgeInsetsModel.fromJson(json['padding']), + margin: json['margin'] == null + ? EdgeInsetsModel.zero + : EdgeInsetsModel.fromJson(json['margin']), + rotationDegrees: json['rotation'] == null + ? 0 + : castRotation(json['rotation']), + reactions: + (json['reactions'] as List?) + ?.map((e) => Reaction.fromJson(e as Map)) + .toList() ?? + const [], + horizontalFit: + $enumDecodeNullable(_$SizeFitEnumMap, json['horizontalFit']) ?? + SizeFit.fixed, + verticalFit: + $enumDecodeNullable(_$SizeFitEnumMap, json['verticalFit']) ?? + SizeFit.fixed, + flex: (json['flex'] as num?)?.toInt() ?? 1, + aspectRatioLock: json['aspectRatioLock'] as bool? ?? false, + constraints: json['constraints'] == null + ? const BoxConstraintsModel() + : BoxConstraintsModel.fromJson(json['constraints'] as Map), + positioningMode: + $enumDecodeNullable( + _$PositioningModeEnumMap, + json['positioningMode'], + ) ?? + PositioningMode.align, + edgePins: json['edgePins'] == null + ? EdgePinsModel.standard + : EdgePinsModel.fromJson( + Map.from(json['edgePins'] as Map), + ), + properties: SignatureProperties.fromJson(json['properties'] as Map), + variables: + (json['variables'] as Map?)?.map( + (k, e) => MapEntry(k as String, e as String), + ) ?? + {}, + multipleVariables: + (json['multipleVariables'] as Map?)?.map( + (k, e) => MapEntry( + k as String, + (e as List).map((e) => e as String).toList(), + ), + ) ?? + {}, + ) + ..componentId = json['componentId'] as String? + ..markerType = $enumDecodeNullable( + _$ComponentMarkerTypeEnumMap, + json['markerType'], + ) + ..componentVersion = (json['componentVersion'] as num?)?.toInt() ?? 1 + ..componentSchema = + (json['componentSchema'] as Map?)?.map( + (k, e) => MapEntry(k as String, e), + ) ?? + {} + ..widthFactor = (json['widthFactor'] as num?)?.toDouble() + ..heightFactor = (json['heightFactor'] as num?)?.toDouble() + ..interactionStates = + (json['interactionStates'] as Map?)?.map( + (k, e) => MapEntry( + $enumDecode(_$InteractionStateEnumMap, k), + (e as Map).map( + (k, e) => + MapEntry(k as String, Map.from(e as Map)), + ), + ), + ) ?? + {} + ..tooltip = json['tooltip'] as String? ?? '' + ..zoomable = json['zoomable'] as bool? ?? false + ..type = json['type'] as String; + +Map _$SignatureNodeToJson(SignatureNode instance) => + { + 'variables': instance.variables, + 'multipleVariables': instance.multipleVariables, + 'componentId': ?instance.componentId, + 'markerType': ?_$ComponentMarkerTypeEnumMap[instance.markerType], + 'componentVersion': instance.componentVersion, + 'componentSchema': instance.componentSchema, + 'id': instance.id, + 'name': instance.name, + 'visible': instance.visible, + 'constraints': instance.constraints.toJson(), + 'edgePins': instance.edgePins.toJson(), + 'positioningMode': _$PositioningModeEnumMap[instance.positioningMode]!, + 'horizontalFit': _$SizeFitEnumMap[instance.horizontalFit]!, + 'verticalFit': _$SizeFitEnumMap[instance.verticalFit]!, + '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(), + 'rotation': instance.rotationDegrees, + 'widthFactor': ?instance.widthFactor, + 'heightFactor': ?instance.heightFactor, + 'interactionStates': instance.interactionStates.map( + (k, e) => MapEntry(_$InteractionStateEnumMap[k]!, e), + ), + 'tooltip': instance.tooltip, + 'zoomable': instance.zoomable, + 'enabled': instance.enabled, + 'type': instance.type, + 'strokes': instance.strokes, + 'properties': instance.properties.toJson(), + }; + +const _$SizeFitEnumMap = { + SizeFit.locked: 'locked', + SizeFit.fixed: 'fixed', + SizeFit.expanded: 'expanded', + SizeFit.flexible: 'flexible', + SizeFit.shrinkWrap: 'shrinkWrap', +}; + +const _$PositioningModeEnumMap = { + PositioningMode.align: 'align', + PositioningMode.pin: 'pin', +}; + +const _$ComponentMarkerTypeEnumMap = { + ComponentMarkerType.component: 'component', + ComponentMarkerType.instance: 'instance', +}; + +const _$InteractionStateEnumMap = { + InteractionState.hovered: 'hovered', + InteractionState.focused: 'focused', + InteractionState.pressed: 'pressed', + InteractionState.selected: 'selected', + InteractionState.disabled: 'disabled', +}; + +SignatureProperties _$SignaturePropertiesFromJson(Map json) => + SignatureProperties( + inkColor: json['inkColor'] == null + ? ColorRGBA.black + : ColorRGBA.fromJson(json['inkColor']), + strokeWidth: (json['strokeWidth'] as num?)?.toDouble() ?? 2.6, + readOnly: json['readOnly'] as bool? ?? false, + ); + +Map _$SignaturePropertiesToJson( + SignatureProperties instance, +) => { + 'inkColor': instance.inkColor.toJson(), + 'strokeWidth': instance.strokeWidth, + 'readOnly': instance.readOnly, +}; diff --git a/test/json_defaults_test.dart b/test/json_defaults_test.dart index bb2e1328..1aa7d4f3 100644 --- a/test/json_defaults_test.dart +++ b/test/json_defaults_test.dart @@ -180,6 +180,7 @@ List sampleNodes() { RadioNode.empty(), SegmentedButtonNode.empty(), SliderNode.empty(), + SignatureNode.empty(), SwitchNode.empty(), for (final json in [ _node('root', {'children': []}), From fd4db84edbac10a8c5ef6a9793474b3138eac2c3 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 02:48:28 +0000 Subject: [PATCH 6/9] Read a navigate-back action without a destination Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0147gqWiq3xoEdAGwWSpq9JK --- CHANGELOG.md | 3 +++ lib/src/api/models/action/navigation_action.dart | 7 ++++++- .../api/models/action/navigation_action.g.dart | 2 +- test/navigation_pop_json_test.dart | 15 +++++++++++++++ 4 files changed, 25 insertions(+), 2 deletions(-) create mode 100644 test/navigation_pop_json_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index c07f8f97..021e88b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ ## Unreleased +- A navigate-back (`pop`) action reads from JSON without `destinationId`, + which it has no use for. It is still written, as `''`, for readers that + require it. - `SignatureNode` (`signature`): a box to sign in. Its `value` is a list of strokes, each a list of `[x, y]` points normalized to the box; `inkColor`, `strokeWidth` and `readOnly` are in `SignatureProperties`. diff --git a/lib/src/api/models/action/navigation_action.dart b/lib/src/api/models/action/navigation_action.dart index b74cd1ba..e8f92819 100644 --- a/lib/src/api/models/action/navigation_action.dart +++ b/lib/src/api/models/action/navigation_action.dart @@ -32,13 +32,18 @@ enum NavigationType { } } +Object? _readDestinationId(Map json, String key) => json[key] ?? ''; + /// An action that navigates to a new page. @JsonSerializable() class NavigationAction extends ActionModel with Equatable, SerializableMixin { /// Defines how the navigation should be performed. final NavigationType navigationType; - /// Destination canvas node's ID. + /// Destination canvas node's ID. Empty for [NavigationType.pop], which + /// has no destination, and still written so older readers, which require + /// it, can read the action. + @JsonKey(readValue: _readDestinationId) final String destinationId; /// Parameters to pass to the destination page. diff --git a/lib/src/api/models/action/navigation_action.g.dart b/lib/src/api/models/action/navigation_action.g.dart index d486f57f..e28e3622 100644 --- a/lib/src/api/models/action/navigation_action.g.dart +++ b/lib/src/api/models/action/navigation_action.g.dart @@ -25,7 +25,7 @@ NavigationAction _$NavigationActionFromJson(Map json) => NavigationAction( navigationType: $enumDecodeNullable(_$NavigationTypeEnumMap, json['navigationType']) ?? NavigationType.push, - destinationId: json['destinationId'] as String, + destinationId: _readDestinationId(json, 'destinationId') as String, params: (json['params'] as Map?)?.map((k, e) => MapEntry(k as String, e)), nonBlocking: json['nonBlocking'] as bool? ?? false, enabled: json['enabled'] as bool? ?? true, diff --git a/test/navigation_pop_json_test.dart b/test/navigation_pop_json_test.dart new file mode 100644 index 00000000..47097de1 --- /dev/null +++ b/test/navigation_pop_json_test.dart @@ -0,0 +1,15 @@ +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:test/test.dart'; + +void main() { + test('a navigate back action reads without a destination', () { + final action = NavigationAction.fromJson({ + 'type': 'navigation', + 'navigationType': 'pop', + }); + expect(action.navigationType, NavigationType.pop); + expect(action.destinationId, ''); + // Still written, for readers that require it. + expect(action.toJson()['destinationId'], ''); + }); +} From f39b9fe2d0854e8528fcdbc7921b1bc72a3db176 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 02:52:57 +0000 Subject: [PATCH 7/9] Bind a node's border weight Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0147gqWiq3xoEdAGwWSpq9JK --- CHANGELOG.md | 1 + lib/src/api/mixins.dart | 6 ++++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 021e88b2..dbb47de3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ ## Unreleased +- `strokeWeight` is a bindable style property of every node with strokes. - A navigate-back (`pop`) action reads from JSON without `destinationId`, which it has no use for. It is still written, as `''`, for readers that require it. diff --git a/lib/src/api/mixins.dart b/lib/src/api/mixins.dart index 83942320..a28c0d45 100644 --- a/lib/src/api/mixins.dart +++ b/lib/src/api/mixins.dart @@ -1045,8 +1045,8 @@ mixin PropertyVariableMixin on BaseNode { /// Properties of the node that can be used in actions or can be changed by /// an action. /// - /// Style properties (fill, stroke, opacity, width, height, flex; a text's - /// fill is its color) can be set by + /// Style properties (fill, stroke, strokeWeight, opacity, width, height, + /// flex; a text's fill is its color) can be set by /// conditions, bound to variables or formulas, or changed by a set value /// action, on any node that has them. late final List propertyVariables = [ @@ -1055,6 +1055,8 @@ mixin PropertyVariableMixin on BaseNode { if (this case final GeometryMixin geometry) ...[ PaintValue(name: 'fill', value: geometry.fills.firstOrNull), PaintValue(name: 'stroke', value: geometry.strokes.firstOrNull), + // A border that thickens when selected or hovered. + DoubleValue(name: 'strokeWeight', value: geometry.strokeWeight), ], if (this case final TextNode text) PaintValue( From 656d56275f5e300cd967de6ec7ffbd28f016343f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 03:29:30 +0000 Subject: [PATCH 8/9] Wrap a row or column's children onto new runs Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0147gqWiq3xoEdAGwWSpq9JK --- CHANGELOG.md | 2 + lib/src/api/nodes/row_column_node.dart | 49 ++++++++++++ lib/src/api/nodes/row_column_node.g.dart | 9 +++ test/json_defaults_test.dart | 12 +++ .../layout/row_column_wrap_json_test.dart | 77 +++++++++++++++++++ 5 files changed, 149 insertions(+) create mode 100644 test/nodes/layout/row_column_wrap_json_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index dbb47de3..ccc404cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ ## Unreleased +- `RowColumnNode.wrap`, `spacing` and `runSpacing`: a row or column flows its + children onto new runs when they don't fit, like Flutter's `Wrap`. - `strokeWeight` is a bindable style property of every node with strokes. - A navigate-back (`pop`) action reads from JSON without `destinationId`, which it has no use for. It is still written, as `''`, for readers that diff --git a/lib/src/api/nodes/row_column_node.dart b/lib/src/api/nodes/row_column_node.dart index e7564962..26f3964c 100644 --- a/lib/src/api/nodes/row_column_node.dart +++ b/lib/src/api/nodes/row_column_node.dart @@ -1,5 +1,6 @@ import 'package:json_annotation/json_annotation.dart'; +import '../field_access.dart'; import '../mixins.dart'; import '../models/models.dart'; @@ -24,6 +25,19 @@ class RowColumnNode extends ParentNode @override set scrollDirection(AxisC value) {} + /// Whether the children wrap onto new runs when they don't fit the main + /// axis, like Flutter's [Wrap] widget, instead of being laid out in a single + /// line. + bool wrap; + + /// The gap between adjacent children along the main axis within a run. + /// Only used when [wrap] is true. + double spacing; + + /// The gap between adjacent runs along the cross axis. + /// Only used when [wrap] is true. + double runSpacing; + /// Creates a [RowColumnNode] with the given data. RowColumnNode({ required super.id, @@ -76,6 +90,9 @@ class RowColumnNode extends ParentNode ScrollViewKeyboardDismissBehaviorC.manual, bool useFlutterListView = false, bool shouldAlwaysScroll = true, + this.wrap = false, + this.spacing = 0, + this.runSpacing = 0, }) { setCornerMixin( cornerRadius: cornerRadius, @@ -111,6 +128,38 @@ class RowColumnNode extends ParentNode TriggerType.hold, ]; + @override + FieldsMap generateFields() => { + ...super.generateFields(), + 'wrap': BoolFieldAccess( + 'Wrap', + 'Whether the children wrap onto new runs when they overflow.', + () => wrap, + (value) => wrap = value, + defaultValue: () => false, + requiresLayout: true, + ), + 'spacing': NumFieldAccess( + 'Spacing', + 'Gap between children within a run when wrapping.', + () => spacing, + (value) => spacing = value, + min: 0, + defaultValue: () => 0, + requiresLayout: true, + ), + 'runSpacing': NumFieldAccess( + 'Run Spacing', + 'Gap between runs when wrapping.', + () => runSpacing, + (value) => runSpacing = value, + min: 0, + defaultValue: () => 0, + requiresLayout: true, + ), + }; + + /// Creates a [RowColumnNode] from [json]. factory RowColumnNode.fromJson(Map json) => _$RowColumnNodeFromJson(json); @override diff --git a/lib/src/api/nodes/row_column_node.g.dart b/lib/src/api/nodes/row_column_node.g.dart index 4385e68c..2c0a55db 100644 --- a/lib/src/api/nodes/row_column_node.g.dart +++ b/lib/src/api/nodes/row_column_node.g.dart @@ -10,6 +10,9 @@ Map _$RowColumnNodeOmitDefaults( RowColumnNode instance, Map json, ) { + if (isJsonDefault(instance.wrap, false)) json.remove('wrap'); + if (isJsonDefault(instance.spacing, 0)) json.remove('spacing'); + if (isJsonDefault(instance.runSpacing, 0)) json.remove('runSpacing'); if (isJsonDefault(instance.visible, true)) json.remove('visible'); if (excludeConstraintsIf(instance)) json.remove('constraints'); if (isJsonDefault(instance.constraints, const BoxConstraintsModel())) @@ -227,6 +230,9 @@ RowColumnNode _$RowColumnNodeFromJson(Map json) => ScrollViewKeyboardDismissBehaviorC.manual, useFlutterListView: json['useFlutterListView'] as bool? ?? false, shouldAlwaysScroll: json['shouldAlwaysScroll'] as bool? ?? true, + wrap: json['wrap'] as bool? ?? false, + spacing: (json['spacing'] as num?)?.toDouble() ?? 0, + runSpacing: (json['runSpacing'] as num?)?.toDouble() ?? 0, ) ..variables = (json['variables'] as Map?)?.map( @@ -338,6 +344,9 @@ Map _$RowColumnNodeToJson( 'useFlutterListView': instance.useFlutterListView, 'shouldAlwaysScroll': instance.shouldAlwaysScroll, 'type': instance.type, + 'wrap': instance.wrap, + 'spacing': instance.spacing, + 'runSpacing': instance.runSpacing, }; const _$RowColumnTypeEnumMap = { diff --git a/test/json_defaults_test.dart b/test/json_defaults_test.dart index 1aa7d4f3..96cbda10 100644 --- a/test/json_defaults_test.dart +++ b/test/json_defaults_test.dart @@ -121,6 +121,18 @@ List sampleNodes() { verticalFit: SizeFit.shrinkWrap, padding: const EdgeInsetsModel.all(8), ), + RowColumnNode( + id: 'wrapRow', + name: 'Wrap Row', + basicBoxLocal: box, + children: ['a', 'b'], + rowColumnType: RowColumnType.row, + mainAxisAlignment: MainAxisAlignmentC.start, + verticalFit: SizeFit.shrinkWrap, + wrap: true, + spacing: 8, + runSpacing: 6, + ), FrameNode( id: 'frame', name: 'Frame', diff --git a/test/nodes/layout/row_column_wrap_json_test.dart b/test/nodes/layout/row_column_wrap_json_test.dart new file mode 100644 index 00000000..cb245d25 --- /dev/null +++ b/test/nodes/layout/row_column_wrap_json_test.dart @@ -0,0 +1,77 @@ +import 'dart:convert'; + +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:test/test.dart'; + +Map encodeDecode(Object? json) => jsonDecode(jsonEncode(json)) as Map; + +RowColumnNode _row({ + bool wrap = false, + double spacing = 0, + double runSpacing = 0, +}) => RowColumnNode( + id: 'chips', + name: 'Chips', + basicBoxLocal: NodeBox(0, 0, 200, 40), + children: ['a', 'b', 'c'], + rowColumnType: RowColumnType.row, + wrap: wrap, + spacing: spacing, + runSpacing: runSpacing, +); + +void main() { + group('RowColumnNode wrap', () { + test('defaults to no wrap and zero spacing', () { + final node = _row(); + expect(node.wrap, isFalse); + expect(node.spacing, 0); + expect(node.runSpacing, 0); + }); + + test('default values are left out of JSON', () { + final json = encodeDecode(_row().toJson()); + expect(json.containsKey('wrap'), isFalse); + expect(json.containsKey('spacing'), isFalse); + expect(json.containsKey('runSpacing'), isFalse); + }); + + test('round-trips wrap, spacing and runSpacing', () { + final json = encodeDecode( + _row(wrap: true, spacing: 8, runSpacing: 6.5).toJson(), + ); + expect(json['wrap'], isTrue); + expect(json['spacing'], 8); + expect(json['runSpacing'], 6.5); + + final back = NodeJsonConverter().fromJson(json)! as RowColumnNode; + expect(back.wrap, isTrue); + expect(back.spacing, 8); + expect(back.runSpacing, 6.5); + expect(encodeDecode(back.toJson()), json); + }); + + test('reads JSON written without the wrap keys', () { + final json = + encodeDecode(_row(wrap: true, spacing: 8, runSpacing: 6).toJson()) + ..remove('wrap') + ..remove('spacing') + ..remove('runSpacing'); + final back = RowColumnNode.fromJson(json); + expect(back.wrap, isFalse); + expect(back.spacing, 0); + expect(back.runSpacing, 0); + }); + + test('exposes wrap fields through generateFields', () { + final node = _row(); + final fields = node.generateFields(); + fields['wrap']!.setValue(true); + fields['spacing']!.setValue(12.0); + fields['runSpacing']!.setValue(4.0); + expect(node.wrap, isTrue); + expect(node.spacing, 12); + expect(node.runSpacing, 4); + }); + }); +} From 830076e334ced5746b9dd0a308a8045abdcb3211 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 23:32:51 +0000 Subject: [PATCH 9/9] Commit the generated RowColumnNode JSON and format a test CI regenerates *.g.dart and fails on a diff: the generator writes RowColumnNode's reactions first. dialog_presentation_test.dart wasn't formatted. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0147gqWiq3xoEdAGwWSpq9JK --- lib/src/api/nodes/row_column_node.g.dart | 2 +- test/dialog_presentation_test.dart | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/src/api/nodes/row_column_node.g.dart b/lib/src/api/nodes/row_column_node.g.dart index 2c0a55db..7c814719 100644 --- a/lib/src/api/nodes/row_column_node.g.dart +++ b/lib/src/api/nodes/row_column_node.g.dart @@ -283,6 +283,7 @@ RowColumnNode _$RowColumnNodeFromJson(Map json) => Map _$RowColumnNodeToJson( RowColumnNode instance, ) => { + 'reactions': instance.reactions.map((e) => e.toJson()).toList(), 'variables': instance.variables, 'multipleVariables': instance.multipleVariables, 'componentId': ?instance.componentId, @@ -300,7 +301,6 @@ Map _$RowColumnNodeToJson( '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(), diff --git a/test/dialog_presentation_test.dart b/test/dialog_presentation_test.dart index 9fc374d6..c5146cb8 100644 --- a/test/dialog_presentation_test.dart +++ b/test/dialog_presentation_test.dart @@ -8,7 +8,8 @@ void main() { destinationId: 'sheet', presentation: presentation, ); - final ShowDialogAction read = ActionModel.fromJson(action.toJson()) as ShowDialogAction; + final ShowDialogAction read = + ActionModel.fromJson(action.toJson()) as ShowDialogAction; expect(read.presentation, presentation); } });