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); + }); + }); +}