From a26a5a71c5cdcb0e2036a7b4c44973b737b8751c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 22:36:47 +0000 Subject: [PATCH 01/19] Clamp text span ranges to the text A StartEndProp past the end of its text (left by an edit that shortened the text) threw a RangeError on every layout and paint, which blanked the editor canvas and blocked even deleting the node. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SyH8twyrBoe4wFCTw78Cz5 --- lib/src/transformers/utils/text_utils.dart | 5 +++- test/text_span_range_test.dart | 27 ++++++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) create mode 100644 test/text_span_range_test.dart diff --git a/lib/src/transformers/utils/text_utils.dart b/lib/src/transformers/utils/text_utils.dart index 127c6811..eeee3395 100644 --- a/lib/src/transformers/utils/text_utils.dart +++ b/lib/src/transformers/utils/text_utils.dart @@ -31,8 +31,11 @@ class TextUtils { String characters = rawText; // Get substring from the raw text for the given start and end positions. + // A range past the text (left by an edit that shortened it) is clamped, + // so one bad range can't throw on every layout and paint. if (prop is StartEndProp) { - characters = rawText.substring(prop.start, prop.end); + final int end = prop.end.clamp(0, rawText.length); + characters = rawText.substring(prop.start.clamp(0, end), end); } return buildTextSpan( diff --git a/test/text_span_range_test.dart b/test/text_span_range_test.dart new file mode 100644 index 00000000..d62d65b4 --- /dev/null +++ b/test/text_span_range_test.dart @@ -0,0 +1,27 @@ +import 'package:flutter/widgets.dart'; +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:codelessly_sdk/codelessly_sdk.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + TextSpan span(String text, int start, int end) => + TextUtils.buildTextSpanForProp( + null, + text, + node: null, + prop: StartEndProp(start: start, end: end), + tapGestureRecognizer: null, + variablesOverrides: const [], + nullSubstitutionMode: NullSubstitutionMode.noChange, + replaceVariableWithSymbol: false, + ); + + test('a range inside the text takes its characters', () { + expect(span('Swell report', 0, 5).toPlainText(), 'Swell'); + }); + + test('a range past the end of the text is clamped instead of throwing', () { + expect(span('Swell', 0, 9).toPlainText(), 'Swell'); + expect(span('Swell', 7, 9).toPlainText(), ''); + }); +} From af62b87aebdf640eff74ae521346826ebc4425e3 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 22:46:58 +0000 Subject: [PATCH 02/19] Render ChartNode with a CustomPaint transformer PassiveChartTransformer paints the chart like the surf reference app's tide card: grid and labels, shaded ranges, curve and fill, markers, a dashed reference line, and the selection band, line and dot. Points, markers and selectedX read their bound variables (paths or formulas). A tap or horizontal drag snaps and clamps the x under the pointer, writes the bound selectedX variable (or the node value, or local state), sets the node state so actions read it as ${value}, and fires changed. Needs codelessly_api with ChartNode. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SyH8twyrBoe4wFCTw78Cz5 --- .../defaults/default_properties.dart | 3 + .../node_transformers/node_transformers.dart | 1 + .../passive_chart_transformer.dart | 502 ++++++++++++++++++ .../passive_transformer_manager.dart | 1 + test/chart_test.dart | 229 ++++++++ test/transformers_test.dart | 1 + 6 files changed, 737 insertions(+) create mode 100644 lib/src/transformers/node_transformers/passive_chart_transformer.dart create mode 100644 test/chart_test.dart diff --git a/lib/src/transformers/defaults/default_properties.dart b/lib/src/transformers/defaults/default_properties.dart index 0e688793..ecc2fd0b 100644 --- a/lib/src/transformers/defaults/default_properties.dart +++ b/lib/src/transformers/defaults/default_properties.dart @@ -262,6 +262,9 @@ ProgressBarProperties get defaultProgressBarProperties => ProgressBarProperties( cornerRadius: ProgressBarDefaults.cornerRadius, ); +/// A new chart's properties: a sample curve, so it looks like a chart. +ChartProperties get defaultChartProperties => ChartProperties.sample(); + TabBarProperties get defaultTabBarProperties => TabBarProperties( dividerColor: TabBarDefaults.dividerColor.toColorRGBA(), indicatorColor: TabBarDefaults.indicatorColor.toColorRGBA(), diff --git a/lib/src/transformers/node_transformers/node_transformers.dart b/lib/src/transformers/node_transformers/node_transformers.dart index 8421a1e2..682441f2 100644 --- a/lib/src/transformers/node_transformers/node_transformers.dart +++ b/lib/src/transformers/node_transformers/node_transformers.dart @@ -5,6 +5,7 @@ export 'passive_app_bar_transformer.dart'; export 'passive_badge_transformer.dart'; export 'passive_button_transformer.dart'; export 'passive_canvas_transformer.dart'; +export 'passive_chart_transformer.dart'; export 'passive_checkbox_transformer.dart'; export 'passive_chip_transformer.dart'; export 'passive_divider_transformer.dart'; diff --git a/lib/src/transformers/node_transformers/passive_chart_transformer.dart b/lib/src/transformers/node_transformers/passive_chart_transformer.dart new file mode 100644 index 00000000..ccacfcb1 --- /dev/null +++ b/lib/src/transformers/node_transformers/passive_chart_transformer.dart @@ -0,0 +1,502 @@ +import 'dart:math' as math; + +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import '../../../codelessly_sdk.dart'; +import '../utils/node_state_provider.dart'; + +class PassiveChartTransformer extends NodeWidgetTransformer { + PassiveChartTransformer(super.getNode, super.manager); + + @override + Widget buildWidget( + ChartNode node, + BuildContext context, + WidgetBuildSettings settings, + ) { + return PassiveChartWidget(node: node, settings: settings); + } +} + +/// Paints a [ChartNode] and lets the user select an x value on it. +/// +/// The points, markers and selection come from the variables bound to +/// `points`, `markers` and `selectedX` when there are any, and from the +/// node otherwise. Selecting writes `selectedX` and fires +/// [TriggerType.changed] with the selected value, which actions read as +/// `${value}`. +class PassiveChartWidget extends StatefulWidget { + final ChartNode node; + final WidgetBuildSettings settings; + final List variablesOverrides; + + /// Whether tapping or dragging selects. False paints the chart only, as + /// on the editor's canvas. + final bool interactive; + + const PassiveChartWidget({ + super.key, + required this.node, + required this.settings, + this.variablesOverrides = const [], + this.interactive = true, + }); + + @override + State createState() => _PassiveChartWidgetState(); +} + +class _PassiveChartWidgetState extends State { + /// The selection made on this chart while no variable or node value holds + /// it. + double? localSelection; + + /// The selection painted last, so a drag fires only when it changes. + double? shownSelection; + + /// Bumped when a font finishes loading, so labels paint with it. + int fontsGeneration = 0; + + @override + void initState() { + super.initState(); + PaintingBinding.instance.systemFonts.addListener(onFontsChanged); + } + + @override + void didUpdateWidget(covariant PassiveChartWidget oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.node.selectedX != widget.node.selectedX) { + localSelection = null; + } + } + + @override + void dispose() { + PaintingBinding.instance.systemFonts.removeListener(onFontsChanged); + super.dispose(); + } + + void onFontsChanged() { + if (mounted) setState(() => fontsGeneration++); + } + + ChartNode get node => widget.node; + + ChartProperties get props => widget.node.properties; + + void select(BuildContext context, Offset position) { + final Size? size = context.size; + if (size == null || size.width <= 0 || props.xMax == props.xMin) return; + final double x = + props.xMin + position.dx / size.width * (props.xMax - props.xMin); + final double selected = props.snapSelection(x); + if (selected == shownSelection) return; + shownSelection = selected; + setState(() => localSelection = selected); + + // Whole values read as `7`, not `7.0`, in variables and actions. + final num value = selected == selected.roundToDouble() + ? selected.toInt() + : selected; + NodeStateProvider.setState(context, value); + if (!FunctionsRepository.setPropertyVariable( + context, + node: node, + property: 'selectedX', + value: value.toString(), + )) { + FunctionsRepository.setNodeValue( + context, + node: node, + property: 'selectedX', + value: selected, + ); + } + FunctionsRepository.triggerAction( + context, + node: node, + TriggerType.changed, + value: value, + ); + } + + @override + Widget build(BuildContext context) { + final ScopedValues scopedValues = ScopedValues.of( + context, + variablesOverrides: widget.variablesOverrides, + ); + final List points = chartPoints(node, scopedValues); + final List markers = chartMarkers(node, scopedValues); + final double? selectedX = + PropertyValueDelegate.getPropertyValue( + node, + 'selectedX', + scopedValues: scopedValues, + ) ?? + localSelection ?? + node.selectedX; + shownSelection = selectedX; + + final Widget paint = CustomPaint( + size: node.basicBoxLocal.size.flutterSize, + painter: ChartPainter( + properties: props, + points: points, + markers: markers, + selectedX: selectedX, + textScaler: + MediaQuery.maybeTextScalerOf(context) ?? TextScaler.noScaling, + fontsGeneration: fontsGeneration, + ), + ); + + if (!widget.interactive || !props.selectable) { + return AdaptiveNodeBox(node: node, child: paint); + } + + return AdaptiveNodeBox( + node: node, + child: MouseRegion( + cursor: SystemMouseCursors.resizeLeftRight, + child: Builder( + builder: (context) => GestureDetector( + behavior: HitTestBehavior.opaque, + onTapDown: (details) => select(context, details.localPosition), + onHorizontalDragStart: (details) => + select(context, details.localPosition), + onHorizontalDragUpdate: (details) => + select(context, details.localPosition), + child: paint, + ), + ), + ), + ); + } +} + +/// The points of [node]'s chart: the list bound to `points`, or the node's. +List chartPoints(ChartNode node, ScopedValues scopedValues) { + final List? bound = node.variables['points'] == null + ? null + : PropertyValueDelegate.getPropertyValue( + node, + 'points', + scopedValues: scopedValues, + ); + if (bound == null) return node.properties.points; + return [ + for (final Object? value in bound) + if (value.typedValue() case final double number) number, + ]; +} + +/// The markers of [node]'s chart: the list bound to `markers`, or the +/// node's. Bound markers are maps shaped like [ChartMarker]'s JSON. +List chartMarkers(ChartNode node, ScopedValues scopedValues) { + final List? bound = node.variables['markers'] == null + ? null + : PropertyValueDelegate.getPropertyValue( + node, + 'markers', + scopedValues: scopedValues, + ); + if (bound == null) return node.properties.markers; + final List markers = []; + for (final Object? value in bound) { + if (value is! Map) continue; + try { + markers.add(ChartMarker.fromJson(value)); + } catch (_) { + // Skip markers that aren't shaped like one. + } + } + return markers; +} + +/// Paints a line/area chart as described by [properties]. +class ChartPainter extends CustomPainter { + final ChartProperties properties; + final List points; + final List markers; + final double? selectedX; + final TextScaler textScaler; + final int fontsGeneration; + + ChartPainter({ + required this.properties, + required this.points, + required this.markers, + required this.selectedX, + this.textScaler = TextScaler.noScaling, + this.fontsGeneration = 0, + }); + + ChartProperties get p => properties; + + @override + void paint(Canvas canvas, Size size) { + if (size.isEmpty) return; + canvas.save(); + canvas.clipRect(Offset.zero & size); + + final double labelHeight = math.max(0, p.labelHeight); + final double chartHeight = math.max(0, size.height - labelHeight); + final double span = p.xMax - p.xMin; + double x(double value) => + span == 0 ? 0 : (value - p.xMin) / span * size.width; + + double low = p.yMin ?? (points.isEmpty ? 0 : points.reduce(math.min)); + double high = p.yMax ?? (points.isEmpty ? 1 : points.reduce(math.max)); + if (high == low) { + low -= 1; + high += 1; + } + final double inset = p.plotInset; + double y(double value) => + inset + (1 - (value - low) / (high - low)) * (chartHeight - inset * 2); + + // Grid and its labels. + final double? gridStep = p.gridStepX; + if (gridStep != null && gridStep > 0 && span > 0) { + final Paint grid = Paint() + ..color = p.gridColor.toFlutterColor() + ..strokeWidth = 1; + final int lines = (span / gridStep + 1e-9).floor(); + for (int i = 0; i <= lines; i++) { + final double value = p.xMin + i * gridStep; + final double dx = x(value); + canvas.drawLine(Offset(dx, 0), Offset(dx, chartHeight), grid); + if (p.xLabels.isEmpty && i > 0 && value < p.xMax - 1e-9) { + final String label = i - 1 < p.gridLabels.length + ? p.gridLabels[i - 1] + : formatChartNumber(value); + paintLabel(canvas, label, dx, chartHeight, labelHeight); + } + } + } + for (final ChartLabel label in p.xLabels) { + paintLabel(canvas, label.label, x(label.x), chartHeight, labelHeight); + } + if (p.axisColor case final ColorRGBA axisColor) { + canvas.drawLine( + Offset(0, chartHeight), + Offset(size.width, chartHeight), + Paint() + ..color = axisColor.toFlutterColor() + ..strokeWidth = 1, + ); + } + + for (final ChartRange range in p.ranges) { + canvas.drawRect( + Rect.fromLTRB(x(range.from), 0, x(range.to), chartHeight), + Paint()..color = range.color.toFlutterColor(), + ); + } + + // Curve and the area under it. + if (points.isNotEmpty) { + final List offsets = [ + for (int i = 0; i < points.length; i++) + Offset( + points.length == 1 ? 0 : x(p.xMin + span * i / (points.length - 1)), + y(points[i]), + ), + ]; + final Path line = Path()..moveTo(offsets.first.dx, offsets.first.dy); + for (int i = 1; i < offsets.length; i++) { + if (p.smooth) { + final Offset previous = offsets[i - 1]; + final Offset current = offsets[i]; + final double midX = (previous.dx + current.dx) / 2; + line.cubicTo( + midX, + previous.dy, + midX, + current.dy, + current.dx, + current.dy, + ); + } else { + line.lineTo(offsets[i].dx, offsets[i].dy); + } + } + if (p.fillColor case final ColorRGBA fillColor) { + final Path area = Path.from(line) + ..lineTo(offsets.last.dx, chartHeight) + ..lineTo(offsets.first.dx, chartHeight) + ..close(); + canvas.drawPath(area, Paint()..color = fillColor.toFlutterColor()); + } + if (p.lineWidth > 0) { + canvas.drawPath( + line, + Paint() + ..color = p.lineColor.toFlutterColor() + ..style = PaintingStyle.stroke + ..strokeWidth = p.lineWidth + ..strokeJoin = StrokeJoin.round, + ); + } + } + + for (final ChartMarker marker in markers) { + final double? value = marker.y ?? p.valueAt(marker.x, points); + if (value == null) continue; + final Offset dot = Offset(x(marker.x), y(value)); + final Color color = marker.color.toFlutterColor(); + canvas.drawCircle(dot, p.markerRadius, Paint()..color = color); + if (marker.label.isEmpty) continue; + final TextPainter text = layoutText( + marker.label, + p.markerLabelStyle, + color: color, + ); + final double dx = (dot.dx - text.width / 2).clamp( + 0.0, + math.max(0.0, size.width - text.width), + ); + final double dy = switch (marker.position) { + ChartMarkerPosition.above => + dot.dy - p.markerRadius - p.markerLabelStyle.fontSize * 1.3, + ChartMarkerPosition.below => dot.dy + p.markerRadius + 1.5, + }; + text.paint(canvas, Offset(dx, dy)); + text.dispose(); + } + + if (p.referenceX case final double referenceX) { + final double dx = x(referenceX); + final Paint paint = Paint() + ..color = p.referenceColor.toFlutterColor() + ..strokeWidth = p.referenceWidth; + if (p.referenceDashed) { + for (double dy = 0; dy < chartHeight; dy += 6) { + canvas.drawLine( + Offset(dx, dy), + Offset(dx, math.min(chartHeight, dy + 3)), + paint, + ); + } + } else { + canvas.drawLine(Offset(dx, 0), Offset(dx, chartHeight), paint); + } + } + + if (selectedX case final double selected) { + final double step = math.max(0, p.selectionStep); + if (step > 0) { + canvas.drawRect( + Rect.fromLTRB(x(selected), 0, x(selected + step), chartHeight), + Paint()..color = p.selectionColor.toFlutterColor(), + ); + } + final double center = selected + step / 2; + final double dx = x(center); + if (p.selectionLineWidth > 0) { + canvas.drawLine( + Offset(dx, 0), + Offset(dx, chartHeight), + Paint() + ..color = p.selectionLineColor.toFlutterColor() + ..strokeWidth = p.selectionLineWidth, + ); + } + final double? value = p.valueAt(center, points); + if (value != null && p.selectionDotRadius > 0) { + final Offset dot = Offset(dx, y(value)); + canvas.drawCircle( + dot, + p.selectionDotRadius, + Paint()..color = p.selectionDotColor.toFlutterColor(), + ); + if (p.selectionDotStrokeWidth > 0) { + canvas.drawCircle( + dot, + p.selectionDotRadius, + Paint() + ..color = p.selectionDotStrokeColor.toFlutterColor() + ..style = PaintingStyle.stroke + ..strokeWidth = p.selectionDotStrokeWidth, + ); + } + } + } + + canvas.restore(); + } + + void paintLabel( + Canvas canvas, + String label, + double dx, + double chartHeight, + double labelHeight, + ) { + if (labelHeight <= 0 || label.isEmpty) return; + final TextPainter text = layoutText(label, p.labelStyle); + text.paint(canvas, Offset(dx - text.width / 2, chartHeight + 3)); + text.dispose(); + } + + TextPainter layoutText(String text, ChartTextStyle style, {Color? color}) { + return TextPainter( + text: TextSpan( + text: text, + style: chartTextStyle(style, color: color), + ), + textDirection: TextDirection.ltr, + textScaler: textScaler, + )..layout(); + } + + @override + bool shouldRepaint(covariant ChartPainter oldDelegate) => + oldDelegate.properties != properties || + !_listEquals(oldDelegate.points, points) || + !_listEquals(oldDelegate.markers, markers) || + oldDelegate.selectedX != selectedX || + oldDelegate.textScaler != textScaler || + oldDelegate.fontsGeneration != fontsGeneration; + + static bool _listEquals(List a, List b) { + if (identical(a, b)) return true; + if (a.length != b.length) return false; + for (int i = 0; i < a.length; i++) { + if (a[i] != b[i]) return false; + } + return true; + } +} + +/// The Flutter text style of a chart's [style]. +TextStyle chartTextStyle(ChartTextStyle style, {Color? color}) { + final FontWeight weight = + FontWeight.values[((style.fontWeight / 100).round() - 1).clamp(0, 8)]; + final String? family = style.fontFamily; + if (family != null && GoogleFonts.asMap().containsKey(family)) { + return GoogleFonts.getFont( + family, + fontSize: style.fontSize, + fontWeight: weight, + color: color ?? style.color.toFlutterColor(), + height: style.height, + ); + } + return TextStyle( + fontSize: style.fontSize, + fontWeight: weight, + fontFamily: family, + color: color ?? style.color.toFlutterColor(), + height: style.height, + ); +} + +/// Formats an axis value without a trailing `.0`. +String formatChartNumber(double value) => value == value.roundToDouble() + ? value.toInt().toString() + : double.parse(value.toStringAsFixed(2)).toString(); diff --git a/lib/src/transformers/passive_transformer_manager.dart b/lib/src/transformers/passive_transformer_manager.dart index b977354e..6ab9eaf3 100644 --- a/lib/src/transformers/passive_transformer_manager.dart +++ b/lib/src/transformers/passive_transformer_manager.dart @@ -53,6 +53,7 @@ class PassiveNodeTransformerManager extends WidgetNodeTransformerManager { 'loadingIndicator': PassiveLoadingIndicatorTransformer(getNode, this), 'dropdown': PassiveDropdownTransformer(getNode, this), 'progressBar': PassiveProgressBarTransformer(getNode, this), + 'chart': PassiveChartTransformer(getNode, this), 'variance': PassiveVarianceTransformer(getNode, this), 'webView': PassiveWebViewTransformer(getNode, this), 'listView': PassiveListViewTransformer(getNode, this), diff --git a/test/chart_test.dart b/test/chart_test.dart new file mode 100644 index 00000000..f7568eaa --- /dev/null +++ b/test/chart_test.dart @@ -0,0 +1,229 @@ +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:codelessly_sdk/codelessly_sdk.dart'; +import 'package:codelessly_sdk/src/functions/action_chain.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; + +/// A 240 x 100 tide chart: 10 pixels per hour, selection clamped to 5..20. +ChartNode tideChart({ + Map variables = const {}, + List reactions = const [], + double? selectedX, + bool selectable = true, +}) => ChartNode( + id: 'tide', + name: 'Tide', + basicBoxLocal: NodeBox(0, 0, 240, 100), + selectedX: selectedX, + reactions: reactions, + properties: ChartProperties( + points: [for (int i = 0; i <= 24; i++) 1], + xMax: 24, + fillColor: ColorRGBA.grey10, + gridStepX: 3, + gridLabels: const ['3a', '6a', '9a', '12p', '3p', '6p', '9p'], + ranges: const [ChartRange(from: 0, to: 5), ChartRange(from: 21, to: 24)], + markers: const [ChartMarker(x: 4.2, label: '4:12am')], + referenceX: 7.67, + selectionStep: 1, + selectionMin: 5, + selectionMax: 20, + selectable: selectable, + ), +)..variables.addAll(variables); + +Observable variable( + String name, + Object? value, [ + VariableType type = VariableType.text, +]) => Observable(VariableData(id: name, name: name, value: value, type: type)); + +Reaction onChanged(String variableName) => Reaction( + trigger: TriggerModel(type: TriggerType.changed), + action: SetVariableAction( + variable: VariableData(id: variableName, name: variableName), + newValue: r'${value}', + ), +); + +Future pumpChart( + WidgetTester tester, + ChartNode node, + CodelesslyContext codelesslyContext, +) async { + final registry = NodeRegistry()..setNodes({node.id: node}); + final manager = PassiveNodeTransformerManager( + registry.getNodeByID, + (context, bounds, pageID, layoutID, canvasID) => const SizedBox(), + ); + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: codelesslyContext, + child: MaterialApp( + home: ActionChainHost( + child: Scaffold( + body: Align( + alignment: Alignment.topLeft, + child: Builder( + builder: (context) => manager.buildWidgetFromNode( + node, + context, + settings: const WidgetBuildSettings(debugLabel: 'test'), + ), + ), + ), + ), + ), + ), + ), + ); +} + +final Finder chartPaint = find.byWidgetPredicate( + (widget) => widget is CustomPaint && widget.painter is ChartPainter, +); + +ChartPainter painter(WidgetTester tester) => + tester.widget(chartPaint).painter! as ChartPainter; + +/// Taps the chart [hours] from its left edge. +Future tapAt(WidgetTester tester, double hours) async { + await tester.tapAt(tester.getTopLeft(chartPaint) + Offset(hours * 10, 50)); + await tester.pumpAndSettle(); +} + +void main() { + testWidgets('paints the bound points and selection', (tester) async { + final codelesslyContext = CodelesslyContext.empty() + ..variables['curve'] = variable('curve', [ + 0, + 1.5, + '3', + 2, + ], VariableType.list) + ..variables['hour'] = variable('hour', 7, VariableType.integer); + await pumpChart( + tester, + tideChart(variables: {'points': r'${curve}', 'selectedX': r'${hour}'}), + codelesslyContext, + ); + + expect(chartPaint, findsOneWidget); + expect(tester.getSize(chartPaint), const Size(240, 100)); + expect(painter(tester).points, [0, 1.5, 3, 2]); + expect(painter(tester).selectedX, 7); + expect(painter(tester).markers.single.label, '4:12am'); + expect(tester.takeException(), isNull); + }); + + testWidgets('points and markers can come from a formula', (tester) async { + await pumpChart( + tester, + tideChart( + variables: {'points': r'${= [1, 2, 3]}', 'markers': r'${markers}'}, + ), + CodelesslyContext.empty() + ..variables['markers'] = variable('markers', [ + {'x': 1, 'label': 'high', 'position': 'below'}, + 'not a marker', + ], VariableType.list), + ); + + expect(painter(tester).points, [1, 2, 3]); + final ChartMarker marker = painter(tester).markers.single; + expect(marker.label, 'high'); + expect(marker.position, ChartMarkerPosition.below); + }); + + testWidgets('tapping selects the snapped hour, writes the bound variable ' + 'and fires changed', (tester) async { + final hour = variable('hour', 7, VariableType.integer); + final picked = variable('picked', ''); + final codelesslyContext = CodelesslyContext.empty() + ..variables['hour'] = hour + ..variables['picked'] = picked; + await pumpChart( + tester, + tideChart( + variables: {'selectedX': r'${hour}'}, + reactions: [onChanged('picked')], + ), + codelesslyContext, + ); + + await tapAt(tester, 12.4); + expect(hour.value.value.typedValue(), 12); + expect(picked.value.value, '12'); + expect(painter(tester).selectedX, 12); + + // Clamped to selectionMin and selectionMax. + await tapAt(tester, 1.5); + expect(hour.value.value.typedValue(), 5); + expect(picked.value.value, '5'); + await tapAt(tester, 23.5); + expect(hour.value.value.typedValue(), 20); + expect(painter(tester).selectedX, 20); + }); + + testWidgets('dragging scrubs the selection', (tester) async { + final List fired = []; + final picked = variable('picked', ''); + final codelesslyContext = CodelesslyContext.empty() + ..variables['picked'] = picked; + picked.addListener(() => fired.add(picked.value.value.toString())); + await pumpChart( + tester, + tideChart(selectedX: 7, reactions: [onChanged('picked')]), + codelesslyContext, + ); + expect(painter(tester).selectedX, 7); + + final TestGesture gesture = await tester.startGesture( + tester.getTopLeft(chartPaint) + const Offset(75, 50), + ); + for (double dx = 75; dx <= 115; dx += 5) { + await gesture.moveTo(tester.getTopLeft(chartPaint) + Offset(dx, 50)); + await tester.pump(); + } + await gesture.up(); + await tester.pumpAndSettle(); + + // Without a bound variable the chart keeps the selection itself, and + // fires once per hour it moves to (the drag starts past the touch slop). + expect(painter(tester).selectedX, 11); + expect(fired, ['9', '10', '11']); + }); + + testWidgets('a chart that is not selectable ignores taps', (tester) async { + final picked = variable('picked', ''); + await pumpChart( + tester, + tideChart( + selectedX: 7, + selectable: false, + reactions: [onChanged('picked')], + ), + CodelesslyContext.empty()..variables['picked'] = picked, + ); + await tapAt(tester, 12.4); + expect(painter(tester).selectedX, 7); + expect(picked.value.value, ''); + }); + + testWidgets('paints without points, grid or labels', (tester) async { + await pumpChart( + tester, + ChartNode( + id: 'empty', + name: 'Empty', + basicBoxLocal: NodeBox(0, 0, 100, 40), + selectedX: 0, + properties: ChartProperties(smooth: true, labelHeight: 0), + ), + CodelesslyContext.empty(), + ); + expect(chartPaint, findsOneWidget); + expect(tester.takeException(), isNull); + }); +} diff --git a/test/transformers_test.dart b/test/transformers_test.dart index 3b21e502..8fd03117 100644 --- a/test/transformers_test.dart +++ b/test/transformers_test.dart @@ -23,6 +23,7 @@ List standaloneNodes() => [ ), ), ProgressBarNode.empty(), + ChartNode.empty(), RadioNode.empty(), SliderNode.empty(), SwitchNode.empty(), From ee981d607880cd210d384a657f903666d72e2868 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 23:18:52 +0000 Subject: [PATCH 03/19] Show a canvas as a bottom sheet, a side sheet or an aligned dialog Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SyH8twyrBoe4wFCTw78Cz5 --- lib/src/functions/functions_repository.dart | 102 +++++++++++++++++--- lib/src/ui/codelessly_dialog_widget.dart | 44 ++++++++- test/show_dialog_presentation_test.dart | 102 ++++++++++++++++++++ 3 files changed, 231 insertions(+), 17 deletions(-) create mode 100644 test/show_dialog_presentation_test.dart diff --git a/lib/src/functions/functions_repository.dart b/lib/src/functions/functions_repository.dart index 0c1bc3fa..88a8b39f 100644 --- a/lib/src/functions/functions_repository.dart +++ b/lib/src/functions/functions_repository.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:math' as math; import 'dart:convert'; import 'package:codelessly_api/codelessly_api.dart'; @@ -595,18 +596,33 @@ class FunctionsRepository { showCloseButton: action.showCloseButton, presentation: action.presentation, alignment: action.alignment.flutterAlignment ?? Alignment.center, + insets: action.insets.flutterEdgeInsets, ); } + /// The size canvas [canvasId] was designed at, if it is published. + static Size? canvasDesignSize(Codelessly codelessly, String canvasId) { + for (final layout + in codelessly.dataManager.publishModel?.layouts.values ?? + const []) { + final BaseNode? canvas = layout.canvases[canvasId]?[canvasId]; + if (canvas != null) { + return Size(canvas.basicBoxLocal.width, canvas.basicBoxLocal.height); + } + } + return null; + } + /// The id of the published layout that holds the canvas [canvasId], if any. static String? layoutIdForCanvas(Codelessly codelessly, String canvasId) => codelessly.dataManager.publishModel?.layouts.values .firstWhereOrNull((layout) => layout.canvasIds.contains(canvasId)) ?.id; - /// Shows the canvas [canvasId] as a dialog and completes with what it was - /// closed with: the params of the "Navigate back" action that closed it, or - /// null when it was dismissed. + /// Shows the canvas [canvasId] as a dialog, a bottom sheet or a side sheet + /// (see [presentCanvas]) and completes with what it was closed with: the + /// params of the "Navigate back" action that closed it, or null when it + /// was dismissed. static Future showLayoutDialog( BuildContext context, { required String canvasId, @@ -616,6 +632,7 @@ class FunctionsRepository { bool showCloseButton = false, DialogPresentation presentation = DialogPresentation.dialog, AlignmentGeometry alignment = Alignment.center, + EdgeInsets insets = EdgeInsets.zero, }) async { final ScopedValues scopedValues = ActionChain.scopedValuesOf(context); final parsedParams = substituteVariablesInMap(params, scopedValues); @@ -662,9 +679,11 @@ class FunctionsRepository { context, presentation: presentation, alignment: alignment, + insets: insets, barrierDismissible: barrierDismissible, barrierColor: barrierColor, showCloseButton: showCloseButton, + designSize: canvasDesignSize(codelessly, canvasId), routeSettings: RouteSettings(arguments: parsedParams), builder: (context) => ActionChainOpener( openerHosts: openerHosts, @@ -681,41 +700,91 @@ class FunctionsRepository { return result; } - /// Opens what [builder] builds as a dialog at [alignment] or as a bottom - /// sheet, per [presentation] (adaptive: a sheet below 600 px wide), and - /// completes with the value it is closed with. + /// Opens what [builder] builds per [presentation] and completes with the + /// value it is closed with: + /// + /// * a dialog at [alignment], kept [insets] from the screen edges; + /// * a bottom sheet at the screen's width and the canvas's [designSize] + /// height, at most 88% of the screen (adaptive: a sheet below 600 px + /// wide, a dialog otherwise); + /// * a side sheet sliding in from the right at the canvas's width, as tall + /// as the screen less [insets]. + /// + /// The canvas draws the sheet's surface (fill, corners, grab handle). static Future presentCanvas( BuildContext context, { required WidgetBuilder builder, DialogPresentation presentation = DialogPresentation.dialog, AlignmentGeometry alignment = Alignment.center, + EdgeInsets insets = EdgeInsets.zero, bool barrierDismissible = true, Color? barrierColor, bool showCloseButton = false, + Size? designSize, RouteSettings? routeSettings, }) { final bool asSheet = switch (presentation) { - DialogPresentation.dialog => false, + DialogPresentation.dialog || DialogPresentation.sideSheet => false, DialogPresentation.bottomSheet => true, DialogPresentation.adaptive => (MediaQuery.maybeSizeOf(context)?.width ?? 1024) < 600, }; - Widget content(BuildContext context) => CodelesslyDialogWidget( - showCloseButton: showCloseButton, - alignment: asSheet ? Alignment.bottomCenter : alignment, - builder: builder, - ); + if (presentation == DialogPresentation.sideSheet) { + return showGeneralDialog( + context: context, + useRootNavigator: false, + barrierDismissible: barrierDismissible, + barrierLabel: MaterialLocalizations.of(context).modalBarrierDismissLabel, + barrierColor: barrierColor ?? Colors.black54, + routeSettings: routeSettings, + transitionDuration: const Duration(milliseconds: 260), + transitionBuilder: (context, animation, _, child) => SlideTransition( + position: Tween(begin: const Offset(1, 0), end: Offset.zero) + .animate( + CurvedAnimation(parent: animation, curve: Curves.easeOutCubic), + ), + child: child, + ), + pageBuilder: (context, _, _) => SafeArea( + child: Align( + alignment: Alignment.centerRight, + child: Padding( + padding: insets, + child: CanvasSizingScope( + sizing: CanvasSizing.fill, + child: SizedBox( + width: designSize?.width ?? 420, + child: Builder(builder: builder), + ), + ), + ), + ), + ), + ); + } if (asSheet) { return showModalBottomSheet( context: context, useRootNavigator: false, isScrollControlled: true, isDismissible: barrierDismissible, + enableDrag: barrierDismissible, barrierColor: barrierColor, + // The canvas draws the sheet: its fill, corners and grab handle. backgroundColor: Colors.transparent, elevation: 0, + constraints: const BoxConstraints(), routeSettings: routeSettings, - builder: content, + builder: (context) => CanvasSizingScope( + sizing: CanvasSizing.fill, + child: SizedBox( + height: math.min( + designSize?.height ?? double.infinity, + MediaQuery.sizeOf(context).height * 0.88, + ), + child: Builder(builder: builder), + ), + ), ); } return showDialog( @@ -724,7 +793,12 @@ class FunctionsRepository { barrierDismissible: barrierDismissible, barrierColor: barrierColor, routeSettings: routeSettings, - builder: content, + builder: (context) => CodelesslyDialogWidget( + showCloseButton: showCloseButton, + alignment: alignment, + insets: insets, + builder: builder, + ), ); } diff --git a/lib/src/ui/codelessly_dialog_widget.dart b/lib/src/ui/codelessly_dialog_widget.dart index e9af3071..0256baf5 100644 --- a/lib/src/ui/codelessly_dialog_widget.dart +++ b/lib/src/ui/codelessly_dialog_widget.dart @@ -11,13 +11,30 @@ class CodelesslyDialogWidget extends StatelessWidget { /// or anywhere else, e.g. a menu at the top right. final AlignmentGeometry alignment; + /// Space kept between the dialog and the screen edges. + final EdgeInsets insets; + + /// The most of the screen's height the dialog may take, as a share of it + /// (see [ShowDialogAction.maxHeightFactor]). Null keeps the canvas's + /// design height. + final double? maxHeightFactor; + const CodelesslyDialogWidget({ super.key, required this.builder, required this.showCloseButton, this.alignment = Alignment.center, + this.insets = EdgeInsets.zero, + this.maxHeightFactor, }); + /// The height cap of a canvas shown on a screen [screenHeight] tall with + /// [factor] (see [ShowDialogAction.maxHeightFactor]), or null for none. + static double? maxHeightFor(double? factor, double screenHeight) { + if (factor == null || !factor.isFinite || factor <= 0) return null; + return screenHeight * factor.clamp(0, 1); + } + @override Widget build(BuildContext context) { // A dialog is centered and sized by its layout, so its canvas keeps its @@ -29,9 +46,30 @@ class CodelesslyDialogWidget extends StatelessWidget { } Widget _buildDialog(BuildContext context) { + return Padding( + padding: insets, + child: Align(alignment: alignment, child: _buildContent(context)), + ); + } + + Widget _buildContent(BuildContext context) { + // The canvas keeps its design height up to the cap; a taller design is + // laid out at the cap and its content (a flexible or expanded scrolling + // body) adapts. A canvas sized by its content stops at the cap too. + final double? maxHeight = maxHeightFor( + maxHeightFactor, + MediaQuery.sizeOf(context).height, + ); + if (maxHeight == null) return _buildCanvas(context); + return ConstrainedBox( + constraints: BoxConstraints(maxHeight: maxHeight), + child: _buildCanvas(context), + ); + } + + Widget _buildCanvas(BuildContext context) { if (showCloseButton) { - return Align( - alignment: alignment, + return Center( child: Material( type: MaterialType.transparency, child: Stack( @@ -55,6 +93,6 @@ class CodelesslyDialogWidget extends StatelessWidget { ); } - return Align(alignment: alignment, child: builder(context)); + return builder(context); } } diff --git a/test/show_dialog_presentation_test.dart b/test/show_dialog_presentation_test.dart new file mode 100644 index 00000000..9b2486d3 --- /dev/null +++ b/test/show_dialog_presentation_test.dart @@ -0,0 +1,102 @@ +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:codelessly_sdk/codelessly_sdk.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'feedback_actions_test.dart' as harness; + +/// A canvas of [size] holding the text 'Overlay'. +List overlayScreen(Size size) => [ + CanvasNode( + id: 'overlay', + name: 'overlay', + basicBoxLocal: NodeBox(0, 0, size.width, size.height), + children: ['overlayBody'], + properties: CanvasProperties( + bodyId: 'overlayBody', + safeArea: const SafeAreaModel.all(false), + ), + )..parentID = kRootNode, + harness.body('overlayBody', 'overlay', ['label']), + harness.text('label', 'overlayBody', 'Overlay', harness.brandRegular), +]; + +Future showOverlay( + WidgetTester tester, + Map extra, { + Size design = const Size(390, 400), + Size screen = const Size(834, 1112), +}) async { + final ActionModel action = ActionModel.fromJson({ + 'type': 'showDialog', + 'destinationId': 'overlay', + ...extra, + }); + await harness.pumpLayouts(tester, [ + harness.layoutOf('main', harness.mainScreen(action)), + harness.layoutOf('sheet', overlayScreen(design)), + ], size: screen); + await tester.tap(find.text('Go')); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + expect(find.text('Overlay'), findsOneWidget); + // The overlay's canvas: the layout widget the overlay route built. + return tester.getRect(find.byType(CodelesslyWidget).last); +} + +void main() { + testWidgets('a dialog keeps its design size, centered', (tester) async { + final Rect box = await showOverlay(tester, {}); + expect(box.size, const Size(390, 400)); + expect(box.center, const Offset(417, 556)); + }); + + testWidgets('an aligned dialog sits in its corner, inset from the edges', ( + tester, + ) async { + final Rect box = await showOverlay(tester, { + 'alignment': { + 'data': [-1, 1], + }, + 'insets': [96, 0, 0, 16], + }, design: const Size(340, 300)); + expect(box.left, 96); + expect(box.bottom, 1112 - 16); + expect(box.size, const Size(340, 300)); + }); + + testWidgets('a bottom sheet spans the screen width at its design height', ( + tester, + ) async { + final Rect box = await showOverlay(tester, { + 'presentation': 'bottomSheet', + }, screen: const Size(390, 844)); + expect(box.width, 390); + expect(box.height, 400); + expect(box.bottom, 844); + }); + + testWidgets('a tall bottom sheet stops at 88% of the screen', (tester) async { + final Rect box = await showOverlay( + tester, + {'presentation': 'bottomSheet'}, + design: const Size(390, 2000), + screen: const Size(390, 844), + ); + expect(box.height, closeTo(844 * 0.88, 0.5)); + expect(box.bottom, 844); + }); + + testWidgets('a side sheet hugs the right edge at its design width', ( + tester, + ) async { + final Rect box = await showOverlay(tester, { + 'presentation': 'sideSheet', + 'insets': 10, + }, design: const Size(420, 700)); + expect(box.width, 420); + expect(box.right, 834 - 10); + expect(box.top, 10); + expect(box.bottom, 1112 - 10); + }); +} From 379d7d6b007016ffefb94086e07a05938dad12d1 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 02:55:45 +0000 Subject: [PATCH 04/19] Lay out non-scrolling lists without a viewport so stretched rows can measure them A list that doesn't scroll, hugs its items and has a known item count builds every item anyway. It now lays them out in a Flex instead of a ListView, so a Row/Column that stretches its children (IntrinsicHeight) no longer falls back to start when one child holds such a list: cards side by side stay equal. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SyH8twyrBoe4wFCTw78Cz5 --- .../passive_list_view_transformer.dart | 49 +++++++++ .../transformers/utils/viewport_sizing.dart | 16 ++- test/static_list_test.dart | 100 ++++++++++++++++++ 3 files changed, 164 insertions(+), 1 deletion(-) create mode 100644 test/static_list_test.dart diff --git a/lib/src/transformers/node_transformers/passive_list_view_transformer.dart b/lib/src/transformers/node_transformers/passive_list_view_transformer.dart index 84c03ae9..8094befe 100644 --- a/lib/src/transformers/node_transformers/passive_list_view_transformer.dart +++ b/lib/src/transformers/node_transformers/passive_list_view_transformer.dart @@ -83,6 +83,51 @@ class PassiveListViewWidget extends StatelessWidget { builder: builder, ); + /// Lays out all [itemCount] items in a [Flex] instead of a viewport, for a + /// list that [listBuildsAllItems]. It looks the same, and a Row or Column + /// around it can measure it (a viewport can't report intrinsic sizes), so + /// a stretched row of cards keeps equal heights with a list in one of them. + Widget staticList( + BuildContext context, + int itemCount, + IndexedWidgetBuilder itemBuilder, + ) { + final bool vertical = node.scrollDirection == AxisC.vertical; + final List items = [ + for (int index = 0; index < itemCount; index++) ...[ + if (index > 0 && node.properties.hasSeparator) + ListViewItemSeparator( + scrollDirection: node.scrollDirection, + properties: node.properties, + ), + itemBuilder(context, index), + ], + ]; + final Widget flex = Padding( + padding: node.padding.flutterEdgeInsets, + child: Flex( + direction: node.scrollDirection.flutterAxis, + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: node.reverse ? items.reversed.toList() : items, + ), + ); + // The cross axis follows [ViewportLayout]: fill when expanded and + // bounded, else the design size. The scroll axis hugs the items. + final SizeFit crossFit = vertical ? node.horizontalFit : node.verticalFit; + final double designCross = vertical + ? node.basicBoxLocal.width + : node.basicBoxLocal.height; + final bool fill = crossFit == SizeFit.expanded; + return FitBox( + width: vertical ? designCross : null, + height: vertical ? null : designCross, + fillWidth: vertical && fill, + fillHeight: !vertical && fill, + child: node.clipsContent ? ClipRect(child: flex) : flex, + ); + } + /// Builds one item, placed as the editor lays it out; see [ListItemBox]. Widget buildItem(BaseNode item, BuildContext context) => ListItemBox( item: item, @@ -302,6 +347,10 @@ class PassiveListViewWidget extends StatelessWidget { itemBuilder: builder, ); + if (itemCount != null && listBuildsAllItems(node)) { + return staticList(context, itemCount, itemBuilder); + } + // An endless list with items of free extent ends after a viewport's // worth of items that take no space, so they cannot be built without // limit; see [EndlessList]. A fixed item extent is bounded by Flutter. diff --git a/lib/src/transformers/utils/viewport_sizing.dart b/lib/src/transformers/utils/viewport_sizing.dart index 74083b90..dd56816f 100644 --- a/lib/src/transformers/utils/viewport_sizing.dart +++ b/lib/src/transformers/utils/viewport_sizing.dart @@ -300,6 +300,20 @@ class DesignCellGridDelegate extends SliverGridDelegate { oldDelegate.mainAxisExtent != mainAxisExtent; } +/// Whether list [node] builds all its items up front, as a plain [Flex] +/// rather than a viewport: it doesn't scroll, hugs its items along its axis +/// and has a known item count (bound data or a set count). Such a list builds +/// every item either way, so it needs no viewport. +bool listBuildsAllItems(ListViewNode node) { + final SizeFit mainFit = node.scrollDirection == AxisC.vertical + ? node.verticalFit + : node.horizontalFit; + return !node.isScrollable && + mainFit == SizeFit.shrinkWrap && + !node.useCloudDatabase && + (node.variables['data'] != null || node.properties.itemCount != null); +} + /// Whether [node] or any node below it builds a widget that cannot report /// intrinsic sizes: a list, grid or page view (a viewport behind a /// [LayoutBuilder]), a scrolling row, column or frame (a [LayoutBuilder] @@ -310,7 +324,7 @@ class DesignCellGridDelegate extends SliverGridDelegate { /// "RenderViewport does not support returning intrinsic dimensions"), so a /// caller that adds an intrinsic wrapper must skip it when this is true. bool subtreeBlocksIntrinsics(BaseNode node, GetNode getNode) { - if (node is ListViewNode || + if ((node is ListViewNode && !listBuildsAllItems(node)) || node is GridViewNode || node is PageViewNode || node is WebViewNode) { diff --git a/test/static_list_test.dart b/test/static_list_test.dart new file mode 100644 index 00000000..bd2211f9 --- /dev/null +++ b/test/static_list_test.dart @@ -0,0 +1,100 @@ +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:codelessly_sdk/codelessly_sdk.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'viewport_layout_test.dart' as vp; + +/// A list that doesn't scroll and hugs its three items. +ListViewNode staticList({String parentID = kRootNode}) => + vp.list(parentID: parentID, verticalFit: SizeFit.shrinkWrap) + ..isScrollable = false; + +RowColumnNode flex( + String id, + String parentID, + RowColumnType type, + List children, { + SizeFit horizontalFit = SizeFit.shrinkWrap, + SizeFit verticalFit = SizeFit.shrinkWrap, + CrossAxisAlignmentC cross = CrossAxisAlignmentC.start, +}) => RowColumnNode( + id: id, + name: id, + basicBoxLocal: NodeBox(0, 0, 300, 300), + rowColumnType: type, + horizontalFit: horizontalFit, + verticalFit: verticalFit, + crossAxisAlignment: cross, + children: children, +)..parentID = parentID; + +void main() { + test('a list that neither scrolls nor has a fixed extent lets intrinsics ' + 'through', () { + final list = staticList(); + final item = vp.rect('item', 'list'); + BaseNode getNode(String id) => {'list': list, 'item': item}[id]!; + + expect(listBuildsAllItems(list), isTrue); + expect(subtreeBlocksIntrinsics(list, getNode), isFalse); + + list.isScrollable = true; + expect(listBuildsAllItems(list), isFalse); + expect(subtreeBlocksIntrinsics(list, getNode), isTrue); + + list + ..isScrollable = false + ..verticalFit = SizeFit.fixed; + expect(subtreeBlocksIntrinsics(list, getNode), isTrue); + + list + ..verticalFit = SizeFit.shrinkWrap + ..properties = ListViewProperties(); + expect(listBuildsAllItems(list), isFalse, reason: 'endless list'); + }); + + vp.forEachLayout('a static list lays its items out without a viewport', ( + tester, + screen, + host, + ) async { + await vp.pump(tester, screen, host, staticList(), [ + vp.rect('item', 'list'), + ]); + expect(find.byType(ViewportNodeBox), findsNothing); + expect(vp.rectOf('item'), findsNWidgets(3)); + final Rect first = tester.getRect(vp.rectOf('item').at(0)); + final Rect last = tester.getRect(vp.rectOf('item').at(2)); + expect(last.bottom - first.top, 150); + expect(first.size, const Size(100, 50)); + }); + + vp.forEachLayout('a stretched row keeps cards equal with a list in one', ( + tester, + screen, + host, + ) async { + // row(stretch) > [left column > list of 3 x 50, right column > 300 rect] + final row = flex('row', kRootNode, RowColumnType.row, [ + 'left', + 'right', + ], cross: CrossAxisAlignmentC.stretch); + await vp.pump(tester, screen, host, row, [ + flex('left', 'row', RowColumnType.column, [ + 'list', + ], verticalFit: SizeFit.expanded), + staticList(parentID: 'left'), + vp.rect('item', 'list'), + flex('right', 'row', RowColumnType.column, ['tall']), + vp.rect('tall', 'right', height: 300), + ]); + final Finder left = vp.rectOf('left'); + final Finder right = vp.rectOf('right'); + // Without intrinsics the stretch fell back to start and the left card + // kept its own (list) height. A tight host stretches both to the screen. + if (host != vp.Host.tight) expect(tester.getSize(right).height, 300); + expect(tester.getSize(left).height, tester.getSize(right).height); + expect(vp.rectOf('item'), findsNWidgets(3)); + }); +} From d7db53893343bfa36e5bf66dc33b26a1df13bf9d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 03:08:53 +0000 Subject: [PATCH 05/19] Bind an icon's glyph by name to a variable, formula or condition A bound 'icon' names the Material icon or symbol to show (e.g. star or star_border by a favorite flag); materialIconVersions gives the SVG version for a name chosen at runtime. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SyH8twyrBoe4wFCTw78Cz5 --- lib/codelessly_sdk.dart | 1 + .../passive_icon_transformer.dart | 34 +- lib/src/utils/extensions.dart | 17 + lib/src/utils/material_icon_versions.dart | 2129 +++++++++++++++++ test/icon_binding_test.dart | 84 + 5 files changed, 2258 insertions(+), 7 deletions(-) create mode 100644 lib/src/utils/material_icon_versions.dart create mode 100644 test/icon_binding_test.dart diff --git a/lib/codelessly_sdk.dart b/lib/codelessly_sdk.dart index ffaef123..116758b7 100644 --- a/lib/codelessly_sdk.dart +++ b/lib/codelessly_sdk.dart @@ -46,6 +46,7 @@ export 'src/ui/zoomable_box.dart'; export 'src/ui/skeleton.dart'; export 'src/ui/stripe_painter.dart'; export 'src/utils/cupertino_icons_by_codepoint.dart'; +export 'src/utils/material_icon_versions.dart'; export 'src/utils/extensions.dart'; export 'src/utils/flutter_icons_data_map.dart'; export 'src/utils/flutter_icons_names_map.dart'; diff --git a/lib/src/transformers/node_transformers/passive_icon_transformer.dart b/lib/src/transformers/node_transformers/passive_icon_transformer.dart index e1fddacf..5ed08220 100644 --- a/lib/src/transformers/node_transformers/passive_icon_transformer.dart +++ b/lib/src/transformers/node_transformers/passive_icon_transformer.dart @@ -33,18 +33,38 @@ class PassiveIconWidget extends StatelessWidget { this.useFonts = false, }); - @override - Widget build(BuildContext context) { + /// [node]'s icon with its bound color and glyph applied: a bound or + /// conditional `icon` names the glyph (e.g. star or star_border by a + /// favorite flag), `iconColor` colors it. + static MultiSourceIconModel resolvedIcon( + IconNode node, + ScopedValues scopedValues, + ) { final ColorRGBA? iconColor = PropertyValueDelegate.getPropertyValue( node, 'iconColor', - scopedValues: ScopedValues.of(context), + scopedValues: scopedValues, ); - final MultiSourceIconModel icon = iconColor == null - ? node.properties.icon - : node.properties.icon.copyWith(color: iconColor); - return retrieveIconWidget(icon, node.basicBoxLocal.width) ?? + final String? glyph = PropertyValueDelegate.getPropertyValue( + node, + 'icon', + scopedValues: scopedValues, + ); + MultiSourceIconModel icon = node.properties.icon; + if (glyph != null && icon.icon != null) { + icon = icon.copyWith(icon: icon.icon!.named(glyph)); + } + if (iconColor != null) icon = icon.copyWith(color: iconColor); + return icon; + } + + @override + Widget build(BuildContext context) { + return retrieveIconWidget( + resolvedIcon(node, ScopedValues.of(context)), + node.basicBoxLocal.width, + ) ?? SizedBox( width: node.basicBoxLocal.width, height: node.basicBoxLocal.height, diff --git a/lib/src/utils/extensions.dart b/lib/src/utils/extensions.dart index 8cc1c817..6951fe67 100644 --- a/lib/src/utils/extensions.dart +++ b/lib/src/utils/extensions.dart @@ -1128,6 +1128,23 @@ extension BoxWidthStyleEnumExtensions on BoxWidthStyleEnum { } extension IconModelExt on IconModel { + /// This icon's glyph swapped for the one called [name] (e.g. `star_border`) + /// in the same set and style, or this icon when [name] is empty or the set + /// can't be looked up by name (Cupertino icons are found by codepoint). + IconModel named(String name) { + final String glyph = name.trim().toLowerCase(); + if (glyph.isEmpty || glyph == this.name) return this; + final IconModel icon = this; + return switch (icon) { + MaterialIcon() => icon.copyWith( + name: glyph, + version: materialIconVersions[glyph] ?? icon.version, + ), + MaterialSymbol() => icon.copyWith(name: glyph), + _ => icon, + }; + } + String get label => name.split('_').map((part) => part.capitalized).join(' '); // IconData toFontIconData() { diff --git a/lib/src/utils/material_icon_versions.dart b/lib/src/utils/material_icon_versions.dart new file mode 100644 index 00000000..c9dddf42 --- /dev/null +++ b/lib/src/utils/material_icon_versions.dart @@ -0,0 +1,2129 @@ +// Generated from the Material Icons metadata (fonts.google.com); do not edit. + +/// The latest version of each Material Icon by name, as its SVG URL on +/// fonts.gstatic.com needs it. Used to show an icon chosen by name at +/// runtime (an icon bound to a variable or formula). +const Map materialIconVersions = { + '10k': 10, + '10mp': 10, + '11mp': 10, + '123': 1, + '12mp': 10, + '13mp': 10, + '14mp': 10, + '15mp': 10, + '16mp': 10, + '17mp': 10, + '18_up_rating': 1, + '18mp': 10, + '19mp': 10, + '1k': 10, + '1k_plus': 10, + '1x_mobiledata': 9, + '20mp': 10, + '21mp': 10, + '22mp': 10, + '23mp': 10, + '24mp': 10, + '2k': 10, + '2k_plus': 10, + '2mp': 10, + '30fps': 10, + '30fps_select': 10, + '360': 14, + '3d_rotation': 18, + '3g_mobiledata': 9, + '3k': 10, + '3k_plus': 10, + '3mp': 10, + '3p': 11, + '4g_mobiledata': 9, + '4g_plus_mobiledata': 9, + '4k': 11, + '4k_plus': 10, + '4mp': 10, + '5g': 13, + '5k': 10, + '5k_plus': 10, + '5mp': 10, + '60fps': 10, + '60fps_select': 10, + '6_ft_apart': 9, + '6k': 10, + '6k_plus': 10, + '6mp': 10, + '7k': 10, + '7k_plus': 10, + '7mp': 10, + '8k': 11, + '8k_plus': 11, + '8mp': 10, + '9k': 10, + '9k_plus': 10, + '9mp': 10, + 'abc': 1, + 'ac_unit': 12, + 'access_alarm': 12, + 'access_alarms': 12, + 'access_time': 12, + 'access_time_filled': 10, + 'accessibility': 13, + 'accessibility_new': 13, + 'accessible': 13, + 'accessible_forward': 13, + 'account_balance': 19, + 'account_balance_wallet': 12, + 'account_box': 14, + 'account_circle': 20, + 'account_tree': 12, + 'ad_units': 13, + 'adb': 12, + 'add': 21, + 'add_a_photo': 14, + 'add_alarm': 13, + 'add_alert': 16, + 'add_box': 13, + 'add_business': 17, + 'add_card': 1, + 'add_chart': 11, + 'add_circle': 13, + 'add_circle_outline': 16, + 'add_comment': 11, + 'add_home': 1, + 'add_home_work': 1, + 'add_ic_call': 12, + 'add_link': 11, + 'add_location': 16, + 'add_location_alt': 13, + 'add_moderator': 12, + 'add_photo_alternate': 12, + 'add_reaction': 9, + 'add_road': 12, + 'add_shopping_cart': 15, + 'add_task': 6, + 'add_to_drive': 18, + 'add_to_home_screen': 12, + 'add_to_photos': 12, + 'add_to_queue': 11, + 'addchart': 13, + 'adf_scanner': 2, + 'adjust': 12, + 'admin_panel_settings': 13, + 'ads_click': 3, + 'agriculture': 11, + 'air': 10, + 'airline_seat_flat': 12, + 'airline_seat_flat_angled': 12, + 'airline_seat_individual_suite': 11, + 'airline_seat_legroom_extra': 12, + 'airline_seat_legroom_normal': 12, + 'airline_seat_legroom_reduced': 12, + 'airline_seat_recline_extra': 12, + 'airline_seat_recline_normal': 12, + 'airline_stops': 2, + 'airlines': 2, + 'airplane_ticket': 11, + 'airplanemode_active': 16, + 'airplanemode_inactive': 17, + 'airplay': 13, + 'airport_shuttle': 12, + 'alarm': 12, + 'alarm_add': 12, + 'alarm_off': 12, + 'alarm_on': 12, + 'album': 12, + 'align_horizontal_center': 6, + 'align_horizontal_left': 7, + 'align_horizontal_right': 7, + 'align_vertical_bottom': 6, + 'align_vertical_center': 6, + 'align_vertical_top': 6, + 'all_inbox': 11, + 'all_inclusive': 11, + 'all_out': 12, + 'alt_route': 9, + 'alternate_email': 13, + 'analytics': 12, + 'anchor': 7, + 'android': 15, + 'animation': 14, + 'announcement': 18, + 'aod': 10, + 'apartment': 11, + 'api': 7, + 'app_blocking': 13, + 'app_registration': 11, + 'app_settings_alt': 17, + 'app_shortcut': 2, + 'approval': 11, + 'apps': 12, + 'apps_outage': 2, + 'architecture': 11, + 'archive': 16, + 'area_chart': 3, + 'arrow_back': 17, + 'arrow_back_ios': 14, + 'arrow_back_ios_new': 6, + 'arrow_circle_down': 8, + 'arrow_circle_left': 2, + 'arrow_circle_right': 2, + 'arrow_circle_up': 8, + 'arrow_downward': 17, + 'arrow_drop_down': 16, + 'arrow_drop_down_circle': 12, + 'arrow_drop_up': 12, + 'arrow_forward': 13, + 'arrow_forward_ios': 16, + 'arrow_left': 12, + 'arrow_outward': 1, + 'arrow_right': 12, + 'arrow_right_alt': 13, + 'arrow_upward': 11, + 'art_track': 13, + 'article': 13, + 'aspect_ratio': 12, + 'assessment': 12, + 'assignment': 15, + 'assignment_ind': 12, + 'assignment_late': 15, + 'assignment_return': 14, + 'assignment_returned': 12, + 'assignment_turned_in': 16, + 'assist_walker': 1, + 'assistant': 12, + 'assistant_direction': 14, + 'assistant_photo': 12, + 'assured_workload': 1, + 'atm': 12, + 'attach_email': 16, + 'attach_file': 16, + 'attach_money': 17, + 'attachment': 15, + 'attractions': 10, + 'attribution': 11, + 'audio_file': 1, + 'audiotrack': 12, + 'auto_awesome': 14, + 'auto_awesome_mosaic': 14, + 'auto_awesome_motion': 14, + 'auto_delete': 11, + 'auto_fix_high': 14, + 'auto_fix_normal': 14, + 'auto_fix_off': 13, + 'auto_graph': 4, + 'auto_mode': 1, + 'auto_stories': 13, + 'autofps_select': 10, + 'autorenew': 12, + 'av_timer': 12, + 'baby_changing_station': 8, + 'back_hand': 3, + 'backpack': 8, + 'backspace': 13, + 'backup': 15, + 'backup_table': 11, + 'badge': 11, + 'bakery_dining': 11, + 'balance': 1, + 'balcony': 4, + 'ballot': 14, + 'bar_chart': 13, + 'batch_prediction': 12, + 'bathroom': 10, + 'bathtub': 11, + 'battery_0_bar': 1, + 'battery_1_bar': 1, + 'battery_2_bar': 1, + 'battery_3_bar': 1, + 'battery_4_bar': 1, + 'battery_5_bar': 1, + 'battery_6_bar': 1, + 'battery_alert': 12, + 'battery_charging_full': 12, + 'battery_full': 12, + 'battery_saver': 9, + 'battery_std': 12, + 'battery_unknown': 13, + 'beach_access': 12, + 'bed': 10, + 'bedroom_baby': 9, + 'bedroom_child': 9, + 'bedroom_parent': 9, + 'bedtime': 15, + 'bedtime_off': 2, + 'beenhere': 12, + 'bento': 6, + 'bike_scooter': 11, + 'biotech': 11, + 'blender': 9, + 'blind': 1, + 'blinds': 3, + 'blinds_closed': 1, + 'block': 17, + 'bloodtype': 10, + 'bluetooth': 11, + 'bluetooth_audio': 12, + 'bluetooth_connected': 12, + 'bluetooth_disabled': 17, + 'bluetooth_drive': 9, + 'bluetooth_searching': 13, + 'blur_circular': 12, + 'blur_linear': 12, + 'blur_off': 12, + 'blur_on': 12, + 'bolt': 10, + 'book': 16, + 'book_online': 10, + 'bookmark': 17, + 'bookmark_add': 3, + 'bookmark_added': 3, + 'bookmark_border': 12, + 'bookmark_remove': 3, + 'bookmarks': 12, + 'border_all': 12, + 'border_bottom': 12, + 'border_clear': 12, + 'border_color': 15, + 'border_horizontal': 12, + 'border_inner': 12, + 'border_left': 12, + 'border_outer': 12, + 'border_right': 12, + 'border_style': 12, + 'border_top': 12, + 'border_vertical': 12, + 'boy': 1, + 'branding_watermark': 13, + 'breakfast_dining': 11, + 'brightness_1': 19, + 'brightness_2': 12, + 'brightness_3': 16, + 'brightness_4': 16, + 'brightness_5': 12, + 'brightness_6': 12, + 'brightness_7': 13, + 'brightness_auto': 12, + 'brightness_high': 13, + 'brightness_low': 12, + 'brightness_medium': 12, + 'broadcast_on_home': 1, + 'broadcast_on_personal': 1, + 'broken_image': 11, + 'browse_gallery': 1, + 'browser_not_supported': 12, + 'browser_updated': 2, + 'brunch_dining': 11, + 'brush': 13, + 'bubble_chart': 12, + 'bug_report': 12, + 'build': 12, + 'build_circle': 14, + 'bungalow': 4, + 'burst_mode': 12, + 'bus_alert': 15, + 'business': 12, + 'business_center': 15, + 'cabin': 4, + 'cable': 10, + 'cached': 13, + 'cake': 13, + 'calculate': 11, + 'calendar_month': 1, + 'calendar_today': 12, + 'calendar_view_day': 15, + 'calendar_view_month': 10, + 'calendar_view_week': 10, + 'call': 17, + 'call_end': 16, + 'call_made': 13, + 'call_merge': 13, + 'call_missed': 13, + 'call_missed_outgoing': 15, + 'call_received': 13, + 'call_split': 13, + 'call_to_action': 11, + 'camera': 12, + 'camera_alt': 12, + 'camera_enhance': 12, + 'camera_front': 12, + 'camera_indoor': 9, + 'camera_outdoor': 9, + 'camera_rear': 13, + 'camera_roll': 12, + 'cameraswitch': 10, + 'campaign': 12, + 'cancel': 16, + 'cancel_presentation': 14, + 'cancel_schedule_send': 12, + 'candlestick_chart': 2, + 'car_crash': 1, + 'car_rental': 10, + 'car_repair': 10, + 'card_giftcard': 14, + 'card_membership': 14, + 'card_travel': 13, + 'carpenter': 6, + 'cases': 12, + 'casino': 13, + 'cast': 18, + 'cast_connected': 13, + 'cast_for_education': 14, + 'castle': 2, + 'catching_pokemon': 4, + 'category': 12, + 'celebration': 11, + 'cell_tower': 1, + 'cell_wifi': 14, + 'center_focus_strong': 12, + 'center_focus_weak': 12, + 'chair': 10, + 'chair_alt': 9, + 'chalet': 4, + 'change_circle': 8, + 'change_history': 11, + 'charging_station': 8, + 'chat': 19, + 'chat_bubble': 15, + 'chat_bubble_outline': 18, + 'check': 18, + 'check_box': 16, + 'check_box_outline_blank': 16, + 'check_circle': 19, + 'check_circle_outline': 12, + 'checklist': 3, + 'checklist_rtl': 3, + 'checkroom': 8, + 'chevron_left': 16, + 'chevron_right': 16, + 'child_care': 12, + 'child_friendly': 12, + 'chrome_reader_mode': 12, + 'church': 2, + 'circle': 11, + 'circle_notifications': 11, + 'class': 11, + 'clean_hands': 8, + 'cleaning_services': 12, + 'clear': 12, + 'clear_all': 12, + 'close': 19, + 'close_fullscreen': 7, + 'closed_caption': 12, + 'closed_caption_disabled': 8, + 'closed_caption_off': 11, + 'cloud': 12, + 'cloud_circle': 12, + 'cloud_done': 12, + 'cloud_download': 12, + 'cloud_off': 16, + 'cloud_queue': 12, + 'cloud_sync': 1, + 'cloud_upload': 12, + 'co2': 3, + 'co_present': 1, + 'code': 11, + 'code_off': 4, + 'coffee': 9, + 'coffee_maker': 9, + 'collections': 12, + 'collections_bookmark': 12, + 'color_lens': 16, + 'colorize': 12, + 'comment': 19, + 'comment_bank': 15, + 'comments_disabled': 3, + 'commit': 1, + 'commute': 12, + 'compare': 12, + 'compare_arrows': 14, + 'compass_calibration': 12, + 'compost': 3, + 'compress': 10, + 'computer': 12, + 'confirmation_number': 15, + 'connect_without_contact': 7, + 'connected_tv': 11, + 'connecting_airports': 2, + 'construction': 11, + 'contact_emergency': 1, + 'contact_mail': 13, + 'contact_page': 6, + 'contact_phone': 13, + 'contact_support': 13, + 'contactless': 11, + 'contacts': 11, + 'content_copy': 17, + 'content_cut': 17, + 'content_paste': 14, + 'content_paste_go': 2, + 'content_paste_off': 4, + 'content_paste_search': 2, + 'contrast': 1, + 'control_camera': 12, + 'control_point': 13, + 'control_point_duplicate': 12, + 'cookie': 2, + 'copy_all': 8, + 'copyright': 17, + 'coronavirus': 7, + 'corporate_fare': 7, + 'cottage': 4, + 'countertops': 6, + 'create': 16, + 'create_new_folder': 11, + 'credit_card': 12, + 'credit_card_off': 4, + 'credit_score': 10, + 'crib': 4, + 'crisis_alert': 1, + 'crop': 12, + 'crop_16_9': 13, + 'crop_3_2': 13, + 'crop_5_4': 13, + 'crop_7_5': 13, + 'crop_din': 12, + 'crop_free': 12, + 'crop_landscape': 12, + 'crop_original': 13, + 'crop_portrait': 12, + 'crop_rotate': 12, + 'crop_square': 12, + 'cruelty_free': 3, + 'css': 1, + 'currency_bitcoin': 1, + 'currency_exchange': 1, + 'currency_franc': 1, + 'currency_lira': 1, + 'currency_pound': 1, + 'currency_ruble': 1, + 'currency_rupee': 1, + 'currency_yen': 1, + 'currency_yuan': 1, + 'curtains': 1, + 'curtains_closed': 1, + 'cyclone': 1, + 'dangerous': 12, + 'dark_mode': 4, + 'dashboard': 13, + 'dashboard_customize': 11, + 'data_array': 2, + 'data_exploration': 3, + 'data_object': 2, + 'data_saver_off': 10, + 'data_saver_on': 10, + 'data_thresholding': 1, + 'data_usage': 12, + 'dataset': 1, + 'dataset_linked': 1, + 'date_range': 12, + 'deblur': 1, + 'deck': 11, + 'dehaze': 12, + 'delete': 17, + 'delete_forever': 15, + 'delete_outline': 11, + 'delete_sweep': 12, + 'delivery_dining': 15, + 'density_large': 1, + 'density_medium': 1, + 'density_small': 1, + 'departure_board': 11, + 'description': 12, + 'deselect': 1, + 'design_services': 12, + 'desk': 1, + 'desktop_access_disabled': 11, + 'desktop_mac': 17, + 'desktop_windows': 13, + 'details': 16, + 'developer_board': 13, + 'developer_board_off': 4, + 'developer_mode': 12, + 'device_hub': 12, + 'device_thermostat': 11, + 'device_unknown': 12, + 'devices': 17, + 'devices_fold': 1, + 'devices_other': 12, + 'dialer_sip': 12, + 'dialpad': 12, + 'diamond': 2, + 'difference': 1, + 'dining': 9, + 'dinner_dining': 10, + 'directions': 14, + 'directions_bike': 13, + 'directions_boat': 17, + 'directions_boat_filled': 16, + 'directions_bus': 12, + 'directions_bus_filled': 10, + 'directions_car': 18, + 'directions_car_filled': 10, + 'directions_off': 14, + 'directions_railway': 13, + 'directions_railway_filled': 10, + 'directions_run': 13, + 'directions_subway': 12, + 'directions_subway_filled': 10, + 'directions_transit': 12, + 'directions_transit_filled': 10, + 'directions_walk': 13, + 'dirty_lens': 15, + 'disabled_by_default': 6, + 'disabled_visible': 3, + 'disc_full': 12, + 'discount': 1, + 'display_settings': 1, + 'diversity_1': 1, + 'diversity_2': 1, + 'diversity_3': 1, + 'dns': 12, + 'do_disturb': 10, + 'do_disturb_alt': 11, + 'do_disturb_off': 10, + 'do_disturb_on': 10, + 'do_not_disturb': 11, + 'do_not_disturb_alt': 12, + 'do_not_disturb_off': 11, + 'do_not_disturb_on': 11, + 'do_not_disturb_on_total_silence': 15, + 'do_not_step': 8, + 'do_not_touch': 8, + 'dock': 12, + 'document_scanner': 3, + 'domain': 16, + 'domain_add': 1, + 'domain_disabled': 12, + 'domain_verification': 11, + 'done': 19, + 'done_all': 12, + 'done_outline': 12, + 'donut_large': 13, + 'donut_small': 12, + 'door_back': 10, + 'door_front': 10, + 'door_sliding': 10, + 'doorbell': 10, + 'double_arrow': 11, + 'downhill_skiing': 4, + 'download': 10, + 'download_done': 10, + 'download_for_offline': 9, + 'downloading': 9, + 'drafts': 17, + 'drag_handle': 14, + 'drag_indicator': 15, + 'draw': 6, + 'drive_eta': 12, + 'drive_file_move': 19, + 'drive_file_move_rtl': 3, + 'drive_file_rename_outline': 12, + 'drive_folder_upload': 11, + 'dry': 8, + 'dry_cleaning': 10, + 'duo': 12, + 'dvr': 13, + 'dynamic_feed': 15, + 'dynamic_form': 7, + 'e_mobiledata': 9, + 'earbuds': 9, + 'earbuds_battery': 9, + 'east': 7, + 'edgesensor_high': 10, + 'edgesensor_low': 10, + 'edit': 12, + 'edit_attributes': 12, + 'edit_calendar': 5, + 'edit_location': 16, + 'edit_location_alt': 8, + 'edit_note': 5, + 'edit_notifications': 4, + 'edit_off': 15, + 'edit_road': 11, + 'egg': 2, + 'egg_alt': 2, + 'eject': 11, + 'elderly': 7, + 'elderly_woman': 1, + 'electric_bike': 14, + 'electric_bolt': 1, + 'electric_car': 14, + 'electric_meter': 1, + 'electric_moped': 14, + 'electric_rickshaw': 15, + 'electric_scooter': 14, + 'electrical_services': 12, + 'elevator': 8, + 'email': 18, + 'emergency': 5, + 'emergency_recording': 1, + 'emergency_share': 1, + 'emoji_emotions': 11, + 'emoji_events': 13, + 'emoji_food_beverage': 11, + 'emoji_nature': 11, + 'emoji_objects': 11, + 'emoji_people': 11, + 'emoji_symbols': 11, + 'emoji_transportation': 11, + 'energy_savings_leaf': 1, + 'engineering': 11, + 'enhanced_encryption': 19, + 'equalizer': 12, + 'error': 20, + 'error_outline': 16, + 'escalator': 8, + 'escalator_warning': 8, + 'euro': 12, + 'euro_symbol': 13, + 'ev_station': 11, + 'event': 21, + 'event_available': 17, + 'event_busy': 20, + 'event_note': 14, + 'event_repeat': 1, + 'event_seat': 19, + 'exit_to_app': 13, + 'expand': 11, + 'expand_circle_down': 2, + 'expand_less': 12, + 'expand_more': 14, + 'explicit': 12, + 'explore': 12, + 'explore_off': 12, + 'exposure': 12, + 'exposure_neg_1': 13, + 'exposure_neg_2': 13, + 'exposure_plus_1': 12, + 'exposure_plus_2': 12, + 'exposure_zero': 12, + 'extension': 12, + 'extension_off': 4, + 'face': 15, + 'face_2': 1, + 'face_3': 1, + 'face_4': 1, + 'face_5': 1, + 'face_6': 1, + 'face_retouching_natural': 16, + 'face_retouching_off': 15, + 'fact_check': 14, + 'factory': 1, + 'family_restroom': 9, + 'fast_forward': 18, + 'fast_rewind': 12, + 'fastfood': 12, + 'favorite': 17, + 'favorite_border': 12, + 'fax': 2, + 'featured_play_list': 12, + 'featured_video': 12, + 'feed': 10, + 'feedback': 12, + 'female': 3, + 'fence': 6, + 'festival': 10, + 'fiber_dvr': 13, + 'fiber_manual_record': 16, + 'fiber_new': 13, + 'fiber_pin': 12, + 'fiber_smart_record': 12, + 'file_copy': 12, + 'file_download': 11, + 'file_download_done': 11, + 'file_download_off': 4, + 'file_open': 1, + 'file_present': 10, + 'file_upload': 11, + 'filter': 12, + 'filter_1': 12, + 'filter_2': 12, + 'filter_3': 12, + 'filter_4': 12, + 'filter_5': 12, + 'filter_6': 12, + 'filter_7': 12, + 'filter_8': 12, + 'filter_9': 12, + 'filter_9_plus': 13, + 'filter_alt': 11, + 'filter_alt_off': 1, + 'filter_b_and_w': 13, + 'filter_center_focus': 12, + 'filter_drama': 12, + 'filter_frames': 13, + 'filter_hdr': 13, + 'filter_list': 13, + 'filter_list_off': 1, + 'filter_none': 13, + 'filter_tilt_shift': 14, + 'filter_vintage': 13, + 'find_in_page': 12, + 'find_replace': 12, + 'fingerprint': 12, + 'fire_extinguisher': 7, + 'fire_hydrant_alt': 1, + 'fire_truck': 1, + 'fireplace': 11, + 'first_page': 11, + 'fit_screen': 15, + 'fitbit': 2, + 'fitness_center': 12, + 'flag': 18, + 'flag_circle': 1, + 'flaky': 13, + 'flare': 12, + 'flash_auto': 12, + 'flash_off': 12, + 'flash_on': 12, + 'flashlight_off': 10, + 'flashlight_on': 10, + 'flatware': 9, + 'flight': 18, + 'flight_class': 2, + 'flight_land': 13, + 'flight_takeoff': 13, + 'flip': 12, + 'flip_camera_android': 12, + 'flip_camera_ios': 12, + 'flip_to_back': 12, + 'flip_to_front': 12, + 'flood': 1, + 'fluorescent': 1, + 'flutter_dash': 5, + 'fmd_bad': 15, + 'fmd_good': 15, + 'folder': 16, + 'folder_copy': 1, + 'folder_delete': 1, + 'folder_off': 1, + 'folder_open': 13, + 'folder_shared': 16, + 'folder_special': 11, + 'folder_zip': 1, + 'follow_the_signs': 7, + 'font_download': 11, + 'font_download_off': 4, + 'food_bank': 7, + 'forest': 2, + 'fork_left': 1, + 'fork_right': 1, + 'format_align_center': 12, + 'format_align_justify': 12, + 'format_align_left': 13, + 'format_align_right': 13, + 'format_bold': 12, + 'format_clear': 12, + 'format_color_fill': 14, + 'format_color_reset': 11, + 'format_color_text': 17, + 'format_indent_decrease': 13, + 'format_indent_increase': 13, + 'format_italic': 12, + 'format_line_spacing': 12, + 'format_list_bulleted': 13, + 'format_list_numbered': 12, + 'format_list_numbered_rtl': 12, + 'format_overline': 1, + 'format_paint': 12, + 'format_quote': 12, + 'format_shapes': 12, + 'format_size': 12, + 'format_strikethrough': 12, + 'format_textdirection_l_to_r': 13, + 'format_textdirection_r_to_l': 13, + 'format_underlined': 13, + 'fort': 2, + 'forum': 19, + 'forward': 17, + 'forward_10': 13, + 'forward_30': 13, + 'forward_5': 13, + 'forward_to_inbox': 9, + 'foundation': 6, + 'free_breakfast': 11, + 'free_cancellation': 5, + 'front_hand': 3, + 'fullscreen': 12, + 'fullscreen_exit': 12, + 'functions': 12, + 'g_mobiledata': 9, + 'g_translate': 15, + 'gamepad': 12, + 'games': 12, + 'garage': 10, + 'gas_meter': 1, + 'gavel': 14, + 'generating_tokens': 5, + 'gesture': 12, + 'get_app': 12, + 'gif': 14, + 'gif_box': 3, + 'girl': 1, + 'gite': 4, + 'golf_course': 11, + 'gpp_bad': 10, + 'gpp_good': 9, + 'gpp_maybe': 10, + 'gps_fixed': 12, + 'gps_not_fixed': 12, + 'gps_off': 12, + 'grade': 16, + 'gradient': 12, + 'grading': 12, + 'grain': 12, + 'graphic_eq': 13, + 'grass': 7, + 'grid_3x3': 10, + 'grid_4x4': 10, + 'grid_goldenratio': 10, + 'grid_off': 12, + 'grid_on': 12, + 'grid_view': 12, + 'group': 19, + 'group_add': 18, + 'group_off': 5, + 'group_remove': 3, + 'group_work': 12, + 'groups': 6, + 'groups_2': 1, + 'groups_3': 1, + 'h_mobiledata': 9, + 'h_plus_mobiledata': 9, + 'hail': 11, + 'handshake': 1, + 'handyman': 12, + 'hardware': 10, + 'hd': 12, + 'hdr_auto': 10, + 'hdr_auto_select': 10, + 'hdr_enhanced_select': 11, + 'hdr_off': 11, + 'hdr_off_select': 10, + 'hdr_on': 11, + 'hdr_on_select': 10, + 'hdr_plus': 10, + 'hdr_strong': 12, + 'hdr_weak': 12, + 'headphones': 9, + 'headphones_battery': 9, + 'headset': 12, + 'headset_mic': 12, + 'headset_off': 12, + 'healing': 12, + 'health_and_safety': 7, + 'hearing': 12, + 'hearing_disabled': 14, + 'heart_broken': 2, + 'heat_pump': 1, + 'height': 11, + 'help': 13, + 'help_center': 9, + 'help_outline': 12, + 'hevc': 10, + 'hexagon': 1, + 'hide_image': 10, + 'hide_source': 9, + 'high_quality': 12, + 'highlight': 14, + 'highlight_alt': 12, + 'highlight_off': 17, + 'hiking': 4, + 'history': 12, + 'history_edu': 11, + 'history_toggle_off': 9, + 'hive': 2, + 'hls': 1, + 'hls_off': 1, + 'holiday_village': 4, + 'home': 16, + 'home_max': 10, + 'home_mini': 10, + 'home_repair_service': 12, + 'home_work': 13, + 'horizontal_distribute': 6, + 'horizontal_rule': 13, + 'horizontal_split': 15, + 'hot_tub': 12, + 'hotel': 19, + 'hotel_class': 6, + 'hourglass_bottom': 11, + 'hourglass_disabled': 12, + 'hourglass_empty': 15, + 'hourglass_full': 15, + 'hourglass_top': 11, + 'house': 11, + 'house_siding': 6, + 'houseboat': 4, + 'how_to_reg': 14, + 'how_to_vote': 15, + 'html': 1, + 'http': 11, + 'https': 16, + 'hub': 2, + 'hvac': 12, + 'ice_skating': 4, + 'icecream': 11, + 'image': 16, + 'image_aspect_ratio': 12, + 'image_not_supported': 13, + 'image_search': 12, + 'imagesearch_roller': 11, + 'import_contacts': 14, + 'import_export': 12, + 'important_devices': 11, + 'inbox': 18, + 'incomplete_circle': 3, + 'indeterminate_check_box': 18, + 'info': 21, + 'input': 13, + 'insert_chart': 12, + 'insert_chart_outlined': 12, + 'insert_comment': 13, + 'insert_drive_file': 17, + 'insert_emoticon': 15, + 'insert_invitation': 12, + 'insert_link': 12, + 'insert_page_break': 2, + 'insert_photo': 12, + 'insights': 12, + 'install_desktop': 1, + 'install_mobile': 1, + 'integration_instructions': 11, + 'interests': 2, + 'interpreter_mode': 2, + 'inventory': 13, + 'inventory_2': 9, + 'invert_colors': 13, + 'invert_colors_off': 12, + 'ios_share': 13, + 'iron': 4, + 'iso': 11, + 'javascript': 1, + 'join_full': 1, + 'join_inner': 1, + 'join_left': 1, + 'join_right': 1, + 'kayaking': 4, + 'kebab_dining': 2, + 'key': 2, + 'key_off': 1, + 'keyboard': 13, + 'keyboard_alt': 15, + 'keyboard_arrow_down': 18, + 'keyboard_arrow_left': 17, + 'keyboard_arrow_right': 17, + 'keyboard_arrow_up': 19, + 'keyboard_backspace': 13, + 'keyboard_capslock': 13, + 'keyboard_command_key': 1, + 'keyboard_control_key': 1, + 'keyboard_double_arrow_down': 2, + 'keyboard_double_arrow_left': 2, + 'keyboard_double_arrow_right': 2, + 'keyboard_double_arrow_up': 2, + 'keyboard_hide': 13, + 'keyboard_option_key': 1, + 'keyboard_return': 13, + 'keyboard_tab': 13, + 'keyboard_voice': 17, + 'king_bed': 12, + 'kitchen': 12, + 'kitesurfing': 4, + 'label': 17, + 'label_important': 20, + 'label_off': 13, + 'lan': 1, + 'landscape': 12, + 'landslide': 1, + 'language': 12, + 'laptop': 13, + 'laptop_chromebook': 12, + 'laptop_mac': 12, + 'laptop_windows': 12, + 'last_page': 13, + 'launch': 17, + 'layers': 12, + 'layers_clear': 12, + 'leaderboard': 6, + 'leak_add': 12, + 'leak_remove': 12, + 'legend_toggle': 8, + 'lens': 12, + 'lens_blur': 10, + 'library_add': 13, + 'library_add_check': 17, + 'library_books': 14, + 'library_music': 13, + 'light': 9, + 'light_mode': 4, + 'lightbulb': 18, + 'lightbulb_circle': 1, + 'line_axis': 2, + 'line_style': 14, + 'line_weight': 14, + 'linear_scale': 17, + 'link': 21, + 'link_off': 11, + 'linked_camera': 15, + 'liquor': 10, + 'list': 18, + 'list_alt': 13, + 'live_help': 15, + 'live_tv': 11, + 'living': 9, + 'local_activity': 19, + 'local_airport': 14, + 'local_atm': 12, + 'local_bar': 12, + 'local_cafe': 11, + 'local_car_wash': 12, + 'local_convenience_store': 16, + 'local_dining': 21, + 'local_drink': 12, + 'local_fire_department': 15, + 'local_florist': 12, + 'local_gas_station': 13, + 'local_grocery_store': 12, + 'local_hospital': 12, + 'local_hotel': 13, + 'local_laundry_service': 16, + 'local_library': 12, + 'local_mall': 12, + 'local_movies': 12, + 'local_offer': 20, + 'local_parking': 12, + 'local_pharmacy': 12, + 'local_phone': 17, + 'local_pizza': 12, + 'local_play': 12, + 'local_police': 13, + 'local_post_office': 12, + 'local_printshop': 17, + 'local_see': 16, + 'local_shipping': 12, + 'local_taxi': 12, + 'location_city': 12, + 'location_disabled': 12, + 'location_off': 11, + 'location_on': 15, + 'location_searching': 12, + 'lock': 18, + 'lock_clock': 11, + 'lock_open': 16, + 'lock_person': 1, + 'lock_reset': 1, + 'login': 12, + 'logo_dev': 2, + 'logout': 12, + 'looks': 13, + 'looks_3': 12, + 'looks_4': 12, + 'looks_5': 12, + 'looks_6': 13, + 'looks_one': 12, + 'looks_two': 12, + 'loop': 12, + 'loupe': 12, + 'low_priority': 18, + 'loyalty': 12, + 'lte_mobiledata': 9, + 'lte_plus_mobiledata': 9, + 'luggage': 7, + 'lunch_dining': 11, + 'lyrics': 3, + 'macro_off': 1, + 'mail': 16, + 'mail_lock': 1, + 'mail_outline': 12, + 'male': 3, + 'man': 2, + 'man_2': 1, + 'man_3': 1, + 'man_4': 1, + 'manage_accounts': 10, + 'manage_history': 1, + 'manage_search': 10, + 'map': 16, + 'maps_home_work': 10, + 'maps_ugc': 13, + 'margin': 11, + 'mark_as_unread': 10, + 'mark_chat_read': 8, + 'mark_chat_unread': 8, + 'mark_email_read': 8, + 'mark_email_unread': 8, + 'mark_unread_chat_alt': 1, + 'markunread': 12, + 'markunread_mailbox': 12, + 'masks': 7, + 'maximize': 11, + 'media_bluetooth_off': 10, + 'media_bluetooth_on': 10, + 'mediation': 15, + 'medical_information': 1, + 'medical_services': 12, + 'medication': 10, + 'medication_liquid': 2, + 'meeting_room': 17, + 'memory': 12, + 'menu': 13, + 'menu_book': 12, + 'menu_open': 13, + 'merge': 1, + 'merge_type': 13, + 'message': 13, + 'mic': 13, + 'mic_external_off': 11, + 'mic_external_on': 11, + 'mic_none': 12, + 'mic_off': 14, + 'microwave': 6, + 'military_tech': 11, + 'minimize': 11, + 'minor_crash': 1, + 'miscellaneous_services': 12, + 'missed_video_call': 13, + 'mms': 12, + 'mobile_friendly': 12, + 'mobile_off': 12, + 'mobile_screen_share': 12, + 'mobiledata_off': 10, + 'mode': 10, + 'mode_comment': 12, + 'mode_edit': 11, + 'mode_edit_outline': 10, + 'mode_fan_off': 1, + 'mode_night': 10, + 'mode_of_travel': 2, + 'mode_standby': 9, + 'model_training': 13, + 'monetization_on': 12, + 'money': 11, + 'money_off': 12, + 'money_off_csred': 11, + 'monitor': 11, + 'monitor_heart': 2, + 'monitor_weight': 10, + 'monochrome_photos': 11, + 'mood': 16, + 'mood_bad': 12, + 'moped': 14, + 'more': 13, + 'more_horiz': 13, + 'more_time': 11, + 'more_vert': 19, + 'mosque': 2, + 'motion_photos_auto': 15, + 'motion_photos_off': 14, + 'motion_photos_on': 18, + 'motion_photos_pause': 8, + 'motion_photos_paused': 17, + 'mouse': 12, + 'move_down': 2, + 'move_to_inbox': 15, + 'move_up': 2, + 'movie': 12, + 'movie_creation': 12, + 'movie_filter': 13, + 'moving': 4, + 'mp': 15, + 'multiline_chart': 13, + 'multiple_stop': 7, + 'museum': 11, + 'music_note': 12, + 'music_off': 12, + 'music_video': 15, + 'my_location': 12, + 'nat': 11, + 'nature': 12, + 'nature_people': 12, + 'navigate_before': 13, + 'navigate_next': 13, + 'navigation': 12, + 'near_me': 12, + 'near_me_disabled': 6, + 'nearby_error': 10, + 'nearby_off': 10, + 'nest_cam_wired_stand': 1, + 'network_cell': 16, + 'network_check': 12, + 'network_locked': 17, + 'network_ping': 1, + 'network_wifi': 16, + 'network_wifi_1_bar': 1, + 'network_wifi_2_bar': 1, + 'network_wifi_3_bar': 1, + 'new_label': 3, + 'new_releases': 13, + 'newspaper': 1, + 'next_plan': 16, + 'next_week': 14, + 'nfc': 12, + 'night_shelter': 6, + 'nightlife': 10, + 'nightlight': 10, + 'nightlight_round': 10, + 'nights_stay': 12, + 'no_accounts': 10, + 'no_adult_content': 1, + 'no_backpack': 6, + 'no_cell': 8, + 'no_crash': 1, + 'no_drinks': 8, + 'no_encryption': 17, + 'no_encryption_gmailerrorred': 10, + 'no_flash': 8, + 'no_food': 8, + 'no_luggage': 7, + 'no_meals': 7, + 'no_meeting_room': 11, + 'no_photography': 8, + 'no_sim': 12, + 'no_stroller': 9, + 'no_transfer': 7, + 'noise_aware': 2, + 'noise_control_off': 2, + 'nordic_walking': 4, + 'north': 7, + 'north_east': 7, + 'north_west': 7, + 'not_accessible': 13, + 'not_interested': 12, + 'not_listed_location': 18, + 'not_started': 13, + 'note': 12, + 'note_add': 13, + 'note_alt': 10, + 'notes': 12, + 'notification_add': 5, + 'notification_important': 12, + 'notifications': 18, + 'notifications_active': 13, + 'notifications_none': 12, + 'notifications_off': 12, + 'notifications_paused': 12, + 'numbers': 2, + 'offline_bolt': 18, + 'offline_pin': 18, + 'offline_share': 12, + 'oil_barrel': 1, + 'on_device_training': 1, + 'ondemand_video': 11, + 'online_prediction': 12, + 'opacity': 15, + 'open_in_browser': 12, + 'open_in_full': 7, + 'open_in_new': 21, + 'open_in_new_off': 4, + 'open_with': 12, + 'other_houses': 4, + 'outbound': 8, + 'outbox': 10, + 'outdoor_grill': 11, + 'outlet': 7, + 'outlined_flag': 12, + 'output': 1, + 'padding': 11, + 'pages': 11, + 'pageview': 12, + 'paid': 9, + 'palette': 16, + 'pan_tool': 14, + 'pan_tool_alt': 1, + 'panorama': 17, + 'panorama_fish_eye': 13, + 'panorama_horizontal': 12, + 'panorama_horizontal_select': 11, + 'panorama_photosphere': 11, + 'panorama_photosphere_select': 12, + 'panorama_vertical': 13, + 'panorama_vertical_select': 12, + 'panorama_wide_angle': 14, + 'panorama_wide_angle_select': 12, + 'paragliding': 4, + 'park': 10, + 'party_mode': 12, + 'password': 10, + 'pattern': 11, + 'pause': 16, + 'pause_circle': 10, + 'pause_circle_filled': 15, + 'pause_circle_outline': 19, + 'pause_presentation': 12, + 'payment': 12, + 'payments': 13, + 'pedal_bike': 14, + 'pending': 17, + 'pending_actions': 7, + 'pentagon': 1, + 'people': 23, + 'people_alt': 21, + 'people_outline': 12, + 'percent': 1, + 'perm_camera_mic': 12, + 'perm_contact_calendar': 13, + 'perm_data_setting': 12, + 'perm_device_information': 13, + 'perm_identity': 12, + 'perm_media': 15, + 'perm_phone_msg': 12, + 'perm_scan_wifi': 12, + 'person': 17, + 'person_2': 1, + 'person_3': 1, + 'person_4': 1, + 'person_add': 16, + 'person_add_alt': 11, + 'person_add_alt_1': 13, + 'person_add_disabled': 11, + 'person_off': 4, + 'person_outline': 19, + 'person_pin': 13, + 'person_pin_circle': 18, + 'person_remove': 12, + 'person_remove_alt_1': 13, + 'person_search': 12, + 'personal_injury': 3, + 'personal_video': 13, + 'pest_control': 12, + 'pest_control_rodent': 12, + 'pets': 12, + 'phishing': 2, + 'phone': 12, + 'phone_android': 12, + 'phone_bluetooth_speaker': 12, + 'phone_callback': 13, + 'phone_disabled': 12, + 'phone_enabled': 16, + 'phone_forwarded': 13, + 'phone_iphone': 12, + 'phone_locked': 19, + 'phone_missed': 13, + 'phone_paused': 13, + 'phonelink': 12, + 'phonelink_erase': 12, + 'phonelink_lock': 15, + 'phonelink_off': 12, + 'phonelink_ring': 12, + 'phonelink_setup': 13, + 'photo': 12, + 'photo_album': 14, + 'photo_camera': 18, + 'photo_camera_back': 11, + 'photo_camera_front': 11, + 'photo_filter': 12, + 'photo_library': 12, + 'photo_size_select_actual': 12, + 'photo_size_select_large': 12, + 'photo_size_select_small': 12, + 'php': 1, + 'piano': 4, + 'piano_off': 4, + 'picture_as_pdf': 12, + 'picture_in_picture': 12, + 'picture_in_picture_alt': 12, + 'pie_chart': 11, + 'pie_chart_outline': 10, + 'pin': 10, + 'pin_drop': 18, + 'pin_end': 3, + 'pin_invoke': 3, + 'pinch': 1, + 'pivot_table_chart': 11, + 'pix': 2, + 'place': 19, + 'plagiarism': 11, + 'play_arrow': 16, + 'play_circle': 9, + 'play_circle_filled': 14, + 'play_circle_outline': 19, + 'play_disabled': 10, + 'play_for_work': 12, + 'play_lesson': 10, + 'playlist_add': 16, + 'playlist_add_check': 17, + 'playlist_add_check_circle': 2, + 'playlist_add_circle': 2, + 'playlist_play': 14, + 'playlist_remove': 1, + 'plumbing': 12, + 'plus_one': 17, + 'podcasts': 9, + 'point_of_sale': 8, + 'policy': 12, + 'poll': 13, + 'polyline': 1, + 'polymer': 12, + 'pool': 12, + 'portable_wifi_off': 12, + 'portrait': 12, + 'post_add': 11, + 'power': 12, + 'power_input': 12, + 'power_off': 12, + 'power_settings_new': 12, + 'precision_manufacturing': 10, + 'pregnant_woman': 14, + 'present_to_all': 11, + 'preview': 7, + 'price_change': 10, + 'price_check': 10, + 'print': 16, + 'print_disabled': 12, + 'priority_high': 19, + 'privacy_tip': 12, + 'private_connectivity': 5, + 'production_quantity_limits': 7, + 'propane': 1, + 'propane_tank': 1, + 'psychology': 11, + 'psychology_alt': 1, + 'public': 13, + 'public_off': 8, + 'publish': 12, + 'published_with_changes': 6, + 'punch_clock': 2, + 'push_pin': 13, + 'qr_code': 12, + 'qr_code_2': 6, + 'qr_code_scanner': 7, + 'query_builder': 13, + 'query_stats': 4, + 'question_answer': 12, + 'question_mark': 1, + 'queue': 12, + 'queue_music': 18, + 'queue_play_next': 14, + 'quickreply': 12, + 'quiz': 10, + 'r_mobiledata': 9, + 'radar': 10, + 'radio': 12, + 'radio_button_checked': 13, + 'radio_button_unchecked': 13, + 'railway_alert': 10, + 'ramen_dining': 10, + 'ramp_left': 1, + 'ramp_right': 1, + 'rate_review': 11, + 'raw_off': 10, + 'raw_on': 10, + 'read_more': 13, + 'real_estate_agent': 3, + 'receipt': 12, + 'receipt_long': 12, + 'recent_actors': 12, + 'recommend': 14, + 'record_voice_over': 12, + 'rectangle': 1, + 'recycling': 4, + 'redeem': 12, + 'redo': 13, + 'reduce_capacity': 6, + 'refresh': 16, + 'remember_me': 10, + 'remove': 16, + 'remove_circle': 19, + 'remove_circle_outline': 12, + 'remove_done': 10, + 'remove_from_queue': 13, + 'remove_moderator': 11, + 'remove_red_eye': 12, + 'remove_road': 1, + 'remove_shopping_cart': 12, + 'reorder': 16, + 'repartition': 1, + 'repeat': 12, + 'repeat_on': 12, + 'repeat_one': 12, + 'repeat_one_on': 12, + 'replay': 13, + 'replay_10': 14, + 'replay_30': 14, + 'replay_5': 14, + 'replay_circle_filled': 12, + 'reply': 20, + 'reply_all': 17, + 'report': 18, + 'report_gmailerrorred': 11, + 'report_off': 12, + 'report_problem': 18, + 'request_page': 7, + 'request_quote': 7, + 'reset_tv': 10, + 'restart_alt': 10, + 'restaurant': 16, + 'restaurant_menu': 12, + 'restore': 12, + 'restore_from_trash': 12, + 'restore_page': 12, + 'reviews': 10, + 'rice_bowl': 6, + 'ring_volume': 13, + 'rocket': 1, + 'rocket_launch': 1, + 'roller_shades': 1, + 'roller_shades_closed': 1, + 'roller_skating': 1, + 'roofing': 6, + 'room': 19, + 'room_preferences': 7, + 'room_service': 11, + 'rotate_90_degrees_ccw': 12, + 'rotate_90_degrees_cw': 2, + 'rotate_left': 13, + 'rotate_right': 13, + 'roundabout_left': 1, + 'roundabout_right': 1, + 'rounded_corner': 15, + 'route': 2, + 'router': 13, + 'rowing': 14, + 'rss_feed': 12, + 'rsvp': 10, + 'rtt': 15, + 'rule': 8, + 'rule_folder': 7, + 'run_circle': 11, + 'running_with_errors': 4, + 'rv_hookup': 12, + 'safety_check': 1, + 'safety_divider': 7, + 'sailing': 4, + 'sanitizer': 6, + 'satellite': 12, + 'satellite_alt': 1, + 'save': 12, + 'save_alt': 12, + 'save_as': 2, + 'saved_search': 11, + 'savings': 5, + 'scale': 1, + 'scanner': 12, + 'scatter_plot': 12, + 'schedule': 17, + 'schedule_send': 14, + 'schema': 4, + 'school': 13, + 'science': 17, + 'score': 12, + 'scoreboard': 1, + 'screen_lock_landscape': 16, + 'screen_lock_portrait': 17, + 'screen_lock_rotation': 16, + 'screen_rotation': 12, + 'screen_rotation_alt': 1, + 'screen_search_desktop': 10, + 'screen_share': 12, + 'screenshot': 10, + 'screenshot_monitor': 1, + 'scuba_diving': 1, + 'sd': 10, + 'sd_card': 12, + 'sd_card_alert': 11, + 'sd_storage': 12, + 'search': 17, + 'search_off': 12, + 'security': 12, + 'security_update': 11, + 'security_update_good': 10, + 'security_update_warning': 10, + 'segment': 11, + 'select_all': 12, + 'self_improvement': 11, + 'sell': 10, + 'send': 20, + 'send_and_archive': 11, + 'send_time_extension': 2, + 'send_to_mobile': 11, + 'sensor_door': 8, + 'sensor_occupied': 1, + 'sensor_window': 8, + 'sensors': 4, + 'sensors_off': 5, + 'sentiment_dissatisfied': 11, + 'sentiment_neutral': 11, + 'sentiment_satisfied': 11, + 'sentiment_satisfied_alt': 11, + 'sentiment_very_dissatisfied': 11, + 'sentiment_very_satisfied': 11, + 'set_meal': 6, + 'settings': 19, + 'settings_accessibility': 10, + 'settings_applications': 11, + 'settings_backup_restore': 11, + 'settings_bluetooth': 11, + 'settings_brightness': 12, + 'settings_cell': 11, + 'settings_ethernet': 11, + 'settings_input_antenna': 11, + 'settings_input_component': 11, + 'settings_input_composite': 11, + 'settings_input_hdmi': 11, + 'settings_input_svideo': 11, + 'settings_overscan': 11, + 'settings_phone': 15, + 'settings_power': 11, + 'settings_remote': 11, + 'settings_suggest': 20, + 'settings_system_daydream': 12, + 'settings_voice': 11, + 'severe_cold': 1, + 'shape_line': 1, + 'share': 18, + 'share_location': 17, + 'shield': 11, + 'shield_moon': 2, + 'shop': 12, + 'shop_2': 9, + 'shop_two': 11, + 'shopping_bag': 9, + 'shopping_basket': 12, + 'shopping_cart': 18, + 'shopping_cart_checkout': 1, + 'short_text': 15, + 'shortcut': 10, + 'show_chart': 13, + 'shower': 9, + 'shuffle': 14, + 'shuffle_on': 12, + 'shutter_speed': 11, + 'sick': 6, + 'sign_language': 1, + 'signal_cellular_0_bar': 15, + 'signal_cellular_4_bar': 12, + 'signal_cellular_alt': 12, + 'signal_cellular_alt_1_bar': 1, + 'signal_cellular_alt_2_bar': 1, + 'signal_cellular_connected_no_internet_0_bar': 13, + 'signal_cellular_connected_no_internet_4_bar': 13, + 'signal_cellular_no_sim': 11, + 'signal_cellular_nodata': 10, + 'signal_cellular_null': 12, + 'signal_cellular_off': 12, + 'signal_wifi_0_bar': 15, + 'signal_wifi_4_bar': 12, + 'signal_wifi_4_bar_lock': 13, + 'signal_wifi_bad': 10, + 'signal_wifi_connected_no_internet_4': 10, + 'signal_wifi_off': 17, + 'signal_wifi_statusbar_4_bar': 9, + 'signal_wifi_statusbar_connected_no_internet_4': 11, + 'signal_wifi_statusbar_null': 9, + 'signpost': 1, + 'sim_card': 12, + 'sim_card_alert': 11, + 'sim_card_download': 9, + 'single_bed': 11, + 'sip': 10, + 'skateboarding': 4, + 'skip_next': 16, + 'skip_previous': 16, + 'sledding': 5, + 'slideshow': 12, + 'slow_motion_video': 11, + 'smart_button': 7, + 'smart_display': 9, + 'smart_screen': 9, + 'smart_toy': 9, + 'smartphone': 16, + 'smoke_free': 12, + 'smoking_rooms': 12, + 'sms': 12, + 'sms_failed': 13, + 'snippet_folder': 7, + 'snooze': 12, + 'snowboarding': 4, + 'snowmobile': 4, + 'snowshoeing': 4, + 'soap': 8, + 'social_distance': 7, + 'solar_power': 1, + 'sort': 13, + 'sort_by_alpha': 11, + 'sos': 1, + 'soup_kitchen': 2, + 'source': 7, + 'south': 7, + 'south_america': 2, + 'south_east': 7, + 'south_west': 7, + 'spa': 12, + 'space_bar': 11, + 'space_dashboard': 3, + 'spatial_audio': 1, + 'spatial_audio_off': 1, + 'spatial_tracking': 1, + 'speaker': 16, + 'speaker_group': 12, + 'speaker_notes': 13, + 'speaker_notes_off': 13, + 'speaker_phone': 15, + 'speed': 12, + 'spellcheck': 12, + 'splitscreen': 11, + 'spoke': 2, + 'sports': 11, + 'sports_bar': 7, + 'sports_baseball': 11, + 'sports_basketball': 11, + 'sports_cricket': 11, + 'sports_esports': 11, + 'sports_football': 12, + 'sports_golf': 11, + 'sports_gymnastics': 1, + 'sports_handball': 11, + 'sports_hockey': 11, + 'sports_kabaddi': 11, + 'sports_martial_arts': 1, + 'sports_mma': 11, + 'sports_motorsports': 11, + 'sports_rugby': 11, + 'sports_score': 10, + 'sports_soccer': 11, + 'sports_tennis': 11, + 'sports_volleyball': 11, + 'square': 1, + 'square_foot': 11, + 'ssid_chart': 1, + 'stacked_bar_chart': 12, + 'stacked_line_chart': 6, + 'stadium': 1, + 'stairs': 8, + 'star': 22, + 'star_border': 19, + 'star_border_purple500': 10, + 'star_half': 22, + 'star_outline': 11, + 'star_purple500': 10, + 'star_rate': 12, + 'stars': 12, + 'start': 1, + 'stay_current_landscape': 12, + 'stay_current_portrait': 12, + 'stay_primary_landscape': 12, + 'stay_primary_portrait': 12, + 'sticky_note_2': 7, + 'stop': 12, + 'stop_circle': 15, + 'stop_screen_share': 13, + 'storage': 13, + 'store': 11, + 'store_mall_directory': 12, + 'storefront': 17, + 'storm': 10, + 'straight': 1, + 'straighten': 12, + 'stream': 11, + 'streetview': 12, + 'strikethrough_s': 14, + 'stroller': 8, + 'style': 12, + 'subdirectory_arrow_left': 11, + 'subdirectory_arrow_right': 11, + 'subject': 13, + 'subscript': 13, + 'subscriptions': 11, + 'subtitles': 12, + 'subtitles_off': 11, + 'subway': 11, + 'summarize': 10, + 'superscript': 12, + 'supervised_user_circle': 12, + 'supervisor_account': 13, + 'support': 11, + 'support_agent': 12, + 'surfing': 4, + 'surround_sound': 13, + 'swap_calls': 12, + 'swap_horiz': 12, + 'swap_horizontal_circle': 12, + 'swap_vert': 12, + 'swap_vertical_circle': 13, + 'swipe': 12, + 'swipe_down': 1, + 'swipe_down_alt': 1, + 'swipe_left': 1, + 'swipe_left_alt': 1, + 'swipe_right': 1, + 'swipe_right_alt': 1, + 'swipe_up': 1, + 'swipe_up_alt': 1, + 'swipe_vertical': 1, + 'switch_access_shortcut': 3, + 'switch_access_shortcut_add': 3, + 'switch_account': 11, + 'switch_camera': 12, + 'switch_left': 8, + 'switch_right': 9, + 'switch_video': 12, + 'synagogue': 2, + 'sync': 17, + 'sync_alt': 12, + 'sync_disabled': 16, + 'sync_lock': 1, + 'sync_problem': 21, + 'system_security_update': 10, + 'system_security_update_good': 10, + 'system_security_update_warning': 10, + 'system_update': 12, + 'system_update_alt': 13, + 'tab': 12, + 'tab_unselected': 12, + 'table_bar': 2, + 'table_chart': 12, + 'table_restaurant': 2, + 'table_rows': 17, + 'table_view': 8, + 'tablet': 12, + 'tablet_android': 14, + 'tablet_mac': 12, + 'tag': 10, + 'tag_faces': 19, + 'takeout_dining': 16, + 'tap_and_play': 12, + 'tapas': 6, + 'task': 10, + 'task_alt': 6, + 'taxi_alert': 11, + 'temple_buddhist': 2, + 'temple_hindu': 3, + 'terminal': 1, + 'terrain': 12, + 'text_decrease': 1, + 'text_fields': 14, + 'text_format': 12, + 'text_increase': 1, + 'text_rotate_up': 12, + 'text_rotate_vertical': 12, + 'text_rotation_angledown': 12, + 'text_rotation_angleup': 12, + 'text_rotation_down': 12, + 'text_rotation_none': 12, + 'text_snippet': 8, + 'textsms': 12, + 'texture': 12, + 'theater_comedy': 11, + 'theaters': 12, + 'thermostat': 11, + 'thermostat_auto': 10, + 'thumb_down': 19, + 'thumb_down_alt': 11, + 'thumb_down_off_alt': 13, + 'thumb_up': 19, + 'thumb_up_alt': 11, + 'thumb_up_off_alt': 13, + 'thumbs_up_down': 12, + 'thunderstorm': 1, + 'time_to_leave': 12, + 'timelapse': 12, + 'timeline': 13, + 'timer': 13, + 'timer_10': 12, + 'timer_10_select': 15, + 'timer_3': 12, + 'timer_3_select': 15, + 'timer_off': 13, + 'tips_and_updates': 3, + 'tire_repair': 1, + 'title': 11, + 'toc': 13, + 'today': 17, + 'toggle_off': 12, + 'toggle_on': 12, + 'token': 2, + 'toll': 12, + 'tonality': 12, + 'topic': 7, + 'tornado': 1, + 'touch_app': 13, + 'tour': 12, + 'toys': 14, + 'track_changes': 11, + 'traffic': 12, + 'train': 11, + 'tram': 11, + 'transcribe': 1, + 'transfer_within_a_station': 12, + 'transform': 12, + 'transgender': 3, + 'transit_enterexit': 12, + 'translate': 16, + 'travel_explore': 8, + 'trending_down': 18, + 'trending_flat': 19, + 'trending_up': 19, + 'trip_origin': 12, + 'troubleshoot': 10, + 'try': 9, + 'tsunami': 1, + 'tty': 8, + 'tune': 12, + 'tungsten': 10, + 'turn_left': 1, + 'turn_right': 1, + 'turn_sharp_left': 1, + 'turn_sharp_right': 1, + 'turn_slight_left': 1, + 'turn_slight_right': 1, + 'turned_in': 11, + 'turned_in_not': 11, + 'tv': 16, + 'tv_off': 11, + 'two_wheeler': 14, + 'type_specimen': 1, + 'u_turn_left': 1, + 'u_turn_right': 1, + 'umbrella': 8, + 'unarchive': 13, + 'undo': 13, + 'unfold_less': 12, + 'unfold_less_double': 1, + 'unfold_more': 12, + 'unfold_more_double': 1, + 'unpublished': 7, + 'unsubscribe': 12, + 'upcoming': 9, + 'update': 13, + 'update_disabled': 8, + 'upgrade': 12, + 'upload': 10, + 'upload_file': 10, + 'usb': 12, + 'usb_off': 4, + 'vaccines': 2, + 'vape_free': 1, + 'vaping_rooms': 1, + 'verified': 11, + 'verified_user': 18, + 'vertical_align_bottom': 12, + 'vertical_align_center': 12, + 'vertical_align_top': 12, + 'vertical_distribute': 6, + 'vertical_shades': 1, + 'vertical_shades_closed': 1, + 'vertical_split': 15, + 'vibration': 12, + 'video_call': 11, + 'video_camera_back': 10, + 'video_camera_front': 10, + 'video_chat': 1, + 'video_file': 1, + 'video_label': 11, + 'video_library': 13, + 'video_settings': 12, + 'video_stable': 10, + 'videocam': 16, + 'videocam_off': 12, + 'videogame_asset': 11, + 'videogame_asset_off': 4, + 'view_agenda': 14, + 'view_array': 13, + 'view_carousel': 13, + 'view_column': 13, + 'view_comfy': 14, + 'view_comfy_alt': 1, + 'view_compact': 13, + 'view_compact_alt': 1, + 'view_cozy': 1, + 'view_day': 13, + 'view_headline': 11, + 'view_in_ar': 11, + 'view_kanban': 1, + 'view_list': 14, + 'view_module': 13, + 'view_quilt': 14, + 'view_sidebar': 13, + 'view_stream': 13, + 'view_timeline': 1, + 'view_week': 13, + 'vignette': 12, + 'villa': 4, + 'visibility': 16, + 'visibility_off': 12, + 'voice_chat': 13, + 'voice_over_off': 11, + 'voicemail': 12, + 'volcano': 1, + 'volume_down': 13, + 'volume_mute': 13, + 'volume_off': 17, + 'volume_up': 14, + 'volunteer_activism': 10, + 'vpn_key': 12, + 'vpn_key_off': 1, + 'vpn_lock': 17, + 'vrpano': 10, + 'wallet': 1, + 'wallpaper': 13, + 'warehouse': 1, + 'warning': 17, + 'warning_amber': 17, + 'wash': 8, + 'watch': 12, + 'watch_later': 17, + 'watch_off': 1, + 'water': 11, + 'water_damage': 6, + 'water_drop': 3, + 'waterfall_chart': 11, + 'waves': 11, + 'waving_hand': 3, + 'wb_auto': 12, + 'wb_cloudy': 12, + 'wb_incandescent': 12, + 'wb_iridescent': 11, + 'wb_shade': 10, + 'wb_sunny': 12, + 'wb_twilight': 9, + 'wc': 11, + 'web': 13, + 'web_asset': 12, + 'web_asset_off': 4, + 'web_stories': 4, + 'webhook': 1, + 'weekend': 12, + 'west': 7, + 'whatshot': 17, + 'wheelchair_pickup': 8, + 'where_to_vote': 16, + 'widgets': 13, + 'width_full': 1, + 'width_normal': 1, + 'width_wide': 1, + 'wifi': 12, + 'wifi_1_bar': 1, + 'wifi_2_bar': 1, + 'wifi_calling': 11, + 'wifi_calling_3': 10, + 'wifi_channel': 1, + 'wifi_find': 1, + 'wifi_lock': 13, + 'wifi_off': 12, + 'wifi_password': 1, + 'wifi_protected_setup': 12, + 'wifi_tethering': 12, + 'wifi_tethering_error': 3, + 'wifi_tethering_off': 10, + 'wind_power': 1, + 'window': 10, + 'wine_bar': 6, + 'woman': 2, + 'woman_2': 1, + 'work': 12, + 'work_history': 1, + 'work_off': 12, + 'work_outline': 12, + 'workspace_premium': 3, + 'workspaces': 9, + 'wrap_text': 13, + 'wrong_location': 17, + 'wysiwyg': 8, + 'yard': 9, + 'youtube_searched_for': 12, + 'zoom_in': 11, + 'zoom_in_map': 1, + 'zoom_out': 11, + 'zoom_out_map': 17, +}; diff --git a/test/icon_binding_test.dart b/test/icon_binding_test.dart new file mode 100644 index 00000000..a7f0c73c --- /dev/null +++ b/test/icon_binding_test.dart @@ -0,0 +1,84 @@ +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:codelessly_sdk/codelessly_sdk.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'feedback_actions_test.dart' as harness; + +/// A star icon whose glyph is bound to [glyph]. +IconNode star(String? glyph) => IconNode( + id: 'star', + name: 'star', + basicBoxLocal: NodeBox(0, 0, 24, 24), + icon: const MaterialIcon( + codepoint: 0xe838, + name: 'star', + style: MaterialIconStyle.filled, + version: 22, + ), +)..variables.addAll({'icon': ?glyph}); + +/// The scoped values of a pumped screen. (The icon itself isn't pumped: its +/// SVG would be fetched, and tests have no network.) +Future scope(WidgetTester tester) async { + await harness.pumpLayouts(tester, [ + harness.layoutOf('main', [ + harness.canvas('main', 'mainBody'), + harness.body('mainBody', 'main', ['box']), + RectangleNode( + id: 'box', + name: 'box', + basicBoxLocal: NodeBox(0, 0, 10, 10), + )..parentID = 'mainBody', + ]), + ]); + await tester.pumpAndSettle(); + return ScopedValues.of( + tester.element( + find.byKey(const ValueKey('Rectangle Transformer of box [box]')), + ), + ); +} + +void main() { + testWidgets('a bound glyph name picks the icon and its version', ( + tester, + ) async { + final ScopedValues values = await scope(tester); + final IconModel icon = PassiveIconWidget.resolvedIcon( + star(r"${= 1 > 2 ? 'star' : 'star_border'}"), + values, + ).icon!; + expect(icon.name, 'star_border'); + expect((icon as MaterialIcon).version, materialIconVersions['star_border']); + expect(icon.style, MaterialIconStyle.filled); + expect(icon.toSvgUrl(), contains('/materialicons/star_border/v')); + }); + + testWidgets('without a binding the icon keeps its glyph', (tester) async { + final ScopedValues values = await scope(tester); + expect( + PassiveIconWidget.resolvedIcon(star(null), values).icon!.name, + 'star', + ); + }); + + test('named() keeps the icon for an empty name or a Cupertino icon', () { + const material = MaterialIcon( + codepoint: 0xe838, + name: 'star', + style: MaterialIconStyle.outlined, + version: 22, + ); + expect(material.named(''), same(material)); + expect(material.named(' Home ').name, 'home'); + expect( + (material.named('home') as MaterialIcon).style, + MaterialIconStyle.outlined, + ); + // An unknown name keeps the icon's own version. + expect((material.named('not_an_icon') as MaterialIcon).version, 22); + final cupertino = CupertinoIcon(name: 'battery_full', codepoint: 0xf113); + expect(cupertino.named('star'), same(cupertino)); + }); +} From d0d91fc139779e7860e0c0bff490b6a9a43442ca Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 03:21:56 +0000 Subject: [PATCH 06/19] Design canvas preview: a list item reads its own element VariablePreview.scopeFor overlays the list item from the build context on the editor's preview scope, and text spans pass it, so each item of a bound list on the canvas shows its own data instead of the first item's. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SyH8twyrBoe4wFCTw78Cz5 --- lib/src/transformers/utils/variable_span.dart | 28 ++++++++----- test/variable_preview_test.dart | 40 +++++++++++++++++++ 2 files changed, 58 insertions(+), 10 deletions(-) diff --git a/lib/src/transformers/utils/variable_span.dart b/lib/src/transformers/utils/variable_span.dart index 868654be..2edda925 100644 --- a/lib/src/transformers/utils/variable_span.dart +++ b/lib/src/transformers/utils/variable_span.dart @@ -15,6 +15,21 @@ abstract final class VariablePreview { /// fall back to placeholders. static ScopedValues? Function(BaseNode? node)? scopeResolver; + /// The values [node] sees on the design canvas, as [scopeResolver] gives + /// them, with [item] as `item` / `index` when it carries data: a list on + /// the canvas builds each item with its own element of the list's data, + /// while [scopeResolver] only knows the node (so it gives the first item). + static ScopedValues? scopeFor(BaseNode? node, {IndexedItem? item}) { + final ScopedValues? values = scopeResolver?.call(node); + if (values == null || item == null || item.item == null) return values; + return ScopedValues.preview( + variables: values.variables, + codelesslyContext: values.codelesslyContext, + screenSize: values.screenSize, + indexedItem: item, + ); + } + /// Accessors that read a property of a value rather than name it, so the /// placeholder for `${item.title.initial}` is named after `title`. static const Set _accessorNames = { @@ -47,15 +62,7 @@ abstract final class VariablePreview { TextStyle? style, IndexedItem? item, }) { - ScopedValues? scopedValues = scopeResolver?.call(node); - if (scopedValues != null && item != null) { - scopedValues = ScopedValues.preview( - variables: scopedValues.variables, - codelesslyContext: scopedValues.codelesslyContext, - screenSize: scopedValues.screenSize, - indexedItem: item, - ); - } + final ScopedValues? scopedValues = scopeFor(node, item: item); if (scopedValues != null) { final String value = PropertyValueDelegate.substituteVariables( expression, @@ -148,7 +155,8 @@ class VariableSpan extends TextSpan { /// The variable expression, such as `${user.name}`. final String variable; - /// Creates a [VariableSpan] for the [variable] expression of [node]. + /// Creates a [VariableSpan] for the [variable] expression of [node], in + /// list [item] when the node is inside a list. VariableSpan({ required this.variable, BaseNode? node, diff --git a/test/variable_preview_test.dart b/test/variable_preview_test.dart index f1ce98ff..bdddd414 100644 --- a/test/variable_preview_test.dart +++ b/test/variable_preview_test.dart @@ -104,4 +104,44 @@ void main() { 'Shots', ); }); + + group('list items', () { + setUp(() { + // As the editor resolves a node in a bound list: the first item. + VariablePreview.scopeResolver = (node) => ScopedValues.preview( + variables: { + 'region': VariableData( + id: 'v1', + name: 'region', + value: 'All', + type: VariableType.text, + ), + }, + indexedItem: IndexedItem(0, {'label': 'All'}), + ); + }); + + test('an item with data shows its own element', () { + final IndexedItem third = IndexedItem(2, {'label': 'Orange County'}); + expect( + VariablePreview.textFor(r'${item.label}', item: third), + 'Orange County', + ); + expect(VariablePreview.textFor(r'${= index + 1}', item: third), '3'); + expect( + VariableSpan(variable: r'${item.label}', item: third).text, + 'Orange County', + ); + // Variables still read the canvas's defaults. + expect(VariablePreview.textFor(r'${region}', item: third), 'All'); + }); + + test('without item data the item from the resolver stands', () { + expect(VariablePreview.textFor(r'${item.label}'), 'All'); + expect( + VariablePreview.textFor(r'${item.label}', item: IndexedItem(3, null)), + 'All', + ); + }); + }); } From 3a06ec25b8ceaccd5ffd1927e333444784f6a0da Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 03:48:31 +0000 Subject: [PATCH 07/19] Wrapping list views A list with wrap on and a known item count (itemCount or bound data, no cloud query) builds all its items in a Wrap along its scroll direction: the separator spacing between items (no dividers), runSpacing between runs, aligned to the start, sized by its own fits (shrink-wrap hugs the runs). It never scrolls. A list with an unknown count ignores wrap. Items keep their own size in a run instead of being aligned across the list. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SyH8twyrBoe4wFCTw78Cz5 --- .../passive_list_view_transformer.dart | 42 ++++++ .../transformers/utils/viewport_sizing.dart | 41 +++++- test/list_view_wrap_test.dart | 126 ++++++++++++++++++ 3 files changed, 203 insertions(+), 6 deletions(-) create mode 100644 test/list_view_wrap_test.dart diff --git a/lib/src/transformers/node_transformers/passive_list_view_transformer.dart b/lib/src/transformers/node_transformers/passive_list_view_transformer.dart index 8094befe..67d8fb98 100644 --- a/lib/src/transformers/node_transformers/passive_list_view_transformer.dart +++ b/lib/src/transformers/node_transformers/passive_list_view_transformer.dart @@ -1,4 +1,5 @@ import 'dart:core'; +import 'dart:math' as math; import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:codelessly_api/codelessly_api.dart'; @@ -92,6 +93,7 @@ class PassiveListViewWidget extends StatelessWidget { int itemCount, IndexedWidgetBuilder itemBuilder, ) { + if (listWraps(node)) return wrappedList(context, itemCount, itemBuilder); final bool vertical = node.scrollDirection == AxisC.vertical; final List items = [ for (int index = 0; index < itemCount; index++) ...[ @@ -128,11 +130,51 @@ class PassiveListViewWidget extends StatelessWidget { ); } + /// Lays out all [itemCount] items of a wrapping list (see [listWraps]) in + /// a [Wrap] along the scroll direction: items flow onto a new run when + /// they don't fit, with the separator's spacing between items and + /// [ListViewProperties.runSpacing] between runs, all aligned to the start. + /// + /// The list is sized by its own fits: fixed takes its design size, expanded + /// fills a bounded axis, and shrink-wrap hugs (along the scroll direction, + /// the widest run; across it, the runs). + Widget wrappedList( + BuildContext context, + int itemCount, + IndexedWidgetBuilder itemBuilder, + ) { + final List items = [ + for (int index = 0; index < itemCount; index++) + itemBuilder(context, index), + ]; + final Widget wrap = Padding( + padding: node.padding.flutterEdgeInsets, + child: Wrap( + direction: node.scrollDirection.flutterAxis, + spacing: listWrapSpacing(node), + runSpacing: math.max(0, node.properties.runSpacing), + children: node.reverse ? items.reversed.toList() : items, + ), + ); + return FitBox( + width: node.horizontalFit == SizeFit.shrinkWrap + ? null + : node.basicBoxLocal.width, + height: node.verticalFit == SizeFit.shrinkWrap + ? null + : node.basicBoxLocal.height, + fillWidth: node.horizontalFit == SizeFit.expanded, + fillHeight: node.verticalFit == SizeFit.expanded, + child: node.clipsContent ? ClipRect(child: wrap) : wrap, + ); + } + /// Builds one item, placed as the editor lays it out; see [ListItemBox]. Widget buildItem(BaseNode item, BuildContext context) => ListItemBox( item: item, scrollDirection: node.scrollDirection, itemExtent: node.properties.itemExtent, + inWrap: listWraps(node), child: manager.buildWidgetByID(item.id, context, settings: settings), ); diff --git a/lib/src/transformers/utils/viewport_sizing.dart b/lib/src/transformers/utils/viewport_sizing.dart index dd56816f..86538f27 100644 --- a/lib/src/transformers/utils/viewport_sizing.dart +++ b/lib/src/transformers/utils/viewport_sizing.dart @@ -177,6 +177,9 @@ class ViewportNodeBox extends StatelessWidget { /// * Scroll axis: with [itemExtent] the item fills the extent, as in the /// editor. Otherwise an expanded or flexible item, which has nothing to /// fill along an endless axis, takes its design extent. +/// +/// In a wrapping list ([inWrap]) the item is not aligned across the list: +/// it sits in its run at its own size, as [Wrap] places it. class ListItemBox extends StatelessWidget { /// The item node. final BaseNode item; @@ -190,6 +193,9 @@ class ListItemBox extends StatelessWidget { /// The built item. final Widget child; + /// Whether the item is a child of a wrapping list's [Wrap]. + final bool inWrap; + /// Creates a [ListItemBox]. const ListItemBox({ super.key, @@ -197,6 +203,7 @@ class ListItemBox extends StatelessWidget { required this.scrollDirection, required this.itemExtent, required this.child, + this.inWrap = false, }); @override @@ -217,6 +224,7 @@ class ListItemBox extends StatelessWidget { child: result, ); } + if (inWrap) return result; return Align( alignment: vertical ? Alignment.topCenter : Alignment.centerLeft, child: result, @@ -300,20 +308,41 @@ class DesignCellGridDelegate extends SliverGridDelegate { oldDelegate.mainAxisExtent != mainAxisExtent; } -/// Whether list [node] builds all its items up front, as a plain [Flex] -/// rather than a viewport: it doesn't scroll, hugs its items along its axis -/// and has a known item count (bound data or a set count). Such a list builds -/// every item either way, so it needs no viewport. +/// Whether list [node] has a known item count: bound data or a set count, +/// and no cloud query (whose length is only known once it loads, page by +/// page). +bool listHasKnownItemCount(ListViewNode node) => + !node.useCloudDatabase && + (node.variables['data'] != null || node.properties.itemCount != null); + +/// Whether list [node] lays its items out in a [Wrap]: it wraps +/// ([ListViewProperties.wrap]) and has a known item count. Such a list builds +/// all its items and never scrolls, whatever its scroll settings. A list +/// with an unknown count ignores [ListViewProperties.wrap] and scrolls. +bool listWraps(ListViewNode node) => + node.properties.wrap && listHasKnownItemCount(node); + +/// Whether list [node] builds all its items up front, as a plain [Flex] or a +/// [Wrap] rather than a viewport: it wraps (see [listWraps]), or it doesn't +/// scroll, hugs its items along its axis and has a known item count (bound +/// data or a set count). Such a list builds every item either way, so it +/// needs no viewport. bool listBuildsAllItems(ListViewNode node) { + if (listWraps(node)) return true; final SizeFit mainFit = node.scrollDirection == AxisC.vertical ? node.verticalFit : node.horizontalFit; return !node.isScrollable && mainFit == SizeFit.shrinkWrap && - !node.useCloudDatabase && - (node.variables['data'] != null || node.properties.itemCount != null); + listHasKnownItemCount(node); } +/// The gap between consecutive items of a wrapping list: the separator's +/// spacing when it has one. A wrapping list draws no dividers. +double listWrapSpacing(ListViewNode node) => node.properties.hasSeparator + ? math.max(0, node.properties.separatorSpacing) + : 0; + /// Whether [node] or any node below it builds a widget that cannot report /// intrinsic sizes: a list, grid or page view (a viewport behind a /// [LayoutBuilder]), a scrolling row, column or frame (a [LayoutBuilder] diff --git a/test/list_view_wrap_test.dart b/test/list_view_wrap_test.dart new file mode 100644 index 00000000..a60f3923 --- /dev/null +++ b/test/list_view_wrap_test.dart @@ -0,0 +1,126 @@ +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:codelessly_sdk/codelessly_sdk.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'viewport_layout_test.dart' as vp; + +/// A horizontal list of [itemCount] 100x50 items that wraps, 14 apart with +/// 6 between runs, and hugs its height. +ListViewNode wrappingList({ + double width = 250, + int? itemCount = 4, + SizeFit horizontalFit = SizeFit.fixed, + bool hasSeparator = true, +}) { + final ListViewNode list = vp.list( + width: width, + height: 50, + itemCount: itemCount, + horizontalFit: horizontalFit, + verticalFit: SizeFit.shrinkWrap, + scrollDirection: AxisC.horizontal, + ); + list.properties + ..wrap = true + ..hasSeparator = hasSeparator + ..separatorSpacing = 14 + ..runSpacing = 6; + return list; +} + +List itemRects(WidgetTester tester) => [ + for (final Element element in vp.rectOf('item').evaluate()) + tester.getRect(find.byWidget(element.widget).first), +]; + +void main() { + test('a wrapping list with a known count builds all its items', () { + final ListViewNode list = wrappingList(); + expect(list.isScrollable, isTrue); + expect(listWraps(list), isTrue); + expect(listBuildsAllItems(list), isTrue); + expect(listWrapSpacing(list), 14); + + list.properties.itemCount = null; + expect(listWraps(list), isFalse, reason: 'endless list'); + expect(listBuildsAllItems(list), isFalse); + + list + ..properties.itemCount = 3 + ..useCloudDatabase = true; + expect(listWraps(list), isFalse, reason: 'cloud query'); + }); + + testWidgets('a wrapping list keeps its items on one run when they fit', ( + tester, + ) async { + await vp.pump(tester, const Size(800, 800), vp.Host.loose, wrappingList( + width: 500, + ), [vp.rect('item', 'list')]); + expect(find.byType(Wrap), findsOneWidget); + expect(find.byType(ViewportNodeBox), findsNothing); + final List items = itemRects(tester); + expect(items, hasLength(4)); + for (int i = 1; i < 4; i++) { + expect(items[i].top, items[0].top); + expect(items[i].left - items[i - 1].right, 14); + } + expect(tester.getSize(find.byType(Wrap)).height, 50); + }); + + testWidgets('a wrapping list moves items that do not fit to a new run', ( + tester, + ) async { + await vp.pump(tester, const Size(800, 800), vp.Host.loose, wrappingList(), [ + vp.rect('item', 'list'), + ]); + final List items = itemRects(tester); + // 100 + 14 + 100 = 214 fits in 250, a third item does not. + expect(items[1].top, items[0].top); + expect(items[2].left, items[0].left); + expect(items[2].top - items[0].bottom, 6); + expect(items[3].top, items[2].top); + expect(tester.getSize(find.byType(Wrap)), const Size(250, 106)); + }); + + testWidgets('an expanded wrapping list wraps at the width it gets', ( + tester, + ) async { + await vp.pump( + tester, + const Size(250, 800), + vp.Host.tight, + wrappingList(width: 800, horizontalFit: SizeFit.expanded), + [vp.rect('item', 'list')], + ); + final List items = itemRects(tester); + expect(items[2].top - items[0].bottom, 6); + }); + + testWidgets('without a separator the items touch', (tester) async { + await vp.pump( + tester, + const Size(800, 800), + vp.Host.loose, + wrappingList(width: 500, hasSeparator: false), + [vp.rect('item', 'list')], + ); + final List items = itemRects(tester); + expect(items[1].left, items[0].right); + }); + + testWidgets('a wrapping list with no count scrolls as before', ( + tester, + ) async { + await vp.pump( + tester, + const Size(800, 800), + vp.Host.loose, + wrappingList(itemCount: null)..verticalFit = SizeFit.fixed, + [vp.rect('item', 'list')], + ); + expect(find.byType(Wrap), findsNothing); + expect(find.byType(ViewportNodeBox), findsOneWidget); + }); +} From 20a31e5b7d600295cd9f4a2992757ce43b72ad5e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 16:54:07 +0000 Subject: [PATCH 08/19] Bind corner radius and padding to variables and formulas applyStyleOverrides applies a cornerRadius binding (a number or [tl, tr, br, bl]) on corner nodes and a padding binding (a number, [h, v] or [l, t, r, b]) on any node. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SyH8twyrBoe4wFCTw78Cz5 --- .../passive_transformer_manager.dart | 23 ++- test/radius_padding_binding_test.dart | 142 ++++++++++++++++++ 2 files changed, 161 insertions(+), 4 deletions(-) create mode 100644 test/radius_padding_binding_test.dart diff --git a/lib/src/transformers/passive_transformer_manager.dart b/lib/src/transformers/passive_transformer_manager.dart index 6ab9eaf3..b41aabbe 100644 --- a/lib/src/transformers/passive_transformer_manager.dart +++ b/lib/src/transformers/passive_transformer_manager.dart @@ -176,10 +176,10 @@ class PassiveNodeTransformerManager extends WidgetNodeTransformerManager { /// Returns [node] with the style properties that conditions, bound /// variables or formulas, or set value actions give it at runtime: `fill` - /// (a text's color for text nodes), `stroke`, `opacity`, `width`, - /// `height`, `flex` (its share of an expanded row or column, so bars - /// can be sized proportionally) and `rotation` (in degrees). Returns - /// [node] itself when nothing changes it. + /// (a text's color for text nodes), `stroke`, `opacity`, `cornerRadius`, + /// `padding`, `width`, `height`, `flex` (its share of an expanded row or + /// column, so bars can be sized proportionally) and `rotation` (in + /// degrees). Returns [node] itself when nothing changes it. BaseNode applyStyleOverrides(BuildContext context, BaseNode node) { final ScopedValues scopedValues = ScopedValues.of(context); T? value(String property) => @@ -228,6 +228,21 @@ class PassiveNodeTransformerManager extends WidgetNodeTransformerManager { patch['opacity'] = opacity.clamp(0, 1).toDouble(); } } + if (node is CornerMixin) { + // A number rounds every corner; [tl, tr, br, bl] rounds each (a bottom + // sheet's top corners). See [ConversionExt.toCornerRadius]. + final CornerRadius? radius = value('cornerRadius') + ?.toCornerRadius(); + if (radius != null && radius != node.cornerRadius) { + patch['cornerRadius'] = radius.toJson(); + } + } + // A number pads every side; [h, v] or [l, t, r, b] pad each. + final EdgeInsetsModel? padding = value('padding') + ?.toEdgeInsetsModel(); + if (padding != null && padding != node.padding) { + patch['padding'] = padding.toJson(); + } final NodeBox box = node.basicBoxLocal; final double? width = value('width'); final double? height = value('height'); diff --git a/test/radius_padding_binding_test.dart b/test/radius_padding_binding_test.dart new file mode 100644 index 00000000..75b7a9e3 --- /dev/null +++ b/test/radius_padding_binding_test.dart @@ -0,0 +1,142 @@ +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:codelessly_sdk/codelessly_sdk.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; + +void main() { + testWidgets('corner radius and padding follow bound formulas', ( + tester, + ) async { + final RectangleNode node = RectangleNode.fromJson({ + ...RectangleNode( + id: 'sheet', + name: 'Sheet', + basicBoxLocal: NodeBox(0, 0, 200, 100), + fills: [ + PaintModel.solid(id: 'fill', color: ColorRGB.fromHex('#FFFFFF')), + ], + ).toJson(), + 'variables': { + 'cornerRadius': r'${= isMobile ? [18, 18, 0, 0] : 12}', + 'padding': r'${= isMobile ? [16, 8] : 24}', + }, + }); + node.parentID = kRootNode; + final registry = NodeRegistry()..setNodes({node.id: node}); + final manager = PassiveNodeTransformerManager( + registry.getNodeByID, + (context, bounds, pageID, layoutID, canvasID) => const SizedBox(), + ); + final Observable isMobile = Observable( + VariableData( + id: 'isMobile', + name: 'isMobile', + type: VariableType.boolean, + value: true, + ), + ); + final CodelesslyContext codelesslyContext = CodelesslyContext.empty() + ..variables['isMobile'] = isMobile; + + late BuildContext buildContext; + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: codelesslyContext, + child: MaterialApp( + home: Align( + alignment: Alignment.topLeft, + child: Builder( + builder: (context) { + buildContext = context; + return manager.buildWidgetFromNode( + node, + context, + settings: const WidgetBuildSettings(debugLabel: 'test'), + ); + }, + ), + ), + ), + ), + ); + + BorderRadiusGeometry? radius() => tester.allWidgets + .map( + (w) => switch (w) { + final Ink ink => ink.decoration, + final DecoratedBox box => box.decoration, + final Container c => c.decoration, + _ => null, + }, + ) + .whereType() + .map((decoration) => decoration.borderRadius) + .nonNulls + .firstOrNull; + + expect( + radius(), + const BorderRadius.only( + topLeft: Radius.circular(18), + topRight: Radius.circular(18), + ), + ); + BaseNode styled = manager.applyStyleOverrides(buildContext, node); + expect( + styled.padding, + EdgeInsetsModel.symmetric(horizontal: 16, vertical: 8), + ); + expect(styled.innerBoxLocal.edgeLeft, 16); + + isMobile.set(isMobile.value.copyWith(value: false)); + await tester.pump(); + + expect(radius(), BorderRadius.circular(12)); + styled = manager.applyStyleOverrides(buildContext, node); + expect(styled.padding, EdgeInsetsModel.all(24)); + expect( + (styled as CornerMixin).cornerRadius, + CornerRadius.all(RadiusModel.circular(12)), + ); + // The node itself is unchanged. + expect(node.cornerRadius, CornerRadius.zero); + expect(node.padding, EdgeInsetsModel.zero); + }); + + testWidgets('an unparseable binding keeps the node\'s own values', ( + tester, + ) async { + final RectangleNode node = RectangleNode.fromJson({ + ...RectangleNode( + id: 'box', + name: 'Box', + basicBoxLocal: NodeBox(0, 0, 50, 50), + cornerRadius: CornerRadius.all(RadiusModel.circular(6)), + padding: EdgeInsetsModel.all(4), + ).toJson(), + 'variables': {'cornerRadius': r'${= "round"}', 'padding': r'${= "x"}'}, + }); + node.parentID = kRootNode; + final registry = NodeRegistry()..setNodes({node.id: node}); + final manager = PassiveNodeTransformerManager( + registry.getNodeByID, + (context, bounds, pageID, layoutID, canvasID) => const SizedBox(), + ); + late BuildContext buildContext; + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: CodelesslyContext.empty(), + child: MaterialApp( + home: Builder( + builder: (context) { + buildContext = context; + return const SizedBox(); + }, + ), + ), + ), + ); + expect(manager.applyStyleOverrides(buildContext, node), same(node)); + }); +} From 651332521d477a8fed68bc76a6e14b2828533d3b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 17:01:13 +0000 Subject: [PATCH 09/19] Batch cloud writes; bind stroke weight setCloudStorage gains removeDocuments (ids from a variable or formula, where filters, or the whole collection) and setDocuments (a list of documents, optionally replacing the collection), each committed in Firestore batches of up to 500 writes. CloudDatabase.removeDocuments/setDocuments default to one write per document for other implementations. applyStyleOverrides also applies a strokeWeight binding. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SyH8twyrBoe4wFCTw78Cz5 --- lib/src/data/cloud_database.dart | 192 +++++++++++ lib/src/functions/functions_repository.dart | 163 +++++++++ .../passive_transformer_manager.dart | 13 +- lib/src/utils/extensions.dart | 11 + test/cloud_batch_test.dart | 320 ++++++++++++++++++ test/radius_padding_binding_test.dart | 8 +- 6 files changed, 702 insertions(+), 5 deletions(-) create mode 100644 test/cloud_batch_test.dart diff --git a/lib/src/data/cloud_database.dart b/lib/src/data/cloud_database.dart index b4d109e9..c961b051 100644 --- a/lib/src/data/cloud_database.dart +++ b/lib/src/data/cloud_database.dart @@ -132,6 +132,69 @@ abstract class CloudDatabase extends ChangeNotifier { /// it will return true. Future removeDocument(String path, String documentId); + /// The most writes Firestore takes in one batch. + static const int maxBatchWrites = 500; + + /// Removes many documents of the collection at [path] at once and returns + /// how many were removed. + /// + /// Removes the documents with [documentIds] when given; otherwise those + /// matching [filters], or every document of the collection when there are + /// none. Implementations remove them in as few writes as they can (see + /// [FirestoreCloudDatabase.removeDocuments]); this default removes the + /// [documentIds] one by one and can't query. + Future removeDocuments( + String path, { + Iterable? documentIds, + List filters = const [], + }) async { + if (documentIds == null) { + throw UnsupportedError('$runtimeType can only remove documents by id.'); + } + int removed = 0; + for (final String id in documentIds) { + if (id.trim().isEmpty) continue; + if (await removeDocument(path, id.trim())) removed++; + } + return removed; + } + + /// Writes [documents] to the collection at [path] at once and returns how + /// many were written. + /// + /// Each document's id is its [idField]; a document without one gets a + /// generated id. A document replaces the stored one unless [merge] is + /// true. With [replaceCollection], the collection's other documents are + /// removed, so it holds exactly [documents] (restoring seed data). + /// + /// This default writes them one by one and can't [replaceCollection]; + /// see [FirestoreCloudDatabase.setDocuments]. + Future setDocuments( + String path, + List> documents, { + String idField = 'id', + bool merge = false, + bool replaceCollection = false, + }) async { + if (replaceCollection) { + throw UnsupportedError('$runtimeType cannot replace a collection.'); + } + int written = 0; + for (final Map document in documents) { + final String id = document[idField]?.toString().trim() ?? ''; + final bool ok = merge && id.isNotEmpty + ? await updateDocument(path, documentId: id, value: document) + : await addDocument( + path, + documentId: id.isEmpty ? generateId() : id, + skipCreationIfDocumentExists: false, + value: document, + ); + if (ok) written++; + } + return written; + } + /// Retrieves the document data from the cloud storage at the given [path] /// with the given [documentId]. /// @@ -483,6 +546,135 @@ class FirestoreCloudDatabase extends CloudDatabase { return true; } + /// Removes the documents in as few batched writes as Firestore allows + /// ([CloudDatabase.maxBatchWrites] deletes each). Without [documentIds], + /// the documents to remove are read first: those matching [filters] (run + /// in the app past their `==` filters, so no index is needed), and in an + /// owner-only collection only the signed-in user's. + @override + Future removeDocuments( + String path, { + Iterable? documentIds, + List filters = const [], + }) async { + DebugLogger.instance.printFunction( + 'removeDocuments(path: $path, ids: $documentIds, filters: $filters)', + name: name, + ); + final CollectionReference> collection = + getCollectionPath(path); + final List>> refs = + documentIds != null + ? [ + for (final String id in { + for (final String id in documentIds) + if (id.trim().isNotEmpty) id.trim(), + }) + collection.doc(id), + ] + : [ + for (final doc in await _matchingDocuments(path, filters)) + doc.reference, + ]; + await _commitInBatches([ + for (final ref in refs) (WriteBatch batch) => batch.delete(ref), + ], 'cloudDatabase/removeDocuments'); + return refs.length; + } + + /// Writes the documents in as few batched writes as Firestore allows + /// ([CloudDatabase.maxBatchWrites] each), applying the collection's rules + /// to each like [addDocument] does. + @override + Future setDocuments( + String path, + List> documents, { + String idField = 'id', + bool merge = false, + bool replaceCollection = false, + }) async { + DebugLogger.instance.printFunction( + 'setDocuments(path: $path, count: ${documents.length}, ' + 'merge: $merge, replaceCollection: $replaceCollection)', + name: name, + ); + final CollectionReference> collection = + getCollectionPath(path); + final String? uid = currentUserId(); + final Set written = {}; + final List writes = []; + for (final Map document in documents) { + final String id = document[idField]?.toString().trim() ?? ''; + final DocumentReference> ref = id.isEmpty + ? collection.doc() + : collection.doc(id); + written.add(ref.id); + Map data = rules.prepareWrite(path, document, uid); + data = sanitizeCloudDataToSend(data, docId: ref.id, keepCreatedAt: merge); + final Map resolved = resolveFieldOperations(data); + writes.add( + (WriteBatch batch) => + batch.set(ref, resolved, SetOptions(merge: merge)), + ); + } + if (replaceCollection) { + for (final doc in await _matchingDocuments(path, const [])) { + if (written.contains(doc.id)) continue; + writes.add((WriteBatch batch) => batch.delete(doc.reference)); + } + } + await _commitInBatches(writes, 'cloudDatabase/setDocuments'); + return documents.length; + } + + /// The documents of the collection at [path] that pass [filters] and the + /// collection's read rules for the signed-in user. + Future>>> _matchingDocuments( + String path, + List filters, + ) async { + final List? readFilters = rules.readFilters( + path, + currentUserId(), + ); + if (readFilters == null) return []; + final CloudQueryPlan plan = CloudQuery( + filters: [...readFilters, ...filters], + ).plan(forceClient: true); + final QuerySnapshot> snapshot = await plan + .applyTo(getCollectionPath(path)) + .get(); + StatTracker.instance.track(StatType.read, 'cloudDatabase/batchQuery'); + return plan.finish( + snapshot.docs, + data: (doc) => doc.data(), + id: (doc) => doc.id, + ); + } + + /// Commits [writes] in batches of at most [CloudDatabase.maxBatchWrites]. + Future _commitInBatches( + List writes, + String stat, + ) async { + for ( + int start = 0; + start < writes.length; + start += CloudDatabase.maxBatchWrites + ) { + final WriteBatch batch = firestore.batch(); + final int end = (start + CloudDatabase.maxBatchWrites).clamp( + 0, + writes.length, + ); + for (final write in writes.sublist(start, end)) { + write(batch); + } + await batch.commit(); + StatTracker.instance.track(StatType.write, stat); + } + } + @override Future> getDocumentData( String path, diff --git a/lib/src/functions/functions_repository.dart b/lib/src/functions/functions_repository.dart index 88a8b39f..1541580c 100644 --- a/lib/src/functions/functions_repository.dart +++ b/lib/src/functions/functions_repository.dart @@ -1914,9 +1914,172 @@ ${response.body.contains('{') ? const JsonEncoder.withIndent(' ').convert(json. ), RemoveDocumentSubAction removeDocumentSubAction => removeDocumentFromCloud(removeDocumentSubAction, scopedValues), + RemoveDocumentsSubAction subAction => removeDocumentsFromCloud( + subAction, + scopedValues, + ), + SetDocumentsSubAction subAction => setDocumentsOnCloud( + subAction, + scopedValues, + ), }; } + /// The value [template] gives: a value as is when it is exactly one + /// `${...}` path or formula, else its text with variables substituted, + /// decoded when it is JSON (whose strings may hold paths too). + static Object? _valueFromTemplate( + String template, + ScopedValues scopedValues, + ) { + final String trimmed = template.trim(); + if (trimmed.isEmpty) return null; + if (FormulaEvaluator.exactExpression(trimmed) != null || + RegExp(r'^\$\{[^{}]+\}$').hasMatch(trimmed)) { + return PropertyValueDelegate.getVariableValueFromPath( + trimmed, + scopedValues: scopedValues, + ); + } + final Object? json = tryJsonDecode(trimmed); + if (json is List || json is Map) { + return PropertyValueDelegate.substituteJsonValue(json, scopedValues); + } + return PropertyValueDelegate.substituteVariables( + trimmed, + scopedValues: scopedValues, + nullSubstitutionMode: NullSubstitutionMode.emptyString, + ); + } + + /// Document ids from [value]: a list of ids or of documents (their `id`), + /// JSON text of one, or ids separated by commas. + @visibleForTesting + static List documentIdsOf(Object? value) { + if (value is String) { + final Object? json = tryJsonDecode(value.trim()); + if (json is List) return documentIdsOf(json); + return [ + for (final String id in value.split(',')) + if (id.trim().isNotEmpty) id.trim(), + ]; + } + if (value is Iterable) { + return [ + for (final Object? item in value) + if (item is Map + ? item['id'] != null + : item != null && item.toString().trim().isNotEmpty) + (item is Map ? item['id'] : item).toString().trim(), + ]; + } + if (value is Map && value['id'] != null) return [value['id'].toString()]; + return []; + } + + /// Removes many documents in one batched write: those [documentIds] gives, + /// or those matching the where filters (all of the collection when there + /// are none). `${result.count}` is how many were removed. + /// + /// [database] stands in for the app's cloud database, in tests. + static Future removeDocumentsFromCloud( + RemoveDocumentsSubAction subAction, + ScopedValues scopedValues, { + @visibleForTesting CloudDatabase? database, + }) async { + try { + final CloudDatabase? cloudDatabase = + database ?? scopedValues.cloudDatabase; + if (cloudDatabase == null) { + DebugLogger.instance.printInfo('Cloud storage is null.', name: name); + return false; + } + final String path = PropertyValueDelegate.substituteVariables( + subAction.path, + nullSubstitutionMode: NullSubstitutionMode.emptyString, + scopedValues: scopedValues, + ); + final List? ids = subAction.documentIds.trim().isEmpty + ? null + : documentIdsOf( + _valueFromTemplate(subAction.documentIds, scopedValues), + ); + final List filters = ids != null + ? const [] + : resolveCloudQuery( + whereFilters: subAction.whereFilters, + orderByOperations: const [], + scopedValues: scopedValues, + nullSubstitutionMode: NullSubstitutionMode.emptyString, + ).filters; + final int removed = await cloudDatabase.removeDocuments( + path, + documentIds: ids, + filters: filters, + ); + ActionChain.setResult({'count': removed, 'path': path}); + return true; + } catch (error, stackTrace) { + DebugLogger.instance.printInfo(error.toString(), name: name); + DebugLogger.instance.printInfo(stackTrace.toString(), name: name); + return false; + } + } + + /// Writes a list of documents in one batched write, e.g. seed data from a + /// list variable. `${result.count}` is how many were written. + /// + /// [database] stands in for the app's cloud database, in tests. + static Future setDocumentsOnCloud( + SetDocumentsSubAction subAction, + ScopedValues scopedValues, { + @visibleForTesting CloudDatabase? database, + }) async { + try { + final CloudDatabase? cloudDatabase = + database ?? scopedValues.cloudDatabase; + if (cloudDatabase == null) { + DebugLogger.instance.printInfo('Cloud storage is null.', name: name); + return false; + } + final String path = PropertyValueDelegate.substituteVariables( + subAction.path, + nullSubstitutionMode: NullSubstitutionMode.emptyString, + scopedValues: scopedValues, + ); + Object? value = _valueFromTemplate(subAction.documents, scopedValues); + if (value is String) value = tryJsonDecode(value); + if (value is Map) value = [value]; + if (value is! List) { + DebugLogger.instance.printInfo( + 'Set Documents needs a list of documents, got $value.', + name: name, + ); + return false; + } + final List> documents = [ + for (final Object? item in value) + if (item is Map) Map.from(item), + ]; + final String idField = subAction.idField.trim().isEmpty + ? 'id' + : subAction.idField.trim(); + final int written = await cloudDatabase.setDocuments( + path, + documents, + idField: idField, + merge: subAction.merge, + replaceCollection: subAction.replaceCollection, + ); + ActionChain.setResult({'count': written, 'path': path}); + return true; + } catch (error, stackTrace) { + DebugLogger.instance.printInfo(error.toString(), name: name); + DebugLogger.instance.printInfo(stackTrace.toString(), name: name); + return false; + } + } + static Future addDocumentToCloud( AddDocumentSubAction subAction, ScopedValues scopedValues, diff --git a/lib/src/transformers/passive_transformer_manager.dart b/lib/src/transformers/passive_transformer_manager.dart index b41aabbe..572ac5ae 100644 --- a/lib/src/transformers/passive_transformer_manager.dart +++ b/lib/src/transformers/passive_transformer_manager.dart @@ -176,10 +176,10 @@ class PassiveNodeTransformerManager extends WidgetNodeTransformerManager { /// Returns [node] with the style properties that conditions, bound /// variables or formulas, or set value actions give it at runtime: `fill` - /// (a text's color for text nodes), `stroke`, `opacity`, `cornerRadius`, - /// `padding`, `width`, `height`, `flex` (its share of an expanded row or - /// column, so bars can be sized proportionally) and `rotation` (in - /// degrees). Returns [node] itself when nothing changes it. + /// (a text's color for text nodes), `stroke`, `strokeWeight`, `opacity`, + /// `cornerRadius`, `padding`, `width`, `height`, `flex` (its share of an + /// expanded row or column, so bars can be sized proportionally) and + /// `rotation` (in degrees). Returns [node] itself when nothing changes it. BaseNode applyStyleOverrides(BuildContext context, BaseNode node) { final ScopedValues scopedValues = ScopedValues.of(context); T? value(String property) => @@ -206,6 +206,11 @@ class PassiveNodeTransformerManager extends WidgetNodeTransformerManager { for (final paint in geometry.strokes.skip(1)) paint.toJson(), ]; } + // A selected row's border can thicken, e.g. 1.5 to 2 px. + final double? strokeWeight = value('strokeWeight'); + if (strokeWeight != null && strokeWeight != geometry.strokeWeight) { + patch['strokeWeight'] = strokeWeight < 0 ? 0.0 : strokeWeight; + } } if (node is TextNode) { // A text's fill is its color: it replaces the color of every span. diff --git a/lib/src/utils/extensions.dart b/lib/src/utils/extensions.dart index 6951fe67..6e5b8898 100644 --- a/lib/src/utils/extensions.dart +++ b/lib/src/utils/extensions.dart @@ -1780,6 +1780,17 @@ extension ActionModelExt on ActionModel { ]); case RemoveDocumentSubAction action: strings.addAll([action.path, action.documentId]); + case RemoveDocumentsSubAction action: + strings.addAll([ + action.path, + action.documentIds, + for (final filter in action.whereFilters) ...[ + filter.field, + filter.value, + ], + ]); + case SetDocumentsSubAction action: + strings.addAll([action.path, action.documents]); case UpdateDocumentSubAction action: strings.addAll([ action.path, diff --git a/test/cloud_batch_test.dart b/test/cloud_batch_test.dart new file mode 100644 index 00000000..08793c96 --- /dev/null +++ b/test/cloud_batch_test.dart @@ -0,0 +1,320 @@ +// Firestore marks its references @sealed; these test fakes stand in for them. +// ignore_for_file: subtype_of_sealed_class + +import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:codelessly_sdk/codelessly_sdk.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Collection path -> document id -> data. +final Map>> store = {}; + +/// The batches committed, each as its number of writes. +final List commits = []; + +int _autoId = 0; + +class _MemQuery extends Fake implements Query> { + _MemQuery(this._collection, [this.tests = const []]); + + final _MemCollection? _collection; + _MemCollection get collection => _collection!; + final List)> tests; + + @override + Query> where( + Object field, { + Object? isEqualTo, + Object? isNotEqualTo, + Object? isLessThan, + Object? isLessThanOrEqualTo, + Object? isGreaterThan, + Object? isGreaterThanOrEqualTo, + Object? arrayContains, + Iterable? arrayContainsAny, + Iterable? whereIn, + Iterable? whereNotIn, + bool? isNull, + }) => _MemQuery(collection, [...tests, (data) => data[field] == isEqualTo]); + + @override + Future>> get([GetOptions? options]) async { + final docs = store[collection.path] ?? {}; + return _MemSnapshot([ + for (final entry in docs.entries) + if (tests.every((test) => test(entry.value))) + _MemDocSnapshot(collection.doc(entry.key) as _MemDoc, entry.value), + ]); + } +} + +class _MemCollection extends _MemQuery + implements CollectionReference> { + _MemCollection(this.path) : super(null); + + @override + _MemCollection get collection => this; + + @override + final String path; + + @override + DocumentReference> doc([String? id]) => + _MemDoc(this, id ?? 'auto${_autoId++}'); +} + +class _MemDoc extends Fake implements DocumentReference> { + _MemDoc(this.parentCollection, this.id); + + final _MemCollection parentCollection; + + @override + final String id; + + @override + String get path => '${parentCollection.path}/$id'; + + @override + CollectionReference> collection(String id) => + _MemCollection('$path/$id'); + + @override + bool operator ==(Object other) => other is _MemDoc && other.path == path; + + @override + int get hashCode => path.hashCode; +} + +class _MemDocSnapshot extends Fake + implements QueryDocumentSnapshot> { + _MemDocSnapshot(this.reference, this._data); + + @override + final _MemDoc reference; + final Map _data; + + @override + String get id => reference.id; + + @override + Map data() => _data; +} + +class _MemSnapshot extends Fake implements QuerySnapshot> { + _MemSnapshot(this.docs); + + @override + final List>> docs; +} + +class _MemBatch extends Fake implements WriteBatch { + final List _ops = []; + + @override + void delete(DocumentReference document) { + final doc = document as _MemDoc; + _ops.add(() => store[doc.parentCollection.path]?.remove(doc.id)); + } + + @override + void set(DocumentReference document, T data, [SetOptions? options]) { + final doc = document as _MemDoc; + final Map value = Map.of(data as Map); + _ops.add(() { + final docs = store.putIfAbsent(doc.parentCollection.path, () => {}); + docs[doc.id] = options?.merge == true + ? {...?docs[doc.id], ...value} + : value; + }); + } + + @override + Future commit() async { + expect(_ops.length, lessThanOrEqualTo(500)); + commits.add(_ops.length); + for (final op in _ops) { + op(); + } + } +} + +class _MemFirestore extends Fake implements FirebaseFirestore { + @override + CollectionReference> collection(String path) => + _MemCollection(path); + + @override + WriteBatch batch() => _MemBatch(); +} + +const String sessions = 'data/project/sessions'; + +void main() { + late FirestoreCloudDatabase database; + + void seed(int count, {String Function(int i)? spot}) { + store[sessions] = { + for (int i = 0; i < count; i++) + 's$i': {'id': 's$i', 'spot': spot?.call(i) ?? 'Pipe'}, + }; + } + + setUp(() { + store.clear(); + commits.clear(); + database = FirestoreCloudDatabase( + 'project', + PublishSource.publish, + firestore: _MemFirestore(), + ); + }); + + test('removeDocuments clears a collection in one batch', () async { + seed(14); + expect(await database.removeDocuments('sessions'), 14); + expect(store[sessions], isEmpty); + expect(commits, [14]); + }); + + test('removeDocuments splits more than 500 deletes into batches', () async { + seed(1203); + expect(await database.removeDocuments('sessions'), 1203); + expect(store[sessions], isEmpty); + expect(commits, [500, 500, 203]); + }); + + test('removeDocuments by where filter or ids', () async { + seed(6, spot: (i) => i.isEven ? 'Pipe' : 'Malibu'); + expect( + await database.removeDocuments( + 'sessions', + filters: const [ + CloudWhereFilter('spot', WhereQueryOperator.equal, 'Malibu'), + ], + ), + 3, + ); + expect(store[sessions]!.keys, ['s0', 's2', 's4']); + + expect( + await database.removeDocuments('sessions', documentIds: ['s0', ' s4 ']), + 2, + ); + expect(store[sessions]!.keys, ['s2']); + expect(commits, [3, 2]); + }); + + test('setDocuments writes a list in one batch and can replace the ' + 'collection', () async { + seed(3); + final int written = await database.setDocuments('sessions', [ + {'id': 's1', 'spot': 'Rincon'}, + {'id': 'n1', 'spot': 'Trestles'}, + {'spot': 'No id'}, + ], replaceCollection: true); + expect(written, 3); + expect(commits, [5]); // 3 sets + 2 deletes (s0, s2) + final docs = store[sessions]!; + expect(docs.keys, containsAll(['s1', 'n1'])); + expect(docs.length, 3); + expect(docs['s1']!['spot'], 'Rincon'); + // A document without an id gets one, stored in its id field. + final generated = docs.entries.firstWhere( + (e) => e.value['spot'] == 'No id', + ); + expect(generated.value['id'], generated.key); + }); + + test('setDocuments merges into stored documents when asked', () async { + seed(1); + await database.setDocuments('sessions', [ + {'id': 's0', 'rating': 4}, + ], merge: true); + expect(store[sessions]!['s0']!['spot'], 'Pipe'); + expect(store[sessions]!['s0']!['rating'], 4); + }); + + test('the actions read ids, filters and documents from variables', () async { + seed(5, spot: (i) => i < 2 ? 'Malibu' : 'Pipe'); + final ScopedValues scope = ScopedValues.preview( + variables: { + 'selected': VariableData( + id: 'selected', + name: 'selected', + type: VariableType.list, + value: [ + {'id': 's3'}, + {'id': 's4'}, + ], + ), + 'seed': VariableData( + id: 'seed', + name: 'seed', + type: VariableType.list, + value: [ + {'id': 'a', 'spot': 'Pipe'}, + {'id': 'b', 'spot': 'Rincon'}, + ], + ), + }, + ); + + expect( + await FunctionsRepository.removeDocumentsFromCloud( + const RemoveDocumentsSubAction( + path: 'sessions', + documentIds: r'${selected}', + ), + scope, + database: database, + ), + isTrue, + ); + expect(store[sessions]!.keys, ['s0', 's1', 's2']); + + await FunctionsRepository.removeDocumentsFromCloud( + const RemoveDocumentsSubAction( + path: 'sessions', + whereFilters: [ + WhereQueryFilter( + field: 'spot', + operator: WhereQueryOperator.equal, + value: 'Malibu', + ), + ], + ), + scope, + database: database, + ); + expect(store[sessions]!.keys, ['s2']); + + expect( + await FunctionsRepository.setDocumentsOnCloud( + const SetDocumentsSubAction( + path: 'sessions', + documents: r'${seed}', + replaceCollection: true, + ), + scope, + database: database, + ), + isTrue, + ); + expect(store[sessions]!.keys, unorderedEquals(['a', 'b'])); + expect(commits, [2, 2, 3]); + }); + + test('documentIdsOf reads lists, documents, JSON and commas', () { + expect(FunctionsRepository.documentIdsOf(['a', 'b']), ['a', 'b']); + expect( + FunctionsRepository.documentIdsOf([ + {'id': 'a'}, + {'name': 'x'}, + ]), + ['a'], + ); + expect(FunctionsRepository.documentIdsOf('["a","b"]'), ['a', 'b']); + expect(FunctionsRepository.documentIdsOf('a, b,,c'), ['a', 'b', 'c']); + expect(FunctionsRepository.documentIdsOf(null), isEmpty); + }); +} diff --git a/test/radius_padding_binding_test.dart b/test/radius_padding_binding_test.dart index 75b7a9e3..45e29e74 100644 --- a/test/radius_padding_binding_test.dart +++ b/test/radius_padding_binding_test.dart @@ -5,7 +5,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:provider/provider.dart'; void main() { - testWidgets('corner radius and padding follow bound formulas', ( + testWidgets('corner radius, padding and stroke weight follow bound formulas', ( tester, ) async { final RectangleNode node = RectangleNode.fromJson({ @@ -13,6 +13,8 @@ void main() { id: 'sheet', name: 'Sheet', basicBoxLocal: NodeBox(0, 0, 200, 100), + strokes: [PaintModel.blackPaint], + strokeWeight: 1.5, fills: [ PaintModel.solid(id: 'fill', color: ColorRGB.fromHex('#FFFFFF')), ], @@ -20,6 +22,7 @@ void main() { 'variables': { 'cornerRadius': r'${= isMobile ? [18, 18, 0, 0] : 12}', 'padding': r'${= isMobile ? [16, 8] : 24}', + 'strokeWeight': r'${= isMobile ? 1.5 : 2}', }, }); node.parentID = kRootNode; @@ -88,6 +91,7 @@ void main() { EdgeInsetsModel.symmetric(horizontal: 16, vertical: 8), ); expect(styled.innerBoxLocal.edgeLeft, 16); + expect((styled as GeometryMixin).strokeWeight, 1.5); isMobile.set(isMobile.value.copyWith(value: false)); await tester.pump(); @@ -95,6 +99,8 @@ void main() { expect(radius(), BorderRadius.circular(12)); styled = manager.applyStyleOverrides(buildContext, node); expect(styled.padding, EdgeInsetsModel.all(24)); + expect((styled as GeometryMixin).strokeWeight, 2); + expect(node.strokeWeight, 1.5); expect( (styled as CornerMixin).cornerRadius, CornerRadius.all(RadiusModel.circular(12)), From 4de3bdd01755ca8f44683a1fdb5b36fd8d4ba91a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 17:06:39 +0000 Subject: [PATCH 10/19] Multi-page publishing: keep other pages' layout maps, index layout canvases SDKPublishUpdates.merge applies a publish of some layouts (one page) on top of the published updates, keeping the per-layout fonts/apis of the layouts it doesn't touch. A new layoutCanvases index (layout id -> canvas ids) lets navigation and dialogs resolve a destination canvas whose layout is not downloaded yet; FunctionsRepository.resolveLayoutIdForCanvas is the one lookup both actions use. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SyH8twyrBoe4wFCTw78Cz5 --- lib/src/functions/functions_repository.dart | 39 ++-- lib/src/model/sdk_publish_model.dart | 53 +++++ lib/src/model/sdk_publish_model.g.dart | 11 + test/multi_page_publish_test.dart | 231 ++++++++++++++++++++ 4 files changed, 320 insertions(+), 14 deletions(-) create mode 100644 test/multi_page_publish_test.dart diff --git a/lib/src/functions/functions_repository.dart b/lib/src/functions/functions_repository.dart index 1541580c..3da87c03 100644 --- a/lib/src/functions/functions_repository.dart +++ b/lib/src/functions/functions_repository.dart @@ -491,17 +491,10 @@ class FunctionsRepository { await Navigator.maybePop(context, parsedParams); } else { // Check if a layout exists for the action's [destinationId]. - final String? layoutId = - effectiveCodelessly - .dataManager - .publishModel - ?.layouts[action.destinationId] - ?.id ?? - effectiveCodelessly.dataManager.publishModel?.layouts.values - .firstWhereOrNull( - (layout) => layout.canvasIds.contains(action.destinationId), - ) - ?.id; + final String? layoutId = resolveLayoutIdForCanvas( + effectiveCodelessly.dataManager.publishModel, + action.destinationId, + ); DebugLogger.instance.printInfo( 'looking for layout with canvas id: [${action.destinationId}]', @@ -615,9 +608,27 @@ class FunctionsRepository { /// The id of the published layout that holds the canvas [canvasId], if any. static String? layoutIdForCanvas(Codelessly codelessly, String canvasId) => - codelessly.dataManager.publishModel?.layouts.values - .firstWhereOrNull((layout) => layout.canvasIds.contains(canvasId)) - ?.id; + resolveLayoutIdForCanvas(codelessly.dataManager.publishModel, canvasId); + + /// The id of the layout of [model] that holds canvas [canvasId] (or whose + /// id is [canvasId]), if any. + /// + /// Layouts already downloaded are checked first. Otherwise the publish + /// model's index of layout canvases names it, so a destination whose + /// layout is not downloaded yet (a screen of another page of the project, + /// in an app that loads layouts on demand) still resolves: the + /// [CodelesslyWidget] shown for it downloads the layout. + static String? resolveLayoutIdForCanvas( + SDKPublishModel? model, + String canvasId, + ) { + if (model == null || canvasId.isEmpty) return null; + return model.layouts[canvasId]?.id ?? + model.layouts.values + .firstWhereOrNull((layout) => layout.canvasIds.contains(canvasId)) + ?.id ?? + model.updates.layoutIdForCanvas(canvasId); + } /// Shows the canvas [canvasId] as a dialog, a bottom sheet or a side sheet /// (see [presentCanvas]) and completes with what it was closed with: the diff --git a/lib/src/model/sdk_publish_model.dart b/lib/src/model/sdk_publish_model.dart index ba04998e..28dc0a8c 100644 --- a/lib/src/model/sdk_publish_model.dart +++ b/lib/src/model/sdk_publish_model.dart @@ -695,6 +695,13 @@ class SDKPublishUpdates with Equatable { /// apis of a given project. final Map> layoutApis; + /// A map that holds a mapping of layout ids -> the ids of the canvases the + /// layout holds. + /// + /// This lets the SDK find the layout of a canvas (the destination of a + /// navigation or dialog action) before the layout itself is downloaded. + final Map> layoutCanvases; + /// Creates a new instance of [SDKPublishUpdates]. SDKPublishUpdates({ this.fonts = const {}, @@ -704,6 +711,7 @@ class SDKPublishUpdates with Equatable { this.conditions = const {}, this.layoutFonts = const {}, this.layoutApis = const {}, + this.layoutCanvases = const {}, }); /// Creates a copy of this instance with the provided parameters. @@ -715,6 +723,7 @@ class SDKPublishUpdates with Equatable { Map? conditions, Map>? layoutFonts, Map>? layoutApis, + Map>? layoutCanvases, }) { return SDKPublishUpdates( fonts: fonts ?? this.fonts, @@ -724,9 +733,52 @@ class SDKPublishUpdates with Equatable { conditions: conditions ?? this.conditions, layoutFonts: layoutFonts ?? this.layoutFonts, layoutApis: layoutApis ?? this.layoutApis, + layoutCanvases: layoutCanvases ?? this.layoutCanvases, ); } + /// These updates with [newer] applied on top: what the publish model holds + /// after a publish that changed only some of the layouts (for instance the + /// layouts of one page of a project). + /// + /// Timestamps are combined, and the per-layout maps ([layoutFonts], + /// [layoutApis], [layoutCanvases]) keep the entries of layouts [newer] does + /// not mention while taking [newer]'s entries for the layouts it does. + /// A canvas that [newer] places in a layout is dropped from any other + /// layout that listed it, since a canvas lives in one layout. + SDKPublishUpdates merge(SDKPublishUpdates newer) { + final Set movedCanvases = { + for (final Set canvases in newer.layoutCanvases.values) + ...canvases, + }; + return SDKPublishUpdates( + fonts: {...fonts, ...newer.fonts}, + layouts: {...layouts, ...newer.layouts}, + apis: {...apis, ...newer.apis}, + variables: {...variables, ...newer.variables}, + conditions: {...conditions, ...newer.conditions}, + layoutFonts: {...layoutFonts, ...newer.layoutFonts}, + layoutApis: {...layoutApis, ...newer.layoutApis}, + layoutCanvases: { + for (final MapEntry(key: String layoutId, value: Set canvases) + in layoutCanvases.entries) + if (!newer.layoutCanvases.containsKey(layoutId)) + layoutId: canvases.difference(movedCanvases), + ...newer.layoutCanvases, + }..removeWhere((_, Set canvases) => canvases.isEmpty), + ); + } + + /// The id of the layout that holds canvas [canvasId] according to + /// [layoutCanvases], if any. + String? layoutIdForCanvas(String canvasId) { + for (final MapEntry(key: String layoutId, value: Set canvases) + in layoutCanvases.entries) { + if (canvases.contains(canvasId)) return layoutId; + } + return null; + } + @override List get props => [ fonts, @@ -736,6 +788,7 @@ class SDKPublishUpdates with Equatable { conditions, layoutFonts, layoutApis, + layoutCanvases, ]; /// Creates a new instance of [SDKPublishUpdates] from a JSON map. diff --git a/lib/src/model/sdk_publish_model.g.dart b/lib/src/model/sdk_publish_model.g.dart index 7b30d926..8024bb51 100644 --- a/lib/src/model/sdk_publish_model.g.dart +++ b/lib/src/model/sdk_publish_model.g.dart @@ -201,6 +201,14 @@ SDKPublishUpdates _$SDKPublishUpdatesFromJson(Map json) => SDKPublishUpdates( ), ) ?? const {}, + layoutCanvases: + (json['layoutCanvases'] as Map?)?.map( + (k, e) => MapEntry( + k as String, + (e as List).map((e) => e as String).toSet(), + ), + ) ?? + const {}, ); Map _$SDKPublishUpdatesToJson( @@ -213,6 +221,9 @@ Map _$SDKPublishUpdatesToJson( 'conditions': const DateTimeMapConverter().toJson(instance.conditions), 'layoutFonts': instance.layoutFonts.map((k, e) => MapEntry(k, e.toList())), 'layoutApis': instance.layoutApis.map((k, e) => MapEntry(k, e.toList())), + 'layoutCanvases': instance.layoutCanvases.map( + (k, e) => MapEntry(k, e.toList()), + ), }; SDKLayoutVariables _$SDKLayoutVariablesFromJson(Map json) => SDKLayoutVariables( diff --git a/test/multi_page_publish_test.dart b/test/multi_page_publish_test.dart new file mode 100644 index 00000000..65ebc393 --- /dev/null +++ b/test/multi_page_publish_test.dart @@ -0,0 +1,231 @@ +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:codelessly_sdk/codelessly_sdk.dart'; +import 'package:flutter_test/flutter_test.dart'; + +// A project published one page at a time: page A's layouts must survive a +// publish of page B, and a screen of one page must be able to open a screen +// of the other. + +SDKPublishLayout layout(String id, String pageId, List canvasIds) => + SDKPublishLayout( + id: id, + pageId: pageId, + projectId: 'project', + canvases: { + for (final String canvasId in canvasIds) + canvasId: { + canvasId: CanvasNode( + id: canvasId, + name: canvasId, + basicBoxLocal: NodeBox(0, 0, 390, 844), + properties: CanvasProperties(bodyId: canvasId), + children: const [], + ), + }, + }, + lastUpdated: DateTime(2026), + owner: 'owner', + teams: const {}, + users: const {}, + roles: const {}, + public: false, + ); + +SDKPublishModel model({ + Map layouts = const {}, + SDKPublishUpdates? updates, +}) { + final SDKPublishModel published = SDKPublishModel( + projectId: 'project', + updates: updates, + owner: 'owner', + teams: const {}, + users: const {}, + roles: const {}, + public: false, + ); + layouts.forEach((String id, SDKPublishLayout layout) { + published.layouts[id] = layout; + }); + return published; +} + +void main() { + group('SDKPublishUpdates.merge', () { + final DateTime t1 = DateTime(2026, 1, 1); + final DateTime t2 = DateTime(2026, 2, 1); + + // Page A was published first: layout a1 (fonts f1, api p1). + final SDKPublishUpdates pageA = SDKPublishUpdates( + layouts: {'a1': t1}, + fonts: {'f1': t1}, + apis: {'p1': t1}, + variables: {'a1': t1}, + conditions: {'a1': t1}, + layoutFonts: { + 'a1': {'f1'}, + }, + layoutApis: { + 'a1': {'p1'}, + }, + layoutCanvases: { + 'a1': {'canvasA'}, + }, + ); + + test('a publish of another page keeps the first page\'s layouts', () { + final SDKPublishUpdates pageB = SDKPublishUpdates( + layouts: {'b1': t2}, + fonts: {'f2': t2}, + variables: {'b1': t2}, + layoutFonts: { + 'b1': {'f2'}, + }, + layoutApis: {'b1': {}}, + layoutCanvases: { + 'b1': {'canvasB'}, + }, + ); + + final SDKPublishUpdates merged = pageA.merge(pageB); + + expect(merged.layouts, {'a1': t1, 'b1': t2}); + expect(merged.fonts, {'f1': t1, 'f2': t2}); + expect(merged.apis, {'p1': t1}); + expect(merged.variables, {'a1': t1, 'b1': t2}); + expect(merged.conditions, {'a1': t1}); + // These were replaced by the newer publish's maps, which dropped the + // fonts and apis of page A's layouts. + expect(merged.layoutFonts, { + 'a1': {'f1'}, + 'b1': {'f2'}, + }); + expect(merged.layoutApis, { + 'a1': {'p1'}, + 'b1': {}, + }); + expect(merged.layoutCanvases, { + 'a1': {'canvasA'}, + 'b1': {'canvasB'}, + }); + }); + + test('a republished layout takes the newer entries', () { + final SDKPublishUpdates again = SDKPublishUpdates( + layouts: {'a1': t2}, + layoutFonts: {'a1': {}}, + layoutCanvases: { + 'a1': {'canvasA', 'canvasA2'}, + }, + ); + + final SDKPublishUpdates merged = pageA.merge(again); + + expect(merged.layouts, {'a1': t2}); + expect(merged.layoutFonts, {'a1': {}}); + expect(merged.layoutApis, { + 'a1': {'p1'}, + }); + expect(merged.layoutCanvases['a1'], {'canvasA', 'canvasA2'}); + }); + + test('a canvas listed by a newer layout leaves its old layout', () { + final SDKPublishUpdates old = SDKPublishUpdates( + layoutCanvases: { + 'a1': {'canvasA'}, + 'a2': {'canvasMoved', 'canvasStays'}, + }, + ); + final SDKPublishUpdates merged = old.merge( + SDKPublishUpdates( + layoutCanvases: { + 'b1': {'canvasMoved'}, + }, + ), + ); + + expect(merged.layoutCanvases, { + 'a1': {'canvasA'}, + 'a2': {'canvasStays'}, + 'b1': {'canvasMoved'}, + }); + expect(merged.layoutIdForCanvas('canvasMoved'), 'b1'); + }); + + test('layoutCanvases round-trips through JSON', () { + final SDKPublishUpdates decoded = SDKPublishUpdates.fromJson( + pageA.toJson(), + ); + expect(decoded.layoutCanvases, pageA.layoutCanvases); + // Models published before the index existed still parse. + expect( + SDKPublishUpdates.fromJson({}).layoutCanvases, + isEmpty, + ); + }); + }); + + group('resolveLayoutIdForCanvas', () { + test('finds a downloaded layout of any page by its canvas', () { + final SDKPublishModel published = model( + layouts: { + 'a1': layout('a1', 'pageA', ['canvasA']), + 'b1': layout('b1', 'pageB', ['canvasB1', 'canvasB2']), + }, + ); + + expect( + FunctionsRepository.resolveLayoutIdForCanvas(published, 'canvasA'), + 'a1', + ); + expect( + FunctionsRepository.resolveLayoutIdForCanvas(published, 'canvasB2'), + 'b1', + ); + // A layout id works as a destination too. + expect( + FunctionsRepository.resolveLayoutIdForCanvas(published, 'b1'), + 'b1', + ); + }); + + test('finds a layout that is not downloaded yet from the index', () { + final SDKPublishModel published = model( + layouts: { + 'a1': layout('a1', 'pageA', ['canvasA']), + }, + updates: SDKPublishUpdates( + layoutCanvases: { + 'a1': {'canvasA'}, + 'b1': {'canvasB'}, + }, + ), + ); + + expect( + FunctionsRepository.resolveLayoutIdForCanvas(published, 'canvasB'), + 'b1', + ); + }); + + test('is null for an unknown canvas', () { + final SDKPublishModel published = model( + layouts: { + 'a1': layout('a1', 'pageA', ['canvasA']), + }, + ); + expect( + FunctionsRepository.resolveLayoutIdForCanvas(published, 'nope'), + isNull, + ); + expect( + FunctionsRepository.resolveLayoutIdForCanvas(published, ''), + isNull, + ); + expect( + FunctionsRepository.resolveLayoutIdForCanvas(null, 'canvasA'), + isNull, + ); + }); + }); +} From 3c8cd67550c1996eda20dcb948466c4809607ea1 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 17:09:45 +0000 Subject: [PATCH 11/19] Run the rest of a chain after a replace navigation A replace navigation awaited Navigator.pushReplacement, whose future only completes when the new screen pops, so the actions after it (pop a dialog, replace the page, show a toast) never ran. It no longer waits, and the new screen's ActionChainHost joins the chain (ActionChainAdopter), so the remaining actions run there once the old screen unmounts. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SyH8twyrBoe4wFCTw78Cz5 --- lib/src/functions/action_chain.dart | 68 +++++++++++++++++-- lib/src/functions/functions_repository.dart | 36 ++++++----- test/feedback_actions_test.dart | 72 +++++++++++++++++++++ 3 files changed, 154 insertions(+), 22 deletions(-) diff --git a/lib/src/functions/action_chain.dart b/lib/src/functions/action_chain.dart index 748a6c95..0505daf6 100644 --- a/lib/src/functions/action_chain.dart +++ b/lib/src/functions/action_chain.dart @@ -25,6 +25,10 @@ import '../transformers/utils/scoped_values.dart'; /// removed its document, the remaining actions run with the first host /// still mounted, so toasts, dialogs and navigation still happen. See /// [contextFor]. +/// * the screen a `replace` navigation of the chain put in place of the +/// trigger's: its [ActionChainHost] joins the chain ([ActionChainAdopter]), +/// so the actions after the navigation run there once the old screen is +/// gone. /// /// Something the chain registers to run later, such as a toast's Undo /// button, runs on the same chain through [defer]. @@ -39,7 +43,8 @@ class ActionChain { this.scopedValues, this._context, this._hosts = const [], - }); + List? adopted, + }) : _adopted = adopted ?? []; /// Creates a chain for a trigger that fired in [context], capturing its /// scope and hosts. @@ -62,6 +67,17 @@ class ActionChain { final BuildContext? _context; final List _hosts; + /// Hosts of screens a navigation of this chain replaced the trigger's + /// with, oldest first. Shared with nested chains. + final List _adopted; + + /// Makes [host] a context the chain's remaining actions can run with, + /// ahead of the trigger's and its hosts: the screen a `replace` + /// navigation put in their place. See [ActionChainAdopter]. + void adoptHost(BuildContext host) { + if (!_adopted.contains(host)) _adopted.add(host); + } + static const Symbol _zoneKey = #codelesslyActionChain; /// The chain the running action belongs to, if any. @@ -85,6 +101,7 @@ class ActionChain { scopedValues: scopedValues, context: _context, hosts: _hosts, + adopted: _adopted, ); /// Runs [body] in a new chain whose `${result}` starts as [result]. @@ -149,14 +166,19 @@ class ActionChain { current?.scopedValues ?? ScopedValues.of(context); /// A mounted context to run the next action with: [context] while it is - /// mounted, else the chain's trigger context, else the first of its hosts - /// still mounted. Null when all of them have unmounted, e.g. the app - /// replaced the whole screen. + /// mounted, else the host of a screen a `replace` navigation of the chain + /// opened (see [adoptHost]), else the chain's trigger context, else the + /// first of its hosts still mounted. Null when all of them have unmounted, + /// e.g. the app replaced the whole screen. static BuildContext? contextFor(BuildContext context) { if (context.mounted) return context; final ActionChain? chain = current; if (chain == null) return null; - for (final BuildContext? candidate in [chain._context, ...chain._hosts]) { + for (final BuildContext? candidate in [ + ...chain._adopted, + chain._context, + ...chain._hosts, + ]) { if (candidate != null && candidate.mounted) return candidate; } return null; @@ -176,6 +198,9 @@ class ActionChainHost extends InheritedWidget { static BuildContext? maybeOf(BuildContext context) => context.getElementForInheritedWidgetOfExactType(); + @override + InheritedElement createElement() => _ActionChainHostElement(this); + /// The contexts of the hosts a chain triggered in [context] can fall back /// to, nearest first: every [ActionChainHost] above [context] (a layout /// embedded in another has its own), then, past an [ActionChainOpener], @@ -225,3 +250,36 @@ class ActionChainOpener extends InheritedWidget { @override bool updateShouldNotify(ActionChainOpener oldWidget) => false; } + +class _ActionChainHostElement extends InheritedElement { + _ActionChainHostElement(super.widget); + + @override + void mount(Element? parent, Object? newSlot) { + super.mount(parent, newSlot); + final Widget? adopter = getElementForInheritedWidgetOfExactType< + ActionChainAdopter + >()?.widget; + if (adopter is ActionChainAdopter) adopter.chain.adoptHost(this); + } +} + +/// Wraps a screen that an action of [chain] put in place of the screen the +/// chain was triggered on (a `replace` navigation): the first +/// [ActionChainHost] that mounts below it joins [chain], so the chain's +/// remaining actions (e.g. a toast after "pop the dialog, then replace the +/// page") run with the new screen once the old one unmounts. +class ActionChainAdopter extends InheritedWidget { + /// Creates an [ActionChainAdopter] above [child]. + const ActionChainAdopter({ + super.key, + required this.chain, + required super.child, + }); + + /// The chain the new screen's hosts join. + final ActionChain chain; + + @override + bool updateShouldNotify(ActionChainAdopter oldWidget) => false; +} diff --git a/lib/src/functions/functions_repository.dart b/lib/src/functions/functions_repository.dart index 3da87c03..168c7052 100644 --- a/lib/src/functions/functions_repository.dart +++ b/lib/src/functions/functions_repository.dart @@ -553,25 +553,27 @@ class FunctionsRepository { ); } } else if (action.navigationType == NavigationType.replace) { - await Navigator.pushReplacement( - context, - MaterialPageRoute( - settings: RouteSettings(arguments: parsedParams), - builder: (context) => - CodelesslyWidget(controller: effectiveController), + // The new screen takes over the chain: the actions after this one + // (e.g. a toast) run with it once this screen unmounts. + final ActionChain? chain = ActionChain.current; + // Don't wait for the route's result: it only comes when the new + // screen pops, long after this screen is gone, and the rest of the + // chain would never run. + unawaited( + Navigator.pushReplacement( + context, + MaterialPageRoute( + settings: RouteSettings(arguments: parsedParams), + builder: (context) { + final Widget page = CodelesslyWidget( + controller: effectiveController, + ); + if (chain == null) return page; + return ActionChainAdopter(chain: chain, child: page); + }, + ), ), ); - - // Notify navigation listeners that this screen has been navigated back - // to, because the Navigator.push future has completed and came - // back to this screen. - if (context.mounted) { - effectiveCodelessly.notifyNavigationListeners( - context, - layoutId: myLayoutId, - canvasId: myCanvasId, - ); - } } } } diff --git a/test/feedback_actions_test.dart b/test/feedback_actions_test.dart index 0247d15d..466a4441 100644 --- a/test/feedback_actions_test.dart +++ b/test/feedback_actions_test.dart @@ -512,4 +512,76 @@ void main() { await tester.pumpAndSettle(); }); }); + + group('navigation', () { + testWidgets('a chain in a dialog that pops it and replaces the page ' + 'still shows its toast and runs on the new page', (tester) async { + final ButtonNode save = button( + 'save', + 'dialogBody', + 'Save', + NavigationAction(navigationType: NavigationType.pop, destinationId: ''), + ); + save.reactions.addAll([ + Reaction( + action: NavigationAction( + navigationType: NavigationType.replace, + destinationId: 'other', + ), + trigger: TriggerModel(type: TriggerType.click), + ), + Reaction( + action: ShowToastAction(message: 'Session saved', duration: 1500), + trigger: TriggerModel(type: TriggerType.click), + ), + Reaction( + action: DelayAction(milliseconds: 2000), + trigger: TriggerModel(type: TriggerType.click), + ), + Reaction( + action: ShowToastAction(message: 'Still here'), + trigger: TriggerModel(type: TriggerType.click), + ), + ]); + await pumpLayouts(tester, [ + layoutOf( + 'main', + mainScreen(ShowDialogAction(destinationId: 'dialogCanvas')), + ), + layoutOf('dialog', [ + canvas('dialogCanvas', 'dialogBody', fill: ColorRGBA.white), + body('dialogBody', 'dialogCanvas', ['save']), + save, + ]), + layoutOf('other', [ + canvas('other', 'otherBody', fill: ColorRGBA.white), + body('otherBody', 'other', ['otherTitle']), + text('otherTitle', 'otherBody', 'Settings', brandRegular), + ]), + ]); + + await tester.tap(find.text('Go')); + await tester.pumpAndSettle(); + expect(find.text('Save'), findsOneWidget); + + await tester.tap(find.text('Save')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + expect(find.text('Session saved'), findsOneWidget); + await tester.pumpAndSettle(); + // The page was replaced: the dialog and the main screen are gone. + expect(find.text('Save'), findsNothing); + expect(find.text('Go'), findsNothing); + expect(find.text('Settings'), findsOneWidget); + + // The rest of the chain runs on the new page after the old one has + // unmounted. + await tester.pump(const Duration(seconds: 2)); + await tester.pump(const Duration(milliseconds: 300)); + expect(find.text('Still here'), findsOneWidget); + await tester.pump(const Duration(seconds: 3)); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + }); + }); } From 00b44b5b10710fb56387cfab576b924c51a4710d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 17:10:25 +0000 Subject: [PATCH 12/19] Text fields: decoration content padding InputDecorationModel.contentPadding, when set, replaces the node's padding (12 x 16 by default for text fields) as the InputDecoration's content padding, so a collapsed, borderless field can sit flush. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SyH8twyrBoe4wFCTw78Cz5 --- .../passive_text_field_transformer.dart | 5 +- test/text_field_content_padding_test.dart | 104 ++++++++++++++++++ 2 files changed, 108 insertions(+), 1 deletion(-) create mode 100644 test/text_field_content_padding_test.dart diff --git a/lib/src/transformers/node_transformers/passive_text_field_transformer.dart b/lib/src/transformers/node_transformers/passive_text_field_transformer.dart index 7260acc1..6f8e5a6a 100644 --- a/lib/src/transformers/node_transformers/passive_text_field_transformer.dart +++ b/lib/src/transformers/node_transformers/passive_text_field_transformer.dart @@ -551,7 +551,10 @@ class _PassiveTextFieldWidgetState extends State { floatingLabelBehavior: decoration.floatingLabelBehavior.toFlutter(), isCollapsed: isCollapsed, isDense: isDense, - contentPadding: node.padding.flutterEdgeInsets, + // The decoration's own content padding wins over the node's padding, + // which defaults to 12 x 16 for text fields: zero makes a bare input. + contentPadding: (decoration.contentPadding ?? node.padding) + .flutterEdgeInsets, prefixIcon: !decoration.prefixIcon.icon.show || decoration.prefixIcon.icon.isEmpty ? null diff --git a/test/text_field_content_padding_test.dart b/test/text_field_content_padding_test.dart new file mode 100644 index 00000000..f0cbc60d --- /dev/null +++ b/test/text_field_content_padding_test.dart @@ -0,0 +1,104 @@ +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:codelessly_sdk/codelessly_sdk.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; + +const InputBorderModel _none = InputBorderModel( + borderType: BorderTypeEnum.none, +); + +TextFieldNode _field({EdgeInsetsModel? contentPadding}) => TextFieldNode( + id: 'field', + name: 'Field', + basicBoxLocal: NodeBox(0, 0, 300, 40), + properties: TextFieldProperties( + decoration: InputDecorationModel( + isCollapsed: true, + hintText: 'Notes', + border: _none, + enabledBorder: _none, + focusedBorder: _none, + disabledBorder: _none, + errorBorder: _none, + focusedErrorBorder: _none, + contentPadding: contentPadding, + ), + ), +); + +Future _pump(WidgetTester tester, TextFieldNode node) async { + final registry = NodeRegistry()..setNodes({node.id: node}); + final manager = PassiveNodeTransformerManager( + registry.getNodeByID, + (context, bounds, pageID, layoutID, canvasID) => const SizedBox(), + ); + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: CodelesslyContext.empty(), + child: MaterialApp( + home: Scaffold( + body: Align( + alignment: Alignment.topLeft, + child: Builder( + builder: (context) => manager.buildWidgetFromNode( + node, + context, + settings: const WidgetBuildSettings(debugLabel: 'test'), + ), + ), + ), + ), + ), + ), + ); + await tester.pump(); +} + +void main() { + testWidgets('a zero content padding puts the text flush with the field', ( + tester, + ) async { + await _pump(tester, _field(contentPadding: EdgeInsetsModel.zero)); + final InputDecoration decoration = tester + .widget(find.byType(TextField)) + .decoration!; + expect(decoration.contentPadding, EdgeInsets.zero); + expect( + tester.getTopLeft(find.byType(EditableText)).dx, + tester.getTopLeft(find.byType(TextField)).dx, + ); + }); + + testWidgets('without one, the node padding (12 x 16) pads the text', ( + tester, + ) async { + await _pump(tester, _field()); + final InputDecoration decoration = tester + .widget(find.byType(TextField)) + .decoration!; + expect( + decoration.contentPadding, + const EdgeInsets.symmetric(horizontal: 12, vertical: 16), + ); + expect( + tester.getTopLeft(find.byType(EditableText)).dx - + tester.getTopLeft(find.byType(TextField)).dx, + 12, + ); + }); + + testWidgets('a custom content padding applies as given', (tester) async { + await _pump( + tester, + _field(contentPadding: EdgeInsetsModel.only(left: 4, top: 2)), + ); + expect( + tester + .widget(find.byType(TextField)) + .decoration! + .contentPadding, + const EdgeInsets.only(left: 4, top: 2), + ); + }); +} From 6895c64676b02fd0adf8b1d08b9f30f30d50824e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 17:15:23 +0000 Subject: [PATCH 13/19] Toast: action color, close button, radius, font size, bound offset showToast reads ShowToastAction.actionColor (the button's label), showClose (a close button), cornerRadius, fontSize and offset (a variable or formula for the distance from the screen edge). Without a font of its own the toast takes the app's text style at the trigger. Toasts without the new fields look as before. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SyH8twyrBoe4wFCTw78Cz5 --- lib/src/functions/app_toast.dart | 80 +++++++++++++++--- test/toast_test.dart | 134 +++++++++++++++++++++++++++++++ 2 files changed, 205 insertions(+), 9 deletions(-) diff --git a/lib/src/functions/app_toast.dart b/lib/src/functions/app_toast.dart index 51faed5e..1402d148 100644 --- a/lib/src/functions/app_toast.dart +++ b/lib/src/functions/app_toast.dart @@ -35,6 +35,21 @@ class AppToast { final String message = text(action.message); final String actionLabel = action.hasAction ? text(action.actionLabel) : ''; final ResolvedFeedbackTheme theme = FeedbackTheme.of(context); + // Without a font of its own, the toast takes the app's text style at + // the trigger, not Material's default. + final TextStyle appText = DefaultTextStyle.of(context).style; + final String? themeFamily = Theme.of(context) + .textTheme + .bodyMedium + ?.fontFamily; + final TextStyle font = + theme.bodyStyle.fontFamily == themeFamily && appText.fontFamily != null + ? theme.bodyStyle.copyWith( + fontFamily: appText.fontFamily, + fontFamilyFallback: appText.fontFamilyFallback, + ) + : theme.bodyStyle; + final double? offset = offsetOf(action, scopedValues); // The button's actions run on the chain that showed the toast, whenever // it is pressed: with its captured scope (the `${item}` of a row that has // since left the list) and its `${result}` then (e.g. the id of the item @@ -57,6 +72,8 @@ class AppToast { message: message, actionLabel: actionLabel, theme: theme, + font: font, + offset: offset, onAction: onAction, onDone: () { if (_current == entry) _current = null; @@ -69,6 +86,27 @@ class AppToast { return true; } + /// The distance from the screen edge [action]'s bound + /// [ShowToastAction.offset] gives, read with [scopedValues], or null when + /// it is empty or not a number. + static double? offsetOf(ShowToastAction action, ScopedValues scopedValues) { + final String template = action.offset.trim(); + if (template.isEmpty) return null; + final Object? value = + FormulaEvaluator.exactExpression(template) != null || + RegExp(r'^\$\{[^{}]+\}$').hasMatch(template) + ? PropertyValueDelegate.getVariableValueFromPath( + template, + scopedValues: scopedValues, + ) + : PropertyValueDelegate.substituteVariables( + template, + scopedValues: scopedValues, + nullSubstitutionMode: NullSubstitutionMode.emptyString, + ); + return value.toDouble(); + } + /// Removes the toast on screen, if any. static void hide() { final OverlayEntry? entry = _current; @@ -108,6 +146,8 @@ class _ToastView extends StatefulWidget { required this.message, required this.actionLabel, required this.theme, + required this.font, + required this.offset, required this.onAction, required this.onDone, }); @@ -116,6 +156,8 @@ class _ToastView extends StatefulWidget { final String message; final String actionLabel; final ResolvedFeedbackTheme theme; + final TextStyle font; + final double? offset; final VoidCallback? onAction; final VoidCallback onDone; @@ -178,11 +220,14 @@ class _ToastViewState extends State<_ToastView> { child: retrieveIconWidget(action.icon!, 18) ?? const SizedBox(), ); final bool hasButton = widget.onAction != null; + final bool hasClose = action.showClose; final bool top = action.position == ToastPosition.top; final EdgeInsets safe = MediaQuery.paddingOf(context); - final TextStyle messageStyle = theme.bodyStyle.copyWith( + final double fontSize = action.fontSize ?? 14; + final Color actionColor = action.actionColor?.toFlutterColor() ?? textColor; + final TextStyle messageStyle = widget.font.copyWith( color: textColor, - fontSize: 14, + fontSize: fontSize, height: 1.35, fontWeight: FontWeight.w600, decoration: TextDecoration.none, @@ -190,11 +235,12 @@ class _ToastViewState extends State<_ToastView> { Widget toast = Container( constraints: const BoxConstraints(minHeight: 44, maxWidth: 560), - padding: EdgeInsets.fromLTRB(18, 10, hasButton ? 8 : 18, 10), + padding: EdgeInsets.fromLTRB(18, 10, hasButton || hasClose ? 4 : 18, 10), decoration: BoxDecoration( color: background, borderRadius: BorderRadius.circular( - hasButton ? theme.buttonRadius.clamp(8, 999) : 999, + action.cornerRadius ?? + (hasButton ? theme.buttonRadius.clamp(8, 999) : 999), ), boxShadow: const [ BoxShadow( @@ -215,11 +261,11 @@ class _ToastViewState extends State<_ToastView> { key: const ValueKey('codelessly-toast-action'), onPressed: _onActionPressed, style: TextButton.styleFrom( - foregroundColor: textColor, + foregroundColor: actionColor, minimumSize: const Size(48, 36), padding: const EdgeInsets.symmetric(horizontal: 12), textStyle: theme.buttonStyle.copyWith( - fontSize: 14, + fontSize: fontSize, fontWeight: FontWeight.w700, ), shape: RoundedRectangleBorder( @@ -229,6 +275,19 @@ class _ToastViewState extends State<_ToastView> { child: Text(widget.actionLabel), ), ], + if (hasClose) ...[ + if (!hasButton) const SizedBox(width: 4), + IconButton( + key: const ValueKey('codelessly-toast-close'), + onPressed: _close, + tooltip: 'Close', + color: textColor, + iconSize: 18, + constraints: const BoxConstraints(minWidth: 36, minHeight: 36), + padding: EdgeInsets.zero, + icon: const Icon(Icons.close), + ), + ], ], ), ); @@ -253,10 +312,13 @@ class _ToastViewState extends State<_ToastView> { return Positioned( left: 16, right: 16, - top: top ? safe.top + 16 : null, - bottom: top ? null : action.bottomOffset, + top: top ? safe.top + (widget.offset ?? 16) : null, + bottom: top ? null : widget.offset ?? action.bottomOffset, // Only a toast with a button takes taps; the rest passes them through. - child: IgnorePointer(ignoring: !hasButton || !_visible, child: toast), + child: IgnorePointer( + ignoring: !(hasButton || hasClose) || !_visible, + child: toast, + ), ); } } diff --git a/test/toast_test.dart b/test/toast_test.dart index 940850e8..3580f34b 100644 --- a/test/toast_test.dart +++ b/test/toast_test.dart @@ -65,4 +65,138 @@ void main() { await tester.pump(const Duration(milliseconds: 300)); expect(find.text('Second'), findsNothing); }); + + testWidgets('a toast can have an orange Undo, a close button, a radius, ' + 'a font size, a bound offset and the app font', (tester) async { + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + late BuildContext context; + final ctx = CodelesslyContext.empty() + ..variables['isMobile'] = Observable( + VariableData( + id: 'isMobile', + name: 'isMobile', + type: VariableType.boolean, + value: true, + ), + ); + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: ctx, + child: MaterialApp( + home: DefaultTextStyle( + style: const TextStyle(fontFamily: 'AppBody'), + child: Builder( + builder: (c) { + context = c; + return const SizedBox(); + }, + ), + ), + ), + ), + ); + + FunctionsRepository.triggerAction( + context, + TriggerType.click, + reactions: [ + Reaction( + action: ShowToastAction( + message: 'Session deleted', + duration: 10000, + backgroundColor: ColorRGBA.fromHex('#0E2233'), + textColor: ColorRGBA.white, + actionLabel: 'UNDO', + actionColor: ColorRGBA.fromHex('#F2542D'), + showClose: true, + cornerRadius: 8, + fontSize: 13, + offset: r'${= isMobile ? 72 : 16}', + ), + trigger: TriggerModel(type: TriggerType.click), + ), + ], + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + + final Text message = tester.widget(find.text('Session deleted')); + expect(message.style!.fontSize, 13); + expect(message.style!.fontFamily, 'AppBody'); + expect(message.style!.color, Colors.white); + + final ButtonStyle undo = tester + .widget( + find.byKey(const ValueKey('codelessly-toast-action')), + ) + .style!; + expect( + undo.foregroundColor!.resolve({}), + ColorRGBA.fromHex('#F2542D')!.toFlutterColor(), + ); + + final BoxDecoration decoration = tester + .widgetList( + find.ancestor( + of: find.text('Session deleted'), + matching: find.byType(Container), + ), + ) + .map((c) => c.decoration) + .whereType() + .first; + expect(decoration.borderRadius, BorderRadius.circular(8)); + expect(decoration.color, ColorRGBA.fromHex('#0E2233')!.toFlutterColor()); + + // 72 px above the bottom, from the bound offset. + final Positioned positioned = tester.widget( + find + .ancestor( + of: find.text('Session deleted'), + matching: find.byType(Positioned), + ) + .first, + ); + expect(positioned.bottom, 72); + + // The close button hides it. + await tester.tap(find.byKey(const ValueKey('codelessly-toast-close'))); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + expect(find.text('Session deleted'), findsNothing); + }); + + testWidgets('an existing toast keeps its look without the new fields', ( + tester, + ) async { + late BuildContext context; + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: CodelesslyContext.empty(), + child: MaterialApp( + home: Builder( + builder: (c) { + context = c; + return const SizedBox(); + }, + ), + ), + ), + ); + AppToast.show(context, ShowToastAction(message: 'Saved')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + expect(tester.widget(find.text('Saved')).style!.fontSize, 14); + expect(find.byKey(const ValueKey('codelessly-toast-close')), findsNothing); + final Positioned positioned = tester.widget( + find + .ancestor(of: find.text('Saved'), matching: find.byType(Positioned)) + .first, + ); + expect(positioned.bottom, 104); + await tester.pump(const Duration(seconds: 3)); + await tester.pumpAndSettle(); + }); } From 4bfa770da1d208a1686dbfc4c61b8e09869db563 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 17:16:42 +0000 Subject: [PATCH 14/19] Flexible scrolling rows/columns, overlay height caps, hugging sheets, override lookup - A flexible Row/Column that scrolls on its main axis hugs its content there, capped by its Flexible parent (Flutter loose Flexible), instead of taking its solved design size: a form body between header and footer no longer collapses to its padding. - ShowDialogAction.maxHeightFactor caps a dialog (and a bottom sheet, default 88%) at a share of the screen height; the canvas takes min(design height, cap). - A canvas that hugs vertically makes a bottom sheet, side sheet (vertically centred) or dialog as tall as its content, up to the cap. - ScopedValues keys variable overrides by name, as formulas look them up (they were keyed by id and never found). - PassiveIconWidget takes the scoped values its bindings read, for the editor canvas. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SyH8twyrBoe4wFCTw78Cz5 --- lib/src/functions/functions_repository.dart | 68 ++++-- .../passive_canvas_transformer.dart | 16 +- .../passive_icon_transformer.dart | 8 +- .../passive_rectangle_transformer.dart | 17 +- .../passive_row_column_transformer.dart | 48 +++- .../transformers/transformer_functions.dart | 14 +- lib/src/transformers/utils/scoped_values.dart | 4 +- test/flexible_scroll_body_test.dart | 77 +++++++ test/scoped_values_overrides_test.dart | 48 ++++ test/show_dialog_presentation_test.dart | 211 +++++++++++++++++- 10 files changed, 471 insertions(+), 40 deletions(-) create mode 100644 test/flexible_scroll_body_test.dart create mode 100644 test/scoped_values_overrides_test.dart diff --git a/lib/src/functions/functions_repository.dart b/lib/src/functions/functions_repository.dart index 168c7052..b1dc7478 100644 --- a/lib/src/functions/functions_repository.dart +++ b/lib/src/functions/functions_repository.dart @@ -592,18 +592,24 @@ class FunctionsRepository { presentation: action.presentation, alignment: action.alignment.flutterAlignment ?? Alignment.center, insets: action.insets.flutterEdgeInsets, + maxHeightFactor: action.maxHeightFactor, ); } /// The size canvas [canvasId] was designed at, if it is published. static Size? canvasDesignSize(Codelessly codelessly, String canvasId) { + final BaseNode? canvas = publishedCanvas(codelessly, canvasId); + if (canvas == null) return null; + return Size(canvas.basicBoxLocal.width, canvas.basicBoxLocal.height); + } + + /// The canvas node [canvasId], if it is published and downloaded. + static CanvasNode? publishedCanvas(Codelessly codelessly, String canvasId) { for (final layout in codelessly.dataManager.publishModel?.layouts.values ?? const []) { final BaseNode? canvas = layout.canvases[canvasId]?[canvasId]; - if (canvas != null) { - return Size(canvas.basicBoxLocal.width, canvas.basicBoxLocal.height); - } + if (canvas is CanvasNode) return canvas; } return null; } @@ -646,6 +652,7 @@ class FunctionsRepository { DialogPresentation presentation = DialogPresentation.dialog, AlignmentGeometry alignment = Alignment.center, EdgeInsets insets = EdgeInsets.zero, + double? maxHeightFactor, }) async { final ScopedValues scopedValues = ActionChain.scopedValuesOf(context); final parsedParams = substituteVariablesInMap(params, scopedValues); @@ -696,7 +703,12 @@ class FunctionsRepository { barrierDismissible: barrierDismissible, barrierColor: barrierColor, showCloseButton: showCloseButton, + maxHeightFactor: maxHeightFactor, designSize: canvasDesignSize(codelessly, canvasId), + hugsContent: switch (publishedCanvas(codelessly, canvasId)) { + final CanvasNode canvas => CanvasViewport.sizesToContent(canvas), + null => false, + }, routeSettings: RouteSettings(arguments: parsedParams), builder: (context) => ActionChainOpener( openerHosts: openerHosts, @@ -716,13 +728,16 @@ class FunctionsRepository { /// Opens what [builder] builds per [presentation] and completes with the /// value it is closed with: /// - /// * a dialog at [alignment], kept [insets] from the screen edges; + /// * a dialog at [alignment], kept [insets] from the screen edges, at most + /// [maxHeightFactor] of the screen high; /// * a bottom sheet at the screen's width and the canvas's [designSize] - /// height, at most 88% of the screen (adaptive: a sheet below 600 px - /// wide, a dialog otherwise); + /// height, at most [maxHeightFactor] (else 88%) of the screen (adaptive: + /// a sheet below 600 px wide, a dialog otherwise); /// * a side sheet sliding in from the right at the canvas's width, as tall /// as the screen less [insets]. /// + /// With [hugsContent] (the canvas hugs its height) a sheet is as tall as + /// its content, up to its cap; a side sheet is then centered vertically. /// The canvas draws the sheet's surface (fill, corners, grab handle). static Future presentCanvas( BuildContext context, { @@ -733,7 +748,9 @@ class FunctionsRepository { bool barrierDismissible = true, Color? barrierColor, bool showCloseButton = false, + double? maxHeightFactor, Size? designSize, + bool hugsContent = false, RouteSettings? routeSettings, }) { final bool asSheet = switch (presentation) { @@ -764,7 +781,7 @@ class FunctionsRepository { child: Padding( padding: insets, child: CanvasSizingScope( - sizing: CanvasSizing.fill, + sizing: hugsContent ? CanvasSizing.design : CanvasSizing.fill, child: SizedBox( width: designSize?.width ?? 420, child: Builder(builder: builder), @@ -788,16 +805,34 @@ class FunctionsRepository { elevation: 0, constraints: const BoxConstraints(), routeSettings: routeSettings, - builder: (context) => CanvasSizingScope( - sizing: CanvasSizing.fill, - child: SizedBox( - height: math.min( - designSize?.height ?? double.infinity, - MediaQuery.sizeOf(context).height * 0.88, + builder: (context) { + final Size screen = MediaQuery.sizeOf(context); + final double cap = + CodelesslyDialogWidget.maxHeightFor( + maxHeightFactor, + screen.height, + ) ?? + screen.height * 0.88; + if (hugsContent) { + return CanvasSizingScope( + sizing: CanvasSizing.design, + child: ConstrainedBox( + constraints: BoxConstraints( + minWidth: screen.width, + maxHeight: cap, + ), + child: Builder(builder: builder), + ), + ); + } + return CanvasSizingScope( + sizing: CanvasSizing.fill, + child: SizedBox( + height: math.min(designSize?.height ?? double.infinity, cap), + child: Builder(builder: builder), ), - child: Builder(builder: builder), - ), - ), + ); + }, ); } return showDialog( @@ -810,6 +845,7 @@ class FunctionsRepository { showCloseButton: showCloseButton, alignment: alignment, insets: insets, + maxHeightFactor: maxHeightFactor, builder: builder, ), ); diff --git a/lib/src/transformers/node_transformers/passive_canvas_transformer.dart b/lib/src/transformers/node_transformers/passive_canvas_transformer.dart index 9fe25753..2695c00a 100644 --- a/lib/src/transformers/node_transformers/passive_canvas_transformer.dart +++ b/lib/src/transformers/node_transformers/passive_canvas_transformer.dart @@ -499,6 +499,14 @@ class CanvasViewport extends StatelessWidget { const CanvasViewport({super.key, required this.node, required this.child}); + /// Whether [node] is a canvas whose height is its content's: it hugs + /// vertically. A scaffold needs a bounded height, so a canvas with an app + /// bar, navigation bar or FAB takes its design height instead. + static bool sizesToContent(CanvasNode node) => + node.verticalFit == SizeFit.shrinkWrap && + node.scaleMode == ScaleMode.responsive && + !node.isAScaffold; + /// The canvas box for [constraints] under [sizing], per axis. A null /// height leaves the height to the canvas content (see /// [CanvasContentHeight]), within the incoming maximum. @@ -507,13 +515,7 @@ class CanvasViewport extends StatelessWidget { BoxConstraints constraints, { CanvasSizing sizing = CanvasSizing.fill, }) { - // A canvas whose height is its content's. A scaffold needs a bounded - // height, so a canvas with an app bar, navigation bar or FAB takes its - // design height instead. - final bool contentHeight = - node.verticalFit == SizeFit.shrinkWrap && - node.scaleMode == ScaleMode.responsive && - !node.isAScaffold; + final bool contentHeight = sizesToContent(node); if (sizing == CanvasSizing.design) { final double width = constraints.constrainWidth(node.basicBoxLocal.width); if (contentHeight) return (width: width, height: null); diff --git a/lib/src/transformers/node_transformers/passive_icon_transformer.dart b/lib/src/transformers/node_transformers/passive_icon_transformer.dart index 5ed08220..64ef013c 100644 --- a/lib/src/transformers/node_transformers/passive_icon_transformer.dart +++ b/lib/src/transformers/node_transformers/passive_icon_transformer.dart @@ -27,10 +27,16 @@ class PassiveIconWidget extends StatelessWidget { final IconNode node; final bool useFonts; + /// The values the icon's bindings read; by default the ones in scope + /// where it builds. A design canvas passes its variable defaults, which + /// its build context doesn't hold. + final ScopedValues? scopedValues; + const PassiveIconWidget({ super.key, required this.node, this.useFonts = false, + this.scopedValues, }); /// [node]'s icon with its bound color and glyph applied: a bound or @@ -62,7 +68,7 @@ class PassiveIconWidget extends StatelessWidget { @override Widget build(BuildContext context) { return retrieveIconWidget( - resolvedIcon(node, ScopedValues.of(context)), + resolvedIcon(node, scopedValues ?? ScopedValues.of(context)), node.basicBoxLocal.width, ) ?? SizedBox( diff --git a/lib/src/transformers/node_transformers/passive_rectangle_transformer.dart b/lib/src/transformers/node_transformers/passive_rectangle_transformer.dart index 4ec5efb7..c7048cd6 100644 --- a/lib/src/transformers/node_transformers/passive_rectangle_transformer.dart +++ b/lib/src/transformers/node_transformers/passive_rectangle_transformer.dart @@ -79,13 +79,20 @@ class PassiveRectangleWidget extends StatelessWidget { bool isPlaceholder = parent is CanvasNode && parent.properties.bodyId == node.id; - // The canvas body hugs; the canvas sizes the screen. Every other node - // follows its fits (see FitBox): an expanded axis fills when bounded and - // keeps its design size when not. - final SizeFit horizontalFit = isPlaceholder + // The canvas body hugs; the canvas sizes the screen. A flexible + // Row/Column that scrolls along its main axis hugs that axis: its content + // sizes it, capped by its Flexible parent. Every other node follows its + // fits (see FitBox): an expanded axis fills when bounded and keeps its + // design size when not. + final bool contentSized = + PassiveRowColumnTransformer.scrollsFlexiblyOnMainAxis(node); + final bool isRow = + node is RowColumnMixin && + (node as RowColumnMixin).rowColumnType == RowColumnType.row; + final SizeFit horizontalFit = isPlaceholder || (contentSized && isRow) ? SizeFit.shrinkWrap : node.horizontalFit; - final SizeFit verticalFit = isPlaceholder + final SizeFit verticalFit = isPlaceholder || (contentSized && !isRow) ? SizeFit.shrinkWrap : node.verticalFit; diff --git a/lib/src/transformers/node_transformers/passive_row_column_transformer.dart b/lib/src/transformers/node_transformers/passive_row_column_transformer.dart index b7e72510..6ce88997 100644 --- a/lib/src/transformers/node_transformers/passive_row_column_transformer.dart +++ b/lib/src/transformers/node_transformers/passive_row_column_transformer.dart @@ -29,18 +29,28 @@ class PassiveRowColumnTransformer extends NodeWidgetTransformer { final bool isPlaceholder = parent is CanvasNode && parent.properties.bodyId == rowColumnNode.id; + // A flexible Row/Column that scrolls along its main axis is a Flutter + // Flexible (loose) around its scroll view: as long as its content, at + // most the space its parent gives it, and the content scrolls past that. + final bool contentSized = + withScroll && scrollsFlexiblyOnMainAxis(rowColumnNode); + final FitBox outerBox = FitBox( - width: _outerExtent( - rowColumnNode.horizontalFit, - design: rowColumnNode.outerBoxLocal.width, - min: rowColumnNode.constraints.minWidth, - max: rowColumnNode.constraints.maxWidth, - ), + width: contentSized && isRow + ? null + : _outerExtent( + rowColumnNode.horizontalFit, + design: rowColumnNode.outerBoxLocal.width, + min: rowColumnNode.constraints.minWidth, + max: rowColumnNode.constraints.maxWidth, + ), fillWidth: _fillsAxis( rowColumnNode.horizontalFit, max: rowColumnNode.constraints.maxWidth, ), - height: isPlaceholder && rowColumnNode.verticalFit.isFlex + height: contentSized && !isRow + ? null + : isPlaceholder && rowColumnNode.verticalFit.isFlex ? rowColumnNode.constraints.minHeight : _outerExtent( rowColumnNode.verticalFit, @@ -121,9 +131,12 @@ class PassiveRowColumnTransformer extends NodeWidgetTransformer { res = wrapWithPaddedScrollable( padding: rowColumnNode.resolvedPadding().flutterEdgeInsets, node: (rowColumnNode as ScrollableMixin), + // Sized by its content, there is no free space to align it in. fillViewport: + !contentSized && rowColumnNode.mainAxisAlignment != MainAxisAlignmentC.start, flexContent: hasMainAxisFlexChild(rowColumnNode, childrenNodes), + contentSizedOnScrollAxis: contentSized, child: res, ); } @@ -205,6 +218,27 @@ class PassiveRowColumnTransformer extends NodeWidgetTransformer { return res; } + /// Whether [node] is a Row/Column that scrolls along its main axis and is + /// flexible on it: it is as long as its content, capped by its [Flexible] + /// parent, like the editor's `min(flex share, content)`. + static bool scrollsFlexiblyOnMainAxis(BaseNode node) { + if (node is! RowColumnMixin || node is! ScrollableMixin) return false; + if (rowColumnWraps(node)) return false; + final ScrollableMixin scrollable = node as ScrollableMixin; + final bool isRow = node.rowColumnType == RowColumnType.row; + if (!scrollable.isScrollable) return false; + if (scrollable.scrollDirection != + (isRow ? AxisC.horizontal : AxisC.vertical)) { + return false; + } + return (isRow ? node.horizontalFit : node.verticalFit) == SizeFit.flexible; + } + + /// Whether [node] is a row/column whose children flow onto new runs when + /// they don't fit ([RowColumnMixin.wrap]), built as a [CodelesslyWrap]. + static bool rowColumnWraps(BaseNode node) => + node is RowColumnMixin && node.wrap; + /// The [Wrap.alignment] of a wrapping row/column with [alignment] as its /// main-axis alignment. static WrapAlignment wrapAlignment(MainAxisAlignmentC alignment) => diff --git a/lib/src/transformers/transformer_functions.dart b/lib/src/transformers/transformer_functions.dart index 5a4302eb..e4f69524 100644 --- a/lib/src/transformers/transformer_functions.dart +++ b/lib/src/transformers/transformer_functions.dart @@ -662,18 +662,28 @@ bool isCustomPositionRequiredForImage(PaintModel paint, SizeC nodeSize) { /// Wraps [child] in [node]'s scroll view, sized by [node]'s fits (see /// [FitBox]). See [buildScrollView] for [fillViewport] and [flexContent]. +/// +/// With [contentSizedOnScrollAxis] the scroll view hugs its content along +/// the scroll axis (at most the incoming maximum) instead of taking its +/// design size: a flexible Row/Column that scrolls. Widget wrapWithPaddedScrollable({ required ScrollableMixin node, EdgeInsets? padding, Clip? clipBehavior, bool fillViewport = false, bool flexContent = false, + bool contentSizedOnScrollAxis = false, required Widget child, }) { if (!node.isScrollable) return child; + final bool vertical = node.scrollDirection.isVertical; return FitBox.fromFits( - horizontalFit: node.horizontalFit, - verticalFit: node.verticalFit, + horizontalFit: contentSizedOnScrollAxis && !vertical + ? SizeFit.shrinkWrap + : node.horizontalFit, + verticalFit: contentSizedOnScrollAxis && vertical + ? SizeFit.shrinkWrap + : node.verticalFit, size: node.basicBoxLocal.size, child: buildScrollView( node: node, diff --git a/lib/src/transformers/utils/scoped_values.dart b/lib/src/transformers/utils/scoped_values.dart index 25d85b63..9664bbda 100644 --- a/lib/src/transformers/utils/scoped_values.dart +++ b/lib/src/transformers/utils/scoped_values.dart @@ -53,8 +53,10 @@ class ScopedValues { Map get variables => { ...?_previewVariables, ...codelesslyContext?.variableNamesMap() ?? const {}, + // By name, as everything reading this map looks variables up (a + // formula's `formStart`); keyed by id an override was never found. for (final variable in _variablesOverrides ?? const []) - variable.id: variable, + variable.name: variable, }; /// A map of data that is passed to loaded layouts for nodes to replace their diff --git a/test/flexible_scroll_body_test.dart b/test/flexible_scroll_body_test.dart new file mode 100644 index 00000000..d44e0fde --- /dev/null +++ b/test/flexible_scroll_body_test.dart @@ -0,0 +1,77 @@ +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'viewport_layout_test.dart' as vp; + +/// A 400x600 form column: a 60 tall header, a body column that scrolls with +/// 22 padding and [fields] 100 tall fields, and a 50 tall footer. The body's +/// design height is [bodyHeight] (what the editor solved). +Future _pumpForm( + WidgetTester tester, { + required SizeFit bodyFit, + required int fields, + double bodyHeight = 44, +}) async { + final RowColumnNode outer = RowColumnNode( + id: 'outer', + name: 'outer', + basicBoxLocal: NodeBox(0, 0, 400, 600), + rowColumnType: RowColumnType.column, + mainAxisAlignment: MainAxisAlignmentC.start, + crossAxisAlignment: CrossAxisAlignmentC.start, + children: ['header', 'body', 'footer'], + )..parentID = kRootNode; + final RowColumnNode body = RowColumnNode( + id: 'body', + name: 'body', + basicBoxLocal: NodeBox(0, 0, 400, bodyHeight), + rowColumnType: RowColumnType.column, + mainAxisAlignment: MainAxisAlignmentC.start, + crossAxisAlignment: CrossAxisAlignmentC.start, + verticalFit: bodyFit, + padding: const EdgeInsetsModel.all(22), + isScrollable: true, + children: [for (int i = 0; i < fields; i++) 'field$i'], + )..parentID = 'outer'; + await vp.pump(tester, const Size(800, 800), vp.Host.loose, outer, [ + vp.rect('header', 'outer', width: 400, height: 60), + body, + for (int i = 0; i < fields; i++) + vp.rect('field$i', 'body', width: 300, height: 100), + vp.rect('footer', 'outer', width: 400, height: 50), + ]); +} + +double _bodyHeight(WidgetTester tester) => + tester.getRect(vp.rectOf('footer').first).top - + tester.getRect(vp.rectOf('header').first).bottom; + +void main() { + testWidgets('a flexible scrolling body sizes to its content', (tester) async { + await _pumpForm(tester, bodyFit: SizeFit.flexible, fields: 2); + expect(_bodyHeight(tester), 244); + expect( + tester.getRect(vp.rectOf('field0').first).top - + tester.getRect(vp.rectOf('header').first).bottom, + 22, + ); + }); + + testWidgets('a flexible scrolling body takes at most the remaining space ' + 'and scrolls', (tester) async { + await _pumpForm(tester, bodyFit: SizeFit.flexible, fields: 8); + expect(_bodyHeight(tester), 490); + final ScrollableState scrollable = tester.state( + find.byType(Scrollable).first, + ); + expect(scrollable.position.maxScrollExtent, 844 - 490); + }); + + testWidgets('an expanded scrolling body fills the remaining space', ( + tester, + ) async { + await _pumpForm(tester, bodyFit: SizeFit.expanded, fields: 2); + expect(_bodyHeight(tester), 490); + }); +} diff --git a/test/scoped_values_overrides_test.dart b/test/scoped_values_overrides_test.dart new file mode 100644 index 00000000..2181cb39 --- /dev/null +++ b/test/scoped_values_overrides_test.dart @@ -0,0 +1,48 @@ +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:codelessly_sdk/codelessly_sdk.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; + +void main() { + testWidgets('variable overrides are found by name, as formulas read them', ( + tester, + ) async { + late ScopedValues scope; + await tester.pumpWidget( + ChangeNotifierProvider( + create: (_) => CodelesslyContext.empty(), + child: Builder( + builder: (context) { + scope = ScopedValues.of( + context, + variablesOverrides: [ + VariableData( + id: '0SfOJhCuRyCYVLBBiyZE', + name: 'formStart', + type: VariableType.integer, + value: '360', + ), + ], + ); + return const SizedBox(); + }, + ), + ), + ); + expect(scope.variables.keys, ['formStart']); + final IconNode icon = IconNode( + id: 'minus', + name: 'minus', + basicBoxLocal: NodeBox(0, 0, 24, 24), + )..variables['iconColor'] = r"${= formStart <= 0 ? '#D8D2C8' : '#2B2420'}"; + expect( + PropertyValueDelegate.getPropertyValue( + icon, + 'iconColor', + scopedValues: scope, + )?.toFlutterColor().toARGB32(), + 0xFF2B2420, + ); + }); +} diff --git a/test/show_dialog_presentation_test.dart b/test/show_dialog_presentation_test.dart index 9b2486d3..9dbe7d69 100644 --- a/test/show_dialog_presentation_test.dart +++ b/test/show_dialog_presentation_test.dart @@ -1,5 +1,6 @@ import 'package:codelessly_api/codelessly_api.dart'; import 'package:codelessly_sdk/codelessly_sdk.dart'; +import 'package:codelessly_sdk/src/ui/codelessly_dialog_widget.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -21,11 +22,45 @@ List overlayScreen(Size size) => [ harness.text('label', 'overlayBody', 'Overlay', harness.brandRegular), ]; +/// A canvas of [width] that hugs its content vertically: the 'Overlay' text +/// over a box [contentHeight] tall, in a body that hugs too. +List huggingScreen(double width, double contentHeight) => [ + CanvasNode( + id: 'overlay', + name: 'overlay', + basicBoxLocal: NodeBox(0, 0, width, 600), + verticalFit: SizeFit.shrinkWrap, + scaleMode: ScaleMode.responsive, + children: ['overlayBody'], + properties: CanvasProperties( + bodyId: 'overlayBody', + safeArea: const SafeAreaModel.all(false), + ), + )..parentID = kRootNode, + AutoPlaceholderNode( + id: 'overlayBody', + name: 'overlayBody', + basicBoxLocal: NodeBox(0, 0, width, contentHeight + 19), + children: ['label', 'box'], + mainAxisAlignment: MainAxisAlignmentC.start, + crossAxisAlignment: CrossAxisAlignmentC.start, + horizontalFit: SizeFit.expanded, + verticalFit: SizeFit.shrinkWrap, + )..parentID = 'overlay', + harness.text('label', 'overlayBody', 'Overlay', harness.brandRegular), + RectangleNode( + id: 'box', + name: 'box', + basicBoxLocal: NodeBox(0, 0, 100, contentHeight), + )..parentID = 'overlayBody', +]; + Future showOverlay( WidgetTester tester, Map extra, { Size design = const Size(390, 400), Size screen = const Size(834, 1112), + List? overlay, }) async { final ActionModel action = ActionModel.fromJson({ 'type': 'showDialog', @@ -34,7 +69,7 @@ Future showOverlay( }); await harness.pumpLayouts(tester, [ harness.layoutOf('main', harness.mainScreen(action)), - harness.layoutOf('sheet', overlayScreen(design)), + harness.layoutOf('sheet', overlay ?? overlayScreen(design)), ], size: screen); await tester.tap(find.text('Go')); await tester.pumpAndSettle(); @@ -99,4 +134,178 @@ void main() { expect(box.top, 10); expect(box.bottom, 1112 - 10); }); + + group('maxHeightFactor', () { + testWidgets('a dialog that fits keeps its design height', (tester) async { + final Rect box = await showOverlay( + tester, + {'maxHeightFactor': 0.9}, + design: const Size(600, 1000), + screen: const Size(834, 1112), + ); + expect(box.size, const Size(600, 1000)); + }); + + testWidgets('a taller dialog stops at the share of the screen', ( + tester, + ) async { + final Rect box = await showOverlay( + tester, + {'maxHeightFactor': 0.9}, + design: const Size(600, 1000), + screen: const Size(1440, 900), + ); + expect(box.width, 600); + expect(box.height, closeTo(810, 0.5)); + expect(box.center, const Offset(720, 450)); + }); + + testWidgets('without it a dialog keeps its design height', (tester) async { + final Rect box = await showOverlay( + tester, + {}, + design: const Size(600, 800), + screen: const Size(1440, 900), + ); + expect(box.height, 800); + }); + + testWidgets('a bottom sheet uses it instead of 88%', (tester) async { + final Rect box = await showOverlay( + tester, + {'presentation': 'bottomSheet', 'maxHeightFactor': 0.5}, + design: const Size(390, 2000), + screen: const Size(390, 844), + ); + expect(box.height, closeTo(422, 0.5)); + expect(box.bottom, 844); + }); + + test('maxHeightFor caps by the share, ignoring unusable factors', () { + expect(CodelesslyDialogWidget.maxHeightFor(0.9, 1000), 900); + expect(CodelesslyDialogWidget.maxHeightFor(2, 1000), 1000); + expect(CodelesslyDialogWidget.maxHeightFor(null, 1000), isNull); + expect(CodelesslyDialogWidget.maxHeightFor(0, 1000), isNull); + }); + }); + + group('a canvas that hugs its content', () { + // The box plus the label's line, far below the canvas's 600 design + // height. + void expectContentHeight(Rect box, double boxHeight) => expect( + box.height, + allOf(greaterThan(boxHeight + 10), lessThan(boxHeight + 30)), + ); + + testWidgets('makes a bottom sheet as tall as its content', (tester) async { + final Rect box = await showOverlay( + tester, + {'presentation': 'bottomSheet'}, + screen: const Size(390, 844), + overlay: huggingScreen(390, 200), + ); + expect(box.width, 390); + expectContentHeight(box, 200); + expect(box.bottom, 844); + }); + + testWidgets('stops a bottom sheet at its cap', (tester) async { + final Rect box = await showOverlay( + tester, + {'presentation': 'bottomSheet', 'maxHeightFactor': 0.25}, + screen: const Size(390, 844), + overlay: huggingScreen(390, 400), + ); + expect(box.height, closeTo(211, 0.5)); + expect(box.bottom, 844); + }); + + testWidgets('makes a side sheet as tall as its content, centered', ( + tester, + ) async { + final Rect box = await showOverlay(tester, { + 'presentation': 'sideSheet', + 'insets': 10, + }, overlay: huggingScreen(420, 300)); + expect(box.width, 420); + expect(box.right, 834 - 10); + expectContentHeight(box, 300); + expect(box.center.dy, closeTo(1112 / 2, 0.5)); + }); + + testWidgets('makes a dialog as tall as its content', (tester) async { + final Rect box = await showOverlay( + tester, + {}, + overlay: huggingScreen(400, 150), + ); + expect(box.size.width, 400); + expectContentHeight(box, 150); + }); + }); + + group('the actions after it run when the overlay closes', () { + /// Shows the overlay from a button whose next action toasts 'Closed'. + Future open(WidgetTester tester, Map extra) async { + final ActionModel show = ActionModel.fromJson({ + 'type': 'showDialog', + 'destinationId': 'overlay', + ...extra, + }); + final List main = harness.mainScreen(show); + final ButtonNode go = main.whereType().single; + go.reactions.add( + Reaction( + action: ActionModel.fromJson({ + 'type': 'showToast', + 'message': 'Closed', + }), + trigger: TriggerModel(type: TriggerType.click), + ), + ); + await harness.pumpLayouts(tester, [ + harness.layoutOf('main', main), + harness.layoutOf('sheet', overlayScreen(const Size(390, 400))), + ], size: const Size(834, 1112)); + await tester.tap(find.text('Go')); + await tester.pumpAndSettle(); + expect(find.text('Overlay'), findsOneWidget); + expect(find.text('Closed'), findsNothing); + } + + Future expectClosedOnce(WidgetTester tester) async { + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); + expect(find.text('Overlay'), findsNothing); + expect(find.text('Closed'), findsOneWidget); + await tester.pumpAndSettle(const Duration(seconds: 5)); + } + + testWidgets('a dialog dismissed by its barrier', (tester) async { + await open(tester, {}); + await tester.tapAt(const Offset(10, 10)); + await expectClosedOnce(tester); + }); + + testWidgets('a bottom sheet dismissed by its barrier', (tester) async { + await open(tester, {'presentation': 'bottomSheet'}); + await tester.tapAt(const Offset(10, 10)); + await expectClosedOnce(tester); + }); + + testWidgets('a side sheet dismissed by its barrier', (tester) async { + await open(tester, {'presentation': 'sideSheet'}); + await tester.tapAt(const Offset(10, 10)); + await expectClosedOnce(tester); + }); + + testWidgets('a dialog closed by the system back', (tester) async { + await open(tester, {}); + final NavigatorState navigator = tester.state( + find.byType(Navigator).last, + ); + navigator.pop(); + await expectClosedOnce(tester); + }); + }); } From 6ecd61d8cb47505c9ede64204ffb9ddfe7946c93 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 23:28:19 +0000 Subject: [PATCH 15/19] codelessly_sdk 8.4.0 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SyH8twyrBoe4wFCTw78Cz5 --- CHANGELOG.md | 43 +++++++++++++++++++++++++++++++++++++++++++ pubspec.yaml | 4 ++-- 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 555bdd98..14a974f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,46 @@ +## 8.4.0 + +### Layout + +- Charts: `ChartNode` renders as a line/area chart; tapping or dragging + snaps the selection, writes the bound variable and fires `changed`. +- A list that doesn't scroll, hugs its items and has a known item count + lays them out without a viewport, so a stretched row of cards keeps + equal heights with a list in one of them; with `wrap` its items flow onto + runs. +- A flexible row or column that scrolls sizes to its content, up to the + space left. +- Text span ranges are clamped to the text. + +### Bindings + +- `cornerRadius`, `padding` and `strokeWeight` can be bound to data like + fill, opacity and size. +- Bind an icon's glyph by name (`icon`); `materialIconVersions` gives the + SVG version of each Material icon. +- Variable overrides are looked up by name. + +### Overlays and actions + +- Side sheets (`DialogPresentation.sideSheet`), dialogs kept `insets` from + the screen edges, and a maximum height as a share of the screen + (`maxHeightFactor`) for dialogs and bottom sheets; a canvas that hugs its + content gives a content-height sheet or dialog. +- The rest of an action chain runs after a replace navigation. +- Batch cloud writes: `removeDocuments` and `setDocuments` commit in + Firestore batches of up to 500. +- Toasts: action color, close button, corner radius, font size and a bound + offset; without their own font they use the app's text style. +- Text fields use their decoration's `contentPadding`. + +### Publishing and design canvas + +- Navigation, dialogs and links resolve a canvas on any published page + (`layoutCanvases` index); publishing one page keeps the other pages' + fonts and APIs. +- List items on the design canvas read their own element of the list's + data (`VariablePreview.scopeFor`). + ## 8.3.0 Requires codelessly_api 6.2.0. diff --git a/pubspec.yaml b/pubspec.yaml index fb46ccb2..52c576e7 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: codelessly_sdk description: Codelessly CloudUIâ„¢ - Server-driven UI for Flutter. Supercharge your Flutter apps with dynamic UI and real-time updates. Build and publish UI without code! -version: 8.3.0 +version: 8.4.0 homepage: https://codelessly.com/ repository: https://github.com/Codelessly/CodelesslySDK issue_tracker: https://github.com/Codelessly/CodelesslySDK/issues @@ -28,7 +28,7 @@ dependencies: flutter: sdk: flutter # Codelessly - codelessly_api: ^6.2.0 + codelessly_api: ^6.3.0 json_annotation: ^4.12.0 # Firebase From 7cc32bfdc0efb786ca06f0c5f368b63b1d7246f9 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 23:35:17 +0000 Subject: [PATCH 16/19] Format and const fixes for CI Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SyH8twyrBoe4wFCTw78Cz5 --- lib/src/functions/action_chain.dart | 5 +- .../passive_text_field_transformer.dart | 4 +- test/list_view_wrap_test.dart | 10 +- test/radius_padding_binding_test.dart | 197 +++++++++--------- test/text_field_content_padding_test.dart | 2 +- 5 files changed, 111 insertions(+), 107 deletions(-) diff --git a/lib/src/functions/action_chain.dart b/lib/src/functions/action_chain.dart index 0505daf6..56d6381a 100644 --- a/lib/src/functions/action_chain.dart +++ b/lib/src/functions/action_chain.dart @@ -257,9 +257,8 @@ class _ActionChainHostElement extends InheritedElement { @override void mount(Element? parent, Object? newSlot) { super.mount(parent, newSlot); - final Widget? adopter = getElementForInheritedWidgetOfExactType< - ActionChainAdopter - >()?.widget; + final Widget? adopter = + getElementForInheritedWidgetOfExactType()?.widget; if (adopter is ActionChainAdopter) adopter.chain.adoptHost(this); } } diff --git a/lib/src/transformers/node_transformers/passive_text_field_transformer.dart b/lib/src/transformers/node_transformers/passive_text_field_transformer.dart index 6f8e5a6a..eb09aeac 100644 --- a/lib/src/transformers/node_transformers/passive_text_field_transformer.dart +++ b/lib/src/transformers/node_transformers/passive_text_field_transformer.dart @@ -553,8 +553,8 @@ class _PassiveTextFieldWidgetState extends State { isDense: isDense, // The decoration's own content padding wins over the node's padding, // which defaults to 12 x 16 for text fields: zero makes a bare input. - contentPadding: (decoration.contentPadding ?? node.padding) - .flutterEdgeInsets, + contentPadding: + (decoration.contentPadding ?? node.padding).flutterEdgeInsets, prefixIcon: !decoration.prefixIcon.icon.show || decoration.prefixIcon.icon.isEmpty ? null diff --git a/test/list_view_wrap_test.dart b/test/list_view_wrap_test.dart index a60f3923..2c3d8455 100644 --- a/test/list_view_wrap_test.dart +++ b/test/list_view_wrap_test.dart @@ -55,9 +55,13 @@ void main() { testWidgets('a wrapping list keeps its items on one run when they fit', ( tester, ) async { - await vp.pump(tester, const Size(800, 800), vp.Host.loose, wrappingList( - width: 500, - ), [vp.rect('item', 'list')]); + await vp.pump( + tester, + const Size(800, 800), + vp.Host.loose, + wrappingList(width: 500), + [vp.rect('item', 'list')], + ); expect(find.byType(Wrap), findsOneWidget); expect(find.byType(ViewportNodeBox), findsNothing); final List items = itemRects(tester); diff --git a/test/radius_padding_binding_test.dart b/test/radius_padding_binding_test.dart index 45e29e74..f57cb940 100644 --- a/test/radius_padding_binding_test.dart +++ b/test/radius_padding_binding_test.dart @@ -5,110 +5,111 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:provider/provider.dart'; void main() { - testWidgets('corner radius, padding and stroke weight follow bound formulas', ( - tester, - ) async { - final RectangleNode node = RectangleNode.fromJson({ - ...RectangleNode( - id: 'sheet', - name: 'Sheet', - basicBoxLocal: NodeBox(0, 0, 200, 100), - strokes: [PaintModel.blackPaint], - strokeWeight: 1.5, - fills: [ - PaintModel.solid(id: 'fill', color: ColorRGB.fromHex('#FFFFFF')), - ], - ).toJson(), - 'variables': { - 'cornerRadius': r'${= isMobile ? [18, 18, 0, 0] : 12}', - 'padding': r'${= isMobile ? [16, 8] : 24}', - 'strokeWeight': r'${= isMobile ? 1.5 : 2}', - }, - }); - node.parentID = kRootNode; - final registry = NodeRegistry()..setNodes({node.id: node}); - final manager = PassiveNodeTransformerManager( - registry.getNodeByID, - (context, bounds, pageID, layoutID, canvasID) => const SizedBox(), - ); - final Observable isMobile = Observable( - VariableData( - id: 'isMobile', - name: 'isMobile', - type: VariableType.boolean, - value: true, - ), - ); - final CodelesslyContext codelesslyContext = CodelesslyContext.empty() - ..variables['isMobile'] = isMobile; + testWidgets( + 'corner radius, padding and stroke weight follow bound formulas', + (tester) async { + final RectangleNode node = RectangleNode.fromJson({ + ...RectangleNode( + id: 'sheet', + name: 'Sheet', + basicBoxLocal: NodeBox(0, 0, 200, 100), + strokes: [PaintModel.blackPaint], + strokeWeight: 1.5, + fills: [ + PaintModel.solid(id: 'fill', color: ColorRGB.fromHex('#FFFFFF')), + ], + ).toJson(), + 'variables': { + 'cornerRadius': r'${= isMobile ? [18, 18, 0, 0] : 12}', + 'padding': r'${= isMobile ? [16, 8] : 24}', + 'strokeWeight': r'${= isMobile ? 1.5 : 2}', + }, + }); + node.parentID = kRootNode; + final registry = NodeRegistry()..setNodes({node.id: node}); + final manager = PassiveNodeTransformerManager( + registry.getNodeByID, + (context, bounds, pageID, layoutID, canvasID) => const SizedBox(), + ); + final Observable isMobile = Observable( + VariableData( + id: 'isMobile', + name: 'isMobile', + type: VariableType.boolean, + value: true, + ), + ); + final CodelesslyContext codelesslyContext = CodelesslyContext.empty() + ..variables['isMobile'] = isMobile; - late BuildContext buildContext; - await tester.pumpWidget( - ChangeNotifierProvider.value( - value: codelesslyContext, - child: MaterialApp( - home: Align( - alignment: Alignment.topLeft, - child: Builder( - builder: (context) { - buildContext = context; - return manager.buildWidgetFromNode( - node, - context, - settings: const WidgetBuildSettings(debugLabel: 'test'), - ); - }, + late BuildContext buildContext; + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: codelesslyContext, + child: MaterialApp( + home: Align( + alignment: Alignment.topLeft, + child: Builder( + builder: (context) { + buildContext = context; + return manager.buildWidgetFromNode( + node, + context, + settings: const WidgetBuildSettings(debugLabel: 'test'), + ); + }, + ), ), ), ), - ), - ); + ); - BorderRadiusGeometry? radius() => tester.allWidgets - .map( - (w) => switch (w) { - final Ink ink => ink.decoration, - final DecoratedBox box => box.decoration, - final Container c => c.decoration, - _ => null, - }, - ) - .whereType() - .map((decoration) => decoration.borderRadius) - .nonNulls - .firstOrNull; + BorderRadiusGeometry? radius() => tester.allWidgets + .map( + (w) => switch (w) { + final Ink ink => ink.decoration, + final DecoratedBox box => box.decoration, + final Container c => c.decoration, + _ => null, + }, + ) + .whereType() + .map((decoration) => decoration.borderRadius) + .nonNulls + .firstOrNull; - expect( - radius(), - const BorderRadius.only( - topLeft: Radius.circular(18), - topRight: Radius.circular(18), - ), - ); - BaseNode styled = manager.applyStyleOverrides(buildContext, node); - expect( - styled.padding, - EdgeInsetsModel.symmetric(horizontal: 16, vertical: 8), - ); - expect(styled.innerBoxLocal.edgeLeft, 16); - expect((styled as GeometryMixin).strokeWeight, 1.5); + expect( + radius(), + const BorderRadius.only( + topLeft: Radius.circular(18), + topRight: Radius.circular(18), + ), + ); + BaseNode styled = manager.applyStyleOverrides(buildContext, node); + expect( + styled.padding, + const EdgeInsetsModel.symmetric(horizontal: 16, vertical: 8), + ); + expect(styled.innerBoxLocal.edgeLeft, 16); + expect((styled as GeometryMixin).strokeWeight, 1.5); - isMobile.set(isMobile.value.copyWith(value: false)); - await tester.pump(); + isMobile.set(isMobile.value.copyWith(value: false)); + await tester.pump(); - expect(radius(), BorderRadius.circular(12)); - styled = manager.applyStyleOverrides(buildContext, node); - expect(styled.padding, EdgeInsetsModel.all(24)); - expect((styled as GeometryMixin).strokeWeight, 2); - expect(node.strokeWeight, 1.5); - expect( - (styled as CornerMixin).cornerRadius, - CornerRadius.all(RadiusModel.circular(12)), - ); - // The node itself is unchanged. - expect(node.cornerRadius, CornerRadius.zero); - expect(node.padding, EdgeInsetsModel.zero); - }); + expect(radius(), BorderRadius.circular(12)); + styled = manager.applyStyleOverrides(buildContext, node); + expect(styled.padding, const EdgeInsetsModel.all(24)); + expect((styled as GeometryMixin).strokeWeight, 2); + expect(node.strokeWeight, 1.5); + expect( + (styled as CornerMixin).cornerRadius, + const CornerRadius.all(RadiusModel.circular(12)), + ); + // The node itself is unchanged. + expect(node.cornerRadius, CornerRadius.zero); + expect(node.padding, EdgeInsetsModel.zero); + }, + ); testWidgets('an unparseable binding keeps the node\'s own values', ( tester, @@ -118,8 +119,8 @@ void main() { id: 'box', name: 'Box', basicBoxLocal: NodeBox(0, 0, 50, 50), - cornerRadius: CornerRadius.all(RadiusModel.circular(6)), - padding: EdgeInsetsModel.all(4), + cornerRadius: const CornerRadius.all(RadiusModel.circular(6)), + padding: const EdgeInsetsModel.all(4), ).toJson(), 'variables': {'cornerRadius': r'${= "round"}', 'padding': r'${= "x"}'}, }); diff --git a/test/text_field_content_padding_test.dart b/test/text_field_content_padding_test.dart index f0cbc60d..a1107866 100644 --- a/test/text_field_content_padding_test.dart +++ b/test/text_field_content_padding_test.dart @@ -91,7 +91,7 @@ void main() { testWidgets('a custom content padding applies as given', (tester) async { await _pump( tester, - _field(contentPadding: EdgeInsetsModel.only(left: 4, top: 2)), + _field(contentPadding: const EdgeInsetsModel.only(left: 4, top: 2)), ); expect( tester From f487b0d3ab0bd41b8dc0267f5c03e2244a1b7312 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 23:45:31 +0000 Subject: [PATCH 17/19] One canvas-to-layout lookup: resolveLayoutId falls back to the layoutCanvases index Links and actions now resolve a canvas the same way, including layouts on other pages that aren't loaded yet. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SyH8twyrBoe4wFCTw78Cz5 --- lib/src/functions/functions_repository.dart | 6 +----- lib/src/model/sdk_publish_model.dart | 8 +++++--- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/lib/src/functions/functions_repository.dart b/lib/src/functions/functions_repository.dart index b1dc7478..afdf0053 100644 --- a/lib/src/functions/functions_repository.dart +++ b/lib/src/functions/functions_repository.dart @@ -631,11 +631,7 @@ class FunctionsRepository { String canvasId, ) { if (model == null || canvasId.isEmpty) return null; - return model.layouts[canvasId]?.id ?? - model.layouts.values - .firstWhereOrNull((layout) => layout.canvasIds.contains(canvasId)) - ?.id ?? - model.updates.layoutIdForCanvas(canvasId); + return model.resolveLayoutId(canvasId); } /// Shows the canvas [canvasId] as a dialog, a bottom sheet or a side sheet diff --git a/lib/src/model/sdk_publish_model.dart b/lib/src/model/sdk_publish_model.dart index 28dc0a8c..a6df0279 100644 --- a/lib/src/model/sdk_publish_model.dart +++ b/lib/src/model/sdk_publish_model.dart @@ -923,15 +923,17 @@ class SDKLayoutConditions extends PrivacyBase { /// Finding layouts in a published model. extension SDKPublishModelLayouts on SDKPublishModel { /// The layout [id] names: a layout ID, or the ID of a canvas in a layout, - /// so a link can open a screen by its canvas. Null when [id] is null or - /// names neither. + /// so a link can open a screen by its canvas. Layouts not loaded yet (on + /// another page, or loaded on demand) are found through + /// [SDKPublishUpdates.layoutCanvases]. Null when [id] is null or names + /// neither. String? resolveLayoutId(String? id) { if (id == null || id.isEmpty) return null; if (layouts.containsKey(id)) return id; for (final SDKPublishLayout layout in layouts.values) { if (layout.canvasIds.contains(id)) return layout.id; } - return null; + return updates.layoutIdForCanvas(id); } /// The parameters [link] gives a screen of layout [layoutId] (the entry From 64d4cb8d8e460e10033d2e2cb4d70cb6fcabb4ad Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 00:16:41 +0000 Subject: [PATCH 18/19] Format the merged dialog presentation code Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SyH8twyrBoe4wFCTw78Cz5 --- lib/src/functions/functions_repository.dart | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/src/functions/functions_repository.dart b/lib/src/functions/functions_repository.dart index afdf0053..378c2305 100644 --- a/lib/src/functions/functions_repository.dart +++ b/lib/src/functions/functions_repository.dart @@ -760,7 +760,8 @@ class FunctionsRepository { context: context, useRootNavigator: false, barrierDismissible: barrierDismissible, - barrierLabel: MaterialLocalizations.of(context).modalBarrierDismissLabel, + barrierLabel: MaterialLocalizations.of(context) + .modalBarrierDismissLabel, barrierColor: barrierColor ?? Colors.black54, routeSettings: routeSettings, transitionDuration: const Duration(milliseconds: 260), From 871ee0f6aa8d7d358e74b7ade9284470c7c9bb01 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 01:14:29 +0000 Subject: [PATCH 19/19] Toasts take ShowToastAction.fontFamily and fontWeight Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SyH8twyrBoe4wFCTw78Cz5 --- CHANGELOG.md | 2 +- lib/src/functions/app_toast.dart | 23 +++++++++++++++++++++-- test/toast_test.dart | 22 ++++++++++++++++++++++ 3 files changed, 44 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 14a974f5..a5673fc6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,7 +29,7 @@ - The rest of an action chain runs after a replace navigation. - Batch cloud writes: `removeDocuments` and `setDocuments` commit in Firestore batches of up to 500. -- Toasts: action color, close button, corner radius, font size and a bound +- Toasts: action color, close button, corner radius, font size, family and weight and a bound offset; without their own font they use the app's text style. - Text fields use their decoration's `contentPadding`. diff --git a/lib/src/functions/app_toast.dart b/lib/src/functions/app_toast.dart index 1402d148..8a70096c 100644 --- a/lib/src/functions/app_toast.dart +++ b/lib/src/functions/app_toast.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:codelessly_api/codelessly_api.dart'; import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; import '../../codelessly_sdk.dart'; import 'action_chain.dart'; @@ -42,13 +43,14 @@ class AppToast { .textTheme .bodyMedium ?.fontFamily; - final TextStyle font = + final TextStyle appFont = theme.bodyStyle.fontFamily == themeFamily && appText.fontFamily != null ? theme.bodyStyle.copyWith( fontFamily: appText.fontFamily, fontFamilyFallback: appText.fontFamilyFallback, ) : theme.bodyStyle; + final TextStyle font = fontOf(action, appFont); final double? offset = offsetOf(action, scopedValues); // The button's actions run on the chain that showed the toast, whenever // it is pressed: with its captured scope (the `${item}` of a row that has @@ -86,6 +88,24 @@ class AppToast { return true; } + /// The message's text style: [base] in [ShowToastAction.fontFamily] (a + /// Google font or one the project loaded) and [ShowToastAction.fontWeight] + /// when set. Without a weight it is semibold. + static TextStyle fontOf(ShowToastAction action, TextStyle base) { + final FontWeight weight = + action.fontWeight?.flutterFontWeight ?? FontWeight.w600; + final String family = action.fontFamily?.trim() ?? ''; + if (family.isEmpty) return base.copyWith(fontWeight: weight); + if (GoogleFonts.asMap().containsKey(family)) { + return GoogleFonts.getFont(family, textStyle: base, fontWeight: weight); + } + return base.copyWith( + fontFamily: family, + fontFamilyFallback: const [], + fontWeight: weight, + ); + } + /// The distance from the screen edge [action]'s bound /// [ShowToastAction.offset] gives, read with [scopedValues], or null when /// it is empty or not a number. @@ -229,7 +249,6 @@ class _ToastViewState extends State<_ToastView> { color: textColor, fontSize: fontSize, height: 1.35, - fontWeight: FontWeight.w600, decoration: TextDecoration.none, ); diff --git a/test/toast_test.dart b/test/toast_test.dart index 3580f34b..fa942566 100644 --- a/test/toast_test.dart +++ b/test/toast_test.dart @@ -199,4 +199,26 @@ void main() { await tester.pump(const Duration(seconds: 3)); await tester.pumpAndSettle(); }); + + test('a toast can set its font family and weight', () { + const TextStyle app = TextStyle(fontFamily: 'Mono', fontSize: 14); + final TextStyle plain = AppToast.fontOf( + ShowToastAction(message: 'Saved'), + app, + ); + expect(plain.fontFamily, 'Mono'); + expect(plain.fontWeight, FontWeight.w600); + + final TextStyle custom = AppToast.fontOf( + ShowToastAction( + message: 'Saved', + fontFamily: 'Brand Sans', + fontWeight: FontWeightNumeric.w500, + ), + app, + ); + expect(custom.fontFamily, 'Brand Sans'); + expect(custom.fontWeight, FontWeight.w500); + expect(custom.fontSize, 14); + }); }