diff --git a/CHANGELOG.md b/CHANGELOG.md index 6cb1e325..b879dbf0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,44 @@ +## Unreleased + +### Nodes + +- `gauge` node (`GaugeNode`, `GaugeProperties`, `GaugeZone`): a dial with a + bindable `value`, zone bands, LED segments, ticks and labels, a target + notch, a needle and value animation. +- `chart` node (`ChartNode`, `ChartProperties`, `ChartSeries`, + `ChartReferenceLine`): bar, line and timeline charts bound to a list + through `variables.data`; bar, line, area and tick series with stacks, + per-item colors, dashes, end dots and fill-between; reference lines; + bindable `selectedIndex` and a `changed` trigger; a timeline's `nowColor` + colors its now marker. +- `PaintModel.gradientTileMode` (clamp, repeated, mirror) for repeating + gradients such as stripes. +- `BlendMixin.pulse` (`PulseModel`): a looping fade of a node's opacity. +- Rows can wrap: `RowColumnNode.wrap`, `spacing` and `runSpacing` lay a + row's children out in lines like Flutter's `Wrap`. +- `ShowDialogAction.anchor` (`DialogAnchor.trigger`), `placement`, + `anchorGap` and `anchorOffset`: a dialog can open as a popover next to + the node that ran it. `copyWith` keeps `nonBlocking` and `enabled`. +- `ShowToastAction.accentColor` (a 4 px leading edge), `cornerRadius` and + `maxWidth`. +- Slider thumbs can be rectangles: `SliderProperties.thumbShape`, + `thumbWidth`, `thumbHeight`, `thumbCornerRadius`, `thumbBorderColor`, + `thumbBorderWidth` and `thumbGripColor`. +- `MaterialIcon.fromJson` reads a Flutter-style name with a style suffix + (`home_outlined`) as the base name in that style, so it draws. + +### Variables + +- `VariableData.isComputed` and `isFormulaValue`: a variable whose value is + a `${= ...}` formula is computed; `getValue()` returns null for it. + +### JSON + +- Gauge and chart nodes read JSON without `properties`; grid delegates, + scroll settings and placeholder type lists default when left out; a + spacer reads JSON without its positioning mode, aspect lock and + reactions. + ## 6.1.0 ### States and conditions diff --git a/lib/src/api/mixins.dart b/lib/src/api/mixins.dart index b182ef92..97c94aa4 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; + /// A looping fade of the node's opacity, or null for none. + PulseModel? pulse; + /// Sets the blend properties of the node. void setBlendMixin({ required double opacity, @@ -562,6 +565,22 @@ enum SliderTrackShapeEnum { } } +/// Shape of a slider's thumb. +enum SliderThumbShapeEnum { + /// A circle of the slider's thumb radius. + circle, + + /// A rectangle of the slider's thumb width and height, with rounded + /// corners, an optional border and an optional grip line. + rectangle; + + /// Display-able string representation of the enum. + String get prettify => switch (this) { + SliderThumbShapeEnum.circle => 'Circle', + SliderThumbShapeEnum.rectangle => 'Rectangle', + }; +} + /// When a slider shows its value indicator. Mirrors Flutter's /// `ShowValueIndicator`. enum ShowValueIndicatorC { @@ -1255,6 +1274,7 @@ mixin ScrollableMixin on BaseNode { bool get isScrollingEnforced => false; /// Whether the scrolling is enabled. + @JsonKey(defaultValue: false) late bool isScrollable; /// Direction in which the scrolling is allowed. @@ -1277,6 +1297,7 @@ mixin ScrollableMixin on BaseNode { /// Whether to use a [ListView] instead of [SingleChildScrollView] in flutter /// code-gen and transformers. + @JsonKey(defaultValue: false) late bool useFlutterListView; /// Whether to use AlwaysScrollableScrollPhysics or not. diff --git a/lib/src/api/models/action/show_dialog_action.dart b/lib/src/api/models/action/show_dialog_action.dart index 80bfdd32..e72daa5c 100644 --- a/lib/src/api/models/action/show_dialog_action.dart +++ b/lib/src/api/models/action/show_dialog_action.dart @@ -6,7 +6,94 @@ import '../models.dart'; part 'show_dialog_action.g.dart'; -/// An action that navigates to a new page. +/// Where a [ShowDialogAction] shows its canvas. +enum DialogAnchor { + /// Centered on the screen. + none, + + /// Next to the node that ran the action, like a popover or menu, at the + /// action's [ShowDialogAction.placement]. + trigger; + + /// Displayable name. + String get prettify => switch (this) { + DialogAnchor.none => 'Center of screen', + DialogAnchor.trigger => 'Anchor to trigger', + }; +} + +/// The side of the trigger an anchored [ShowDialogAction] opens on, and how +/// it lines up with the trigger along that side. +/// +/// Start and end follow the reading direction for [below] and [above] +/// placements (start is the left edge in a left-to-right app) and are the top +/// and bottom edges for [right] and [left] placements. When the dialog does +/// not fit on its side, it opens on the opposite side if there is more room +/// there. +enum DialogPlacement { + /// Below the trigger, start edges lined up. + belowStart, + + /// Below the trigger, centered on it. + belowCenter, + + /// Below the trigger, end edges lined up. + belowEnd, + + /// Above the trigger, start edges lined up. + aboveStart, + + /// Above the trigger, centered on it. + aboveCenter, + + /// Above the trigger, end edges lined up. + aboveEnd, + + /// Right of the trigger, top edges lined up. + rightStart, + + /// Right of the trigger, centered on it. + rightCenter, + + /// Right of the trigger, bottom edges lined up. + rightEnd, + + /// Left of the trigger, top edges lined up. + leftStart, + + /// Left of the trigger, centered on it. + leftCenter, + + /// Left of the trigger, bottom edges lined up. + leftEnd; + + /// Whether the dialog opens below or above the trigger, rather than beside + /// it. + bool get isVertical => index <= DialogPlacement.aboveEnd.index; + + /// Displayable name. + String get prettify => switch (this) { + DialogPlacement.belowStart => 'Below, start', + DialogPlacement.belowCenter => 'Below, center', + DialogPlacement.belowEnd => 'Below, end', + DialogPlacement.aboveStart => 'Above, start', + DialogPlacement.aboveCenter => 'Above, center', + DialogPlacement.aboveEnd => 'Above, end', + DialogPlacement.rightStart => 'Right, top', + DialogPlacement.rightCenter => 'Right, center', + DialogPlacement.rightEnd => 'Right, bottom', + DialogPlacement.leftStart => 'Left, top', + DialogPlacement.leftCenter => 'Left, center', + DialogPlacement.leftEnd => 'Left, bottom', + }; +} + +/// An action that shows a canvas as a dialog: centered on the screen, or, +/// with [anchor] set to [DialogAnchor.trigger], as a popover next to the node +/// that ran it (an account menu under an avatar, for example). +/// +/// The dialog closes when its barrier is tapped (if [barrierDismissible]) or +/// with a "Navigate back" action inside it. @JsonSerializable() class ShowDialogAction extends ActionModel with Equatable, SerializableMixin { /// Destination canvas node's ID. @@ -24,6 +111,25 @@ class ShowDialogAction extends ActionModel with Equatable, SerializableMixin { /// Whether to show a close button on the dialog. final bool showCloseButton; + /// Where the dialog shows: centered, or next to the node that ran the + /// action. + @JsonKey(unknownEnumValue: DialogAnchor.none) + final DialogAnchor anchor; + + /// The side of the trigger an anchored dialog opens on. Only used when + /// [anchor] is [DialogAnchor.trigger]. + @JsonKey(unknownEnumValue: DialogPlacement.belowStart) + final DialogPlacement placement; + + /// The space between the trigger and an anchored dialog, in logical + /// pixels. + final double anchorGap; + + /// How far an anchored dialog is shifted along the trigger's edge, in + /// logical pixels: toward the end for [DialogPlacement.isVertical] + /// placements (right in a left-to-right app), down for the others. + final double anchorOffset; + /// Creates a new [ShowDialogAction]. ShowDialogAction({ required this.destinationId, @@ -31,6 +137,10 @@ class ShowDialogAction extends ActionModel with Equatable, SerializableMixin { this.barrierColor = ColorRGBA.black54, this.barrierDismissible = true, this.showCloseButton = false, + this.anchor = DialogAnchor.none, + this.placement = DialogPlacement.belowStart, + this.anchorGap = 4, + this.anchorOffset = 0, super.nonBlocking, super.enabled, }) : params = {...params ?? {}}, @@ -43,8 +153,15 @@ class ShowDialogAction extends ActionModel with Equatable, SerializableMixin { barrierDismissible, barrierColor, showCloseButton, + anchor, + placement, + anchorGap, + anchorOffset, ]; + /// Whether the dialog opens next to the node that ran the action. + bool get isAnchored => anchor == DialogAnchor.trigger; + /// Duplicates this [ShowDialogAction] with given data overrides. ShowDialogAction copyWith({ String? destinationId, @@ -52,12 +169,22 @@ class ShowDialogAction extends ActionModel with Equatable, SerializableMixin { bool? barrierDismissible, ColorRGBA? barrierColor, bool? showCloseButton, + DialogAnchor? anchor, + DialogPlacement? placement, + double? anchorGap, + double? anchorOffset, }) => ShowDialogAction( destinationId: destinationId ?? this.destinationId, params: {...this.params, ...params ?? {}}, barrierDismissible: barrierDismissible ?? this.barrierDismissible, barrierColor: barrierColor ?? this.barrierColor, showCloseButton: showCloseButton ?? this.showCloseButton, + anchor: anchor ?? this.anchor, + placement: placement ?? this.placement, + anchorGap: anchorGap ?? this.anchorGap, + anchorOffset: anchorOffset ?? this.anchorOffset, + nonBlocking: nonBlocking, + enabled: enabled, ); /// 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..910ffc27 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,11 @@ Map _$ShowDialogActionOmitDefaults( json.remove('barrierColor'); if (isJsonDefault(instance.showCloseButton, false)) json.remove('showCloseButton'); + if (isJsonDefault(instance.anchor, DialogAnchor.none)) json.remove('anchor'); + if (isJsonDefault(instance.placement, DialogPlacement.belowStart)) + json.remove('placement'); + if (isJsonDefault(instance.anchorGap, 4)) json.remove('anchorGap'); + if (isJsonDefault(instance.anchorOffset, 0)) json.remove('anchorOffset'); if (isJsonDefault(instance.nonBlocking, false)) json.remove('nonBlocking'); if (isJsonDefault(instance.enabled, true)) json.remove('enabled'); return json; @@ -35,6 +40,22 @@ ShowDialogAction _$ShowDialogActionFromJson(Map json) => ShowDialogAction( : ColorRGBA.fromJson(json['barrierColor']), barrierDismissible: json['barrierDismissible'] as bool? ?? true, showCloseButton: json['showCloseButton'] as bool? ?? false, + anchor: + $enumDecodeNullable( + _$DialogAnchorEnumMap, + json['anchor'], + unknownValue: DialogAnchor.none, + ) ?? + DialogAnchor.none, + placement: + $enumDecodeNullable( + _$DialogPlacementEnumMap, + json['placement'], + unknownValue: DialogPlacement.belowStart, + ) ?? + DialogPlacement.belowStart, + anchorGap: (json['anchorGap'] as num?)?.toDouble() ?? 4, + anchorOffset: (json['anchorOffset'] as num?)?.toDouble() ?? 0, nonBlocking: json['nonBlocking'] as bool? ?? false, enabled: json['enabled'] as bool? ?? true, )..type = $enumDecode(_$ActionTypeEnumMap, json['type']); @@ -49,8 +70,32 @@ Map _$ShowDialogActionToJson(ShowDialogAction instance) => 'barrierDismissible': instance.barrierDismissible, 'barrierColor': ?instance.barrierColor?.toJson(), 'showCloseButton': instance.showCloseButton, + 'anchor': _$DialogAnchorEnumMap[instance.anchor]!, + 'placement': _$DialogPlacementEnumMap[instance.placement]!, + 'anchorGap': instance.anchorGap, + 'anchorOffset': instance.anchorOffset, }; +const _$DialogAnchorEnumMap = { + DialogAnchor.none: 'none', + DialogAnchor.trigger: 'trigger', +}; + +const _$DialogPlacementEnumMap = { + DialogPlacement.belowStart: 'belowStart', + DialogPlacement.belowCenter: 'belowCenter', + DialogPlacement.belowEnd: 'belowEnd', + DialogPlacement.aboveStart: 'aboveStart', + DialogPlacement.aboveCenter: 'aboveCenter', + DialogPlacement.aboveEnd: 'aboveEnd', + DialogPlacement.rightStart: 'rightStart', + DialogPlacement.rightCenter: 'rightCenter', + DialogPlacement.rightEnd: 'rightEnd', + DialogPlacement.leftStart: 'leftStart', + DialogPlacement.leftCenter: 'leftCenter', + DialogPlacement.leftEnd: 'leftEnd', +}; + 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..245e4fcb 100644 --- a/lib/src/api/models/action/show_toast_action.dart +++ b/lib/src/api/models/action/show_toast_action.dart @@ -91,6 +91,16 @@ class ShowToastAction extends ActionModel with Equatable, SerializableMixin { /// [ToastPosition.bottom]. final double bottomOffset; + /// Color of a 4 px edge on the toast's leading side, or null for none. + final ColorRGBA? accentColor; + + /// Corner radius, or null for the default: a pill, or the theme's button + /// radius when the toast has a button. + final double? cornerRadius; + + /// The widest the toast gets. + final double maxWidth; + /// Creates a new [ShowToastAction]. ShowToastAction({ required this.message, @@ -103,6 +113,9 @@ class ShowToastAction extends ActionModel with Equatable, SerializableMixin { List? actions, this.icon, this.bottomOffset = 104, + this.accentColor, + this.cornerRadius, + this.maxWidth = 560, super.nonBlocking, super.enabled, }) : actions = actions ?? [], @@ -123,6 +136,9 @@ class ShowToastAction extends ActionModel with Equatable, SerializableMixin { actions, icon, bottomOffset, + accentColor, + cornerRadius, + maxWidth, ]; /// Duplicates this [ShowToastAction] with given data overrides. @@ -140,6 +156,10 @@ class ShowToastAction extends ActionModel with Equatable, SerializableMixin { MultiSourceIconModel? icon, bool clearIcon = false, double? bottomOffset, + ColorRGBA? accentColor, + bool clearAccentColor = false, + double? cornerRadius, + double? maxWidth, }) => ShowToastAction( message: message ?? this.message, duration: duration ?? this.duration, @@ -153,6 +173,9 @@ class ShowToastAction extends ActionModel with Equatable, SerializableMixin { actions: actions ?? this.actions, icon: clearIcon ? null : icon ?? this.icon, bottomOffset: bottomOffset ?? this.bottomOffset, + accentColor: clearAccentColor ? null : accentColor ?? this.accentColor, + cornerRadius: cornerRadius ?? this.cornerRadius, + maxWidth: maxWidth ?? this.maxWidth, 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..d30689bf 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,7 @@ 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.maxWidth, 560)) json.remove('maxWidth'); if (isJsonDefault(instance.nonBlocking, false)) json.remove('nonBlocking'); if (isJsonDefault(instance.enabled, true)) json.remove('enabled'); return json; @@ -57,6 +58,11 @@ ShowToastAction _$ShowToastActionFromJson(Map json) => ShowToastAction( ? null : MultiSourceIconModel.fromJson(json['icon'] as Map), bottomOffset: (json['bottomOffset'] as num?)?.toDouble() ?? 104, + accentColor: json['accentColor'] == null + ? null + : ColorRGBA.fromJson(json['accentColor']), + cornerRadius: (json['cornerRadius'] as num?)?.toDouble(), + maxWidth: (json['maxWidth'] as num?)?.toDouble() ?? 560, nonBlocking: json['nonBlocking'] as bool? ?? false, enabled: json['enabled'] as bool? ?? true, )..type = $enumDecode(_$ActionTypeEnumMap, json['type']); @@ -76,6 +82,9 @@ Map _$ShowToastActionToJson(ShowToastAction instance) => 'actions': instance.actions.map((e) => e.toJson()).toList(), 'icon': ?instance.icon?.toJson(), 'bottomOffset': instance.bottomOffset, + 'accentColor': ?instance.accentColor?.toJson(), + 'cornerRadius': ?instance.cornerRadius, + 'maxWidth': instance.maxWidth, }; const _$ToastStyleEnumMap = { diff --git a/lib/src/api/models/icon_model.dart b/lib/src/api/models/icon_model.dart index e147919b..ebd238e4 100644 --- a/lib/src/api/models/icon_model.dart +++ b/lib/src/api/models/icon_model.dart @@ -132,9 +132,25 @@ class MaterialIcon extends IconModel { ); /// Factory constructor for creating a new [MaterialIcon] instance from - /// JSON data. - factory MaterialIcon.fromJson(Map json) => - _$MaterialIconFromJson(json); + /// JSON data. A Flutter-style name with a style suffix and no style of its + /// own, such as `home_outlined`, becomes the base name in that style: the + /// icon is drawn from Google's icon set by base name and style. + factory MaterialIcon.fromJson(Map json) { + final MaterialIcon icon = _$MaterialIconFromJson(json); + if (icon.style != MaterialIconStyle.filled) return icon; + final RegExpMatch? match = _styleSuffix.firstMatch(icon.name); + if (match == null) return icon; + return icon.copyWith( + name: icon.name.substring(0, match.start), + style: switch (match[1]) { + 'outlined' => MaterialIconStyle.outlined, + 'rounded' => MaterialIconStyle.rounded, + _ => MaterialIconStyle.sharp, + }, + ); + } + + static final RegExp _styleSuffix = RegExp(r'_(outlined|rounded|sharp)$'); @override List get props => [codepoint, fontFamily, name, style, version]; diff --git a/lib/src/api/models/models.dart b/lib/src/api/models/models.dart index 3b86dfac..66bfb8a8 100644 --- a/lib/src/api/models/models.dart +++ b/lib/src/api/models/models.dart @@ -55,6 +55,7 @@ export 'list_tile_control_affinity.dart'; export 'navigation_bar_item.dart'; export 'node_box.dart'; export 'paint.dart'; +export 'pulse.dart'; export 'paint_type.dart'; export 'pins_model.dart'; export 'property_overrides.dart'; diff --git a/lib/src/api/models/paint.dart b/lib/src/api/models/paint.dart index 85644755..a3adfded 100644 --- a/lib/src/api/models/paint.dart +++ b/lib/src/api/models/paint.dart @@ -29,6 +29,27 @@ enum ImageRepeatEnum { final String label; } +/// How a gradient paints outside the span between its first and last +/// handle. +enum GradientTileMode { + /// The edge colors extend outward (Flutter's default). + clamp, + + /// The gradient repeats, e.g. stripes from a short gradient with hard + /// stops. + repeated, + + /// The gradient repeats, mirrored every other time. + mirror; + + /// Display name. + String get prettify => switch (this) { + GradientTileMode.clamp => 'Clamp', + GradientTileMode.repeated => 'Repeat', + GradientTileMode.mirror => 'Mirror', + }; +} + /// Generates a random id if the given [json] does not contain a value for the /// given [key]. Required for backwards compatibility. Object? _readId(Map json, String key) => json[key] ?? generateId(); @@ -88,6 +109,10 @@ class PaintModel with Equatable, SerializableMixin, FieldsHolder { /// neighboring gradient stops. final List? gradientStops; + /// How the gradient paints beyond its handles. [GradientTileMode.repeated] + /// with hard stops makes stripes (hazard tape, hatching). + final GradientTileMode gradientTileMode; + // For image paint: /// Affine transform applied to the image. @@ -254,6 +279,7 @@ class PaintModel with Equatable, SerializableMixin, FieldsHolder { imageHash = null, gradientTransform = null, gradientStops = null, + gradientTileMode = GradientTileMode.clamp, imageTransform = null, imageName = null, fit = Fit.none, @@ -293,6 +319,7 @@ class PaintModel with Equatable, SerializableMixin, FieldsHolder { type = PaintType.image, gradientTransform = null, gradientStops = null, + gradientTileMode = GradientTileMode.clamp, color = null; /// Creates [PaintModel] with linear gradient. @@ -302,6 +329,7 @@ class PaintModel with Equatable, SerializableMixin, FieldsHolder { this.opacity = 1, this.gradientTransform, this.gradientStops, + this.gradientTileMode = GradientTileMode.clamp, this.blendMode = BlendModeC.srcOver, }) : type = PaintType.gradientLinear, color = null, @@ -328,6 +356,7 @@ class PaintModel with Equatable, SerializableMixin, FieldsHolder { this.opacity = 1, this.gradientTransform, this.gradientStops, + this.gradientTileMode = GradientTileMode.clamp, this.blendMode = BlendModeC.srcOver, }) : type = PaintType.gradientRadial, color = null, @@ -354,6 +383,7 @@ class PaintModel with Equatable, SerializableMixin, FieldsHolder { this.opacity = 1, this.gradientTransform, this.gradientStops, + this.gradientTileMode = GradientTileMode.clamp, this.blendMode = BlendModeC.srcOver, }) : type = PaintType.gradientAngular, color = null, @@ -383,6 +413,7 @@ class PaintModel with Equatable, SerializableMixin, FieldsHolder { this.blendMode = BlendModeC.srcOver, this.gradientTransform, this.gradientStops, + this.gradientTileMode = GradientTileMode.clamp, this.imageTransform, this.hasImageBytes = false, this.downloadUrl, @@ -409,6 +440,7 @@ class PaintModel with Equatable, SerializableMixin, FieldsHolder { BlendModeC? blendMode, List? gradientTransform, List? gradientStops, + GradientTileMode? gradientTileMode, List>? imageTransform, double? scalingFactor, String? downloadUrl, @@ -437,6 +469,7 @@ class PaintModel with Equatable, SerializableMixin, FieldsHolder { blendMode: blendMode ?? this.blendMode, gradientTransform: gradientTransform ?? this.gradientTransform, gradientStops: gradientStops ?? this.gradientStops, + gradientTileMode: gradientTileMode ?? this.gradientTileMode, imageTransform: imageTransform ?? this.imageTransform, hasImageBytes: hasImageBytes ?? this.hasImageBytes, downloadUrl: downloadUrl ?? this.downloadUrl, @@ -487,6 +520,7 @@ class PaintModel with Equatable, SerializableMixin, FieldsHolder { visible: visible, gradientStops: gradientStops, gradientTransform: gradientTransform, + gradientTileMode: gradientTileMode, ); case PaintType.image: return PaintModel.image( @@ -554,6 +588,7 @@ class PaintModel with Equatable, SerializableMixin, FieldsHolder { blendMode, gradientTransform, gradientStops, + gradientTileMode, imageTransform, hasImageBytes, downloadUrl, diff --git a/lib/src/api/models/paint.g.dart b/lib/src/api/models/paint.g.dart index e0ee399f..496bdf78 100644 --- a/lib/src/api/models/paint.g.dart +++ b/lib/src/api/models/paint.g.dart @@ -14,6 +14,8 @@ Map _$PaintModelOmitDefaults( if (isJsonDefault(instance.opacity, 1)) json.remove('opacity'); if (isJsonDefault(instance.blendMode, BlendModeC.srcOver)) json.remove('blendMode'); + if (isJsonDefault(instance.gradientTileMode, GradientTileMode.clamp)) + json.remove('gradientTileMode'); if (isJsonDefault(instance.fit, Fit.none)) json.remove('fit'); if (isJsonDefault(instance.alignment, AlignmentModel.none)) json.remove('alignment'); @@ -54,6 +56,12 @@ PaintModel _$PaintModelFromJson(Map json) => PaintModel( gradientStops: (json['gradientStops'] as List?) ?.map((e) => ColorStop.fromJson(e as Map)) .toList(), + gradientTileMode: + $enumDecodeNullable( + _$GradientTileModeEnumMap, + json['gradientTileMode'], + ) ?? + GradientTileMode.clamp, imageTransform: (json['imageTransform'] as List?) ?.map((e) => (e as List).map((e) => e as num).toList()) .toList(), @@ -88,6 +96,7 @@ Map _$PaintModelToJson(PaintModel instance) => 'blendMode': _$BlendModeCEnumMap[instance.blendMode]!, 'gradientTransform': ?instance.gradientTransform, 'gradientStops': ?instance.gradientStops?.map((e) => e.toJson()).toList(), + 'gradientTileMode': _$GradientTileModeEnumMap[instance.gradientTileMode]!, 'imageTransform': ?instance.imageTransform, 'downloadUrl': ?instance.downloadUrl, 'imageName': ?instance.imageName, @@ -146,6 +155,12 @@ const _$BlendModeCEnumMap = { BlendModeC.luminosity: 'luminosity', }; +const _$GradientTileModeEnumMap = { + GradientTileMode.clamp: 'clamp', + GradientTileMode.repeated: 'repeated', + GradientTileMode.mirror: 'mirror', +}; + const _$FitEnumMap = { Fit.contain: 'contain', Fit.cover: 'cover', diff --git a/lib/src/api/models/pulse.dart b/lib/src/api/models/pulse.dart new file mode 100644 index 00000000..ff5224e3 --- /dev/null +++ b/lib/src/api/models/pulse.dart @@ -0,0 +1,36 @@ +import 'package:json_annotation/json_annotation.dart'; +import 'package:equatable/equatable.dart'; + +import '../../../codelessly_api.dart'; + +part 'pulse.g.dart'; + +/// A looping fade: the node's opacity eases from [minOpacity] to its own +/// opacity and back, one way in [durationMillis]. Used for skeleton +/// placeholders and blinking alarm indicators. +@JsonSerializable() +class PulseModel with Equatable, SerializableMixin { + /// The opacity, as a fraction of the node's own, at the faint end. + final double minOpacity; + + /// How long one fade (faint to full, or back) takes. + final int durationMillis; + + /// Creates a [PulseModel] with the given data. + const PulseModel({this.minOpacity = 0.45, this.durationMillis = 1100}); + + /// Duplicates this [PulseModel] with the given values replaced. + PulseModel copyWith({double? minOpacity, int? durationMillis}) => PulseModel( + minOpacity: minOpacity ?? this.minOpacity, + durationMillis: durationMillis ?? this.durationMillis, + ); + + /// Creates a [PulseModel] from JSON data. + factory PulseModel.fromJson(Map json) => _$PulseModelFromJson(json); + + @override + Map toJson() => _$PulseModelOmitDefaults(this, _$PulseModelToJson(this)); + + @override + List get props => [minOpacity, durationMillis]; +} diff --git a/lib/src/api/models/pulse.g.dart b/lib/src/api/models/pulse.g.dart new file mode 100644 index 00000000..c031b7c4 --- /dev/null +++ b/lib/src/api/models/pulse.g.dart @@ -0,0 +1,32 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'pulse.dart'; + +// ************************************************************************** +// JsonDefaultsGenerator +// ************************************************************************** + +Map _$PulseModelOmitDefaults( + PulseModel instance, + Map json, +) { + if (isJsonDefault(instance.minOpacity, 0.45)) json.remove('minOpacity'); + if (isJsonDefault(instance.durationMillis, 1100)) + json.remove('durationMillis'); + return json; +} + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +PulseModel _$PulseModelFromJson(Map json) => PulseModel( + minOpacity: (json['minOpacity'] as num?)?.toDouble() ?? 0.45, + durationMillis: (json['durationMillis'] as num?)?.toInt() ?? 1100, +); + +Map _$PulseModelToJson(PulseModel instance) => + { + 'minOpacity': instance.minOpacity, + 'durationMillis': instance.durationMillis, + }; diff --git a/lib/src/api/models/variables_model.dart b/lib/src/api/models/variables_model.dart index eb860565..f5ab3e25 100644 --- a/lib/src/api/models/variables_model.dart +++ b/lib/src/api/models/variables_model.dart @@ -139,6 +139,12 @@ class VariableData .firstOrNull ?? value; + /// Whether this is a computed variable: its value is a formula, + /// `${= ...}`, that the runtime evaluates whenever the variable is read, + /// e.g. `${= sum(filter(events, e => e.machineId == 'PR-102'), 'minutes')}`. + /// Anything bound to it updates when a variable the formula reads changes. + bool get isComputed => isFormulaValue(value); + /// Type of the variable. This is used to determine how to parse the value. @JsonKey(unknownEnumValue: VariableType.text) final VariableType type; @@ -207,16 +213,28 @@ class VariableData /// /// A text variable returns its value as is, so one holding an empty string /// stays an empty string and compares equal to `''` in conditions. - Object? getValue() => switch (type) { - VariableType.text || VariableType.choice => value, - VariableType.image => value.isEmpty ? null : value, - VariableType.integer => num.tryParse(value).toInt(), - VariableType.decimal => num.tryParse(value).toDouble(), - VariableType.boolean => bool.tryParse(value, caseSensitive: false), - VariableType.color => ColorRGBA.fromHex(value), - VariableType.map => tryJsonDecode(value), - VariableType.list => value.toList(), - }; + /// + /// A computed variable ([isComputed]) has no value of its own here; the + /// runtime evaluates its formula, so this returns null. + Object? getValue() => isComputed + ? null + : switch (type) { + VariableType.text || VariableType.choice => value, + VariableType.image => value.isEmpty ? null : value, + VariableType.integer => num.tryParse(value).toInt(), + VariableType.decimal => num.tryParse(value).toDouble(), + VariableType.boolean => bool.tryParse(value, caseSensitive: false), + VariableType.color => ColorRGBA.fromHex(value), + VariableType.map => tryJsonDecode(value), + VariableType.list => value.toList(), + }; +} + +/// Whether [value] is exactly one formula, `${= ...}`: a computed +/// variable's value. +bool isFormulaValue(String value) { + final String trimmed = value.trim(); + return trimmed.startsWith(r'${=') && trimmed.endsWith('}'); } /// Returns the value converted to the appropriate type according to [type]. @@ -228,6 +246,8 @@ class VariableData String? sanitizeValueForVariableType(Object? value, VariableType type) { if (value == null) return null; // if (value.isEmpty) return ''; + // A formula makes a computed variable of any type; keep it as written. + if (value is String && isFormulaValue(value)) return value.trim(); // Do not sanitize if the value contains a variable path. if (value.toString().contains(variablePathPattern)) return value.toString(); switch (type) { diff --git a/lib/src/api/node_json_converter.dart b/lib/src/api/node_json_converter.dart index 311acd7c..392b48d7 100644 --- a/lib/src/api/node_json_converter.dart +++ b/lib/src/api/node_json_converter.dart @@ -75,6 +75,8 @@ class NodeJsonConverter implements JsonConverter { 'chip': ChipNode.fromJson, 'segmentedButton': SegmentedButtonNode.fromJson, 'external': ExternalComponentNode.fromJson, + 'gauge': GaugeNode.fromJson, + 'chart': ChartNode.fromJson, }; } @@ -107,7 +109,12 @@ class NodeJsonConverter implements JsonConverter { if (json['type'] == null || _registry[json['type']] == null) { throw UnsupportedError('Unsupported node type: ${json["type"]}\n$json'); } - return _registry[json['type']]!(json); + // Hand-written JSON (e.g. nodes.insertTree) leaves out default + // properties; nodes with custom properties read a missing key as all + // defaults. Nodes without properties ignore the key. + return _registry[json['type']]!( + json['properties'] == null ? {...json, 'properties': const {}} : json, + ); } @override diff --git a/lib/src/api/nodes/auto_placeholder_node.g.dart b/lib/src/api/nodes/auto_placeholder_node.g.dart index 29e36b8f..9d044ce2 100644 --- a/lib/src/api/nodes/auto_placeholder_node.g.dart +++ b/lib/src/api/nodes/auto_placeholder_node.g.dart @@ -236,6 +236,9 @@ AutoPlaceholderNode _$AutoPlaceholderNodeFromJson(Map json) => : InkWellModel.fromJson( Map.from(json['inkWell'] as Map), ) + ..pulse = json['pulse'] == null + ? null + : PulseModel.fromJson(json['pulse'] as Map) ..type = json['type'] as String; Map _$AutoPlaceholderNodeToJson( @@ -277,6 +280,7 @@ Map _$AutoPlaceholderNodeToJson( 'isMask': instance.isMask, 'effects': instance.effects.map((e) => e.toJson()).toList(), 'inkWell': ?instance.inkWell?.toJson(), + 'pulse': ?instance.pulse?.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/canvas_node.g.dart b/lib/src/api/nodes/canvas_node.g.dart index 70aeb784..d92bb0bc 100644 --- a/lib/src/api/nodes/canvas_node.g.dart +++ b/lib/src/api/nodes/canvas_node.g.dart @@ -290,6 +290,9 @@ CanvasNode _$CanvasNodeFromJson(Map json) => : InkWellModel.fromJson( Map.from(json['inkWell'] as Map), ) + ..pulse = json['pulse'] == null + ? null + : PulseModel.fromJson(json['pulse'] as Map) ..type = json['type'] as String; Map _$CanvasNodeToJson( @@ -331,6 +334,7 @@ Map _$CanvasNodeToJson( 'isMask': instance.isMask, 'effects': instance.effects.map((e) => e.toJson()).toList(), 'inkWell': ?instance.inkWell?.toJson(), + 'pulse': ?instance.pulse?.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/chart_node.dart b/lib/src/api/nodes/chart_node.dart new file mode 100644 index 00000000..5205c81f --- /dev/null +++ b/lib/src/api/nodes/chart_node.dart @@ -0,0 +1,626 @@ +import 'package:equatable/equatable.dart'; +import 'package:json_annotation/json_annotation.dart'; + +import '../field_access.dart'; +import '../mixins.dart'; +import '../models/models.dart'; +import 'nodes.dart'; + +part 'chart_node.g.dart'; + +/// The default width of a chart. +const double kChartDefaultWidth = 360; + +/// The default height of a chart. +const double kChartDefaultHeight = 200; + +/// What a [ChartNode] draws. +enum ChartType { + /// Bars per category (stacked or grouped by series), with line and tick + /// series drawn over them. + bar, + + /// Lines (optionally with a filled area) across the categories. + line, + + /// One horizontal band of colored segments over a numeric domain, e.g. a + /// machine's states across a shift. + timeline; + + /// Display name. + String get prettify => switch (this) { + ChartType.bar => 'Bar', + ChartType.line => 'Line', + ChartType.timeline => 'Timeline', + }; +} + +/// How a [ChartSeries] is drawn. +enum ChartSeriesKind { + /// A bar per category. Bars of series with the same [ChartSeries.stack] + /// stack; other bar series sit side by side. + bar, + + /// A line through the category points. + line, + + /// A line with the area under it filled. + area, + + /// A short horizontal mark per category (a plan or target). + tick; + + /// Display name. + String get prettify => switch (this) { + ChartSeriesKind.bar => 'Bar', + ChartSeriesKind.line => 'Line', + ChartSeriesKind.area => 'Area', + ChartSeriesKind.tick => 'Tick', + }; +} + +/// One series of a [ChartNode]: which field of each data item it reads and +/// how it is drawn. +@JsonSerializable() +class ChartSeries with Equatable, SerializableMixin { + /// Field of each data item holding the value. Items where it is null or + /// missing have no mark (for lines, the line stops). + final String field; + + /// Name shown in descriptions. + final String name; + + /// How the series is drawn. + final ChartSeriesKind kind; + + /// Color. + final ColorRGBA color; + + /// Field of each item holding a per-item color (hex), overriding [color]. + final String? colorField; + + /// Bar series with the same stack id stack on each other. + final String? stack; + + /// Whether lines and ticks are dashed. + final bool dashed; + + /// Line or tick thickness. + final double strokeWidth; + + /// Whether a line ends in a dot at its last point. + final bool endDot; + + /// For area series, the field of another series to fill down to instead of + /// the axis (the gap between two lines). + final String? fillTo; + + /// Opacity of an area fill. + final double fillOpacity; + + /// Creates a [ChartSeries]. + const ChartSeries({ + required this.field, + this.name = '', + this.kind = ChartSeriesKind.bar, + this.color = const ColorRGBA(r: 46 / 255, g: 209 / 255, b: 122 / 255, a: 1), + this.colorField, + this.stack, + this.dashed = false, + this.strokeWidth = 2, + this.endDot = false, + this.fillTo, + this.fillOpacity = 0.25, + }); + + /// Creates a [ChartSeries] from JSON. + factory ChartSeries.fromJson(Map json) => _$ChartSeriesFromJson(json); + + @override + Map toJson() => _$ChartSeriesToJson(this); + + @override + List get props => [ + field, + name, + kind, + color, + colorField, + stack, + dashed, + strokeWidth, + endDot, + fillTo, + fillOpacity, + ]; +} + +/// A horizontal line across the plot at [value] (a target or threshold). +@JsonSerializable() +class ChartReferenceLine with Equatable, SerializableMixin { + /// Value on the y axis. + final double value; + + /// Line color. + final ColorRGBA color; + + /// Whether the line is dashed. + final bool dashed; + + /// Line thickness. + final double strokeWidth; + + /// Creates a [ChartReferenceLine]. + const ChartReferenceLine({ + required this.value, + this.color = const ColorRGBA(r: 1, g: 1, b: 1, a: 0.6), + this.dashed = true, + this.strokeWidth = 1.5, + }); + + /// Creates a [ChartReferenceLine] from JSON. + factory ChartReferenceLine.fromJson(Map json) => + _$ChartReferenceLineFromJson(json); + + @override + Map toJson() => _$ChartReferenceLineToJson(this); + + @override + List get props => [value, color, dashed, strokeWidth]; +} + +/// A data chart. Its data is a list of items (maps) bound with +/// `variables: {"data": "${list}"}` (or [ChartProperties.sampleData] when +/// unbound); each [ChartSeries] reads one field of every item, and +/// [ChartProperties.labelField] names the x-axis labels. +/// +/// Tapping a category fires the node's `changed` reactions with the +/// category index as `${value}`; bind `selectedIndex` to show a selection. +@JsonSerializable() +class ChartNode extends SceneNode with CustomPropertiesMixin, FieldsHolder { + @override + final String type = 'chart'; + + /// Configurable properties of the chart. + @override + covariant ChartProperties properties; + + /// Index of the highlighted category, or -1 for none. Bindable. + int selectedIndex; + + @override + bool get supportsPadding => true; + + /// Creates a preview chart, e.g. for the components panel. + ChartNode.empty() + : properties = ChartProperties(), + selectedIndex = -1, + super( + id: 'chart', + name: 'Chart', + basicBoxLocal: NodeBox(0, 0, kChartDefaultWidth, kChartDefaultHeight), + ); + + /// Creates a [ChartNode] with the given data. + ChartNode({ + 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, + this.selectedIndex = -1, + required this.properties, + super.variables, + super.multipleVariables, + }) { + setVariablesMixin( + variables: variables, + multipleVariables: multipleVariables, + ); + } + + @override + List get triggerTypes => [TriggerType.changed]; + + @override + FieldsMap generateFields() => { + ...super.generateFields(), + 'selectedIndex': NumFieldAccess( + 'Selected Index', + 'Index of the highlighted category (-1 for none)', + () => selectedIndex, + (v) => selectedIndex = v, + ), + 'properties': ObjectFieldAccess( + 'Properties', + 'Properties of the chart', + () => properties, + (v) => properties = v, + ), + }; + + @override + List get props => [ + ...super.props, + selectedIndex, + properties, + variables, + multipleVariables, + ]; + + @override + late final List propertyVariables = [ + ...super.propertyVariables, + IntValue(name: 'selectedIndex', value: selectedIndex), + ]; + + /// Creates a [ChartNode] from JSON. + /// A missing `properties` key means default properties, so hand-written + /// JSON (e.g. nodes.insertTree) can leave it out. + factory ChartNode.fromJson(Map json) => _$ChartNodeFromJson( + json['properties'] == null ? {...json, 'properties': const {}} : json, + ); + + @override + Map toJson() => _$ChartNodeOmitDefaults(this, _$ChartNodeToJson(this)); +} + +/// Default sample data: six hours of output against a plan. +const List> kDefaultChartSampleData = [ + {'label': '14', 'good': 760, 'scrap': 12, 'plan': 800}, + {'label': '15', 'good': 690, 'scrap': 9, 'plan': 800}, + {'label': '16', 'good': 540, 'scrap': 15, 'plan': 800}, + {'label': '17', 'good': 780, 'scrap': 8, 'plan': 800}, + {'label': '18', 'good': 805, 'scrap': 11, 'plan': 800}, + {'label': '19', 'good': 420, 'scrap': 6, 'plan': 800}, +]; + +/// Default series for [kDefaultChartSampleData]. +const List kDefaultChartSeries = [ + ChartSeries(field: 'good', name: 'Good', stack: 'output'), + ChartSeries( + field: 'scrap', + name: 'Scrap', + stack: 'output', + color: ColorRGBA(r: 1, g: 74 / 255, b: 61 / 255, a: 1), + ), + ChartSeries( + field: 'plan', + name: 'Plan', + kind: ChartSeriesKind.tick, + color: ColorRGBA(r: 232 / 255, g: 235 / 255, b: 237 / 255, a: 1), + ), +]; + +/// Configurable properties of a [ChartNode]. +@JsonSerializable() +class ChartProperties extends CustomProperties with FieldsHolder { + /// What the chart draws. + ChartType chartType; + + /// The series. + List series; + + /// Field of each item holding its x-axis label. + String labelField; + + /// Data shown when the node's `data` isn't bound to a variable. + List> sampleData; + + /// Lowest y value, or null for 0 (or the data minimum when negative). + double? minY; + + /// Highest y value, or null for a rounded-up maximum of the data. + double? maxY; + + /// Horizontal grid intervals (and y-axis labels). 0 hides the grid. + int gridLines; + + /// Grid line color. + ColorRGBA gridColor; + + /// Whether y-axis labels are shown left of the plot. + bool showYAxis; + + /// Width reserved for y-axis labels. + double yAxisWidth; + + /// Whether x-axis labels are shown under the plot. + bool showXAxis; + + /// Show every n-th x-axis label. + int xLabelEvery; + + /// Axis label color. + ColorRGBA axisLabelColor; + + /// Axis label font size. + double axisFontSize; + + /// Axis label font family (a Google Font name), or null for the default. + String? axisFontFamily; + + /// Horizontal lines across the plot. + List referenceLines; + + /// Width of a category's bars as a fraction of the category width. + double barWidthFraction; + + /// Maximum width of one bar (or one group of side-by-side bars). + double maxBarWidth; + + /// Gap between side-by-side bars. + double barGap; + + /// Corner radius of bar tops. + double barRadius; + + /// For bar charts: categories whose bar series are all empty draw a dashed + /// outline up to their first tick series (hours still to come). + bool dashEmpty; + + /// Fill behind the selected category. + ColorRGBA selectionColor; + + /// For line charts: number of x slots the lines span (points sit on slot + /// boundaries), or 0 to use the data length. + int xSlots; + + /// Timeline: field holding a segment's start (number). + String startField; + + /// Timeline: field holding a segment's end (number). + String endField; + + /// Timeline: field holding a segment's color (hex). + String segmentColorField; + + /// Timeline: domain start. + double domainMin; + + /// Timeline: domain end. + double domainMax; + + /// Timeline: domain position of "now"; the rest is hatched. Null for none. + double? nowValue; + + /// Timeline: color of the "now" marker. Null for [axisLabelColor]. + ColorRGBA? nowColor; + + /// Timeline: track color and hatch color. + ColorRGBA trackColor; + + /// Timeline: divisions marked with ticks (e.g. hours). + int divisions; + + /// Creates [ChartProperties]. + ChartProperties({ + this.chartType = ChartType.bar, + this.series = kDefaultChartSeries, + this.labelField = 'label', + this.sampleData = kDefaultChartSampleData, + this.minY, + this.maxY, + this.gridLines = 4, + this.gridColor = const ColorRGBA( + r: 41 / 255, + g: 46 / 255, + b: 51 / 255, + a: 1, + ), + this.showYAxis = true, + this.yAxisWidth = 36, + this.showXAxis = true, + this.xLabelEvery = 1, + this.axisLabelColor = const ColorRGBA( + r: 112 / 255, + g: 120 / 255, + b: 130 / 255, + a: 1, + ), + this.axisFontSize = 10, + this.axisFontFamily, + this.referenceLines = const [], + this.barWidthFraction = 0.62, + this.maxBarWidth = 36, + this.barGap = 2, + this.barRadius = 0, + this.dashEmpty = true, + this.selectionColor = const ColorRGBA(r: 1, g: 212 / 255, b: 0, a: 0.08), + this.xSlots = 0, + this.startField = 'from', + this.endField = 'to', + this.segmentColorField = 'color', + this.domainMin = 0, + this.domainMax = 100, + this.nowValue, + this.nowColor, + this.trackColor = const ColorRGBA( + r: 18 / 255, + g: 19 / 255, + b: 22 / 255, + a: 1, + ), + this.divisions = 8, + }); + + /// Duplicates these properties with the given overrides. + ChartProperties copyWith({ + ChartType? chartType, + List? series, + String? labelField, + List>? sampleData, + double? minY, + double? maxY, + int? gridLines, + ColorRGBA? gridColor, + bool? showYAxis, + double? yAxisWidth, + bool? showXAxis, + int? xLabelEvery, + ColorRGBA? axisLabelColor, + double? axisFontSize, + String? axisFontFamily, + List? referenceLines, + double? barWidthFraction, + double? maxBarWidth, + double? barGap, + double? barRadius, + bool? dashEmpty, + ColorRGBA? selectionColor, + int? xSlots, + String? startField, + String? endField, + String? segmentColorField, + double? domainMin, + double? domainMax, + double? nowValue, + ColorRGBA? nowColor, + ColorRGBA? trackColor, + int? divisions, + }) => ChartProperties( + chartType: chartType ?? this.chartType, + series: series ?? this.series, + labelField: labelField ?? this.labelField, + sampleData: sampleData ?? this.sampleData, + minY: minY ?? this.minY, + maxY: maxY ?? this.maxY, + gridLines: gridLines ?? this.gridLines, + gridColor: gridColor ?? this.gridColor, + showYAxis: showYAxis ?? this.showYAxis, + yAxisWidth: yAxisWidth ?? this.yAxisWidth, + showXAxis: showXAxis ?? this.showXAxis, + xLabelEvery: xLabelEvery ?? this.xLabelEvery, + axisLabelColor: axisLabelColor ?? this.axisLabelColor, + axisFontSize: axisFontSize ?? this.axisFontSize, + axisFontFamily: axisFontFamily ?? this.axisFontFamily, + referenceLines: referenceLines ?? this.referenceLines, + barWidthFraction: barWidthFraction ?? this.barWidthFraction, + maxBarWidth: maxBarWidth ?? this.maxBarWidth, + barGap: barGap ?? this.barGap, + barRadius: barRadius ?? this.barRadius, + dashEmpty: dashEmpty ?? this.dashEmpty, + selectionColor: selectionColor ?? this.selectionColor, + xSlots: xSlots ?? this.xSlots, + startField: startField ?? this.startField, + endField: endField ?? this.endField, + segmentColorField: segmentColorField ?? this.segmentColorField, + domainMin: domainMin ?? this.domainMin, + domainMax: domainMax ?? this.domainMax, + nowValue: nowValue ?? this.nowValue, + nowColor: nowColor ?? this.nowColor, + trackColor: trackColor ?? this.trackColor, + divisions: divisions ?? this.divisions, + ); + + @override + FieldsMap generateFields() => { + ...super.generateFields(), + 'labelField': StringFieldAccess( + 'Label Field', + 'Field holding x-axis labels', + () => labelField, + (v) => labelField = v, + ), + 'gridLines': NumFieldAccess( + 'Grid Lines', + 'Horizontal grid intervals', + () => gridLines, + (v) => gridLines = v, + ), + 'gridColor': ColorFieldAccess( + 'Grid Color', + 'Grid line color', + () => gridColor, + (v) => gridColor = v, + ), + 'showYAxis': BoolFieldAccess( + 'Y Axis', + 'Show y-axis labels', + () => showYAxis, + (v) => showYAxis = v, + ), + 'showXAxis': BoolFieldAccess( + 'X Axis', + 'Show x-axis labels', + () => showXAxis, + (v) => showXAxis = v, + ), + 'axisLabelColor': ColorFieldAccess( + 'Axis Color', + 'Axis label color', + () => axisLabelColor, + (v) => axisLabelColor = v, + ), + 'maxBarWidth': NumFieldAccess( + 'Max Bar Width', + 'Widest a bar gets', + () => maxBarWidth, + (v) => maxBarWidth = v, + ), + 'dashEmpty': BoolFieldAccess( + 'Dash Empty', + 'Dashed outline for empty categories', + () => dashEmpty, + (v) => dashEmpty = v, + ), + }; + + @override + List get props => [ + chartType, + series, + labelField, + sampleData, + minY, + maxY, + gridLines, + gridColor, + showYAxis, + yAxisWidth, + showXAxis, + xLabelEvery, + axisLabelColor, + axisFontSize, + axisFontFamily, + referenceLines, + barWidthFraction, + maxBarWidth, + barGap, + barRadius, + dashEmpty, + selectionColor, + xSlots, + startField, + endField, + segmentColorField, + domainMin, + domainMax, + nowValue, + nowColor, + trackColor, + divisions, + ]; + + /// Creates [ChartProperties] from JSON. + factory ChartProperties.fromJson(Map json) => _$ChartPropertiesFromJson(json); + + @override + Map toJson() => + _$ChartPropertiesOmitDefaults(this, _$ChartPropertiesToJson(this)); +} diff --git a/lib/src/api/nodes/chart_node.g.dart b/lib/src/api/nodes/chart_node.g.dart new file mode 100644 index 00000000..a3cd2830 --- /dev/null +++ b/lib/src/api/nodes/chart_node.g.dart @@ -0,0 +1,449 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'chart_node.dart'; + +// ************************************************************************** +// JsonDefaultsGenerator +// ************************************************************************** + +Map _$ChartSeriesOmitDefaults( + ChartSeries instance, + Map json, +) { + if (isJsonDefault(instance.name, '')) json.remove('name'); + if (isJsonDefault(instance.kind, ChartSeriesKind.bar)) json.remove('kind'); + if (isJsonDefault( + instance.color, + const ColorRGBA(r: 46 / 255, g: 209 / 255, b: 122 / 255, a: 1), + )) + json.remove('color'); + if (isJsonDefault(instance.dashed, false)) json.remove('dashed'); + if (isJsonDefault(instance.strokeWidth, 2)) json.remove('strokeWidth'); + if (isJsonDefault(instance.endDot, false)) json.remove('endDot'); + if (isJsonDefault(instance.fillOpacity, 0.25)) json.remove('fillOpacity'); + return json; +} + +Map _$ChartReferenceLineOmitDefaults( + ChartReferenceLine instance, + Map json, +) { + if (isJsonDefault(instance.color, const ColorRGBA(r: 1, g: 1, b: 1, a: 0.6))) + json.remove('color'); + if (isJsonDefault(instance.dashed, true)) json.remove('dashed'); + if (isJsonDefault(instance.strokeWidth, 1.5)) json.remove('strokeWidth'); + return json; +} + +Map _$ChartNodeOmitDefaults( + ChartNode instance, + Map json, +) { + if (isJsonDefault(instance.selectedIndex, -1)) json.remove('selectedIndex'); + 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 _$ChartPropertiesOmitDefaults( + ChartProperties instance, + Map json, +) { + if (isJsonDefault(instance.chartType, ChartType.bar)) + json.remove('chartType'); + if (isJsonDefault(instance.series, kDefaultChartSeries)) + json.remove('series'); + if (isJsonDefault(instance.labelField, 'label')) json.remove('labelField'); + if (isJsonDefault(instance.sampleData, kDefaultChartSampleData)) + json.remove('sampleData'); + if (isJsonDefault(instance.gridLines, 4)) json.remove('gridLines'); + if (isJsonDefault( + instance.gridColor, + const ColorRGBA(r: 41 / 255, g: 46 / 255, b: 51 / 255, a: 1), + )) + json.remove('gridColor'); + if (isJsonDefault(instance.showYAxis, true)) json.remove('showYAxis'); + if (isJsonDefault(instance.yAxisWidth, 36)) json.remove('yAxisWidth'); + if (isJsonDefault(instance.showXAxis, true)) json.remove('showXAxis'); + if (isJsonDefault(instance.xLabelEvery, 1)) json.remove('xLabelEvery'); + if (isJsonDefault( + instance.axisLabelColor, + const ColorRGBA(r: 112 / 255, g: 120 / 255, b: 130 / 255, a: 1), + )) + json.remove('axisLabelColor'); + if (isJsonDefault(instance.axisFontSize, 10)) json.remove('axisFontSize'); + if (isJsonDefault(instance.referenceLines, const [])) + json.remove('referenceLines'); + if (isJsonDefault(instance.barWidthFraction, 0.62)) + json.remove('barWidthFraction'); + if (isJsonDefault(instance.maxBarWidth, 36)) json.remove('maxBarWidth'); + if (isJsonDefault(instance.barGap, 2)) json.remove('barGap'); + if (isJsonDefault(instance.barRadius, 0)) json.remove('barRadius'); + if (isJsonDefault(instance.dashEmpty, true)) json.remove('dashEmpty'); + if (isJsonDefault( + instance.selectionColor, + const ColorRGBA(r: 1, g: 212 / 255, b: 0, a: 0.08), + )) + json.remove('selectionColor'); + if (isJsonDefault(instance.xSlots, 0)) json.remove('xSlots'); + if (isJsonDefault(instance.startField, 'from')) json.remove('startField'); + if (isJsonDefault(instance.endField, 'to')) json.remove('endField'); + if (isJsonDefault(instance.segmentColorField, 'color')) + json.remove('segmentColorField'); + if (isJsonDefault(instance.domainMin, 0)) json.remove('domainMin'); + if (isJsonDefault(instance.domainMax, 100)) json.remove('domainMax'); + if (isJsonDefault( + instance.trackColor, + const ColorRGBA(r: 18 / 255, g: 19 / 255, b: 22 / 255, a: 1), + )) + json.remove('trackColor'); + if (isJsonDefault(instance.divisions, 8)) json.remove('divisions'); + return json; +} + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +ChartSeries _$ChartSeriesFromJson(Map json) => ChartSeries( + field: json['field'] as String, + name: json['name'] as String? ?? '', + kind: + $enumDecodeNullable(_$ChartSeriesKindEnumMap, json['kind']) ?? + ChartSeriesKind.bar, + color: json['color'] == null + ? const ColorRGBA(r: 46 / 255, g: 209 / 255, b: 122 / 255, a: 1) + : ColorRGBA.fromJson(json['color']), + colorField: json['colorField'] as String?, + stack: json['stack'] as String?, + dashed: json['dashed'] as bool? ?? false, + strokeWidth: (json['strokeWidth'] as num?)?.toDouble() ?? 2, + endDot: json['endDot'] as bool? ?? false, + fillTo: json['fillTo'] as String?, + fillOpacity: (json['fillOpacity'] as num?)?.toDouble() ?? 0.25, +); + +Map _$ChartSeriesToJson(ChartSeries instance) => + { + 'field': instance.field, + 'name': instance.name, + 'kind': _$ChartSeriesKindEnumMap[instance.kind]!, + 'color': instance.color.toJson(), + 'colorField': ?instance.colorField, + 'stack': ?instance.stack, + 'dashed': instance.dashed, + 'strokeWidth': instance.strokeWidth, + 'endDot': instance.endDot, + 'fillTo': ?instance.fillTo, + 'fillOpacity': instance.fillOpacity, + }; + +const _$ChartSeriesKindEnumMap = { + ChartSeriesKind.bar: 'bar', + ChartSeriesKind.line: 'line', + ChartSeriesKind.area: 'area', + ChartSeriesKind.tick: 'tick', +}; + +ChartReferenceLine _$ChartReferenceLineFromJson(Map json) => ChartReferenceLine( + value: (json['value'] as num).toDouble(), + color: json['color'] == null + ? const ColorRGBA(r: 1, g: 1, b: 1, a: 0.6) + : ColorRGBA.fromJson(json['color']), + dashed: json['dashed'] as bool? ?? true, + strokeWidth: (json['strokeWidth'] as num?)?.toDouble() ?? 1.5, +); + +Map _$ChartReferenceLineToJson(ChartReferenceLine instance) => + { + 'value': instance.value, + 'color': instance.color.toJson(), + 'dashed': instance.dashed, + 'strokeWidth': instance.strokeWidth, + }; + +ChartNode _$ChartNodeFromJson(Map json) => + ChartNode( + 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 [], + selectedIndex: (json['selectedIndex'] as num?)?.toInt() ?? -1, + properties: ChartProperties.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 _$ChartNodeToJson(ChartNode 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(), + 'selectedIndex': instance.selectedIndex, +}; + +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', +}; + +ChartProperties _$ChartPropertiesFromJson(Map json) => ChartProperties( + chartType: + $enumDecodeNullable(_$ChartTypeEnumMap, json['chartType']) ?? + ChartType.bar, + series: + (json['series'] as List?) + ?.map((e) => ChartSeries.fromJson(e as Map)) + .toList() ?? + kDefaultChartSeries, + labelField: json['labelField'] as String? ?? 'label', + sampleData: + (json['sampleData'] as List?) + ?.map((e) => Map.from(e as Map)) + .toList() ?? + kDefaultChartSampleData, + minY: (json['minY'] as num?)?.toDouble(), + maxY: (json['maxY'] as num?)?.toDouble(), + gridLines: (json['gridLines'] as num?)?.toInt() ?? 4, + gridColor: json['gridColor'] == null + ? const ColorRGBA(r: 41 / 255, g: 46 / 255, b: 51 / 255, a: 1) + : ColorRGBA.fromJson(json['gridColor']), + showYAxis: json['showYAxis'] as bool? ?? true, + yAxisWidth: (json['yAxisWidth'] as num?)?.toDouble() ?? 36, + showXAxis: json['showXAxis'] as bool? ?? true, + xLabelEvery: (json['xLabelEvery'] as num?)?.toInt() ?? 1, + axisLabelColor: json['axisLabelColor'] == null + ? const ColorRGBA(r: 112 / 255, g: 120 / 255, b: 130 / 255, a: 1) + : ColorRGBA.fromJson(json['axisLabelColor']), + axisFontSize: (json['axisFontSize'] as num?)?.toDouble() ?? 10, + axisFontFamily: json['axisFontFamily'] as String?, + referenceLines: + (json['referenceLines'] as List?) + ?.map((e) => ChartReferenceLine.fromJson(e as Map)) + .toList() ?? + const [], + barWidthFraction: (json['barWidthFraction'] as num?)?.toDouble() ?? 0.62, + maxBarWidth: (json['maxBarWidth'] as num?)?.toDouble() ?? 36, + barGap: (json['barGap'] as num?)?.toDouble() ?? 2, + barRadius: (json['barRadius'] as num?)?.toDouble() ?? 0, + dashEmpty: json['dashEmpty'] as bool? ?? true, + selectionColor: json['selectionColor'] == null + ? const ColorRGBA(r: 1, g: 212 / 255, b: 0, a: 0.08) + : ColorRGBA.fromJson(json['selectionColor']), + xSlots: (json['xSlots'] as num?)?.toInt() ?? 0, + startField: json['startField'] as String? ?? 'from', + endField: json['endField'] as String? ?? 'to', + segmentColorField: json['segmentColorField'] as String? ?? 'color', + domainMin: (json['domainMin'] as num?)?.toDouble() ?? 0, + domainMax: (json['domainMax'] as num?)?.toDouble() ?? 100, + nowValue: (json['nowValue'] as num?)?.toDouble(), + nowColor: json['nowColor'] == null + ? null + : ColorRGBA.fromJson(json['nowColor']), + trackColor: json['trackColor'] == null + ? const ColorRGBA(r: 18 / 255, g: 19 / 255, b: 22 / 255, a: 1) + : ColorRGBA.fromJson(json['trackColor']), + divisions: (json['divisions'] as num?)?.toInt() ?? 8, +); + +Map _$ChartPropertiesToJson(ChartProperties instance) => + { + 'chartType': _$ChartTypeEnumMap[instance.chartType]!, + 'series': instance.series.map((e) => e.toJson()).toList(), + 'labelField': instance.labelField, + 'sampleData': instance.sampleData, + 'minY': ?instance.minY, + 'maxY': ?instance.maxY, + 'gridLines': instance.gridLines, + 'gridColor': instance.gridColor.toJson(), + 'showYAxis': instance.showYAxis, + 'yAxisWidth': instance.yAxisWidth, + 'showXAxis': instance.showXAxis, + 'xLabelEvery': instance.xLabelEvery, + 'axisLabelColor': instance.axisLabelColor.toJson(), + 'axisFontSize': instance.axisFontSize, + 'axisFontFamily': ?instance.axisFontFamily, + 'referenceLines': instance.referenceLines.map((e) => e.toJson()).toList(), + 'barWidthFraction': instance.barWidthFraction, + 'maxBarWidth': instance.maxBarWidth, + 'barGap': instance.barGap, + 'barRadius': instance.barRadius, + 'dashEmpty': instance.dashEmpty, + 'selectionColor': instance.selectionColor.toJson(), + 'xSlots': instance.xSlots, + 'startField': instance.startField, + 'endField': instance.endField, + 'segmentColorField': instance.segmentColorField, + 'domainMin': instance.domainMin, + 'domainMax': instance.domainMax, + 'nowValue': ?instance.nowValue, + 'nowColor': ?instance.nowColor?.toJson(), + 'trackColor': instance.trackColor.toJson(), + 'divisions': instance.divisions, + }; + +const _$ChartTypeEnumMap = { + ChartType.bar: 'bar', + ChartType.line: 'line', + ChartType.timeline: 'timeline', +}; diff --git a/lib/src/api/nodes/frame_node.g.dart b/lib/src/api/nodes/frame_node.g.dart index 48c894de..73e27d85 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), ) + ..pulse = json['pulse'] == null + ? null + : PulseModel.fromJson(json['pulse'] 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(), + 'pulse': ?instance.pulse?.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..efb83927 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), ) + ..pulse = json['pulse'] == null + ? null + : PulseModel.fromJson(json['pulse'] 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(), + 'pulse': ?instance.pulse?.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..cd3c1e61 --- /dev/null +++ b/lib/src/api/nodes/gauge_node.dart @@ -0,0 +1,520 @@ +import 'package:equatable/equatable.dart'; +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 width of a gauge. +const double kGaugeDefaultWidth = 220; + +/// The default height of a gauge. +const double kGaugeDefaultHeight = 185; + +/// A radial gauge (dial): an arc track, optional colored zones, ticks and +/// labels, a value arc (continuous or in segments), a target notch and a +/// needle. The value is bindable (`variables: {"value": "${...}"}`), so a +/// readout is usually a text node placed over the dial. +@JsonSerializable() +class GaugeNode extends SceneNode with CustomPropertiesMixin, FieldsHolder { + @override + final String type = 'gauge'; + + /// Configurable properties of the gauge. + @override + covariant GaugeProperties properties; + + /// Current value, between [GaugeProperties.min] and [GaugeProperties.max]. + double value; + + @override + bool get supportsPadding => false; + + /// Creates a preview gauge, e.g. for the components panel. + GaugeNode.empty() + : properties = GaugeProperties(), + value = 72, + super( + id: 'gauge', + name: 'Gauge', + basicBoxLocal: NodeBox(0, 0, kGaugeDefaultWidth, kGaugeDefaultHeight), + ); + + /// 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, + this.value = 0, + required this.properties, + super.variables, + super.multipleVariables, + }) { + setVariablesMixin( + variables: variables, + multipleVariables: multipleVariables, + ); + } + + @override + List get triggerTypes => [TriggerType.click]; + + @override + FieldsMap generateFields() => { + ...super.generateFields(), + 'value': NumFieldAccess( + 'Value', + 'Current value of the gauge', + () => value, + (v) => value = v, + ), + 'properties': ObjectFieldAccess( + 'Properties', + 'Properties of the gauge', + () => properties, + (v) => properties = v, + ), + }; + + @override + List get props => [ + ...super.props, + value, + properties, + variables, + multipleVariables, + ]; + + @override + late final List propertyVariables = [ + ...super.propertyVariables, + DoubleValue(name: 'value', value: value), + ]; + + /// Creates a [GaugeNode] from JSON. + /// A missing `properties` key means default properties, so hand-written + /// JSON (e.g. nodes.insertTree) can leave it out. + factory GaugeNode.fromJson(Map json) => _$GaugeNodeFromJson( + json['properties'] == null ? {...json, 'properties': const {}} : json, + ); + + @override + Map toJson() => _$GaugeNodeOmitDefaults(this, _$GaugeNodeToJson(this)); +} + +/// A colored band of the gauge, from [from] to [to] in value units. +@JsonSerializable() +class GaugeZone with Equatable, SerializableMixin { + /// Start value. + final double from; + + /// End value. + final double to; + + /// Band color. The value arc takes the color of the zone its value is in + /// when [GaugeProperties.colorByZone] is true. + final ColorRGBA color; + + /// Creates a [GaugeZone]. + const GaugeZone({required this.from, required this.to, required this.color}); + + /// Creates a [GaugeZone] from JSON. + factory GaugeZone.fromJson(Map json) => _$GaugeZoneFromJson(json); + + @override + Map toJson() => _$GaugeZoneToJson(this); + + @override + List get props => [from, to, color]; +} + +/// The default zones: poor below 60, typical to 85, world class above. +const List kDefaultGaugeZones = [ + GaugeZone( + from: 0, + to: 60, + color: ColorRGBA(r: 1, g: 74 / 255, b: 61 / 255, a: 1), + ), + GaugeZone( + from: 60, + to: 85, + color: ColorRGBA(r: 245 / 255, g: 158 / 255, b: 43 / 255, a: 1), + ), + GaugeZone( + from: 85, + to: 100, + color: ColorRGBA(r: 46 / 255, g: 209 / 255, b: 122 / 255, a: 1), + ), +]; + +/// Configurable properties of a [GaugeNode]. +@JsonSerializable() +class GaugeProperties extends CustomProperties with FieldsHolder { + /// Value at the start of the arc. + double min; + + /// Value at the end of the arc. + double max; + + /// Where the arc starts, in degrees clockwise from 3 o'clock. + double startAngle; + + /// How far the arc sweeps, in degrees. + double sweepAngle; + + /// Thickness of the track and value arc. + double thickness; + + /// Color of the empty track. + ColorRGBA trackColor; + + /// Color of the value arc when [colorByZone] is false or no zone matches. + ColorRGBA valueColor; + + /// Whether the value arc takes the color of the zone its value is in. + bool colorByZone; + + /// Number of segments the arc is split into (an LED bar look). 0 draws a + /// continuous arc. + int segments; + + /// Gap between segments, in degrees. + double segmentGap; + + /// Colored bands drawn outside the track. + List zones; + + /// Thickness of the zone bands. 0 hides them. + double zoneThickness; + + /// Opacity of the zone bands. + double zoneOpacity; + + /// Number of major tick intervals (0 hides ticks). + int majorTicks; + + /// Minor ticks between two major ticks. + int minorTicks; + + /// Tick color. + ColorRGBA tickColor; + + /// Values that get a number label inside the arc. + List labels; + + /// Label color. + ColorRGBA labelColor; + + /// Label font size. + double labelFontSize; + + /// Label font family (a Google Font name), or null for the default. + String? labelFontFamily; + + /// Whether a target value is marked with a notch outside the arc. + bool showTarget; + + /// The target value. + double target; + + /// Color of the target notch. + ColorRGBA targetColor; + + /// Whether a needle points at the value. + bool showNeedle; + + /// Needle color. + ColorRGBA needleColor; + + /// Fraction of the radius around the center the needle leaves clear, so it + /// doesn't cross a readout placed in the middle (0 draws it to the hub). + double needleClearance; + + /// Whether value changes animate. + bool animate; + + /// Animation duration in milliseconds. + int animationDurationInMillis; + + /// Creates [GaugeProperties]. + GaugeProperties({ + this.min = 0, + this.max = 100, + this.startAngle = 150, + this.sweepAngle = 240, + this.thickness = 14, + this.trackColor = const ColorRGBA( + r: 18 / 255, + g: 19 / 255, + b: 22 / 255, + a: 1, + ), + this.valueColor = const ColorRGBA(r: 1, g: 212 / 255, b: 0, a: 1), + this.colorByZone = true, + this.segments = 40, + this.segmentGap = 0.9, + this.zones = kDefaultGaugeZones, + this.zoneThickness = 4, + this.zoneOpacity = 0.55, + this.majorTicks = 10, + this.minorTicks = 1, + this.tickColor = const ColorRGBA( + r: 163 / 255, + g: 171 / 255, + b: 178 / 255, + a: 1, + ), + this.labels = const [0, 60, 85, 100], + this.labelColor = const ColorRGBA( + r: 112 / 255, + g: 120 / 255, + b: 130 / 255, + a: 1, + ), + this.labelFontSize = 10, + this.labelFontFamily, + this.showTarget = true, + this.target = 85, + this.targetColor = const ColorRGBA( + r: 232 / 255, + g: 235 / 255, + b: 237 / 255, + a: 1, + ), + this.showNeedle = true, + this.needleColor = const ColorRGBA( + r: 232 / 255, + g: 235 / 255, + b: 237 / 255, + a: 1, + ), + this.needleClearance = 0.62, + this.animate = true, + this.animationDurationInMillis = 420, + }); + + /// Value as a 0–1 fraction of the arc. + double fraction(double value) => + max == min ? 0 : ((value - min) / (max - min)).clamp(0.0, 1.0); + + /// Color of the value arc for [value]. + ColorRGBA colorFor(double value) { + if (colorByZone) { + for (final zone in zones) { + if (value >= zone.from && value < zone.to) return zone.color; + } + if (zones.isNotEmpty && value >= zones.last.to) return zones.last.color; + } + return valueColor; + } + + /// Duplicates these properties with the given overrides. + GaugeProperties copyWith({ + double? min, + double? max, + double? startAngle, + double? sweepAngle, + double? thickness, + ColorRGBA? trackColor, + ColorRGBA? valueColor, + bool? colorByZone, + int? segments, + double? segmentGap, + List? zones, + double? zoneThickness, + double? zoneOpacity, + int? majorTicks, + int? minorTicks, + ColorRGBA? tickColor, + List? labels, + ColorRGBA? labelColor, + double? labelFontSize, + String? labelFontFamily, + bool? showTarget, + double? target, + ColorRGBA? targetColor, + bool? showNeedle, + ColorRGBA? needleColor, + double? needleClearance, + bool? animate, + int? animationDurationInMillis, + }) => GaugeProperties( + min: min ?? this.min, + max: max ?? this.max, + startAngle: startAngle ?? this.startAngle, + sweepAngle: sweepAngle ?? this.sweepAngle, + thickness: thickness ?? this.thickness, + trackColor: trackColor ?? this.trackColor, + valueColor: valueColor ?? this.valueColor, + colorByZone: colorByZone ?? this.colorByZone, + segments: segments ?? this.segments, + segmentGap: segmentGap ?? this.segmentGap, + zones: zones ?? this.zones, + zoneThickness: zoneThickness ?? this.zoneThickness, + zoneOpacity: zoneOpacity ?? this.zoneOpacity, + majorTicks: majorTicks ?? this.majorTicks, + minorTicks: minorTicks ?? this.minorTicks, + tickColor: tickColor ?? this.tickColor, + labels: labels ?? this.labels, + labelColor: labelColor ?? this.labelColor, + labelFontSize: labelFontSize ?? this.labelFontSize, + labelFontFamily: labelFontFamily ?? this.labelFontFamily, + showTarget: showTarget ?? this.showTarget, + target: target ?? this.target, + targetColor: targetColor ?? this.targetColor, + showNeedle: showNeedle ?? this.showNeedle, + needleColor: needleColor ?? this.needleColor, + needleClearance: needleClearance ?? this.needleClearance, + animate: animate ?? this.animate, + animationDurationInMillis: + animationDurationInMillis ?? this.animationDurationInMillis, + ); + + @override + FieldsMap generateFields() => { + ...super.generateFields(), + 'min': NumFieldAccess( + 'Min', + 'Value at the start of the arc', + () => min, + (v) => min = v, + ), + 'max': NumFieldAccess( + 'Max', + 'Value at the end of the arc', + () => max, + (v) => max = v, + ), + 'startAngle': NumFieldAccess( + 'Start Angle', + 'Arc start in degrees', + () => startAngle, + (v) => startAngle = v, + ), + 'sweepAngle': NumFieldAccess( + 'Sweep Angle', + 'Arc sweep in degrees', + () => sweepAngle, + (v) => sweepAngle = v, + ), + 'thickness': NumFieldAccess( + 'Thickness', + 'Track thickness', + () => thickness, + (v) => thickness = v, + ), + 'trackColor': ColorFieldAccess( + 'Track Color', + 'Color of the empty track', + () => trackColor, + (v) => trackColor = v, + ), + 'valueColor': ColorFieldAccess( + 'Value Color', + 'Color of the value arc', + () => valueColor, + (v) => valueColor = v, + ), + 'colorByZone': BoolFieldAccess( + 'Color By Zone', + 'Value arc takes its zone color', + () => colorByZone, + (v) => colorByZone = v, + ), + 'segments': NumFieldAccess( + 'Segments', + 'Segments in the value arc (0 = continuous)', + () => segments, + (v) => segments = v, + ), + 'zoneThickness': NumFieldAccess( + 'Zone Thickness', + 'Thickness of the zone bands', + () => zoneThickness, + (v) => zoneThickness = v, + ), + 'majorTicks': NumFieldAccess( + 'Major Ticks', + 'Major tick intervals', + () => majorTicks, + (v) => majorTicks = v, + ), + 'minorTicks': NumFieldAccess( + 'Minor Ticks', + 'Minor ticks per interval', + () => minorTicks, + (v) => minorTicks = v, + ), + 'showNeedle': BoolFieldAccess( + 'Needle', + 'Whether a needle points at the value', + () => showNeedle, + (v) => showNeedle = v, + ), + 'animate': BoolFieldAccess( + 'Animate', + 'Whether value changes animate', + () => animate, + (v) => animate = v, + ), + }; + + @override + List get props => [ + min, + max, + startAngle, + sweepAngle, + thickness, + trackColor, + valueColor, + colorByZone, + segments, + segmentGap, + zones, + zoneThickness, + zoneOpacity, + majorTicks, + minorTicks, + tickColor, + labels, + labelColor, + labelFontSize, + labelFontFamily, + showTarget, + target, + targetColor, + showNeedle, + needleColor, + needleClearance, + animate, + animationDurationInMillis, + ]; + + /// Creates [GaugeProperties] from JSON. + 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..51ef2620 --- /dev/null +++ b/lib/src/api/nodes/gauge_node.g.dart @@ -0,0 +1,370 @@ +// 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 _$GaugeZoneOmitDefaults( + GaugeZone instance, + Map json, +) { + return json; +} + +Map _$GaugePropertiesOmitDefaults( + GaugeProperties instance, + Map json, +) { + if (isJsonDefault(instance.min, 0)) json.remove('min'); + if (isJsonDefault(instance.max, 100)) json.remove('max'); + if (isJsonDefault(instance.startAngle, 150)) json.remove('startAngle'); + if (isJsonDefault(instance.sweepAngle, 240)) json.remove('sweepAngle'); + if (isJsonDefault(instance.thickness, 14)) json.remove('thickness'); + if (isJsonDefault( + instance.trackColor, + const ColorRGBA(r: 18 / 255, g: 19 / 255, b: 22 / 255, a: 1), + )) + json.remove('trackColor'); + if (isJsonDefault( + instance.valueColor, + const ColorRGBA(r: 1, g: 212 / 255, b: 0, a: 1), + )) + json.remove('valueColor'); + if (isJsonDefault(instance.colorByZone, true)) json.remove('colorByZone'); + if (isJsonDefault(instance.segments, 40)) json.remove('segments'); + if (isJsonDefault(instance.segmentGap, 0.9)) json.remove('segmentGap'); + if (isJsonDefault(instance.zones, kDefaultGaugeZones)) json.remove('zones'); + if (isJsonDefault(instance.zoneThickness, 4)) json.remove('zoneThickness'); + if (isJsonDefault(instance.zoneOpacity, 0.55)) json.remove('zoneOpacity'); + if (isJsonDefault(instance.majorTicks, 10)) json.remove('majorTicks'); + if (isJsonDefault(instance.minorTicks, 1)) json.remove('minorTicks'); + if (isJsonDefault( + instance.tickColor, + const ColorRGBA(r: 163 / 255, g: 171 / 255, b: 178 / 255, a: 1), + )) + json.remove('tickColor'); + if (isJsonDefault(instance.labels, const [0, 60, 85, 100])) + json.remove('labels'); + if (isJsonDefault( + instance.labelColor, + const ColorRGBA(r: 112 / 255, g: 120 / 255, b: 130 / 255, a: 1), + )) + json.remove('labelColor'); + if (isJsonDefault(instance.labelFontSize, 10)) json.remove('labelFontSize'); + if (isJsonDefault(instance.showTarget, true)) json.remove('showTarget'); + if (isJsonDefault(instance.target, 85)) json.remove('target'); + if (isJsonDefault( + instance.targetColor, + const ColorRGBA(r: 232 / 255, g: 235 / 255, b: 237 / 255, a: 1), + )) + json.remove('targetColor'); + if (isJsonDefault(instance.showNeedle, true)) json.remove('showNeedle'); + if (isJsonDefault( + instance.needleColor, + const ColorRGBA(r: 232 / 255, g: 235 / 255, b: 237 / 255, a: 1), + )) + json.remove('needleColor'); + if (isJsonDefault(instance.needleClearance, 0.62)) + json.remove('needleClearance'); + if (isJsonDefault(instance.animate, true)) json.remove('animate'); + if (isJsonDefault(instance.animationDurationInMillis, 420)) + 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, + 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, +}; + +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', +}; + +GaugeZone _$GaugeZoneFromJson(Map json) => GaugeZone( + from: (json['from'] as num).toDouble(), + to: (json['to'] as num).toDouble(), + color: ColorRGBA.fromJson(json['color']), +); + +Map _$GaugeZoneToJson(GaugeZone instance) => { + 'from': instance.from, + 'to': instance.to, + 'color': instance.color.toJson(), +}; + +GaugeProperties _$GaugePropertiesFromJson(Map json) => GaugeProperties( + min: (json['min'] as num?)?.toDouble() ?? 0, + max: (json['max'] as num?)?.toDouble() ?? 100, + startAngle: (json['startAngle'] as num?)?.toDouble() ?? 150, + sweepAngle: (json['sweepAngle'] as num?)?.toDouble() ?? 240, + thickness: (json['thickness'] as num?)?.toDouble() ?? 14, + trackColor: json['trackColor'] == null + ? const ColorRGBA(r: 18 / 255, g: 19 / 255, b: 22 / 255, a: 1) + : ColorRGBA.fromJson(json['trackColor']), + valueColor: json['valueColor'] == null + ? const ColorRGBA(r: 1, g: 212 / 255, b: 0, a: 1) + : ColorRGBA.fromJson(json['valueColor']), + colorByZone: json['colorByZone'] as bool? ?? true, + segments: (json['segments'] as num?)?.toInt() ?? 40, + segmentGap: (json['segmentGap'] as num?)?.toDouble() ?? 0.9, + zones: + (json['zones'] as List?) + ?.map((e) => GaugeZone.fromJson(e as Map)) + .toList() ?? + kDefaultGaugeZones, + zoneThickness: (json['zoneThickness'] as num?)?.toDouble() ?? 4, + zoneOpacity: (json['zoneOpacity'] as num?)?.toDouble() ?? 0.55, + majorTicks: (json['majorTicks'] as num?)?.toInt() ?? 10, + minorTicks: (json['minorTicks'] as num?)?.toInt() ?? 1, + tickColor: json['tickColor'] == null + ? const ColorRGBA(r: 163 / 255, g: 171 / 255, b: 178 / 255, a: 1) + : ColorRGBA.fromJson(json['tickColor']), + labels: + (json['labels'] as List?) + ?.map((e) => (e as num).toDouble()) + .toList() ?? + const [0, 60, 85, 100], + labelColor: json['labelColor'] == null + ? const ColorRGBA(r: 112 / 255, g: 120 / 255, b: 130 / 255, a: 1) + : ColorRGBA.fromJson(json['labelColor']), + labelFontSize: (json['labelFontSize'] as num?)?.toDouble() ?? 10, + labelFontFamily: json['labelFontFamily'] as String?, + showTarget: json['showTarget'] as bool? ?? true, + target: (json['target'] as num?)?.toDouble() ?? 85, + targetColor: json['targetColor'] == null + ? const ColorRGBA(r: 232 / 255, g: 235 / 255, b: 237 / 255, a: 1) + : ColorRGBA.fromJson(json['targetColor']), + showNeedle: json['showNeedle'] as bool? ?? true, + needleColor: json['needleColor'] == null + ? const ColorRGBA(r: 232 / 255, g: 235 / 255, b: 237 / 255, a: 1) + : ColorRGBA.fromJson(json['needleColor']), + needleClearance: (json['needleClearance'] as num?)?.toDouble() ?? 0.62, + animate: json['animate'] as bool? ?? true, + animationDurationInMillis: + (json['animationDurationInMillis'] as num?)?.toInt() ?? 420, +); + +Map _$GaugePropertiesToJson(GaugeProperties instance) => + { + 'min': instance.min, + 'max': instance.max, + 'startAngle': instance.startAngle, + 'sweepAngle': instance.sweepAngle, + 'thickness': instance.thickness, + 'trackColor': instance.trackColor.toJson(), + 'valueColor': instance.valueColor.toJson(), + 'colorByZone': instance.colorByZone, + 'segments': instance.segments, + 'segmentGap': instance.segmentGap, + 'zones': instance.zones.map((e) => e.toJson()).toList(), + 'zoneThickness': instance.zoneThickness, + 'zoneOpacity': instance.zoneOpacity, + 'majorTicks': instance.majorTicks, + 'minorTicks': instance.minorTicks, + 'tickColor': instance.tickColor.toJson(), + 'labels': instance.labels, + 'labelColor': instance.labelColor.toJson(), + 'labelFontSize': instance.labelFontSize, + 'labelFontFamily': ?instance.labelFontFamily, + 'showTarget': instance.showTarget, + 'target': instance.target, + 'targetColor': instance.targetColor.toJson(), + 'showNeedle': instance.showNeedle, + 'needleColor': instance.needleColor.toJson(), + 'needleClearance': instance.needleClearance, + 'animate': instance.animate, + 'animationDurationInMillis': instance.animationDurationInMillis, + }; diff --git a/lib/src/api/nodes/grid_view_node.dart b/lib/src/api/nodes/grid_view_node.dart index 626d8b38..c6a4301c 100644 --- a/lib/src/api/nodes/grid_view_node.dart +++ b/lib/src/api/nodes/grid_view_node.dart @@ -229,6 +229,8 @@ class GridViewProperties extends CustomProperties with FieldsHolder { double? cacheExtent; /// Properties of the grid delegate that controls the layout of the grid. + /// JSON without one gets two columns. + @JsonKey(readValue: _readGridDelegate) GridDelegateProperties gridDelegate; /// Creates a new [GridViewProperties] instance. @@ -500,3 +502,6 @@ class MaxCrossAxisExtentGridDelegateProperties extends GridDelegateProperties { @override List get props => [...super.props, maxCrossAxisExtent]; } + +Object? _readGridDelegate(Map json, String key) => + json[key] ?? const {'type': 'fixedCrossAxisCount', 'crossAxisCount': 2}; diff --git a/lib/src/api/nodes/grid_view_node.g.dart b/lib/src/api/nodes/grid_view_node.g.dart index eb9c6485..bca2826e 100644 --- a/lib/src/api/nodes/grid_view_node.g.dart +++ b/lib/src/api/nodes/grid_view_node.g.dart @@ -11,6 +11,10 @@ Map _$GridViewNodeOmitDefaults( Map json, ) { if (isJsonDefault(instance.ephemeral, false)) json.remove('ephemeral'); + if (isJsonDefault(instance.allowedTypes, [])) + json.remove('allowedTypes'); + if (isJsonDefault(instance.deniedTypes, [])) + json.remove('deniedTypes'); if (isJsonDefault(instance.visible, true)) json.remove('visible'); if (excludeConstraintsIf(instance)) json.remove('constraints'); if (isJsonDefault(instance.constraints, const BoxConstraintsModel())) @@ -47,6 +51,7 @@ Map _$GridViewNodeOmitDefaults( json.remove('componentSchema'); if (isJsonDefault(instance.reactions, const [])) json.remove('reactions'); if (isJsonDefault(instance.children, [])) json.remove('children'); + if (isJsonDefault(instance.isScrollable, false)) json.remove('isScrollable'); if (isJsonDefault(instance.scrollDirection, AxisC.vertical)) json.remove('scrollDirection'); if (isJsonDefault(instance.reverse, false)) json.remove('reverse'); @@ -58,6 +63,8 @@ Map _$GridViewNodeOmitDefaults( ScrollViewKeyboardDismissBehaviorC.manual, )) json.remove('keyboardDismissBehavior'); + if (isJsonDefault(instance.useFlutterListView, false)) + json.remove('useFlutterListView'); if (isJsonDefault(instance.shouldAlwaysScroll, true)) json.remove('shouldAlwaysScroll'); if (isJsonDefault(instance.clipsContent, true)) json.remove('clipsContent'); @@ -242,18 +249,22 @@ GridViewNode _$GridViewNodeFromJson(Map json) => {} ..tooltip = json['tooltip'] as String? ?? '' ..zoomable = json['zoomable'] as bool? ?? false - ..deniedTypes = (json['deniedTypes'] as List) - .map((e) => e as String) - .toList() + ..deniedTypes = + (json['deniedTypes'] as List?) + ?.map((e) => e as String) + .toList() ?? + [] ..maxAllowedSize = json['maxAllowedSize'] == null ? null : SizeC.fromJson(json['maxAllowedSize'] as Map) ..ephemeral = json['ephemeral'] as bool? ?? false - ..allowedTypes = (json['allowedTypes'] as List) - .map((e) => e as String) - .toList() - ..isScrollable = json['isScrollable'] as bool - ..useFlutterListView = json['useFlutterListView'] as bool + ..allowedTypes = + (json['allowedTypes'] as List?) + ?.map((e) => e as String) + .toList() ?? + [] + ..isScrollable = json['isScrollable'] as bool? ?? false + ..useFlutterListView = json['useFlutterListView'] as bool? ?? false ..paging = $enumDecodeNullable(_$QueryPagingEnumMap, json['paging']) ?? QueryPaging.scroll @@ -376,7 +387,9 @@ const _$QueryPagingEnumMap = { GridViewProperties _$GridViewPropertiesFromJson(Map json) => GridViewProperties( itemCount: (json['itemCount'] as num?)?.toInt(), cacheExtent: (json['cacheExtent'] as num?)?.toDouble(), - gridDelegate: GridDelegateProperties.fromJson(json['gridDelegate'] as Map), + gridDelegate: GridDelegateProperties.fromJson( + _readGridDelegate(json, 'gridDelegate') as Map, + ), ); Map _$GridViewPropertiesToJson(GridViewProperties instance) => diff --git a/lib/src/api/nodes/icon_node.g.dart b/lib/src/api/nodes/icon_node.g.dart index 59174871..dee4b606 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 + ..pulse = json['pulse'] == null + ? null + : PulseModel.fromJson(json['pulse'] 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(), + 'pulse': ?instance.pulse?.toJson(), 'properties': instance.properties.toJson(), 'type': instance.type, }; diff --git a/lib/src/api/nodes/list_view_node.g.dart b/lib/src/api/nodes/list_view_node.g.dart index 2dce408d..589860b0 100644 --- a/lib/src/api/nodes/list_view_node.g.dart +++ b/lib/src/api/nodes/list_view_node.g.dart @@ -11,6 +11,10 @@ Map _$ListViewNodeOmitDefaults( Map json, ) { if (isJsonDefault(instance.ephemeral, false)) json.remove('ephemeral'); + if (isJsonDefault(instance.allowedTypes, [])) + json.remove('allowedTypes'); + if (isJsonDefault(instance.deniedTypes, [])) + json.remove('deniedTypes'); if (isJsonDefault(instance.visible, true)) json.remove('visible'); if (excludeConstraintsIf(instance)) json.remove('constraints'); if (isJsonDefault(instance.constraints, const BoxConstraintsModel())) @@ -47,6 +51,7 @@ Map _$ListViewNodeOmitDefaults( json.remove('componentSchema'); if (isJsonDefault(instance.reactions, const [])) json.remove('reactions'); if (isJsonDefault(instance.children, [])) json.remove('children'); + if (isJsonDefault(instance.isScrollable, false)) json.remove('isScrollable'); if (isJsonDefault(instance.scrollDirection, AxisC.vertical)) json.remove('scrollDirection'); if (isJsonDefault(instance.reverse, false)) json.remove('reverse'); @@ -58,6 +63,8 @@ Map _$ListViewNodeOmitDefaults( ScrollViewKeyboardDismissBehaviorC.manual, )) json.remove('keyboardDismissBehavior'); + if (isJsonDefault(instance.useFlutterListView, false)) + json.remove('useFlutterListView'); if (isJsonDefault(instance.shouldAlwaysScroll, true)) json.remove('shouldAlwaysScroll'); if (isJsonDefault(instance.clipsContent, true)) json.remove('clipsContent'); @@ -221,18 +228,22 @@ ListViewNode _$ListViewNodeFromJson(Map json) => {} ..tooltip = json['tooltip'] as String? ?? '' ..zoomable = json['zoomable'] as bool? ?? false - ..deniedTypes = (json['deniedTypes'] as List) - .map((e) => e as String) - .toList() + ..deniedTypes = + (json['deniedTypes'] as List?) + ?.map((e) => e as String) + .toList() ?? + [] ..maxAllowedSize = json['maxAllowedSize'] == null ? null : SizeC.fromJson(json['maxAllowedSize'] as Map) ..ephemeral = json['ephemeral'] as bool? ?? false - ..allowedTypes = (json['allowedTypes'] as List) - .map((e) => e as String) - .toList() - ..isScrollable = json['isScrollable'] as bool - ..useFlutterListView = json['useFlutterListView'] as bool + ..allowedTypes = + (json['allowedTypes'] as List?) + ?.map((e) => e as String) + .toList() ?? + [] + ..isScrollable = json['isScrollable'] as bool? ?? false + ..useFlutterListView = json['useFlutterListView'] as bool? ?? false ..paging = $enumDecodeNullable(_$QueryPagingEnumMap, json['paging']) ?? QueryPaging.scroll diff --git a/lib/src/api/nodes/nodes.dart b/lib/src/api/nodes/nodes.dart index 440e5adc..2e79accc 100644 --- a/lib/src/api/nodes/nodes.dart +++ b/lib/src/api/nodes/nodes.dart @@ -5,6 +5,7 @@ export 'badge_node.dart'; export 'base_node.dart'; export 'button_node.dart'; export 'canvas_node.dart'; +export 'chart_node.dart'; export 'checkbox_node.dart'; export 'chip_node.dart'; export 'divider_node.dart'; @@ -12,6 +13,7 @@ export 'dropdown_node.dart'; export 'embedded_video_node.dart'; export 'expansion_tile_node.dart'; export 'external_component_node.dart'; +export 'gauge_node.dart'; export 'floating_action_button_node.dart'; export 'frame_node.dart'; export 'freeform_placeholder_node.dart'; diff --git a/lib/src/api/nodes/page_view_node.g.dart b/lib/src/api/nodes/page_view_node.g.dart index bdc21484..f2988be9 100644 --- a/lib/src/api/nodes/page_view_node.g.dart +++ b/lib/src/api/nodes/page_view_node.g.dart @@ -11,6 +11,10 @@ Map _$PageViewNodeOmitDefaults( Map json, ) { if (isJsonDefault(instance.ephemeral, false)) json.remove('ephemeral'); + if (isJsonDefault(instance.allowedTypes, [])) + json.remove('allowedTypes'); + if (isJsonDefault(instance.deniedTypes, [])) + json.remove('deniedTypes'); if (isJsonDefault(instance.visible, true)) json.remove('visible'); if (excludeConstraintsIf(instance)) json.remove('constraints'); if (isJsonDefault(instance.constraints, const BoxConstraintsModel())) @@ -47,6 +51,7 @@ Map _$PageViewNodeOmitDefaults( json.remove('componentSchema'); if (isJsonDefault(instance.reactions, const [])) json.remove('reactions'); if (isJsonDefault(instance.children, [])) json.remove('children'); + if (isJsonDefault(instance.isScrollable, false)) json.remove('isScrollable'); if (isJsonDefault(instance.scrollDirection, AxisC.vertical)) json.remove('scrollDirection'); if (isJsonDefault(instance.reverse, false)) json.remove('reverse'); @@ -58,6 +63,8 @@ Map _$PageViewNodeOmitDefaults( ScrollViewKeyboardDismissBehaviorC.manual, )) json.remove('keyboardDismissBehavior'); + if (isJsonDefault(instance.useFlutterListView, false)) + json.remove('useFlutterListView'); if (isJsonDefault(instance.shouldAlwaysScroll, true)) json.remove('shouldAlwaysScroll'); if (isJsonDefault(instance.clipsContent, true)) json.remove('clipsContent'); @@ -196,17 +203,21 @@ PageViewNode _$PageViewNodeFromJson(Map json) => ..tooltip = json['tooltip'] as String? ?? '' ..zoomable = json['zoomable'] as bool? ?? false ..ephemeral = json['ephemeral'] as bool? ?? false - ..allowedTypes = (json['allowedTypes'] as List) - .map((e) => e as String) - .toList() - ..deniedTypes = (json['deniedTypes'] as List) - .map((e) => e as String) - .toList() + ..allowedTypes = + (json['allowedTypes'] as List?) + ?.map((e) => e as String) + .toList() ?? + [] + ..deniedTypes = + (json['deniedTypes'] as List?) + ?.map((e) => e as String) + .toList() ?? + [] ..maxAllowedSize = json['maxAllowedSize'] == null ? null : SizeC.fromJson(json['maxAllowedSize'] as Map) - ..isScrollable = json['isScrollable'] as bool - ..useFlutterListView = json['useFlutterListView'] as bool + ..isScrollable = json['isScrollable'] as bool? ?? false + ..useFlutterListView = json['useFlutterListView'] as bool? ?? false ..type = json['type'] as String; Map _$PageViewNodeToJson(PageViewNode instance) => diff --git a/lib/src/api/nodes/rectangle_node.g.dart b/lib/src/api/nodes/rectangle_node.g.dart index 3e3fc760..48b1a45f 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), ) + ..pulse = json['pulse'] == null + ? null + : PulseModel.fromJson(json['pulse'] 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(), + 'pulse': ?instance.pulse?.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.dart b/lib/src/api/nodes/row_column_node.dart index 2094d2f7..367bb08e 100644 --- a/lib/src/api/nodes/row_column_node.dart +++ b/lib/src/api/nodes/row_column_node.dart @@ -1,5 +1,6 @@ import 'package:json_annotation/json_annotation.dart'; +import '../field_access.dart'; import '../mixins.dart'; import '../models/models.dart'; @@ -24,6 +25,29 @@ class RowColumnNode extends ParentNode @override set scrollDirection(AxisC value) {} + /// Whether a row lays its children out in runs, starting a new line when + /// the next child does not fit the remaining width, like Flutter's [Wrap]. + /// + /// Only rows wrap; a column ignores it. A wrapping row does not scroll, and + /// its expanded or flexible children take their natural (design) size, as + /// children of a Flutter [Wrap] do. [mainAxisAlignment] aligns each run + /// along the row, and [crossAxisAlignment] aligns each child within its + /// run (stretch and baseline behave as start). + bool wrap; + + /// The gap between neighboring children of a run, along the main axis, + /// when [wrap] is on. Corresponds to [Wrap.spacing]. + double spacing; + + /// The gap between runs, along the cross axis, when [wrap] is on. + /// Corresponds to [Wrap.runSpacing]. + double runSpacing; + + /// Whether this node wraps its children into runs: [wrap] is on and it is + /// a row. + @JsonKey(includeFromJson: false, includeToJson: false) + bool get wrapsChildren => wrap && rowColumnType == RowColumnType.row; + /// Creates a [RowColumnNode] with the given data. RowColumnNode({ required super.id, @@ -34,6 +58,9 @@ class RowColumnNode extends ParentNode required RowColumnType rowColumnType, MainAxisAlignmentC mainAxisAlignment = MainAxisAlignmentC.center, CrossAxisAlignmentC crossAxisAlignment = CrossAxisAlignmentC.center, + this.wrap = false, + this.spacing = 0, + this.runSpacing = 0, super.retainedOuterBoxLocal, super.visible, super.enabled, @@ -101,6 +128,37 @@ class RowColumnNode extends ParentNode ); } + @override + FieldsMap generateFields() => { + ...super.generateFields(), + 'wrap': BoolFieldAccess( + 'Wrap', + 'Whether a row starts a new line when its children do not fit.', + () => wrap, + (value) => wrap = value, + defaultValue: () => false, + requiresLayout: true, + ), + 'spacing': NumFieldAccess( + 'Spacing', + 'Gap between children of a run when wrap is on.', + () => spacing, + (value) => spacing = value, + min: 0, + defaultValue: () => 0, + requiresLayout: true, + ), + 'runSpacing': NumFieldAccess( + 'Run Spacing', + 'Gap between runs when wrap is on.', + () => runSpacing, + (value) => runSpacing = value, + min: 0, + defaultValue: () => 0, + requiresLayout: true, + ), + }; + /// Creates a [RowColumnNode] from a JSON data. factory RowColumnNode.fromJson(Map json) => _$RowColumnNodeFromJson(json); diff --git a/lib/src/api/nodes/row_column_node.g.dart b/lib/src/api/nodes/row_column_node.g.dart index 4385e68c..dcfaaf85 100644 --- a/lib/src/api/nodes/row_column_node.g.dart +++ b/lib/src/api/nodes/row_column_node.g.dart @@ -10,6 +10,9 @@ Map _$RowColumnNodeOmitDefaults( RowColumnNode instance, Map json, ) { + if (isJsonDefault(instance.wrap, false)) json.remove('wrap'); + if (isJsonDefault(instance.spacing, 0)) json.remove('spacing'); + if (isJsonDefault(instance.runSpacing, 0)) json.remove('runSpacing'); if (isJsonDefault(instance.visible, true)) json.remove('visible'); if (excludeConstraintsIf(instance)) json.remove('constraints'); if (isJsonDefault(instance.constraints, const BoxConstraintsModel())) @@ -119,6 +122,9 @@ RowColumnNode _$RowColumnNodeFromJson(Map json) => json['crossAxisAlignment'], ) ?? CrossAxisAlignmentC.center, + wrap: json['wrap'] as bool? ?? false, + spacing: (json['spacing'] as num?)?.toDouble() ?? 0, + runSpacing: (json['runSpacing'] as num?)?.toDouble() ?? 0, visible: json['visible'] as bool? ?? true, enabled: json['enabled'] as bool? ?? true, rotationDegrees: json['rotation'] == null @@ -272,11 +278,15 @@ RowColumnNode _$RowColumnNodeFromJson(Map json) => : InkWellModel.fromJson( Map.from(json['inkWell'] as Map), ) + ..pulse = json['pulse'] == null + ? null + : PulseModel.fromJson(json['pulse'] as Map) ..type = json['type'] as String; Map _$RowColumnNodeToJson( RowColumnNode instance, ) => { + 'reactions': instance.reactions.map((e) => e.toJson()).toList(), 'variables': instance.variables, 'multipleVariables': instance.multipleVariables, 'componentId': ?instance.componentId, @@ -294,7 +304,6 @@ Map _$RowColumnNodeToJson( 'flex': instance.flex, 'aspectRatioLock': instance.aspectRatioLock, 'alignment': instance.alignment.toJson(), - 'reactions': instance.reactions.map((e) => e.toJson()).toList(), 'basicBoxLocal': instance.basicBoxLocal.toJson(), 'margin': instance.margin.toJson(), 'padding': instance.padding.toJson(), @@ -313,6 +322,7 @@ Map _$RowColumnNodeToJson( 'isMask': instance.isMask, 'effects': instance.effects.map((e) => e.toJson()).toList(), 'inkWell': ?instance.inkWell?.toJson(), + 'pulse': ?instance.pulse?.toJson(), 'fills': instance.fills.map((e) => e.toJson()).toList(), 'strokes': instance.strokes.map((e) => e.toJson()).toList(), 'strokeWeight': instance.strokeWeight, @@ -338,6 +348,9 @@ Map _$RowColumnNodeToJson( 'useFlutterListView': instance.useFlutterListView, 'shouldAlwaysScroll': instance.shouldAlwaysScroll, 'type': instance.type, + 'wrap': instance.wrap, + 'spacing': instance.spacing, + 'runSpacing': instance.runSpacing, }; const _$RowColumnTypeEnumMap = { diff --git a/lib/src/api/nodes/single_placeholder_node.dart b/lib/src/api/nodes/single_placeholder_node.dart index 2cfa6ca2..0d7c164d 100644 --- a/lib/src/api/nodes/single_placeholder_node.dart +++ b/lib/src/api/nodes/single_placeholder_node.dart @@ -34,12 +34,14 @@ class SinglePlaceholderNode extends SceneNode /// The node types that are allowed to be inside this [SinglePlaceholderNode]. /// Example: 'APP_BAR' for AppBarNode. /// When empty, allow all types. + @JsonKey(defaultValue: []) List allowedTypes; /// The node types that are not allowed to be inside this /// [SinglePlaceholderNode]. /// Example: "TEXT" for TextNode. /// When empty, allow all types. + @JsonKey(defaultValue: []) List deniedTypes; /// If not null, this [SinglePlaceholderNode] will only accept nodes that do diff --git a/lib/src/api/nodes/single_placeholder_node.g.dart b/lib/src/api/nodes/single_placeholder_node.g.dart index d8ce6f51..b3cdff45 100644 --- a/lib/src/api/nodes/single_placeholder_node.g.dart +++ b/lib/src/api/nodes/single_placeholder_node.g.dart @@ -11,6 +11,10 @@ Map _$SinglePlaceholderNodeOmitDefaults( Map json, ) { if (isJsonDefault(instance.ephemeral, false)) json.remove('ephemeral'); + if (isJsonDefault(instance.allowedTypes, [])) + json.remove('allowedTypes'); + if (isJsonDefault(instance.deniedTypes, [])) + json.remove('deniedTypes'); if (isJsonDefault(instance.visible, true)) json.remove('visible'); if (excludeConstraintsIf(instance)) json.remove('constraints'); if (isJsonDefault(instance.constraints, const BoxConstraintsModel())) @@ -56,12 +60,16 @@ Map _$SinglePlaceholderNodeOmitDefaults( SinglePlaceholderNode _$SinglePlaceholderNodeFromJson(Map json) => SinglePlaceholderNode( - allowedTypes: (json['allowedTypes'] as List) - .map((e) => e as String) - .toList(), - deniedTypes: (json['deniedTypes'] as List) - .map((e) => e as String) - .toList(), + allowedTypes: + (json['allowedTypes'] as List?) + ?.map((e) => e as String) + .toList() ?? + [], + deniedTypes: + (json['deniedTypes'] as List?) + ?.map((e) => e as String) + .toList() ?? + [], maxAllowedSize: json['maxAllowedSize'] == null ? null : SizeC.fromJson(json['maxAllowedSize'] as Map), diff --git a/lib/src/api/nodes/slider_node.dart b/lib/src/api/nodes/slider_node.dart index 89636208..2e30b579 100644 --- a/lib/src/api/nodes/slider_node.dart +++ b/lib/src/api/nodes/slider_node.dart @@ -192,6 +192,28 @@ class SliderProperties extends CustomProperties with FieldsHolder { /// Whether to show slider's thumb. bool showThumb; + /// Shape of slider's thumb. + SliderThumbShapeEnum thumbShape; + + /// Width of a rectangular thumb. + double thumbWidth; + + /// Height of a rectangular thumb. + double thumbHeight; + + /// Corner radius of a rectangular thumb. + double thumbCornerRadius; + + /// Border color of a rectangular thumb, or null for no border. + ColorRGBA? thumbBorderColor; + + /// Border width of a rectangular thumb. + double thumbBorderWidth; + + /// Color of a vertical grip line in the middle of a rectangular thumb, or + /// null for none. + ColorRGBA? thumbGripColor; + /// Shape of slider's track. SliderTrackShapeEnum trackShape; @@ -246,6 +268,13 @@ class SliderProperties extends CustomProperties with FieldsHolder { this.valueIndicatorFontSize = 14, this.thumbRadius = kSliderDefaultThumbRadius, this.showThumb = true, + this.thumbShape = SliderThumbShapeEnum.circle, + this.thumbWidth = 18, + this.thumbHeight = 24, + this.thumbCornerRadius = 2, + this.thumbBorderColor, + this.thumbBorderWidth = 2, + this.thumbGripColor, this.trackShape = SliderTrackShapeEnum.roundedRectangle, this.tickMarkRadius = kSliderDefaultTickMarkRadius, this.valueIndicatorShape = SliderValueIndicatorShape.rectangle, @@ -289,6 +318,13 @@ 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, }) { return SliderProperties( activeTrackColor: activeTrackColor ?? this.activeTrackColor, @@ -326,6 +362,13 @@ 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, ); } @@ -473,6 +516,53 @@ class SliderProperties extends CustomProperties with FieldsHolder { (bool value) => showThumb = value, requiresLayout: true, ), + 'thumbShape': EnumFieldAccess( + 'Thumb Shape', + 'Shape of slider\'s thumb.', + () => thumbShape, + (SliderThumbShapeEnum value) => thumbShape = value, + defaultValue: () => SliderThumbShapeEnum.circle, + options: () => SliderThumbShapeEnum.values, + requiresLayout: true, + ), + 'thumbWidth': NumFieldAccess( + 'Thumb Width', + 'Width of a rectangular thumb.', + () => thumbWidth, + (double value) => thumbWidth = value, + requiresLayout: true, + ), + 'thumbHeight': NumFieldAccess( + 'Thumb Height', + 'Height of a rectangular thumb.', + () => thumbHeight, + (double value) => thumbHeight = value, + requiresLayout: true, + ), + 'thumbCornerRadius': NumFieldAccess( + 'Thumb Corner Radius', + 'Corner radius of a rectangular thumb.', + () => thumbCornerRadius, + (double value) => thumbCornerRadius = value, + ), + 'thumbBorderColor': ColorFieldAccess( + 'Thumb Border Color', + 'Border color of a rectangular thumb.', + () => thumbBorderColor, + (value) => thumbBorderColor = value, + ), + 'thumbBorderWidth': NumFieldAccess( + 'Thumb Border Width', + 'Border width of a rectangular thumb.', + () => thumbBorderWidth, + (double value) => thumbBorderWidth = value, + ), + 'thumbGripColor': ColorFieldAccess( + 'Thumb Grip Color', + 'Color of the grip line in a rectangular thumb.', + () => thumbGripColor, + (value) => thumbGripColor = value, + ), 'trackShape': EnumFieldAccess( 'Track Shape', 'Shape of slider\'s track.', @@ -561,6 +651,13 @@ class SliderProperties extends CustomProperties with FieldsHolder { secondaryTrackValue, secondaryActiveTrackColor, padding, + thumbShape, + thumbWidth, + thumbHeight, + thumbCornerRadius, + thumbBorderColor, + thumbBorderWidth, + thumbGripColor, ]; /// 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..61d06aa9 100644 --- a/lib/src/api/nodes/slider_node.g.dart +++ b/lib/src/api/nodes/slider_node.g.dart @@ -89,6 +89,14 @@ Map _$SliderPropertiesOmitDefaults( if (isJsonDefault(instance.thumbRadius, kSliderDefaultThumbRadius)) json.remove('thumbRadius'); if (isJsonDefault(instance.showThumb, true)) json.remove('showThumb'); + if (isJsonDefault(instance.thumbShape, SliderThumbShapeEnum.circle)) + json.remove('thumbShape'); + if (isJsonDefault(instance.thumbWidth, 18)) json.remove('thumbWidth'); + if (isJsonDefault(instance.thumbHeight, 24)) json.remove('thumbHeight'); + if (isJsonDefault(instance.thumbCornerRadius, 2)) + json.remove('thumbCornerRadius'); + if (isJsonDefault(instance.thumbBorderWidth, 2)) + json.remove('thumbBorderWidth'); if (isJsonDefault(instance.trackShape, SliderTrackShapeEnum.roundedRectangle)) json.remove('trackShape'); if (isJsonDefault(instance.tickMarkRadius, kSliderDefaultTickMarkRadius)) @@ -302,6 +310,19 @@ SliderProperties _$SliderPropertiesFromJson(Map json) => SliderProperties( thumbRadius: (json['thumbRadius'] as num?)?.toDouble() ?? kSliderDefaultThumbRadius, showThumb: json['showThumb'] as bool? ?? true, + thumbShape: + $enumDecodeNullable(_$SliderThumbShapeEnumEnumMap, json['thumbShape']) ?? + SliderThumbShapeEnum.circle, + thumbWidth: (json['thumbWidth'] as num?)?.toDouble() ?? 18, + thumbHeight: (json['thumbHeight'] as num?)?.toDouble() ?? 24, + thumbCornerRadius: (json['thumbCornerRadius'] as num?)?.toDouble() ?? 2, + thumbBorderColor: json['thumbBorderColor'] == null + ? null + : ColorRGBA.fromJson(json['thumbBorderColor']), + thumbBorderWidth: (json['thumbBorderWidth'] as num?)?.toDouble() ?? 2, + thumbGripColor: json['thumbGripColor'] == null + ? null + : ColorRGBA.fromJson(json['thumbGripColor']), trackShape: $enumDecodeNullable(_$SliderTrackShapeEnumEnumMap, json['trackShape']) ?? SliderTrackShapeEnum.roundedRectangle, @@ -355,6 +376,13 @@ Map _$SliderPropertiesToJson( 'allowFractionalPoints': instance.allowFractionalPoints, 'thumbRadius': instance.thumbRadius, 'showThumb': instance.showThumb, + 'thumbShape': _$SliderThumbShapeEnumEnumMap[instance.thumbShape]!, + 'thumbWidth': instance.thumbWidth, + 'thumbHeight': instance.thumbHeight, + 'thumbCornerRadius': instance.thumbCornerRadius, + 'thumbBorderColor': ?instance.thumbBorderColor?.toJson(), + 'thumbBorderWidth': instance.thumbBorderWidth, + 'thumbGripColor': ?instance.thumbGripColor?.toJson(), 'trackShape': _$SliderTrackShapeEnumEnumMap[instance.trackShape]!, 'tickMarkRadius': instance.tickMarkRadius, 'valueIndicatorShape': @@ -368,6 +396,11 @@ Map _$SliderPropertiesToJson( 'padding': ?instance.padding?.toJson(), }; +const _$SliderThumbShapeEnumEnumMap = { + SliderThumbShapeEnum.circle: 'circle', + SliderThumbShapeEnum.rectangle: 'rectangle', +}; + const _$SliderTrackShapeEnumEnumMap = { SliderTrackShapeEnum.rectangle: 'rectangle', SliderTrackShapeEnum.roundedRectangle: 'roundedRectangle', 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/tab_bar_node.g.dart b/lib/src/api/nodes/tab_bar_node.g.dart index b93405cc..34886f36 100644 --- a/lib/src/api/nodes/tab_bar_node.g.dart +++ b/lib/src/api/nodes/tab_bar_node.g.dart @@ -49,6 +49,8 @@ Map _$TabBarNodeOmitDefaults( if (isJsonDefault(instance.isScrollable, false)) json.remove('isScrollable'); if (isJsonDefault(instance.physics, ScrollPhysicsC.platformDependent)) json.remove('physics'); + if (isJsonDefault(instance.useFlutterListView, false)) + json.remove('useFlutterListView'); if (isJsonDefault(instance.shouldAlwaysScroll, true)) json.remove('shouldAlwaysScroll'); return json; @@ -212,7 +214,7 @@ TabBarNode _$TabBarNodeFromJson(Map json) => _$ScrollViewKeyboardDismissBehaviorCEnumMap, json['keyboardDismissBehavior'], ) - ..useFlutterListView = json['useFlutterListView'] as bool + ..useFlutterListView = json['useFlutterListView'] as bool? ?? false ..type = json['type'] as String; Map _$TabBarNodeToJson(TabBarNode instance) => diff --git a/lib/src/api/nodes/text_node.g.dart b/lib/src/api/nodes/text_node.g.dart index 01949d07..e1f473fe 100644 --- a/lib/src/api/nodes/text_node.g.dart +++ b/lib/src/api/nodes/text_node.g.dart @@ -216,6 +216,9 @@ TextNode _$TextNodeFromJson(Map json) => {} ..tooltip = json['tooltip'] as String? ?? '' ..zoomable = json['zoomable'] as bool? ?? false + ..pulse = json['pulse'] == null + ? null + : PulseModel.fromJson(json['pulse'] as Map) ..type = json['type'] as String; Map _$TextNodeToJson(TextNode instance) => { @@ -266,6 +269,7 @@ Map _$TextNodeToJson(TextNode instance) => { 'isMask': instance.isMask, 'effects': instance.effects.map((e) => e.toJson()).toList(), 'inkWell': ?instance.inkWell?.toJson(), + 'pulse': ?instance.pulse?.toJson(), 'type': instance.type, }; diff --git a/lib/src/api/nodes/variance_node.g.dart b/lib/src/api/nodes/variance_node.g.dart index be7a515d..82d7379d 100644 --- a/lib/src/api/nodes/variance_node.g.dart +++ b/lib/src/api/nodes/variance_node.g.dart @@ -13,6 +13,10 @@ Map _$VarianceNodeOmitDefaults( if (isJsonDefault(instance.properties, const VarianceProperties())) json.remove('properties'); if (isJsonDefault(instance.ephemeral, false)) json.remove('ephemeral'); + if (isJsonDefault(instance.allowedTypes, [])) + json.remove('allowedTypes'); + if (isJsonDefault(instance.deniedTypes, [])) + json.remove('deniedTypes'); if (isJsonDefault(instance.visible, true)) json.remove('visible'); if (excludeConstraintsIf(instance)) json.remove('constraints'); if (isJsonDefault(instance.constraints, const BoxConstraintsModel())) @@ -169,12 +173,16 @@ VarianceNode _$VarianceNodeFromJson(Map json) => ..tooltip = json['tooltip'] as String? ?? '' ..zoomable = json['zoomable'] as bool? ?? false ..ephemeral = json['ephemeral'] as bool? ?? false - ..allowedTypes = (json['allowedTypes'] as List) - .map((e) => e as String) - .toList() - ..deniedTypes = (json['deniedTypes'] as List) - .map((e) => e as String) - .toList() + ..allowedTypes = + (json['allowedTypes'] as List?) + ?.map((e) => e as String) + .toList() ?? + [] + ..deniedTypes = + (json['deniedTypes'] as List?) + ?.map((e) => e as String) + .toList() ?? + [] ..type = json['type'] as String; Map _$VarianceNodeToJson(VarianceNode instance) => diff --git a/test/computed_variable_test.dart b/test/computed_variable_test.dart new file mode 100644 index 00000000..df6b1129 --- /dev/null +++ b/test/computed_variable_test.dart @@ -0,0 +1,50 @@ +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:test/test.dart'; + +void main() { + test('a formula value makes a computed variable of any type', () { + for (final VariableType type in VariableType.values) { + final VariableData v = VariableData( + id: 'v', + name: 'downtime', + type: type, + value: r' ${= sum(events, "minutes")} ', + ); + expect(v.isComputed, isTrue, reason: type.name); + expect(v.value, r'${= sum(events, "minutes")}', reason: type.name); + expect(v.getValue(), isNull, reason: type.name); + } + }); + + test('plain values and paths are not computed', () { + expect( + VariableData( + id: 'a', + name: 'a', + value: 12, + type: VariableType.integer, + ).isComputed, + isFalse, + ); + expect( + VariableData(id: 'b', name: 'b', value: r'${other}').isComputed, + isFalse, + ); + expect( + VariableData(id: 'c', name: 'c', value: r'total: ${= 1 + 1}').isComputed, + isFalse, + ); + }); + + test('a computed variable round-trips through JSON', () { + final VariableData v = VariableData( + id: 'v', + name: 'pareto', + type: VariableType.list, + value: r'${= sortBy(rows, "min", "desc")}', + ); + final VariableData back = VariableData.fromJson(v.toJson()); + expect(back.isComputed, isTrue); + expect(back, v); + }); +} diff --git a/test/gauge_chart_test.dart b/test/gauge_chart_test.dart new file mode 100644 index 00000000..46b31b46 --- /dev/null +++ b/test/gauge_chart_test.dart @@ -0,0 +1,156 @@ +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:test/test.dart'; + +void main() { + group('GaugeProperties', () { + final props = GaugeProperties(); + + test('fraction clamps to the arc', () { + expect(props.fraction(50), 0.5); + expect(props.fraction(-10), 0); + expect(props.fraction(140), 1); + }); + + test('the value arc takes the color of its zone', () { + expect(props.colorFor(40), kDefaultGaugeZones[0].color); + expect(props.colorFor(72), kDefaultGaugeZones[1].color); + expect(props.colorFor(85), kDefaultGaugeZones[2].color); + expect(props.colorFor(100), kDefaultGaugeZones[2].color); + expect( + GaugeProperties(colorByZone: false).colorFor(40), + props.valueColor, + ); + }); + + test('a gauge with a bound value round-trips', () { + final node = GaugeNode( + id: 'g', + name: 'Plant OEE', + basicBoxLocal: NodeBox(0, 0, 220, 185), + properties: GaugeProperties(showTarget: false, showNeedle: false), + variables: {'value': r'${plantOee}'}, + ); + final back = NodeJsonConverter().fromJson(node.toJson())! as GaugeNode; + expect(back.properties.showTarget, isFalse); + expect(back.properties.showNeedle, isFalse); + expect(back.variables['value'], r'${plantOee}'); + }); + }); + + group('ChartNode', () { + test('defaults omit from JSON and come back', () { + final node = ChartNode( + id: 'c', + name: 'Chart', + basicBoxLocal: NodeBox(0, 0, 360, 200), + properties: ChartProperties(), + ); + final json = node.toJson(); + expect((json['properties'] as Map?) ?? {}, isEmpty); + final back = NodeJsonConverter().fromJson(json)! as ChartNode; + expect(back.properties.series, kDefaultChartSeries); + expect(back.selectedIndex, -1); + }); + + test('series and data bindings round-trip', () { + final node = ChartNode.fromJson({ + 'id': 'c', + 'name': 'Hourly', + 'type': 'chart', + 'basicBoxLocal': [0, 0, 400, 220], + 'variables': { + 'data': r'${hourly}', + 'selectedIndex': r'${selectedHour}', + }, + 'properties': { + 'series': [ + {'field': 'good', 'stack': 'o', 'colorField': 'color'}, + {'field': 'plan', 'kind': 'tick', 'dashed': true}, + ], + }, + }); + expect(node.properties.series[0].colorField, 'color'); + expect(node.properties.series[1].kind, ChartSeriesKind.tick); + expect(node.triggerTypes, [TriggerType.changed]); + final back = NodeJsonConverter().fromJson(node.toJson())! as ChartNode; + expect(back, node); + }); + + test('a timeline keeps its now marker color', () { + final props = ChartProperties.fromJson({ + 'type': 'timeline', + 'nowValue': 7.7, + 'nowColor': {'r': 1, 'g': 1, 'b': 1, 'a': 1}, + }); + expect(props.nowColor, const ColorRGBA(r: 1, g: 1, b: 1, a: 1)); + expect(ChartProperties.fromJson(props.toJson()), props); + expect(ChartProperties().toJson().containsKey('nowColor'), isFalse); + }); + }); + + test('a repeating gradient keeps its tile mode', () { + final paint = PaintModel.linearGradient( + id: 'p', + gradientTileMode: GradientTileMode.repeated, + gradientStops: const [ColorStop(position: 0, color: ColorRGBA.black)], + ); + final back = PaintModel.fromJson(paint.toJson()); + expect(back.gradientTileMode, GradientTileMode.repeated); + expect(back.sanitize().gradientTileMode, GradientTileMode.repeated); + expect( + PaintModel.fromJson(PaintModel.linearGradient(id: 'q').toJson()) + .gradientTileMode, + GradientTileMode.clamp, + ); + }); + + test('gauge and chart JSON may leave out properties', () { + final gauge = NodeJsonConverter().fromJson({ + 'id': 'g', + 'name': 'G', + 'type': 'gauge', + 'basicBoxLocal': [0, 0, 220, 185], + }); + final chart = NodeJsonConverter().fromJson({ + 'id': 'c', + 'name': 'C', + 'type': 'chart', + 'basicBoxLocal': [0, 0, 300, 200], + }); + expect((gauge! as GaugeNode).properties, GaugeProperties()); + expect((chart! as ChartNode).properties.series, kDefaultChartSeries); + }); + + // Types that need content of their own (an icon, a URL, a title, ...) + // still need it in JSON. + test('nodes read minimal JSON: no properties, no scroll settings', () { + for (final type in [ + 'listView', + 'gridView', + 'pageView', + 'progressBar', + 'switch', + 'checkbox', + 'textField', + 'button', + 'chip', + 'badge', + 'divider', + 'segmentedButton', + 'listTile', + 'radio', + 'floatingActionButton', + 'accordion', + 'gauge', + 'chart', + ]) { + final node = NodeJsonConverter().fromJson({ + 'id': type, + 'name': type, + 'type': type, + 'basicBoxLocal': [0, 0, 100, 40], + }); + expect(node?.type, type, reason: type); + } + }); +} diff --git a/test/json_defaults_test.dart b/test/json_defaults_test.dart index bb2e1328..1b109938 100644 --- a/test/json_defaults_test.dart +++ b/test/json_defaults_test.dart @@ -257,6 +257,43 @@ List sampleNodes() { _node('placeholder'), _node('accordion', {'rowColumnType': 'column', 'properties': {}}), _node('external', {'properties': {}}), + _node('gauge', { + 'value': 72.6, + 'properties': { + 'segments': 0, + 'target': 90, + 'labels': [0, 100], + 'labelFontFamily': 'JetBrains Mono', + 'zones': [ + {'from': 0, 'to': 50, 'color': '#FF4A3D'}, + {'from': 50, 'to': 100, 'color': '#2FD17A'}, + ], + }, + }), + _node('chart', { + 'selectedIndex': 2, + 'properties': { + 'chartType': 'line', + 'xSlots': 8, + 'nowValue': 342, + 'series': [ + { + 'field': 'actual', + 'kind': 'area', + 'fillTo': 'plan', + 'endDot': true, + }, + {'field': 'plan', 'kind': 'line', 'dashed': true}, + ], + 'referenceLines': [ + {'value': 85, 'color': '#2FD17A'}, + ], + 'sampleData': [ + {'label': '14', 'actual': 0, 'plan': 0}, + {'label': '15', 'actual': 700, 'plan': 800}, + ], + }, + }), ]) NodeJsonConverter().fromJson(json)!, ]; @@ -334,6 +371,39 @@ void main() { }); } + test('a pulse round-trips and is left out when unset', () { + final RectangleNode rect = RectangleNode.fromJson({ + 'id': 'r', + 'name': 'Skeleton', + 'type': 'rectangle', + 'basicBoxLocal': [0, 0, 100, 12], + 'pulse': {'durationMillis': 600}, + }); + expect(rect.pulse, const PulseModel(durationMillis: 600)); + expect(rect.pulse!.minOpacity, 0.45); + expect(rect.toJson()['pulse'], {'durationMillis': 600}); + expect( + RectangleNode.fromJson(rect.toJson()).pulse, + const PulseModel(durationMillis: 600), + ); + rect.pulse = null; + expect(rect.toJson(), isNot(contains('pulse'))); + }); + + test('a spacer reads JSON that leaves out its defaults', () { + final SpacerNode spacer = SpacerNode.fromJson({ + 'id': 's', + 'name': 'Spacer', + 'type': 'spacer', + 'basicBoxLocal': [0, 0, 1, 1], + 'flex': 2, + }); + expect(spacer.positioningMode, PositioningMode.align); + expect(spacer.aspectRatioLock, isFalse); + expect(spacer.reactions, isEmpty); + expect(spacer.toJson(), isNot(contains('positioningMode'))); + }); + test('empty action lists, maps and sets are left out', () { expect( ConditionalAction(expression: _expression()).toJson().keys, diff --git a/test/material_icon_test.dart b/test/material_icon_test.dart new file mode 100644 index 00000000..8248b731 --- /dev/null +++ b/test/material_icon_test.dart @@ -0,0 +1,27 @@ +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:test/test.dart'; + +void main() { + MaterialIcon read(String name, [String style = 'filled']) => + MaterialIcon.fromJson({ + 'codepoint': 0xf2c7, + 'name': name, + 'style': style, + 'type': 'MATERIAL_ICON', + }); + + test('a style suffix in the name becomes the style', () { + final MaterialIcon icon = read('precision_manufacturing_outlined'); + expect(icon.name, 'precision_manufacturing'); + expect(icon.style, MaterialIconStyle.outlined); + expect(read('home_rounded').style, MaterialIconStyle.rounded); + expect(read('home_sharp').style, MaterialIconStyle.sharp); + }); + + test('names without a style suffix stay as they are', () { + final MaterialIcon icon = read('person_outline'); + expect(icon.name, 'person_outline'); + expect(icon.style, MaterialIconStyle.filled); + expect(read('home_outlined', 'rounded').name, 'home_outlined'); + }); +} diff --git a/test/row_column_wrap_test.dart b/test/row_column_wrap_test.dart new file mode 100644 index 00000000..985a4501 --- /dev/null +++ b/test/row_column_wrap_test.dart @@ -0,0 +1,95 @@ +import 'dart:convert'; + +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:test/test.dart'; + +RowColumnNode _row({ + bool wrap = false, + double spacing = 0, + double runSpacing = 0, + RowColumnType type = RowColumnType.row, +}) => RowColumnNode( + id: 'row', + name: 'Row', + basicBoxLocal: NodeBox(0, 0, 200, 40), + children: const ['a', 'b'], + rowColumnType: type, + wrap: wrap, + spacing: spacing, + runSpacing: runSpacing, +); + +Map _encodeDecode(Map json) => + jsonDecode(jsonEncode(json)) as Map; + +void main() { + group('RowColumnNode wrap', () { + test('defaults are off and left out of the JSON', () { + final RowColumnNode node = _row(); + expect(node.wrap, isFalse); + expect(node.spacing, 0); + expect(node.runSpacing, 0); + + final Map json = node.toJson(); + expect(json.containsKey('wrap'), isFalse); + expect(json.containsKey('spacing'), isFalse); + expect(json.containsKey('runSpacing'), isFalse); + expect(json.containsKey('wrapsChildren'), isFalse); + }); + + test('JSON without the keys reads the defaults', () { + final RowColumnNode node = RowColumnNode.fromJson( + _encodeDecode(_row().toJson()), + ); + expect(node.wrap, isFalse); + expect(node.spacing, 0); + expect(node.runSpacing, 0); + }); + + test('round-trips wrap, spacing and runSpacing', () { + final Map json = _encodeDecode( + _row(wrap: true, spacing: 8, runSpacing: 6).toJson(), + ); + expect(json['wrap'], isTrue); + expect(json['spacing'], 8); + expect(json['runSpacing'], 6); + + final RowColumnNode node = RowColumnNode.fromJson(json); + expect(node.wrap, isTrue); + expect(node.spacing, 8.0); + expect(node.runSpacing, 6.0); + expect(node.wrapsChildren, isTrue); + }); + + test('accepts integer spacing values in JSON', () { + final Map json = _encodeDecode(_row().toJson()) + ..['wrap'] = true + ..['spacing'] = 4 + ..['runSpacing'] = 2; + final RowColumnNode node = RowColumnNode.fromJson(json); + expect(node.spacing, isA()); + expect(node.spacing, 4.0); + expect(node.runSpacing, 2.0); + }); + + test('only rows wrap their children', () { + expect(_row(wrap: true).wrapsChildren, isTrue); + expect( + _row(wrap: true, type: RowColumnType.column).wrapsChildren, + isFalse, + ); + expect(_row().wrapsChildren, isFalse); + }); + + test('fields are exposed for overrides', () { + final RowColumnNode node = _row(); + final FieldsMap fields = node.generateFields(); + fields['wrap']!.setValue(true); + fields['spacing']!.setValue(12.0); + fields['runSpacing']!.setValue(3.0); + expect(node.wrap, isTrue); + expect(node.spacing, 12.0); + expect(node.runSpacing, 3.0); + }); + }); +} diff --git a/test/show_dialog_action_test.dart b/test/show_dialog_action_test.dart new file mode 100644 index 00000000..2dad25f7 --- /dev/null +++ b/test/show_dialog_action_test.dart @@ -0,0 +1,85 @@ +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:test/test.dart'; + +void main() { + test('A saved dialog action without an anchor stays centered', () { + final Map saved = { + 'type': 'showDialog', + 'destinationId': 'canvas-1', + 'params': {}, + }; + final action = ActionModel.fromJson(saved) as ShowDialogAction; + expect(action.anchor, DialogAnchor.none); + expect(action.isAnchored, isFalse); + expect(action.placement, DialogPlacement.belowStart); + expect(action.anchorGap, 4); + expect(action.anchorOffset, 0); + // Defaults stay out of the JSON, so old projects save unchanged. + expect(action.toJson(), saved); + }); + + test('An anchored dialog action round-trips its placement', () { + final action = ShowDialogAction( + destinationId: 'menu', + anchor: DialogAnchor.trigger, + placement: DialogPlacement.belowEnd, + anchorGap: 8, + anchorOffset: -2, + barrierColor: ColorRGBA.transparent, + ); + final Map json = action.toJson(); + expect(json['anchor'], 'trigger'); + expect(json['placement'], 'belowEnd'); + expect(json['anchorGap'], 8); + expect(json['anchorOffset'], -2); + final decoded = ActionModel.fromJson(json) as ShowDialogAction; + expect(decoded, action); + expect(decoded.isAnchored, isTrue); + + // Only what differs from the defaults is written. + final Map onlyAnchor = ShowDialogAction( + destinationId: 'menu', + anchor: DialogAnchor.trigger, + ).toJson(); + expect(onlyAnchor.containsKey('placement'), isFalse); + expect(onlyAnchor.containsKey('anchorGap'), isFalse); + expect(onlyAnchor.containsKey('anchorOffset'), isFalse); + }); + + test( + 'Unknown anchors and placements from a newer editor read as defaults', + () { + final action = ActionModel.fromJson({ + 'type': 'showDialog', + 'destinationId': 'menu', + 'anchor': 'cursor', + 'placement': 'diagonal', + }) as ShowDialogAction; + expect(action.anchor, DialogAnchor.none); + expect(action.placement, DialogPlacement.belowStart); + }, + ); + + test('copyWith keeps the anchor and the enabled and blocking flags', () { + final action = ShowDialogAction( + destinationId: 'menu', + anchor: DialogAnchor.trigger, + placement: DialogPlacement.aboveCenter, + nonBlocking: true, + enabled: false, + ); + final copy = action.copyWith(showCloseButton: true); + expect(copy.anchor, DialogAnchor.trigger); + expect(copy.placement, DialogPlacement.aboveCenter); + expect(copy.nonBlocking, isTrue); + expect(copy.enabled, isFalse); + expect(copy.copyWith(anchor: DialogAnchor.none).isAnchored, isFalse); + }); + + test('Placements know their axis', () { + expect(DialogPlacement.belowStart.isVertical, isTrue); + expect(DialogPlacement.aboveEnd.isVertical, isTrue); + expect(DialogPlacement.rightStart.isVertical, isFalse); + expect(DialogPlacement.leftEnd.isVertical, isFalse); + }); +} diff --git a/test/show_toast_action_test.dart b/test/show_toast_action_test.dart index 05ef88e0..7105828d 100644 --- a/test/show_toast_action_test.dart +++ b/test/show_toast_action_test.dart @@ -75,4 +75,20 @@ void main() { expect(toast.copyWith(clearBackgroundColor: true).backgroundColor, isNull); }); + + test('ShowToastAction keeps its accent edge, radius and width', () { + final toast = ShowToastAction( + message: 'Saved', + accentColor: ColorRGBA.black, + cornerRadius: 4, + maxWidth: 460, + ); + final Map json = toast.toJson(); + expect(json['maxWidth'], 460); + expect(ShowToastAction.fromJson(json), toast); + final Map plain = ShowToastAction(message: 'Saved').toJson(); + for (final key in ['accentColor', 'cornerRadius', 'maxWidth']) { + expect(plain.containsKey(key), isFalse, reason: key); + } + }); } diff --git a/test/slider_thumb_test.dart b/test/slider_thumb_test.dart new file mode 100644 index 00000000..af18b1f2 --- /dev/null +++ b/test/slider_thumb_test.dart @@ -0,0 +1,21 @@ +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:test/test.dart'; + +void main() { + test('a rectangular slider thumb round-trips; defaults stay out', () { + final props = SliderProperties.fromJson({ + 'thumbShape': 'rectangle', + 'thumbWidth': 18, + 'thumbHeight': 24, + 'thumbBorderColor': {'r': 0, 'g': 0, 'b': 0, 'a': 1}, + 'thumbGripColor': {'r': 1, 'g': 1, 'b': 1, 'a': 1}, + }); + expect(props.thumbShape, SliderThumbShapeEnum.rectangle); + expect(props.thumbCornerRadius, 2); + expect(SliderProperties.fromJson(props.toJson()), props); + final Map defaults = SliderProperties().toJson(); + for (final key in ['thumbShape', 'thumbWidth', 'thumbGripColor']) { + expect(defaults.containsKey(key), isFalse, reason: key); + } + }); +}