diff --git a/CHANGELOG.md b/CHANGELOG.md index c489c255..07d5136d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,4 @@ -## Unreleased +## 6.2.0 ### Layout @@ -7,11 +7,32 @@ - Row/column `wrap` and `runSpacing`: children wrap onto further runs, like Flutter's `Wrap`. -### Variables +### 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 for every type (computed variables). +### 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/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/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', 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/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(), 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/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/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/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 diff --git a/test/computed_variable_value_test.dart b/test/computed_variable_value_test.dart index bb4bd10d..a8eb5d00 100644 --- a/test/computed_variable_value_test.dart +++ b/test/computed_variable_value_test.dart @@ -2,14 +2,35 @@ import 'package:codelessly_api/codelessly_api.dart'; import 'package:test/test.dart'; void main() { - test('a formula value survives sanitizing for every type', () { - const String formula = r"${= find(trips, 'id', selected)}"; + 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', type: type, value: formula).value, + VariableData(id: 'v', name: 'v', value: formula, type: type).value, formula, - reason: type.name, + 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'), + ); }); } 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, + ); + }); +} 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); + }); +} 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, + ); + }); +} 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, + ); + }); +} 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);