diff --git a/CHANGELOG.md b/CHANGELOG.md index 07d5136d..7aefba23 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,37 @@ - A variable whose value is a `${= ...}` formula keeps it for every type (computed variables). +### Nodes + +- `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`. +- `SpacerNode` parses JSON without `positioningMode`, `aspectRatioLock` or + `reactions`, like other nodes. Its constructor didn't take them, so the + generated `fromJson` required them. + +### Triggers and actions + +- `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. +- `DialogPresentation.fullScreen`: a show dialog action can cover the + whole screen, like a photo viewer. +- 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. + +### Bindings + +- `strokeWeight` is a bindable style property of every node with strokes. +- `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. + ### JSON - List, grid, page and tab views and spacers parse from minimal node JSON: diff --git a/lib/src/api/mixins.dart b/lib/src/api/mixins.dart index 7c00015e..1a894611 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, 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( @@ -1065,6 +1067,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), ]; } 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/lib/src/api/models/action/show_dialog_action.dart b/lib/src/api/models/action/show_dialog_action.dart index e4cfed84..0ced9668 100644 --- a/lib/src/api/models/action/show_dialog_action.dart +++ b/lib/src/api/models/action/show_dialog_action.dart @@ -99,4 +99,7 @@ enum DialogPresentation { /// A bottom sheet on phones (narrower than 600 px), a dialog otherwise. adaptive, + + /// Covers the whole screen, like a photo viewer. + fullScreen, } 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 45efe467..c00fd5c4 100644 --- a/lib/src/api/models/action/show_dialog_action.g.dart +++ b/lib/src/api/models/action/show_dialog_action.g.dart @@ -71,6 +71,7 @@ const _$DialogPresentationEnumMap = { DialogPresentation.dialog: 'dialog', DialogPresentation.bottomSheet: 'bottomSheet', DialogPresentation.adaptive: 'adaptive', + DialogPresentation.fullScreen: 'fullScreen', }; const _$ActionTypeEnumMap = { 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/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. 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 c7d527df..cb30f007 100644 --- a/lib/src/api/nodes/row_column_node.dart +++ b/lib/src/api/nodes/row_column_node.dart @@ -112,6 +112,15 @@ class RowColumnNode extends ParentNode ); } + /// 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, + ]; + @override FieldsMap generateFields() => { ...super.generateFields(), 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/lib/src/api/nodes/spacer_node.dart b/lib/src/api/nodes/spacer_node.dart index 5147c62a..498a2bb3 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({ 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/dialog_presentation_test.dart b/test/dialog_presentation_test.dart new file mode 100644 index 00000000..c5146cb8 --- /dev/null +++ b/test/dialog_presentation_test.dart @@ -0,0 +1,25 @@ +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); + }); +} diff --git a/test/json_defaults_test.dart b/test/json_defaults_test.dart index bb2e1328..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', @@ -180,6 +192,7 @@ List sampleNodes() { RadioNode.empty(), SegmentedButtonNode.empty(), SliderNode.empty(), + SignatureNode.empty(), SwitchNode.empty(), for (final json in [ _node('root', {'children': []}), diff --git a/test/minimal_node_types_json_test.dart b/test/minimal_node_types_json_test.dart new file mode 100644 index 00000000..bb6b3be2 --- /dev/null +++ b/test/minimal_node_types_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); + }); + } +} 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'], ''); + }); +} 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); + }); + }); +}