diff --git a/CHANGELOG.md b/CHANGELOG.md index c489c255..9d64afd4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,4 @@ -## Unreleased +## 6.2.0 ### Layout @@ -7,10 +7,39 @@ - Row/column `wrap` and `runSpacing`: children wrap onto further runs, like Flutter's `Wrap`. -### Variables - -- A variable whose value is a `${= ...}` formula keeps it for every type - (computed variables). +### Nodes + +- `GaugeNode` (`gauge`): an arc or bar gauge, continuous or segmented, with + scale ticks, a solid or dashed limit marker and an optional fill fade and + meniscus. `value`, `limit`, `color` and `limitColor` are bindable. +- `BarChartNode` (`barChart`): bars with null "future" slots, a highlighted + bar, grid lines with labels and per-bar labels. `values`, `labels`, + `highlight` and `color` are bindable. +- `SliderNode`: a rectangle fader thumb with grip, fill from the thumb to the + end, notch tick marks every N divisions, a marker at a bindable + `markerValue`, a bindable `activeTrackColor`, and a selectable range. +- `TextNode` exposes `textColor` for bindings and conditions. +- `BlendMixin.transition` (`NodeTransition`: duration and curve): runtime + opacity changes from bindings and conditions animate. +- `SpacerNode` reads minimal JSON (`positioningMode`, `aspectRatioLock` and + `reactions` are optional). +- `CanvasProperties.previewParams`: sample route params the design canvas + shows a canvas with (a confirm dialog's `${route.title}`); the runtime + never reads them. +- `CornerRadius.fromJson` reads a number (every corner) and four numbers + (top-left, top-right, bottom-right, bottom-left), so `cornerRadius` can be + bound: `${= screen.isMobile ? [6, 6, 0, 0] : 6}`. + +### Triggers, actions and variables + +- `TriggerType.interval` with `TriggerModel.intervalMillis`, and + `TriggerType.changeEnd` for sliders. +- `ShowDialogAction.placement` (center, bottom sheet, adaptive, anchored) and + `anchorGap`; `ConfirmAction.placement` for canvas confirms. +- `ShowToastAction`: alignment, side margin, max width, corner radius, + action color, close button and font size. +- Computed variables: `VariableData.formula` and `isComputed`; a variable + whose value is a `${= ...}` formula keeps it for every type. ## 6.1.0 diff --git a/lib/src/api/mixins.dart b/lib/src/api/mixins.dart index 7c00015e..f8dacf34 100644 --- a/lib/src/api/mixins.dart +++ b/lib/src/api/mixins.dart @@ -277,6 +277,9 @@ mixin BlendMixin on BaseNode { /// Whether to use ink well on this node, along with its properties. late InkWellModel? inkWell; + /// How the node animates when its style changes at runtime. Null jumps. + NodeTransition? transition; + /// Sets the blend properties of the node. void setBlendMixin({ required double opacity, diff --git a/lib/src/api/models/action/confirm_action.dart b/lib/src/api/models/action/confirm_action.dart index 7d39e4ae..c17a780c 100644 --- a/lib/src/api/models/action/confirm_action.dart +++ b/lib/src/api/models/action/confirm_action.dart @@ -71,6 +71,9 @@ class ConfirmAction extends ActionModel with Equatable, SerializableMixin { /// Corner radius of the built-in dialog. Null follows the app's buttons. final double? cornerRadius; + /// Where a [dialogId] dialog shows, as for [ShowDialogAction.placement]. + final DialogPlacement placement; + /// The key of the "Navigate back" params that answers a [dialogId] dialog. static const String resultKey = 'confirmed'; @@ -107,6 +110,7 @@ class ConfirmAction extends ActionModel with Equatable, SerializableMixin { this.textColor, this.confirmColor, this.cornerRadius, + this.placement = DialogPlacement.center, super.nonBlocking, super.enabled, }) : actions = actions ?? [], @@ -137,6 +141,7 @@ class ConfirmAction extends ActionModel with Equatable, SerializableMixin { bool clearConfirmColor = false, double? cornerRadius, bool clearCornerRadius = false, + DialogPlacement? placement, }) => ConfirmAction( title: title ?? this.title, message: message ?? this.message, @@ -155,6 +160,7 @@ class ConfirmAction extends ActionModel with Equatable, SerializableMixin { textColor: clearTextColor ? null : textColor ?? this.textColor, confirmColor: clearConfirmColor ? null : confirmColor ?? this.confirmColor, cornerRadius: clearCornerRadius ? null : cornerRadius ?? this.cornerRadius, + placement: placement ?? this.placement, nonBlocking: nonBlocking, enabled: enabled, ); @@ -176,6 +182,7 @@ class ConfirmAction extends ActionModel with Equatable, SerializableMixin { textColor, confirmColor, cornerRadius, + placement, ]; /// Creates an instance from a JSON object. diff --git a/lib/src/api/models/action/confirm_action.g.dart b/lib/src/api/models/action/confirm_action.g.dart index 93096cc1..b4c2a119 100644 --- a/lib/src/api/models/action/confirm_action.g.dart +++ b/lib/src/api/models/action/confirm_action.g.dart @@ -19,6 +19,8 @@ Map _$ConfirmActionOmitDefaults( if (isJsonDefault(instance.params, {})) json.remove('params'); if (isJsonDefault(instance.barrierDismissible, true)) json.remove('barrierDismissible'); + if (isJsonDefault(instance.placement, DialogPlacement.center)) + json.remove('placement'); if (isJsonDefault(instance.nonBlocking, false)) json.remove('nonBlocking'); if (isJsonDefault(instance.enabled, true)) json.remove('enabled'); return json; @@ -56,6 +58,9 @@ ConfirmAction _$ConfirmActionFromJson(Map json) => ConfirmAction( ? null : ColorRGBA.fromJson(json['confirmColor']), cornerRadius: (json['cornerRadius'] as num?)?.toDouble(), + placement: + $enumDecodeNullable(_$DialogPlacementEnumMap, json['placement']) ?? + DialogPlacement.center, nonBlocking: json['nonBlocking'] as bool? ?? false, enabled: json['enabled'] as bool? ?? true, )..type = $enumDecode(_$ActionTypeEnumMap, json['type']); @@ -80,8 +85,16 @@ Map _$ConfirmActionToJson(ConfirmAction instance) => 'textColor': ?instance.textColor?.toJson(), 'confirmColor': ?instance.confirmColor?.toJson(), 'cornerRadius': ?instance.cornerRadius, + 'placement': _$DialogPlacementEnumMap[instance.placement]!, }; +const _$DialogPlacementEnumMap = { + DialogPlacement.center: 'center', + DialogPlacement.bottomSheet: 'bottomSheet', + DialogPlacement.adaptive: 'adaptive', + DialogPlacement.anchored: 'anchored', +}; + const _$ActionTypeEnumMap = { ActionType.navigation: 'navigation', ActionType.showDialog: 'showDialog', diff --git a/lib/src/api/models/action/show_dialog_action.dart b/lib/src/api/models/action/show_dialog_action.dart index 80bfdd32..89cf6ec4 100644 --- a/lib/src/api/models/action/show_dialog_action.dart +++ b/lib/src/api/models/action/show_dialog_action.dart @@ -6,6 +6,31 @@ import '../models.dart'; part 'show_dialog_action.g.dart'; +/// Where a [ShowDialogAction] puts its canvas. +enum DialogPlacement { + /// Centered over the screen at its design size. + center, + + /// Pinned to the bottom edge, full width, sliding up. + bottomSheet, + + /// A bottom sheet on screens narrower than 600 (mobile), centered + /// otherwise. + adaptive, + + /// A popover just below the widget that opened it, like a menu, kept on + /// screen. Falls back to [center] when the opener has no position. + anchored; + + /// Displayable label. + String get prettify => switch (this) { + DialogPlacement.center => 'Center', + DialogPlacement.bottomSheet => 'Bottom sheet', + DialogPlacement.adaptive => 'Sheet on mobile', + DialogPlacement.anchored => 'Below trigger', + }; +} + /// An action that navigates to a new page. @JsonSerializable() class ShowDialogAction extends ActionModel with Equatable, SerializableMixin { @@ -24,6 +49,12 @@ class ShowDialogAction extends ActionModel with Equatable, SerializableMixin { /// Whether to show a close button on the dialog. final bool showCloseButton; + /// Where the dialog shows. + final DialogPlacement placement; + + /// Space between the opener and an [DialogPlacement.anchored] dialog. + final double anchorGap; + /// Creates a new [ShowDialogAction]. ShowDialogAction({ required this.destinationId, @@ -31,6 +62,8 @@ class ShowDialogAction extends ActionModel with Equatable, SerializableMixin { this.barrierColor = ColorRGBA.black54, this.barrierDismissible = true, this.showCloseButton = false, + this.placement = DialogPlacement.center, + this.anchorGap = 6, super.nonBlocking, super.enabled, }) : params = {...params ?? {}}, @@ -43,6 +76,8 @@ class ShowDialogAction extends ActionModel with Equatable, SerializableMixin { barrierDismissible, barrierColor, showCloseButton, + placement, + anchorGap, ]; /// Duplicates this [ShowDialogAction] with given data overrides. @@ -52,12 +87,16 @@ class ShowDialogAction extends ActionModel with Equatable, SerializableMixin { bool? barrierDismissible, ColorRGBA? barrierColor, bool? showCloseButton, + DialogPlacement? placement, + double? anchorGap, }) => ShowDialogAction( destinationId: destinationId ?? this.destinationId, params: {...this.params, ...params ?? {}}, barrierDismissible: barrierDismissible ?? this.barrierDismissible, barrierColor: barrierColor ?? this.barrierColor, showCloseButton: showCloseButton ?? this.showCloseButton, + placement: placement ?? this.placement, + anchorGap: anchorGap ?? this.anchorGap, ); /// 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..d0f89ca7 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,9 @@ Map _$ShowDialogActionOmitDefaults( json.remove('barrierColor'); if (isJsonDefault(instance.showCloseButton, false)) json.remove('showCloseButton'); + if (isJsonDefault(instance.placement, DialogPlacement.center)) + json.remove('placement'); + if (isJsonDefault(instance.anchorGap, 6)) json.remove('anchorGap'); if (isJsonDefault(instance.nonBlocking, false)) json.remove('nonBlocking'); if (isJsonDefault(instance.enabled, true)) json.remove('enabled'); return json; @@ -35,6 +38,10 @@ ShowDialogAction _$ShowDialogActionFromJson(Map json) => ShowDialogAction( : ColorRGBA.fromJson(json['barrierColor']), barrierDismissible: json['barrierDismissible'] as bool? ?? true, showCloseButton: json['showCloseButton'] as bool? ?? false, + placement: + $enumDecodeNullable(_$DialogPlacementEnumMap, json['placement']) ?? + DialogPlacement.center, + anchorGap: (json['anchorGap'] as num?)?.toDouble() ?? 6, nonBlocking: json['nonBlocking'] as bool? ?? false, enabled: json['enabled'] as bool? ?? true, )..type = $enumDecode(_$ActionTypeEnumMap, json['type']); @@ -49,8 +56,17 @@ Map _$ShowDialogActionToJson(ShowDialogAction instance) => 'barrierDismissible': instance.barrierDismissible, 'barrierColor': ?instance.barrierColor?.toJson(), 'showCloseButton': instance.showCloseButton, + 'placement': _$DialogPlacementEnumMap[instance.placement]!, + 'anchorGap': instance.anchorGap, }; +const _$DialogPlacementEnumMap = { + DialogPlacement.center: 'center', + DialogPlacement.bottomSheet: 'bottomSheet', + DialogPlacement.adaptive: 'adaptive', + DialogPlacement.anchored: 'anchored', +}; + const _$ActionTypeEnumMap = { ActionType.navigation: 'navigation', ActionType.showDialog: 'showDialog', diff --git a/lib/src/api/models/action/show_toast_action.dart b/lib/src/api/models/action/show_toast_action.dart index a7deaf5a..aa8f0c37 100644 --- a/lib/src/api/models/action/show_toast_action.dart +++ b/lib/src/api/models/action/show_toast_action.dart @@ -48,6 +48,29 @@ enum ToastPosition { }; } +/// Where a toast sits across the screen. +enum ToastAlignment { + /// Centered, as wide as its content. + center, + + /// Against the left margin. + start, + + /// Against the right margin. + end, + + /// As wide as the space between the margins. + stretch; + + /// Label for the editor. + String get prettify => switch (this) { + ToastAlignment.center => 'Center', + ToastAlignment.start => 'Left', + ToastAlignment.end => 'Right', + ToastAlignment.stretch => 'Stretch', + }; +} + /// An action that shows a short message near the bottom or top of the screen, /// then hides it. The toast can carry a button, e.g. Undo, that runs /// [actions]. @@ -91,6 +114,29 @@ class ShowToastAction extends ActionModel with Equatable, SerializableMixin { /// [ToastPosition.bottom]. final double bottomOffset; + /// Where the toast sits across the screen. + @JsonKey(unknownEnumValue: ToastAlignment.center) + final ToastAlignment alignment; + + /// Space between the toast and the screen's sides. + final double sideMargin; + + /// The widest the toast gets. + final double maxWidth; + + /// Corner radius. Null: a pill without a button, the app's button radius + /// (at least 8) with one. + final double? cornerRadius; + + /// Color of the button's label. Null uses the message color. + final ColorRGBA? actionColor; + + /// Whether the toast has a close (✕) button. + final bool showCloseButton; + + /// Font size of the message. + final double fontSize; + /// Creates a new [ShowToastAction]. ShowToastAction({ required this.message, @@ -103,6 +149,13 @@ class ShowToastAction extends ActionModel with Equatable, SerializableMixin { List? actions, this.icon, this.bottomOffset = 104, + this.alignment = ToastAlignment.center, + this.sideMargin = 16, + this.maxWidth = 560, + this.cornerRadius, + this.actionColor, + this.showCloseButton = false, + this.fontSize = 14, super.nonBlocking, super.enabled, }) : actions = actions ?? [], @@ -123,6 +176,13 @@ class ShowToastAction extends ActionModel with Equatable, SerializableMixin { actions, icon, bottomOffset, + alignment, + sideMargin, + maxWidth, + cornerRadius, + actionColor, + showCloseButton, + fontSize, ]; /// Duplicates this [ShowToastAction] with given data overrides. @@ -140,6 +200,15 @@ class ShowToastAction extends ActionModel with Equatable, SerializableMixin { MultiSourceIconModel? icon, bool clearIcon = false, double? bottomOffset, + ToastAlignment? alignment, + double? sideMargin, + double? maxWidth, + double? cornerRadius, + ColorRGBA? actionColor, + bool clearActionColor = false, + bool clearCornerRadius = false, + bool? showCloseButton, + double? fontSize, }) => ShowToastAction( message: message ?? this.message, duration: duration ?? this.duration, @@ -153,6 +222,13 @@ class ShowToastAction extends ActionModel with Equatable, SerializableMixin { actions: actions ?? this.actions, icon: clearIcon ? null : icon ?? this.icon, bottomOffset: bottomOffset ?? this.bottomOffset, + alignment: alignment ?? this.alignment, + sideMargin: sideMargin ?? this.sideMargin, + maxWidth: maxWidth ?? this.maxWidth, + cornerRadius: clearCornerRadius ? null : cornerRadius ?? this.cornerRadius, + actionColor: clearActionColor ? null : actionColor ?? this.actionColor, + showCloseButton: showCloseButton ?? this.showCloseButton, + fontSize: fontSize ?? this.fontSize, nonBlocking: nonBlocking, enabled: enabled, ); diff --git a/lib/src/api/models/action/show_toast_action.g.dart b/lib/src/api/models/action/show_toast_action.g.dart index 684de2c2..b27caf98 100644 --- a/lib/src/api/models/action/show_toast_action.g.dart +++ b/lib/src/api/models/action/show_toast_action.g.dart @@ -17,6 +17,13 @@ Map _$ShowToastActionOmitDefaults( if (isJsonDefault(instance.actionLabel, '')) json.remove('actionLabel'); if (isJsonDefault(instance.actions, [])) json.remove('actions'); if (isJsonDefault(instance.bottomOffset, 104)) json.remove('bottomOffset'); + if (isJsonDefault(instance.alignment, ToastAlignment.center)) + json.remove('alignment'); + if (isJsonDefault(instance.sideMargin, 16)) json.remove('sideMargin'); + if (isJsonDefault(instance.maxWidth, 560)) json.remove('maxWidth'); + if (isJsonDefault(instance.showCloseButton, false)) + json.remove('showCloseButton'); + if (isJsonDefault(instance.fontSize, 14)) json.remove('fontSize'); if (isJsonDefault(instance.nonBlocking, false)) json.remove('nonBlocking'); if (isJsonDefault(instance.enabled, true)) json.remove('enabled'); return json; @@ -57,6 +64,21 @@ ShowToastAction _$ShowToastActionFromJson(Map json) => ShowToastAction( ? null : MultiSourceIconModel.fromJson(json['icon'] as Map), bottomOffset: (json['bottomOffset'] as num?)?.toDouble() ?? 104, + alignment: + $enumDecodeNullable( + _$ToastAlignmentEnumMap, + json['alignment'], + unknownValue: ToastAlignment.center, + ) ?? + ToastAlignment.center, + sideMargin: (json['sideMargin'] as num?)?.toDouble() ?? 16, + maxWidth: (json['maxWidth'] as num?)?.toDouble() ?? 560, + cornerRadius: (json['cornerRadius'] as num?)?.toDouble(), + actionColor: json['actionColor'] == null + ? null + : ColorRGBA.fromJson(json['actionColor']), + showCloseButton: json['showCloseButton'] as bool? ?? false, + fontSize: (json['fontSize'] as num?)?.toDouble() ?? 14, nonBlocking: json['nonBlocking'] as bool? ?? false, enabled: json['enabled'] as bool? ?? true, )..type = $enumDecode(_$ActionTypeEnumMap, json['type']); @@ -76,6 +98,13 @@ Map _$ShowToastActionToJson(ShowToastAction instance) => 'actions': instance.actions.map((e) => e.toJson()).toList(), 'icon': ?instance.icon?.toJson(), 'bottomOffset': instance.bottomOffset, + 'alignment': _$ToastAlignmentEnumMap[instance.alignment]!, + 'sideMargin': instance.sideMargin, + 'maxWidth': instance.maxWidth, + 'cornerRadius': ?instance.cornerRadius, + 'actionColor': ?instance.actionColor?.toJson(), + 'showCloseButton': instance.showCloseButton, + 'fontSize': instance.fontSize, }; const _$ToastStyleEnumMap = { @@ -91,6 +120,13 @@ const _$ToastPositionEnumMap = { ToastPosition.top: 'top', }; +const _$ToastAlignmentEnumMap = { + ToastAlignment.center: 'center', + ToastAlignment.start: 'start', + ToastAlignment.end: 'end', + ToastAlignment.stretch: 'stretch', +}; + const _$ActionTypeEnumMap = { ActionType.navigation: 'navigation', ActionType.showDialog: 'showDialog', diff --git a/lib/src/api/models/corner_radius.dart b/lib/src/api/models/corner_radius.dart index 688a05c1..b1c5a259 100644 --- a/lib/src/api/models/corner_radius.dart +++ b/lib/src/api/models/corner_radius.dart @@ -118,7 +118,24 @@ class CornerRadius extends Equatable with DynamicSerializableMixin { /// Factory constructor for creating a new [CornerRadius] instance from /// JSON data. + /// + /// Besides the forms [toJson] writes, a number rounds every corner by it + /// and four numbers round the corners top-left, top-right, bottom-right + /// and bottom-left, as a `cornerRadius` binding gives them: + /// `${= screen.isMobile ? [6, 6, 0, 0] : 6}`. factory CornerRadius.fromJson(dynamic json) { + if (json is num) { + return CornerRadius.all(RadiusModel.circular(json.toDouble())); + } + if (json case [num tl, num tr, num br, num bl]) { + return CornerRadius( + tl: RadiusModel.circular(tl.toDouble()), + tr: RadiusModel.circular(tr.toDouble()), + br: RadiusModel.circular(br.toDouble()), + bl: RadiusModel.circular(bl.toDouble()), + linked: tl == tr && tr == br && br == bl, + ); + } if (json case [num radius, bool linked, String type]) { return CornerRadius.all( RadiusModel.circular(radius.toDouble()), diff --git a/lib/src/api/models/models.dart b/lib/src/api/models/models.dart index 3b86dfac..18d999d5 100644 --- a/lib/src/api/models/models.dart +++ b/lib/src/api/models/models.dart @@ -47,6 +47,7 @@ export 'grid_delegate_type.dart'; export 'icon.dart'; export 'icon_model.dart'; export 'ink_well.dart'; +export 'node_transition.dart'; export 'input_border.dart'; export 'input_decoration.dart'; export 'letter_spacing.dart'; diff --git a/lib/src/api/models/node_transition.dart b/lib/src/api/models/node_transition.dart new file mode 100644 index 00000000..b7812717 --- /dev/null +++ b/lib/src/api/models/node_transition.dart @@ -0,0 +1,75 @@ +import 'package:json_annotation/json_annotation.dart'; +import 'package:equatable/equatable.dart'; + +import '../../../codelessly_api.dart'; + +part 'node_transition.g.dart'; + +/// The easing of a [NodeTransition]. +enum TransitionCurve { + /// Constant speed. + linear, + + /// Starts slowly, ends quickly. + easeIn, + + /// Starts quickly, ends slowly. + easeOut, + + /// Slow at both ends. + easeInOut, + + /// A strong ease out, for things that arrive. + easeOutCubic; + + /// Displayable name. + String get prettify => switch (this) { + TransitionCurve.linear => 'Linear', + TransitionCurve.easeIn => 'Ease in', + TransitionCurve.easeOut => 'Ease out', + TransitionCurve.easeInOut => 'Ease in out', + TransitionCurve.easeOutCubic => 'Ease out cubic', + }; +} + +/// How a node animates when a condition, a bound variable or a formula +/// changes its style at runtime: instead of jumping, its opacity tweens to +/// the new value over [durationMillis]. +/// +/// A pulsing loading placeholder is an opacity bound to a variable that an +/// interval reaction toggles, with a transition as long as the interval. +@JsonSerializable() +class NodeTransition with Equatable, SerializableMixin { + /// How long a change takes. + final int durationMillis; + + /// The easing of the change. + final TransitionCurve curve; + + /// Creates a [NodeTransition]. + const NodeTransition({ + this.durationMillis = 200, + this.curve = TransitionCurve.easeInOut, + }); + + /// [durationMillis] as a [Duration], never negative. + Duration get duration => + Duration(milliseconds: durationMillis < 0 ? 0 : durationMillis); + + /// Duplicates this with the given overrides. + NodeTransition copyWith({int? durationMillis, TransitionCurve? curve}) => + NodeTransition( + durationMillis: durationMillis ?? this.durationMillis, + curve: curve ?? this.curve, + ); + + /// Creates a [NodeTransition] from JSON. + factory NodeTransition.fromJson(Map json) => _$NodeTransitionFromJson(json); + + @override + Map toJson() => + _$NodeTransitionOmitDefaults(this, _$NodeTransitionToJson(this)); + + @override + List get props => [durationMillis, curve]; +} diff --git a/lib/src/api/models/node_transition.g.dart b/lib/src/api/models/node_transition.g.dart new file mode 100644 index 00000000..98f51365 --- /dev/null +++ b/lib/src/api/models/node_transition.g.dart @@ -0,0 +1,43 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'node_transition.dart'; + +// ************************************************************************** +// JsonDefaultsGenerator +// ************************************************************************** + +Map _$NodeTransitionOmitDefaults( + NodeTransition instance, + Map json, +) { + if (isJsonDefault(instance.durationMillis, 200)) + json.remove('durationMillis'); + if (isJsonDefault(instance.curve, TransitionCurve.easeInOut)) + json.remove('curve'); + return json; +} + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +NodeTransition _$NodeTransitionFromJson(Map json) => NodeTransition( + durationMillis: (json['durationMillis'] as num?)?.toInt() ?? 200, + curve: + $enumDecodeNullable(_$TransitionCurveEnumMap, json['curve']) ?? + TransitionCurve.easeInOut, +); + +Map _$NodeTransitionToJson(NodeTransition instance) => + { + 'durationMillis': instance.durationMillis, + 'curve': _$TransitionCurveEnumMap[instance.curve]!, + }; + +const _$TransitionCurveEnumMap = { + TransitionCurve.linear: 'linear', + TransitionCurve.easeIn: 'easeIn', + TransitionCurve.easeOut: 'easeOut', + TransitionCurve.easeInOut: 'easeInOut', + TransitionCurve.easeOutCubic: 'easeOutCubic', +}; diff --git a/lib/src/api/models/trigger.dart b/lib/src/api/models/trigger.dart index 8b75dbdd..bbe19eed 100644 --- a/lib/src/api/models/trigger.dart +++ b/lib/src/api/models/trigger.dart @@ -47,7 +47,16 @@ 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 lets go of a slider after changing it, with + /// the final value. [changed] fires on every step of the drag. + changeEnd, + + /// Triggered repeatedly while the canvas is shown, every + /// [TriggerModel.intervalMillis] milliseconds. Live readouts, countdowns + /// and polling use it. + interval; /// Displayable string representation of the trigger type. String get prettify { @@ -78,6 +87,10 @@ enum TriggerType { return 'Load'; case TriggerType.valueChanged: return 'Value Changed'; + case TriggerType.interval: + return 'Interval'; + case TriggerType.changeEnd: + return 'Change End'; } } } @@ -97,21 +110,39 @@ class TriggerModel with Equatable, SerializableMixin { /// For [TriggerType.valueChanged], the name of the variable to watch. final String? variable; + /// How often an [TriggerType.interval] trigger fires, in milliseconds. + /// Ignored by other triggers. + final int intervalMillis; + + /// The shortest interval allowed, so a typo can't flood the app. + static const int minIntervalMillis = 100; + /// 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.intervalMillis = 1000, + }); + + /// [intervalMillis], no shorter than [minIntervalMillis]. + int get effectiveIntervalMillis => + intervalMillis < minIntervalMillis ? minIntervalMillis : intervalMillis; @override - List get props => [type, debounceMs, variable]; + List get props => [type, debounceMs, variable, intervalMillis]; /// Duplicate this [TriggerModel] with given data overrides. TriggerModel copyWith({ TriggerType? type, int? debounceMs, String? variable, + int? intervalMillis, }) => TriggerModel( type: type ?? this.type, debounceMs: debounceMs ?? this.debounceMs, variable: variable ?? this.variable, + intervalMillis: intervalMillis ?? this.intervalMillis, ); /// 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..c24a617f 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.intervalMillis, 1000)) + json.remove('intervalMillis'); 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?, + intervalMillis: (json['intervalMillis'] as num?)?.toInt() ?? 1000, ); Map _$TriggerModelToJson(TriggerModel instance) => @@ -29,6 +32,7 @@ Map _$TriggerModelToJson(TriggerModel instance) => 'type': _$TriggerTypeEnumMap[instance.type]!, 'debounceMs': instance.debounceMs, 'variable': ?instance.variable, + 'intervalMillis': instance.intervalMillis, }; const _$TriggerTypeEnumMap = { @@ -45,4 +49,6 @@ const _$TriggerTypeEnumMap = { TriggerType.pointerUp: 'pointerUp', TriggerType.load: 'load', TriggerType.valueChanged: 'valueChanged', + TriggerType.changeEnd: 'changeEnd', + TriggerType.interval: 'interval', }; diff --git a/lib/src/api/models/variables_model.dart b/lib/src/api/models/variables_model.dart index 7e703dcd..c7fcc476 100644 --- a/lib/src/api/models/variables_model.dart +++ b/lib/src/api/models/variables_model.dart @@ -147,6 +147,16 @@ class VariableData /// metadata or any other information that is not part of the core variable. final Map extra; + /// A formula expression (what goes inside `${= ...}`) that computes this + /// variable's value from other variables, e.g. `fresh < freshLimit`. Empty + /// for a variable that holds a value. A computed variable is read-only: + /// actions that set it are ignored, and it changes whenever a variable its + /// formula reads changes. + final String formula; + + /// Whether this variable's value comes from [formula]. + bool get isComputed => formula.trim().isNotEmpty; + /// Creates a new [VariableData]. VariableData({ required this.id, @@ -154,6 +164,7 @@ class VariableData Object? value = '', this.type = VariableType.text, Map? extra, + this.formula = '', }) : value = sanitizeValueForVariableType(value, type) ?? '', extra = extra ?? {}; @@ -166,6 +177,7 @@ class VariableData bool? isUsed, Set? nodes, Map? extra, + String? formula, }) { final String? sanitizedValue = value == null ? null @@ -176,11 +188,12 @@ class VariableData value: sanitizedValue ?? this.value, type: type ?? this.type, extra: extra ?? this.extra, + formula: formula ?? this.formula, ); } @override - List get props => [id, name, value, type]; + List get props => [id, name, value, type, formula]; @override int compareTo(VariableData other) => name.compareTo(other.name); @@ -201,6 +214,7 @@ class VariableData name: name, value: value, type: type, + formula: formula, ); /// Returns the value converted to the appropriate type according to [type]. @@ -281,6 +295,7 @@ class CanvasVariableData extends VariableData { super.type, super.value, super.extra, + super.formula, }); @override @@ -293,6 +308,7 @@ class CanvasVariableData extends VariableData { String? canvasId, Set? nodes, Map? extra, + String? formula, }) { final String? sanitizedValue = value == null ? null @@ -304,6 +320,7 @@ class CanvasVariableData extends VariableData { id: id ?? this.id, canvasId: canvasId ?? this.canvasId, extra: extra ?? this.extra, + formula: formula ?? this.formula, ); } @@ -339,6 +356,7 @@ class RuntimeVariableData extends VariableData { super.value = '', super.type = VariableType.text, super.extra, + super.formula, }) : super(id: id ?? generateId()); @override @@ -350,6 +368,7 @@ class RuntimeVariableData extends VariableData { bool? isUsed, Set? nodes, Map? extra, + String? formula, }) { final String? sanitizedValue = value == null ? null @@ -360,6 +379,7 @@ class RuntimeVariableData extends VariableData { value: sanitizedValue ?? this.value, type: type ?? this.type, extra: extra ?? this.extra, + formula: formula ?? this.formula, ); } } diff --git a/lib/src/api/models/variables_model.g.dart b/lib/src/api/models/variables_model.g.dart index 86c5e5f9..fc61a07b 100644 --- a/lib/src/api/models/variables_model.g.dart +++ b/lib/src/api/models/variables_model.g.dart @@ -13,6 +13,7 @@ Map _$VariableDataOmitDefaults( if (isJsonDefault(instance.value, '')) json.remove('value'); if (isJsonDefault(instance.type, VariableType.text)) json.remove('type'); if (isJsonDefault(instance.extra, {})) json.remove('extra'); + if (isJsonDefault(instance.formula, '')) json.remove('formula'); return json; } @@ -22,6 +23,7 @@ Map _$CanvasVariableDataOmitDefaults( ) { if (isJsonDefault(instance.value, '')) json.remove('value'); if (isJsonDefault(instance.type, VariableType.text)) json.remove('type'); + if (isJsonDefault(instance.formula, '')) json.remove('formula'); return json; } @@ -41,6 +43,7 @@ VariableData _$VariableDataFromJson(Map json) => VariableData( ) ?? VariableType.text, extra: (json['extra'] as Map?)?.map((k, e) => MapEntry(k as String, e)), + formula: json['formula'] as String? ?? '', ); Map _$VariableDataToJson(VariableData instance) => @@ -50,6 +53,7 @@ Map _$VariableDataToJson(VariableData instance) => 'value': instance.value, 'type': _$VariableTypeEnumMap[instance.type]!, 'extra': instance.extra, + 'formula': instance.formula, }; const _$VariableTypeEnumMap = { @@ -77,6 +81,7 @@ CanvasVariableData _$CanvasVariableDataFromJson(Map json) => CanvasVariableData( VariableType.text, value: json['value'] ?? '', extra: (json['extra'] as Map?)?.map((k, e) => MapEntry(k as String, e)), + formula: json['formula'] as String? ?? '', ); Map _$CanvasVariableDataToJson(CanvasVariableData instance) => @@ -86,5 +91,6 @@ Map _$CanvasVariableDataToJson(CanvasVariableData instance) => 'value': instance.value, 'type': _$VariableTypeEnumMap[instance.type]!, 'extra': instance.extra, + 'formula': instance.formula, 'canvasId': instance.canvasId, }; diff --git a/lib/src/api/node_json_converter.dart b/lib/src/api/node_json_converter.dart index 311acd7c..d091708d 100644 --- a/lib/src/api/node_json_converter.dart +++ b/lib/src/api/node_json_converter.dart @@ -66,6 +66,8 @@ class NodeJsonConverter implements JsonConverter { 'webView': WebViewNode.fromJson, 'dropdown': DropdownNode.fromJson, 'progressBar': ProgressBarNode.fromJson, + 'gauge': GaugeNode.fromJson, + 'barChart': BarChartNode.fromJson, 'variance': VarianceNode.fromJson, 'listView': ListViewNode.fromJson, 'gridView': GridViewNode.fromJson, diff --git a/lib/src/api/nodes/auto_placeholder_node.g.dart b/lib/src/api/nodes/auto_placeholder_node.g.dart index 68fbcbd2..0cb6e86b 100644 --- a/lib/src/api/nodes/auto_placeholder_node.g.dart +++ b/lib/src/api/nodes/auto_placeholder_node.g.dart @@ -239,6 +239,9 @@ AutoPlaceholderNode _$AutoPlaceholderNodeFromJson(Map json) => : InkWellModel.fromJson( Map.from(json['inkWell'] as Map), ) + ..transition = json['transition'] == null + ? null + : NodeTransition.fromJson(json['transition'] as Map) ..spacing = (json['spacing'] as num?)?.toDouble() ?? 0.0 ..wrap = json['wrap'] as bool? ?? false ..runSpacing = (json['runSpacing'] as num?)?.toDouble() ?? 0.0 @@ -247,6 +250,7 @@ AutoPlaceholderNode _$AutoPlaceholderNodeFromJson(Map json) => Map _$AutoPlaceholderNodeToJson( AutoPlaceholderNode instance, ) => { + 'reactions': instance.reactions.map((e) => e.toJson()).toList(), 'variables': instance.variables, 'multipleVariables': instance.multipleVariables, 'componentId': ?instance.componentId, @@ -264,7 +268,6 @@ Map _$AutoPlaceholderNodeToJson( '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(), @@ -283,6 +286,7 @@ Map _$AutoPlaceholderNodeToJson( 'isMask': instance.isMask, 'effects': instance.effects.map((e) => e.toJson()).toList(), 'inkWell': ?instance.inkWell?.toJson(), + 'transition': ?instance.transition?.toJson(), 'fills': instance.fills.map((e) => e.toJson()).toList(), 'strokes': instance.strokes.map((e) => e.toJson()).toList(), 'strokeWeight': instance.strokeWeight, diff --git a/lib/src/api/nodes/bar_chart_node.dart b/lib/src/api/nodes/bar_chart_node.dart new file mode 100644 index 00000000..726e3c6a --- /dev/null +++ b/lib/src/api/nodes/bar_chart_node.dart @@ -0,0 +1,320 @@ +import 'package:json_annotation/json_annotation.dart'; + +import '../field_access.dart'; +import '../mixins.dart'; +import '../models/models.dart'; +import 'nodes.dart'; + +part 'bar_chart_node.g.dart'; + +/// The default width of a bar chart. +const double kBarChartDefaultWidth = 320; + +/// The default height of a bar chart. +const double kBarChartDefaultHeight = 160; + +/// A column chart for a short series, e.g. energy per hour. +/// +/// Each value is a bar scaled against [BarChartProperties.max]; a null value +/// (data that doesn't exist yet) draws as a short dash on the baseline. One +/// bar can be highlighted, with the rest dimmed. Horizontal grid lines with +/// labels in a right-hand gutter and a label under each bar are optional. +/// +/// [values], [labels] and [highlight] can be bound to variables or formulas +/// (`values`, `labels`, `highlight`). +@JsonSerializable() +class BarChartNode extends SceneNode with CustomPropertiesMixin, FieldsHolder { + @override + final String type = 'barChart'; + + @override + covariant BarChartProperties properties; + + /// The series. Null entries draw as empty slots. + List values; + + /// A label under each bar; empty strings leave that bar unlabeled. + List labels; + + /// Index of the highlighted bar, or null to draw every bar at full + /// strength. + int? highlight; + + @override + bool get supportsPadding => false; + + /// Creates a bar chart preview. Strictly used for previews, e.g. in the + /// components panel. + BarChartNode.empty() + : properties = BarChartProperties(), + values = [2, 5, 9, 7, 4, null], + labels = ['a', '', 'c', '', 'e', ''], + highlight = 4, + super( + id: 'barChart', + name: 'Bar Chart', + basicBoxLocal: NodeBox( + 0, + 0, + kBarChartDefaultWidth, + kBarChartDefaultHeight, + ), + ); + + /// Creates a [BarChartNode] with the given data. + BarChartNode({ + required super.id, + required super.name, + required super.basicBoxLocal, + super.outerBoxLocal, + super.retainedOuterBoxLocal, + super.visible, + super.enabled, + super.rotationDegrees, + super.alignment, + super.margin, + super.padding, + super.horizontalFit, + super.verticalFit, + super.flex, + super.constraints, + super.edgePins, + super.aspectRatioLock, + super.positioningMode, + super.parentID, + super.reactions, + // Custom properties. + List? values, + List? labels, + this.highlight, + required this.properties, + super.variables, + super.multipleVariables, + }) : values = values ?? [], + labels = labels ?? [] { + setVariablesMixin( + variables: variables, + multipleVariables: multipleVariables, + ); + } + + @override + FieldsMap generateFields() => { + ...super.generateFields(), + 'properties': ObjectFieldAccess( + 'Properties', + 'properties of the bar chart', + () => properties, + (value) => properties = value, + ), + }; + + @override + List get props => [ + ...super.props, + values, + labels, + highlight, + properties, + variables, + multipleVariables, + ]; + + @override + late final List propertyVariables = [ + ...super.propertyVariables, + ObjectValue(name: 'values', value: values), + ObjectValue(name: 'labels', value: labels), + IntValue(name: 'highlight', value: highlight ?? -1), + ColorValue(name: 'color', value: properties.color), + ]; + + /// Creates a [BarChartNode] from a JSON object. + factory BarChartNode.fromJson(Map json) => _$BarChartNodeFromJson(json); + + @override + Map toJson() => _$BarChartNodeOmitDefaults(this, _$BarChartNodeToJson(this)); +} + +/// Configurable properties of a [BarChartNode]. +@JsonSerializable() +class BarChartProperties extends CustomProperties with FieldsHolder { + /// The value of a full-height bar. 0 scales to the largest value. + double max; + + /// Bar color. Bindable as `color`. + ColorRGBA color; + + /// Opacity of the bars that aren't highlighted, when a bar is. + double dimmedOpacity; + + /// Space between bars. + double barGap; + + /// Radius of a bar's top corners. + double barRadius; + + /// Color of the dash drawn for a null value. + ColorRGBA emptyColor; + + /// Height of the dash drawn for a null value. + double emptyHeight; + + /// Number of horizontal grid lines, evenly spaced up to the top. + int gridLines; + + /// Grid line color. + ColorRGBA gridColor; + + /// Labels for the grid lines from the bottom one up. Shown in a gutter on + /// the right when not empty. + List gridLabels; + + /// Width of the right-hand gutter for [gridLabels]. + double gridLabelWidth; + + /// Color of the baseline under the bars. Transparent for none. + ColorRGBA baselineColor; + + /// Font family of bar and grid labels. + String labelFontFamily; + + /// Font size of bar and grid labels. + double labelFontSize; + + /// Color of bar and grid labels. + ColorRGBA labelColor; + + /// Color of the highlighted bar's label. + ColorRGBA highlightLabelColor; + + /// Space between the baseline and the bar labels. + double labelGap; + + /// Whether bar heights animate when values change. + bool animate; + + /// Animation duration in milliseconds. + int animationDurationInMillis; + + /// Creates [BarChartProperties] with the given data. + BarChartProperties({ + this.max = 0, + this.color = const ColorRGBA(r: 0.95, g: 0.76, b: 0.19, a: 1), + this.dimmedOpacity = 0.55, + this.barGap = 3, + this.barRadius = 1, + this.emptyColor = const ColorRGBA(r: 0.23, g: 0.25, b: 0.28, a: 1), + this.emptyHeight = 2, + this.gridLines = 3, + this.gridColor = const ColorRGBA(r: 0.17, g: 0.19, b: 0.22, a: 1), + List? gridLabels, + this.gridLabelWidth = 40, + this.baselineColor = const ColorRGBA(r: 0.23, g: 0.25, b: 0.28, a: 1), + this.labelFontFamily = 'JetBrains Mono', + this.labelFontSize = 10, + this.labelColor = const ColorRGBA(r: 0.42, g: 0.45, b: 0.49, a: 1), + this.highlightLabelColor = const ColorRGBA(r: 0.91, g: 0.92, b: 0.93, a: 1), + this.labelGap = 6, + this.animate = true, + this.animationDurationInMillis = 700, + }) : gridLabels = gridLabels ?? []; + + /// Duplicates these properties with the given overrides. + BarChartProperties copyWith({ + double? max, + ColorRGBA? color, + double? dimmedOpacity, + double? barGap, + double? barRadius, + ColorRGBA? emptyColor, + double? emptyHeight, + int? gridLines, + ColorRGBA? gridColor, + List? gridLabels, + double? gridLabelWidth, + ColorRGBA? baselineColor, + String? labelFontFamily, + double? labelFontSize, + ColorRGBA? labelColor, + ColorRGBA? highlightLabelColor, + double? labelGap, + bool? animate, + int? animationDurationInMillis, + }) => BarChartProperties( + max: max ?? this.max, + color: color ?? this.color, + dimmedOpacity: dimmedOpacity ?? this.dimmedOpacity, + barGap: barGap ?? this.barGap, + barRadius: barRadius ?? this.barRadius, + emptyColor: emptyColor ?? this.emptyColor, + emptyHeight: emptyHeight ?? this.emptyHeight, + gridLines: gridLines ?? this.gridLines, + gridColor: gridColor ?? this.gridColor, + gridLabels: gridLabels ?? this.gridLabels, + gridLabelWidth: gridLabelWidth ?? this.gridLabelWidth, + baselineColor: baselineColor ?? this.baselineColor, + labelFontFamily: labelFontFamily ?? this.labelFontFamily, + labelFontSize: labelFontSize ?? this.labelFontSize, + labelColor: labelColor ?? this.labelColor, + highlightLabelColor: highlightLabelColor ?? this.highlightLabelColor, + labelGap: labelGap ?? this.labelGap, + animate: animate ?? this.animate, + animationDurationInMillis: + animationDurationInMillis ?? this.animationDurationInMillis, + ); + + @override + FieldsMap generateFields() => { + ...super.generateFields(), + 'max': NumFieldAccess( + 'Max', + 'The value of a full-height bar, 0 for the largest value', + () => max, + (value) => max = value, + ), + 'color': ColorFieldAccess( + 'Color', + 'Bar color', + () => color, + (value) => color = value, + ), + 'gridLines': NumFieldAccess( + 'Grid Lines', + 'Number of horizontal grid lines', + () => gridLines, + (value) => gridLines = value, + ), + }; + + @override + List get props => [ + max, + color, + dimmedOpacity, + barGap, + barRadius, + emptyColor, + emptyHeight, + gridLines, + gridColor, + gridLabels, + gridLabelWidth, + baselineColor, + labelFontFamily, + labelFontSize, + labelColor, + highlightLabelColor, + labelGap, + animate, + animationDurationInMillis, + ]; + + /// Creates [BarChartProperties] from a JSON object. + factory BarChartProperties.fromJson(Map json) => + _$BarChartPropertiesFromJson(json); + + @override + Map toJson() => + _$BarChartPropertiesOmitDefaults(this, _$BarChartPropertiesToJson(this)); +} diff --git a/lib/src/api/nodes/bar_chart_node.g.dart b/lib/src/api/nodes/bar_chart_node.g.dart new file mode 100644 index 00000000..07a39373 --- /dev/null +++ b/lib/src/api/nodes/bar_chart_node.g.dart @@ -0,0 +1,328 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'bar_chart_node.dart'; + +// ************************************************************************** +// JsonDefaultsGenerator +// ************************************************************************** + +Map _$BarChartNodeOmitDefaults( + BarChartNode instance, + Map json, +) { + if (isJsonDefault(instance.values, [])) json.remove('values'); + if (isJsonDefault(instance.labels, [])) json.remove('labels'); + 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 _$BarChartPropertiesOmitDefaults( + BarChartProperties instance, + Map json, +) { + if (isJsonDefault(instance.max, 0)) json.remove('max'); + if (isJsonDefault( + instance.color, + const ColorRGBA(r: 0.95, g: 0.76, b: 0.19, a: 1), + )) + json.remove('color'); + if (isJsonDefault(instance.dimmedOpacity, 0.55)) json.remove('dimmedOpacity'); + if (isJsonDefault(instance.barGap, 3)) json.remove('barGap'); + if (isJsonDefault(instance.barRadius, 1)) json.remove('barRadius'); + if (isJsonDefault( + instance.emptyColor, + const ColorRGBA(r: 0.23, g: 0.25, b: 0.28, a: 1), + )) + json.remove('emptyColor'); + if (isJsonDefault(instance.emptyHeight, 2)) json.remove('emptyHeight'); + if (isJsonDefault(instance.gridLines, 3)) json.remove('gridLines'); + if (isJsonDefault( + instance.gridColor, + const ColorRGBA(r: 0.17, g: 0.19, b: 0.22, a: 1), + )) + json.remove('gridColor'); + if (isJsonDefault(instance.gridLabels, [])) json.remove('gridLabels'); + if (isJsonDefault(instance.gridLabelWidth, 40)) json.remove('gridLabelWidth'); + if (isJsonDefault( + instance.baselineColor, + const ColorRGBA(r: 0.23, g: 0.25, b: 0.28, a: 1), + )) + json.remove('baselineColor'); + if (isJsonDefault(instance.labelFontFamily, 'JetBrains Mono')) + json.remove('labelFontFamily'); + if (isJsonDefault(instance.labelFontSize, 10)) json.remove('labelFontSize'); + if (isJsonDefault( + instance.labelColor, + const ColorRGBA(r: 0.42, g: 0.45, b: 0.49, a: 1), + )) + json.remove('labelColor'); + if (isJsonDefault( + instance.highlightLabelColor, + const ColorRGBA(r: 0.91, g: 0.92, b: 0.93, a: 1), + )) + json.remove('highlightLabelColor'); + if (isJsonDefault(instance.labelGap, 6)) json.remove('labelGap'); + if (isJsonDefault(instance.animate, true)) json.remove('animate'); + if (isJsonDefault(instance.animationDurationInMillis, 700)) + json.remove('animationDurationInMillis'); + return json; +} + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +BarChartNode _$BarChartNodeFromJson(Map json) => + BarChartNode( + id: json['id'] as String, + name: json['name'] as String, + basicBoxLocal: NodeBox.fromJson(json['basicBoxLocal']), + visible: json['visible'] as bool? ?? true, + enabled: json['enabled'] as bool? ?? true, + rotationDegrees: json['rotation'] == null + ? 0 + : castRotation(json['rotation']), + alignment: json['alignment'] == null + ? AlignmentModel.none + : AlignmentModel.fromJson(json['alignment'] as Map), + margin: json['margin'] == null + ? EdgeInsetsModel.zero + : EdgeInsetsModel.fromJson(json['margin']), + padding: json['padding'] == null + ? EdgeInsetsModel.zero + : EdgeInsetsModel.fromJson(json['padding']), + horizontalFit: + $enumDecodeNullable(_$SizeFitEnumMap, json['horizontalFit']) ?? + SizeFit.fixed, + verticalFit: + $enumDecodeNullable(_$SizeFitEnumMap, json['verticalFit']) ?? + SizeFit.fixed, + flex: (json['flex'] as num?)?.toInt() ?? 1, + constraints: json['constraints'] == null + ? const BoxConstraintsModel() + : BoxConstraintsModel.fromJson(json['constraints'] as Map), + edgePins: json['edgePins'] == null + ? EdgePinsModel.standard + : EdgePinsModel.fromJson( + Map.from(json['edgePins'] 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 [], + values: (json['values'] as List?) + ?.map((e) => (e as num?)?.toDouble()) + .toList(), + labels: (json['labels'] as List?) + ?.map((e) => e as String) + .toList(), + highlight: (json['highlight'] as num?)?.toInt(), + properties: BarChartProperties.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 _$BarChartNodeToJson(BarChartNode 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, + 'properties': instance.properties.toJson(), + 'values': instance.values, + 'labels': instance.labels, + 'highlight': ?instance.highlight, + }; + +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', +}; + +BarChartProperties _$BarChartPropertiesFromJson(Map json) => BarChartProperties( + max: (json['max'] as num?)?.toDouble() ?? 0, + color: json['color'] == null + ? const ColorRGBA(r: 0.95, g: 0.76, b: 0.19, a: 1) + : ColorRGBA.fromJson(json['color']), + dimmedOpacity: (json['dimmedOpacity'] as num?)?.toDouble() ?? 0.55, + barGap: (json['barGap'] as num?)?.toDouble() ?? 3, + barRadius: (json['barRadius'] as num?)?.toDouble() ?? 1, + emptyColor: json['emptyColor'] == null + ? const ColorRGBA(r: 0.23, g: 0.25, b: 0.28, a: 1) + : ColorRGBA.fromJson(json['emptyColor']), + emptyHeight: (json['emptyHeight'] as num?)?.toDouble() ?? 2, + gridLines: (json['gridLines'] as num?)?.toInt() ?? 3, + gridColor: json['gridColor'] == null + ? const ColorRGBA(r: 0.17, g: 0.19, b: 0.22, a: 1) + : ColorRGBA.fromJson(json['gridColor']), + gridLabels: (json['gridLabels'] as List?) + ?.map((e) => e as String) + .toList(), + gridLabelWidth: (json['gridLabelWidth'] as num?)?.toDouble() ?? 40, + baselineColor: json['baselineColor'] == null + ? const ColorRGBA(r: 0.23, g: 0.25, b: 0.28, a: 1) + : ColorRGBA.fromJson(json['baselineColor']), + labelFontFamily: json['labelFontFamily'] as String? ?? 'JetBrains Mono', + labelFontSize: (json['labelFontSize'] as num?)?.toDouble() ?? 10, + labelColor: json['labelColor'] == null + ? const ColorRGBA(r: 0.42, g: 0.45, b: 0.49, a: 1) + : ColorRGBA.fromJson(json['labelColor']), + highlightLabelColor: json['highlightLabelColor'] == null + ? const ColorRGBA(r: 0.91, g: 0.92, b: 0.93, a: 1) + : ColorRGBA.fromJson(json['highlightLabelColor']), + labelGap: (json['labelGap'] as num?)?.toDouble() ?? 6, + animate: json['animate'] as bool? ?? true, + animationDurationInMillis: + (json['animationDurationInMillis'] as num?)?.toInt() ?? 700, +); + +Map _$BarChartPropertiesToJson(BarChartProperties instance) => + { + 'max': instance.max, + 'color': instance.color.toJson(), + 'dimmedOpacity': instance.dimmedOpacity, + 'barGap': instance.barGap, + 'barRadius': instance.barRadius, + 'emptyColor': instance.emptyColor.toJson(), + 'emptyHeight': instance.emptyHeight, + 'gridLines': instance.gridLines, + 'gridColor': instance.gridColor.toJson(), + 'gridLabels': instance.gridLabels, + 'gridLabelWidth': instance.gridLabelWidth, + 'baselineColor': instance.baselineColor.toJson(), + 'labelFontFamily': instance.labelFontFamily, + 'labelFontSize': instance.labelFontSize, + 'labelColor': instance.labelColor.toJson(), + 'highlightLabelColor': instance.highlightLabelColor.toJson(), + 'labelGap': instance.labelGap, + 'animate': instance.animate, + 'animationDurationInMillis': instance.animationDurationInMillis, + }; diff --git a/lib/src/api/nodes/canvas_node.dart b/lib/src/api/nodes/canvas_node.dart index 9634c1f0..763ddb7a 100644 --- a/lib/src/api/nodes/canvas_node.dart +++ b/lib/src/api/nodes/canvas_node.dart @@ -46,6 +46,7 @@ class CanvasNode extends ParentNode List get triggerTypes => [ TriggerType.load, TriggerType.valueChanged, + TriggerType.interval, ]; @override @@ -197,6 +198,12 @@ class CanvasProperties extends CustomProperties { /// to the canvas with this ID instead, such as a sign-in screen. String? signInRedirectId; + /// Sample route params the design canvas shows this canvas with, in place + /// of the params a navigation or dialog action passes it at runtime: a + /// confirm dialog reading `${route.title}` shows its title while designing. + /// The runtime never reads them. + Map previewParams; + /// Whether this screen needs a signed-in user. bool get requiresSignIn => signInRedirectId?.isNotEmpty ?? false; @@ -217,6 +224,7 @@ class CanvasProperties extends CustomProperties { this.safeArea = const SafeAreaModel.all(true), this.brightness = BrightnessModel.system, this.signInRedirectId, + this.previewParams = const {}, }); /// Checks if a node is symbiotic with its canvas, ie the node acts as the @@ -234,6 +242,7 @@ class CanvasProperties extends CustomProperties { safeArea, brightness, signInRedirectId, + previewParams, ]; /// Creates a new [CanvasProperties] from a JSON data. diff --git a/lib/src/api/nodes/canvas_node.g.dart b/lib/src/api/nodes/canvas_node.g.dart index 5f4c9a09..711084fa 100644 --- a/lib/src/api/nodes/canvas_node.g.dart +++ b/lib/src/api/nodes/canvas_node.g.dart @@ -101,6 +101,8 @@ Map _$CanvasPropertiesOmitDefaults( json.remove('safeArea'); if (isJsonDefault(instance.brightness, BrightnessModel.system)) json.remove('brightness'); + if (isJsonDefault(instance.previewParams, const {})) + json.remove('previewParams'); return json; } @@ -293,6 +295,9 @@ CanvasNode _$CanvasNodeFromJson(Map json) => : InkWellModel.fromJson( Map.from(json['inkWell'] as Map), ) + ..transition = json['transition'] == null + ? null + : NodeTransition.fromJson(json['transition'] as Map) ..spacing = (json['spacing'] as num?)?.toDouble() ?? 0.0 ..wrap = json['wrap'] as bool? ?? false ..runSpacing = (json['runSpacing'] as num?)?.toDouble() ?? 0.0 @@ -337,6 +342,7 @@ Map _$CanvasNodeToJson( 'isMask': instance.isMask, 'effects': instance.effects.map((e) => e.toJson()).toList(), 'inkWell': ?instance.inkWell?.toJson(), + 'transition': ?instance.transition?.toJson(), 'fills': instance.fills.map((e) => e.toJson()).toList(), 'strokes': instance.strokes.map((e) => e.toJson()).toList(), 'strokeWeight': instance.strokeWeight, @@ -510,6 +516,11 @@ CanvasProperties _$CanvasPropertiesFromJson(Map json) => CanvasProperties( $enumDecodeNullable(_$BrightnessModelEnumMap, json['brightness']) ?? BrightnessModel.system, signInRedirectId: json['signInRedirectId'] as String?, + previewParams: + (json['previewParams'] as Map?)?.map( + (k, e) => MapEntry(k as String, e), + ) ?? + const {}, ); Map _$CanvasPropertiesToJson(CanvasProperties instance) => @@ -521,6 +532,7 @@ Map _$CanvasPropertiesToJson(CanvasProperties instance) => 'safeArea': instance.safeArea.toJson(), 'brightness': _$BrightnessModelEnumMap[instance.brightness]!, 'signInRedirectId': ?instance.signInRedirectId, + 'previewParams': instance.previewParams, }; const _$BrightnessModelEnumMap = { 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/frame_node.g.dart b/lib/src/api/nodes/frame_node.g.dart index 48c894de..88a52fda 100644 --- a/lib/src/api/nodes/frame_node.g.dart +++ b/lib/src/api/nodes/frame_node.g.dart @@ -262,6 +262,9 @@ FrameNode _$FrameNodeFromJson(Map json) => : InkWellModel.fromJson( Map.from(json['inkWell'] as Map), ) + ..transition = json['transition'] == null + ? null + : NodeTransition.fromJson(json['transition'] as Map) ..type = json['type'] as String; Map _$FrameNodeToJson(FrameNode instance) => { @@ -301,6 +304,7 @@ Map _$FrameNodeToJson(FrameNode instance) => { 'isMask': instance.isMask, 'effects': instance.effects.map((e) => e.toJson()).toList(), 'inkWell': ?instance.inkWell?.toJson(), + 'transition': ?instance.transition?.toJson(), 'fills': instance.fills.map((e) => e.toJson()).toList(), 'strokes': instance.strokes.map((e) => e.toJson()).toList(), 'strokeWeight': instance.strokeWeight, diff --git a/lib/src/api/nodes/freeform_placeholder_node.g.dart b/lib/src/api/nodes/freeform_placeholder_node.g.dart index 1f9d5453..d5dcfcfc 100644 --- a/lib/src/api/nodes/freeform_placeholder_node.g.dart +++ b/lib/src/api/nodes/freeform_placeholder_node.g.dart @@ -210,6 +210,9 @@ FreeformPlaceholderNode _$FreeformPlaceholderNodeFromJson(Map json) => : InkWellModel.fromJson( Map.from(json['inkWell'] as Map), ) + ..transition = json['transition'] == null + ? null + : NodeTransition.fromJson(json['transition'] as Map) ..type = json['type'] as String; Map _$FreeformPlaceholderNodeToJson( @@ -251,6 +254,7 @@ Map _$FreeformPlaceholderNodeToJson( 'isMask': instance.isMask, 'effects': instance.effects.map((e) => e.toJson()).toList(), 'inkWell': ?instance.inkWell?.toJson(), + 'transition': ?instance.transition?.toJson(), 'fills': instance.fills.map((e) => e.toJson()).toList(), 'strokes': instance.strokes.map((e) => e.toJson()).toList(), 'strokeWeight': instance.strokeWeight, diff --git a/lib/src/api/nodes/gauge_node.dart b/lib/src/api/nodes/gauge_node.dart new file mode 100644 index 00000000..3aac11b6 --- /dev/null +++ b/lib/src/api/nodes/gauge_node.dart @@ -0,0 +1,430 @@ +import 'package:json_annotation/json_annotation.dart'; + +import '../field_access.dart'; +import '../mixins.dart'; +import '../models/models.dart'; +import 'nodes.dart'; + +part 'gauge_node.g.dart'; + +/// The default size of an arc gauge. +const double kGaugeDefaultSize = 160; + +/// The shape a [GaugeNode] draws. +enum GaugeShape { + /// A ring segment, e.g. a speedometer. Sweeps clockwise from + /// [GaugeProperties.startAngle] by [GaugeProperties.sweepAngle]. + arc, + + /// A straight bar that fills along its length: left to right, or bottom to + /// top when the node is taller than it is wide. + bar; + + /// Displayable label. + String get prettify => switch (this) { + GaugeShape.arc => 'Arc', + GaugeShape.bar => 'Bar', + }; +} + +/// How a gauge's limit line is drawn. +enum GaugeLimitStyle { + /// A solid line. + solid, + + /// A dashed line. + dashed; + + /// Displayable label. + String get prettify => switch (this) { + GaugeLimitStyle.solid => 'Solid', + GaugeLimitStyle.dashed => 'Dashed', + }; +} + +/// A gauge: a value between [GaugeProperties.min] and [GaugeProperties.max] +/// shown as a filled arc (dials) or bar (tank levels, LED meters). +/// +/// The fill is either continuous or split into [GaugeProperties.segments] +/// blocks. Optional scale ticks and a limit marker (e.g. an alarm +/// threshold) sit on top. [value], the fill [GaugeProperties.color] and the +/// [limit] can be bound to variables or formulas. +@JsonSerializable() +class GaugeNode extends SceneNode with CustomPropertiesMixin, FieldsHolder { + @override + final String type = 'gauge'; + + @override + covariant GaugeProperties properties; + + /// The current value, between [GaugeProperties.min] and + /// [GaugeProperties.max]. + double value; + + /// Where the limit marker sits, in the same units as [value]. No marker + /// when null. + double? limit; + + @override + bool get supportsPadding => false; + + /// Creates a gauge preview. Strictly used for previews, e.g. in the + /// components panel. + GaugeNode.empty() + : properties = GaugeProperties(), + value = 60, + limit = null, + super( + id: 'gauge', + name: 'Gauge', + basicBoxLocal: NodeBox(0, 0, kGaugeDefaultSize, kGaugeDefaultSize), + ); + + /// Creates a [GaugeNode] with the given data. + GaugeNode({ + required super.id, + required super.name, + required super.basicBoxLocal, + super.outerBoxLocal, + super.retainedOuterBoxLocal, + super.visible, + super.enabled, + super.rotationDegrees, + super.alignment, + super.margin, + super.padding, + super.horizontalFit, + super.verticalFit, + super.flex, + super.constraints, + super.edgePins, + super.aspectRatioLock, + super.positioningMode, + super.parentID, + super.reactions, + // Custom properties. + this.value = 0, + this.limit, + required this.properties, + super.variables, + super.multipleVariables, + }) { + setVariablesMixin( + variables: variables, + multipleVariables: multipleVariables, + ); + } + + @override + FieldsMap generateFields() => { + ...super.generateFields(), + 'value': NumFieldAccess( + 'Value', + 'Current value of the gauge', + () => value, + (value) => this.value = value, + ), + 'properties': ObjectFieldAccess( + 'Properties', + 'properties of the gauge', + () => properties, + (value) => properties = value, + ), + }; + + @override + List get props => [ + ...super.props, + value, + limit, + properties, + variables, + multipleVariables, + ]; + + @override + late final List propertyVariables = [ + ...super.propertyVariables, + DoubleValue(name: 'value', value: value), + DoubleValue(name: 'limit', value: limit ?? 0), + ColorValue(name: 'color', value: properties.color), + ColorValue(name: 'limitColor', value: properties.limitColor), + ]; + + /// Creates a [GaugeNode] from a JSON object. + factory GaugeNode.fromJson(Map json) => _$GaugeNodeFromJson(json); + + @override + Map toJson() => _$GaugeNodeOmitDefaults(this, _$GaugeNodeToJson(this)); +} + +/// Configurable properties of a [GaugeNode]. +@JsonSerializable() +class GaugeProperties extends CustomProperties with FieldsHolder { + /// Arc or bar. + GaugeShape shape; + + /// The value of an empty gauge. + double min; + + /// The value of a full gauge. + double max; + + /// Fill color. Bindable as `color`. + ColorRGBA color; + + /// Color of the unfilled part (the track, or unlit segments). + ColorRGBA trackColor; + + /// Opacity of the fill at its start (the bottom of a vertical bar), from 0 + /// to 1. Below 1 the fill fades from [color] at its end to this opacity at + /// its start, like liquid in a sight glass. + double fillStartOpacity; + + /// Number of blocks the gauge is split into, like LEDs. 0 draws a + /// continuous fill. A segment is lit when it lies fully below the value + /// (rounded to the nearest segment). + int segments; + + /// Space between segments: degrees for an arc, logical pixels for a bar. + double segmentGap; + + /// Arc thickness in logical pixels. 0 uses 7.5% of the gauge's width. + double thickness; + + /// Where the arc starts, in degrees clockwise from 3 o'clock. 135 starts + /// at 7:30. + double startAngle; + + /// How far the arc sweeps clockwise, in degrees. + double sweepAngle; + + /// Corner radius of a bar (its well) and of bar segments. + double cornerRadius; + + /// Background of a bar's well, behind the track. Transparent for none. + ColorRGBA backgroundColor; + + /// 1 px border around a bar's well. Transparent for none. + ColorRGBA borderColor; + + /// Space between a bar's well and its fill. + double inset; + + /// Thickness of a line drawn across the end of the fill (the meniscus of a + /// liquid). 0 for none. + double edgeWidth; + + /// Color of the [edgeWidth] line. + ColorRGBA edgeColor; + + /// Number of scale divisions. An arc draws ticks inside the ring; a bar + /// draws graduation lines across the well. 0 for none. + int ticks; + + /// Every this many ticks is a major tick: longer (and thicker on arcs). + /// 0 makes every tick minor. + int majorTickEvery; + + /// Tick color. + ColorRGBA tickColor; + + /// Color of the limit marker. Bindable as `limitColor`. + ColorRGBA limitColor; + + /// Thickness of the limit marker line. + double limitWidth; + + /// Solid or dashed limit line. + GaugeLimitStyle limitStyle; + + /// Whether a bar's limit line has a small triangle pointing at it from the + /// start edge. + bool limitPointer; + + /// Whether value changes animate. + bool animate; + + /// Animation duration in milliseconds. + int animationDurationInMillis; + + /// Creates [GaugeProperties] with the given data. + GaugeProperties({ + this.shape = GaugeShape.arc, + this.min = 0, + this.max = 100, + this.color = const ColorRGBA(r: 0.24, g: 0.84, b: 0.55, a: 1), + this.trackColor = const ColorRGBA(r: 0.16, g: 0.18, b: 0.21, a: 1), + this.fillStartOpacity = 1, + this.segments = 0, + this.segmentGap = 1.25, + this.thickness = 0, + this.startAngle = 135, + this.sweepAngle = 270, + this.cornerRadius = 0, + this.backgroundColor = ColorRGBA.transparent, + this.borderColor = ColorRGBA.transparent, + this.inset = 0, + this.edgeWidth = 0, + this.edgeColor = ColorRGBA.white, + this.ticks = 0, + this.majorTickEvery = 0, + this.tickColor = const ColorRGBA(r: 0.23, g: 0.25, b: 0.28, a: 1), + this.limitColor = const ColorRGBA(r: 0.95, g: 0.76, b: 0.19, a: 1), + this.limitWidth = 3, + this.limitStyle = GaugeLimitStyle.solid, + this.limitPointer = false, + this.animate = true, + this.animationDurationInMillis = 700, + }); + + /// Duplicates these properties with the given overrides. + GaugeProperties copyWith({ + GaugeShape? shape, + double? min, + double? max, + ColorRGBA? color, + ColorRGBA? trackColor, + double? fillStartOpacity, + int? segments, + double? segmentGap, + double? thickness, + double? startAngle, + double? sweepAngle, + double? cornerRadius, + ColorRGBA? backgroundColor, + ColorRGBA? borderColor, + double? inset, + double? edgeWidth, + ColorRGBA? edgeColor, + int? ticks, + int? majorTickEvery, + ColorRGBA? tickColor, + ColorRGBA? limitColor, + double? limitWidth, + GaugeLimitStyle? limitStyle, + bool? limitPointer, + bool? animate, + int? animationDurationInMillis, + }) => GaugeProperties( + shape: shape ?? this.shape, + min: min ?? this.min, + max: max ?? this.max, + color: color ?? this.color, + trackColor: trackColor ?? this.trackColor, + fillStartOpacity: fillStartOpacity ?? this.fillStartOpacity, + segments: segments ?? this.segments, + segmentGap: segmentGap ?? this.segmentGap, + thickness: thickness ?? this.thickness, + startAngle: startAngle ?? this.startAngle, + sweepAngle: sweepAngle ?? this.sweepAngle, + cornerRadius: cornerRadius ?? this.cornerRadius, + backgroundColor: backgroundColor ?? this.backgroundColor, + borderColor: borderColor ?? this.borderColor, + inset: inset ?? this.inset, + edgeWidth: edgeWidth ?? this.edgeWidth, + edgeColor: edgeColor ?? this.edgeColor, + ticks: ticks ?? this.ticks, + majorTickEvery: majorTickEvery ?? this.majorTickEvery, + tickColor: tickColor ?? this.tickColor, + limitColor: limitColor ?? this.limitColor, + limitWidth: limitWidth ?? this.limitWidth, + limitStyle: limitStyle ?? this.limitStyle, + limitPointer: limitPointer ?? this.limitPointer, + animate: animate ?? this.animate, + animationDurationInMillis: + animationDurationInMillis ?? this.animationDurationInMillis, + ); + + @override + FieldsMap generateFields() => { + ...super.generateFields(), + 'shape': EnumFieldAccess( + 'Shape', + 'Arc or bar', + () => shape, + (value) => shape = value, + defaultValue: () => GaugeShape.arc, + options: () => GaugeShape.values, + ), + 'min': NumFieldAccess( + 'Min', + 'The value of an empty gauge', + () => min, + (value) => min = value, + ), + 'max': NumFieldAccess( + 'Max', + 'The value of a full gauge', + () => max, + (value) => max = value, + ), + 'color': ColorFieldAccess( + 'Color', + 'Fill color', + () => color, + (value) => color = value, + ), + 'trackColor': ColorFieldAccess( + 'Track Color', + 'Color of the unfilled part', + () => trackColor, + (value) => trackColor = value, + ), + 'segments': NumFieldAccess( + 'Segments', + 'Number of blocks, 0 for a continuous fill', + () => segments, + (value) => segments = value, + ), + 'ticks': NumFieldAccess( + 'Ticks', + 'Number of scale divisions', + () => ticks, + (value) => ticks = value, + ), + 'limitColor': ColorFieldAccess( + 'Limit Color', + 'Color of the limit marker', + () => limitColor, + (value) => limitColor = value, + ), + }; + + @override + List get props => [ + shape, + min, + max, + color, + trackColor, + fillStartOpacity, + segments, + segmentGap, + thickness, + startAngle, + sweepAngle, + cornerRadius, + backgroundColor, + borderColor, + inset, + edgeWidth, + edgeColor, + ticks, + majorTickEvery, + tickColor, + limitColor, + limitWidth, + limitStyle, + limitPointer, + animate, + animationDurationInMillis, + ]; + + /// Creates [GaugeProperties] from a JSON object. + factory GaugeProperties.fromJson(Map json) => _$GaugePropertiesFromJson(json); + + @override + Map toJson() => + _$GaugePropertiesOmitDefaults(this, _$GaugePropertiesToJson(this)); +} diff --git a/lib/src/api/nodes/gauge_node.g.dart b/lib/src/api/nodes/gauge_node.g.dart new file mode 100644 index 00000000..fcfff29c --- /dev/null +++ b/lib/src/api/nodes/gauge_node.g.dart @@ -0,0 +1,347 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'gauge_node.dart'; + +// ************************************************************************** +// JsonDefaultsGenerator +// ************************************************************************** + +Map _$GaugeNodeOmitDefaults( + GaugeNode instance, + Map json, +) { + if (isJsonDefault(instance.value, 0)) json.remove('value'); + 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 _$GaugePropertiesOmitDefaults( + GaugeProperties instance, + Map json, +) { + if (isJsonDefault(instance.shape, GaugeShape.arc)) json.remove('shape'); + if (isJsonDefault(instance.min, 0)) json.remove('min'); + if (isJsonDefault(instance.max, 100)) json.remove('max'); + if (isJsonDefault( + instance.color, + const ColorRGBA(r: 0.24, g: 0.84, b: 0.55, a: 1), + )) + json.remove('color'); + if (isJsonDefault( + instance.trackColor, + const ColorRGBA(r: 0.16, g: 0.18, b: 0.21, a: 1), + )) + json.remove('trackColor'); + if (isJsonDefault(instance.fillStartOpacity, 1)) + json.remove('fillStartOpacity'); + if (isJsonDefault(instance.segments, 0)) json.remove('segments'); + if (isJsonDefault(instance.segmentGap, 1.25)) json.remove('segmentGap'); + if (isJsonDefault(instance.thickness, 0)) json.remove('thickness'); + if (isJsonDefault(instance.startAngle, 135)) json.remove('startAngle'); + if (isJsonDefault(instance.sweepAngle, 270)) json.remove('sweepAngle'); + if (isJsonDefault(instance.cornerRadius, 0)) json.remove('cornerRadius'); + if (isJsonDefault(instance.backgroundColor, ColorRGBA.transparent)) + json.remove('backgroundColor'); + if (isJsonDefault(instance.borderColor, ColorRGBA.transparent)) + json.remove('borderColor'); + if (isJsonDefault(instance.inset, 0)) json.remove('inset'); + if (isJsonDefault(instance.edgeWidth, 0)) json.remove('edgeWidth'); + if (isJsonDefault(instance.edgeColor, ColorRGBA.white)) + json.remove('edgeColor'); + if (isJsonDefault(instance.ticks, 0)) json.remove('ticks'); + if (isJsonDefault(instance.majorTickEvery, 0)) json.remove('majorTickEvery'); + if (isJsonDefault( + instance.tickColor, + const ColorRGBA(r: 0.23, g: 0.25, b: 0.28, a: 1), + )) + json.remove('tickColor'); + if (isJsonDefault( + instance.limitColor, + const ColorRGBA(r: 0.95, g: 0.76, b: 0.19, a: 1), + )) + json.remove('limitColor'); + if (isJsonDefault(instance.limitWidth, 3)) json.remove('limitWidth'); + if (isJsonDefault(instance.limitStyle, GaugeLimitStyle.solid)) + json.remove('limitStyle'); + if (isJsonDefault(instance.limitPointer, false)) json.remove('limitPointer'); + if (isJsonDefault(instance.animate, true)) json.remove('animate'); + if (isJsonDefault(instance.animationDurationInMillis, 700)) + json.remove('animationDurationInMillis'); + return json; +} + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +GaugeNode _$GaugeNodeFromJson(Map json) => + GaugeNode( + id: json['id'] as String, + name: json['name'] as String, + basicBoxLocal: NodeBox.fromJson(json['basicBoxLocal']), + visible: json['visible'] as bool? ?? true, + enabled: json['enabled'] as bool? ?? true, + rotationDegrees: json['rotation'] == null + ? 0 + : castRotation(json['rotation']), + alignment: json['alignment'] == null + ? AlignmentModel.none + : AlignmentModel.fromJson(json['alignment'] as Map), + margin: json['margin'] == null + ? EdgeInsetsModel.zero + : EdgeInsetsModel.fromJson(json['margin']), + padding: json['padding'] == null + ? EdgeInsetsModel.zero + : EdgeInsetsModel.fromJson(json['padding']), + horizontalFit: + $enumDecodeNullable(_$SizeFitEnumMap, json['horizontalFit']) ?? + SizeFit.fixed, + verticalFit: + $enumDecodeNullable(_$SizeFitEnumMap, json['verticalFit']) ?? + SizeFit.fixed, + flex: (json['flex'] as num?)?.toInt() ?? 1, + constraints: json['constraints'] == null + ? const BoxConstraintsModel() + : BoxConstraintsModel.fromJson(json['constraints'] as Map), + edgePins: json['edgePins'] == null + ? EdgePinsModel.standard + : EdgePinsModel.fromJson( + Map.from(json['edgePins'] 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 [], + value: (json['value'] as num?)?.toDouble() ?? 0, + limit: (json['limit'] as num?)?.toDouble(), + properties: GaugeProperties.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 _$GaugeNodeToJson(GaugeNode 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, + 'properties': instance.properties.toJson(), + 'value': instance.value, + 'limit': ?instance.limit, +}; + +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', +}; + +GaugeProperties _$GaugePropertiesFromJson(Map json) => GaugeProperties( + shape: + $enumDecodeNullable(_$GaugeShapeEnumMap, json['shape']) ?? GaugeShape.arc, + min: (json['min'] as num?)?.toDouble() ?? 0, + max: (json['max'] as num?)?.toDouble() ?? 100, + color: json['color'] == null + ? const ColorRGBA(r: 0.24, g: 0.84, b: 0.55, a: 1) + : ColorRGBA.fromJson(json['color']), + trackColor: json['trackColor'] == null + ? const ColorRGBA(r: 0.16, g: 0.18, b: 0.21, a: 1) + : ColorRGBA.fromJson(json['trackColor']), + fillStartOpacity: (json['fillStartOpacity'] as num?)?.toDouble() ?? 1, + segments: (json['segments'] as num?)?.toInt() ?? 0, + segmentGap: (json['segmentGap'] as num?)?.toDouble() ?? 1.25, + thickness: (json['thickness'] as num?)?.toDouble() ?? 0, + startAngle: (json['startAngle'] as num?)?.toDouble() ?? 135, + sweepAngle: (json['sweepAngle'] as num?)?.toDouble() ?? 270, + cornerRadius: (json['cornerRadius'] as num?)?.toDouble() ?? 0, + backgroundColor: json['backgroundColor'] == null + ? ColorRGBA.transparent + : ColorRGBA.fromJson(json['backgroundColor']), + borderColor: json['borderColor'] == null + ? ColorRGBA.transparent + : ColorRGBA.fromJson(json['borderColor']), + inset: (json['inset'] as num?)?.toDouble() ?? 0, + edgeWidth: (json['edgeWidth'] as num?)?.toDouble() ?? 0, + edgeColor: json['edgeColor'] == null + ? ColorRGBA.white + : ColorRGBA.fromJson(json['edgeColor']), + ticks: (json['ticks'] as num?)?.toInt() ?? 0, + majorTickEvery: (json['majorTickEvery'] as num?)?.toInt() ?? 0, + tickColor: json['tickColor'] == null + ? const ColorRGBA(r: 0.23, g: 0.25, b: 0.28, a: 1) + : ColorRGBA.fromJson(json['tickColor']), + limitColor: json['limitColor'] == null + ? const ColorRGBA(r: 0.95, g: 0.76, b: 0.19, a: 1) + : ColorRGBA.fromJson(json['limitColor']), + limitWidth: (json['limitWidth'] as num?)?.toDouble() ?? 3, + limitStyle: + $enumDecodeNullable(_$GaugeLimitStyleEnumMap, json['limitStyle']) ?? + GaugeLimitStyle.solid, + limitPointer: json['limitPointer'] as bool? ?? false, + animate: json['animate'] as bool? ?? true, + animationDurationInMillis: + (json['animationDurationInMillis'] as num?)?.toInt() ?? 700, +); + +Map _$GaugePropertiesToJson(GaugeProperties instance) => + { + 'shape': _$GaugeShapeEnumMap[instance.shape]!, + 'min': instance.min, + 'max': instance.max, + 'color': instance.color.toJson(), + 'trackColor': instance.trackColor.toJson(), + 'fillStartOpacity': instance.fillStartOpacity, + 'segments': instance.segments, + 'segmentGap': instance.segmentGap, + 'thickness': instance.thickness, + 'startAngle': instance.startAngle, + 'sweepAngle': instance.sweepAngle, + 'cornerRadius': instance.cornerRadius, + 'backgroundColor': instance.backgroundColor.toJson(), + 'borderColor': instance.borderColor.toJson(), + 'inset': instance.inset, + 'edgeWidth': instance.edgeWidth, + 'edgeColor': instance.edgeColor.toJson(), + 'ticks': instance.ticks, + 'majorTickEvery': instance.majorTickEvery, + 'tickColor': instance.tickColor.toJson(), + 'limitColor': instance.limitColor.toJson(), + 'limitWidth': instance.limitWidth, + 'limitStyle': _$GaugeLimitStyleEnumMap[instance.limitStyle]!, + 'limitPointer': instance.limitPointer, + 'animate': instance.animate, + 'animationDurationInMillis': instance.animationDurationInMillis, + }; + +const _$GaugeShapeEnumMap = {GaugeShape.arc: 'arc', GaugeShape.bar: 'bar'}; + +const _$GaugeLimitStyleEnumMap = { + GaugeLimitStyle.solid: 'solid', + GaugeLimitStyle.dashed: 'dashed', +}; diff --git a/lib/src/api/nodes/icon_node.g.dart b/lib/src/api/nodes/icon_node.g.dart index 59174871..bfec3ad7 100644 --- a/lib/src/api/nodes/icon_node.g.dart +++ b/lib/src/api/nodes/icon_node.g.dart @@ -172,6 +172,9 @@ IconNode _$IconNodeFromJson(Map json) => {} ..tooltip = json['tooltip'] as String? ?? '' ..zoomable = json['zoomable'] as bool? ?? false + ..transition = json['transition'] == null + ? null + : NodeTransition.fromJson(json['transition'] as Map) ..type = json['type'] as String; Map _$IconNodeToJson(IconNode instance) => { @@ -210,6 +213,7 @@ Map _$IconNodeToJson(IconNode instance) => { 'isMask': instance.isMask, 'effects': instance.effects.map((e) => e.toJson()).toList(), 'inkWell': ?instance.inkWell?.toJson(), + 'transition': ?instance.transition?.toJson(), 'properties': instance.properties.toJson(), 'type': instance.type, }; diff --git a/lib/src/api/nodes/nodes.dart b/lib/src/api/nodes/nodes.dart index 440e5adc..f0336521 100644 --- a/lib/src/api/nodes/nodes.dart +++ b/lib/src/api/nodes/nodes.dart @@ -24,6 +24,8 @@ export 'navigation_bar_node.dart'; export 'page_view_node.dart'; export 'placeholder_node.dart'; export 'progressbar_node.dart'; +export 'gauge_node.dart'; +export 'bar_chart_node.dart'; export 'radio_node.dart'; export 'rectangle_node.dart'; export 'root_node.dart'; diff --git a/lib/src/api/nodes/rectangle_node.g.dart b/lib/src/api/nodes/rectangle_node.g.dart index 3e3fc760..6ecdd378 100644 --- a/lib/src/api/nodes/rectangle_node.g.dart +++ b/lib/src/api/nodes/rectangle_node.g.dart @@ -214,6 +214,9 @@ RectangleNode _$RectangleNodeFromJson(Map json) => : InkWellModel.fromJson( Map.from(json['inkWell'] as Map), ) + ..transition = json['transition'] == null + ? null + : NodeTransition.fromJson(json['transition'] as Map) ..type = json['type'] as String; Map _$RectangleNodeToJson(RectangleNode instance) => @@ -253,6 +256,7 @@ Map _$RectangleNodeToJson(RectangleNode instance) => 'isMask': instance.isMask, 'effects': instance.effects.map((e) => e.toJson()).toList(), 'inkWell': ?instance.inkWell?.toJson(), + 'transition': ?instance.transition?.toJson(), 'fills': instance.fills.map((e) => e.toJson()).toList(), 'strokes': instance.strokes.map((e) => e.toJson()).toList(), 'strokeWeight': instance.strokeWeight, diff --git a/lib/src/api/nodes/row_column_node.g.dart b/lib/src/api/nodes/row_column_node.g.dart index ccfdfcd2..f5e30ad1 100644 --- a/lib/src/api/nodes/row_column_node.g.dart +++ b/lib/src/api/nodes/row_column_node.g.dart @@ -278,6 +278,9 @@ RowColumnNode _$RowColumnNodeFromJson(Map json) => : InkWellModel.fromJson( Map.from(json['inkWell'] as Map), ) + ..transition = json['transition'] == null + ? null + : NodeTransition.fromJson(json['transition'] as Map) ..type = json['type'] as String; Map _$RowColumnNodeToJson( @@ -319,6 +322,7 @@ Map _$RowColumnNodeToJson( 'isMask': instance.isMask, 'effects': instance.effects.map((e) => e.toJson()).toList(), 'inkWell': ?instance.inkWell?.toJson(), + 'transition': ?instance.transition?.toJson(), 'fills': instance.fills.map((e) => e.toJson()).toList(), 'strokes': instance.strokes.map((e) => e.toJson()).toList(), 'strokeWeight': instance.strokeWeight, diff --git a/lib/src/api/nodes/slider_node.dart b/lib/src/api/nodes/slider_node.dart index 89636208..820babc7 100644 --- a/lib/src/api/nodes/slider_node.dart +++ b/lib/src/api/nodes/slider_node.dart @@ -39,6 +39,25 @@ const ColorRGBA kSliderDefaultInactiveTrackColor = ColorRGBA( /// Default opacity of slider's overlay. const double kSliderDefaultOverlayOpacity = 0.12; +/// The shape of a slider's thumb. +enum SliderThumbShapeEnum { + /// A circle of [SliderProperties.thumbRadius]. + circle, + + /// A [SliderProperties.thumbWidth] × [SliderProperties.thumbHeight] + /// rounded rectangle, like a fader knob. + rectangle, +} + +/// How a slider draws the marks at its divisions. +enum SliderTickMarkStyle { + /// Dots on the track. + dot, + + /// Short lines below the track, longer at every fifth mark. + notch, +} + /// Slider is a widget that lets the user select a value from a given range. /// Refer to [Slider](https://api.flutter.dev/flutter/material/Slider-class.html) /// in Flutter for more details. @@ -50,6 +69,11 @@ class SliderNode extends SceneNode with CustomPropertiesMixin { /// Currently selected value of slider. double value; + /// Where a thin marker sits on the track, e.g. the current reading next + /// to an alarm limit the slider sets. No marker when null. Bindable as + /// `markerValue`. + double? markerValue; + /// Holds configurable properties of the slider. @override covariant SliderProperties properties; @@ -79,6 +103,7 @@ class SliderNode extends SceneNode with CustomPropertiesMixin { required super.name, required super.basicBoxLocal, required this.value, + this.markerValue, super.outerBoxLocal, super.retainedOuterBoxLocal, super.visible, @@ -114,12 +139,17 @@ class SliderNode extends SceneNode with CustomPropertiesMixin { }; @override - List get triggerTypes => [TriggerType.changed]; + List get triggerTypes => [ + TriggerType.changed, + TriggerType.changeEnd, + ]; @override late final List propertyVariables = [ ...super.propertyVariables, DoubleValue(name: 'value', value: value), + DoubleValue(name: 'markerValue', value: markerValue ?? 0), + ColorValue(name: 'activeTrackColor', value: properties.activeTrackColor), ]; /// Creates a [SliderNode] instance from a JSON object. @@ -224,6 +254,56 @@ class SliderProperties extends CustomProperties with FieldsHolder { /// room for the thumb's overlay. Mirrors `Slider.padding`. EdgeInsetsModel? padding; + /// The thumb's shape. + SliderThumbShapeEnum thumbShape; + + /// Width of a rectangle thumb. + double thumbWidth; + + /// Height of a rectangle thumb. + double thumbHeight; + + /// Corner radius of a rectangle thumb. + double thumbCornerRadius; + + /// Border drawn around the thumb. Transparent for none. + ColorRGBA thumbBorderColor; + + /// Width of [thumbBorderColor]. + double thumbBorderWidth; + + /// Color of a short grip line across a rectangle thumb. Transparent for + /// none. + ColorRGBA thumbGripColor; + + /// Whether the active track runs from the thumb to the end instead of + /// from the start to the thumb, e.g. to shade the "above the limit" zone. + bool fillFromEnd; + + /// How division marks are drawn. + SliderTickMarkStyle tickMarkStyle; + + /// Draw a notch only at every this many divisions, e.g. 2 for a notch + /// every 10% on a slider that snaps every 5%. Every fifth notch drawn is a + /// long one. + int notchEvery; + + /// Color of the [SliderNode.markerValue] marker. + ColorRGBA markerColor; + + /// Width of the marker. + double markerWidth; + + /// Height of the marker, centered on the track. + double markerHeight; + + /// The lowest value the thumb can be dragged to, when the track's range + /// ([min] to [max]) is wider than what may be picked. Null for [min]. + double? selectableMin; + + /// The highest value the thumb can be dragged to. Null for [max]. + double? selectableMax; + /// Creates a [SliderProperties] instance with the given data. SliderProperties({ this.activeTrackColor = ColorRGBA.black, @@ -255,6 +335,21 @@ class SliderProperties extends CustomProperties with FieldsHolder { this.secondaryTrackValue, this.secondaryActiveTrackColor, this.padding, + this.thumbShape = SliderThumbShapeEnum.circle, + this.thumbWidth = 14, + this.thumbHeight = 24, + this.thumbCornerRadius = 2, + this.thumbBorderColor = ColorRGBA.transparent, + this.thumbBorderWidth = 2, + this.thumbGripColor = ColorRGBA.transparent, + this.fillFromEnd = false, + this.tickMarkStyle = SliderTickMarkStyle.dot, + this.notchEvery = 1, + this.markerColor = ColorRGBA.white, + this.markerWidth = 2, + this.markerHeight = 14, + this.selectableMin, + this.selectableMax, }); /// Duplicates this [SliderProperties] instance with the given data @@ -289,6 +384,21 @@ class SliderProperties extends CustomProperties with FieldsHolder { double? secondaryTrackValue, ColorRGBA? secondaryActiveTrackColor, EdgeInsetsModel? padding, + SliderThumbShapeEnum? thumbShape, + double? thumbWidth, + double? thumbHeight, + double? thumbCornerRadius, + ColorRGBA? thumbBorderColor, + double? thumbBorderWidth, + ColorRGBA? thumbGripColor, + bool? fillFromEnd, + SliderTickMarkStyle? tickMarkStyle, + int? notchEvery, + ColorRGBA? markerColor, + double? markerWidth, + double? markerHeight, + double? selectableMin, + double? selectableMax, }) { return SliderProperties( activeTrackColor: activeTrackColor ?? this.activeTrackColor, @@ -326,6 +436,21 @@ class SliderProperties extends CustomProperties with FieldsHolder { secondaryActiveTrackColor: secondaryActiveTrackColor ?? this.secondaryActiveTrackColor, padding: padding ?? this.padding, + thumbShape: thumbShape ?? this.thumbShape, + thumbWidth: thumbWidth ?? this.thumbWidth, + thumbHeight: thumbHeight ?? this.thumbHeight, + thumbCornerRadius: thumbCornerRadius ?? this.thumbCornerRadius, + thumbBorderColor: thumbBorderColor ?? this.thumbBorderColor, + thumbBorderWidth: thumbBorderWidth ?? this.thumbBorderWidth, + thumbGripColor: thumbGripColor ?? this.thumbGripColor, + fillFromEnd: fillFromEnd ?? this.fillFromEnd, + tickMarkStyle: tickMarkStyle ?? this.tickMarkStyle, + notchEvery: notchEvery ?? this.notchEvery, + markerColor: markerColor ?? this.markerColor, + markerWidth: markerWidth ?? this.markerWidth, + markerHeight: markerHeight ?? this.markerHeight, + selectableMin: selectableMin ?? this.selectableMin, + selectableMax: selectableMax ?? this.selectableMax, ); } @@ -561,6 +686,21 @@ class SliderProperties extends CustomProperties with FieldsHolder { secondaryTrackValue, secondaryActiveTrackColor, padding, + thumbShape, + thumbWidth, + thumbHeight, + thumbCornerRadius, + thumbBorderColor, + thumbBorderWidth, + thumbGripColor, + fillFromEnd, + tickMarkStyle, + notchEvery, + markerColor, + markerWidth, + markerHeight, + selectableMin, + selectableMax, ]; /// Creates a [SliderProperties] instance from a JSON object. diff --git a/lib/src/api/nodes/slider_node.g.dart b/lib/src/api/nodes/slider_node.g.dart index 93ef55f9..75f5a0a1 100644 --- a/lib/src/api/nodes/slider_node.g.dart +++ b/lib/src/api/nodes/slider_node.g.dart @@ -102,6 +102,26 @@ Map _$SliderPropertiesOmitDefaults( json.remove('overlayRadius'); if (isJsonDefault(instance.deriveOverlayColorFromThumb, true)) json.remove('deriveOverlayColorFromThumb'); + if (isJsonDefault(instance.thumbShape, SliderThumbShapeEnum.circle)) + json.remove('thumbShape'); + if (isJsonDefault(instance.thumbWidth, 14)) json.remove('thumbWidth'); + if (isJsonDefault(instance.thumbHeight, 24)) json.remove('thumbHeight'); + if (isJsonDefault(instance.thumbCornerRadius, 2)) + json.remove('thumbCornerRadius'); + if (isJsonDefault(instance.thumbBorderColor, ColorRGBA.transparent)) + json.remove('thumbBorderColor'); + if (isJsonDefault(instance.thumbBorderWidth, 2)) + json.remove('thumbBorderWidth'); + if (isJsonDefault(instance.thumbGripColor, ColorRGBA.transparent)) + json.remove('thumbGripColor'); + if (isJsonDefault(instance.fillFromEnd, false)) json.remove('fillFromEnd'); + if (isJsonDefault(instance.tickMarkStyle, SliderTickMarkStyle.dot)) + json.remove('tickMarkStyle'); + if (isJsonDefault(instance.notchEvery, 1)) json.remove('notchEvery'); + if (isJsonDefault(instance.markerColor, ColorRGBA.white)) + json.remove('markerColor'); + if (isJsonDefault(instance.markerWidth, 2)) json.remove('markerWidth'); + if (isJsonDefault(instance.markerHeight, 14)) json.remove('markerHeight'); return json; } @@ -115,6 +135,7 @@ SliderNode _$SliderNodeFromJson(Map json) => name: json['name'] as String, basicBoxLocal: NodeBox.fromJson(json['basicBoxLocal']), value: (json['value'] as num).toDouble(), + markerValue: (json['markerValue'] as num?)?.toDouble(), visible: json['visible'] as bool? ?? true, enabled: json['enabled'] as bool? ?? true, alignment: json['alignment'] == null @@ -233,6 +254,7 @@ Map _$SliderNodeToJson(SliderNode instance) => 'enabled': instance.enabled, 'type': instance.type, 'value': instance.value, + 'markerValue': ?instance.markerValue, 'properties': instance.properties.toJson(), }; @@ -330,6 +352,34 @@ SliderProperties _$SliderPropertiesFromJson(Map json) => SliderProperties( padding: json['padding'] == null ? null : EdgeInsetsModel.fromJson(json['padding']), + thumbShape: + $enumDecodeNullable(_$SliderThumbShapeEnumEnumMap, json['thumbShape']) ?? + SliderThumbShapeEnum.circle, + thumbWidth: (json['thumbWidth'] as num?)?.toDouble() ?? 14, + thumbHeight: (json['thumbHeight'] as num?)?.toDouble() ?? 24, + thumbCornerRadius: (json['thumbCornerRadius'] as num?)?.toDouble() ?? 2, + thumbBorderColor: json['thumbBorderColor'] == null + ? ColorRGBA.transparent + : ColorRGBA.fromJson(json['thumbBorderColor']), + thumbBorderWidth: (json['thumbBorderWidth'] as num?)?.toDouble() ?? 2, + thumbGripColor: json['thumbGripColor'] == null + ? ColorRGBA.transparent + : ColorRGBA.fromJson(json['thumbGripColor']), + fillFromEnd: json['fillFromEnd'] as bool? ?? false, + tickMarkStyle: + $enumDecodeNullable( + _$SliderTickMarkStyleEnumMap, + json['tickMarkStyle'], + ) ?? + SliderTickMarkStyle.dot, + notchEvery: (json['notchEvery'] as num?)?.toInt() ?? 1, + markerColor: json['markerColor'] == null + ? ColorRGBA.white + : ColorRGBA.fromJson(json['markerColor']), + markerWidth: (json['markerWidth'] as num?)?.toDouble() ?? 2, + markerHeight: (json['markerHeight'] as num?)?.toDouble() ?? 14, + selectableMin: (json['selectableMin'] as num?)?.toDouble(), + selectableMax: (json['selectableMax'] as num?)?.toDouble(), ); Map _$SliderPropertiesToJson( @@ -366,6 +416,21 @@ Map _$SliderPropertiesToJson( 'secondaryTrackValue': ?instance.secondaryTrackValue, 'secondaryActiveTrackColor': ?instance.secondaryActiveTrackColor?.toJson(), 'padding': ?instance.padding?.toJson(), + 'thumbShape': _$SliderThumbShapeEnumEnumMap[instance.thumbShape]!, + 'thumbWidth': instance.thumbWidth, + 'thumbHeight': instance.thumbHeight, + 'thumbCornerRadius': instance.thumbCornerRadius, + 'thumbBorderColor': instance.thumbBorderColor.toJson(), + 'thumbBorderWidth': instance.thumbBorderWidth, + 'thumbGripColor': instance.thumbGripColor.toJson(), + 'fillFromEnd': instance.fillFromEnd, + 'tickMarkStyle': _$SliderTickMarkStyleEnumMap[instance.tickMarkStyle]!, + 'notchEvery': instance.notchEvery, + 'markerColor': instance.markerColor.toJson(), + 'markerWidth': instance.markerWidth, + 'markerHeight': instance.markerHeight, + 'selectableMin': ?instance.selectableMin, + 'selectableMax': ?instance.selectableMax, }; const _$SliderTrackShapeEnumEnumMap = { @@ -388,3 +453,13 @@ const _$ShowValueIndicatorCEnumMap = { ShowValueIndicatorC.alwaysVisible: 'alwaysVisible', ShowValueIndicatorC.never: 'never', }; + +const _$SliderThumbShapeEnumEnumMap = { + SliderThumbShapeEnum.circle: 'circle', + SliderThumbShapeEnum.rectangle: 'rectangle', +}; + +const _$SliderTickMarkStyleEnumMap = { + SliderTickMarkStyle.dot: 'dot', + SliderTickMarkStyle.notch: 'notch', +}; diff --git a/lib/src/api/nodes/spacer_node.dart b/lib/src/api/nodes/spacer_node.dart index 7310bb09..cc6fa325 100644 --- a/lib/src/api/nodes/spacer_node.dart +++ b/lib/src/api/nodes/spacer_node.dart @@ -41,6 +41,9 @@ class SpacerNode extends SceneNode { super.edgePins, super.parentID, super.constraints, + super.positioningMode, + super.aspectRatioLock, + super.reactions, }); /// Creates a new [SpacerNode] from a JSON data. diff --git a/lib/src/api/nodes/spacer_node.g.dart b/lib/src/api/nodes/spacer_node.g.dart index 9d7b529c..d540e561 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), + positioningMode: + $enumDecodeNullable( + _$PositioningModeEnumMap, + json['positioningMode'], + ) ?? + PositioningMode.align, + aspectRatioLock: json['aspectRatioLock'] as bool? ?? false, + 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/lib/src/api/nodes/text_node.dart b/lib/src/api/nodes/text_node.dart index 520bdaa1..a23131c8 100644 --- a/lib/src/api/nodes/text_node.dart +++ b/lib/src/api/nodes/text_node.dart @@ -154,6 +154,8 @@ class TextNode extends SceneNode with BlendMixin, TextMixin, FontMixin { late final List propertyVariables = [ ...super.propertyVariables, StringValue(name: 'characters', value: characters), + // Overrides the color of every span; set by a binding or condition. + const ColorValue(name: 'textColor', value: null), ]; @override diff --git a/lib/src/api/nodes/text_node.g.dart b/lib/src/api/nodes/text_node.g.dart index 01949d07..7f680cb7 100644 --- a/lib/src/api/nodes/text_node.g.dart +++ b/lib/src/api/nodes/text_node.g.dart @@ -216,10 +216,12 @@ TextNode _$TextNodeFromJson(Map json) => {} ..tooltip = json['tooltip'] as String? ?? '' ..zoomable = json['zoomable'] as bool? ?? false + ..transition = json['transition'] == null + ? null + : NodeTransition.fromJson(json['transition'] as Map) ..type = json['type'] as String; Map _$TextNodeToJson(TextNode instance) => { - 'characters': instance.characters, 'variables': instance.variables, 'multipleVariables': instance.multipleVariables, 'componentId': ?instance.componentId, @@ -237,14 +239,15 @@ Map _$TextNodeToJson(TextNode instance) => { 'flex': instance.flex, 'aspectRatioLock': instance.aspectRatioLock, 'alignment': instance.alignment.toJson(), + 'characters': instance.characters, 'textMixedProps': instance.textMixedProps.map((e) => e.toJson()).toList(), 'textAlignHorizontal': _$TextAlignHorizontalEnumEnumMap[instance.textAlignHorizontal]!, 'textAlignVertical': _$TextAlignVerticalEnumEnumMap[instance.textAlignVertical]!, 'paragraphIndent': instance.paragraphIndent, - 'paragraphSpacing': instance.paragraphSpacing, 'reactions': instance.reactions.map((e) => e.toJson()).toList(), + 'paragraphSpacing': instance.paragraphSpacing, 'maxLines': ?instance.maxLines, 'overflow': _$TextOverflowCEnumMap[instance.overflow]!, 'softWrap': instance.softWrap, @@ -266,6 +269,7 @@ Map _$TextNodeToJson(TextNode instance) => { 'isMask': instance.isMask, 'effects': instance.effects.map((e) => e.toJson()).toList(), 'inkWell': ?instance.inkWell?.toJson(), + 'transition': ?instance.transition?.toJson(), 'type': instance.type, }; 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/canvas_preview_params_test.dart b/test/canvas_preview_params_test.dart new file mode 100644 index 00000000..f72838b0 --- /dev/null +++ b/test/canvas_preview_params_test.dart @@ -0,0 +1,29 @@ +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:test/test.dart'; + +void main() { + test('a canvas keeps the sample route params it previews with', () { + final CanvasProperties properties = CanvasProperties( + bodyId: 'body', + previewParams: const { + 'title': 'Log black tank dump?', + 'danger': true, + }, + ); + final Map json = properties.toJson(); + expect(json['previewParams'], { + 'title': 'Log black tank dump?', + 'danger': true, + }); + expect( + CanvasProperties.fromJson(json).previewParams, + properties.previewParams, + ); + }); + + test('a canvas without preview params writes none', () { + final Map json = CanvasProperties(bodyId: 'body').toJson(); + expect(json.containsKey('previewParams'), isFalse); + expect(CanvasProperties.fromJson(json).previewParams, isEmpty); + }); +} diff --git a/test/computed_variable_json_test.dart b/test/computed_variable_json_test.dart new file mode 100644 index 00000000..012491f1 --- /dev/null +++ b/test/computed_variable_json_test.dart @@ -0,0 +1,24 @@ +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:test/test.dart'; + +void main() { + test('a computed variable round-trips its formula', () { + final variable = VariableData( + id: 'over', + name: 'over', + type: VariableType.boolean, + formula: 'level > limit', + ); + final back = VariableData.fromJson(variable.toJson()); + expect(back, variable); + expect(back.isComputed, isTrue); + expect(variable.withCanvas('c').formula, 'level > limit'); + expect(variable.copyWith(name: 'x').formula, 'level > limit'); + }); + + test('a plain variable stores no formula', () { + final json = VariableData(id: 'a', name: 'a', value: '1').toJson(); + expect(json.containsKey('formula'), isFalse); + expect(VariableData.fromJson(json).isComputed, isFalse); + }); +} diff --git a/test/corner_radius_binding_test.dart b/test/corner_radius_binding_test.dart new file mode 100644 index 00000000..8e1666d6 --- /dev/null +++ b/test/corner_radius_binding_test.dart @@ -0,0 +1,46 @@ +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:test/test.dart'; + +void main() { + group('CornerRadius from a binding value', () { + test('a number rounds every corner', () { + final CornerRadius radius = 6.typedValue()!; + expect(radius, const CornerRadius.all(RadiusModel.circular(6))); + }); + + test( + 'four numbers round top-left, top-right, bottom-right, bottom-left', + () { + final CornerRadius radius = [ + 6, + 6, + 0, + 0, + ].typedValue()!; + expect(radius.tl, const RadiusModel.circular(6)); + expect(radius.tr, const RadiusModel.circular(6)); + expect(radius.br, RadiusModel.zero); + expect(radius.bl, RadiusModel.zero); + expect(radius.linked, isFalse); + }, + ); + + test('the forms toJson writes still read back', () { + for (final CornerRadius radius in [ + const CornerRadius.all(RadiusModel.circular(12)), + const CornerRadius( + tl: RadiusModel.circular(1), + tr: RadiusModel.circular(2), + br: RadiusModel.circular(3), + bl: RadiusModel.circular(4), + ), + const CornerRadius.all( + RadiusModel.elliptical(4, 8), + type: RadiusType.elliptical, + ), + ]) { + expect(CornerRadius.fromJson(radius.toJson()), radius); + } + }); + }); +} diff --git a/test/industrial_slider_json_test.dart b/test/industrial_slider_json_test.dart new file mode 100644 index 00000000..ff0c5dde --- /dev/null +++ b/test/industrial_slider_json_test.dart @@ -0,0 +1,44 @@ +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:test/test.dart'; + +void main() { + test('slider marker, fader thumb, notches and range round-trip', () { + final node = SliderNode( + id: 's', + name: 'Limit', + basicBoxLocal: NodeBox(0, 0, 300, 32), + value: 75, + markerValue: 87, + properties: SliderProperties( + thumbShape: SliderThumbShapeEnum.rectangle, + thumbWidth: 14, + thumbHeight: 24, + thumbBorderColor: ColorRGBA.black, + thumbGripColor: ColorRGBA.black, + fillFromEnd: true, + tickMarkStyle: SliderTickMarkStyle.notch, + notchEvery: 2, + markerColor: ColorRGBA.white, + selectableMin: 5, + selectableMax: 95, + ), + ); + final back = + NodeJsonConverter.instance.fromJson(node.toJson()) as SliderNode; + expect(back.markerValue, 87); + expect(back.properties, node.properties); + expect(back.triggerTypes, contains(TriggerType.changeEnd)); + }); + + test('a slider from before keeps its defaults', () { + final json = SliderNode( + id: 's', + name: 's', + basicBoxLocal: NodeBox(0, 0, 300, 48), + value: 1, + properties: SliderProperties(), + ).toJson(); + expect(json.containsKey('markerValue'), isFalse); + expect((json['properties'] as Map).containsKey('thumbShape'), isFalse); + }); +} diff --git a/test/interval_trigger_test.dart b/test/interval_trigger_test.dart new file mode 100644 index 00000000..22271ba0 --- /dev/null +++ b/test/interval_trigger_test.dart @@ -0,0 +1,34 @@ +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:test/test.dart'; + +void main() { + test('an interval trigger round-trips its period', () { + final trigger = TriggerModel( + type: TriggerType.interval, + intervalMillis: 3000, + ); + final json = trigger.toJson(); + expect(json, {'type': 'interval', 'intervalMillis': 3000}); + expect(TriggerModel.fromJson(json), trigger); + }); + + test('the default period is omitted and read back as one second', () { + final json = TriggerModel(type: TriggerType.interval).toJson(); + expect(json, {'type': 'interval'}); + expect(TriggerModel.fromJson(json).intervalMillis, 1000); + }); + + test('a trigger from before intervals still reads', () { + expect(TriggerModel.fromJson({'type': 'click'}).type, TriggerType.click); + }); + + test('periods shorter than the minimum are clamped', () { + expect( + TriggerModel( + type: TriggerType.interval, + intervalMillis: 5, + ).effectiveIntervalMillis, + TriggerModel.minIntervalMillis, + ); + }); +} diff --git a/test/json_defaults_test.dart b/test/json_defaults_test.dart index bb2e1328..1c018c4a 100644 --- a/test/json_defaults_test.dart +++ b/test/json_defaults_test.dart @@ -177,6 +177,8 @@ List sampleNodes() { IconNode.empty(), ListTileNode.empty(), ProgressBarNode.empty(), + GaugeNode.empty(), + BarChartNode.empty(), RadioNode.empty(), SegmentedButtonNode.empty(), SliderNode.empty(), diff --git a/test/nodes/gauge_bar_chart_json_test.dart b/test/nodes/gauge_bar_chart_json_test.dart new file mode 100644 index 00000000..fd2b47ab --- /dev/null +++ b/test/nodes/gauge_bar_chart_json_test.dart @@ -0,0 +1,108 @@ +import 'dart:convert'; + +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:test/test.dart'; + +BaseNode roundTrip(BaseNode node) => NodeJsonConverter.instance.fromJson( + jsonDecode(jsonEncode(node.toJson())) as Map, +)!; + +void main() { + group('GaugeNode', () { + test('a default gauge stores only its identity and box', () { + final node = GaugeNode( + id: 'g', + name: 'Gauge', + basicBoxLocal: NodeBox(0, 0, 160, 160), + properties: GaugeProperties(), + ); + final json = node.toJson(); + expect(json['type'], 'gauge'); + expect(json['properties'], isEmpty); + expect(json.containsKey('value'), isFalse); + expect(json.containsKey('limit'), isFalse); + expect(roundTrip(node), node); + }); + + test('a tank-style bar gauge round-trips every property', () { + final node = GaugeNode( + id: 'tank', + name: 'Black tank', + basicBoxLocal: NodeBox(0, 0, 76, 220), + value: 87, + limit: 75, + variables: {'value': r'${= tanks.black * 100}'}, + properties: GaugeProperties( + shape: GaugeShape.bar, + color: ColorRGBA.black, + trackColor: ColorRGBA.transparent, + fillStartOpacity: 0.55, + segments: 0, + cornerRadius: 4, + backgroundColor: ColorRGBA.black, + borderColor: ColorRGBA.white, + inset: 3, + edgeWidth: 2, + ticks: 10, + majorTickEvery: 5, + limitStyle: GaugeLimitStyle.dashed, + limitWidth: 2, + limitPointer: true, + animate: false, + ), + ); + final back = roundTrip(node) as GaugeNode; + expect(back, node); + expect(back.properties.shape, GaugeShape.bar); + expect(back.properties.limitStyle, GaugeLimitStyle.dashed); + expect(back.limit, 75); + expect(back.variables['value'], r'${= tanks.black * 100}'); + }); + + test('exposes value, limit and colors as settable properties', () { + final node = GaugeNode( + id: 'g', + name: 'Gauge', + basicBoxLocal: NodeBox(0, 0, 160, 160), + properties: GaugeProperties(), + ); + expect( + node.propertyVariables.map((v) => v.name), + containsAll(['value', 'limit', 'color', 'limitColor']), + ); + }); + }); + + group('BarChartNode', () { + test('round-trips nullable values, labels and highlight', () { + final node = BarChartNode( + id: 'chart', + name: 'Solar by hour', + basicBoxLocal: NodeBox(0, 0, 400, 160), + values: [4, 22, 78, null, null], + labels: ['6a', '', '8a', '', '10a'], + highlight: 2, + properties: BarChartProperties( + max: 480, + gridLabels: ['160', '320', '480'], + labelFontSize: 11, + ), + ); + final back = roundTrip(node) as BarChartNode; + expect(back, node); + expect(back.values, [4, 22, 78, null, null]); + expect(back.properties.gridLabels, ['160', '320', '480']); + }); + + test('a default chart omits its defaults', () { + final node = BarChartNode( + id: 'chart', + name: 'Chart', + basicBoxLocal: NodeBox(0, 0, 320, 160), + properties: BarChartProperties(), + ); + expect(node.toJson()['properties'], isEmpty); + expect(roundTrip(node), node); + }); + }); +} diff --git a/test/nodes/node_transition_json_test.dart b/test/nodes/node_transition_json_test.dart new file mode 100644 index 00000000..747ab05b --- /dev/null +++ b/test/nodes/node_transition_json_test.dart @@ -0,0 +1,34 @@ +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:test/test.dart'; + +void main() { + test('a node without a transition writes none', () { + final RectangleNode node = RectangleNode( + id: 'r', + name: 'r', + basicBoxLocal: NodeBox(0, 0, 10, 10), + ); + expect(node.transition, isNull); + expect(node.toJson().containsKey('transition'), isFalse); + }); + + test('a transition round-trips on any node with an opacity', () { + final Map json = { + 'id': 'f', + 'name': 'f', + 'type': 'rowColumn', + 'rowColumnType': 'column', + 'basicBoxLocal': [0, 0, 10, 10], + 'transition': {'durationMillis': 1100, 'curve': 'easeInOut'}, + }; + final BaseNode node = NodeJsonConverter().fromJson(json)!; + expect( + (node as BlendMixin).transition, + const NodeTransition(durationMillis: 1100), + ); + final BaseNode again = NodeJsonConverter().fromJson(node.toJson())!; + expect((again as BlendMixin).transition?.durationMillis, 1100); + expect(const NodeTransition().toJson(), isEmpty); + expect(const NodeTransition().duration, const Duration(milliseconds: 200)); + }); +} diff --git a/test/nodes/spacer_json_test.dart b/test/nodes/spacer_json_test.dart new file mode 100644 index 00000000..32a5f36a --- /dev/null +++ b/test/nodes/spacer_json_test.dart @@ -0,0 +1,22 @@ +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:test/test.dart'; + +void main() { + test('a spacer reads from minimal JSON and round-trips', () { + final SpacerNode spacer = SpacerNode.fromJson({ + 'id': 's', + 'name': 'spacer', + 'type': 'spacer', + 'basicBoxLocal': [0, 0, 0, 0], + 'flex': 2, + }); + expect(spacer.positioningMode, PositioningMode.align); + expect(spacer.aspectRatioLock, isFalse); + expect(spacer.reactions, isEmpty); + expect(spacer.flex, 2); + + final SpacerNode again = SpacerNode.fromJson(spacer.toJson()); + expect(again.flex, 2); + expect(again.positioningMode, PositioningMode.align); + }); +} 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); diff --git a/test/show_dialog_placement_test.dart b/test/show_dialog_placement_test.dart new file mode 100644 index 00000000..e95845cb --- /dev/null +++ b/test/show_dialog_placement_test.dart @@ -0,0 +1,28 @@ +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:test/test.dart'; + +void main() { + test('a dialog from before placements reads as centered', () { + final action = ActionModel.fromJson({ + 'type': 'showDialog', + 'destinationId': 'menu', + }) as ShowDialogAction; + expect(action.placement, DialogPlacement.center); + expect(action.anchorGap, 6); + }); + + test('placement and gap round-trip; defaults are omitted', () { + final action = ShowDialogAction( + destinationId: 'menu', + placement: DialogPlacement.anchored, + anchorGap: 4, + ); + final json = action.toJson(); + expect(json['placement'], 'anchored'); + expect(ActionModel.fromJson(json), action); + expect( + ShowDialogAction(destinationId: 'x').toJson().containsKey('placement'), + isFalse, + ); + }); +}