From c3e2d120eb4696dd11b3786748088b10928c8660 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 18:25:31 +0000 Subject: [PATCH] codelessly_api 5.1.0: badge, chip and segmented button nodes Port BadgeNode, ChipNode and SegmentedButtonNode from the unmerged migrate-json-serializable branch onto the 5.x conventions: standard json_serializable with the json_defaults_builder omit-defaults step, Equatable props, copyWith, field access, and Material 3 defaults that follow Flutter 3.47's Badge, chip widgets and SegmentedButton. Fields renamed to their Flutter names keep the branch's JSON keys (dotSize, labelStyle, selectedSegments, selectedColor, unselectedColor), so JSON written with the branch still loads. The segment model is ButtonSegmentModel to avoid clashing with Flutter's ButtonSegment. Register the three types with NodeJsonConverter, port the branch's tests and badge fixtures, and add round-trip and branch-JSON tests. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01A67XoQYsLVvoMtiKcXXUxY --- CHANGELOG.md | 37 ++ lib/src/api/node_json_converter.dart | 3 + lib/src/api/nodes/badge_node.dart | 394 ++++++++++++ lib/src/api/nodes/badge_node.g.dart | 257 ++++++++ lib/src/api/nodes/chip_node.dart | 480 ++++++++++++++ lib/src/api/nodes/chip_node.g.dart | 277 ++++++++ lib/src/api/nodes/nodes.dart | 3 + lib/src/api/nodes/segmented_button_node.dart | 429 +++++++++++++ .../api/nodes/segmented_button_node.g.dart | 300 +++++++++ pubspec.yaml | 2 +- test/fixtures/badge_01_basic.json | 17 + test/fixtures/badge_02_with_child.json | 30 + test/fixtures/node_json_branch.json | 173 +++++ .../nodes/badge_chip_segmented_json_test.dart | 317 ++++++++++ test/nodes/display/badge_node_test.dart | 507 +++++++++++++++ test/nodes/display/chip_node_test.dart | 594 ++++++++++++++++++ .../input/segmented_button_node_test.dart | 314 +++++++++ 17 files changed, 4133 insertions(+), 1 deletion(-) create mode 100644 lib/src/api/nodes/badge_node.dart create mode 100644 lib/src/api/nodes/badge_node.g.dart create mode 100644 lib/src/api/nodes/chip_node.dart create mode 100644 lib/src/api/nodes/chip_node.g.dart create mode 100644 lib/src/api/nodes/segmented_button_node.dart create mode 100644 lib/src/api/nodes/segmented_button_node.g.dart create mode 100644 test/fixtures/badge_01_basic.json create mode 100644 test/fixtures/badge_02_with_child.json create mode 100644 test/fixtures/node_json_branch.json create mode 100644 test/nodes/badge_chip_segmented_json_test.dart create mode 100644 test/nodes/display/badge_node_test.dart create mode 100644 test/nodes/display/chip_node_test.dart create mode 100644 test/nodes/input/segmented_button_node_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 7eeb0af..0884197 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,40 @@ +## 5.1.0 + +### New nodes + +- `BadgeNode` (`'badge'`): Material 3 `Badge` as a dot, label or count + (`BadgeType`), optionally anchored to another node through `childID`. + `BadgeProperties` mirrors Flutter's `backgroundColor`, `textColor`, + `textStyle`, `smallSize`, `largeSize`, `padding`, `isLabelVisible` and + `Badge.count`'s `maxCount`, plus `BadgeAlignment` and an `offsetX`/ + `offsetY` offset (Flutter's default `Offset(4, -4)`). +- `ChipNode` (`'chip'`): Material 3 `Chip`, `ActionChip`, `ChoiceChip`, + `FilterChip` and `InputChip` (`ChipType`). `ChipProperties` covers the + label and its style, an icon or image avatar, the delete icon and its + color, selection with `showCheckmark`/`checkmarkColor`, colors, border, + corner radius, elevation, paddings (Material 3 defaults: 8 px padding, + 8 px horizontal label padding), `tooltip` and `autofocus`. Choice and + filter chips expose a `selected` property variable. +- `SegmentedButtonNode` (`'segmentedButton'`): Material 3 + `SegmentedButton` built from `ButtonSegmentModel`s, with + `selected`, `multiSelectionEnabled`, `emptySelectionAllowed`, + `showSelectedIcon`, `selectedIcon`, `direction`, border, foreground and + background colors for both states and a stadium-shaped default corner + radius. The selection is exposed as the `selected` property variable. +- All three are registered with `NodeJsonConverter`. + +### JSON + +- These nodes first appeared on the unreleased `migrate-json-serializable` + branch. JSON written with it still loads: keys are unchanged where a + field was renamed to its Flutter name (`dotSize` holds + `BadgeProperties.smallSize`, `labelStyle` holds + `BadgeProperties.textStyle`, `selectedSegments` holds + `SegmentedButtonProperties.selected`, `selectedColor`/`unselectedColor` + hold `selectedBackgroundColor`/`backgroundColor`), and field access uses + the same keys. +- As with every other node, fields that equal their defaults are omitted. + ## 5.0.0 ### Breaking changes diff --git a/lib/src/api/node_json_converter.dart b/lib/src/api/node_json_converter.dart index 0124f9f..311acd7 100644 --- a/lib/src/api/node_json_converter.dart +++ b/lib/src/api/node_json_converter.dart @@ -71,6 +71,9 @@ class NodeJsonConverter implements JsonConverter { 'gridView': GridViewNode.fromJson, 'pageView': PageViewNode.fromJson, 'tabBar': TabBarNode.fromJson, + 'badge': BadgeNode.fromJson, + 'chip': ChipNode.fromJson, + 'segmentedButton': SegmentedButtonNode.fromJson, 'external': ExternalComponentNode.fromJson, }; } diff --git a/lib/src/api/nodes/badge_node.dart b/lib/src/api/nodes/badge_node.dart new file mode 100644 index 0000000..a72c9e2 --- /dev/null +++ b/lib/src/api/nodes/badge_node.dart @@ -0,0 +1,394 @@ +import 'package:json_annotation/json_annotation.dart'; + +import '../field_access.dart'; +import '../mixins.dart'; +import '../models/models.dart'; +import 'nodes.dart'; + +part 'badge_node.g.dart'; + +/// Default size of a badge node. +const double kBadgeDefaultSize = 48; + +/// A Material 3 badge that shows a small dot, a label or a count, optionally +/// anchored to another node. +/// Refer to [Badge](https://api.flutter.dev/flutter/material/Badge-class.html) +/// in Flutter for more details. +@JsonSerializable() +class BadgeNode extends SceneNode with CustomPropertiesMixin { + @override + final String type = 'badge'; + + @override + bool get supportsPadding => true; + + /// Badges have no tap callback of their own, so click reactions are + /// handled by the renderer like on any other node. + @override + bool get handlesDefaultReactionsInternally => false; + + /// Holds configurable properties of this badge. + @override + covariant BadgeProperties properties; + + /// ID of the node that the badge is anchored to, rendered as the badge's + /// child. When null, only the badge itself is rendered. + String? childID; + + /// Strictly used for previews. e.g in components panel. + BadgeNode.empty() + : childID = null, + properties = BadgeProperties(label: '1'), + super( + id: 'badge', + name: 'Badge', + basicBoxLocal: NodeBox(0, 0, kBadgeDefaultSize, kBadgeDefaultSize), + ); + + /// Strictly used for previews. e.g in components panel. + BadgeNode.fromProperties(this.properties) + : childID = null, + super( + id: 'badge', + name: 'Badge', + basicBoxLocal: NodeBox(0, 0, kBadgeDefaultSize, kBadgeDefaultSize), + ); + + /// Creates a [BadgeNode] with the given data. + BadgeNode({ + required super.id, + required super.name, + required super.basicBoxLocal, + super.outerBoxLocal, + super.retainedOuterBoxLocal, + super.visible, + super.enabled, + super.alignment, + super.padding, + super.margin, + super.rotationDegrees, + super.reactions, + super.horizontalFit, + super.verticalFit, + super.flex, + super.constraints, + super.aspectRatioLock, + super.edgePins, + super.parentID, + super.positioningMode, + // Custom properties. + required this.properties, + this.childID, + super.variables, + super.multipleVariables, + }); + + @override + FieldsMap generateFields() => { + ...super.generateFields(), + 'properties': ObjectFieldAccess( + 'Properties', + 'Properties of the badge.', + () => properties, + (value) => properties = value, + ), + }; + + @override + List get triggerTypes => [TriggerType.click]; + + @override + late final List propertyVariables = [ + ...super.propertyVariables, + StringValue(name: 'label', value: properties.label), + BoolValue(name: 'isLabelVisible', value: properties.isLabelVisible), + ]; + + @override + List get props => [ + ...super.props, + properties, + childID, + variables, + multipleVariables, + ]; + + /// Creates a [BadgeNode] from a JSON data. + factory BadgeNode.fromJson(Map json) => _$BadgeNodeFromJson(json); + + @override + Map toJson() => _$BadgeNodeOmitDefaults(this, _$BadgeNodeToJson(this)); +} + +/// Badge types. +enum BadgeType { + /// Small dot badge without a label, sized by [BadgeProperties.smallSize]. + dot, + + /// Badge with a text label. + label, + + /// Badge with a count. Counts above [BadgeProperties.maxCount] show as + /// `'+'`, like Flutter's `Badge.count`. + count; + + /// Returns displayable string representation. + String get prettify => switch (this) { + BadgeType.dot => 'Dot Badge', + BadgeType.label => 'Label Badge', + BadgeType.count => 'Count Badge', + }; +} + +/// Badge alignment relative to its child. +enum BadgeAlignment { + /// Top-right corner. + topRight, + + /// Top-left corner. + topLeft, + + /// Bottom-right corner. + bottomRight, + + /// Bottom-left corner. + bottomLeft; + + /// Returns displayable string representation. + String get prettify => switch (this) { + BadgeAlignment.topRight => 'Top Right', + BadgeAlignment.topLeft => 'Top Left', + BadgeAlignment.bottomRight => 'Bottom Right', + BadgeAlignment.bottomLeft => 'Bottom Left', + }; +} + +/// Holds configurable properties of a [BadgeNode]. +@JsonSerializable() +class BadgeProperties extends CustomProperties with FieldsHolder { + /// Type of badge. + BadgeType badgeType; + + /// Label text for label and count badges. + String label; + + /// Largest count shown as-is by a [BadgeType.count] badge whose [label] is + /// a number. Larger counts show as `'+'`. + int maxCount; + + /// Badge background color. Defaults to the theme's `colorScheme.error`. + ColorRGBA? backgroundColor; + + /// Label color. Defaults to the theme's `colorScheme.onError`. + ColorRGBA? textColor; + + /// Label text style. Its color is used when [textColor] is null and the + /// style has fills. + @JsonKey(name: 'labelStyle') + TextProp textStyle; + + /// Badge alignment relative to the child. + BadgeAlignment alignment; + + /// Horizontal offset of a labeled badge from the aligned position. + double offsetX; + + /// Vertical offset of a labeled badge from the aligned position. + double offsetY; + + /// Whether to show the badge. + bool isLabelVisible; + + /// Diameter of a [BadgeType.dot] badge. + @JsonKey(name: 'dotSize') + double smallSize; + + /// Height of a labeled badge, and its minimum width. + double largeSize; + + /// Padding around the label of label and count badges. + EdgeInsetsModel padding; + + /// Creates a [BadgeProperties] with the given data. + BadgeProperties({ + this.badgeType = BadgeType.count, + this.label = '', + this.maxCount = 999, + this.backgroundColor, + this.textColor, + TextProp? textStyle, + this.alignment = BadgeAlignment.topRight, + this.offsetX = 4, + this.offsetY = -4, + this.isLabelVisible = true, + this.smallSize = 6, + this.largeSize = 16, + this.padding = const EdgeInsetsModel.symmetric(horizontal: 4), + }) : textStyle = + textStyle ?? + TextProp.general(fontSize: 11, fills: List.empty(growable: true)); + + /// The text a labeled badge shows: [label], or `'+'` for a + /// [BadgeType.count] badge whose numeric label exceeds [maxCount]. + String effectiveLabel([String? label]) { + final text = label ?? this.label; + if (badgeType != BadgeType.count) return text; + final count = int.tryParse(text.trim()); + if (count == null || count <= maxCount) return text; + return '$maxCount+'; + } + + /// Duplicates this [BadgeProperties] with the given data overrides. + BadgeProperties copyWith({ + BadgeType? badgeType, + String? label, + int? maxCount, + ColorRGBA? backgroundColor, + ColorRGBA? textColor, + TextProp? textStyle, + BadgeAlignment? alignment, + double? offsetX, + double? offsetY, + bool? isLabelVisible, + double? smallSize, + double? largeSize, + EdgeInsetsModel? padding, + }) { + return BadgeProperties( + badgeType: badgeType ?? this.badgeType, + label: label ?? this.label, + maxCount: maxCount ?? this.maxCount, + backgroundColor: backgroundColor ?? this.backgroundColor, + textColor: textColor ?? this.textColor, + textStyle: textStyle ?? this.textStyle, + alignment: alignment ?? this.alignment, + offsetX: offsetX ?? this.offsetX, + offsetY: offsetY ?? this.offsetY, + isLabelVisible: isLabelVisible ?? this.isLabelVisible, + smallSize: smallSize ?? this.smallSize, + largeSize: largeSize ?? this.largeSize, + padding: padding ?? this.padding, + ); + } + + @override + FieldsMap generateFields() => { + ...super.generateFields(), + 'badgeType': EnumFieldAccess( + 'Badge Type', + 'Type of the badge.', + () => badgeType, + (value) => badgeType = value, + defaultValue: () => BadgeType.count, + options: () => BadgeType.values, + requiresLayout: true, + ), + 'label': StringFieldAccess( + 'Label', + 'Text label of the badge.', + () => label, + (value) => label = value, + requiresLayout: true, + ), + 'maxCount': NumFieldAccess( + 'Max Count', + 'Largest count shown as-is; larger counts show as "+".', + () => maxCount, + (value) => maxCount = value, + requiresLayout: true, + ), + 'backgroundColor': ColorFieldAccess( + 'Background Color', + 'Background color of the badge.', + () => backgroundColor, + (value) => backgroundColor = value, + ), + 'textColor': ColorFieldAccess( + 'Text Color', + 'Color of the badge label.', + () => textColor, + (value) => textColor = value, + ), + 'labelStyle': TextStyleFieldAccess( + 'Text Style', + 'Style of the badge label.', + () => textStyle, + (value) => textStyle = value, + requiresLayout: true, + ), + 'alignment': EnumFieldAccess( + 'Alignment', + 'Alignment of the badge relative to its child.', + () => alignment, + (value) => alignment = value, + defaultValue: () => BadgeAlignment.topRight, + options: () => BadgeAlignment.values, + requiresLayout: true, + ), + 'offsetX': NumFieldAccess( + 'Offset X', + 'Horizontal offset from the aligned position.', + () => offsetX, + (value) => offsetX = value, + requiresLayout: true, + ), + 'offsetY': NumFieldAccess( + 'Offset Y', + 'Vertical offset from the aligned position.', + () => offsetY, + (value) => offsetY = value, + requiresLayout: true, + ), + 'isLabelVisible': BoolFieldAccess( + 'Show Badge', + 'Whether to show the badge.', + () => isLabelVisible, + (value) => isLabelVisible = value, + ), + 'dotSize': NumFieldAccess( + 'Dot Size', + 'Diameter of a dot badge.', + () => smallSize, + (value) => smallSize = value, + requiresLayout: true, + ), + 'largeSize': NumFieldAccess( + 'Large Size', + 'Height of a labeled badge.', + () => largeSize, + (value) => largeSize = value, + requiresLayout: true, + ), + 'padding': SpacingFieldAccess.padding( + 'Padding', + 'Padding around the label of label and count badges.', + () => padding, + (value) => padding = value, + requiresLayout: true, + ), + }; + + @override + List get props => [ + badgeType, + label, + maxCount, + backgroundColor, + textColor, + textStyle, + alignment, + offsetX, + offsetY, + isLabelVisible, + smallSize, + largeSize, + padding, + ]; + + /// Creates a [BadgeProperties] from a JSON data. + factory BadgeProperties.fromJson(Map json) => _$BadgePropertiesFromJson(json); + + @override + Map toJson() => + _$BadgePropertiesOmitDefaults(this, _$BadgePropertiesToJson(this)); +} diff --git a/lib/src/api/nodes/badge_node.g.dart b/lib/src/api/nodes/badge_node.g.dart new file mode 100644 index 0000000..7c7673a --- /dev/null +++ b/lib/src/api/nodes/badge_node.g.dart @@ -0,0 +1,257 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'badge_node.dart'; + +// ************************************************************************** +// JsonDefaultsGenerator +// ************************************************************************** + +Map _$BadgeNodeOmitDefaults( + BadgeNode instance, + Map json, +) { + 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.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 _$BadgePropertiesOmitDefaults( + BadgeProperties instance, + Map json, +) { + if (isJsonDefault(instance.badgeType, BadgeType.count)) + json.remove('badgeType'); + if (isJsonDefault(instance.label, '')) json.remove('label'); + if (isJsonDefault(instance.maxCount, 999)) json.remove('maxCount'); + if (isJsonDefault(instance.alignment, BadgeAlignment.topRight)) + json.remove('alignment'); + if (isJsonDefault(instance.offsetX, 4)) json.remove('offsetX'); + if (isJsonDefault(instance.offsetY, -4)) json.remove('offsetY'); + if (isJsonDefault(instance.isLabelVisible, true)) + json.remove('isLabelVisible'); + if (isJsonDefault(instance.smallSize, 6)) json.remove('dotSize'); + if (isJsonDefault(instance.largeSize, 16)) json.remove('largeSize'); + if (isJsonDefault( + instance.padding, + const EdgeInsetsModel.symmetric(horizontal: 4), + )) + json.remove('padding'); + return json; +} + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +BadgeNode _$BadgeNodeFromJson(Map json) => + BadgeNode( + 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, + alignment: json['alignment'] == null + ? AlignmentModel.none + : AlignmentModel.fromJson(json['alignment'] as Map), + padding: json['padding'] == null + ? EdgeInsetsModel.zero + : EdgeInsetsModel.fromJson(json['padding']), + margin: json['margin'] == null + ? EdgeInsetsModel.zero + : EdgeInsetsModel.fromJson(json['margin']), + rotationDegrees: json['rotation'] == null + ? 0 + : castRotation(json['rotation']), + reactions: + (json['reactions'] as List?) + ?.map((e) => Reaction.fromJson(e as Map)) + .toList() ?? + const [], + horizontalFit: + $enumDecodeNullable(_$SizeFitEnumMap, json['horizontalFit']) ?? + SizeFit.fixed, + verticalFit: + $enumDecodeNullable(_$SizeFitEnumMap, json['verticalFit']) ?? + SizeFit.fixed, + flex: (json['flex'] as num?)?.toInt() ?? 1, + constraints: json['constraints'] == null + ? const BoxConstraintsModel() + : BoxConstraintsModel.fromJson(json['constraints'] as Map), + aspectRatioLock: json['aspectRatioLock'] as bool? ?? false, + edgePins: json['edgePins'] == null + ? EdgePinsModel.standard + : EdgePinsModel.fromJson( + Map.from(json['edgePins'] as Map), + ), + positioningMode: + $enumDecodeNullable( + _$PositioningModeEnumMap, + json['positioningMode'], + ) ?? + PositioningMode.align, + properties: BadgeProperties.fromJson(json['properties'] as Map), + childID: json['childID'] as String?, + 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() + ..type = json['type'] as String; + +Map _$BadgeNodeToJson(BadgeNode 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, + 'enabled': instance.enabled, + 'type': instance.type, + 'properties': instance.properties.toJson(), + 'childID': ?instance.childID, +}; + +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', +}; + +BadgeProperties _$BadgePropertiesFromJson(Map json) => BadgeProperties( + badgeType: + $enumDecodeNullable(_$BadgeTypeEnumMap, json['badgeType']) ?? + BadgeType.count, + label: json['label'] as String? ?? '', + maxCount: (json['maxCount'] as num?)?.toInt() ?? 999, + backgroundColor: json['backgroundColor'] == null + ? null + : ColorRGBA.fromJson(json['backgroundColor']), + textColor: json['textColor'] == null + ? null + : ColorRGBA.fromJson(json['textColor']), + textStyle: json['labelStyle'] == null + ? null + : TextProp.fromJson(json['labelStyle'] as Map), + alignment: + $enumDecodeNullable(_$BadgeAlignmentEnumMap, json['alignment']) ?? + BadgeAlignment.topRight, + offsetX: (json['offsetX'] as num?)?.toDouble() ?? 4, + offsetY: (json['offsetY'] as num?)?.toDouble() ?? -4, + isLabelVisible: json['isLabelVisible'] as bool? ?? true, + smallSize: (json['dotSize'] as num?)?.toDouble() ?? 6, + largeSize: (json['largeSize'] as num?)?.toDouble() ?? 16, + padding: json['padding'] == null + ? const EdgeInsetsModel.symmetric(horizontal: 4) + : EdgeInsetsModel.fromJson(json['padding']), +); + +Map _$BadgePropertiesToJson(BadgeProperties instance) => + { + 'badgeType': _$BadgeTypeEnumMap[instance.badgeType]!, + 'label': instance.label, + 'maxCount': instance.maxCount, + 'backgroundColor': ?instance.backgroundColor?.toJson(), + 'textColor': ?instance.textColor?.toJson(), + 'labelStyle': instance.textStyle.toJson(), + 'alignment': _$BadgeAlignmentEnumMap[instance.alignment]!, + 'offsetX': instance.offsetX, + 'offsetY': instance.offsetY, + 'isLabelVisible': instance.isLabelVisible, + 'dotSize': instance.smallSize, + 'largeSize': instance.largeSize, + 'padding': instance.padding.toJson(), + }; + +const _$BadgeTypeEnumMap = { + BadgeType.dot: 'dot', + BadgeType.label: 'label', + BadgeType.count: 'count', +}; + +const _$BadgeAlignmentEnumMap = { + BadgeAlignment.topRight: 'topRight', + BadgeAlignment.topLeft: 'topLeft', + BadgeAlignment.bottomRight: 'bottomRight', + BadgeAlignment.bottomLeft: 'bottomLeft', +}; diff --git a/lib/src/api/nodes/chip_node.dart b/lib/src/api/nodes/chip_node.dart new file mode 100644 index 0000000..e3570bd --- /dev/null +++ b/lib/src/api/nodes/chip_node.dart @@ -0,0 +1,480 @@ +import 'package:json_annotation/json_annotation.dart'; + +import '../field_access.dart'; +import '../mixins.dart'; +import '../models/models.dart'; +import 'nodes.dart'; + +part 'chip_node.g.dart'; + +/// Default width of a chip node. +const double kChipDefaultWidth = 100; + +/// Default height of a chip node, the Material 3 chip height. +const double kChipDefaultHeight = 32; + +/// A Material 3 chip: a compact element that represents an input, attribute +/// or action. +/// Refer to [Chip](https://api.flutter.dev/flutter/material/Chip-class.html), +/// [ActionChip](https://api.flutter.dev/flutter/material/ActionChip-class.html), +/// [ChoiceChip](https://api.flutter.dev/flutter/material/ChoiceChip-class.html), +/// [FilterChip](https://api.flutter.dev/flutter/material/FilterChip-class.html) +/// and [InputChip](https://api.flutter.dev/flutter/material/InputChip-class.html) +/// in Flutter for more details. +@JsonSerializable() +class ChipNode extends SceneNode with CustomPropertiesMixin { + @override + final String type = 'chip'; + + @override + bool get supportsPadding => true; + + /// Holds configurable properties of this chip. + @override + covariant ChipProperties properties; + + /// Strictly used for previews. e.g in components panel. + ChipNode.empty() + : properties = ChipProperties(), + super( + id: 'chip', + name: 'Chip', + basicBoxLocal: NodeBox(0, 0, kChipDefaultWidth, kChipDefaultHeight), + ); + + /// Strictly used for previews. e.g in components panel. + ChipNode.fromProperties(this.properties) + : super( + id: 'chip', + name: 'Chip', + basicBoxLocal: NodeBox(0, 0, kChipDefaultWidth, kChipDefaultHeight), + ); + + /// Creates a [ChipNode] with the given data. + ChipNode({ + required super.id, + required super.name, + required super.basicBoxLocal, + super.outerBoxLocal, + super.retainedOuterBoxLocal, + super.visible, + super.enabled, + super.alignment, + super.padding, + super.margin, + super.rotationDegrees, + super.reactions, + super.horizontalFit, + super.verticalFit, + super.flex, + super.constraints, + super.aspectRatioLock, + super.edgePins, + super.parentID, + super.positioningMode, + // Custom properties. + required this.properties, + super.variables, + super.multipleVariables, + }); + + @override + FieldsMap generateFields() => { + ...super.generateFields(), + 'properties': ObjectFieldAccess( + 'Properties', + 'Properties of the chip.', + () => properties, + (value) => properties = value, + ), + }; + + /// [TriggerType.click] fires when an action, choice, filter or input chip + /// is pressed and when an input chip's delete icon is pressed. + /// [TriggerType.changed] fires with the new selection when a choice or + /// filter chip is selected or deselected. + @override + List get triggerTypes => [ + TriggerType.click, + TriggerType.changed, + ]; + + @override + late final List propertyVariables = [ + ...super.propertyVariables, + StringValue(name: 'label', value: properties.label), + if (properties.chipType.supportsSelection) + BoolValue(name: 'selected', value: properties.selected), + ]; + + @override + List get props => [ + ...super.props, + properties, + variables, + multipleVariables, + ]; + + /// Creates a [ChipNode] from a JSON data. + factory ChipNode.fromJson(Map json) => _$ChipNodeFromJson(json); + + @override + Map toJson() => _$ChipNodeOmitDefaults(this, _$ChipNodeToJson(this)); +} + +/// Material 3 chip types. +enum ChipType { + /// A plain [Chip] that displays information. + basic, + + /// An [ActionChip] that triggers an action when pressed. + action, + + /// A [ChoiceChip] for single selection. + choice, + + /// A [FilterChip] for multi-selection. + filter, + + /// An [InputChip] representing a piece of user input, with a delete icon. + input; + + /// Returns displayable string representation. + String get prettify => switch (this) { + ChipType.basic => 'Chip', + ChipType.action => 'Action Chip', + ChipType.choice => 'Choice Chip', + ChipType.filter => 'Filter Chip', + ChipType.input => 'Input Chip', + }; + + /// Whether this chip type has a selected state. + bool get supportsSelection => this == choice || this == filter; + + /// Whether this chip type has a delete icon. + bool get supportsDeletion => this == input; + + /// Whether this chip type reacts to presses. + bool get supportsPress => this == action || this == choice || this == filter; +} + +/// Holds configurable properties of a [ChipNode]. +@JsonSerializable() +class ChipProperties extends CustomProperties with FieldsHolder { + /// Type of chip. + ChipType chipType; + + /// Label text of the chip. + String label; + + /// Label text style. Its color defaults to the theme's when the style has + /// no fills. + TextProp labelStyle; + + /// Icon shown as the chip's avatar when [avatar] is not set. + MultiSourceIconModel? icon; + + /// URL of an image shown in a circle as the chip's avatar. + String? avatar; + + /// Delete icon of input chips. Defaults to Flutter's clear icon. + MultiSourceIconModel? deleteIcon; + + /// Color of the default delete icon. + ColorRGBA? deleteIconColor; + + /// Whether a choice or filter chip is selected. + bool selected; + + /// Whether a selected filter chip shows a checkmark. + bool showCheckmark; + + /// Color of the checkmark of a selected filter chip. + ColorRGBA? checkmarkColor; + + /// Background color of the chip. + ColorRGBA? backgroundColor; + + /// Background color of a selected choice or filter chip. + ColorRGBA? selectedColor; + + /// Border color. Defaults to the theme's `colorScheme.outlineVariant`. + ColorRGBA? borderColor; + + /// Border width. + double borderWidth; + + /// Corner radius. + double cornerRadius; + + /// Elevation. + double elevation; + + /// Shadow color. + ColorRGBA? shadowColor; + + /// Padding between the chip's contents and its border. + EdgeInsetsModel padding; + + /// Padding around the label. + EdgeInsetsModel labelPadding; + + /// Tooltip shown on long press or hover. + String? tooltip; + + /// Whether the chip is focused automatically. + bool autofocus; + + /// Creates a [ChipProperties] with the given data. + ChipProperties({ + this.chipType = ChipType.action, + this.label = 'Chip', + TextProp? labelStyle, + this.icon, + this.avatar, + this.deleteIcon, + this.deleteIconColor, + this.selected = false, + this.showCheckmark = true, + this.checkmarkColor, + this.backgroundColor, + this.selectedColor, + this.borderColor, + this.borderWidth = 1, + this.cornerRadius = 8, + this.elevation = 0, + this.shadowColor, + this.padding = const EdgeInsetsModel.all(8), + this.labelPadding = const EdgeInsetsModel.symmetric(horizontal: 8), + this.tooltip, + this.autofocus = false, + }) : labelStyle = + labelStyle ?? + TextProp.general(fontSize: 14, fills: List.empty(growable: true)); + + /// Duplicates this [ChipProperties] with the given data overrides. + ChipProperties copyWith({ + ChipType? chipType, + String? label, + TextProp? labelStyle, + MultiSourceIconModel? icon, + String? avatar, + MultiSourceIconModel? deleteIcon, + ColorRGBA? deleteIconColor, + bool? selected, + bool? showCheckmark, + ColorRGBA? checkmarkColor, + ColorRGBA? backgroundColor, + ColorRGBA? selectedColor, + ColorRGBA? borderColor, + double? borderWidth, + double? cornerRadius, + double? elevation, + ColorRGBA? shadowColor, + EdgeInsetsModel? padding, + EdgeInsetsModel? labelPadding, + String? tooltip, + bool? autofocus, + }) { + return ChipProperties( + chipType: chipType ?? this.chipType, + label: label ?? this.label, + labelStyle: labelStyle ?? this.labelStyle, + icon: icon ?? this.icon, + avatar: avatar ?? this.avatar, + deleteIcon: deleteIcon ?? this.deleteIcon, + deleteIconColor: deleteIconColor ?? this.deleteIconColor, + selected: selected ?? this.selected, + showCheckmark: showCheckmark ?? this.showCheckmark, + checkmarkColor: checkmarkColor ?? this.checkmarkColor, + backgroundColor: backgroundColor ?? this.backgroundColor, + selectedColor: selectedColor ?? this.selectedColor, + borderColor: borderColor ?? this.borderColor, + borderWidth: borderWidth ?? this.borderWidth, + cornerRadius: cornerRadius ?? this.cornerRadius, + elevation: elevation ?? this.elevation, + shadowColor: shadowColor ?? this.shadowColor, + padding: padding ?? this.padding, + labelPadding: labelPadding ?? this.labelPadding, + tooltip: tooltip ?? this.tooltip, + autofocus: autofocus ?? this.autofocus, + ); + } + + @override + FieldsMap generateFields() => { + ...super.generateFields(), + 'chipType': EnumFieldAccess( + 'Chip Type', + 'Type of the chip.', + () => chipType, + (value) => chipType = value, + defaultValue: () => ChipType.action, + options: () => ChipType.values, + requiresLayout: true, + ), + 'label': StringFieldAccess( + 'Label', + 'Text label of the chip.', + () => label, + (value) => label = value, + requiresLayout: true, + ), + 'labelStyle': TextStyleFieldAccess( + 'Label Style', + 'Style of the chip label.', + () => labelStyle, + (value) => labelStyle = value, + requiresLayout: true, + ), + 'icon': IconFieldAccess( + 'Icon', + 'Leading icon of the chip, shown when no avatar image is set.', + () => icon, + (value) => icon = value, + requiresLayout: true, + required: false, + ), + 'avatar': StringFieldAccess( + 'Avatar', + 'URL of a leading avatar image.', + () => avatar ?? '', + (value) => avatar = value.isEmpty ? null : value, + requiresLayout: true, + ), + 'deleteIcon': IconFieldAccess( + 'Delete Icon', + 'Delete icon of input chips.', + () => deleteIcon, + (value) => deleteIcon = value, + requiresLayout: true, + required: false, + ), + 'deleteIconColor': ColorFieldAccess( + 'Delete Icon Color', + 'Color of the delete icon of input chips.', + () => deleteIconColor, + (value) => deleteIconColor = value, + ), + 'selected': BoolFieldAccess( + 'Selected', + 'Whether a choice or filter chip is selected.', + () => selected, + (value) => selected = value, + ), + 'showCheckmark': BoolFieldAccess( + 'Show Checkmark', + 'Whether a selected filter chip shows a checkmark.', + () => showCheckmark, + (value) => showCheckmark = value, + requiresLayout: true, + ), + 'checkmarkColor': ColorFieldAccess( + 'Checkmark Color', + 'Color of the checkmark of a selected filter chip.', + () => checkmarkColor, + (value) => checkmarkColor = value, + ), + 'backgroundColor': ColorFieldAccess( + 'Background Color', + 'Background color of the chip.', + () => backgroundColor, + (value) => backgroundColor = value, + ), + 'selectedColor': ColorFieldAccess( + 'Selected Color', + 'Background color of a selected choice or filter chip.', + () => selectedColor, + (value) => selectedColor = value, + ), + 'borderColor': ColorFieldAccess( + 'Border Color', + 'Border color of the chip.', + () => borderColor, + (value) => borderColor = value, + ), + 'borderWidth': NumFieldAccess( + 'Border Width', + 'Width of the chip border.', + () => borderWidth, + (value) => borderWidth = value, + requiresLayout: true, + ), + 'cornerRadius': NumFieldAccess( + 'Corner Radius', + 'Corner radius of the chip.', + () => cornerRadius, + (value) => cornerRadius = value, + ), + 'elevation': NumFieldAccess( + 'Elevation', + 'Elevation of the chip.', + () => elevation, + (value) => elevation = value, + ), + 'shadowColor': ColorFieldAccess( + 'Shadow Color', + 'Shadow color of the chip.', + () => shadowColor, + (value) => shadowColor = value, + ), + 'padding': SpacingFieldAccess.padding( + 'Padding', + "Padding between the chip's contents and its border.", + () => padding, + (value) => padding = value, + requiresLayout: true, + ), + 'labelPadding': SpacingFieldAccess.padding( + 'Label Padding', + 'Padding around the label.', + () => labelPadding, + (value) => labelPadding = value, + requiresLayout: true, + ), + 'tooltip': StringFieldAccess( + 'Tooltip', + 'Tooltip shown on long press or hover.', + () => tooltip ?? '', + (value) => tooltip = value.isEmpty ? null : value, + ), + 'autofocus': BoolFieldAccess( + 'Autofocus', + 'Whether the chip is focused automatically.', + () => autofocus, + (value) => autofocus = value, + ), + }; + + @override + List get props => [ + chipType, + label, + labelStyle, + icon, + avatar, + deleteIcon, + deleteIconColor, + selected, + showCheckmark, + checkmarkColor, + backgroundColor, + selectedColor, + borderColor, + borderWidth, + cornerRadius, + elevation, + shadowColor, + padding, + labelPadding, + tooltip, + autofocus, + ]; + + /// Creates a [ChipProperties] from a JSON data. + factory ChipProperties.fromJson(Map json) => _$ChipPropertiesFromJson(json); + + @override + Map toJson() => + _$ChipPropertiesOmitDefaults(this, _$ChipPropertiesToJson(this)); +} diff --git a/lib/src/api/nodes/chip_node.g.dart b/lib/src/api/nodes/chip_node.g.dart new file mode 100644 index 0000000..15c6065 --- /dev/null +++ b/lib/src/api/nodes/chip_node.g.dart @@ -0,0 +1,277 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'chip_node.dart'; + +// ************************************************************************** +// JsonDefaultsGenerator +// ************************************************************************** + +Map _$ChipNodeOmitDefaults( + ChipNode instance, + Map json, +) { + 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.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 _$ChipPropertiesOmitDefaults( + ChipProperties instance, + Map json, +) { + if (isJsonDefault(instance.chipType, ChipType.action)) + json.remove('chipType'); + if (isJsonDefault(instance.label, 'Chip')) json.remove('label'); + if (isJsonDefault(instance.selected, false)) json.remove('selected'); + if (isJsonDefault(instance.showCheckmark, true)) json.remove('showCheckmark'); + if (isJsonDefault(instance.borderWidth, 1)) json.remove('borderWidth'); + if (isJsonDefault(instance.cornerRadius, 8)) json.remove('cornerRadius'); + if (isJsonDefault(instance.elevation, 0)) json.remove('elevation'); + if (isJsonDefault(instance.padding, const EdgeInsetsModel.all(8))) + json.remove('padding'); + if (isJsonDefault( + instance.labelPadding, + const EdgeInsetsModel.symmetric(horizontal: 8), + )) + json.remove('labelPadding'); + if (isJsonDefault(instance.autofocus, false)) json.remove('autofocus'); + return json; +} + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +ChipNode _$ChipNodeFromJson(Map json) => + ChipNode( + 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, + alignment: json['alignment'] == null + ? AlignmentModel.none + : AlignmentModel.fromJson(json['alignment'] as Map), + padding: json['padding'] == null + ? EdgeInsetsModel.zero + : EdgeInsetsModel.fromJson(json['padding']), + margin: json['margin'] == null + ? EdgeInsetsModel.zero + : EdgeInsetsModel.fromJson(json['margin']), + rotationDegrees: json['rotation'] == null + ? 0 + : castRotation(json['rotation']), + reactions: + (json['reactions'] as List?) + ?.map((e) => Reaction.fromJson(e as Map)) + .toList() ?? + const [], + horizontalFit: + $enumDecodeNullable(_$SizeFitEnumMap, json['horizontalFit']) ?? + SizeFit.fixed, + verticalFit: + $enumDecodeNullable(_$SizeFitEnumMap, json['verticalFit']) ?? + SizeFit.fixed, + flex: (json['flex'] as num?)?.toInt() ?? 1, + constraints: json['constraints'] == null + ? const BoxConstraintsModel() + : BoxConstraintsModel.fromJson(json['constraints'] as Map), + aspectRatioLock: json['aspectRatioLock'] as bool? ?? false, + edgePins: json['edgePins'] == null + ? EdgePinsModel.standard + : EdgePinsModel.fromJson( + Map.from(json['edgePins'] as Map), + ), + positioningMode: + $enumDecodeNullable( + _$PositioningModeEnumMap, + json['positioningMode'], + ) ?? + PositioningMode.align, + properties: ChipProperties.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() + ..type = json['type'] as String; + +Map _$ChipNodeToJson(ChipNode 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, + 'enabled': instance.enabled, + 'type': instance.type, + 'properties': instance.properties.toJson(), +}; + +const _$SizeFitEnumMap = { + SizeFit.locked: 'locked', + SizeFit.fixed: 'fixed', + SizeFit.expanded: 'expanded', + SizeFit.flexible: 'flexible', + SizeFit.shrinkWrap: 'shrinkWrap', +}; + +const _$PositioningModeEnumMap = { + PositioningMode.align: 'align', + PositioningMode.pin: 'pin', +}; + +const _$ComponentMarkerTypeEnumMap = { + ComponentMarkerType.component: 'component', + ComponentMarkerType.instance: 'instance', +}; + +ChipProperties _$ChipPropertiesFromJson(Map json) => ChipProperties( + chipType: + $enumDecodeNullable(_$ChipTypeEnumMap, json['chipType']) ?? + ChipType.action, + label: json['label'] as String? ?? 'Chip', + labelStyle: json['labelStyle'] == null + ? null + : TextProp.fromJson(json['labelStyle'] as Map), + icon: json['icon'] == null + ? null + : MultiSourceIconModel.fromJson(json['icon'] as Map), + avatar: json['avatar'] as String?, + deleteIcon: json['deleteIcon'] == null + ? null + : MultiSourceIconModel.fromJson(json['deleteIcon'] as Map), + deleteIconColor: json['deleteIconColor'] == null + ? null + : ColorRGBA.fromJson(json['deleteIconColor']), + selected: json['selected'] as bool? ?? false, + showCheckmark: json['showCheckmark'] as bool? ?? true, + checkmarkColor: json['checkmarkColor'] == null + ? null + : ColorRGBA.fromJson(json['checkmarkColor']), + backgroundColor: json['backgroundColor'] == null + ? null + : ColorRGBA.fromJson(json['backgroundColor']), + selectedColor: json['selectedColor'] == null + ? null + : ColorRGBA.fromJson(json['selectedColor']), + borderColor: json['borderColor'] == null + ? null + : ColorRGBA.fromJson(json['borderColor']), + borderWidth: (json['borderWidth'] as num?)?.toDouble() ?? 1, + cornerRadius: (json['cornerRadius'] as num?)?.toDouble() ?? 8, + elevation: (json['elevation'] as num?)?.toDouble() ?? 0, + shadowColor: json['shadowColor'] == null + ? null + : ColorRGBA.fromJson(json['shadowColor']), + padding: json['padding'] == null + ? const EdgeInsetsModel.all(8) + : EdgeInsetsModel.fromJson(json['padding']), + labelPadding: json['labelPadding'] == null + ? const EdgeInsetsModel.symmetric(horizontal: 8) + : EdgeInsetsModel.fromJson(json['labelPadding']), + tooltip: json['tooltip'] as String?, + autofocus: json['autofocus'] as bool? ?? false, +); + +Map _$ChipPropertiesToJson(ChipProperties instance) => + { + 'chipType': _$ChipTypeEnumMap[instance.chipType]!, + 'label': instance.label, + 'labelStyle': instance.labelStyle.toJson(), + 'icon': ?instance.icon?.toJson(), + 'avatar': ?instance.avatar, + 'deleteIcon': ?instance.deleteIcon?.toJson(), + 'deleteIconColor': ?instance.deleteIconColor?.toJson(), + 'selected': instance.selected, + 'showCheckmark': instance.showCheckmark, + 'checkmarkColor': ?instance.checkmarkColor?.toJson(), + 'backgroundColor': ?instance.backgroundColor?.toJson(), + 'selectedColor': ?instance.selectedColor?.toJson(), + 'borderColor': ?instance.borderColor?.toJson(), + 'borderWidth': instance.borderWidth, + 'cornerRadius': instance.cornerRadius, + 'elevation': instance.elevation, + 'shadowColor': ?instance.shadowColor?.toJson(), + 'padding': instance.padding.toJson(), + 'labelPadding': instance.labelPadding.toJson(), + 'tooltip': ?instance.tooltip, + 'autofocus': instance.autofocus, + }; + +const _$ChipTypeEnumMap = { + ChipType.basic: 'basic', + ChipType.action: 'action', + ChipType.choice: 'choice', + ChipType.filter: 'filter', + ChipType.input: 'input', +}; diff --git a/lib/src/api/nodes/nodes.dart b/lib/src/api/nodes/nodes.dart index 7a38aef..440e5ad 100644 --- a/lib/src/api/nodes/nodes.dart +++ b/lib/src/api/nodes/nodes.dart @@ -1,10 +1,12 @@ export 'accordion_node.dart'; export 'app_bar_node.dart'; export 'auto_placeholder_node.dart'; +export 'badge_node.dart'; export 'base_node.dart'; export 'button_node.dart'; export 'canvas_node.dart'; export 'checkbox_node.dart'; +export 'chip_node.dart'; export 'divider_node.dart'; export 'dropdown_node.dart'; export 'embedded_video_node.dart'; @@ -27,6 +29,7 @@ export 'rectangle_node.dart'; export 'root_node.dart'; export 'row_column_node.dart'; export 'scene_node.dart'; +export 'segmented_button_node.dart'; export 'single_placeholder_node.dart'; export 'slider_node.dart'; export 'spacer_node.dart'; diff --git a/lib/src/api/nodes/segmented_button_node.dart b/lib/src/api/nodes/segmented_button_node.dart new file mode 100644 index 0000000..6a0650c --- /dev/null +++ b/lib/src/api/nodes/segmented_button_node.dart @@ -0,0 +1,429 @@ +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 'segmented_button_node.g.dart'; + +/// Default width of a segmented button node. +const double kSegmentedButtonDefaultWidth = 300; + +/// Default height of a segmented button node, the Material 3 height. +const double kSegmentedButtonDefaultHeight = 40; + +/// A Material 3 segmented button for single or multiple selection among a +/// few options. +/// Refer to [SegmentedButton](https://api.flutter.dev/flutter/material/SegmentedButton-class.html) +/// in Flutter for more details. +@JsonSerializable() +class SegmentedButtonNode extends SceneNode with CustomPropertiesMixin { + @override + final String type = 'segmentedButton'; + + @override + bool get supportsPadding => true; + + /// Holds configurable properties of this segmented button. + @override + covariant SegmentedButtonProperties properties; + + /// Strictly used for previews. e.g in components panel. + SegmentedButtonNode.empty() + : properties = SegmentedButtonProperties(selected: const ['option1']), + super( + id: 'segmentedButton', + name: 'Segmented Button', + basicBoxLocal: NodeBox( + 0, + 0, + kSegmentedButtonDefaultWidth, + kSegmentedButtonDefaultHeight, + ), + ); + + /// Strictly used for previews. e.g in components panel. + SegmentedButtonNode.fromProperties(this.properties) + : super( + id: 'segmentedButton', + name: 'Segmented Button', + basicBoxLocal: NodeBox( + 0, + 0, + kSegmentedButtonDefaultWidth, + kSegmentedButtonDefaultHeight, + ), + ); + + /// Creates a [SegmentedButtonNode] with the given data. + SegmentedButtonNode({ + required super.id, + required super.name, + required super.basicBoxLocal, + super.outerBoxLocal, + super.retainedOuterBoxLocal, + super.visible, + super.enabled, + super.alignment, + super.padding, + super.margin, + super.rotationDegrees, + super.reactions, + super.horizontalFit, + super.verticalFit, + super.flex, + super.constraints, + super.aspectRatioLock, + super.edgePins, + super.parentID, + super.positioningMode, + // Custom properties. + required this.properties, + super.variables, + super.multipleVariables, + }); + + @override + FieldsMap generateFields() => { + ...super.generateFields(), + 'properties': ObjectFieldAccess( + 'Properties', + 'Properties of the segmented button.', + () => properties, + (value) => properties = value, + ), + }; + + /// [TriggerType.changed] fires with the list of selected segment values + /// whenever the selection changes. + @override + List get triggerTypes => [TriggerType.changed]; + + @override + late final List propertyVariables = [ + ...super.propertyVariables, + ObjectValue(name: 'selected', value: properties.selected), + ]; + + @override + List get props => [ + ...super.props, + properties, + variables, + multipleVariables, + ]; + + /// Creates a [SegmentedButtonNode] from a JSON data. + factory SegmentedButtonNode.fromJson(Map json) => + _$SegmentedButtonNodeFromJson(json); + + @override + Map toJson() => _$SegmentedButtonNodeOmitDefaults( + this, + _$SegmentedButtonNodeToJson(this), + ); +} + +/// A single segment of a [SegmentedButtonNode], like Flutter's +/// [ButtonSegment](https://api.flutter.dev/flutter/material/ButtonSegment-class.html). +@JsonSerializable() +class ButtonSegmentModel with Equatable, SerializableMixin { + /// Unique value of this segment, used in + /// [SegmentedButtonProperties.selected]. + final String value; + + /// Label text of this segment. + final String label; + + /// Icon of this segment. + final MultiSourceIconModel? icon; + + /// Tooltip of this segment. + final String? tooltip; + + /// Whether this segment can be selected. + final bool enabled; + + /// Creates a [ButtonSegmentModel] with the given data. + const ButtonSegmentModel({ + required this.value, + this.label = '', + this.icon, + this.tooltip, + this.enabled = true, + }); + + /// Duplicates this [ButtonSegmentModel] with the given data overrides. + ButtonSegmentModel copyWith({ + String? value, + String? label, + MultiSourceIconModel? icon, + String? tooltip, + bool? enabled, + }) { + return ButtonSegmentModel( + value: value ?? this.value, + label: label ?? this.label, + icon: icon ?? this.icon, + tooltip: tooltip ?? this.tooltip, + enabled: enabled ?? this.enabled, + ); + } + + /// Creates a [ButtonSegmentModel] from a JSON data. + factory ButtonSegmentModel.fromJson(Map json) => + _$ButtonSegmentModelFromJson(json); + + @override + Map toJson() => + _$ButtonSegmentModelOmitDefaults(this, _$ButtonSegmentModelToJson(this)); + + @override + List get props => [value, label, icon, tooltip, enabled]; +} + +/// The default segments of a new segmented button. +const List kDefaultButtonSegments = [ + ButtonSegmentModel(value: 'option1', label: 'Option 1'), + ButtonSegmentModel(value: 'option2', label: 'Option 2'), + ButtonSegmentModel(value: 'option3', label: 'Option 3'), +]; + +/// Holds configurable properties of a [SegmentedButtonNode]. +@JsonSerializable() +class SegmentedButtonProperties extends CustomProperties with FieldsHolder { + /// Segments of the button. + List segments; + + /// Values of the selected segments. + @JsonKey(name: 'selectedSegments') + List selected; + + /// Whether more than one segment can be selected. + bool multiSelectionEnabled; + + /// Whether no segment may be selected. + bool emptySelectionAllowed; + + /// Whether selected segments show [selectedIcon]. + bool showSelectedIcon; + + /// Icon shown on selected segments. Defaults to Flutter's check icon. + MultiSourceIconModel? selectedIcon; + + /// Whether the segments are laid out in a row or a column. + AxisC direction; + + /// Border color. Defaults to the theme's `colorScheme.outline`. + ColorRGBA? borderColor; + + /// Border width. + double borderWidth; + + /// Background color of selected segments. Defaults to the theme's + /// `colorScheme.secondaryContainer`. + @JsonKey(name: 'selectedColor') + ColorRGBA? selectedBackgroundColor; + + /// Background color of unselected segments. Transparent by default. + @JsonKey(name: 'unselectedColor') + ColorRGBA? backgroundColor; + + /// Label and icon color of selected segments. + ColorRGBA? selectedForegroundColor; + + /// Label and icon color of unselected segments. + ColorRGBA? foregroundColor; + + /// Corner radius of the outer corners. Material 3 segmented buttons are + /// stadium-shaped, which a radius of half the default height matches. + CornerRadius cornerRadius; + + /// Creates a [SegmentedButtonProperties] with the given data. + SegmentedButtonProperties({ + this.segments = kDefaultButtonSegments, + this.selected = const [], + this.multiSelectionEnabled = false, + this.emptySelectionAllowed = false, + this.showSelectedIcon = true, + this.selectedIcon, + this.direction = AxisC.horizontal, + this.borderColor, + this.borderWidth = 1, + this.selectedBackgroundColor, + this.backgroundColor, + this.selectedForegroundColor, + this.foregroundColor, + this.cornerRadius = const CornerRadius.all( + RadiusModel.circular(kSegmentedButtonDefaultHeight / 2), + ), + }); + + /// Duplicates this [SegmentedButtonProperties] with the given data + /// overrides. + SegmentedButtonProperties copyWith({ + List? segments, + List? selected, + bool? multiSelectionEnabled, + bool? emptySelectionAllowed, + bool? showSelectedIcon, + MultiSourceIconModel? selectedIcon, + AxisC? direction, + ColorRGBA? borderColor, + double? borderWidth, + ColorRGBA? selectedBackgroundColor, + ColorRGBA? backgroundColor, + ColorRGBA? selectedForegroundColor, + ColorRGBA? foregroundColor, + CornerRadius? cornerRadius, + }) { + return SegmentedButtonProperties( + segments: segments ?? this.segments, + selected: selected ?? this.selected, + multiSelectionEnabled: + multiSelectionEnabled ?? this.multiSelectionEnabled, + emptySelectionAllowed: + emptySelectionAllowed ?? this.emptySelectionAllowed, + showSelectedIcon: showSelectedIcon ?? this.showSelectedIcon, + selectedIcon: selectedIcon ?? this.selectedIcon, + direction: direction ?? this.direction, + borderColor: borderColor ?? this.borderColor, + borderWidth: borderWidth ?? this.borderWidth, + selectedBackgroundColor: + selectedBackgroundColor ?? this.selectedBackgroundColor, + backgroundColor: backgroundColor ?? this.backgroundColor, + selectedForegroundColor: + selectedForegroundColor ?? this.selectedForegroundColor, + foregroundColor: foregroundColor ?? this.foregroundColor, + cornerRadius: cornerRadius ?? this.cornerRadius, + ); + } + + @override + FieldsMap generateFields() => { + ...super.generateFields(), + 'segments': IterableFieldAccess( + 'Segments', + 'Segments of the button.', + () => segments, + (value) => segments = value, + (item) => item.label.isEmpty ? item.value : item.label, + requiresLayout: true, + ), + 'selectedSegments': IterableFieldAccess( + 'Selected Segments', + 'Values of the selected segments.', + () => selected, + (value) => selected = value, + (item) => item, + ), + 'multiSelectionEnabled': BoolFieldAccess( + 'Multi Selection', + 'Whether more than one segment can be selected.', + () => multiSelectionEnabled, + (value) => multiSelectionEnabled = value, + ), + 'emptySelectionAllowed': BoolFieldAccess( + 'Allow Empty Selection', + 'Whether no segment may be selected.', + () => emptySelectionAllowed, + (value) => emptySelectionAllowed = value, + ), + 'showSelectedIcon': BoolFieldAccess( + 'Show Selected Icon', + 'Whether selected segments show the selected icon.', + () => showSelectedIcon, + (value) => showSelectedIcon = value, + requiresLayout: true, + ), + 'selectedIcon': IconFieldAccess( + 'Selected Icon', + 'Icon shown on selected segments.', + () => selectedIcon, + (value) => selectedIcon = value, + requiresLayout: true, + required: false, + ), + 'direction': EnumFieldAccess( + 'Direction', + 'Whether the segments are laid out in a row or a column.', + () => direction, + (value) => direction = value, + defaultValue: () => AxisC.horizontal, + options: () => AxisC.values, + requiresLayout: true, + ), + 'borderColor': ColorFieldAccess( + 'Border Color', + 'Color of the segment borders.', + () => borderColor, + (value) => borderColor = value, + ), + 'borderWidth': NumFieldAccess( + 'Border Width', + 'Width of the segment borders.', + () => borderWidth, + (value) => borderWidth = value, + ), + 'selectedColor': ColorFieldAccess( + 'Selected Color', + 'Background color of selected segments.', + () => selectedBackgroundColor, + (value) => selectedBackgroundColor = value, + ), + 'unselectedColor': ColorFieldAccess( + 'Unselected Color', + 'Background color of unselected segments.', + () => backgroundColor, + (value) => backgroundColor = value, + ), + 'selectedForegroundColor': ColorFieldAccess( + 'Selected Foreground Color', + 'Label and icon color of selected segments.', + () => selectedForegroundColor, + (value) => selectedForegroundColor = value, + ), + 'foregroundColor': ColorFieldAccess( + 'Foreground Color', + 'Label and icon color of unselected segments.', + () => foregroundColor, + (value) => foregroundColor = value, + ), + 'cornerRadius': RadiusFieldAccess( + 'Corner Radius', + 'Corner radius of the outer corners.', + () => cornerRadius, + (value) => cornerRadius = value, + ), + }; + + @override + List get props => [ + segments, + selected, + multiSelectionEnabled, + emptySelectionAllowed, + showSelectedIcon, + selectedIcon, + direction, + borderColor, + borderWidth, + selectedBackgroundColor, + backgroundColor, + selectedForegroundColor, + foregroundColor, + cornerRadius, + ]; + + /// Creates a [SegmentedButtonProperties] from a JSON data. + factory SegmentedButtonProperties.fromJson(Map json) => + _$SegmentedButtonPropertiesFromJson(json); + + @override + Map toJson() => _$SegmentedButtonPropertiesOmitDefaults( + this, + _$SegmentedButtonPropertiesToJson(this), + ); +} diff --git a/lib/src/api/nodes/segmented_button_node.g.dart b/lib/src/api/nodes/segmented_button_node.g.dart new file mode 100644 index 0000000..76c346d --- /dev/null +++ b/lib/src/api/nodes/segmented_button_node.g.dart @@ -0,0 +1,300 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'segmented_button_node.dart'; + +// ************************************************************************** +// JsonDefaultsGenerator +// ************************************************************************** + +Map _$SegmentedButtonNodeOmitDefaults( + SegmentedButtonNode instance, + Map json, +) { + 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.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 _$ButtonSegmentModelOmitDefaults( + ButtonSegmentModel instance, + Map json, +) { + if (isJsonDefault(instance.label, '')) json.remove('label'); + if (isJsonDefault(instance.enabled, true)) json.remove('enabled'); + return json; +} + +Map _$SegmentedButtonPropertiesOmitDefaults( + SegmentedButtonProperties instance, + Map json, +) { + if (isJsonDefault(instance.segments, kDefaultButtonSegments)) + json.remove('segments'); + if (isJsonDefault(instance.selected, const [])) + json.remove('selectedSegments'); + if (isJsonDefault(instance.multiSelectionEnabled, false)) + json.remove('multiSelectionEnabled'); + if (isJsonDefault(instance.emptySelectionAllowed, false)) + json.remove('emptySelectionAllowed'); + if (isJsonDefault(instance.showSelectedIcon, true)) + json.remove('showSelectedIcon'); + if (isJsonDefault(instance.direction, AxisC.horizontal)) + json.remove('direction'); + if (isJsonDefault(instance.borderWidth, 1)) json.remove('borderWidth'); + if (isJsonDefault( + instance.cornerRadius, + const CornerRadius.all( + RadiusModel.circular(kSegmentedButtonDefaultHeight / 2), + ), + )) + json.remove('cornerRadius'); + return json; +} + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +SegmentedButtonNode _$SegmentedButtonNodeFromJson(Map json) => + SegmentedButtonNode( + 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, + alignment: json['alignment'] == null + ? AlignmentModel.none + : AlignmentModel.fromJson(json['alignment'] as Map), + padding: json['padding'] == null + ? EdgeInsetsModel.zero + : EdgeInsetsModel.fromJson(json['padding']), + margin: json['margin'] == null + ? EdgeInsetsModel.zero + : EdgeInsetsModel.fromJson(json['margin']), + rotationDegrees: json['rotation'] == null + ? 0 + : castRotation(json['rotation']), + reactions: + (json['reactions'] as List?) + ?.map((e) => Reaction.fromJson(e as Map)) + .toList() ?? + const [], + horizontalFit: + $enumDecodeNullable(_$SizeFitEnumMap, json['horizontalFit']) ?? + SizeFit.fixed, + verticalFit: + $enumDecodeNullable(_$SizeFitEnumMap, json['verticalFit']) ?? + SizeFit.fixed, + flex: (json['flex'] as num?)?.toInt() ?? 1, + constraints: json['constraints'] == null + ? const BoxConstraintsModel() + : BoxConstraintsModel.fromJson(json['constraints'] as Map), + aspectRatioLock: json['aspectRatioLock'] as bool? ?? false, + edgePins: json['edgePins'] == null + ? EdgePinsModel.standard + : EdgePinsModel.fromJson( + Map.from(json['edgePins'] as Map), + ), + positioningMode: + $enumDecodeNullable( + _$PositioningModeEnumMap, + json['positioningMode'], + ) ?? + PositioningMode.align, + properties: SegmentedButtonProperties.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() + ..type = json['type'] as String; + +Map _$SegmentedButtonNodeToJson( + SegmentedButtonNode 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, + 'enabled': instance.enabled, + 'type': instance.type, + 'properties': instance.properties.toJson(), +}; + +const _$SizeFitEnumMap = { + SizeFit.locked: 'locked', + SizeFit.fixed: 'fixed', + SizeFit.expanded: 'expanded', + SizeFit.flexible: 'flexible', + SizeFit.shrinkWrap: 'shrinkWrap', +}; + +const _$PositioningModeEnumMap = { + PositioningMode.align: 'align', + PositioningMode.pin: 'pin', +}; + +const _$ComponentMarkerTypeEnumMap = { + ComponentMarkerType.component: 'component', + ComponentMarkerType.instance: 'instance', +}; + +ButtonSegmentModel _$ButtonSegmentModelFromJson(Map json) => ButtonSegmentModel( + value: json['value'] as String, + label: json['label'] as String? ?? '', + icon: json['icon'] == null + ? null + : MultiSourceIconModel.fromJson(json['icon'] as Map), + tooltip: json['tooltip'] as String?, + enabled: json['enabled'] as bool? ?? true, +); + +Map _$ButtonSegmentModelToJson(ButtonSegmentModel instance) => + { + 'value': instance.value, + 'label': instance.label, + 'icon': ?instance.icon?.toJson(), + 'tooltip': ?instance.tooltip, + 'enabled': instance.enabled, + }; + +SegmentedButtonProperties _$SegmentedButtonPropertiesFromJson(Map json) => + SegmentedButtonProperties( + segments: + (json['segments'] as List?) + ?.map((e) => ButtonSegmentModel.fromJson(e as Map)) + .toList() ?? + kDefaultButtonSegments, + selected: + (json['selectedSegments'] as List?) + ?.map((e) => e as String) + .toList() ?? + const [], + multiSelectionEnabled: json['multiSelectionEnabled'] as bool? ?? false, + emptySelectionAllowed: json['emptySelectionAllowed'] as bool? ?? false, + showSelectedIcon: json['showSelectedIcon'] as bool? ?? true, + selectedIcon: json['selectedIcon'] == null + ? null + : MultiSourceIconModel.fromJson(json['selectedIcon'] as Map), + direction: + $enumDecodeNullable(_$AxisCEnumMap, json['direction']) ?? + AxisC.horizontal, + borderColor: json['borderColor'] == null + ? null + : ColorRGBA.fromJson(json['borderColor']), + borderWidth: (json['borderWidth'] as num?)?.toDouble() ?? 1, + selectedBackgroundColor: json['selectedColor'] == null + ? null + : ColorRGBA.fromJson(json['selectedColor']), + backgroundColor: json['unselectedColor'] == null + ? null + : ColorRGBA.fromJson(json['unselectedColor']), + selectedForegroundColor: json['selectedForegroundColor'] == null + ? null + : ColorRGBA.fromJson(json['selectedForegroundColor']), + foregroundColor: json['foregroundColor'] == null + ? null + : ColorRGBA.fromJson(json['foregroundColor']), + cornerRadius: json['cornerRadius'] == null + ? const CornerRadius.all( + RadiusModel.circular(kSegmentedButtonDefaultHeight / 2), + ) + : CornerRadius.fromJson(json['cornerRadius']), + ); + +Map _$SegmentedButtonPropertiesToJson( + SegmentedButtonProperties instance, +) => { + 'segments': instance.segments.map((e) => e.toJson()).toList(), + 'selectedSegments': instance.selected, + 'multiSelectionEnabled': instance.multiSelectionEnabled, + 'emptySelectionAllowed': instance.emptySelectionAllowed, + 'showSelectedIcon': instance.showSelectedIcon, + 'selectedIcon': ?instance.selectedIcon?.toJson(), + 'direction': _$AxisCEnumMap[instance.direction]!, + 'borderColor': ?instance.borderColor?.toJson(), + 'borderWidth': instance.borderWidth, + 'selectedColor': ?instance.selectedBackgroundColor?.toJson(), + 'unselectedColor': ?instance.backgroundColor?.toJson(), + 'selectedForegroundColor': ?instance.selectedForegroundColor?.toJson(), + 'foregroundColor': ?instance.foregroundColor?.toJson(), + 'cornerRadius': instance.cornerRadius.toJson(), +}; + +const _$AxisCEnumMap = { + AxisC.horizontal: 'horizontal', + AxisC.vertical: 'vertical', +}; diff --git a/pubspec.yaml b/pubspec.yaml index 5a1ee64..b6ddaeb 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: codelessly_api description: Flutter's layouts and widgets represented as models and JSON data. This API is used by the Codelessly Editor and CloudUI SDK to render Flutter Widgets. -version: 5.0.0 +version: 5.1.0 homepage: https://codelessly.com/ repository: https://github.com/Codelessly/CodelesslyAPI diff --git a/test/fixtures/badge_01_basic.json b/test/fixtures/badge_01_basic.json new file mode 100644 index 0000000..d3aaf8d --- /dev/null +++ b/test/fixtures/badge_01_basic.json @@ -0,0 +1,17 @@ +{ + "id": "badge-basic-1", + "name": "Basic Badge", + "type": "badge", + "visible": true, + "basicBoxLocal": { + "x": 0.0, + "y": 0.0, + "width": 24.0, + "height": 24.0 + }, + "properties": { + "label": "5", + "isLabelVisible": true + }, + "reactions": [] +} diff --git a/test/fixtures/badge_02_with_child.json b/test/fixtures/badge_02_with_child.json new file mode 100644 index 0000000..4816062 --- /dev/null +++ b/test/fixtures/badge_02_with_child.json @@ -0,0 +1,30 @@ +{ + "id": "badge-icon-1", + "name": "Badge on Icon", + "type": "badge", + "visible": true, + "basicBoxLocal": { + "x": 0.0, + "y": 0.0, + "width": 32.0, + "height": 32.0 + }, + "properties": { + "label": "99+", + "isLabelVisible": true, + "backgroundColor": { + "r": 1.0, + "g": 0.0, + "b": 0.0, + "a": 1.0 + }, + "textColor": { + "r": 1.0, + "g": 1.0, + "b": 1.0, + "a": 1.0 + } + }, + "childID": "icon-1", + "reactions": [] +} diff --git a/test/fixtures/node_json_branch.json b/test/fixtures/node_json_branch.json new file mode 100644 index 0000000..ef6da46 --- /dev/null +++ b/test/fixtures/node_json_branch.json @@ -0,0 +1,173 @@ +{ + "branch_badge": { + "variables": {}, + "multipleVariables": {}, + "componentVersion": 1, + "componentSchema": {}, + "id": "branch_badge", + "name": "branch_badge", + "visible": true, + "constraints": {}, + "edgePins": { + "left": 0, + "top": 0 + }, + "positioningMode": "align", + "horizontalFit": "fixed", + "verticalFit": "fixed", + "flex": 1, + "aspectRatioLock": false, + "alignment": {}, + "reactions": [], + "basicBoxLocal": [ + 0, + 0, + 48, + 48 + ], + "margin": 0, + "padding": 0, + "rotation": 0, + "enabled": true, + "type": "badge", + "properties": { + "badgeType": "count", + "label": "1200", + "backgroundColor": "#FFFF0000", + "labelStyle": { + "fills": [], + "fontSize": 12.0 + }, + "alignment": "topLeft", + "offsetX": 0, + "offsetY": 0, + "isLabelVisible": true, + "dotSize": 6, + "padding": [ + 4, + 0 + ] + }, + "childID": "icon-1" + }, + "branch_chip": { + "variables": {}, + "multipleVariables": {}, + "componentVersion": 1, + "componentSchema": {}, + "id": "branch_chip", + "name": "branch_chip", + "visible": true, + "constraints": {}, + "edgePins": { + "left": 0, + "top": 0 + }, + "positioningMode": "align", + "horizontalFit": "fixed", + "verticalFit": "fixed", + "flex": 1, + "aspectRatioLock": false, + "alignment": {}, + "reactions": [], + "basicBoxLocal": [ + 0, + 0, + 100, + 32 + ], + "margin": 0, + "padding": 0, + "rotation": 0, + "enabled": true, + "type": "chip", + "properties": { + "chipType": "filter", + "label": "Category", + "labelStyle": { + "fills": [], + "fontSize": 12.0 + }, + "icon": { + "size": 18.0 + }, + "selected": true, + "selectedColor": "#FFFF0000", + "borderWidth": 1.0, + "cornerRadius": 8.0, + "elevation": 0, + "padding": [ + 12, + 0 + ], + "labelPadding": [ + 8, + 4 + ], + "autofocus": false + } + }, + "branch_segmented_button": { + "variables": {}, + "multipleVariables": {}, + "componentVersion": 1, + "componentSchema": {}, + "id": "branch_segmented_button", + "name": "branch_segmented_button", + "visible": true, + "constraints": {}, + "edgePins": { + "left": 0, + "top": 0 + }, + "positioningMode": "align", + "horizontalFit": "fixed", + "verticalFit": "fixed", + "flex": 1, + "aspectRatioLock": false, + "alignment": {}, + "reactions": [], + "basicBoxLocal": [ + 0, + 0, + 300, + 40 + ], + "margin": 0, + "padding": 0, + "rotation": 0, + "enabled": true, + "type": "segmentedButton", + "properties": { + "segments": [ + { + "value": "day", + "label": "Day", + "enabled": true + }, + { + "value": "week", + "label": "Week", + "enabled": true + }, + { + "value": "month", + "label": "Month", + "enabled": true + } + ], + "selectedSegments": [ + "week" + ], + "multiSelectionEnabled": false, + "emptySelectionAllowed": false, + "borderWidth": 1, + "selectedColor": "#FFFF0000", + "cornerRadius": [ + 20, + false, + "circular" + ] + } + } +} diff --git a/test/nodes/badge_chip_segmented_json_test.dart b/test/nodes/badge_chip_segmented_json_test.dart new file mode 100644 index 0000000..ab311a4 --- /dev/null +++ b/test/nodes/badge_chip_segmented_json_test.dart @@ -0,0 +1,317 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:test/test.dart'; + +Map fixture(String name) => + jsonDecode(File('test/fixtures/$name').readAsStringSync()) + as Map; + +/// Encodes [node] to JSON text and reads it back through the registry, the +/// way a stored layout is loaded. +BaseNode roundTrip(BaseNode node) => NodeJsonConverter.instance.fromJson( + jsonDecode(jsonEncode(node.toJson())) as Map, +)!; + +Object? encodeDecode(Object? json) => jsonDecode(jsonEncode(json)); + +void main() { + final customNodes = [ + BadgeNode( + id: 'badge_custom', + name: 'Badge', + basicBoxLocal: NodeBox(0, 0, 40, 40), + childID: 'icon', + reactions: [ + Reaction( + trigger: TriggerModel(type: TriggerType.click), + action: NavigationAction(destinationId: 'page'), + ), + ], + properties: BadgeProperties( + badgeType: BadgeType.label, + label: 'NEW', + maxCount: 99, + backgroundColor: ColorRGBA.black, + textColor: ColorRGBA.white, + textStyle: TextProp.general(fontSize: 10), + alignment: BadgeAlignment.bottomLeft, + offsetX: 2, + offsetY: 3, + isLabelVisible: false, + smallSize: 8, + largeSize: 20, + padding: const EdgeInsetsModel.all(2), + ), + ), + ChipNode( + id: 'chip_custom', + name: 'Chip', + basicBoxLocal: NodeBox(0, 0, 120, 32), + properties: ChipProperties( + chipType: ChipType.input, + label: 'a@b.c', + labelStyle: TextProp.general(fontSize: 13), + icon: const MultiSourceIconModel(size: 18), + avatar: 'https://example.com/a.png', + deleteIcon: const MultiSourceIconModel(size: 16), + deleteIconColor: ColorRGBA.transparent, + selected: true, + showCheckmark: false, + checkmarkColor: ColorRGBA.white, + backgroundColor: ColorRGBA.white, + selectedColor: ColorRGBA.black, + borderColor: ColorRGBA.transparent, + borderWidth: 2, + cornerRadius: 16, + elevation: 1, + shadowColor: ColorRGBA.black, + padding: const EdgeInsetsModel.all(4), + labelPadding: const EdgeInsetsModel.symmetric(horizontal: 4), + tooltip: 'Remove', + autofocus: true, + ), + ), + SegmentedButtonNode( + id: 'segmented_custom', + name: 'Segmented', + basicBoxLocal: NodeBox(0, 0, 200, 40), + properties: SegmentedButtonProperties( + segments: const [ + ButtonSegmentModel(value: 'a', label: 'A', tooltip: 'First'), + ButtonSegmentModel( + value: 'b', + icon: MultiSourceIconModel(size: 18), + enabled: false, + ), + ], + selected: const ['a', 'b'], + multiSelectionEnabled: true, + emptySelectionAllowed: true, + showSelectedIcon: false, + selectedIcon: const MultiSourceIconModel(size: 18), + direction: AxisC.vertical, + borderColor: ColorRGBA.transparent, + borderWidth: 2, + selectedBackgroundColor: ColorRGBA.black, + backgroundColor: ColorRGBA.white, + selectedForegroundColor: ColorRGBA.white, + foregroundColor: ColorRGBA.black, + cornerRadius: const CornerRadius.all(RadiusModel.circular(4)), + ), + ), + ]; + + group('round trip through NodeJsonConverter', () { + for (final node in [ + BadgeNode.empty(), + ChipNode.empty(), + SegmentedButtonNode.empty(), + ...customNodes, + ]) { + test('${node.type} "${node.id}"', () { + final restored = roundTrip(node); + expect(restored.runtimeType, node.runtimeType); + expect(restored, node); + expect( + (restored as CustomPropertiesMixin).properties, + (node as CustomPropertiesMixin).properties, + ); + expect(encodeDecode(restored.toJson()), encodeDecode(node.toJson())); + }); + } + }); + + group('defaults are omitted from JSON', () { + test('badge', () { + final json = encodeDecode(BadgeProperties().toJson()) as Map; + expect(json.keys, ['labelStyle']); + }); + + test('chip', () { + final json = encodeDecode(ChipProperties().toJson()) as Map; + expect(json.keys, ['labelStyle']); + }); + + test('segmented button', () { + expect(SegmentedButtonProperties().toJson(), isEmpty); + expect( + SegmentedButtonProperties.fromJson({}), + SegmentedButtonProperties(), + ); + }); + + test('custom values are written under the original keys', () { + final badge = BadgeProperties( + smallSize: 9, + textStyle: TextProp.general(fontSize: 9), + ).toJson(); + expect(badge, contains('dotSize')); + expect(badge, contains('labelStyle')); + final segmented = SegmentedButtonProperties( + selected: const ['x'], + selectedBackgroundColor: ColorRGBA.black, + backgroundColor: ColorRGBA.white, + ).toJson(); + expect(segmented['selectedSegments'], ['x']); + expect(segmented, contains('selectedColor')); + expect(segmented, contains('unselectedColor')); + }); + }); + + group('JSON written by the migrate-json-serializable branch', () { + final branch = fixture('node_json_branch.json'); + + test('badge', () { + final node = + NodeJsonConverter.instance.fromJson(branch['branch_badge'])! + as BadgeNode; + expect(node.childID, 'icon-1'); + final properties = node.properties; + expect(properties.badgeType, BadgeType.count); + expect(properties.label, '1200'); + expect(properties.effectiveLabel(), '999+'); + expect( + properties.backgroundColor, + const ColorRGBA(r: 1, g: 0, b: 0, a: 1), + ); + expect(properties.alignment, BadgeAlignment.topLeft); + // The branch always wrote its offsets, so its 0 default is kept. + expect(properties.offsetX, 0); + expect(properties.offsetY, 0); + expect(properties.smallSize, 6); + expect(properties.textStyle.fontSize, 12); + expect(roundTrip(node), node); + }); + + test('chip', () { + final node = + NodeJsonConverter.instance.fromJson(branch['branch_chip'])! + as ChipNode; + final properties = node.properties; + expect(properties.chipType, ChipType.filter); + expect(properties.label, 'Category'); + expect(properties.selected, isTrue); + expect(properties.icon?.size, 18); + expect(properties.selectedColor, const ColorRGBA(r: 1, g: 0, b: 0, a: 1)); + // The branch always wrote its paddings, so its old defaults are kept. + expect( + properties.padding, + const EdgeInsetsModel.symmetric(horizontal: 12), + ); + expect( + properties.labelPadding, + const EdgeInsetsModel.symmetric(horizontal: 8, vertical: 4), + ); + expect(roundTrip(node), node); + }); + + test('segmented button', () { + final node = + NodeJsonConverter.instance.fromJson( + branch['branch_segmented_button'], + )! + as SegmentedButtonNode; + final properties = node.properties; + expect(properties.segments.map((s) => s.value), ['day', 'week', 'month']); + expect(properties.selected, ['week']); + expect( + properties.selectedBackgroundColor, + const ColorRGBA(r: 1, g: 0, b: 0, a: 1), + ); + expect(properties.backgroundColor, isNull); + expect( + properties.cornerRadius, + const CornerRadius.all(RadiusModel.circular(20)), + ); + expect(roundTrip(node), node); + }); + + test('badge fixtures', () { + final basic = + NodeJsonConverter.instance.fromJson(fixture('badge_01_basic.json'))! + as BadgeNode; + expect(basic.properties.label, '5'); + expect(basic.properties.isLabelVisible, isTrue); + expect(basic.childID, isNull); + expect(basic.basicBoxLocal.width, 24); + + final withChild = + NodeJsonConverter.instance.fromJson( + fixture('badge_02_with_child.json'), + )! + as BadgeNode; + expect(withChild.childID, 'icon-1'); + expect(withChild.properties.label, '99+'); + expect(withChild.properties.textColor, ColorRGBA.white); + expect( + withChild.properties.backgroundColor, + const ColorRGBA(r: 1, g: 0, b: 0, a: 1), + ); + }); + }); + + group('field access', () { + test('uses the JSON keys', () { + expect( + BadgeProperties().fields.keys, + containsAll(['labelStyle', 'dotSize', 'textColor', 'maxCount']), + ); + expect( + SegmentedButtonProperties().fields.keys, + containsAll(['selectedSegments', 'selectedColor', 'unselectedColor']), + ); + expect( + ChipProperties().fields.keys, + containsAll(['chipType', 'showCheckmark', 'tooltip']), + ); + }); + + test('sets values', () { + final badge = BadgeProperties(); + badge.fields['dotSize']!.setValue(10.0); + badge.fields['badgeType']!.setValue(BadgeType.dot); + expect(badge.smallSize, 10); + expect(badge.badgeType, BadgeType.dot); + + final chip = ChipProperties(); + chip.fields['selected']!.setValue(true); + chip.fields['avatar']!.setValue(''); + expect(chip.selected, isTrue); + expect(chip.avatar, isNull); + + final segmented = SegmentedButtonProperties(); + segmented.fields['selectedSegments']!.setValue(['option2']); + expect(segmented.selected, ['option2']); + }); + }); + + group('badge count', () { + test('caps numeric counts at maxCount', () { + final properties = BadgeProperties(maxCount: 99); + expect(properties.effectiveLabel('5'), '5'); + expect(properties.effectiveLabel('99'), '99'); + expect(properties.effectiveLabel('100'), '99+'); + expect(properties.effectiveLabel('new'), 'new'); + }); + + test('leaves label badges alone', () { + final properties = BadgeProperties( + badgeType: BadgeType.label, + maxCount: 9, + ); + expect(properties.effectiveLabel('100'), '100'); + }); + }); + + test('chip exposes selected only for selectable types', () { + for (final type in ChipType.values) { + final node = ChipNode.fromProperties(ChipProperties(chipType: type)); + expect( + node.propertyVariables.any((v) => v.name == 'selected'), + type.supportsSelection, + ); + } + }); +} diff --git a/test/nodes/display/badge_node_test.dart b/test/nodes/display/badge_node_test.dart new file mode 100644 index 0000000..276a0e1 --- /dev/null +++ b/test/nodes/display/badge_node_test.dart @@ -0,0 +1,507 @@ +/// Tests for BadgeNode serialization and behavior. +/// +/// These tests verify: +/// - Round-trip serialization (toJson -> fromJson) +/// - Default value handling +/// - Property validation +/// - Field access functionality +/// - Badge type handling +/// - Alignment and offset handling +library; + +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:test/test.dart'; + +void main() { + group('BadgeNode serialization', () { + test('round-trip preserves all values', () { + final original = BadgeNode( + id: 'test-badge-123', + name: 'Notification Badge', + basicBoxLocal: NodeBox(10, 20, 48, 48), + properties: BadgeProperties( + badgeType: BadgeType.count, + label: '5', + backgroundColor: const ColorRGBA(r: 1.0, g: 0.0, b: 0.0, a: 1.0), + alignment: BadgeAlignment.topRight, + offsetX: 10, + offsetY: -5, + isLabelVisible: true, + smallSize: 8, + padding: const EdgeInsetsModel.symmetric(horizontal: 6, vertical: 3), + ), + childID: 'child-icon-node', + horizontalFit: SizeFit.shrinkWrap, + verticalFit: SizeFit.shrinkWrap, + ); + + final json = original.toJson(); + final restored = BadgeNode.fromJson(json); + + expect(restored.id, equals(original.id)); + expect(restored.name, equals(original.name)); + expect(restored.basicBoxLocal.width, equals(48)); + expect(restored.basicBoxLocal.height, equals(48)); + expect(restored.properties.badgeType, equals(BadgeType.count)); + expect(restored.properties.label, equals('5')); + expect(restored.properties.backgroundColor?.r, equals(1.0)); + expect(restored.properties.backgroundColor?.g, equals(0.0)); + expect(restored.properties.backgroundColor?.b, equals(0.0)); + expect(restored.properties.alignment, equals(BadgeAlignment.topRight)); + expect(restored.properties.offsetX, equals(10)); + expect(restored.properties.offsetY, equals(-5)); + expect(restored.properties.isLabelVisible, isTrue); + expect(restored.properties.smallSize, equals(8)); + expect(restored.properties.padding.horizontal, equals(12)); + expect(restored.childID, equals('child-icon-node')); + expect(restored.horizontalFit, equals(SizeFit.shrinkWrap)); + }); + + test('default values are handled correctly', () { + final node = BadgeNode( + id: 'default-badge', + name: 'Default Badge', + basicBoxLocal: NodeBox(0, 0, 48, 48), + properties: BadgeProperties(), + ); + + final json = node.toJson(); + final restored = BadgeNode.fromJson(json); + + // Verify defaults + expect(restored.properties.badgeType, equals(BadgeType.count)); + expect(restored.properties.label, isEmpty); + expect(restored.properties.backgroundColor, isNull); + expect(restored.properties.alignment, equals(BadgeAlignment.topRight)); + // Flutter's default badge offset for left-to-right text. + expect(restored.properties.offsetX, equals(4)); + expect(restored.properties.offsetY, equals(-4)); + expect(restored.properties.isLabelVisible, isTrue); + expect(restored.properties.smallSize, equals(6)); + expect(restored.properties.padding.horizontal, equals(8)); + expect(restored.properties.padding.vertical, equals(0)); + }); + + test('dot badge type serializes correctly', () { + final node = BadgeNode( + id: 'dot-badge', + name: 'Dot Badge', + basicBoxLocal: NodeBox(0, 0, 48, 48), + properties: BadgeProperties(badgeType: BadgeType.dot, smallSize: 10), + ); + + final json = node.toJson(); + final restored = BadgeNode.fromJson(json); + + expect(restored.properties.badgeType, equals(BadgeType.dot)); + expect(restored.properties.smallSize, equals(10)); + }); + + test('label badge type serializes correctly', () { + final node = BadgeNode( + id: 'label-badge', + name: 'Label Badge', + basicBoxLocal: NodeBox(0, 0, 48, 48), + properties: BadgeProperties(badgeType: BadgeType.label, label: 'NEW'), + ); + + final json = node.toJson(); + final restored = BadgeNode.fromJson(json); + + expect(restored.properties.badgeType, equals(BadgeType.label)); + expect(restored.properties.label, equals('NEW')); + }); + + test('count badge type serializes correctly', () { + final node = BadgeNode( + id: 'count-badge', + name: 'Count Badge', + basicBoxLocal: NodeBox(0, 0, 48, 48), + properties: BadgeProperties(badgeType: BadgeType.count, label: '99+'), + ); + + final json = node.toJson(); + final restored = BadgeNode.fromJson(json); + + expect(restored.properties.badgeType, equals(BadgeType.count)); + expect(restored.properties.label, equals('99+')); + }); + + test('topRight alignment serializes correctly', () { + final node = BadgeNode( + id: 'topright', + name: 'Top Right', + basicBoxLocal: NodeBox(0, 0, 48, 48), + properties: BadgeProperties(alignment: BadgeAlignment.topRight), + ); + + final json = node.toJson(); + final restored = BadgeNode.fromJson(json); + + expect(restored.properties.alignment, equals(BadgeAlignment.topRight)); + }); + + test('topLeft alignment serializes correctly', () { + final node = BadgeNode( + id: 'topleft', + name: 'Top Left', + basicBoxLocal: NodeBox(0, 0, 48, 48), + properties: BadgeProperties(alignment: BadgeAlignment.topLeft), + ); + + final json = node.toJson(); + final restored = BadgeNode.fromJson(json); + + expect(restored.properties.alignment, equals(BadgeAlignment.topLeft)); + }); + + test('bottomRight alignment serializes correctly', () { + final node = BadgeNode( + id: 'bottomright', + name: 'Bottom Right', + basicBoxLocal: NodeBox(0, 0, 48, 48), + properties: BadgeProperties(alignment: BadgeAlignment.bottomRight), + ); + + final json = node.toJson(); + final restored = BadgeNode.fromJson(json); + + expect(restored.properties.alignment, equals(BadgeAlignment.bottomRight)); + }); + + test('bottomLeft alignment serializes correctly', () { + final node = BadgeNode( + id: 'bottomleft', + name: 'Bottom Left', + basicBoxLocal: NodeBox(0, 0, 48, 48), + properties: BadgeProperties(alignment: BadgeAlignment.bottomLeft), + ); + + final json = node.toJson(); + final restored = BadgeNode.fromJson(json); + + expect(restored.properties.alignment, equals(BadgeAlignment.bottomLeft)); + }); + + test('offsets serialize correctly', () { + final node = BadgeNode( + id: 'offset-badge', + name: 'Offset Badge', + basicBoxLocal: NodeBox(0, 0, 48, 48), + properties: BadgeProperties(offsetX: 15.5, offsetY: -20.3), + ); + + final json = node.toJson(); + final restored = BadgeNode.fromJson(json); + + expect(restored.properties.offsetX, closeTo(15.5, 0.01)); + expect(restored.properties.offsetY, closeTo(-20.3, 0.01)); + }); + + test('custom colors serialize correctly', () { + final node = BadgeNode( + id: 'colored', + name: 'Colored', + basicBoxLocal: NodeBox(0, 0, 48, 48), + properties: BadgeProperties( + backgroundColor: const ColorRGBA(r: 0.2, g: 0.8, b: 0.4, a: 1.0), + ), + ); + + final json = node.toJson(); + final restored = BadgeNode.fromJson(json); + + expect(restored.properties.backgroundColor?.r, closeTo(0.2, 0.01)); + expect(restored.properties.backgroundColor?.g, closeTo(0.8, 0.01)); + expect(restored.properties.backgroundColor?.b, closeTo(0.4, 0.01)); + expect(restored.properties.backgroundColor?.a, closeTo(1.0, 0.01)); + }); + + test('childID serializes correctly', () { + final node = BadgeNode( + id: 'badge-with-child', + name: 'Badge With Child', + basicBoxLocal: NodeBox(0, 0, 48, 48), + properties: BadgeProperties(), + childID: 'icon-123', + ); + + final json = node.toJson(); + final restored = BadgeNode.fromJson(json); + + expect(restored.childID, equals('icon-123')); + }); + + test('null childID serializes correctly', () { + final node = BadgeNode( + id: 'badge-no-child', + name: 'Badge No Child', + basicBoxLocal: NodeBox(0, 0, 48, 48), + properties: BadgeProperties(), + childID: null, + ); + + final json = node.toJson(); + final restored = BadgeNode.fromJson(json); + + expect(restored.childID, isNull); + }); + }); + + group('BadgeNode behavior', () { + test('trigger types include click', () { + final node = BadgeNode( + id: 'badge', + name: 'Badge', + basicBoxLocal: NodeBox(0, 0, 48, 48), + properties: BadgeProperties(), + ); + + expect(node.triggerTypes, contains(TriggerType.click)); + }); + + test('type is correct', () { + final node = BadgeNode( + id: 'badge', + name: 'Badge', + basicBoxLocal: NodeBox(0, 0, 48, 48), + properties: BadgeProperties(), + ); + + expect(node.type, equals('badge')); + }); + + test('supportsPadding is true', () { + final node = BadgeNode( + id: 'badge', + name: 'Badge', + basicBoxLocal: NodeBox(0, 0, 48, 48), + properties: BadgeProperties(), + ); + + expect(node.supportsPadding, isTrue); + }); + }); + + group('BadgeProperties', () { + test('copyWith creates a modified copy', () { + final original = BadgeProperties( + badgeType: BadgeType.count, + label: '5', + alignment: BadgeAlignment.topRight, + offsetX: 0, + offsetY: 0, + ); + + final modified = original.copyWith( + badgeType: BadgeType.dot, + label: 'NEW', + alignment: BadgeAlignment.bottomLeft, + offsetX: 10, + offsetY: -5, + ); + + // Original unchanged + expect(original.badgeType, equals(BadgeType.count)); + expect(original.label, equals('5')); + expect(original.alignment, equals(BadgeAlignment.topRight)); + expect(original.offsetX, equals(0)); + expect(original.offsetY, equals(0)); + + // Modified has new values + expect(modified.badgeType, equals(BadgeType.dot)); + expect(modified.label, equals('NEW')); + expect(modified.alignment, equals(BadgeAlignment.bottomLeft)); + expect(modified.offsetX, equals(10)); + expect(modified.offsetY, equals(-5)); + }); + + test('props returns all properties for equality comparison', () { + final props1 = BadgeProperties( + badgeType: BadgeType.count, + label: '5', + offsetX: 10, + ); + final props2 = BadgeProperties( + badgeType: BadgeType.count, + label: '5', + offsetX: 10, + ); + final props3 = BadgeProperties( + badgeType: BadgeType.dot, + label: '5', + offsetX: 10, + ); + + expect(props1.props.length, equals(props2.props.length)); + expect(props1.props[0], equals(props2.props[0])); // badgeType + expect(props1.props[0], isNot(equals(props3.props[0]))); + }); + }); + + group('BadgeType enum', () { + test('prettify returns human-readable names', () { + expect(BadgeType.dot.prettify, equals('Dot Badge')); + expect(BadgeType.label.prettify, equals('Label Badge')); + expect(BadgeType.count.prettify, equals('Count Badge')); + }); + + test('all badge types are covered', () { + expect(BadgeType.values.length, equals(3)); + expect(BadgeType.values, contains(BadgeType.dot)); + expect(BadgeType.values, contains(BadgeType.label)); + expect(BadgeType.values, contains(BadgeType.count)); + }); + }); + + group('BadgeAlignment enum', () { + test('prettify returns human-readable names', () { + expect(BadgeAlignment.topRight.prettify, equals('Top Right')); + expect(BadgeAlignment.topLeft.prettify, equals('Top Left')); + expect(BadgeAlignment.bottomRight.prettify, equals('Bottom Right')); + expect(BadgeAlignment.bottomLeft.prettify, equals('Bottom Left')); + }); + + test('all alignments are covered', () { + expect(BadgeAlignment.values.length, equals(4)); + expect(BadgeAlignment.values, contains(BadgeAlignment.topRight)); + expect(BadgeAlignment.values, contains(BadgeAlignment.topLeft)); + expect(BadgeAlignment.values, contains(BadgeAlignment.bottomRight)); + expect(BadgeAlignment.values, contains(BadgeAlignment.bottomLeft)); + }); + }); + + group('BadgeNode edge cases', () { + test('handles zero offsets', () { + final node = BadgeNode( + id: 'zero-offset', + name: 'Zero Offset', + basicBoxLocal: NodeBox(0, 0, 48, 48), + properties: BadgeProperties(offsetX: 0, offsetY: 0), + ); + + final json = node.toJson(); + final restored = BadgeNode.fromJson(json); + + expect(restored.properties.offsetX, equals(0)); + expect(restored.properties.offsetY, equals(0)); + }); + + test('handles negative offsets', () { + final node = BadgeNode( + id: 'negative-offset', + name: 'Negative Offset', + basicBoxLocal: NodeBox(0, 0, 48, 48), + properties: BadgeProperties(offsetX: -10, offsetY: -15), + ); + + final json = node.toJson(); + final restored = BadgeNode.fromJson(json); + + expect(restored.properties.offsetX, equals(-10)); + expect(restored.properties.offsetY, equals(-15)); + }); + + test('handles large offsets', () { + final node = BadgeNode( + id: 'large-offset', + name: 'Large Offset', + basicBoxLocal: NodeBox(0, 0, 48, 48), + properties: BadgeProperties(offsetX: 100, offsetY: 200), + ); + + final json = node.toJson(); + final restored = BadgeNode.fromJson(json); + + expect(restored.properties.offsetX, equals(100)); + expect(restored.properties.offsetY, equals(200)); + }); + + test('handles hidden badge', () { + final node = BadgeNode( + id: 'hidden', + name: 'Hidden', + basicBoxLocal: NodeBox(0, 0, 48, 48), + properties: BadgeProperties(isLabelVisible: false), + ); + + final json = node.toJson(); + final restored = BadgeNode.fromJson(json); + + expect(restored.properties.isLabelVisible, isFalse); + }); + + test('handles null background color', () { + final node = BadgeNode( + id: 'no-bg', + name: 'No Background', + basicBoxLocal: NodeBox(0, 0, 48, 48), + properties: BadgeProperties(backgroundColor: null), + ); + + final json = node.toJson(); + final restored = BadgeNode.fromJson(json); + + expect(restored.properties.backgroundColor, isNull); + }); + + test('handles empty label', () { + final node = BadgeNode( + id: 'empty-label', + name: 'Empty Label', + basicBoxLocal: NodeBox(0, 0, 48, 48), + properties: BadgeProperties(badgeType: BadgeType.label, label: ''), + ); + + final json = node.toJson(); + final restored = BadgeNode.fromJson(json); + + expect(restored.properties.label, isEmpty); + }); + + test('handles very small dot size', () { + final node = BadgeNode( + id: 'tiny-dot', + name: 'Tiny Dot', + basicBoxLocal: NodeBox(0, 0, 48, 48), + properties: BadgeProperties(badgeType: BadgeType.dot, smallSize: 2), + ); + + final json = node.toJson(); + final restored = BadgeNode.fromJson(json); + + expect(restored.properties.smallSize, equals(2)); + }); + + test('handles large dot size', () { + final node = BadgeNode( + id: 'large-dot', + name: 'Large Dot', + basicBoxLocal: NodeBox(0, 0, 48, 48), + properties: BadgeProperties(badgeType: BadgeType.dot, smallSize: 20), + ); + + final json = node.toJson(); + final restored = BadgeNode.fromJson(json); + + expect(restored.properties.smallSize, equals(20)); + }); + + test('handles custom padding', () { + final node = BadgeNode( + id: 'custom-padding', + name: 'Custom Padding', + basicBoxLocal: NodeBox(0, 0, 48, 48), + properties: BadgeProperties(padding: const EdgeInsetsModel.all(8)), + ); + + final json = node.toJson(); + final restored = BadgeNode.fromJson(json); + + expect(restored.properties.padding.l, equals(8)); + expect(restored.properties.padding.t, equals(8)); + expect(restored.properties.padding.r, equals(8)); + expect(restored.properties.padding.b, equals(8)); + }); + }); +} diff --git a/test/nodes/display/chip_node_test.dart b/test/nodes/display/chip_node_test.dart new file mode 100644 index 0000000..2694714 --- /dev/null +++ b/test/nodes/display/chip_node_test.dart @@ -0,0 +1,594 @@ +/// Tests for ChipNode serialization and behavior. +/// +/// These tests verify: +/// - Round-trip serialization (toJson -> fromJson) +/// - Default value handling +/// - Property validation +/// - Field access functionality +/// - All chip type handling +library; + +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:test/test.dart'; + +void main() { + group('ChipNode serialization', () { + test('round-trip preserves all values', () { + final original = ChipNode( + id: 'test-chip-123', + name: 'Action Chip', + basicBoxLocal: NodeBox(10, 20, 100, 32), + properties: ChipProperties( + chipType: ChipType.action, + label: 'Save', + backgroundColor: const ColorRGBA(r: 0.2, g: 0.6, b: 1.0, a: 1.0), + borderColor: const ColorRGBA(r: 0.0, g: 0.0, b: 0.0, a: 1.0), + borderWidth: 1.5, + cornerRadius: 16, + elevation: 2, + shadowColor: const ColorRGBA(r: 0.0, g: 0.0, b: 0.0, a: 0.3), + ), + horizontalFit: SizeFit.shrinkWrap, + verticalFit: SizeFit.shrinkWrap, + ); + + final json = original.toJson(); + final restored = ChipNode.fromJson(json); + + expect(restored.id, equals(original.id)); + expect(restored.name, equals(original.name)); + expect(restored.basicBoxLocal.width, equals(100)); + expect(restored.basicBoxLocal.height, equals(32)); + expect(restored.properties.chipType, equals(ChipType.action)); + expect(restored.properties.label, equals('Save')); + expect(restored.properties.backgroundColor?.r, closeTo(0.2, 0.01)); + expect(restored.properties.borderColor?.r, closeTo(0.0, 0.01)); + expect(restored.properties.borderWidth, equals(1.5)); + expect(restored.properties.cornerRadius, equals(16)); + expect(restored.properties.elevation, equals(2)); + expect(restored.properties.shadowColor?.a, closeTo(0.3, 0.01)); + expect(restored.horizontalFit, equals(SizeFit.shrinkWrap)); + }); + + test('default values are handled correctly', () { + final node = ChipNode( + id: 'default-chip', + name: 'Default Chip', + basicBoxLocal: NodeBox(0, 0, 100, 32), + properties: ChipProperties(), + ); + + final json = node.toJson(); + final restored = ChipNode.fromJson(json); + + // Verify defaults + expect(restored.properties.chipType, equals(ChipType.action)); + expect(restored.properties.label, equals('Chip')); + expect(restored.properties.selected, isFalse); + expect(restored.properties.backgroundColor, isNull); + expect(restored.properties.borderWidth, equals(1.0)); + expect(restored.properties.cornerRadius, equals(8.0)); + expect(restored.properties.elevation, equals(0)); + expect(restored.properties.autofocus, isFalse); + // Material 3 chip padding (all 8) and label padding (horizontal 8). + expect(restored.properties.padding.horizontal, equals(16)); + expect(restored.properties.padding.vertical, equals(16)); + expect(restored.properties.labelPadding.horizontal, equals(16)); + }); + + test('basic chip type serializes correctly', () { + final node = ChipNode( + id: 'basic-chip', + name: 'Basic Chip', + basicBoxLocal: NodeBox(0, 0, 100, 32), + properties: ChipProperties(chipType: ChipType.basic, label: 'Info'), + ); + + final json = node.toJson(); + final restored = ChipNode.fromJson(json); + + expect(restored.properties.chipType, equals(ChipType.basic)); + expect(restored.properties.label, equals('Info')); + }); + + test('action chip type serializes correctly', () { + final node = ChipNode( + id: 'action-chip', + name: 'Action Chip', + basicBoxLocal: NodeBox(0, 0, 100, 32), + properties: ChipProperties(chipType: ChipType.action, label: 'Delete'), + ); + + final json = node.toJson(); + final restored = ChipNode.fromJson(json); + + expect(restored.properties.chipType, equals(ChipType.action)); + expect(restored.properties.label, equals('Delete')); + }); + + test('choice chip type serializes correctly', () { + final node = ChipNode( + id: 'choice-chip', + name: 'Choice Chip', + basicBoxLocal: NodeBox(0, 0, 100, 32), + properties: ChipProperties( + chipType: ChipType.choice, + label: 'Option A', + selected: true, + ), + ); + + final json = node.toJson(); + final restored = ChipNode.fromJson(json); + + expect(restored.properties.chipType, equals(ChipType.choice)); + expect(restored.properties.label, equals('Option A')); + expect(restored.properties.selected, isTrue); + }); + + test('filter chip type serializes correctly', () { + final node = ChipNode( + id: 'filter-chip', + name: 'Filter Chip', + basicBoxLocal: NodeBox(0, 0, 100, 32), + properties: ChipProperties( + chipType: ChipType.filter, + label: 'Category', + selected: false, + ), + ); + + final json = node.toJson(); + final restored = ChipNode.fromJson(json); + + expect(restored.properties.chipType, equals(ChipType.filter)); + expect(restored.properties.label, equals('Category')); + expect(restored.properties.selected, isFalse); + }); + + test('input chip type serializes correctly', () { + final node = ChipNode( + id: 'input-chip', + name: 'Input Chip', + basicBoxLocal: NodeBox(0, 0, 100, 32), + properties: ChipProperties( + chipType: ChipType.input, + label: 'user@example.com', + ), + ); + + final json = node.toJson(); + final restored = ChipNode.fromJson(json); + + expect(restored.properties.chipType, equals(ChipType.input)); + expect(restored.properties.label, equals('user@example.com')); + }); + + test('icon serializes correctly', () { + final node = ChipNode( + id: 'chip-with-icon', + name: 'Chip With Icon', + basicBoxLocal: NodeBox(0, 0, 100, 32), + properties: ChipProperties( + icon: const MultiSourceIconModel(size: 20, show: true), + ), + ); + + final json = node.toJson(); + final restored = ChipNode.fromJson(json); + + expect(restored.properties.icon?.size, equals(20)); + expect(restored.properties.icon?.show, isTrue); + }); + + test('avatar serializes correctly', () { + final node = ChipNode( + id: 'chip-with-avatar', + name: 'Chip With Avatar', + basicBoxLocal: NodeBox(0, 0, 100, 32), + properties: ChipProperties(avatar: 'https://example.com/avatar.png'), + ); + + final json = node.toJson(); + final restored = ChipNode.fromJson(json); + + expect( + restored.properties.avatar, + equals('https://example.com/avatar.png'), + ); + }); + + test('deleteIcon serializes correctly', () { + final node = ChipNode( + id: 'chip-with-delete', + name: 'Chip With Delete Icon', + basicBoxLocal: NodeBox(0, 0, 100, 32), + properties: ChipProperties( + chipType: ChipType.input, + deleteIcon: const MultiSourceIconModel(size: 18, show: true), + ), + ); + + final json = node.toJson(); + final restored = ChipNode.fromJson(json); + + expect(restored.properties.deleteIcon?.size, equals(18)); + expect(restored.properties.deleteIcon?.show, isTrue); + }); + + test('colors serialize correctly', () { + final node = ChipNode( + id: 'colored-chip', + name: 'Colored Chip', + basicBoxLocal: NodeBox(0, 0, 100, 32), + properties: ChipProperties( + backgroundColor: const ColorRGBA(r: 0.9, g: 0.9, b: 0.9, a: 1.0), + selectedColor: const ColorRGBA(r: 0.2, g: 0.6, b: 1.0, a: 1.0), + borderColor: const ColorRGBA(r: 0.5, g: 0.5, b: 0.5, a: 1.0), + shadowColor: const ColorRGBA(r: 0.0, g: 0.0, b: 0.0, a: 0.2), + ), + ); + + final json = node.toJson(); + final restored = ChipNode.fromJson(json); + + expect(restored.properties.backgroundColor?.r, closeTo(0.9, 0.01)); + expect(restored.properties.selectedColor?.r, closeTo(0.2, 0.01)); + expect(restored.properties.borderColor?.r, closeTo(0.5, 0.01)); + expect(restored.properties.shadowColor?.a, closeTo(0.2, 0.01)); + }); + + test('padding serializes correctly', () { + final node = ChipNode( + id: 'padded-chip', + name: 'Padded Chip', + basicBoxLocal: NodeBox(0, 0, 100, 32), + properties: ChipProperties( + padding: const EdgeInsetsModel.all(16), + labelPadding: const EdgeInsetsModel.symmetric( + horizontal: 12, + vertical: 8, + ), + ), + ); + + final json = node.toJson(); + final restored = ChipNode.fromJson(json); + + expect(restored.properties.padding.l, equals(16)); + expect(restored.properties.padding.t, equals(16)); + expect(restored.properties.padding.r, equals(16)); + expect(restored.properties.padding.b, equals(16)); + expect(restored.properties.labelPadding.horizontal, equals(24)); + expect(restored.properties.labelPadding.vertical, equals(16)); + }); + }); + + group('ChipNode behavior', () { + test('trigger types include click', () { + final node = ChipNode( + id: 'chip', + name: 'Chip', + basicBoxLocal: NodeBox(0, 0, 100, 32), + properties: ChipProperties(), + ); + + expect(node.triggerTypes, contains(TriggerType.click)); + }); + + test('type is correct', () { + final node = ChipNode( + id: 'chip', + name: 'Chip', + basicBoxLocal: NodeBox(0, 0, 100, 32), + properties: ChipProperties(), + ); + + expect(node.type, equals('chip')); + }); + + test('supportsPadding is true', () { + final node = ChipNode( + id: 'chip', + name: 'Chip', + basicBoxLocal: NodeBox(0, 0, 100, 32), + properties: ChipProperties(), + ); + + expect(node.supportsPadding, isTrue); + }); + + test('propertyVariables includes selected for choice chip', () { + final node = ChipNode( + id: 'choice-chip', + name: 'Choice Chip', + basicBoxLocal: NodeBox(0, 0, 100, 32), + properties: ChipProperties(chipType: ChipType.choice, selected: true), + ); + + final selectedVariable = node.propertyVariables.firstWhere( + (v) => v.name == 'selected', + orElse: () => const BoolValue(name: 'notfound', value: false), + ); + + expect(selectedVariable.name, equals('selected')); + expect(selectedVariable.value, isTrue); + }); + + test('propertyVariables includes selected for filter chip', () { + final node = ChipNode( + id: 'filter-chip', + name: 'Filter Chip', + basicBoxLocal: NodeBox(0, 0, 100, 32), + properties: ChipProperties(chipType: ChipType.filter, selected: false), + ); + + final selectedVariable = node.propertyVariables.firstWhere( + (v) => v.name == 'selected', + orElse: () => const BoolValue(name: 'notfound', value: true), + ); + + expect(selectedVariable.name, equals('selected')); + expect(selectedVariable.value, isFalse); + }); + + test('propertyVariables does not include selected for basic chip', () { + final node = ChipNode( + id: 'basic-chip', + name: 'Basic Chip', + basicBoxLocal: NodeBox(0, 0, 100, 32), + properties: ChipProperties(chipType: ChipType.basic), + ); + + final hasSelected = node.propertyVariables.any( + (v) => v.name == 'selected', + ); + expect(hasSelected, isFalse); + }); + }); + + group('ChipProperties', () { + test('copyWith creates a modified copy', () { + final original = ChipProperties( + chipType: ChipType.action, + label: 'Original', + selected: false, + borderWidth: 1.0, + ); + + final modified = original.copyWith( + chipType: ChipType.choice, + label: 'Modified', + selected: true, + borderWidth: 2.0, + ); + + // Original unchanged + expect(original.chipType, equals(ChipType.action)); + expect(original.label, equals('Original')); + expect(original.selected, isFalse); + expect(original.borderWidth, equals(1.0)); + + // Modified has new values + expect(modified.chipType, equals(ChipType.choice)); + expect(modified.label, equals('Modified')); + expect(modified.selected, isTrue); + expect(modified.borderWidth, equals(2.0)); + }); + + test('props returns all properties for equality comparison', () { + final props1 = ChipProperties( + chipType: ChipType.action, + label: 'Test', + selected: false, + ); + final props2 = ChipProperties( + chipType: ChipType.action, + label: 'Test', + selected: false, + ); + final props3 = ChipProperties( + chipType: ChipType.filter, + label: 'Test', + selected: false, + ); + + expect(props1.props.length, equals(props2.props.length)); + expect(props1.props[0], equals(props2.props[0])); // chipType + expect(props1.props[0], isNot(equals(props3.props[0]))); + }); + }); + + group('ChipType enum', () { + test('prettify returns human-readable names', () { + expect(ChipType.basic.prettify, equals('Chip')); + expect(ChipType.action.prettify, equals('Action Chip')); + expect(ChipType.choice.prettify, equals('Choice Chip')); + expect(ChipType.filter.prettify, equals('Filter Chip')); + expect(ChipType.input.prettify, equals('Input Chip')); + }); + + test('all chip types are covered', () { + expect(ChipType.values.length, equals(5)); + expect(ChipType.values, contains(ChipType.basic)); + expect(ChipType.values, contains(ChipType.action)); + expect(ChipType.values, contains(ChipType.choice)); + expect(ChipType.values, contains(ChipType.filter)); + expect(ChipType.values, contains(ChipType.input)); + }); + + test('supportsSelection returns correct values', () { + expect(ChipType.basic.supportsSelection, isFalse); + expect(ChipType.action.supportsSelection, isFalse); + expect(ChipType.choice.supportsSelection, isTrue); + expect(ChipType.filter.supportsSelection, isTrue); + expect(ChipType.input.supportsSelection, isFalse); + }); + + test('supportsDeletion returns correct values', () { + expect(ChipType.basic.supportsDeletion, isFalse); + expect(ChipType.action.supportsDeletion, isFalse); + expect(ChipType.choice.supportsDeletion, isFalse); + expect(ChipType.filter.supportsDeletion, isFalse); + expect(ChipType.input.supportsDeletion, isTrue); + }); + + test('supportsPress returns correct values', () { + expect(ChipType.basic.supportsPress, isFalse); + expect(ChipType.action.supportsPress, isTrue); + expect(ChipType.choice.supportsPress, isTrue); + expect(ChipType.filter.supportsPress, isTrue); + expect(ChipType.input.supportsPress, isFalse); + }); + }); + + group('ChipNode edge cases', () { + test('handles null colors', () { + final node = ChipNode( + id: 'no-colors', + name: 'No Colors', + basicBoxLocal: NodeBox(0, 0, 100, 32), + properties: ChipProperties( + backgroundColor: null, + selectedColor: null, + borderColor: null, + shadowColor: null, + ), + ); + + final json = node.toJson(); + final restored = ChipNode.fromJson(json); + + expect(restored.properties.backgroundColor, isNull); + expect(restored.properties.selectedColor, isNull); + expect(restored.properties.borderColor, isNull); + expect(restored.properties.shadowColor, isNull); + }); + + test('handles null icon', () { + final node = ChipNode( + id: 'no-icon', + name: 'No Icon', + basicBoxLocal: NodeBox(0, 0, 100, 32), + properties: ChipProperties(icon: null), + ); + + final json = node.toJson(); + final restored = ChipNode.fromJson(json); + + expect(restored.properties.icon, isNull); + }); + + test('handles null avatar', () { + final node = ChipNode( + id: 'no-avatar', + name: 'No Avatar', + basicBoxLocal: NodeBox(0, 0, 100, 32), + properties: ChipProperties(avatar: null), + ); + + final json = node.toJson(); + final restored = ChipNode.fromJson(json); + + expect(restored.properties.avatar, isNull); + }); + + test('handles null deleteIcon', () { + final node = ChipNode( + id: 'no-delete', + name: 'No Delete Icon', + basicBoxLocal: NodeBox(0, 0, 100, 32), + properties: ChipProperties(deleteIcon: null), + ); + + final json = node.toJson(); + final restored = ChipNode.fromJson(json); + + expect(restored.properties.deleteIcon, isNull); + }); + + test('handles empty label', () { + final node = ChipNode( + id: 'empty-label', + name: 'Empty Label', + basicBoxLocal: NodeBox(0, 0, 100, 32), + properties: ChipProperties(label: ''), + ); + + final json = node.toJson(); + final restored = ChipNode.fromJson(json); + + expect(restored.properties.label, isEmpty); + }); + + test('handles zero elevation', () { + final node = ChipNode( + id: 'flat-chip', + name: 'Flat Chip', + basicBoxLocal: NodeBox(0, 0, 100, 32), + properties: ChipProperties(elevation: 0), + ); + + final json = node.toJson(); + final restored = ChipNode.fromJson(json); + + expect(restored.properties.elevation, equals(0)); + }); + + test('handles large elevation', () { + final node = ChipNode( + id: 'elevated-chip', + name: 'Elevated Chip', + basicBoxLocal: NodeBox(0, 0, 100, 32), + properties: ChipProperties(elevation: 12), + ); + + final json = node.toJson(); + final restored = ChipNode.fromJson(json); + + expect(restored.properties.elevation, equals(12)); + }); + + test('handles zero borderWidth', () { + final node = ChipNode( + id: 'no-border', + name: 'No Border', + basicBoxLocal: NodeBox(0, 0, 100, 32), + properties: ChipProperties(borderWidth: 0), + ); + + final json = node.toJson(); + final restored = ChipNode.fromJson(json); + + expect(restored.properties.borderWidth, equals(0)); + }); + + test('handles large cornerRadius', () { + final node = ChipNode( + id: 'round-chip', + name: 'Round Chip', + basicBoxLocal: NodeBox(0, 0, 100, 32), + properties: ChipProperties(cornerRadius: 50), + ); + + final json = node.toJson(); + final restored = ChipNode.fromJson(json); + + expect(restored.properties.cornerRadius, equals(50)); + }); + + test('handles autofocus enabled', () { + final node = ChipNode( + id: 'autofocus-chip', + name: 'Autofocus Chip', + basicBoxLocal: NodeBox(0, 0, 100, 32), + properties: ChipProperties(autofocus: true), + ); + + final json = node.toJson(); + final restored = ChipNode.fromJson(json); + + expect(restored.properties.autofocus, isTrue); + }); + }); +} diff --git a/test/nodes/input/segmented_button_node_test.dart b/test/nodes/input/segmented_button_node_test.dart new file mode 100644 index 0000000..6501d25 --- /dev/null +++ b/test/nodes/input/segmented_button_node_test.dart @@ -0,0 +1,314 @@ +/// Tests for SegmentedButtonNode serialization and behavior. +/// +/// These tests verify: +/// - Round-trip serialization (toJson -> fromJson) +/// - Default value handling +/// - Property validation +/// - Field access functionality +/// - Segment handling +library; + +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:test/test.dart'; + +void main() { + group('SegmentedButtonNode serialization', () { + test('round-trip preserves all values', () { + final original = SegmentedButtonNode( + id: 'test-segmented-button-123', + name: 'View Selector', + basicBoxLocal: NodeBox(10, 20, 300, 40), + properties: SegmentedButtonProperties( + segments: const [ + ButtonSegmentModel(value: 'day', label: 'Day'), + ButtonSegmentModel(value: 'week', label: 'Week'), + ButtonSegmentModel(value: 'month', label: 'Month'), + ], + selected: const ['day'], + multiSelectionEnabled: false, + emptySelectionAllowed: false, + borderColor: const ColorRGBA(r: 0.5, g: 0.5, b: 0.5, a: 1.0), + borderWidth: 1.5, + ), + horizontalFit: SizeFit.shrinkWrap, + verticalFit: SizeFit.fixed, + ); + + final json = original.toJson(); + final restored = SegmentedButtonNode.fromJson(json); + + expect(restored.id, equals(original.id)); + expect(restored.name, equals(original.name)); + expect(restored.basicBoxLocal.width, equals(300)); + expect(restored.basicBoxLocal.height, equals(40)); + expect(restored.properties.segments.length, equals(3)); + expect(restored.properties.segments[0].value, equals('day')); + expect(restored.properties.segments[0].label, equals('Day')); + expect(restored.properties.selected, equals(['day'])); + expect(restored.properties.multiSelectionEnabled, isFalse); + expect(restored.properties.emptySelectionAllowed, isFalse); + expect(restored.properties.borderWidth, equals(1.5)); + expect(restored.horizontalFit, equals(SizeFit.shrinkWrap)); + }); + + test('default values are handled correctly', () { + final node = SegmentedButtonNode( + id: 'default-segmented-button', + name: 'Default Segmented Button', + basicBoxLocal: NodeBox(0, 0, 300, 40), + properties: SegmentedButtonProperties(), + ); + + final json = node.toJson(); + final restored = SegmentedButtonNode.fromJson(json); + + // Verify defaults + expect(restored.properties.segments.length, equals(3)); + expect(restored.properties.segments[0].value, equals('option1')); + expect(restored.properties.segments[0].label, equals('Option 1')); + expect(restored.properties.selected, isEmpty); + expect(restored.properties.multiSelectionEnabled, isFalse); + expect(restored.properties.emptySelectionAllowed, isFalse); + expect(restored.properties.borderWidth, equals(1.0)); + }); + + test('segments serialize correctly', () { + const segment = ButtonSegmentModel( + value: 'test', + label: 'Test Label', + icon: MultiSourceIconModel(size: 20), + tooltip: 'Test tooltip', + enabled: true, + ); + + final json = segment.toJson(); + final restored = ButtonSegmentModel.fromJson(json); + + expect(restored.value, equals('test')); + expect(restored.label, equals('Test Label')); + expect(restored.icon?.size, equals(20)); + expect(restored.tooltip, equals('Test tooltip')); + expect(restored.enabled, isTrue); + }); + + test('multi-selection mode serializes correctly', () { + final node = SegmentedButtonNode( + id: 'multi-select', + name: 'Multi Select', + basicBoxLocal: NodeBox(0, 0, 300, 40), + properties: SegmentedButtonProperties( + multiSelectionEnabled: true, + selected: const ['option1', 'option2'], + ), + ); + + final json = node.toJson(); + final restored = SegmentedButtonNode.fromJson(json); + + expect(restored.properties.multiSelectionEnabled, isTrue); + expect(restored.properties.selected.length, equals(2)); + expect(restored.properties.selected, contains('option1')); + expect(restored.properties.selected, contains('option2')); + }); + + test('empty selection allowed serializes correctly', () { + final node = SegmentedButtonNode( + id: 'empty-allowed', + name: 'Empty Allowed', + basicBoxLocal: NodeBox(0, 0, 300, 40), + properties: SegmentedButtonProperties( + emptySelectionAllowed: true, + selected: const [], + ), + ); + + final json = node.toJson(); + final restored = SegmentedButtonNode.fromJson(json); + + expect(restored.properties.emptySelectionAllowed, isTrue); + expect(restored.properties.selected, isEmpty); + }); + + test('custom colors serialize correctly', () { + final node = SegmentedButtonNode( + id: 'colored', + name: 'Colored', + basicBoxLocal: NodeBox(0, 0, 300, 40), + properties: SegmentedButtonProperties( + selectedBackgroundColor: const ColorRGBA( + r: 0.2, + g: 0.4, + b: 0.8, + a: 1.0, + ), + backgroundColor: const ColorRGBA(r: 0.9, g: 0.9, b: 0.9, a: 1.0), + borderColor: const ColorRGBA(r: 0.5, g: 0.5, b: 0.5, a: 1.0), + ), + ); + + final json = node.toJson(); + final restored = SegmentedButtonNode.fromJson(json); + + expect( + restored.properties.selectedBackgroundColor?.r, + closeTo(0.2, 0.01), + ); + expect(restored.properties.backgroundColor?.r, closeTo(0.9, 0.01)); + expect(restored.properties.borderColor?.r, closeTo(0.5, 0.01)); + }); + }); + + group('SegmentedButtonNode behavior', () { + test('trigger types include changed', () { + final node = SegmentedButtonNode( + id: 'btn', + name: 'Button', + basicBoxLocal: NodeBox(0, 0, 300, 40), + properties: SegmentedButtonProperties(), + ); + + expect(node.triggerTypes, contains(TriggerType.changed)); + }); + + test('propertyVariables includes selected', () { + final node = SegmentedButtonNode( + id: 'btn', + name: 'Button', + basicBoxLocal: NodeBox(0, 0, 300, 40), + properties: SegmentedButtonProperties(selected: const ['option1']), + ); + + final selectedVariable = node.propertyVariables.firstWhere( + (v) => v.name == 'selected', + ); + + expect(selectedVariable, isNotNull); + expect(selectedVariable.value, equals(['option1'])); + }); + }); + + group('SegmentedButtonProperties', () { + test('copyWith creates a modified copy', () { + final original = SegmentedButtonProperties( + segments: const [ButtonSegmentModel(value: 'a', label: 'A')], + selected: const ['a'], + multiSelectionEnabled: false, + ); + + final modified = original.copyWith( + selected: const ['b'], + multiSelectionEnabled: true, + ); + + // Original unchanged + expect(original.selected, equals(['a'])); + expect(original.multiSelectionEnabled, isFalse); + + // Modified has new values + expect(modified.selected, equals(['b'])); + expect(modified.multiSelectionEnabled, isTrue); + expect(modified.segments.length, equals(1)); // Preserved + }); + + test('props returns all properties for equality comparison', () { + final props1 = SegmentedButtonProperties( + selected: const ['option1'], + borderWidth: 1.0, + ); + final props2 = SegmentedButtonProperties( + selected: const ['option1'], + borderWidth: 1.0, + ); + final props3 = SegmentedButtonProperties( + selected: const ['option2'], + borderWidth: 1.0, + ); + + expect(props1.props.length, equals(props2.props.length)); + expect(props1.props[1], equals(props2.props[1])); // selected + expect(props1.props[1], isNot(equals(props3.props[1]))); + }); + }); + + group('ButtonSegmentModel', () { + test('copyWith creates a modified copy', () { + const original = ButtonSegmentModel( + value: 'original', + label: 'Original', + enabled: true, + ); + + final modified = original.copyWith(label: 'Modified', enabled: false); + + // Original unchanged + expect(original.label, equals('Original')); + expect(original.enabled, isTrue); + + // Modified has new values + expect(modified.label, equals('Modified')); + expect(modified.enabled, isFalse); + expect(modified.value, equals('original')); // Preserved + }); + + test('default values work correctly', () { + const segment = ButtonSegmentModel(value: 'test'); + + expect(segment.label, isEmpty); + expect(segment.icon, isNull); + expect(segment.tooltip, isNull); + expect(segment.enabled, isTrue); + }); + + test('props returns all properties', () { + const segment1 = ButtonSegmentModel(value: 'a', label: 'A'); + const segment2 = ButtonSegmentModel(value: 'a', label: 'A'); + const segment3 = ButtonSegmentModel(value: 'b', label: 'B'); + + expect(segment1.props, equals(segment2.props)); + expect(segment1.props, isNot(equals(segment3.props))); + }); + }); + + group('SegmentedButtonNode edge cases', () { + test('handles empty segments list', () { + final node = SegmentedButtonNode( + id: 'empty', + name: 'Empty', + basicBoxLocal: NodeBox(0, 0, 300, 40), + properties: SegmentedButtonProperties(segments: const []), + ); + + final json = node.toJson(); + final restored = SegmentedButtonNode.fromJson(json); + + expect(restored.properties.segments, isEmpty); + }); + + test('handles segment with icon only', () { + const segment = ButtonSegmentModel( + value: 'icon-only', + icon: MultiSourceIconModel(size: 24), + ); + + final json = segment.toJson(); + final restored = ButtonSegmentModel.fromJson(json); + + expect(restored.value, equals('icon-only')); + expect(restored.label, isEmpty); + expect(restored.icon?.size, equals(24)); + }); + + test('handles disabled segments', () { + const segment = ButtonSegmentModel( + value: 'disabled', + label: 'Disabled', + enabled: false, + ); + + final json = segment.toJson(); + final restored = ButtonSegmentModel.fromJson(json); + + expect(restored.enabled, isFalse); + }); + }); +}