From 3203bb8a99a614d0b1da25afe17b89091f715651 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 18:25:45 +0000 Subject: [PATCH] codelessly_sdk 7.1.0: render badge, chip and segmented button nodes Port the badge, chip and segmented button transformers from the unmerged migrate-json-serializable branch onto SDK 7 and codelessly_api 5.1.0, and register them with PassiveNodeTransformerManager. - Badge: Material 3 Badge as a dot, label or count (capped at maxCount), wrapping the node named by childID when it exists; label and visibility can be bound; click reactions handled by the manager. - Chip: Chip, ActionChip, ChoiceChip, FilterChip or InputChip; presses fire click, selection updates `selected` and fires changed and click. - SegmentedButton: selection updates `selected` (the branch read and wrote different properties) and fires changed; selections Flutter would assert on are sanitized. Previews keep local state. Add smoke tests for every badge/chip type and segmented button configuration, including reactions firing on selection. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01A67XoQYsLVvoMtiKcXXUxY --- CHANGELOG.md | 19 ++ .../node_transformers/node_transformers.dart | 3 + .../passive_badge_transformer.dart | 116 +++++++ .../passive_chip_transformer.dart | 315 ++++++++++++++++++ .../passive_segmented_button_transformer.dart | 256 ++++++++++++++ .../passive_transformer_manager.dart | 3 + pubspec.yaml | 4 +- test/smoke_test.dart | 293 ++++++++++++++++ test/transformers_test.dart | 4 + 9 files changed, 1011 insertions(+), 2 deletions(-) create mode 100644 lib/src/transformers/node_transformers/passive_badge_transformer.dart create mode 100644 lib/src/transformers/node_transformers/passive_chip_transformer.dart create mode 100644 lib/src/transformers/node_transformers/passive_segmented_button_transformer.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b9485d9..dd6fa34a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,22 @@ +## 7.1.0 + +- Require codelessly_api 5.1.0. +- Render the new badge, chip and segmented button nodes: + - `PassiveBadgeTransformer` builds a Material 3 `Badge` as a dot, label + or count (capped at `maxCount` like `Badge.count`), around the node + named by `childID` when it exists. `label` and `isLabelVisible` can be + bound to variables, and click reactions work like on other nodes. + - `PassiveChipTransformer` builds a `Chip`, `ActionChip`, `ChoiceChip`, + `FilterChip` or `InputChip`. Presses and delete-icon presses fire click + reactions; selecting a choice or filter chip updates its `selected` + value and fires changed (and click) reactions with the new value. + - `PassiveSegmentedButtonTransformer` builds a `SegmentedButton`. + Selecting segments updates its `selected` value and fires changed + reactions with the list of selected values. Selections it cannot show + (unknown values, several values without multi-selection) are dropped + instead of failing Flutter's assertions. + - Previews keep chip and segmented button selection as local state. + ## 7.0.1 - Fix published layouts failing with "Node with id not found" on web diff --git a/lib/src/transformers/node_transformers/node_transformers.dart b/lib/src/transformers/node_transformers/node_transformers.dart index 48076fc4..eb7ba93c 100644 --- a/lib/src/transformers/node_transformers/node_transformers.dart +++ b/lib/src/transformers/node_transformers/node_transformers.dart @@ -1,8 +1,10 @@ export 'passive_accordion_transformer.dart'; export 'passive_app_bar_transformer.dart'; +export 'passive_badge_transformer.dart'; export 'passive_button_transformer.dart'; export 'passive_canvas_transformer.dart'; export 'passive_checkbox_transformer.dart'; +export 'passive_chip_transformer.dart'; export 'passive_divider_transformer.dart'; export 'passive_dropdown_transformer.dart'; export 'passive_embedded_video_transformer.dart'; @@ -21,6 +23,7 @@ export 'passive_progressbar_transformer.dart'; export 'passive_radio_transformer.dart'; export 'passive_rectangle_transformer.dart'; export 'passive_row_column_transformer.dart'; +export 'passive_segmented_button_transformer.dart'; export 'passive_single_placeholder_transformer.dart'; export 'passive_slider_transformer.dart'; export 'passive_spacer_transformer.dart'; diff --git a/lib/src/transformers/node_transformers/passive_badge_transformer.dart b/lib/src/transformers/node_transformers/passive_badge_transformer.dart new file mode 100644 index 00000000..1098f396 --- /dev/null +++ b/lib/src/transformers/node_transformers/passive_badge_transformer.dart @@ -0,0 +1,116 @@ +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:flutter/material.dart'; + +import '../../../codelessly_sdk.dart'; + +class PassiveBadgeTransformer extends NodeWidgetTransformer { + PassiveBadgeTransformer(super.getNode, super.manager); + + @override + Widget buildWidget( + BadgeNode node, + BuildContext context, + WidgetBuildSettings settings, + ) { + return PassiveBadgeWidget(node: node, settings: settings, manager: manager); + } +} + +class PassiveBadgeWidget extends StatelessWidget { + final BadgeNode node; + final WidgetBuildSettings settings; + final WidgetNodeTransformerManager manager; + final List variablesOverrides; + + const PassiveBadgeWidget({ + super.key, + required this.node, + required this.settings, + required this.manager, + this.variablesOverrides = const [], + }); + + /// The node the badge is anchored to, if it exists. + BaseNode? get childNode { + final String? childID = node.childID; + if (childID == null || childID.isEmpty || childID == node.id) return null; + try { + return manager.getNode(childID); + } on Object { + // A dangling child ID renders the badge on its own. + return null; + } + } + + @override + Widget build(BuildContext context) { + final BadgeProperties properties = node.properties; + final ScopedValues scopedValues = ScopedValues.of( + context, + variablesOverrides: variablesOverrides, + ); + + final String label = properties.effectiveLabel( + PropertyValueDelegate.getPropertyValue( + node, + 'label', + scopedValues: scopedValues, + ) ?? + PropertyValueDelegate.substituteVariables( + properties.label, + scopedValues: scopedValues, + nullSubstitutionMode: settings.nullSubstitutionMode, + ), + ); + final bool isLabelVisible = + PropertyValueDelegate.getPropertyValue( + node, + 'isLabelVisible', + scopedValues: scopedValues, + ) ?? + properties.isLabelVisible; + + final TextStyle textStyle = TextUtils.retrieveTextStyleFromProp( + properties.textStyle, + ); + final Alignment alignment = switch (properties.alignment) { + BadgeAlignment.topRight => Alignment.topRight, + BadgeAlignment.topLeft => Alignment.topLeft, + BadgeAlignment.bottomRight => Alignment.bottomRight, + BadgeAlignment.bottomLeft => Alignment.bottomLeft, + }; + + final BaseNode? childNode = this.childNode; + final bool isDot = properties.badgeType == BadgeType.dot; + + final Widget badge = Badge( + key: ValueKey( + '${node.id}-${IndexedItemProvider.maybeOf(context)?.index ?? ''}', + ), + isLabelVisible: isLabelVisible, + label: isDot ? null : Text(label), + backgroundColor: properties.backgroundColor?.toFlutterColor(), + // Flutter applies textColor over the text style's color, so a color + // set on the style is used when no text color is given. + textColor: properties.textColor?.toFlutterColor() ?? textStyle.color, + textStyle: textStyle, + smallSize: properties.smallSize, + largeSize: properties.largeSize, + padding: properties.padding.flutterEdgeInsets, + alignment: alignment, + offset: Offset(properties.offsetX, properties.offsetY), + child: childNode == null + ? null + : manager.buildWidgetFromNode(childNode, context, settings: settings), + ); + + return AdaptiveNodeBox( + node: node, + // Without a child, the badge keeps its own size and sits at its + // alignment inside the node's box. + child: childNode == null + ? Align(alignment: alignment, child: badge) + : badge, + ); + } +} diff --git a/lib/src/transformers/node_transformers/passive_chip_transformer.dart b/lib/src/transformers/node_transformers/passive_chip_transformer.dart new file mode 100644 index 00000000..d713856a --- /dev/null +++ b/lib/src/transformers/node_transformers/passive_chip_transformer.dart @@ -0,0 +1,315 @@ +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:flutter/material.dart'; + +import '../../../codelessly_sdk.dart'; +import '../utils/node_state_provider.dart'; + +class PassiveChipTransformer extends NodeWidgetTransformer { + PassiveChipTransformer(super.getNode, super.manager); + + @override + Widget buildWidget( + ChipNode node, + BuildContext context, + WidgetBuildSettings settings, + ) { + if (settings.isPreview) { + return PreviewChipWidget(node: node, settings: settings); + } + return PassiveChipWidget(node: node, settings: settings); + } +} + +/// A chip whose selection is local state, for previews without a +/// [CodelesslyContext]. +class PreviewChipWidget extends StatefulWidget { + const PreviewChipWidget({ + super.key, + required this.node, + required this.settings, + }); + + final ChipNode node; + final WidgetBuildSettings settings; + + @override + State createState() => _PreviewChipWidgetState(); +} + +class _PreviewChipWidgetState extends State { + late bool selected = widget.node.properties.selected; + + @override + Widget build(BuildContext context) { + return TransformerChip( + node: widget.node, + settings: widget.settings, + selected: selected, + onPressed: (_) {}, + onSelected: (_, value) => setState(() => selected = value), + onDeleted: (_) {}, + ); + } +} + +class PassiveChipWidget extends StatelessWidget { + final ChipNode node; + final WidgetBuildSettings settings; + final List variablesOverrides; + + const PassiveChipWidget({ + super.key, + required this.node, + required this.settings, + this.variablesOverrides = const [], + }); + + void onPressed(BuildContext context) { + FunctionsRepository.triggerAction(context, node: node, TriggerType.click); + } + + void onSelected(BuildContext context, bool selected) { + NodeStateProvider.setState(context, selected); + FunctionsRepository.setPropertyValue( + context, + node: node, + property: 'selected', + value: selected, + ); + FunctionsRepository.triggerAction( + context, + node: node, + TriggerType.changed, + value: selected, + ); + // Selecting is also the chip's press. + FunctionsRepository.triggerAction( + context, + node: node, + TriggerType.click, + value: selected, + ); + } + + void onDeleted(BuildContext context) { + FunctionsRepository.triggerAction(context, node: node, TriggerType.click); + } + + @override + Widget build(BuildContext context) { + final ScopedValues scopedValues = ScopedValues.of( + context, + variablesOverrides: variablesOverrides, + ); + final bool selected = + PropertyValueDelegate.getPropertyValue( + node, + 'selected', + scopedValues: scopedValues, + ) ?? + node.properties.selected; + + return TransformerChip( + node: node, + settings: settings, + variablesOverrides: variablesOverrides, + selected: selected, + onPressed: onPressed, + onSelected: onSelected, + onDeleted: onDeleted, + ); + } +} + +/// Builds the Flutter chip that matches [ChipNode.properties]' chip type. +class TransformerChip extends StatelessWidget { + final ChipNode node; + final WidgetBuildSettings settings; + final List variablesOverrides; + final bool selected; + final void Function(BuildContext context)? onPressed; + final void Function(BuildContext context, bool selected)? onSelected; + final void Function(BuildContext context)? onDeleted; + + const TransformerChip({ + super.key, + required this.node, + required this.settings, + required this.selected, + this.onPressed, + this.onSelected, + this.onDeleted, + this.variablesOverrides = const [], + }); + + @override + Widget build(BuildContext context) { + final ChipProperties properties = node.properties; + final ScopedValues scopedValues = ScopedValues.of( + context, + variablesOverrides: variablesOverrides, + ); + + final String label = + PropertyValueDelegate.getPropertyValue( + node, + 'label', + scopedValues: scopedValues, + ) ?? + PropertyValueDelegate.substituteVariables( + properties.label, + scopedValues: scopedValues, + nullSubstitutionMode: settings.nullSubstitutionMode, + ); + final TextStyle labelStyle = TextUtils.retrieveTextStyleFromProp( + properties.labelStyle, + ); + + final String? avatarUrl = properties.avatar; + final Widget? avatar = avatarUrl != null && avatarUrl.isNotEmpty + ? CircleAvatar(backgroundImage: NetworkImage(avatarUrl)) + : properties.icon != null + ? retrieveIconWidget(properties.icon!, properties.icon!.size ?? 18) + : null; + final Widget? deleteIcon = properties.deleteIcon != null + ? retrieveIconWidget( + properties.deleteIcon!, + properties.deleteIcon!.size ?? 18, + ) + : null; + + // Without a border color or width, the chip keeps the Material 3 border, + // which also changes with the selected state. + final BorderSide? side = + properties.borderColor != null || properties.borderWidth != 1 + ? BorderSide( + color: + properties.borderColor?.toFlutterColor() ?? + Theme.of(context).colorScheme.outlineVariant, + width: properties.borderWidth, + ) + : null; + final OutlinedBorder shape = RoundedRectangleBorder( + borderRadius: BorderRadius.circular(properties.cornerRadius), + ); + + final Color? backgroundColor = properties.backgroundColor?.toFlutterColor(); + final Color? selectedColor = properties.selectedColor?.toFlutterColor(); + final Color? shadowColor = properties.shadowColor?.toFlutterColor(); + final EdgeInsets padding = properties.padding.flutterEdgeInsets; + final EdgeInsets labelPadding = properties.labelPadding.flutterEdgeInsets; + final String? tooltip = properties.tooltip?.isNotEmpty == true + ? properties.tooltip + : null; + final bool enabled = node.enabled; + final Key key = ValueKey( + '${node.id}-${IndexedItemProvider.maybeOf(context)?.index ?? ''}', + ); + final Widget labelWidget = Text(label); + + final Widget chip = switch (properties.chipType) { + ChipType.basic => Chip( + key: key, + label: labelWidget, + avatar: avatar, + labelStyle: labelStyle, + labelPadding: labelPadding, + backgroundColor: backgroundColor, + side: side, + shape: shape, + elevation: properties.elevation, + shadowColor: shadowColor, + padding: padding, + autofocus: properties.autofocus, + ), + ChipType.action => ActionChip( + key: key, + label: labelWidget, + avatar: avatar, + labelStyle: labelStyle, + labelPadding: labelPadding, + backgroundColor: backgroundColor, + side: side, + shape: shape, + elevation: properties.elevation, + shadowColor: shadowColor, + padding: padding, + tooltip: tooltip, + autofocus: properties.autofocus, + onPressed: enabled && onPressed != null + ? () => onPressed!(context) + : null, + ), + ChipType.choice => ChoiceChip( + key: key, + label: labelWidget, + avatar: avatar, + labelStyle: labelStyle, + labelPadding: labelPadding, + selected: selected, + backgroundColor: backgroundColor, + selectedColor: selectedColor, + showCheckmark: properties.showCheckmark, + checkmarkColor: properties.checkmarkColor?.toFlutterColor(), + side: side, + shape: shape, + elevation: properties.elevation, + shadowColor: shadowColor, + padding: padding, + tooltip: tooltip, + autofocus: properties.autofocus, + onSelected: enabled && onSelected != null + ? (value) => onSelected!(context, value) + : null, + ), + ChipType.filter => FilterChip( + key: key, + label: labelWidget, + avatar: avatar, + labelStyle: labelStyle, + labelPadding: labelPadding, + selected: selected, + backgroundColor: backgroundColor, + selectedColor: selectedColor, + showCheckmark: properties.showCheckmark, + checkmarkColor: properties.checkmarkColor?.toFlutterColor(), + side: side, + shape: shape, + elevation: properties.elevation, + shadowColor: shadowColor, + padding: padding, + tooltip: tooltip, + autofocus: properties.autofocus, + onSelected: enabled && onSelected != null + ? (value) => onSelected!(context, value) + : null, + ), + ChipType.input => InputChip( + key: key, + label: labelWidget, + avatar: avatar, + labelStyle: labelStyle, + labelPadding: labelPadding, + deleteIcon: deleteIcon, + deleteIconColor: properties.deleteIconColor?.toFlutterColor(), + backgroundColor: backgroundColor, + side: side, + shape: shape, + elevation: properties.elevation, + shadowColor: shadowColor, + padding: padding, + tooltip: tooltip, + autofocus: properties.autofocus, + isEnabled: enabled, + onPressed: enabled && onPressed != null + ? () => onPressed!(context) + : null, + onDeleted: enabled && onDeleted != null + ? () => onDeleted!(context) + : null, + ), + }; + + return AdaptiveNodeBox(node: node, child: chip); + } +} diff --git a/lib/src/transformers/node_transformers/passive_segmented_button_transformer.dart b/lib/src/transformers/node_transformers/passive_segmented_button_transformer.dart new file mode 100644 index 00000000..c114a085 --- /dev/null +++ b/lib/src/transformers/node_transformers/passive_segmented_button_transformer.dart @@ -0,0 +1,256 @@ +import 'dart:convert'; + +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:flutter/material.dart'; + +import '../../../codelessly_sdk.dart'; +import '../utils/node_state_provider.dart'; + +class PassiveSegmentedButtonTransformer + extends NodeWidgetTransformer { + PassiveSegmentedButtonTransformer(super.getNode, super.manager); + + @override + Widget buildWidget( + SegmentedButtonNode node, + BuildContext context, + WidgetBuildSettings settings, + ) { + if (settings.isPreview) { + return PreviewSegmentedButtonWidget(node: node, settings: settings); + } + return PassiveSegmentedButtonWidget(node: node, settings: settings); + } +} + +/// A segmented button whose selection is local state, for previews without +/// a [CodelesslyContext]. +class PreviewSegmentedButtonWidget extends StatefulWidget { + const PreviewSegmentedButtonWidget({ + super.key, + required this.node, + required this.settings, + }); + + final SegmentedButtonNode node; + final WidgetBuildSettings settings; + + @override + State createState() => + _PreviewSegmentedButtonWidgetState(); +} + +class _PreviewSegmentedButtonWidgetState + extends State { + late Set selected = widget.node.properties.selected.toSet(); + + @override + Widget build(BuildContext context) { + return TransformerSegmentedButton( + node: widget.node, + settings: widget.settings, + selected: selected, + onSelectionChanged: (context, selection) => + setState(() => selected = selection), + ); + } +} + +class PassiveSegmentedButtonWidget extends StatelessWidget { + final SegmentedButtonNode node; + final WidgetBuildSettings settings; + final List variablesOverrides; + + const PassiveSegmentedButtonWidget({ + super.key, + required this.node, + required this.settings, + this.variablesOverrides = const [], + }); + + void onSelectionChanged(BuildContext context, Set selection) { + final List values = selection.toList(); + NodeStateProvider.setState(context, values); + FunctionsRepository.setPropertyValue( + context, + node: node, + property: 'selected', + value: values, + ); + FunctionsRepository.triggerAction( + context, + node: node, + TriggerType.changed, + value: values, + ); + } + + /// Reads a selection from a node value (a list) or a variable (a list or + /// its string form, such as `["a","b"]` or `[a, b]`). + static Set? parseSelection(Object? value) { + switch (value) { + case null: + return null; + case Iterable(): + return {for (final item in value) '$item'}; + case String(): + final String text = value.trim(); + if (text.isEmpty) return {}; + if (text.startsWith('[')) { + try { + final Object? decoded = jsonDecode(text); + if (decoded is List) return {for (final item in decoded) '$item'}; + } on FormatException { + // Dart's List.toString form, which is not JSON. + } + final String inner = text.substring( + 1, + text.endsWith(']') ? text.length - 1 : text.length, + ); + return { + for (final item in inner.split(',')) + if (item.trim().isNotEmpty) item.trim(), + }; + } + return {text}; + default: + return {'$value'}; + } + } + + @override + Widget build(BuildContext context) { + final Set selected = + parseSelection( + PropertyValueDelegate.getPropertyValue( + node, + 'selected', + scopedValues: ScopedValues.of( + context, + variablesOverrides: variablesOverrides, + ), + ), + ) ?? + node.properties.selected.toSet(); + + return TransformerSegmentedButton( + node: node, + settings: settings, + selected: selected, + onSelectionChanged: onSelectionChanged, + variablesOverrides: variablesOverrides, + ); + } +} + +class TransformerSegmentedButton extends StatelessWidget { + final SegmentedButtonNode node; + final WidgetBuildSettings settings; + final List variablesOverrides; + final void Function(BuildContext context, Set selection)? + onSelectionChanged; + final Set selected; + + const TransformerSegmentedButton({ + super.key, + required this.node, + required this.settings, + required this.onSelectionChanged, + required this.selected, + this.variablesOverrides = const [], + }); + + @override + Widget build(BuildContext context) { + final SegmentedButtonProperties properties = node.properties; + // SegmentedButton needs at least one segment. + if (properties.segments.isEmpty) return AdaptiveNodeBox(node: node); + + final ScopedValues scopedValues = ScopedValues.of( + context, + variablesOverrides: variablesOverrides, + ); + + final List> segments = [ + for (final segment in properties.segments) + _buildSegment(segment, scopedValues), + ]; + + // Keep the selection within what SegmentedButton accepts, so stale or + // bound values cannot trip its assertions. + final Set values = {for (final s in segments) s.value}; + Set selection = selected.where(values.contains).toSet(); + if (!properties.multiSelectionEnabled && selection.length > 1) { + selection = {selection.first}; + } + + final BorderSide? side = + properties.borderColor != null || properties.borderWidth != 1 + ? BorderSide( + color: + properties.borderColor?.toFlutterColor() ?? + Theme.of(context).colorScheme.outline, + width: properties.borderWidth, + ) + : null; + + return AdaptiveNodeBox( + node: node, + child: SegmentedButton( + key: ValueKey( + '${node.id}-${IndexedItemProvider.maybeOf(context)?.index ?? ''}', + ), + segments: segments, + selected: selection, + onSelectionChanged: node.enabled && onSelectionChanged != null + ? (selection) => onSelectionChanged!(context, selection) + : null, + multiSelectionEnabled: properties.multiSelectionEnabled, + // A layout without a selection still renders when one is required. + emptySelectionAllowed: + properties.emptySelectionAllowed || selection.isEmpty, + showSelectedIcon: properties.showSelectedIcon, + selectedIcon: properties.selectedIcon != null + ? retrieveIconWidget(properties.selectedIcon!) + : null, + direction: properties.direction.flutterAxis, + style: SegmentedButton.styleFrom( + backgroundColor: properties.backgroundColor?.toFlutterColor(), + selectedBackgroundColor: properties.selectedBackgroundColor + ?.toFlutterColor(), + foregroundColor: properties.foregroundColor?.toFlutterColor(), + selectedForegroundColor: properties.selectedForegroundColor + ?.toFlutterColor(), + side: side, + shape: RoundedRectangleBorder( + borderRadius: properties.cornerRadius.borderRadius, + ), + ), + ), + ); + } + + ButtonSegment _buildSegment( + ButtonSegmentModel segment, + ScopedValues scopedValues, + ) { + final Widget? icon = segment.icon != null + ? retrieveIconWidget(segment.icon!) + : null; + final String label = PropertyValueDelegate.substituteVariables( + segment.label, + scopedValues: scopedValues, + nullSubstitutionMode: settings.nullSubstitutionMode, + ); + return ButtonSegment( + value: segment.value, + icon: icon, + // A segment needs a label or an icon. + label: label.isNotEmpty || icon == null + ? Text(label.isNotEmpty ? label : segment.value) + : null, + tooltip: segment.tooltip?.isNotEmpty == true ? segment.tooltip : null, + enabled: segment.enabled, + ); + } +} diff --git a/lib/src/transformers/passive_transformer_manager.dart b/lib/src/transformers/passive_transformer_manager.dart index 5c2453d9..3957dfc6 100644 --- a/lib/src/transformers/passive_transformer_manager.dart +++ b/lib/src/transformers/passive_transformer_manager.dart @@ -57,6 +57,9 @@ class PassiveNodeTransformerManager extends WidgetNodeTransformerManager { 'gridView': PassiveGridViewTransformer(getNode, this), 'pageView': PassivePageViewTransformer(getNode, this), 'tabBar': PassiveTabBarTransformer(getNode, this), + 'badge': PassiveBadgeTransformer(getNode, this), + 'chip': PassiveChipTransformer(getNode, this), + 'segmentedButton': PassiveSegmentedButtonTransformer(getNode, this), 'external': PassiveExternalComponentTransformer(getNode, this), }); } diff --git a/pubspec.yaml b/pubspec.yaml index e7c836c0..302d80c1 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: codelessly_sdk description: Codelessly CloudUIâ„¢ - Server-driven UI for Flutter. Supercharge your Flutter apps with dynamic UI and real-time updates. Build and publish UI without code! -version: 7.0.1 +version: 7.1.0 homepage: https://codelessly.com/ repository: https://github.com/Codelessly/CodelesslySDK issue_tracker: https://github.com/Codelessly/CodelesslySDK/issues @@ -28,7 +28,7 @@ dependencies: flutter: sdk: flutter # Codelessly - codelessly_api: ^5.0.0 + codelessly_api: ^5.1.0 json_annotation: ^4.12.0 # Firebase diff --git a/test/smoke_test.dart b/test/smoke_test.dart index 57a2ba97..b11e0a78 100644 --- a/test/smoke_test.dart +++ b/test/smoke_test.dart @@ -1,12 +1,45 @@ import 'package:codelessly_api/codelessly_api.dart'; import 'package:codelessly_sdk/codelessly_sdk.dart'; +import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; Widget host(Widget child) => MaterialApp( home: Scaffold(body: Center(child: child)), ); +/// Hosts [child] with a [CodelesslyContext] that has [functions], so +/// reactions with a call-function action can be observed. +Widget hostWithContext( + Widget child, { + Map functions = const {}, +}) => ChangeNotifierProvider.value( + value: CodelesslyContext.empty()..functions = {...functions}, + child: host(child), +); + +/// A Cupertino icon renders from the bundled font; Material icons load SVGs +/// over the network, which widget tests cannot reach. +final heartIcon = MultiSourceIconModel( + size: 18, + icon: CupertinoIcon(codepoint: CupertinoIcons.heart.codePoint, name: 'heart'), +); + +Reaction callFunction(TriggerType type, String name) => Reaction( + action: CallFunctionAction(name: name), + trigger: TriggerModel(type: type), +); + +PassiveNodeTransformerManager managerFor(List nodes) { + final registry = NodeRegistry() + ..setNodes({for (final node in nodes) node.id: node}); + return PassiveNodeTransformerManager( + registry.getNodeByID, + (context, bounds, pageID, layoutID, canvasID) => const SizedBox(), + ); +} + void main() { const settings = WidgetBuildSettings(debugLabel: 'test'); @@ -69,4 +102,264 @@ void main() { } } }); + + testWidgets('badge renders every type, alone and on a child', (tester) async { + final child = IconNode( + id: 'icon', + name: 'Icon', + basicBoxLocal: NodeBox(0, 0, 24, 24), + icon: heartIcon.icon, + ); + for (final type in BadgeType.values) { + for (final childID in [null, 'icon', 'missing']) { + final node = BadgeNode( + id: 'badge', + name: 'Badge', + basicBoxLocal: NodeBox(0, 0, 48, 48), + childID: childID, + properties: BadgeProperties( + badgeType: type, + label: '1200', + maxCount: 99, + backgroundColor: ColorRGBA.black, + textColor: ColorRGBA.white, + alignment: BadgeAlignment.bottomLeft, + offsetX: 2, + offsetY: 2, + smallSize: 8, + largeSize: 18, + padding: const EdgeInsetsModel.symmetric(horizontal: 6), + ), + ); + final manager = managerFor([node, child]); + await tester.pumpWidget( + hostWithContext( + PassiveBadgeWidget( + node: node, + settings: settings, + manager: manager, + ), + ), + ); + expect(tester.takeException(), isNull, reason: '$type $childID'); + expect(find.byType(Badge), findsOneWidget); + expect( + find.text('99+'), + type == BadgeType.count ? findsOneWidget : findsNothing, + ); + expect( + find.byType(Icon), + childID == 'icon' ? findsOneWidget : findsNothing, + reason: '$type $childID', + ); + } + } + }); + + testWidgets('hidden badge renders nothing but its child', (tester) async { + final node = BadgeNode( + id: 'badge', + name: 'Badge', + basicBoxLocal: NodeBox(0, 0, 48, 48), + properties: BadgeProperties(label: '3', isLabelVisible: false), + ); + await tester.pumpWidget( + hostWithContext( + PassiveBadgeWidget( + node: node, + settings: settings, + manager: managerFor([node]), + ), + ), + ); + expect(tester.takeException(), isNull); + expect(find.text('3'), findsNothing); + }); + + testWidgets('chip renders every type', (tester) async { + for (final type in ChipType.values) { + for (final selected in [false, true]) { + final node = ChipNode( + id: 'chip', + name: 'Chip', + basicBoxLocal: NodeBox(0, 0, 160, 32), + properties: ChipProperties( + chipType: type, + label: 'Label', + icon: heartIcon, + deleteIcon: heartIcon, + deleteIconColor: ColorRGBA.black, + selected: selected, + showCheckmark: !selected, + checkmarkColor: ColorRGBA.white, + backgroundColor: ColorRGBA.white, + selectedColor: ColorRGBA.black, + borderColor: ColorRGBA.black, + borderWidth: 2, + cornerRadius: 16, + elevation: 2, + shadowColor: ColorRGBA.black, + padding: const EdgeInsetsModel.all(4), + labelPadding: const EdgeInsetsModel.symmetric(horizontal: 4), + tooltip: 'Tooltip', + ), + ); + await tester.pumpWidget( + hostWithContext(PassiveChipWidget(node: node, settings: settings)), + ); + expect(tester.takeException(), isNull, reason: '$type $selected'); + expect(find.text('Label'), findsOneWidget); + } + } + }); + + testWidgets('chip presses and selections fire their reactions', ( + tester, + ) async { + for (final type in [ChipType.action, ChipType.filter]) { + final calls = []; + final node = ChipNode( + id: 'chip', + name: 'Chip', + basicBoxLocal: NodeBox(0, 0, 120, 32), + reactions: [ + callFunction(TriggerType.click, 'click'), + callFunction(TriggerType.changed, 'changed'), + ], + properties: ChipProperties(chipType: type, label: 'Tap'), + ); + await tester.pumpWidget( + hostWithContext( + PassiveChipWidget(node: node, settings: settings), + functions: { + 'click': (context, reference, params) => calls.add('click'), + 'changed': (context, reference, params) => calls.add('changed'), + }, + ), + ); + await tester.tap(find.text('Tap')); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + expect( + calls, + type == ChipType.filter ? ['changed', 'click'] : ['click'], + reason: '$type', + ); + } + }); + + testWidgets('segmented button renders and reports selection changes', ( + tester, + ) async { + final node = SegmentedButtonNode( + id: 'segmented', + name: 'Segmented', + basicBoxLocal: NodeBox(0, 0, 300, 40), + properties: SegmentedButtonProperties( + segments: [ + const ButtonSegmentModel(value: 'day', label: 'Day'), + const ButtonSegmentModel(value: 'week', label: 'Week'), + ButtonSegmentModel(value: 'month', icon: heartIcon, tooltip: 'M'), + const ButtonSegmentModel(value: 'off', label: 'Off', enabled: false), + ], + selected: const ['day'], + selectedIcon: heartIcon, + borderColor: ColorRGBA.black, + borderWidth: 2, + selectedBackgroundColor: ColorRGBA.black, + backgroundColor: ColorRGBA.white, + selectedForegroundColor: ColorRGBA.white, + foregroundColor: ColorRGBA.black, + ), + ); + Set? changed; + await tester.pumpWidget( + host( + TransformerSegmentedButton( + node: node, + settings: settings, + selected: const {'day'}, + onSelectionChanged: (_, selection) => changed = selection, + ), + ), + ); + expect(tester.takeException(), isNull); + expect(find.byType(SegmentedButton), findsOneWidget); + await tester.tap(find.text('Week')); + expect(changed, {'week'}); + }); + + testWidgets('segmented button selection fires its changed reaction', ( + tester, + ) async { + final calls = []; + final node = SegmentedButtonNode( + id: 'segmented', + name: 'Segmented', + basicBoxLocal: NodeBox(0, 0, 300, 40), + reactions: [callFunction(TriggerType.changed, 'changed')], + properties: SegmentedButtonProperties( + multiSelectionEnabled: true, + selected: const ['option1'], + ), + ); + await tester.pumpWidget( + hostWithContext( + PassiveSegmentedButtonWidget(node: node, settings: settings), + functions: { + 'changed': (context, reference, params) => calls.add('changed'), + }, + ), + ); + await tester.tap(find.text('Option 2')); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + expect(calls, ['changed']); + }); + + testWidgets('segmented button tolerates selections it cannot show', ( + tester, + ) async { + for (final properties in [ + SegmentedButtonProperties(), + SegmentedButtonProperties(selected: const ['option1', 'option2']), + SegmentedButtonProperties(selected: const ['missing']), + SegmentedButtonProperties(segments: const []), + SegmentedButtonProperties( + segments: const [ButtonSegmentModel(value: 'bare')], + direction: AxisC.vertical, + showSelectedIcon: false, + ), + ]) { + final node = SegmentedButtonNode( + id: 'segmented', + name: 'Segmented', + basicBoxLocal: NodeBox(0, 0, 300, 120), + properties: properties, + ); + await tester.pumpWidget( + host( + TransformerSegmentedButton( + node: node, + settings: settings, + selected: properties.selected.toSet(), + onSelectionChanged: (_, _) {}, + ), + ), + ); + expect(tester.takeException(), isNull, reason: '$properties'); + } + }); + + test('segmented button reads bound selections', () { + expect(PassiveSegmentedButtonWidget.parseSelection(null), isNull); + expect(PassiveSegmentedButtonWidget.parseSelection(['a', 1]), {'a', '1'}); + expect(PassiveSegmentedButtonWidget.parseSelection('["a","b"]'), { + 'a', + 'b', + }); + expect(PassiveSegmentedButtonWidget.parseSelection('[a, b]'), {'a', 'b'}); + expect(PassiveSegmentedButtonWidget.parseSelection('[]'), isEmpty); + expect(PassiveSegmentedButtonWidget.parseSelection('a'), {'a'}); + }); } diff --git a/test/transformers_test.dart b/test/transformers_test.dart index 4bc27c1c..3b21e502 100644 --- a/test/transformers_test.dart +++ b/test/transformers_test.dart @@ -34,6 +34,10 @@ List standaloneNodes() => [ basicBoxLocal: NodeBox(0, 0, 100, 20), characters: 'Hello', ), + BadgeNode.empty(), + for (final type in ChipType.values) + ChipNode.fromProperties(ChipProperties(chipType: type)), + SegmentedButtonNode.empty(), AccordionNode( id: 'accordion', name: 'Accordion',