From 9de4c7dfab98c7fbda3f6d8e7c3294c873c1a4f0 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 22:46:53 +0000 Subject: [PATCH 01/11] Add ChartNode: a line/area chart with a selectable x value ChartNode (type "chart") plots evenly spaced y values over [xMin, xMax] with an optional area fill, vertical grid with axis labels, shaded x ranges, labeled markers, a dashed reference line and a selection band that snaps to selectionStep and clamps to selectionMin..selectionMax. selectedX lives on the node; points, markers and selectedX can be bound through node.variables. It fires the changed trigger. Tests round-trip the tide chart of the surf reference app and cover copyWith, snapping and interpolation. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SyH8twyrBoe4wFCTw78Cz5 --- lib/src/api/node_json_converter.dart | 1 + lib/src/api/nodes/chart_node.dart | 671 ++++++++++++++++++++++++ lib/src/api/nodes/chart_node.g.dart | 453 ++++++++++++++++ lib/src/api/nodes/nodes.dart | 1 + test/fixtures/chart_tide.json | 228 ++++++++ test/json_defaults_test.dart | 1 + test/nodes/display/chart_node_test.dart | 131 +++++ 7 files changed, 1486 insertions(+) create mode 100644 lib/src/api/nodes/chart_node.dart create mode 100644 lib/src/api/nodes/chart_node.g.dart create mode 100644 test/fixtures/chart_tide.json create mode 100644 test/nodes/display/chart_node_test.dart diff --git a/lib/src/api/node_json_converter.dart b/lib/src/api/node_json_converter.dart index 311acd7c..a91a4e29 100644 --- a/lib/src/api/node_json_converter.dart +++ b/lib/src/api/node_json_converter.dart @@ -66,6 +66,7 @@ class NodeJsonConverter implements JsonConverter { 'webView': WebViewNode.fromJson, 'dropdown': DropdownNode.fromJson, 'progressBar': ProgressBarNode.fromJson, + 'chart': ChartNode.fromJson, 'variance': VarianceNode.fromJson, 'listView': ListViewNode.fromJson, 'gridView': GridViewNode.fromJson, diff --git a/lib/src/api/nodes/chart_node.dart b/lib/src/api/nodes/chart_node.dart new file mode 100644 index 00000000..006299e8 --- /dev/null +++ b/lib/src/api/nodes/chart_node.dart @@ -0,0 +1,671 @@ +import 'package:equatable/equatable.dart'; +import 'package:json_annotation/json_annotation.dart'; + +import '../field_access.dart'; +import '../mixins.dart'; +import '../models/models.dart'; +import 'nodes.dart'; + +part 'chart_node.g.dart'; + +/// Default width of a chart. +const double kChartDefaultWidth = 320; + +/// Default height of a chart. +const double kChartDefaultHeight = 140; + +/// Default color of a chart's line. +const ColorRGBA kChartDefaultLineColor = ColorRGBA( + r: 0x1F / 255, + g: 0x6F / 255, + b: 0x78 / 255, + a: 1, +); + +/// Fill color of the sample chart. +const ColorRGBA kChartSampleFillColor = ColorRGBA( + r: 0x1F / 255, + g: 0x6F / 255, + b: 0x78 / 255, + a: 0x26 / 255, +); + +/// Default color of a chart's reference line. +const ColorRGBA kChartDefaultReferenceColor = ColorRGBA( + r: 0xF2 / 255, + g: 0x54 / 255, + b: 0x2D / 255, + a: 1, +); + +/// A line/area chart that plots [ChartProperties.points] evenly across an x +/// domain and lets the user select an x value by tapping or dragging on it. +/// +/// `variables['points']`, `variables['markers']` and `variables['selectedX']` +/// bind the data, the markers and the selection to variables. Selecting an +/// x value writes it to the bound `selectedX` variable and fires +/// [TriggerType.changed] with it. +@JsonSerializable() +class ChartNode extends SceneNode with CustomPropertiesMixin { + @override + final String type = 'chart'; + + /// Holds configurable properties of the chart. + @override + covariant ChartProperties properties; + + /// The selected x value: the start of the selection band, which is + /// [ChartProperties.selectionStep] wide. Null shows no selection. + double? selectedX; + + /// Creates a chart with sample data, for previews. + ChartNode.empty() + : properties = ChartProperties.sample(), + selectedX = null, + super( + id: 'chart', + name: 'Chart', + basicBoxLocal: NodeBox(0, 0, kChartDefaultWidth, kChartDefaultHeight), + ); + + /// Creates a chart from the given properties, for previews. + ChartNode.fromProperties(this.properties) + : selectedX = null, + super( + id: 'chart', + name: 'Chart', + basicBoxLocal: NodeBox(0, 0, kChartDefaultWidth, kChartDefaultHeight), + ); + + /// Creates a [ChartNode] with the given data. + ChartNode({ + required super.id, + required super.name, + required super.basicBoxLocal, + super.outerBoxLocal, + super.retainedOuterBoxLocal, + super.visible, + super.enabled, + super.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.selectedX, + super.variables, + super.multipleVariables, + }); + + @override + bool get supportsPadding => false; + + @override + FieldsMap generateFields() => { + ...super.generateFields(), + 'properties': ObjectFieldAccess( + 'Properties', + 'Properties of the chart.', + () => properties, + (value) => properties = value, + ), + }; + + @override + List get triggerTypes => [TriggerType.changed]; + + @override + late final List propertyVariables = [ + ...super.propertyVariables, + if (selectedX case final double selectedX) + DoubleValue(name: 'selectedX', value: selectedX), + ]; + + @override + List get props => [ + ...super.props, + properties, + selectedX, + variables, + multipleVariables, + ]; + + /// Creates a [ChartNode] from JSON data. + factory ChartNode.fromJson(Map json) => _$ChartNodeFromJson(json); + + @override + Map toJson() => _$ChartNodeOmitDefaults(this, _$ChartNodeToJson(this)); +} + +/// Where a marker's label sits relative to its dot. +enum ChartMarkerPosition { + /// Above the dot. + above, + + /// Below the dot. + below; + + /// Displayable string representation. + String get prettify => switch (this) { + ChartMarkerPosition.above => 'Above', + ChartMarkerPosition.below => 'Below', + }; +} + +/// The style of a chart's text. +@JsonSerializable() +class ChartTextStyle extends Equatable with SerializableMixin { + /// Font size. + final double fontSize; + + /// Text color. Markers paint their label in their own color instead. + final ColorRGBA color; + + /// Font family. Null uses the app's default font. + final String? fontFamily; + + /// Font weight, from 100 to 900. + final int fontWeight; + + /// Line height as a multiple of [fontSize]. Null uses the font's own. + final double? height; + + /// Creates a [ChartTextStyle] with the given data. + const ChartTextStyle({ + this.fontSize = 10, + this.color = ColorRGBA.black54, + this.fontFamily, + this.fontWeight = 400, + this.height, + }); + + /// Duplicates this [ChartTextStyle] with the given data overrides. + ChartTextStyle copyWith({ + double? fontSize, + ColorRGBA? color, + String? fontFamily, + int? fontWeight, + double? height, + }) => ChartTextStyle( + fontSize: fontSize ?? this.fontSize, + color: color ?? this.color, + fontFamily: fontFamily ?? this.fontFamily, + fontWeight: fontWeight ?? this.fontWeight, + height: height ?? this.height, + ); + + @override + List get props => [fontSize, color, fontFamily, fontWeight, height]; + + /// Creates a [ChartTextStyle] from JSON data. + factory ChartTextStyle.fromJson(Map json) => _$ChartTextStyleFromJson(json); + + @override + Map toJson() => + _$ChartTextStyleOmitDefaults(this, _$ChartTextStyleToJson(this)); +} + +/// A label on a chart's x axis. +@JsonSerializable() +class ChartLabel extends Equatable with SerializableMixin { + /// The x value the label is centered on. + final double x; + + /// The text of the label. + final String label; + + /// Creates a [ChartLabel] with the given data. + const ChartLabel({required this.x, required this.label}); + + @override + List get props => [x, label]; + + /// Creates a [ChartLabel] from JSON data. + factory ChartLabel.fromJson(Map json) => _$ChartLabelFromJson(json); + + @override + Map toJson() => _$ChartLabelOmitDefaults(this, _$ChartLabelToJson(this)); +} + +/// A shaded x range of a chart, painted behind the curve. +@JsonSerializable() +class ChartRange extends Equatable with SerializableMixin { + /// Where the range starts on the x axis. + final double from; + + /// Where the range ends on the x axis. + final double to; + + /// Color of the range. + final ColorRGBA color; + + /// Creates a [ChartRange] with the given data. + const ChartRange({ + required this.from, + required this.to, + this.color = ColorRGBA.black10, + }); + + @override + List get props => [from, to, color]; + + /// Creates a [ChartRange] from JSON data. + factory ChartRange.fromJson(Map json) => _$ChartRangeFromJson(json); + + @override + Map toJson() => _$ChartRangeOmitDefaults(this, _$ChartRangeToJson(this)); +} + +/// A labeled dot on a chart. +@JsonSerializable() +class ChartMarker extends Equatable with SerializableMixin { + /// The x value of the dot. + final double x; + + /// The y value of the dot. Null puts the dot on the curve. + final double? y; + + /// Text shown next to the dot. + final String label; + + /// Color of the dot and its label. + final ColorRGBA color; + + /// Where the label sits relative to the dot. + final ChartMarkerPosition position; + + /// Creates a [ChartMarker] with the given data. + const ChartMarker({ + required this.x, + this.y, + this.label = '', + this.color = ColorRGBA.black, + this.position = ChartMarkerPosition.above, + }); + + @override + List get props => [x, y, label, color, position]; + + /// Creates a [ChartMarker] from JSON data. + factory ChartMarker.fromJson(Map json) => _$ChartMarkerFromJson(json); + + @override + Map toJson() => _$ChartMarkerOmitDefaults(this, _$ChartMarkerToJson(this)); +} + +/// Holds configurable properties of a [ChartNode]. +@JsonSerializable() +class ChartProperties extends CustomProperties with FieldsHolder { + /// The y values, spread evenly from [xMin] to [xMax]. + List points; + + /// The x value of the first point. + double xMin; + + /// The x value of the last point. + double xMax; + + /// The y value at the bottom of the plot. Null uses the smallest point. + double? yMin; + + /// The y value at the top of the plot. Null uses the largest point. + double? yMax; + + /// Space kept above [yMax] and below [yMin] inside the plot, in pixels. + double plotInset; + + /// Color of the line. + ColorRGBA lineColor; + + /// Width of the line. Zero draws no line. + double lineWidth; + + /// Color of the area under the line. Null draws no area. + ColorRGBA? fillColor; + + /// Whether to draw the line as a smooth curve instead of straight segments. + bool smooth; + + /// Draws a vertical gridline every [gridStepX] x units, starting at [xMin]. + /// Null draws no grid. + double? gridStepX; + + /// Color of the gridlines. + ColorRGBA gridColor; + + /// Color of the line along the bottom of the plot. Null draws none. + ColorRGBA? axisColor; + + /// Labels along the bottom of the chart. When empty, the gridlines + /// between [xMin] and [xMax] are labeled with [gridLabels] in order, or + /// with their x value. + List xLabels; + + /// Labels for the gridlines between [xMin] and [xMax], in order, used when + /// [xLabels] is empty. + List gridLabels; + + /// Style of the axis labels. + ChartTextStyle labelStyle; + + /// Height of the strip under the plot that holds the axis labels. Zero + /// shows no labels. + double labelHeight; + + /// Shaded x ranges painted behind the curve. + List ranges; + + /// Labeled dots on the curve. + List markers; + + /// Radius of the marker dots. + double markerRadius; + + /// Style of the marker labels. Each label takes its marker's color. + ChartTextStyle markerLabelStyle; + + /// The x value of a vertical reference line, e.g. now. Null draws none. + double? referenceX; + + /// Color of the reference line. + ColorRGBA referenceColor; + + /// Width of the reference line. + double referenceWidth; + + /// Whether the reference line is dashed. + bool referenceDashed; + + /// Whether tapping or dragging on the chart selects an x value. + bool selectable; + + /// Width of the selection band, and the step selections snap to from + /// [xMin]. Zero selects any x value. + double selectionStep; + + /// The smallest x value that can be selected. Null allows [xMin]. + double? selectionMin; + + /// The largest x value that can be selected. Null allows the last step + /// before [xMax]. + double? selectionMax; + + /// Color of the selection band. + ColorRGBA selectionColor; + + /// Color of the vertical line through the middle of the selection. + ColorRGBA selectionLineColor; + + /// Width of the selection line. Zero draws no line. + double selectionLineWidth; + + /// Radius of the dot on the curve in the middle of the selection. Zero + /// draws no dot. + double selectionDotRadius; + + /// Fill color of the selection dot. + ColorRGBA selectionDotColor; + + /// Outline color of the selection dot. + ColorRGBA selectionDotStrokeColor; + + /// Outline width of the selection dot. + double selectionDotStrokeWidth; + + /// Creates a [ChartProperties] with the given data. + ChartProperties({ + this.points = const [], + this.xMin = 0, + this.xMax = 1, + this.yMin, + this.yMax, + this.plotInset = 8, + this.lineColor = kChartDefaultLineColor, + this.lineWidth = 2, + this.fillColor, + this.smooth = false, + this.gridStepX, + this.gridColor = ColorRGBA.black10, + this.axisColor, + this.xLabels = const [], + this.gridLabels = const [], + this.labelStyle = const ChartTextStyle(), + this.labelHeight = 16, + this.ranges = const [], + this.markers = const [], + this.markerRadius = 3.5, + this.markerLabelStyle = const ChartTextStyle(fontWeight: 700), + this.referenceX, + this.referenceColor = kChartDefaultReferenceColor, + this.referenceWidth = 1.5, + this.referenceDashed = true, + this.selectable = true, + this.selectionStep = 1, + this.selectionMin, + this.selectionMax, + this.selectionColor = ColorRGBA.black10, + this.selectionLineColor = ColorRGBA.black, + this.selectionLineWidth = 1.5, + this.selectionDotRadius = 6.5, + this.selectionDotColor = ColorRGBA.white, + this.selectionDotStrokeColor = ColorRGBA.black, + this.selectionDotStrokeWidth = 2.5, + }); + + /// A chart with a sample wave, so a new chart looks like one. + factory ChartProperties.sample() => ChartProperties( + points: const [ + 2, 2.6, 3.4, 4.2, 4.7, 4.8, 4.4, 3.7, 2.8, 2.0, 1.4, 1.1, 1.2, // + 1.7, 2.5, 3.3, 4.0, 4.3, 4.2, 3.7, 3.0, 2.3, 1.8, 1.6, 1.8, + ], + xMin: 0, + xMax: 24, + yMin: 0, + yMax: 5.5, + fillColor: kChartSampleFillColor, + gridStepX: 6, + gridLabels: const ['6a', '12p', '6p'], + selectionStep: 1, + ); + + /// Duplicates this [ChartProperties] with the given data overrides. + ChartProperties copyWith({ + List? points, + double? xMin, + double? xMax, + double? yMin, + double? yMax, + double? plotInset, + ColorRGBA? lineColor, + double? lineWidth, + ColorRGBA? fillColor, + bool? smooth, + double? gridStepX, + ColorRGBA? gridColor, + ColorRGBA? axisColor, + List? xLabels, + List? gridLabels, + ChartTextStyle? labelStyle, + double? labelHeight, + List? ranges, + List? markers, + double? markerRadius, + ChartTextStyle? markerLabelStyle, + double? referenceX, + ColorRGBA? referenceColor, + double? referenceWidth, + bool? referenceDashed, + bool? selectable, + double? selectionStep, + double? selectionMin, + double? selectionMax, + ColorRGBA? selectionColor, + ColorRGBA? selectionLineColor, + double? selectionLineWidth, + double? selectionDotRadius, + ColorRGBA? selectionDotColor, + ColorRGBA? selectionDotStrokeColor, + double? selectionDotStrokeWidth, + }) => ChartProperties( + points: points ?? this.points, + xMin: xMin ?? this.xMin, + xMax: xMax ?? this.xMax, + yMin: yMin ?? this.yMin, + yMax: yMax ?? this.yMax, + plotInset: plotInset ?? this.plotInset, + lineColor: lineColor ?? this.lineColor, + lineWidth: lineWidth ?? this.lineWidth, + fillColor: fillColor ?? this.fillColor, + smooth: smooth ?? this.smooth, + gridStepX: gridStepX ?? this.gridStepX, + gridColor: gridColor ?? this.gridColor, + axisColor: axisColor ?? this.axisColor, + xLabels: xLabels ?? this.xLabels, + gridLabels: gridLabels ?? this.gridLabels, + labelStyle: labelStyle ?? this.labelStyle, + labelHeight: labelHeight ?? this.labelHeight, + ranges: ranges ?? this.ranges, + markers: markers ?? this.markers, + markerRadius: markerRadius ?? this.markerRadius, + markerLabelStyle: markerLabelStyle ?? this.markerLabelStyle, + referenceX: referenceX ?? this.referenceX, + referenceColor: referenceColor ?? this.referenceColor, + referenceWidth: referenceWidth ?? this.referenceWidth, + referenceDashed: referenceDashed ?? this.referenceDashed, + selectable: selectable ?? this.selectable, + selectionStep: selectionStep ?? this.selectionStep, + selectionMin: selectionMin ?? this.selectionMin, + selectionMax: selectionMax ?? this.selectionMax, + selectionColor: selectionColor ?? this.selectionColor, + selectionLineColor: selectionLineColor ?? this.selectionLineColor, + selectionLineWidth: selectionLineWidth ?? this.selectionLineWidth, + selectionDotRadius: selectionDotRadius ?? this.selectionDotRadius, + selectionDotColor: selectionDotColor ?? this.selectionDotColor, + selectionDotStrokeColor: + selectionDotStrokeColor ?? this.selectionDotStrokeColor, + selectionDotStrokeWidth: + selectionDotStrokeWidth ?? this.selectionDotStrokeWidth, + ); + + /// Snaps [x] to the selection step and clamps it to the selectable range. + double snapSelection(double x) { + final double step = selectionStep; + double snapped = step > 0 ? xMin + ((x - xMin) / step).floor() * step : x; + final double lowest = selectionMin ?? xMin; + final double highest = + selectionMax ?? (step > 0 ? xMax - step : xMax).clamp(lowest, xMax); + if (snapped < lowest) snapped = lowest; + if (snapped > highest) snapped = highest; + // Steps like 0.1 accumulate float error; keep 10 decimals at most. + return double.parse(snapped.toStringAsFixed(10)); + } + + /// The y value of the curve at [x], interpolated between points. + double? valueAt(double x, [List? points]) { + final List values = points ?? this.points; + if (values.isEmpty) return null; + if (values.length == 1 || xMax == xMin) return values.first; + final double position = + ((x - xMin) / (xMax - xMin)).clamp(0.0, 1.0) * (values.length - 1); + final int index = position.floor().clamp(0, values.length - 2); + final double t = position - index; + return values[index] + (values[index + 1] - values[index]) * t; + } + + @override + FieldsMap generateFields() => { + ...super.generateFields(), + 'lineColor': ColorFieldAccess( + 'Line Color', + 'Color of the line', + () => lineColor, + (value) => lineColor = value, + ), + 'lineWidth': NumFieldAccess( + 'Line Width', + 'Width of the line', + () => lineWidth, + (value) => lineWidth = value, + ), + 'smooth': BoolFieldAccess( + 'Smooth', + 'Whether to draw the line as a smooth curve', + () => smooth, + (value) => smooth = value, + ), + 'gridColor': ColorFieldAccess( + 'Grid Color', + 'Color of the gridlines', + () => gridColor, + (value) => gridColor = value, + ), + 'selectionStep': NumFieldAccess( + 'Selection Step', + 'Width of the selection band', + () => selectionStep, + (value) => selectionStep = value, + ), + 'selectable': BoolFieldAccess( + 'Selectable', + 'Whether tapping or dragging on the chart selects an x value', + () => selectable, + (value) => selectable = value, + ), + }; + + @override + List get props => [ + points, + xMin, + xMax, + yMin, + yMax, + plotInset, + lineColor, + lineWidth, + fillColor, + smooth, + gridStepX, + gridColor, + axisColor, + xLabels, + gridLabels, + labelStyle, + labelHeight, + ranges, + markers, + markerRadius, + markerLabelStyle, + referenceX, + referenceColor, + referenceWidth, + referenceDashed, + selectable, + selectionStep, + selectionMin, + selectionMax, + selectionColor, + selectionLineColor, + selectionLineWidth, + selectionDotRadius, + selectionDotColor, + selectionDotStrokeColor, + selectionDotStrokeWidth, + ]; + + /// Creates a [ChartProperties] from JSON data. + factory ChartProperties.fromJson(Map json) => _$ChartPropertiesFromJson(json); + + @override + Map toJson() => + _$ChartPropertiesOmitDefaults(this, _$ChartPropertiesToJson(this)); +} diff --git a/lib/src/api/nodes/chart_node.g.dart b/lib/src/api/nodes/chart_node.g.dart new file mode 100644 index 00000000..33bbbd83 --- /dev/null +++ b/lib/src/api/nodes/chart_node.g.dart @@ -0,0 +1,453 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'chart_node.dart'; + +// ************************************************************************** +// JsonDefaultsGenerator +// ************************************************************************** + +Map _$ChartNodeOmitDefaults( + ChartNode 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 _$ChartTextStyleOmitDefaults( + ChartTextStyle instance, + Map json, +) { + if (isJsonDefault(instance.fontSize, 10)) json.remove('fontSize'); + if (isJsonDefault(instance.color, ColorRGBA.black54)) json.remove('color'); + if (isJsonDefault(instance.fontWeight, 400)) json.remove('fontWeight'); + return json; +} + +Map _$ChartLabelOmitDefaults( + ChartLabel instance, + Map json, +) { + return json; +} + +Map _$ChartRangeOmitDefaults( + ChartRange instance, + Map json, +) { + if (isJsonDefault(instance.color, ColorRGBA.black10)) json.remove('color'); + return json; +} + +Map _$ChartMarkerOmitDefaults( + ChartMarker instance, + Map json, +) { + if (isJsonDefault(instance.label, '')) json.remove('label'); + if (isJsonDefault(instance.color, ColorRGBA.black)) json.remove('color'); + if (isJsonDefault(instance.position, ChartMarkerPosition.above)) + json.remove('position'); + return json; +} + +Map _$ChartPropertiesOmitDefaults( + ChartProperties instance, + Map json, +) { + if (isJsonDefault(instance.points, const [])) json.remove('points'); + if (isJsonDefault(instance.xMin, 0)) json.remove('xMin'); + if (isJsonDefault(instance.xMax, 1)) json.remove('xMax'); + if (isJsonDefault(instance.plotInset, 8)) json.remove('plotInset'); + if (isJsonDefault(instance.lineColor, kChartDefaultLineColor)) + json.remove('lineColor'); + if (isJsonDefault(instance.lineWidth, 2)) json.remove('lineWidth'); + if (isJsonDefault(instance.smooth, false)) json.remove('smooth'); + if (isJsonDefault(instance.gridColor, ColorRGBA.black10)) + json.remove('gridColor'); + if (isJsonDefault(instance.xLabels, const [])) json.remove('xLabels'); + if (isJsonDefault(instance.gridLabels, const [])) json.remove('gridLabels'); + if (isJsonDefault(instance.labelStyle, const ChartTextStyle())) + json.remove('labelStyle'); + if (isJsonDefault(instance.labelHeight, 16)) json.remove('labelHeight'); + if (isJsonDefault(instance.ranges, const [])) json.remove('ranges'); + if (isJsonDefault(instance.markers, const [])) json.remove('markers'); + if (isJsonDefault(instance.markerRadius, 3.5)) json.remove('markerRadius'); + if (isJsonDefault( + instance.markerLabelStyle, + const ChartTextStyle(fontWeight: 700), + )) + json.remove('markerLabelStyle'); + if (isJsonDefault(instance.referenceColor, kChartDefaultReferenceColor)) + json.remove('referenceColor'); + if (isJsonDefault(instance.referenceWidth, 1.5)) + json.remove('referenceWidth'); + if (isJsonDefault(instance.referenceDashed, true)) + json.remove('referenceDashed'); + if (isJsonDefault(instance.selectable, true)) json.remove('selectable'); + if (isJsonDefault(instance.selectionStep, 1)) json.remove('selectionStep'); + if (isJsonDefault(instance.selectionColor, ColorRGBA.black10)) + json.remove('selectionColor'); + if (isJsonDefault(instance.selectionLineColor, ColorRGBA.black)) + json.remove('selectionLineColor'); + if (isJsonDefault(instance.selectionLineWidth, 1.5)) + json.remove('selectionLineWidth'); + if (isJsonDefault(instance.selectionDotRadius, 6.5)) + json.remove('selectionDotRadius'); + if (isJsonDefault(instance.selectionDotColor, ColorRGBA.white)) + json.remove('selectionDotColor'); + if (isJsonDefault(instance.selectionDotStrokeColor, ColorRGBA.black)) + json.remove('selectionDotStrokeColor'); + if (isJsonDefault(instance.selectionDotStrokeWidth, 2.5)) + json.remove('selectionDotStrokeWidth'); + return json; +} + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +ChartNode _$ChartNodeFromJson(Map json) => + ChartNode( + id: json['id'] as String, + name: json['name'] as String, + basicBoxLocal: NodeBox.fromJson(json['basicBoxLocal']), + visible: json['visible'] as bool? ?? true, + enabled: json['enabled'] as bool? ?? true, + 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: ChartProperties.fromJson(json['properties'] as Map), + selectedX: (json['selectedX'] as num?)?.toDouble(), + 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 _$ChartNodeToJson(ChartNode instance) => { + 'variables': instance.variables, + 'multipleVariables': instance.multipleVariables, + 'componentId': ?instance.componentId, + 'markerType': ?_$ComponentMarkerTypeEnumMap[instance.markerType], + 'componentVersion': instance.componentVersion, + 'componentSchema': instance.componentSchema, + 'id': instance.id, + 'name': instance.name, + 'visible': instance.visible, + 'constraints': instance.constraints.toJson(), + 'edgePins': instance.edgePins.toJson(), + 'positioningMode': _$PositioningModeEnumMap[instance.positioningMode]!, + 'horizontalFit': _$SizeFitEnumMap[instance.horizontalFit]!, + 'verticalFit': _$SizeFitEnumMap[instance.verticalFit]!, + 'flex': instance.flex, + 'aspectRatioLock': instance.aspectRatioLock, + 'alignment': instance.alignment.toJson(), + 'reactions': instance.reactions.map((e) => e.toJson()).toList(), + 'basicBoxLocal': instance.basicBoxLocal.toJson(), + 'margin': instance.margin.toJson(), + 'padding': instance.padding.toJson(), + 'rotation': instance.rotationDegrees, + 'widthFactor': ?instance.widthFactor, + 'heightFactor': ?instance.heightFactor, + 'enabled': instance.enabled, + 'type': instance.type, + 'properties': instance.properties.toJson(), + 'selectedX': ?instance.selectedX, +}; + +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', +}; + +ChartTextStyle _$ChartTextStyleFromJson(Map json) => ChartTextStyle( + fontSize: (json['fontSize'] as num?)?.toDouble() ?? 10, + color: json['color'] == null + ? ColorRGBA.black54 + : ColorRGBA.fromJson(json['color']), + fontFamily: json['fontFamily'] as String?, + fontWeight: (json['fontWeight'] as num?)?.toInt() ?? 400, + height: (json['height'] as num?)?.toDouble(), +); + +Map _$ChartTextStyleToJson(ChartTextStyle instance) => + { + 'fontSize': instance.fontSize, + 'color': instance.color.toJson(), + 'fontFamily': ?instance.fontFamily, + 'fontWeight': instance.fontWeight, + 'height': ?instance.height, + }; + +ChartLabel _$ChartLabelFromJson(Map json) => ChartLabel( + x: (json['x'] as num).toDouble(), + label: json['label'] as String, +); + +Map _$ChartLabelToJson(ChartLabel instance) => + {'x': instance.x, 'label': instance.label}; + +ChartRange _$ChartRangeFromJson(Map json) => ChartRange( + from: (json['from'] as num).toDouble(), + to: (json['to'] as num).toDouble(), + color: json['color'] == null + ? ColorRGBA.black10 + : ColorRGBA.fromJson(json['color']), +); + +Map _$ChartRangeToJson(ChartRange instance) => + { + 'from': instance.from, + 'to': instance.to, + 'color': instance.color.toJson(), + }; + +ChartMarker _$ChartMarkerFromJson(Map json) => ChartMarker( + x: (json['x'] as num).toDouble(), + y: (json['y'] as num?)?.toDouble(), + label: json['label'] as String? ?? '', + color: json['color'] == null + ? ColorRGBA.black + : ColorRGBA.fromJson(json['color']), + position: + $enumDecodeNullable(_$ChartMarkerPositionEnumMap, json['position']) ?? + ChartMarkerPosition.above, +); + +Map _$ChartMarkerToJson(ChartMarker instance) => + { + 'x': instance.x, + 'y': ?instance.y, + 'label': instance.label, + 'color': instance.color.toJson(), + 'position': _$ChartMarkerPositionEnumMap[instance.position]!, + }; + +const _$ChartMarkerPositionEnumMap = { + ChartMarkerPosition.above: 'above', + ChartMarkerPosition.below: 'below', +}; + +ChartProperties _$ChartPropertiesFromJson(Map json) => ChartProperties( + points: + (json['points'] as List?) + ?.map((e) => (e as num).toDouble()) + .toList() ?? + const [], + xMin: (json['xMin'] as num?)?.toDouble() ?? 0, + xMax: (json['xMax'] as num?)?.toDouble() ?? 1, + yMin: (json['yMin'] as num?)?.toDouble(), + yMax: (json['yMax'] as num?)?.toDouble(), + plotInset: (json['plotInset'] as num?)?.toDouble() ?? 8, + lineColor: json['lineColor'] == null + ? kChartDefaultLineColor + : ColorRGBA.fromJson(json['lineColor']), + lineWidth: (json['lineWidth'] as num?)?.toDouble() ?? 2, + fillColor: json['fillColor'] == null + ? null + : ColorRGBA.fromJson(json['fillColor']), + smooth: json['smooth'] as bool? ?? false, + gridStepX: (json['gridStepX'] as num?)?.toDouble(), + gridColor: json['gridColor'] == null + ? ColorRGBA.black10 + : ColorRGBA.fromJson(json['gridColor']), + axisColor: json['axisColor'] == null + ? null + : ColorRGBA.fromJson(json['axisColor']), + xLabels: + (json['xLabels'] as List?) + ?.map((e) => ChartLabel.fromJson(e as Map)) + .toList() ?? + const [], + gridLabels: + (json['gridLabels'] as List?) + ?.map((e) => e as String) + .toList() ?? + const [], + labelStyle: json['labelStyle'] == null + ? const ChartTextStyle() + : ChartTextStyle.fromJson(json['labelStyle'] as Map), + labelHeight: (json['labelHeight'] as num?)?.toDouble() ?? 16, + ranges: + (json['ranges'] as List?) + ?.map((e) => ChartRange.fromJson(e as Map)) + .toList() ?? + const [], + markers: + (json['markers'] as List?) + ?.map((e) => ChartMarker.fromJson(e as Map)) + .toList() ?? + const [], + markerRadius: (json['markerRadius'] as num?)?.toDouble() ?? 3.5, + markerLabelStyle: json['markerLabelStyle'] == null + ? const ChartTextStyle(fontWeight: 700) + : ChartTextStyle.fromJson(json['markerLabelStyle'] as Map), + referenceX: (json['referenceX'] as num?)?.toDouble(), + referenceColor: json['referenceColor'] == null + ? kChartDefaultReferenceColor + : ColorRGBA.fromJson(json['referenceColor']), + referenceWidth: (json['referenceWidth'] as num?)?.toDouble() ?? 1.5, + referenceDashed: json['referenceDashed'] as bool? ?? true, + selectable: json['selectable'] as bool? ?? true, + selectionStep: (json['selectionStep'] as num?)?.toDouble() ?? 1, + selectionMin: (json['selectionMin'] as num?)?.toDouble(), + selectionMax: (json['selectionMax'] as num?)?.toDouble(), + selectionColor: json['selectionColor'] == null + ? ColorRGBA.black10 + : ColorRGBA.fromJson(json['selectionColor']), + selectionLineColor: json['selectionLineColor'] == null + ? ColorRGBA.black + : ColorRGBA.fromJson(json['selectionLineColor']), + selectionLineWidth: (json['selectionLineWidth'] as num?)?.toDouble() ?? 1.5, + selectionDotRadius: (json['selectionDotRadius'] as num?)?.toDouble() ?? 6.5, + selectionDotColor: json['selectionDotColor'] == null + ? ColorRGBA.white + : ColorRGBA.fromJson(json['selectionDotColor']), + selectionDotStrokeColor: json['selectionDotStrokeColor'] == null + ? ColorRGBA.black + : ColorRGBA.fromJson(json['selectionDotStrokeColor']), + selectionDotStrokeWidth: + (json['selectionDotStrokeWidth'] as num?)?.toDouble() ?? 2.5, +); + +Map _$ChartPropertiesToJson(ChartProperties instance) => + { + 'points': instance.points, + 'xMin': instance.xMin, + 'xMax': instance.xMax, + 'yMin': ?instance.yMin, + 'yMax': ?instance.yMax, + 'plotInset': instance.plotInset, + 'lineColor': instance.lineColor.toJson(), + 'lineWidth': instance.lineWidth, + 'fillColor': ?instance.fillColor?.toJson(), + 'smooth': instance.smooth, + 'gridStepX': ?instance.gridStepX, + 'gridColor': instance.gridColor.toJson(), + 'axisColor': ?instance.axisColor?.toJson(), + 'xLabels': instance.xLabels.map((e) => e.toJson()).toList(), + 'gridLabels': instance.gridLabels, + 'labelStyle': instance.labelStyle.toJson(), + 'labelHeight': instance.labelHeight, + 'ranges': instance.ranges.map((e) => e.toJson()).toList(), + 'markers': instance.markers.map((e) => e.toJson()).toList(), + 'markerRadius': instance.markerRadius, + 'markerLabelStyle': instance.markerLabelStyle.toJson(), + 'referenceX': ?instance.referenceX, + 'referenceColor': instance.referenceColor.toJson(), + 'referenceWidth': instance.referenceWidth, + 'referenceDashed': instance.referenceDashed, + 'selectable': instance.selectable, + 'selectionStep': instance.selectionStep, + 'selectionMin': ?instance.selectionMin, + 'selectionMax': ?instance.selectionMax, + 'selectionColor': instance.selectionColor.toJson(), + 'selectionLineColor': instance.selectionLineColor.toJson(), + 'selectionLineWidth': instance.selectionLineWidth, + 'selectionDotRadius': instance.selectionDotRadius, + 'selectionDotColor': instance.selectionDotColor.toJson(), + 'selectionDotStrokeColor': instance.selectionDotStrokeColor.toJson(), + 'selectionDotStrokeWidth': instance.selectionDotStrokeWidth, + }; diff --git a/lib/src/api/nodes/nodes.dart b/lib/src/api/nodes/nodes.dart index 440e5adc..08725aaf 100644 --- a/lib/src/api/nodes/nodes.dart +++ b/lib/src/api/nodes/nodes.dart @@ -5,6 +5,7 @@ export 'badge_node.dart'; export 'base_node.dart'; export 'button_node.dart'; export 'canvas_node.dart'; +export 'chart_node.dart'; export 'checkbox_node.dart'; export 'chip_node.dart'; export 'divider_node.dart'; diff --git a/test/fixtures/chart_tide.json b/test/fixtures/chart_tide.json new file mode 100644 index 00000000..3873148b --- /dev/null +++ b/test/fixtures/chart_tide.json @@ -0,0 +1,228 @@ +{ + "id": "tide", + "name": "Tide chart", + "type": "chart", + "basicBoxLocal": [ + 0, + 0, + 360, + 132 + ], + "horizontalFit": "expanded", + "selectedX": 7, + "variables": { + "points": "${tideCurve}", + "markers": "${tideMarkers}", + "selectedX": "${selectedHour}" + }, + "reactions": [ + { + "id": "r1", + "name": "select", + "trigger": { + "type": "changed" + }, + "action": { + "type": "setVariable", + "variable": { + "id": "selectedHour", + "name": "selectedHour", + "type": "integer" + }, + "newValue": "${value}" + } + } + ], + "properties": { + "points": [ + 1.6, + 1.9, + 2.1, + 2.4, + 2.7, + 3.0, + 3.2, + 3.5, + 3.8, + 4.0, + 4.2, + 4.4, + 4.6, + 4.8, + 4.9, + 4.9, + 5.0, + 5.0, + 5.0, + 4.9, + 4.8, + 4.7, + 4.5, + 4.4, + 4.1, + 3.9, + 3.7, + 3.4, + 3.1, + 2.9, + 2.6, + 2.3, + 2.0, + 1.8, + 1.5, + 1.3, + 1.1, + 1.0, + 0.8, + 0.7, + 0.6, + 0.6, + 0.6, + 0.6, + 0.7, + 0.8, + 0.9, + 1.1, + 1.3, + 1.5, + 1.7, + 2.0, + 2.2, + 2.5, + 2.8, + 3.1, + 3.3, + 3.6, + 3.9, + 4.1, + 4.3, + 4.5, + 4.7, + 4.8, + 4.9, + 5.0, + 5.0, + 5.0, + 5.0, + 4.9, + 4.8, + 4.7, + 4.5, + 4.3, + 4.1, + 3.8, + 3.6, + 3.3, + 3.0, + 2.8, + 2.5, + 2.2, + 2.0, + 1.7, + 1.5, + 1.3, + 1.1, + 0.9, + 0.8, + 0.7, + 0.6, + 0.6, + 0.6, + 0.6, + 0.7, + 0.8, + 1.0 + ], + "xMin": 0, + "xMax": 24, + "yMin": -0.5, + "yMax": 6, + "plotInset": 8, + "lineColor": "#FF1F6F78", + "lineWidth": 2.5, + "fillColor": "#FFD5E6E4", + "gridStepX": 3, + "gridColor": "#FFDCD3C3", + "axisColor": "#FFCFC5B3", + "gridLabels": [ + "3a", + "6a", + "9a", + "12p", + "3p", + "6p", + "9p" + ], + "labelStyle": { + "fontSize": 10, + "color": "#FF7A8690", + "fontFamily": "JetBrains Mono", + "fontWeight": 500, + "height": 1.45 + }, + "labelHeight": 16, + "ranges": [ + { + "from": 0, + "to": 5, + "color": "#0F0E2233" + }, + { + "from": 21, + "to": 24, + "color": "#0F0E2233" + } + ], + "markers": [ + { + "x": 4.2, + "y": 5.0, + "label": "4:12am", + "color": "#FF1F6F78", + "position": "above" + }, + { + "x": 10.42, + "y": 0.6, + "label": "10:25am", + "color": "#FF1F6F78", + "position": "below" + }, + { + "x": 16.62, + "y": 5.0, + "label": "4:37pm", + "color": "#FF1F6F78", + "position": "above" + }, + { + "x": 22.83, + "y": 0.6, + "label": "10:50pm", + "color": "#FF1F6F78", + "position": "below" + } + ], + "markerRadius": 3.5, + "markerLabelStyle": { + "fontSize": 9.5, + "fontFamily": "JetBrains Mono", + "fontWeight": 700, + "height": 1.45 + }, + "referenceX": 7.67, + "referenceColor": "#FFF2542D", + "referenceWidth": 1.5, + "referenceDashed": true, + "selectionStep": 1, + "selectionMin": 5, + "selectionMax": 20, + "selectionColor": "#1A0E2233", + "selectionLineColor": "#FF0E2233", + "selectionLineWidth": 1.5, + "selectionDotRadius": 6.5, + "selectionDotColor": "#FFFBF8F2", + "selectionDotStrokeColor": "#FF0E2233", + "selectionDotStrokeWidth": 2.5 + } +} diff --git a/test/json_defaults_test.dart b/test/json_defaults_test.dart index bb2e1328..55d6537e 100644 --- a/test/json_defaults_test.dart +++ b/test/json_defaults_test.dart @@ -177,6 +177,7 @@ List sampleNodes() { IconNode.empty(), ListTileNode.empty(), ProgressBarNode.empty(), + ChartNode.empty(), RadioNode.empty(), SegmentedButtonNode.empty(), SliderNode.empty(), diff --git a/test/nodes/display/chart_node_test.dart b/test/nodes/display/chart_node_test.dart new file mode 100644 index 00000000..82329a4c --- /dev/null +++ b/test/nodes/display/chart_node_test.dart @@ -0,0 +1,131 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:test/test.dart'; + +Object? encodeDecode(Object? json) => jsonDecode(jsonEncode(json)); + +void main() { + // The tide card of qa/surf_log_reference: 97 quarter-hour points over a + // day, gridlines every 3 hours, night ranges, high/low markers, a dashed + // "now" line and an hour selection clamped to 5..20. + final Map tide = jsonDecode( + File('test/fixtures/chart_tide.json').readAsStringSync(), + ) as Map; + + group('ChartNode JSON', () { + test('reads every property of the tide chart', () { + final node = NodeJsonConverter().fromJson(tide)! as ChartNode; + final props = node.properties; + + expect(node.type, 'chart'); + expect(node.selectedX, 7); + expect(node.variables['points'], r'${tideCurve}'); + expect(node.variables['selectedX'], r'${selectedHour}'); + expect(node.reactions.single.trigger.type, TriggerType.changed); + expect(node.triggerTypes, [TriggerType.changed]); + expect(props.points, hasLength(97)); + expect(props.xMax, 24); + expect(props.yMin, -0.5); + expect(props.fillColor, ColorRGBA.fromHex('#D5E6E4')); + expect(props.gridStepX, 3); + expect(props.gridLabels.first, '3a'); + expect(props.labelStyle.fontFamily, 'JetBrains Mono'); + expect(props.labelStyle.height, 1.45); + expect(props.ranges, [ + ChartRange(from: 0, to: 5, color: ColorRGBA.fromHex('#0F0E2233')!), + ChartRange(from: 21, to: 24, color: ColorRGBA.fromHex('#0F0E2233')!), + ]); + expect(props.markers, hasLength(4)); + expect(props.markers[1].position, ChartMarkerPosition.below); + expect(props.referenceX, 7.67); + expect(props.referenceDashed, isTrue); + expect(props.selectionMin, 5); + expect(props.selectionMax, 20); + }); + + test('round-trips the tide chart', () { + final node = NodeJsonConverter().fromJson(tide)! as ChartNode; + final json = encodeDecode(node.toJson()) as Map; + final back = NodeJsonConverter().fromJson(json)! as ChartNode; + + expect(back, node); + expect(back.properties, node.properties); + expect(encodeDecode(back.toJson()), json); + // Values equal to their defaults are left out. + final props = json['properties'] as Map; + expect(props, isNot(contains('plotInset'))); + expect(props, isNot(contains('selectionStep'))); + expect(props['points'], tide['properties']['points']); + }); + + test('a new chart writes only what differs from the defaults', () { + final node = ChartNode( + id: 'c', + name: 'Chart', + basicBoxLocal: NodeBox(0, 0, 100, 50), + properties: ChartProperties(points: [1, 2, 3]), + ); + expect(encodeDecode(node.toJson()), { + 'id': 'c', + 'name': 'Chart', + 'basicBoxLocal': [0, 0, 100, 50], + 'type': 'chart', + 'properties': { + 'points': [1, 2, 3], + }, + }); + final back = ChartNode.fromJson(encodeDecode(node.toJson()) as Map); + expect(back.properties, ChartProperties(points: [1, 2, 3])); + expect(back.selectedX, isNull); + }); + + test('the sample chart round-trips', () { + final node = ChartNode.empty(); + final back = ChartNode.fromJson(encodeDecode(node.toJson()) as Map); + expect(back.properties, node.properties); + expect(back.properties.points, isNotEmpty); + }); + }); + + group('ChartProperties', () { + test('copyWith overrides only what it is given', () { + final props = ChartProperties(points: [1, 2], lineWidth: 3); + final copy = props.copyWith(gridStepX: 2, smooth: true); + expect(copy.points, [1, 2]); + expect(copy.lineWidth, 3); + expect(copy.gridStepX, 2); + expect(copy.smooth, isTrue); + expect(copy, isNot(props)); + expect(props.copyWith(), props); + }); + + test('snaps selections to the step and clamps them', () { + final props = ChartProperties( + xMax: 24, + selectionStep: 1, + selectionMin: 5, + selectionMax: 20, + ); + expect(props.snapSelection(7.9), 7); + expect(props.snapSelection(2), 5); + expect(props.snapSelection(23.5), 20); + + final open = ChartProperties(xMax: 24, selectionStep: 3); + expect(open.snapSelection(-1), 0); + expect(open.snapSelection(23.9), 21); + expect(ChartProperties(selectionStep: 0.1).snapSelection(0.35), 0.3); + expect(ChartProperties(selectionStep: 0).snapSelection(0.37), 0.37); + }); + + test('interpolates the curve between points', () { + final props = ChartProperties(points: [0, 10, 20], xMax: 2); + expect(props.valueAt(0), 0); + expect(props.valueAt(0.5), 5); + expect(props.valueAt(2), 20); + expect(props.valueAt(5), 20); + expect(ChartProperties().valueAt(0), isNull); + }); + }); +} From 5bb0956966d0962bdfbd9da5ec5ec6b86bfd362e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 23:18:50 +0000 Subject: [PATCH 02/11] ShowDialogAction: bottom sheet and side sheet presentations, dialog alignment and insets Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SyH8twyrBoe4wFCTw78Cz5 --- .../api/models/action/show_dialog_action.dart | 29 ++++++++- .../models/action/show_dialog_action.g.dart | 9 +++ lib/src/api/nodes/chart_node.g.dart | 30 +++++++++ test/show_dialog_action_test.dart | 63 +++++++++++++++++++ 4 files changed, 130 insertions(+), 1 deletion(-) create mode 100644 test/show_dialog_action_test.dart diff --git a/lib/src/api/models/action/show_dialog_action.dart b/lib/src/api/models/action/show_dialog_action.dart index e4cfed84..9e5bcbce 100644 --- a/lib/src/api/models/action/show_dialog_action.dart +++ b/lib/src/api/models/action/show_dialog_action.dart @@ -30,9 +30,19 @@ class ShowDialogAction extends ActionModel with Equatable, SerializableMixin { final DialogPresentation presentation; /// Where a dialog sits on the screen, e.g. top right for a menu. Center by - /// default. Bottom sheets always sit at the bottom. + /// default. Bottom sheets always sit at the bottom and side sheets at the + /// right edge. final AlignmentModel alignment; + /// Space kept between the overlay and the screen edges: a dialog placed + /// in a corner stays this far from it, a side sheet is inset by it. + final EdgeInsetsModel insets; + + /// The most of the screen height a dialog or bottom sheet may take, as a + /// share of it (e.g. 0.9); its content scrolls beyond. Null keeps the + /// canvas's design height (a bottom sheet's own cap applies). + final double? maxHeightFactor; + /// Creates a new [ShowDialogAction]. ShowDialogAction({ required this.destinationId, @@ -42,6 +52,8 @@ class ShowDialogAction extends ActionModel with Equatable, SerializableMixin { this.showCloseButton = false, this.presentation = DialogPresentation.dialog, this.alignment = AlignmentModel.center, + this.insets = EdgeInsetsModel.zero, + this.maxHeightFactor, super.nonBlocking, super.enabled, }) : params = {...params ?? {}}, @@ -56,6 +68,8 @@ class ShowDialogAction extends ActionModel with Equatable, SerializableMixin { showCloseButton, presentation, alignment, + insets, + maxHeightFactor, ]; /// Duplicates this [ShowDialogAction] with given data overrides. @@ -67,6 +81,9 @@ class ShowDialogAction extends ActionModel with Equatable, SerializableMixin { bool? showCloseButton, DialogPresentation? presentation, AlignmentModel? alignment, + EdgeInsetsModel? insets, + double? maxHeightFactor, + bool clearMaxHeightFactor = false, }) => ShowDialogAction( destinationId: destinationId ?? this.destinationId, params: {...this.params, ...params ?? {}}, @@ -75,6 +92,12 @@ class ShowDialogAction extends ActionModel with Equatable, SerializableMixin { showCloseButton: showCloseButton ?? this.showCloseButton, presentation: presentation ?? this.presentation, alignment: alignment ?? this.alignment, + insets: insets ?? this.insets, + maxHeightFactor: clearMaxHeightFactor + ? null + : maxHeightFactor ?? this.maxHeightFactor, + nonBlocking: nonBlocking, + enabled: enabled, ); /// Creates a new [ShowDialogAction] instance from a JSON data. @@ -99,4 +122,8 @@ enum DialogPresentation { /// A bottom sheet on phones (narrower than 600 px), a dialog otherwise. adaptive, + + /// A sheet that slides in from the right edge, as tall as the screen less + /// the insets (or as its content when the canvas hugs its height). + sideSheet, } diff --git a/lib/src/api/models/action/show_dialog_action.g.dart b/lib/src/api/models/action/show_dialog_action.g.dart index 45efe467..c579f232 100644 --- a/lib/src/api/models/action/show_dialog_action.g.dart +++ b/lib/src/api/models/action/show_dialog_action.g.dart @@ -20,6 +20,8 @@ Map _$ShowDialogActionOmitDefaults( json.remove('presentation'); if (isJsonDefault(instance.alignment, AlignmentModel.center)) json.remove('alignment'); + if (isJsonDefault(instance.insets, EdgeInsetsModel.zero)) + json.remove('insets'); if (isJsonDefault(instance.nonBlocking, false)) json.remove('nonBlocking'); if (isJsonDefault(instance.enabled, true)) json.remove('enabled'); return json; @@ -49,6 +51,10 @@ ShowDialogAction _$ShowDialogActionFromJson(Map json) => ShowDialogAction( alignment: json['alignment'] == null ? AlignmentModel.center : AlignmentModel.fromJson(json['alignment'] as Map), + insets: json['insets'] == null + ? EdgeInsetsModel.zero + : EdgeInsetsModel.fromJson(json['insets']), + maxHeightFactor: (json['maxHeightFactor'] as num?)?.toDouble(), nonBlocking: json['nonBlocking'] as bool? ?? false, enabled: json['enabled'] as bool? ?? true, )..type = $enumDecode(_$ActionTypeEnumMap, json['type']); @@ -65,12 +71,15 @@ Map _$ShowDialogActionToJson(ShowDialogAction instance) => 'showCloseButton': instance.showCloseButton, 'presentation': _$DialogPresentationEnumMap[instance.presentation]!, 'alignment': instance.alignment.toJson(), + 'insets': instance.insets.toJson(), + 'maxHeightFactor': ?instance.maxHeightFactor, }; const _$DialogPresentationEnumMap = { DialogPresentation.dialog: 'dialog', DialogPresentation.bottomSheet: 'bottomSheet', DialogPresentation.adaptive: 'adaptive', + DialogPresentation.sideSheet: 'sideSheet', }; const _$ActionTypeEnumMap = { diff --git a/lib/src/api/nodes/chart_node.g.dart b/lib/src/api/nodes/chart_node.g.dart index 33bbbd83..15b3acbb 100644 --- a/lib/src/api/nodes/chart_node.g.dart +++ b/lib/src/api/nodes/chart_node.g.dart @@ -32,6 +32,10 @@ Map _$ChartNodeOmitDefaults( if (isJsonDefault(instance.padding, EdgeInsetsModel.zero)) json.remove('padding'); if (isJsonDefault(instance.rotationDegrees, 0)) json.remove('rotation'); + if (isJsonDefault(instance.interactionStates, {})) + json.remove('interactionStates'); + if (isJsonDefault(instance.tooltip, '')) json.remove('tooltip'); + if (isJsonDefault(instance.zoomable, false)) json.remove('zoomable'); if (isJsonDefault(instance.enabled, true)) json.remove('enabled'); if (isJsonDefault(instance.variables, {})) json.remove('variables'); if (isJsonDefault(instance.multipleVariables, {})) @@ -211,6 +215,19 @@ ChartNode _$ChartNodeFromJson(Map json) => {} ..widthFactor = (json['widthFactor'] as num?)?.toDouble() ..heightFactor = (json['heightFactor'] as num?)?.toDouble() + ..interactionStates = + (json['interactionStates'] as Map?)?.map( + (k, e) => MapEntry( + $enumDecode(_$InteractionStateEnumMap, k), + (e as Map).map( + (k, e) => + MapEntry(k as String, Map.from(e as Map)), + ), + ), + ) ?? + {} + ..tooltip = json['tooltip'] as String? ?? '' + ..zoomable = json['zoomable'] as bool? ?? false ..type = json['type'] as String; Map _$ChartNodeToJson(ChartNode instance) => { @@ -238,6 +255,11 @@ Map _$ChartNodeToJson(ChartNode instance) => { 'rotation': instance.rotationDegrees, 'widthFactor': ?instance.widthFactor, 'heightFactor': ?instance.heightFactor, + 'interactionStates': instance.interactionStates.map( + (k, e) => MapEntry(_$InteractionStateEnumMap[k]!, e), + ), + 'tooltip': instance.tooltip, + 'zoomable': instance.zoomable, 'enabled': instance.enabled, 'type': instance.type, 'properties': instance.properties.toJson(), @@ -262,6 +284,14 @@ const _$ComponentMarkerTypeEnumMap = { ComponentMarkerType.instance: 'instance', }; +const _$InteractionStateEnumMap = { + InteractionState.hovered: 'hovered', + InteractionState.focused: 'focused', + InteractionState.pressed: 'pressed', + InteractionState.selected: 'selected', + InteractionState.disabled: 'disabled', +}; + ChartTextStyle _$ChartTextStyleFromJson(Map json) => ChartTextStyle( fontSize: (json['fontSize'] as num?)?.toDouble() ?? 10, color: json['color'] == null diff --git a/test/show_dialog_action_test.dart b/test/show_dialog_action_test.dart new file mode 100644 index 00000000..d4f204b0 --- /dev/null +++ b/test/show_dialog_action_test.dart @@ -0,0 +1,63 @@ +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:test/test.dart'; + +void main() { + test('a plain dialog keeps its old JSON', () { + final ActionModel action = ActionModel.fromJson({ + 'type': 'showDialog', + 'destinationId': 'c1', + }); + expect(action, isA()); + final ShowDialogAction dialog = action as ShowDialogAction; + expect(dialog.presentation, DialogPresentation.dialog); + expect(dialog.alignment, AlignmentModel.center); + expect(dialog.insets, EdgeInsetsModel.zero); + expect(dialog.toJson(), { + 'type': 'showDialog', + 'destinationId': 'c1', + 'params': {}, + }); + }); + + test('sheets and placed dialogs round-trip', () { + for (final Map json in >[ + { + 'type': 'showDialog', + 'destinationId': 'session', + 'params': {'id': r'${item.id}'}, + 'presentation': 'bottomSheet', + }, + { + 'type': 'showDialog', + 'destinationId': 'session', + 'params': {}, + 'presentation': 'sideSheet', + 'insets': 10, + }, + { + 'type': 'showDialog', + 'destinationId': 'settings', + 'params': {}, + 'alignment': { + 'data': [-1, 1], + }, + 'insets': [96, 0, 0, 16], + }, + ]) { + final ShowDialogAction action = + ActionModel.fromJson(json) as ShowDialogAction; + expect(ActionModel.fromJson(action.toJson()), action, reason: '$json'); + } + final ShowDialogAction popover = ActionModel.fromJson({ + 'type': 'showDialog', + 'destinationId': 'settings', + 'alignment': { + 'data': [-1, 1], + }, + 'insets': [96, 0, 0, 16], + }) as ShowDialogAction; + expect(popover.alignment, AlignmentModel.bottomLeft); + expect(popover.insets.l, 96); + expect(popover.insets.b, 16); + }); +} From fe8ea51acdec93d9aa05a62111fc61038b3ccc75 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 03:02:58 +0000 Subject: [PATCH 03/11] Let a pop navigation action parse without a destination destinationId defaults to '' (a pop has none), so {type: navigation, navigationType: pop} no longer fails to parse. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SyH8twyrBoe4wFCTw78Cz5 --- .../api/models/action/navigation_action.dart | 5 ++-- .../models/action/navigation_action.g.dart | 3 ++- test/navigation_action_test.dart | 26 +++++++++++++++++++ 3 files changed, 31 insertions(+), 3 deletions(-) create mode 100644 test/navigation_action_test.dart diff --git a/lib/src/api/models/action/navigation_action.dart b/lib/src/api/models/action/navigation_action.dart index b74cd1ba..ff0dd4e6 100644 --- a/lib/src/api/models/action/navigation_action.dart +++ b/lib/src/api/models/action/navigation_action.dart @@ -38,7 +38,8 @@ class NavigationAction extends ActionModel with Equatable, SerializableMixin { /// Defines how the navigation should be performed. final NavigationType navigationType; - /// Destination canvas node's ID. + /// Destination canvas node's ID. Empty for a pop, which has no + /// destination. final String destinationId; /// Parameters to pass to the destination page. @@ -47,7 +48,7 @@ class NavigationAction extends ActionModel with Equatable, SerializableMixin { /// Creates a new [NavigationAction]. NavigationAction({ this.navigationType = NavigationType.push, - required this.destinationId, + this.destinationId = '', Map? params, super.nonBlocking, super.enabled, diff --git a/lib/src/api/models/action/navigation_action.g.dart b/lib/src/api/models/action/navigation_action.g.dart index d486f57f..8eab9575 100644 --- a/lib/src/api/models/action/navigation_action.g.dart +++ b/lib/src/api/models/action/navigation_action.g.dart @@ -12,6 +12,7 @@ Map _$NavigationActionOmitDefaults( ) { if (isJsonDefault(instance.navigationType, NavigationType.push)) json.remove('navigationType'); + if (isJsonDefault(instance.destinationId, '')) json.remove('destinationId'); if (isJsonDefault(instance.nonBlocking, false)) json.remove('nonBlocking'); if (isJsonDefault(instance.enabled, true)) json.remove('enabled'); return json; @@ -25,7 +26,7 @@ NavigationAction _$NavigationActionFromJson(Map json) => NavigationAction( navigationType: $enumDecodeNullable(_$NavigationTypeEnumMap, json['navigationType']) ?? NavigationType.push, - destinationId: json['destinationId'] as String, + destinationId: json['destinationId'] as String? ?? '', params: (json['params'] as Map?)?.map((k, e) => MapEntry(k as String, e)), nonBlocking: json['nonBlocking'] as bool? ?? false, enabled: json['enabled'] as bool? ?? true, diff --git a/test/navigation_action_test.dart b/test/navigation_action_test.dart new file mode 100644 index 00000000..3f95d250 --- /dev/null +++ b/test/navigation_action_test.dart @@ -0,0 +1,26 @@ +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:test/test.dart'; + +void main() { + test('a pop navigation parses without a destination', () { + final ActionModel action = ActionModel.fromJson({ + 'type': 'navigation', + 'navigationType': 'pop', + }); + expect(action, isA()); + final nav = action as NavigationAction; + expect(nav.navigationType, NavigationType.pop); + expect(nav.destinationId, ''); + expect(ActionModel.fromJson(nav.toJson()), nav); + }); + + test('a push keeps its destination and params', () { + final nav = NavigationAction( + destinationId: 'canvas1', + params: {'id': r'${item.id}'}, + ); + final json = nav.toJson(); + expect(json['destinationId'], 'canvas1'); + expect(ActionModel.fromJson(json), nav); + }); +} From 95adf9d6f3a45baa20280bb10fe7f032c52c0bb2 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 03:48:28 +0000 Subject: [PATCH 04/11] ListViewProperties: wrap and runSpacing wrap makes a list with a known item count lay its items out in runs, like Flutter's Wrap, instead of scrolling; runSpacing is the gap between runs. The gap between items is the existing separator spacing. Both default to off and are left out of the JSON at their defaults. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SyH8twyrBoe4wFCTw78Cz5 --- lib/src/api/nodes/list_view_node.dart | 39 ++++++++++++ lib/src/api/nodes/list_view_node.g.dart | 6 ++ test/nodes/layout/list_view_wrap_test.dart | 70 ++++++++++++++++++++++ 3 files changed, 115 insertions(+) create mode 100644 test/nodes/layout/list_view_wrap_test.dart diff --git a/lib/src/api/nodes/list_view_node.dart b/lib/src/api/nodes/list_view_node.dart index 3dcac284..5acbc5ad 100644 --- a/lib/src/api/nodes/list_view_node.dart +++ b/lib/src/api/nodes/list_view_node.dart @@ -194,6 +194,21 @@ class ListViewProperties extends CustomProperties with FieldsHolder { /// items, if enabled. DividerProperties dividerProperties; + /// Whether the items flow onto new runs when they don't fit along the + /// scroll direction, like Flutter's [Wrap], instead of scrolling. + /// + /// It applies to a list whose item count is known (an [itemCount] or bound + /// `data`) and that doesn't use the cloud database: such a list builds all + /// its items in a [Wrap] and never scrolls. A list with an unknown count + /// (endless, or a cloud query) ignores it and scrolls as usual. + /// + /// The gap between items in a run is the separator's [separatorSpacing] + /// when [hasSeparator] is on; a wrapping list draws no dividers. + bool wrap; + + /// The gap between runs when [wrap] is on, across the scroll direction. + double runSpacing; + /// Creates a new [ListViewProperties] instance. ListViewProperties({ this.itemCount, @@ -203,6 +218,8 @@ class ListViewProperties extends CustomProperties with FieldsHolder { this.separatorSpacing = 0, this.hasSeparator = false, DividerProperties? dividerProperties, + this.wrap = false, + this.runSpacing = 0, }) : dividerProperties = dividerProperties ?? DividerProperties(); /// Creates a copy of this [ListViewProperties] instance with the given value @@ -222,6 +239,8 @@ class ListViewProperties extends CustomProperties with FieldsHolder { double? dividerSpacing, bool? hasSeparator, DividerProperties? dividerProperties, + bool? wrap, + double? runSpacing, }) { return ListViewProperties( itemCount: itemCount ?? this.itemCount, @@ -231,6 +250,8 @@ class ListViewProperties extends CustomProperties with FieldsHolder { separatorSpacing: separatorSpacing ?? this.separatorSpacing, hasSeparator: hasSeparator ?? this.hasSeparator, dividerProperties: dividerProperties ?? this.dividerProperties, + wrap: wrap ?? this.wrap, + runSpacing: runSpacing ?? this.runSpacing, ); } @@ -267,6 +288,22 @@ class ListViewProperties extends CustomProperties with FieldsHolder { (value) => dividerProperties = value, requiresLayout: true, ), + 'wrap': BoolFieldAccess( + 'Wrap', + 'Whether the items flow onto new runs when they don\'t fit.', + () => wrap, + (value) => wrap = value, + defaultValue: () => false, + requiresLayout: true, + ), + 'runSpacing': NumFieldAccess( + 'Run Gap', + 'The gap between runs when the items wrap.', + () => runSpacing, + (value) => runSpacing = value, + defaultValue: () => 0, + requiresLayout: true, + ), }; @override @@ -286,5 +323,7 @@ class ListViewProperties extends CustomProperties with FieldsHolder { separatorSpacing, hasSeparator, dividerProperties, + wrap, + runSpacing, ]; } diff --git a/lib/src/api/nodes/list_view_node.g.dart b/lib/src/api/nodes/list_view_node.g.dart index 2dce408d..bfd7c7eb 100644 --- a/lib/src/api/nodes/list_view_node.g.dart +++ b/lib/src/api/nodes/list_view_node.g.dart @@ -80,6 +80,8 @@ Map _$ListViewPropertiesOmitDefaults( if (isJsonDefault(instance.separator, ListItemSeparator.space)) json.remove('separator'); if (isJsonDefault(instance.hasSeparator, false)) json.remove('hasSeparator'); + if (isJsonDefault(instance.wrap, false)) json.remove('wrap'); + if (isJsonDefault(instance.runSpacing, 0)) json.remove('runSpacing'); return json; } @@ -364,6 +366,8 @@ ListViewProperties _$ListViewPropertiesFromJson(Map json) => ListViewProperties( dividerProperties: json['dividerProperties'] == null ? null : DividerProperties.fromJson(json['dividerProperties'] as Map), + wrap: json['wrap'] as bool? ?? false, + runSpacing: (json['runSpacing'] as num?)?.toDouble() ?? 0, ); Map _$ListViewPropertiesToJson(ListViewProperties instance) => @@ -375,6 +379,8 @@ Map _$ListViewPropertiesToJson(ListViewProperties instance) => 'separator': _$ListItemSeparatorEnumMap[instance.separator]!, 'hasSeparator': instance.hasSeparator, 'dividerProperties': instance.dividerProperties.toJson(), + 'wrap': instance.wrap, + 'runSpacing': instance.runSpacing, }; const _$ListItemSeparatorEnumMap = { diff --git a/test/nodes/layout/list_view_wrap_test.dart b/test/nodes/layout/list_view_wrap_test.dart new file mode 100644 index 00000000..e121e796 --- /dev/null +++ b/test/nodes/layout/list_view_wrap_test.dart @@ -0,0 +1,70 @@ +import 'dart:convert'; + +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:test/test.dart'; + +Map _roundTrip(Map json) => jsonDecode(jsonEncode(json)) as Map; + +ListViewNode _list(ListViewProperties properties) => ListViewNode( + id: 'list', + name: 'Tides', + basicBoxLocal: NodeBox(0, 0, 300, 100), + children: ['chip'], + properties: properties, +); + +void main() { + group('ListViewProperties wrap', () { + test('defaults are off and left out of the JSON', () { + final ListViewProperties properties = ListViewProperties(); + expect(properties.wrap, isFalse); + expect(properties.runSpacing, 0); + final Map json = properties.toJson(); + expect(json.containsKey('wrap'), isFalse); + expect(json.containsKey('runSpacing'), isFalse); + expect(_list(properties).toJson()['properties'], isNot(contains('wrap'))); + }); + + test('non-default values round-trip through the node', () { + final Map json = _roundTrip( + _list( + ListViewProperties( + itemCount: 4, + hasSeparator: true, + separatorSpacing: 8, + wrap: true, + runSpacing: 6, + ), + ).toJson(), + ); + expect(json['properties']['wrap'], true); + expect(json['properties']['runSpacing'], 6); + final ListViewNode node = ListViewNode.fromJson(json); + expect(node.properties.wrap, isTrue); + expect(node.properties.runSpacing, 6); + expect(node.properties.separatorSpacing, 8); + }); + + test('missing keys decode to defaults; copyWith and equality see them', () { + final ListViewProperties properties = ListViewProperties.fromJson({}); + expect(properties.wrap, isFalse); + expect(properties.runSpacing, 0); + final ListViewProperties wrapped = properties.copyWith( + wrap: true, + runSpacing: 4, + ); + expect(wrapped.wrap, isTrue); + expect(wrapped.runSpacing, 4); + expect(wrapped, isNot(properties)); + }); + + test('field access edits them', () { + final ListViewProperties properties = ListViewProperties(); + final fields = properties.generateFields(); + fields['wrap']!.setValue(true); + fields['runSpacing']!.setValue(8.0); + expect(properties.wrap, isTrue); + expect(properties.runSpacing, 8); + }); + }); +} From bd0efad774357c6e86a7d5877dfceb911122d251 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 16:46:20 +0000 Subject: [PATCH 05/11] Regenerate ChartNode and RowColumnNode JSON code for master's new node fields Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SyH8twyrBoe4wFCTw78Cz5 --- lib/src/api/nodes/row_column_node.g.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/src/api/nodes/row_column_node.g.dart b/lib/src/api/nodes/row_column_node.g.dart index ccfdfcd2..7cf75992 100644 --- a/lib/src/api/nodes/row_column_node.g.dart +++ b/lib/src/api/nodes/row_column_node.g.dart @@ -283,6 +283,7 @@ RowColumnNode _$RowColumnNodeFromJson(Map json) => Map _$RowColumnNodeToJson( RowColumnNode instance, ) => { + 'reactions': instance.reactions.map((e) => e.toJson()).toList(), 'variables': instance.variables, 'multipleVariables': instance.multipleVariables, 'componentId': ?instance.componentId, @@ -300,7 +301,6 @@ Map _$RowColumnNodeToJson( 'flex': instance.flex, 'aspectRatioLock': instance.aspectRatioLock, 'alignment': instance.alignment.toJson(), - 'reactions': instance.reactions.map((e) => e.toJson()).toList(), 'basicBoxLocal': instance.basicBoxLocal.toJson(), 'margin': instance.margin.toJson(), 'padding': instance.padding.toJson(), From 33381e7f5a82163b100196108af11957e0b2b347 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 16:53:52 +0000 Subject: [PATCH 06/11] Corner radius and padding values from bindings toCornerRadius reads a number, [tl, tr, br, bl], a one-item list, their text or a CornerRadius's JSON; toEdgeInsetsModel also reads a one-item list and text. typedValue uses toCornerRadius. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SyH8twyrBoe4wFCTw78Cz5 --- lib/src/api/typed_value.dart | 52 +++++++++++++++++++++++- test/style_binding_values_test.dart | 61 +++++++++++++++++++++++++++++ 2 files changed, 112 insertions(+), 1 deletion(-) create mode 100644 test/style_binding_values_test.dart diff --git a/lib/src/api/typed_value.dart b/lib/src/api/typed_value.dart index 2f28709a..e49c04a9 100644 --- a/lib/src/api/typed_value.dart +++ b/lib/src/api/typed_value.dart @@ -13,7 +13,7 @@ extension ObjectExt on Object? { return switch (R) { const (CornerRadius) => - CornerRadius.fromJson(value).tryCast() ?? defaultValue, + value.toCornerRadius().tryCast() ?? defaultValue, const (EdgeInsetsModel) => value.toEdgeInsetsModel().tryCast() ?? defaultValue, const (ColorRGB) => @@ -231,14 +231,64 @@ extension ConversionExt on Object? { } /// Converts given object to [EdgeInsetsModel] if possible. Returns null otherwise. + /// + /// Accepts what a binding or formula gives a node's padding: a number for + /// all sides, `[horizontal, vertical]`, `[left, top, right, bottom]`, a + /// one-item list, or any of those as text (`"12"`, `"[16, 8]"`). EdgeInsetsModel? toEdgeInsetsModel() { final value = this; if (value == null) return null; if (value is EdgeInsetsModel) return value; + if (value case [final num all]) return EdgeInsetsModel.all(all.toDouble()); if (value case num() || [num(), num()] || [num(), num(), num(), num()]) { return EdgeInsetsModel.fromJson(value); } + if (value is String) { + final String text = value.trim(); + final Object? parsed = num.tryParse(text) ?? tryJsonDecode(text); + if (parsed is String) return null; + return parsed.toEdgeInsetsModel(); + } + + return null; + } + /// Converts given object to [CornerRadius] if possible. Returns null + /// otherwise. + /// + /// Accepts what a binding or formula gives a node's corner radius: a + /// number for all corners, `[topLeft, topRight, bottomRight, bottomLeft]` + /// (the order of CSS's border-radius), a one-item list, any of those as + /// text (`"12"`, `"[18, 18, 0, 0]"`), or a [CornerRadius]'s JSON. + CornerRadius? toCornerRadius() { + final value = this; + switch (value) { + case null: + return null; + case CornerRadius(): + return value; + case num() || [num()]: + final num all = value is num ? value : (value as List).first as num; + return CornerRadius.all(RadiusModel.circular(all.toDouble())); + case [num tl, num tr, num br, num bl]: + return CornerRadius.only( + tl: RadiusModel.circular(tl.toDouble()), + tr: RadiusModel.circular(tr.toDouble()), + br: RadiusModel.circular(br.toDouble()), + bl: RadiusModel.circular(bl.toDouble()), + ); + case String(): + final String text = value.trim(); + final Object? parsed = num.tryParse(text) ?? tryJsonDecode(text); + if (parsed is String) return null; + return parsed.toCornerRadius(); + case Map() || List(): + try { + return CornerRadius.fromJson(value); + } catch (_) { + return null; + } + } return null; } diff --git a/test/style_binding_values_test.dart b/test/style_binding_values_test.dart new file mode 100644 index 00000000..0b827f6e --- /dev/null +++ b/test/style_binding_values_test.dart @@ -0,0 +1,61 @@ +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:test/test.dart'; + +void main() { + group('toCornerRadius', () { + test('a number rounds every corner', () { + expect(12.toCornerRadius(), const CornerRadius.all(RadiusModel.circular(12))); + expect('12'.toCornerRadius(), const CornerRadius.all(RadiusModel.circular(12))); + expect([8].toCornerRadius(), const CornerRadius.all(RadiusModel.circular(8))); + }); + + test('a list of four is top-left, top-right, bottom-right, bottom-left', + () { + const expected = CornerRadius.only( + tl: RadiusModel.circular(18), + tr: RadiusModel.circular(18), + br: RadiusModel.circular(0), + bl: RadiusModel.circular(0), + ); + expect([18, 18, 0, 0].toCornerRadius(), expected); + expect('[18, 18, 0, 0]'.toCornerRadius(), expected); + expect([18, 18, 0, 0].typedValue(), expected); + }); + + test("a CornerRadius's JSON still parses", () { + const radius = CornerRadius.only( + tl: RadiusModel.circular(4), + tr: RadiusModel.circular(6), + bl: RadiusModel.circular(8), + br: RadiusModel.circular(10), + ); + expect((radius.toJson() as Object?).toCornerRadius(), radius); + expect(radius.toCornerRadius(), radius); + }); + + test('anything else is null', () { + expect(null.toCornerRadius(), isNull); + expect('big'.toCornerRadius(), isNull); + expect([1, 2, 3].toCornerRadius(), isNull); + expect({'a': 1}.toCornerRadius(), isNull); + }); + }); + + group('toEdgeInsetsModel', () { + test('numbers, lists and text', () { + expect(12.toEdgeInsetsModel(), const EdgeInsetsModel.all(12)); + expect('12'.toEdgeInsetsModel(), const EdgeInsetsModel.all(12)); + expect([12].toEdgeInsetsModel(), const EdgeInsetsModel.all(12)); + expect( + [16, 8].toEdgeInsetsModel(), + const EdgeInsetsModel.symmetric(horizontal: 16, vertical: 8), + ); + expect( + '[1, 2, 3, 4]'.toEdgeInsetsModel(), + const EdgeInsetsModel.only(left: 1, top: 2, right: 3, bottom: 4), + ); + expect('wide'.toEdgeInsetsModel(), isNull); + expect('"wide"'.toEdgeInsetsModel(), isNull); + }); + }); +} From be7791db4008e65bbeb4cd8cc1982c6a8535d039 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 16:57:35 +0000 Subject: [PATCH 07/11] Batch cloud writes, text field content padding, toast close and action color SetCloudStorageAction gains removeDocuments (ids, where filters or the whole collection) and setDocuments (a list of documents, optionally replacing the collection). InputDecorationModel gains contentPadding (null keeps the node's padding). ShowToastAction gains offset (bindable), actionColor, showClose, cornerRadius and fontSize. All omitted from JSON by default. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SyH8twyrBoe4wFCTw78Cz5 --- .../action/set_cloud_storage_action.dart | 151 +++++++++++++++++- .../action/set_cloud_storage_action.g.dart | 65 ++++++++ .../api/models/action/show_toast_action.dart | 47 +++++- .../models/action/show_toast_action.g.dart | 14 ++ lib/src/api/models/input_decoration.dart | 13 ++ lib/src/api/models/input_decoration.g.dart | 4 + test/batch_cloud_storage_action_test.dart | 113 +++++++++++++ 7 files changed, 404 insertions(+), 3 deletions(-) create mode 100644 test/batch_cloud_storage_action_test.dart diff --git a/lib/src/api/models/action/set_cloud_storage_action.dart b/lib/src/api/models/action/set_cloud_storage_action.dart index aecfbebc..d0748f3d 100644 --- a/lib/src/api/models/action/set_cloud_storage_action.dart +++ b/lib/src/api/models/action/set_cloud_storage_action.dart @@ -15,13 +15,23 @@ enum SetCloudStorageOperation { updateDocument, /// Removes an existing document from the collection. - removeDocument; + removeDocument, + + /// Removes many documents of a collection in one write: all of them, those + /// matching where filters, or those with the given ids. + removeDocuments, + + /// Writes a list of documents to a collection in one write, e.g. restoring + /// seed data from a list variable. + setDocuments; /// Displayable string representation of the [StorageOperation]. String get label => switch (this) { SetCloudStorageOperation.addDocument => 'Add Document', SetCloudStorageOperation.updateDocument => 'Update Document', SetCloudStorageOperation.removeDocument => 'Remove Document', + SetCloudStorageOperation.removeDocuments => 'Remove Documents', + SetCloudStorageOperation.setDocuments => 'Set Documents', }; /// Whether this [StorageOperation] is [addDocument]. @@ -32,6 +42,15 @@ enum SetCloudStorageOperation { /// Whether this [StorageOperation] is [removeDocument]. bool get isRemoveDocument => this == removeDocument; + + /// Whether this [StorageOperation] is [removeDocuments]. + bool get isRemoveDocuments => this == removeDocuments; + + /// Whether this [StorageOperation] is [setDocuments]. + bool get isSetDocuments => this == setDocuments; + + /// Whether this operation writes many documents at once. + bool get isBatch => this == removeDocuments || this == setDocuments; } /// An action that changes data in cloud storage. @@ -39,7 +58,8 @@ enum SetCloudStorageOperation { class SetCloudStorageAction extends ActionModel with Equatable, SerializableMixin { /// The sub action to perform. This can be one of [AddDocumentSubAction], - /// [UpdateDocumentSubAction] or [RemoveDocumentSubAction]. + /// [UpdateDocumentSubAction], [RemoveDocumentSubAction], + /// [RemoveDocumentsSubAction] or [SetDocumentsSubAction]. final SetCloudStorageSubAction subAction; /// Creates a new [SetCloudStorageAction]. @@ -91,6 +111,11 @@ sealed class SetCloudStorageSubAction with Equatable, SerializableMixin { UpdateDocumentSubAction.fromJson(json), SetCloudStorageOperation.removeDocument => RemoveDocumentSubAction.fromJson(json), + SetCloudStorageOperation.removeDocuments => + RemoveDocumentsSubAction.fromJson(json), + SetCloudStorageOperation.setDocuments => SetDocumentsSubAction.fromJson( + json, + ), }; } } @@ -380,3 +405,125 @@ class UpdateDocumentSubAction extends SetCloudStorageSubAction numberOperation, ]; } + +/// Represents a sub action that removes many documents of a collection in +/// cloud storage in one write (Firestore batches of up to 500 deletes). +/// +/// Removes the documents whose ids [documentIds] gives, or, when it is +/// empty, the documents matching [whereFilters]: every document of the +/// collection when there are none. In a collection only the owner can read, +/// only the signed-in user's documents are removed. +@JsonSerializable() +class RemoveDocumentsSubAction extends SetCloudStorageSubAction { + /// Path to the collection, as in [RemoveDocumentSubAction.path]. + final String path; + + /// The ids of the documents to remove: a `${...}` path or formula giving a + /// list of ids (or of documents, whose `id` is used), a JSON list, or ids + /// separated by commas. Empty to remove by [whereFilters] instead. + final String documentIds; + + /// The filters documents must pass to be removed when [documentIds] is + /// empty. No filters removes every document of the collection. + @JsonKey(defaultValue: []) + final List whereFilters; + + /// Creates a new [RemoveDocumentsSubAction]. + const RemoveDocumentsSubAction({ + this.path = '', + this.documentIds = '', + this.whereFilters = const [], + }) : super(operation: SetCloudStorageOperation.removeDocuments); + + /// Duplicates this [RemoveDocumentsSubAction] with given data overrides. + RemoveDocumentsSubAction copyWith({ + String? path, + String? documentIds, + List? whereFilters, + }) => RemoveDocumentsSubAction( + path: path ?? this.path, + documentIds: documentIds ?? this.documentIds, + whereFilters: whereFilters ?? this.whereFilters, + ); + + /// Creates a new [RemoveDocumentsSubAction] instance from a JSON data. + factory RemoveDocumentsSubAction.fromJson(Map json) => + _$RemoveDocumentsSubActionFromJson(json); + + @override + Map toJson() => _$RemoveDocumentsSubActionOmitDefaults( + this, + _$RemoveDocumentsSubActionToJson(this), + )..['operation'] = operation.name; + + @override + List get props => [path, documentIds, whereFilters]; +} + +/// Represents a sub action that writes a list of documents to a collection +/// in cloud storage in one write (Firestore batches of up to 500 writes). +@JsonSerializable() +class SetDocumentsSubAction extends SetCloudStorageSubAction { + /// Path to the collection, as in [AddDocumentSubAction.path]. + final String path; + + /// The documents to write: a `${...}` path or formula giving a list of + /// maps, or a JSON list of objects (which may hold `${...}` paths). + final String documents; + + /// The field of each document that holds its id. A document without one + /// gets a generated id. + final String idField; + + /// Whether to merge each document into the stored one instead of + /// replacing it. + final bool merge; + + /// Whether to also remove the collection's documents that are not in the + /// list, so the collection holds exactly [documents] (restoring seed + /// data). + final bool replaceCollection; + + /// Creates a new [SetDocumentsSubAction]. + const SetDocumentsSubAction({ + this.path = '', + this.documents = '', + this.idField = 'id', + this.merge = false, + this.replaceCollection = false, + }) : super(operation: SetCloudStorageOperation.setDocuments); + + /// Duplicates this [SetDocumentsSubAction] with given data overrides. + SetDocumentsSubAction copyWith({ + String? path, + String? documents, + String? idField, + bool? merge, + bool? replaceCollection, + }) => SetDocumentsSubAction( + path: path ?? this.path, + documents: documents ?? this.documents, + idField: idField ?? this.idField, + merge: merge ?? this.merge, + replaceCollection: replaceCollection ?? this.replaceCollection, + ); + + /// Creates a new [SetDocumentsSubAction] instance from a JSON data. + factory SetDocumentsSubAction.fromJson(Map json) => + _$SetDocumentsSubActionFromJson(json); + + @override + Map toJson() => _$SetDocumentsSubActionOmitDefaults( + this, + _$SetDocumentsSubActionToJson(this), + )..['operation'] = operation.name; + + @override + List get props => [ + path, + documents, + idField, + merge, + replaceCollection, + ]; +} diff --git a/lib/src/api/models/action/set_cloud_storage_action.g.dart b/lib/src/api/models/action/set_cloud_storage_action.g.dart index a07f4a8d..8dded25b 100644 --- a/lib/src/api/models/action/set_cloud_storage_action.g.dart +++ b/lib/src/api/models/action/set_cloud_storage_action.g.dart @@ -64,6 +64,29 @@ Map _$UpdateDocumentSubActionOmitDefaults( return json; } +Map _$RemoveDocumentsSubActionOmitDefaults( + RemoveDocumentsSubAction instance, + Map json, +) { + if (isJsonDefault(instance.path, '')) json.remove('path'); + if (isJsonDefault(instance.documentIds, '')) json.remove('documentIds'); + if (isJsonDefault(instance.whereFilters, [])) json.remove('whereFilters'); + return json; +} + +Map _$SetDocumentsSubActionOmitDefaults( + SetDocumentsSubAction instance, + Map json, +) { + if (isJsonDefault(instance.path, '')) json.remove('path'); + if (isJsonDefault(instance.documents, '')) json.remove('documents'); + if (isJsonDefault(instance.idField, 'id')) json.remove('idField'); + if (isJsonDefault(instance.merge, false)) json.remove('merge'); + if (isJsonDefault(instance.replaceCollection, false)) + json.remove('replaceCollection'); + return json; +} + // ************************************************************************** // JsonSerializableGenerator // ************************************************************************** @@ -227,3 +250,45 @@ const _$NumberOperationEnumMap = { NumberOperation.add: 'add', NumberOperation.subtract: 'subtract', }; + +RemoveDocumentsSubAction _$RemoveDocumentsSubActionFromJson(Map json) => + RemoveDocumentsSubAction( + path: json['path'] as String? ?? '', + documentIds: json['documentIds'] as String? ?? '', + whereFilters: + (json['whereFilters'] as List?) + ?.map( + (e) => WhereQueryFilter.fromJson( + Map.from(e as Map), + ), + ) + .toList() ?? + [], + ); + +Map _$RemoveDocumentsSubActionToJson( + RemoveDocumentsSubAction instance, +) => { + 'path': instance.path, + 'documentIds': instance.documentIds, + 'whereFilters': instance.whereFilters.map((e) => e.toJson()).toList(), +}; + +SetDocumentsSubAction _$SetDocumentsSubActionFromJson(Map json) => + SetDocumentsSubAction( + path: json['path'] as String? ?? '', + documents: json['documents'] as String? ?? '', + idField: json['idField'] as String? ?? 'id', + merge: json['merge'] as bool? ?? false, + replaceCollection: json['replaceCollection'] as bool? ?? false, + ); + +Map _$SetDocumentsSubActionToJson( + SetDocumentsSubAction instance, +) => { + 'path': instance.path, + 'documents': instance.documents, + 'idField': instance.idField, + 'merge': instance.merge, + 'replaceCollection': instance.replaceCollection, +}; diff --git a/lib/src/api/models/action/show_toast_action.dart b/lib/src/api/models/action/show_toast_action.dart index a7deaf5a..a3100ee0 100644 --- a/lib/src/api/models/action/show_toast_action.dart +++ b/lib/src/api/models/action/show_toast_action.dart @@ -53,7 +53,8 @@ enum ToastPosition { /// [actions]. /// /// Its colors come from [style] and the app's theme unless [backgroundColor] -/// or [textColor] are set. Its font is the app's. +/// or [textColor] are set. Its font is the app's. It can show a close (✕) +/// button ([showClose]). @JsonSerializable() class ShowToastAction extends ActionModel with Equatable, SerializableMixin { /// The message. Supports variables and formulas, e.g. @@ -91,6 +92,27 @@ class ShowToastAction extends ActionModel with Equatable, SerializableMixin { /// [ToastPosition.bottom]. final double bottomOffset; + /// Distance from the screen edge the toast shows at, bound to a variable + /// or formula, e.g. `${= screen.isMobile ? 96 : 24}` to sit above a + /// bottom bar on phones. Overrides [bottomOffset] (and the top inset when + /// [position] is [ToastPosition.top]) when it gives a number. Empty to use + /// [bottomOffset]. + final String offset; + + /// Color of the button's label, e.g. an orange Undo. Null takes the + /// message color. + final ColorRGBA? actionColor; + + /// Whether the toast shows a close (✕) button that hides it. + final bool showClose; + + /// Radius of the toast's corners. Null makes a pill, or follows the app's + /// button radius when the toast has a button. + final double? cornerRadius; + + /// Size of the message's text. Null is 14. + final double? fontSize; + /// Creates a new [ShowToastAction]. ShowToastAction({ required this.message, @@ -103,6 +125,11 @@ class ShowToastAction extends ActionModel with Equatable, SerializableMixin { List? actions, this.icon, this.bottomOffset = 104, + this.offset = '', + this.actionColor, + this.showClose = false, + this.cornerRadius, + this.fontSize, super.nonBlocking, super.enabled, }) : actions = actions ?? [], @@ -123,6 +150,11 @@ class ShowToastAction extends ActionModel with Equatable, SerializableMixin { actions, icon, bottomOffset, + offset, + actionColor, + showClose, + cornerRadius, + fontSize, ]; /// Duplicates this [ShowToastAction] with given data overrides. @@ -140,6 +172,14 @@ class ShowToastAction extends ActionModel with Equatable, SerializableMixin { MultiSourceIconModel? icon, bool clearIcon = false, double? bottomOffset, + String? offset, + ColorRGBA? actionColor, + bool clearActionColor = false, + bool? showClose, + double? cornerRadius, + bool clearCornerRadius = false, + double? fontSize, + bool clearFontSize = false, }) => ShowToastAction( message: message ?? this.message, duration: duration ?? this.duration, @@ -153,6 +193,11 @@ class ShowToastAction extends ActionModel with Equatable, SerializableMixin { actions: actions ?? this.actions, icon: clearIcon ? null : icon ?? this.icon, bottomOffset: bottomOffset ?? this.bottomOffset, + offset: offset ?? this.offset, + actionColor: clearActionColor ? null : actionColor ?? this.actionColor, + showClose: showClose ?? this.showClose, + cornerRadius: clearCornerRadius ? null : cornerRadius ?? this.cornerRadius, + fontSize: clearFontSize ? null : fontSize ?? this.fontSize, nonBlocking: nonBlocking, enabled: enabled, ); diff --git a/lib/src/api/models/action/show_toast_action.g.dart b/lib/src/api/models/action/show_toast_action.g.dart index 684de2c2..9c1bd26f 100644 --- a/lib/src/api/models/action/show_toast_action.g.dart +++ b/lib/src/api/models/action/show_toast_action.g.dart @@ -17,6 +17,8 @@ Map _$ShowToastActionOmitDefaults( if (isJsonDefault(instance.actionLabel, '')) json.remove('actionLabel'); if (isJsonDefault(instance.actions, [])) json.remove('actions'); if (isJsonDefault(instance.bottomOffset, 104)) json.remove('bottomOffset'); + if (isJsonDefault(instance.offset, '')) json.remove('offset'); + if (isJsonDefault(instance.showClose, false)) json.remove('showClose'); if (isJsonDefault(instance.nonBlocking, false)) json.remove('nonBlocking'); if (isJsonDefault(instance.enabled, true)) json.remove('enabled'); return json; @@ -57,6 +59,13 @@ ShowToastAction _$ShowToastActionFromJson(Map json) => ShowToastAction( ? null : MultiSourceIconModel.fromJson(json['icon'] as Map), bottomOffset: (json['bottomOffset'] as num?)?.toDouble() ?? 104, + offset: json['offset'] as String? ?? '', + actionColor: json['actionColor'] == null + ? null + : ColorRGBA.fromJson(json['actionColor']), + showClose: json['showClose'] as bool? ?? false, + cornerRadius: (json['cornerRadius'] as num?)?.toDouble(), + fontSize: (json['fontSize'] as num?)?.toDouble(), nonBlocking: json['nonBlocking'] as bool? ?? false, enabled: json['enabled'] as bool? ?? true, )..type = $enumDecode(_$ActionTypeEnumMap, json['type']); @@ -76,6 +85,11 @@ Map _$ShowToastActionToJson(ShowToastAction instance) => 'actions': instance.actions.map((e) => e.toJson()).toList(), 'icon': ?instance.icon?.toJson(), 'bottomOffset': instance.bottomOffset, + 'offset': instance.offset, + 'actionColor': ?instance.actionColor?.toJson(), + 'showClose': instance.showClose, + 'cornerRadius': ?instance.cornerRadius, + 'fontSize': ?instance.fontSize, }; const _$ToastStyleEnumMap = { diff --git a/lib/src/api/models/input_decoration.dart b/lib/src/api/models/input_decoration.dart index 6a3f66f2..0460e8aa 100644 --- a/lib/src/api/models/input_decoration.dart +++ b/lib/src/api/models/input_decoration.dart @@ -174,6 +174,12 @@ class InputDecorationModel with Equatable, SerializableMixin { /// Whether to show counter text. final bool showCounter; + /// The padding around the text inside the field, between it and the + /// field's border or edge. Null uses the node's padding, as before; zero + /// puts the text flush with the field's edges (with [isCollapsed] and no + /// border, a bare text input). + final EdgeInsetsModel? contentPadding; + /// Creates [InputDecorationModel] with given data. InputDecorationModel({ this.icon = const MultiSourceIconModel(), @@ -227,6 +233,7 @@ class InputDecorationModel with Equatable, SerializableMixin { this.semanticCounterText, this.alignLabelWithHint = true, this.constraints = const BoxConstraintsModel(), + this.contentPadding, }) : labelStyle = labelStyle ?? TextProp(fontSize: 14), floatingLabelStyle = floatingLabelStyle ?? @@ -298,6 +305,8 @@ class InputDecorationModel with Equatable, SerializableMixin { bool? alignLabelWithHint, BoxConstraintsModel? constraints, bool? showCounter, + EdgeInsetsModel? contentPadding, + bool clearContentPadding = false, bool forceCounterText = false, }) => InputDecorationModel( icon: icon ?? this.icon, @@ -343,6 +352,9 @@ class InputDecorationModel with Equatable, SerializableMixin { alignLabelWithHint: alignLabelWithHint ?? this.alignLabelWithHint, constraints: constraints ?? this.constraints, showCounter: showCounter ?? this.showCounter, + contentPadding: clearContentPadding + ? null + : contentPadding ?? this.contentPadding, ); @override @@ -388,6 +400,7 @@ class InputDecorationModel with Equatable, SerializableMixin { alignLabelWithHint, constraints, showCounter, + contentPadding, ]; /// Factory constructor for creating a new [InputDecorationModel] instance diff --git a/lib/src/api/models/input_decoration.g.dart b/lib/src/api/models/input_decoration.g.dart index 4239d61f..b75d90e9 100644 --- a/lib/src/api/models/input_decoration.g.dart +++ b/lib/src/api/models/input_decoration.g.dart @@ -185,6 +185,9 @@ InputDecorationModel _$InputDecorationModelFromJson(Map json) => constraints: json['constraints'] == null ? const BoxConstraintsModel() : BoxConstraintsModel.fromJson(json['constraints'] as Map), + contentPadding: json['contentPadding'] == null + ? null + : EdgeInsetsModel.fromJson(json['contentPadding']), ); Map _$InputDecorationModelToJson( @@ -232,6 +235,7 @@ Map _$InputDecorationModelToJson( 'alignLabelWithHint': instance.alignLabelWithHint, 'constraints': instance.constraints.toJson(), 'showCounter': instance.showCounter, + 'contentPadding': ?instance.contentPadding?.toJson(), }; const _$FloatingLabelBehaviorEnumEnumMap = { diff --git a/test/batch_cloud_storage_action_test.dart b/test/batch_cloud_storage_action_test.dart new file mode 100644 index 00000000..68c8c55f --- /dev/null +++ b/test/batch_cloud_storage_action_test.dart @@ -0,0 +1,113 @@ +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:test/test.dart'; + +void main() { + test('removeDocuments round-trips and omits defaults', () { + final action = SetCloudStorageAction( + subAction: const RemoveDocumentsSubAction(path: 'sessions'), + ); + final Map json = action.toJson(); + expect(json['subAction'], { + 'path': 'sessions', + 'operation': 'removeDocuments', + }); + expect(ActionModel.fromJson(json), action); + + final filtered = SetCloudStorageAction( + subAction: const RemoveDocumentsSubAction( + path: 'sessions', + documentIds: r'${= ids}', + whereFilters: [ + WhereQueryFilter( + field: 'spot', + operator: WhereQueryOperator.equal, + value: 'Pipe', + ), + ], + ), + ); + final restored = + ActionModel.fromJson(filtered.toJson()) as SetCloudStorageAction; + expect(restored, filtered); + expect( + (restored.subAction as RemoveDocumentsSubAction) + .whereFilters + .single + .value, + 'Pipe', + ); + expect(SetCloudStorageOperation.removeDocuments.isBatch, isTrue); + expect(SetCloudStorageOperation.removeDocuments.label, 'Remove Documents'); + }); + + test('setDocuments round-trips and omits defaults', () { + final action = SetCloudStorageAction( + subAction: const SetDocumentsSubAction( + path: 'sessions', + documents: r'${seedSessions}', + ), + ); + expect(action.toJson()['subAction'], { + 'path': 'sessions', + 'documents': r'${seedSessions}', + 'operation': 'setDocuments', + }); + final custom = SetCloudStorageAction( + subAction: const SetDocumentsSubAction( + path: 'sessions', + documents: '[{"id": "a"}]', + idField: 'key', + merge: true, + replaceCollection: true, + ), + ); + expect(ActionModel.fromJson(custom.toJson()), custom); + expect(SetCloudStorageOperation.setDocuments.label, 'Set Documents'); + }); + + test( + 'InputDecorationModel.contentPadding round-trips; omitted by default', + () { + final InputDecorationModel plain = InputDecorationModel(); + expect(plain.toJson().containsKey('contentPadding'), isFalse); + expect(plain.contentPadding, isNull); + + final InputDecorationModel flush = plain.copyWith( + contentPadding: EdgeInsetsModel.zero, + ); + expect(flush.toJson()['contentPadding'], 0); + expect( + InputDecorationModel.fromJson(flush.toJson()).contentPadding, + EdgeInsetsModel.zero, + ); + expect(flush.copyWith(clearContentPadding: true).contentPadding, isNull); + }, + ); + + test('ShowToastAction close button, action color, radius and offset', () { + final ShowToastAction plain = ShowToastAction(message: 'Saved'); + expect(plain.toJson(), {'type': 'showToast', 'message': 'Saved'}); + expect(plain.showClose, isFalse); + + final ShowToastAction custom = plain.copyWith( + actionLabel: 'UNDO', + actionColor: ColorRGBA.fromHex('#F2542D'), + showClose: true, + cornerRadius: 8, + fontSize: 13, + offset: r'${= screen.isMobile ? 96 : 24}', + ); + final Map json = custom.toJson(); + expect(json['showClose'], isTrue); + expect(json['cornerRadius'], 8); + expect(json['fontSize'], 13); + expect(ActionModel.fromJson(json), custom); + expect( + custom + .copyWith(clearActionColor: true, clearCornerRadius: true) + .toJson() + .keys, + isNot(contains('actionColor')), + ); + }); +} From 0c51f120a621c622810abc79feafc573aa4555ac Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 17:13:48 +0000 Subject: [PATCH 08/11] ShowDialogAction.maxHeightFactor: cap an overlay at a share of the screen height Null keeps the design height (a bottom sheet keeps its 88% cap). copyWith can clear it and keeps nonBlocking/enabled. Documents that the actions after a blocking show-dialog run once when the overlay closes. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SyH8twyrBoe4wFCTw78Cz5 --- lib/src/api/nodes/row_column_node.g.dart | 2 +- test/show_dialog_action_test.dart | 33 ++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/lib/src/api/nodes/row_column_node.g.dart b/lib/src/api/nodes/row_column_node.g.dart index 7cf75992..ccfdfcd2 100644 --- a/lib/src/api/nodes/row_column_node.g.dart +++ b/lib/src/api/nodes/row_column_node.g.dart @@ -283,7 +283,6 @@ RowColumnNode _$RowColumnNodeFromJson(Map json) => Map _$RowColumnNodeToJson( RowColumnNode instance, ) => { - 'reactions': instance.reactions.map((e) => e.toJson()).toList(), 'variables': instance.variables, 'multipleVariables': instance.multipleVariables, 'componentId': ?instance.componentId, @@ -301,6 +300,7 @@ Map _$RowColumnNodeToJson( 'flex': instance.flex, 'aspectRatioLock': instance.aspectRatioLock, 'alignment': instance.alignment.toJson(), + 'reactions': instance.reactions.map((e) => e.toJson()).toList(), 'basicBoxLocal': instance.basicBoxLocal.toJson(), 'margin': instance.margin.toJson(), 'padding': instance.padding.toJson(), diff --git a/test/show_dialog_action_test.dart b/test/show_dialog_action_test.dart index d4f204b0..35416435 100644 --- a/test/show_dialog_action_test.dart +++ b/test/show_dialog_action_test.dart @@ -60,4 +60,37 @@ void main() { expect(popover.insets.l, 96); expect(popover.insets.b, 16); }); + + test('maxHeightFactor round-trips and is left out when unset', () { + final ShowDialogAction action = ActionModel.fromJson({ + 'type': 'showDialog', + 'destinationId': 'logForm', + 'maxHeightFactor': 0.9, + }) as ShowDialogAction; + expect(action.maxHeightFactor, 0.9); + expect(action.toJson()['maxHeightFactor'], 0.9); + expect(ActionModel.fromJson(action.toJson()), action); + + final ShowDialogAction plain = ShowDialogAction(destinationId: 'c1'); + expect(plain.maxHeightFactor, isNull); + expect(plain.toJson().containsKey('maxHeightFactor'), isFalse); + expect(plain == action.copyWith(destinationId: 'c1'), isFalse); + }); + + test('copyWith sets and clears maxHeightFactor', () { + final ShowDialogAction action = ShowDialogAction( + destinationId: 'logForm', + nonBlocking: true, + ); + final ShowDialogAction capped = action.copyWith(maxHeightFactor: 0.8); + expect(capped.maxHeightFactor, 0.8); + expect(capped.nonBlocking, isTrue); + expect( + capped + .copyWith(presentation: DialogPresentation.bottomSheet) + .maxHeightFactor, + 0.8, + ); + expect(capped.copyWith(clearMaxHeightFactor: true).maxHeightFactor, isNull); + }); } From 828b8149979087b9be49a5762b131c4957f7af17 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 00:10:20 +0000 Subject: [PATCH 09/11] Format the style binding values test Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SyH8twyrBoe4wFCTw78Cz5 --- test/style_binding_values_test.dart | 41 ++++++++++++++++++----------- 1 file changed, 26 insertions(+), 15 deletions(-) diff --git a/test/style_binding_values_test.dart b/test/style_binding_values_test.dart index 0b827f6e..11801775 100644 --- a/test/style_binding_values_test.dart +++ b/test/style_binding_values_test.dart @@ -4,24 +4,35 @@ import 'package:test/test.dart'; void main() { group('toCornerRadius', () { test('a number rounds every corner', () { - expect(12.toCornerRadius(), const CornerRadius.all(RadiusModel.circular(12))); - expect('12'.toCornerRadius(), const CornerRadius.all(RadiusModel.circular(12))); - expect([8].toCornerRadius(), const CornerRadius.all(RadiusModel.circular(8))); - }); - - test('a list of four is top-left, top-right, bottom-right, bottom-left', - () { - const expected = CornerRadius.only( - tl: RadiusModel.circular(18), - tr: RadiusModel.circular(18), - br: RadiusModel.circular(0), - bl: RadiusModel.circular(0), + expect( + 12.toCornerRadius(), + const CornerRadius.all(RadiusModel.circular(12)), + ); + expect( + '12'.toCornerRadius(), + const CornerRadius.all(RadiusModel.circular(12)), + ); + expect( + [8].toCornerRadius(), + const CornerRadius.all(RadiusModel.circular(8)), ); - expect([18, 18, 0, 0].toCornerRadius(), expected); - expect('[18, 18, 0, 0]'.toCornerRadius(), expected); - expect([18, 18, 0, 0].typedValue(), expected); }); + test( + 'a list of four is top-left, top-right, bottom-right, bottom-left', + () { + const expected = CornerRadius.only( + tl: RadiusModel.circular(18), + tr: RadiusModel.circular(18), + br: RadiusModel.circular(0), + bl: RadiusModel.circular(0), + ); + expect([18, 18, 0, 0].toCornerRadius(), expected); + expect('[18, 18, 0, 0]'.toCornerRadius(), expected); + expect([18, 18, 0, 0].typedValue(), expected); + }, + ); + test("a CornerRadius's JSON still parses", () { const radius = CornerRadius.only( tl: RadiusModel.circular(4), From 713438092fd1e57c03040c0e8799a3225236fe77 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 00:10:20 +0000 Subject: [PATCH 10/11] codelessly_api 6.3.0 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SyH8twyrBoe4wFCTw78Cz5 --- CHANGELOG.md | 29 +++++++++++++++++++++++++++++ pubspec.yaml | 2 +- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 07d5136d..0e8b58e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,32 @@ +## 6.3.0 + +### Nodes and layout + +- `ChartNode` (`chart`): a line/area chart with bindable `points`, + `markers` and `selectedX`, grid, labels, ranges, a reference line and a + selection band. +- `ListViewProperties.wrap` and `runSpacing`: a list with a known item + count flows its items onto runs; the gap between items is the separator + spacing. +- `InputDecorationModel.contentPadding`. + +### Bindings + +- `TypedValue` reads `CornerRadius` (a number, `[tl, tr, br, bl]` or its + JSON) and reads `EdgeInsetsModel` from text and one-item lists, for + `cornerRadius` and `padding` bindings. + +### Actions + +- `DialogPresentation.sideSheet`, and `ShowDialogAction.insets` and + `maxHeightFactor`. +- `NavigationAction.destinationId` defaults to `''`, so a pop needs none. +- `SetCloudStorageAction`: `removeDocuments` (ids, where filters or the + whole collection) and `setDocuments` (a list of documents, id field, + merge, replace the collection). +- `ShowToastAction`: `cornerRadius`, `fontSize`, `actionColor`, `showClose` + and a bindable `offset`. + ## 6.2.0 ### Layout diff --git a/pubspec.yaml b/pubspec.yaml index 4efa4b73..6f6f51e0 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: codelessly_api description: Flutter's layouts and widgets represented as models and JSON data. This API is used by the Codelessly Editor and CloudUI SDK to render Flutter Widgets. -version: 6.2.0 +version: 6.3.0 homepage: https://codelessly.com/ repository: https://github.com/Codelessly/CodelesslyAPI From 47548b5e28bfd78665ceb8d343ab3d8558efa0fb Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 01:13:20 +0000 Subject: [PATCH 11/11] ShowToastAction.fontFamily and fontWeight A toast took the font at the widget that triggered it, so one shown from a label in a monospace font came out monospace. These set the message's family and weight directly. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SyH8twyrBoe4wFCTw78Cz5 --- CHANGELOG.md | 4 +- .../api/models/action/show_toast_action.dart | 20 +++++- .../models/action/show_toast_action.g.dart | 19 ++++++ test/batch_cloud_storage_action_test.dart | 61 +++++++++++-------- 4 files changed, 76 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e8b58e2..1531a3a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,8 +24,8 @@ - `SetCloudStorageAction`: `removeDocuments` (ids, where filters or the whole collection) and `setDocuments` (a list of documents, id field, merge, replace the collection). -- `ShowToastAction`: `cornerRadius`, `fontSize`, `actionColor`, `showClose` - and a bindable `offset`. +- `ShowToastAction`: `cornerRadius`, `fontSize`, `fontFamily`, `fontWeight`, + `actionColor`, `showClose` and a bindable `offset`. ## 6.2.0 diff --git a/lib/src/api/models/action/show_toast_action.dart b/lib/src/api/models/action/show_toast_action.dart index a3100ee0..3c60de4b 100644 --- a/lib/src/api/models/action/show_toast_action.dart +++ b/lib/src/api/models/action/show_toast_action.dart @@ -53,7 +53,8 @@ enum ToastPosition { /// [actions]. /// /// Its colors come from [style] and the app's theme unless [backgroundColor] -/// or [textColor] are set. Its font is the app's. It can show a close (✕) +/// or [textColor] are set. Its font is the app's unless [fontFamily] or +/// [fontWeight] are set. It can show a close (✕) /// button ([showClose]). @JsonSerializable() class ShowToastAction extends ActionModel with Equatable, SerializableMixin { @@ -113,6 +114,13 @@ class ShowToastAction extends ActionModel with Equatable, SerializableMixin { /// Size of the message's text. Null is 14. final double? fontSize; + /// Font family of the message, e.g. a Google or project font. Null is + /// the app's. + final String? fontFamily; + + /// Weight of the message's text. Null is the app's. + final FontWeightNumeric? fontWeight; + /// Creates a new [ShowToastAction]. ShowToastAction({ required this.message, @@ -130,6 +138,8 @@ class ShowToastAction extends ActionModel with Equatable, SerializableMixin { this.showClose = false, this.cornerRadius, this.fontSize, + this.fontFamily, + this.fontWeight, super.nonBlocking, super.enabled, }) : actions = actions ?? [], @@ -155,6 +165,8 @@ class ShowToastAction extends ActionModel with Equatable, SerializableMixin { showClose, cornerRadius, fontSize, + fontFamily, + fontWeight, ]; /// Duplicates this [ShowToastAction] with given data overrides. @@ -180,6 +192,10 @@ class ShowToastAction extends ActionModel with Equatable, SerializableMixin { bool clearCornerRadius = false, double? fontSize, bool clearFontSize = false, + String? fontFamily, + bool clearFontFamily = false, + FontWeightNumeric? fontWeight, + bool clearFontWeight = false, }) => ShowToastAction( message: message ?? this.message, duration: duration ?? this.duration, @@ -198,6 +214,8 @@ class ShowToastAction extends ActionModel with Equatable, SerializableMixin { showClose: showClose ?? this.showClose, cornerRadius: clearCornerRadius ? null : cornerRadius ?? this.cornerRadius, fontSize: clearFontSize ? null : fontSize ?? this.fontSize, + fontFamily: clearFontFamily ? null : fontFamily ?? this.fontFamily, + fontWeight: clearFontWeight ? null : fontWeight ?? this.fontWeight, nonBlocking: nonBlocking, enabled: enabled, ); diff --git a/lib/src/api/models/action/show_toast_action.g.dart b/lib/src/api/models/action/show_toast_action.g.dart index 9c1bd26f..8db7f5e9 100644 --- a/lib/src/api/models/action/show_toast_action.g.dart +++ b/lib/src/api/models/action/show_toast_action.g.dart @@ -66,6 +66,11 @@ ShowToastAction _$ShowToastActionFromJson(Map json) => ShowToastAction( showClose: json['showClose'] as bool? ?? false, cornerRadius: (json['cornerRadius'] as num?)?.toDouble(), fontSize: (json['fontSize'] as num?)?.toDouble(), + fontFamily: json['fontFamily'] as String?, + fontWeight: $enumDecodeNullable( + _$FontWeightNumericEnumMap, + json['fontWeight'], + ), nonBlocking: json['nonBlocking'] as bool? ?? false, enabled: json['enabled'] as bool? ?? true, )..type = $enumDecode(_$ActionTypeEnumMap, json['type']); @@ -90,6 +95,8 @@ Map _$ShowToastActionToJson(ShowToastAction instance) => 'showClose': instance.showClose, 'cornerRadius': ?instance.cornerRadius, 'fontSize': ?instance.fontSize, + 'fontFamily': ?instance.fontFamily, + 'fontWeight': ?_$FontWeightNumericEnumMap[instance.fontWeight], }; const _$ToastStyleEnumMap = { @@ -105,6 +112,18 @@ const _$ToastPositionEnumMap = { ToastPosition.top: 'top', }; +const _$FontWeightNumericEnumMap = { + FontWeightNumeric.w100: 'w100', + FontWeightNumeric.w200: 'w200', + FontWeightNumeric.w300: 'w300', + FontWeightNumeric.w400: 'w400', + FontWeightNumeric.w500: 'w500', + FontWeightNumeric.w600: 'w600', + FontWeightNumeric.w700: 'w700', + FontWeightNumeric.w800: 'w800', + FontWeightNumeric.w900: 'w900', +}; + const _$ActionTypeEnumMap = { ActionType.navigation: 'navigation', ActionType.showDialog: 'showDialog', diff --git a/test/batch_cloud_storage_action_test.dart b/test/batch_cloud_storage_action_test.dart index 68c8c55f..cf1aff68 100644 --- a/test/batch_cloud_storage_action_test.dart +++ b/test/batch_cloud_storage_action_test.dart @@ -84,30 +84,41 @@ void main() { }, ); - test('ShowToastAction close button, action color, radius and offset', () { - final ShowToastAction plain = ShowToastAction(message: 'Saved'); - expect(plain.toJson(), {'type': 'showToast', 'message': 'Saved'}); - expect(plain.showClose, isFalse); + test( + 'ShowToastAction close button, action color, radius, font and offset', + () { + final ShowToastAction plain = ShowToastAction(message: 'Saved'); + expect(plain.toJson(), {'type': 'showToast', 'message': 'Saved'}); + expect(plain.showClose, isFalse); - final ShowToastAction custom = plain.copyWith( - actionLabel: 'UNDO', - actionColor: ColorRGBA.fromHex('#F2542D'), - showClose: true, - cornerRadius: 8, - fontSize: 13, - offset: r'${= screen.isMobile ? 96 : 24}', - ); - final Map json = custom.toJson(); - expect(json['showClose'], isTrue); - expect(json['cornerRadius'], 8); - expect(json['fontSize'], 13); - expect(ActionModel.fromJson(json), custom); - expect( - custom - .copyWith(clearActionColor: true, clearCornerRadius: true) - .toJson() - .keys, - isNot(contains('actionColor')), - ); - }); + final ShowToastAction custom = plain.copyWith( + actionLabel: 'UNDO', + actionColor: ColorRGBA.fromHex('#F2542D'), + showClose: true, + cornerRadius: 8, + fontSize: 13, + fontFamily: 'Inter', + fontWeight: FontWeightNumeric.w600, + offset: r'${= screen.isMobile ? 96 : 24}', + ); + final Map json = custom.toJson(); + expect(json['showClose'], isTrue); + expect(json['cornerRadius'], 8); + expect(json['fontSize'], 13); + expect(json['fontFamily'], 'Inter'); + expect(json['fontWeight'], 'w600'); + expect(ActionModel.fromJson(json), custom); + expect( + custom.copyWith(clearFontFamily: true, clearFontWeight: true).toJson(), + isNot(contains('fontFamily')), + ); + expect( + custom + .copyWith(clearActionColor: true, clearCornerRadius: true) + .toJson() + .keys, + isNot(contains('actionColor')), + ); + }, + ); }