From ad0fe8a13a7556e2db014c142f49b3f3bb70f3a4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 22:38:11 +0000 Subject: [PATCH 01/18] Render gauge and chart nodes and repeating gradients PassiveGaugeTransformer paints GaugeNode dials (top-anchored, animated value, zone colors, segments, ticks, labels in Google Fonts, target and needle). PassiveChartTransformer paints bar, line and timeline charts from the node's bound data, keeps a selected category, and fires changed reactions with the tapped index as ${value}. Gradients honor the paint's tile mode; a repeating linear gradient spans 1 / scale of the box. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FeVQ6NCLHS9Vrfxhii4Mj8 --- .../node_transformers/node_transformers.dart | 2 + .../passive_chart_transformer.dart | 714 ++++++++++++++++++ .../passive_gauge_transformer.dart | 338 +++++++++ .../passive_rectangle_transformer.dart | 21 +- .../passive_transformer_manager.dart | 2 + test/gauge_chart_test.dart | 289 +++++++ 6 files changed, 1364 insertions(+), 2 deletions(-) create mode 100644 lib/src/transformers/node_transformers/passive_chart_transformer.dart create mode 100644 lib/src/transformers/node_transformers/passive_gauge_transformer.dart create mode 100644 test/gauge_chart_test.dart diff --git a/lib/src/transformers/node_transformers/node_transformers.dart b/lib/src/transformers/node_transformers/node_transformers.dart index 8421a1e2..117f31c3 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'; @@ -13,6 +14,7 @@ export 'passive_embedded_video_transformer.dart'; export 'passive_expansion_tile_transformer.dart'; export 'passive_external_component_transformer.dart'; export 'passive_floating_action_button_transformer.dart'; +export 'passive_gauge_transformer.dart'; export 'passive_grid_view_transformer.dart'; export 'passive_icon_transformer.dart'; export 'passive_list_tile_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..2adf0701 --- /dev/null +++ b/lib/src/transformers/node_transformers/passive_chart_transformer.dart @@ -0,0 +1,714 @@ +import 'dart:math' as math; + +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:flutter/material.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, + ) => PassiveChartWidget(node: node, settings: settings); +} + +/// Renders a [ChartNode]: resolves its data and selection, paints the chart +/// and turns taps into a selected category and `changed` reactions. +class PassiveChartWidget extends StatelessWidget { + final ChartNode node; + final WidgetBuildSettings settings; + final List variablesOverrides; + + const PassiveChartWidget({ + super.key, + required this.node, + required this.settings, + this.variablesOverrides = const [], + }); + + /// The chart's items: the bound `data` list, else the sample data. + static List> resolveData( + ChartNode node, + ScopedValues scopedValues, + ) { + final String? path = node.variables['data']; + final List? bound = path == null || path.isEmpty + ? null + : PropertyValueDelegate.getVariableValueFromPath( + path, + scopedValues: scopedValues, + ); + final List source = + bound ?? + (path != null && path.isNotEmpty + ? const [] + : node.properties.sampleData); + return [ + for (final Object? item in source) + if (item is Map) + {for (final MapEntry e in item.entries) e.key.toString(): e.value} + else + {'value': item}, + ]; + } + + void onSelect(BuildContext context, int index) { + // `${value}` in the reactions reads the node's state. + NodeStateProvider.setState(context, index); + FunctionsRepository.setPropertyValue( + context, + node: node, + property: 'selectedIndex', + value: index, + ); + FunctionsRepository.triggerAction( + context, + node: node, + TriggerType.changed, + value: index, + ); + } + + @override + Widget build(BuildContext context) { + final ScopedValues scopedValues = ScopedValues.of( + context, + variablesOverrides: variablesOverrides, + ); + final List> data = resolveData(node, scopedValues); + final int selected = + PropertyValueDelegate.getPropertyValue( + node, + 'selectedIndex', + scopedValues: scopedValues, + ) ?? + node.selectedIndex; + final ChartPainter painter = ChartPainter( + props: node.properties, + data: data, + selectedIndex: selected, + ); + final Widget paint = CustomPaint( + painter: painter, + child: const SizedBox.expand(), + ); + Widget chart = paint; + if (node.properties.chartType != ChartType.timeline && + !settings.isPreview && + data.isNotEmpty) { + chart = LayoutBuilder( + builder: (context, constraints) => MouseRegion( + cursor: node.reactions.isEmpty + ? MouseCursor.defer + : SystemMouseCursors.click, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTapUp: (details) { + final int? index = painter.categoryAt( + details.localPosition, + constraints.biggest, + ); + if (index != null) onSelect(context, index); + }, + child: paint, + ), + ), + ); + } + return AdaptiveNodeBox(node: node, child: chart); + } +} + +/// Reads a number from [item][field]; null when missing or not a number. +double? chartNumber(Map item, String field) { + final Object? v = item[field]; + if (v is num) return v.toDouble(); + if (v is String) return double.tryParse(v); + return null; +} + +/// Parses a hex color like `#FF4A3D`; null when it isn't one. +Color? chartColor(Object? value) { + if (value is! String || value.isEmpty) return null; + try { + return ColorRGBA.fromJson(value).toFlutterColor(); + } catch (_) { + return null; + } +} + +/// A rounded axis maximum and step for [max] split into [steps]. +(double, double) niceAxis(double max, int steps) { + if (max <= 0 || steps <= 0) return (math.max(1, steps).toDouble(), 1); + final double raw = max / steps; + final double mag = math + .pow(10, (math.log(raw) / math.ln10).floor()) + .toDouble(); + final double n = raw / mag; + final double step = + (n <= 1 + ? 1 + : n <= 2 + ? 2 + : n <= 2.5 + ? 2.5 + : n <= 5 + ? 5 + : 10) * + mag; + return (step * steps, step); +} + +/// "1.0k", "750", "12k". +String compactNumber(num v) => v.abs() >= 10000 + ? '${(v / 1000).toStringAsFixed(0)}k' + : v.abs() >= 1000 + ? '${(v / 1000).toStringAsFixed(1)}k' + : (v == v.roundToDouble() ? v.round().toString() : v.toStringAsFixed(1)); + +/// Paints a [ChartProperties] chart for [data]. +class ChartPainter extends CustomPainter { + ChartPainter({ + required this.props, + required this.data, + this.selectedIndex = -1, + }) : super(repaint: PaintingBinding.instance.systemFonts); + + final ChartProperties props; + final List> data; + final int selectedIndex; + + static const double _xAxisHeight = 22; + + TextStyle get _axisStyle => chartLabelStyle( + family: props.axisFontFamily, + size: props.axisFontSize, + color: props.axisLabelColor.toFlutterColor(), + ); + + /// The plot area inside [size], leaving room for the axes. + Rect plotRect(Size size) { + final double left = props.showYAxis ? props.yAxisWidth + 8 : 0; + final double bottom = props.showXAxis ? _xAxisHeight : 0; + return Rect.fromLTRB( + left, + props.chartType == ChartType.line ? 6 : 0, + size.width - (props.chartType == ChartType.line ? 6 : 0), + size.height - bottom, + ); + } + + /// The category under [position], or null. + int? categoryAt(Offset position, Size size) { + if (data.isEmpty) return null; + final Rect plot = plotRect(size); + if (position.dx < plot.left || position.dx > plot.right) return null; + if (props.chartType == ChartType.line) { + final int slots = _slots; + final int i = ((position.dx - plot.left) / plot.width * slots).round(); + return i.clamp(0, data.length - 1); + } + final double catW = plot.width / data.length; + return ((position.dx - plot.left) / catW).floor().clamp(0, data.length - 1); + } + + int get _slots => + props.xSlots > 0 ? props.xSlots : math.max(1, data.length - 1); + + Iterable get _bars => + props.series.where((s) => s.kind == ChartSeriesKind.bar); + + /// Bar groups in series order: series sharing a stack id form one group. + List> get _groups { + final List> groups = []; + final Map> byStack = {}; + for (final ChartSeries s in _bars) { + if (s.stack == null) { + groups.add([s]); + } else { + final List? existing = byStack[s.stack!]; + if (existing != null) { + existing.add(s); + } else { + groups.add(byStack[s.stack!] = [s]); + } + } + } + return groups; + } + + /// The y domain: min and max. + (double, double, double) _yDomain() { + double maxV = 0, minV = 0; + final groups = _groups; + for (final item in data) { + for (final group in groups) { + double sum = 0; + for (final s in group) { + final double v = chartNumber(item, s.field) ?? 0; + sum += v; + } + maxV = math.max(maxV, sum); + minV = math.min(minV, sum); + } + for (final s in props.series.where( + (s) => s.kind != ChartSeriesKind.bar, + )) { + final double? v = chartNumber(item, s.field); + if (v != null) { + maxV = math.max(maxV, v); + minV = math.min(minV, v); + } + } + } + for (final line in props.referenceLines) { + maxV = math.max(maxV, line.value); + } + final double lo = props.minY ?? minV; + if (props.maxY != null) { + final double step = props.gridLines > 0 + ? (props.maxY! - lo) / props.gridLines + : props.maxY! - lo; + return (lo, props.maxY!, step); + } + final (double top, double step) = niceAxis( + (maxV - lo) * 1.05, + math.max(1, props.gridLines), + ); + return (lo, lo + top, step); + } + + void _dashedLine( + Canvas canvas, + Offset a, + Offset b, + Paint paint, { + double dash = 4, + double gap = 4, + }) { + final double len = (b - a).distance; + if (len == 0) return; + final Offset dir = (b - a) / len; + for (double d = 0; d < len; d += dash + gap) { + canvas.drawLine(a + dir * d, a + dir * math.min(len, d + dash), paint); + } + } + + void _label( + Canvas canvas, + String text, + Offset anchor, { + TextAlign align = TextAlign.center, + TextStyle? style, + }) { + final TextPainter tp = TextPainter( + text: TextSpan(text: text, style: style ?? _axisStyle), + textDirection: TextDirection.ltr, + )..layout(); + final double dx = switch (align) { + TextAlign.right || TextAlign.end => -tp.width, + TextAlign.left || TextAlign.start => 0, + _ => -tp.width / 2, + }; + tp.paint(canvas, anchor + Offset(dx, -tp.height / 2)); + } + + @override + void paint(Canvas canvas, Size size) { + if (size.isEmpty) return; + switch (props.chartType) { + case ChartType.timeline: + _paintTimeline(canvas, size); + case ChartType.bar: + case ChartType.line: + _paintXY(canvas, size); + } + } + + void _paintXY(Canvas canvas, Size size) { + final Rect plot = plotRect(size); + final (double lo, double hi, double step) = _yDomain(); + double y(double v) => + plot.bottom - plot.height * ((v - lo) / (hi - lo == 0 ? 1 : hi - lo)); + final bool isLine = props.chartType == ChartType.line; + final int n = data.length; + final double catW = n == 0 ? plot.width : plot.width / n; + double cx(int i) => isLine + ? plot.left + plot.width * i / _slots + : plot.left + catW * (i + 0.5); + + // Grid and y labels. + if (props.gridLines > 0) { + final Paint grid = Paint() + ..color = props.gridColor.toFlutterColor() + ..strokeWidth = 1; + for (int k = 0; k <= props.gridLines; k++) { + final double v = lo + (hi - lo) * k / props.gridLines; + final double gy = y(v).roundToDouble() - (k == 0 ? 0.5 : 0); + canvas.drawLine(Offset(plot.left, gy), Offset(plot.right, gy), grid); + if (props.showYAxis) { + _label( + canvas, + compactNumber(v), + Offset(plot.left - 8, gy), + align: TextAlign.right, + ); + } + } + } + if (n == 0) return; + + // Selection. + final Color selection = props.selectionColor.toFlutterColor(); + if (!isLine && selectedIndex >= 0 && selectedIndex < n) { + canvas.drawRect( + Rect.fromLTWH( + plot.left + catW * selectedIndex + 2, + plot.top, + catW - 4, + plot.height, + ), + Paint()..color = selection, + ); + } + + // Bars. + final List> groups = _groups; + final double total = math.min( + props.maxBarWidth * math.max(1, groups.length), + catW * props.barWidthFraction, + ); + final double groupW = groups.isEmpty + ? 0 + : (total - props.barGap * (groups.length - 1)) / groups.length; + final ChartSeries? firstTick = props.series + .where((s) => s.kind == ChartSeriesKind.tick) + .firstOrNull; + if (!isLine) { + for (int i = 0; i < n; i++) { + final Map item = data[i]; + final bool empty = + groups.isNotEmpty && + groups.every( + (g) => g.every((s) => chartNumber(item, s.field) == null), + ); + final double left0 = cx(i) - total / 2; + if (empty) { + if (props.dashEmpty && firstTick != null) { + final double? t = chartNumber(item, firstTick.field); + if (t != null) { + final Rect r = Rect.fromLTRB( + left0, + y(t), + left0 + total, + plot.bottom, + ); + // A shade lighter than the grid, so it reads over grid lines. + final Paint dash = Paint() + ..color = Color.lerp( + props.gridColor.toFlutterColor(), + Colors.white, + 0.15, + )! + ..style = PaintingStyle.stroke + ..strokeWidth = 1; + _dashedLine(canvas, r.topLeft, r.topRight, dash, dash: 3, gap: 3); + _dashedLine( + canvas, + r.topLeft, + r.bottomLeft, + dash, + dash: 3, + gap: 3, + ); + _dashedLine( + canvas, + r.topRight, + r.bottomRight, + dash, + dash: 3, + gap: 3, + ); + } + } + continue; + } + for (int g = 0; g < groups.length; g++) { + double base = 0; + final double left = left0 + g * (groupW + props.barGap); + for (final ChartSeries s in groups[g]) { + final double? v = chartNumber(item, s.field); + if (v == null || v == 0) continue; + final Color color = + chartColor(s.colorField == null ? null : item[s.colorField]) ?? + s.color.toFlutterColor(); + final Rect r = Rect.fromLTRB( + left, + y(base + v), + left + groupW, + y(base), + ); + final bool top = s == groups[g].last; + canvas.drawRRect( + RRect.fromRectAndCorners( + r, + topLeft: Radius.circular(top ? props.barRadius : 0), + topRight: Radius.circular(top ? props.barRadius : 0), + ), + Paint()..color = color, + ); + base += v; + } + } + } + } + + // Reference lines. + for (final ChartReferenceLine line in props.referenceLines) { + final double ly = y(line.value); + final Paint p = Paint() + ..color = line.color.toFlutterColor() + ..strokeWidth = line.strokeWidth; + if (line.dashed) { + _dashedLine(canvas, Offset(plot.left, ly), Offset(plot.right, ly), p); + } else { + canvas.drawLine(Offset(plot.left, ly), Offset(plot.right, ly), p); + } + } + + // Areas (fills first, so lines sit on top). + for (final ChartSeries s in props.series.where( + (s) => s.kind == ChartSeriesKind.area, + )) { + final List pts = [ + for (int i = 0; i < n; i++) + if (chartNumber(data[i], s.field) case final double v) + Offset(cx(i), y(v)), + ]; + if (pts.length < 2) continue; + final Path area = Path()..moveTo(pts.first.dx, pts.first.dy); + for (final Offset p in pts.skip(1)) { + area.lineTo(p.dx, p.dy); + } + final Color color = s.color.toFlutterColor(); + if (s.fillTo != null) { + final List other = [ + for (int i = 0; i < n; i++) + if (chartNumber(data[i], s.field) != null) + Offset(cx(i), y(chartNumber(data[i], s.fillTo!) ?? 0)), + ]; + for (final Offset p in other.reversed) { + area.lineTo(p.dx, p.dy); + } + area.close(); + canvas.drawPath( + area, + Paint()..color = color.withValues(alpha: s.fillOpacity), + ); + } else { + area + ..lineTo(pts.last.dx, plot.bottom) + ..lineTo(pts.first.dx, plot.bottom) + ..close(); + canvas.drawPath( + area, + Paint() + ..shader = LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + color.withValues(alpha: s.fillOpacity), + color.withValues(alpha: 0), + ], + ).createShader(plot), + ); + } + } + + // Lines and area outlines. + for (final ChartSeries s in props.series.where( + (s) => s.kind == ChartSeriesKind.line || s.kind == ChartSeriesKind.area, + )) { + if (s.strokeWidth <= 0) continue; + final List pts = [ + for (int i = 0; i < n; i++) + if (chartNumber(data[i], s.field) case final double v) + Offset(cx(i), y(v)), + ]; + if (pts.isEmpty) continue; + final Paint p = Paint() + ..color = s.color.toFlutterColor() + ..strokeWidth = s.strokeWidth + ..style = PaintingStyle.stroke + ..strokeJoin = StrokeJoin.round + ..strokeCap = StrokeCap.round; + if (s.dashed) { + for (int i = 0; i < pts.length - 1; i++) { + _dashedLine(canvas, pts[i], pts[i + 1], p, dash: 4, gap: 4); + } + } else if (pts.length > 1) { + final Path path = Path()..moveTo(pts.first.dx, pts.first.dy); + for (final Offset pt in pts.skip(1)) { + path.lineTo(pt.dx, pt.dy); + } + canvas.drawPath(path, p); + } + if (s.endDot) { + canvas.drawCircle( + pts.last, + s.strokeWidth + 2.5, + Paint()..color = Colors.black.withValues(alpha: 0.85), + ); + canvas.drawCircle( + pts.last, + s.strokeWidth + 1, + Paint()..color = s.color.toFlutterColor(), + ); + } + } + + // Ticks (plan marks). + for (final ChartSeries s in props.series.where( + (s) => s.kind == ChartSeriesKind.tick, + )) { + final Paint p = Paint() + ..color = s.color.toFlutterColor() + ..strokeWidth = s.strokeWidth; + for (int i = 0; i < n; i++) { + final double? v = chartNumber(data[i], s.field); + if (v == null) continue; + final double ty = y(v); + final double half = (isLine ? 6 : total / 2) + 5; + final Offset a = Offset(cx(i) - half, ty), b = Offset(cx(i) + half, ty); + if (s.dashed) { + _dashedLine(canvas, a, b, p, dash: 3, gap: 2); + } else { + canvas.drawLine(a, b, p); + } + } + } + + // X labels. + if (props.showXAxis) { + final TextStyle style = _axisStyle; + final TextStyle selectedStyle = style.copyWith( + color: selection.withValues(alpha: 1), + ); + final int every = math.max(1, props.xLabelEvery); + for (int i = 0; i < n; i += every) { + final Object? label = data[i][props.labelField]; + if (label == null) continue; + _label( + canvas, + '$label', + Offset(cx(i), plot.bottom + _xAxisHeight / 2 + 2), + style: i == selectedIndex && !isLine ? selectedStyle : style, + ); + } + } + } + + void _paintTimeline(Canvas canvas, Size size) { + final double bandH = props.showXAxis + ? size.height - _xAxisHeight + : size.height; + final Rect band = Rect.fromLTWH(0, 0, size.width, bandH); + final double span = props.domainMax - props.domainMin; + double x(double v) => span == 0 + ? 0 + : band.width * ((v - props.domainMin) / span).clamp(0.0, 1.0); + final Color track = props.trackColor.toFlutterColor(); + canvas.drawRect(band, Paint()..color = track); + for (final Map item in data) { + final double? from = chartNumber(item, props.startField); + final double? to = chartNumber(item, props.endField); + if (from == null || to == null) continue; + final Color color = + chartColor(item[props.segmentColorField]) ?? + props.series.firstOrNull?.color.toFlutterColor() ?? + Colors.green; + canvas.drawRect( + Rect.fromLTRB(x(from), 0, math.max(x(from) + 1.5, x(to)), bandH), + Paint()..color = color, + ); + } + // Hatch after now. + if (props.nowValue != null) { + final double nx = x(props.nowValue!); + canvas.save(); + canvas.clipRect(Rect.fromLTRB(nx, 0, band.right, bandH)); + final Paint hatch = Paint() + ..color = props.gridColor.toFlutterColor() + ..strokeWidth = 1; + for (double i = nx - bandH; i < band.right; i += 6) { + canvas.drawLine(Offset(i, bandH), Offset(i + bandH, 0), hatch); + } + canvas.restore(); + } + // Division ticks. + if (props.divisions > 1) { + final Paint tick = Paint() + ..color = Colors.black.withValues(alpha: 0.4) + ..strokeWidth = 1; + for (int k = 1; k < props.divisions; k++) { + final double tx = band.width * k / props.divisions; + canvas.drawLine(Offset(tx, 0), Offset(tx, bandH), tick); + } + } + if (props.nowValue != null) { + final double nx = x(props.nowValue!); + canvas.drawRect( + Rect.fromLTWH(nx - 1, 0, 2, bandH), + Paint() + ..color = props.axisLabelColor.toFlutterColor().withValues(alpha: 1), + ); + } + canvas.drawRect( + band.deflate(0.5), + Paint() + ..style = PaintingStyle.stroke + ..color = props.gridColor.toFlutterColor(), + ); + // Division labels. + if (props.showXAxis && props.divisions > 0) { + final int every = math.max(1, props.xLabelEvery); + for (int k = 0; k <= props.divisions; k += every) { + final double v = props.domainMin + span * k / props.divisions; + final double lx = band.width * k / props.divisions; + _label( + canvas, + compactNumber(v), + Offset(lx, bandH + _xAxisHeight / 2 + 2), + align: k == 0 + ? TextAlign.left + : (k == props.divisions ? TextAlign.right : TextAlign.center), + ); + } + } + } + + @override + bool shouldRepaint(ChartPainter oldDelegate) => + oldDelegate.props != props || + oldDelegate.selectedIndex != selectedIndex || + !_sameData(oldDelegate.data, data); + + static bool _sameData( + List> a, + List> b, + ) { + if (a.length != b.length) return false; + for (int i = 0; i < a.length; i++) { + if (a[i].length != b[i].length) return false; + for (final MapEntry e in a[i].entries) { + if (b[i][e.key] != e.value) return false; + } + } + return true; + } +} diff --git a/lib/src/transformers/node_transformers/passive_gauge_transformer.dart b/lib/src/transformers/node_transformers/passive_gauge_transformer.dart new file mode 100644 index 00000000..ad8bf2e4 --- /dev/null +++ b/lib/src/transformers/node_transformers/passive_gauge_transformer.dart @@ -0,0 +1,338 @@ +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'; + +class PassiveGaugeTransformer extends NodeWidgetTransformer { + PassiveGaugeTransformer(super.getNode, super.manager); + + @override + Widget buildWidget( + GaugeNode node, + BuildContext context, + WidgetBuildSettings settings, + ) => PassiveGaugeWidget(node: node, settings: settings); +} + +/// Renders a [GaugeNode]: resolves the bound value and paints the dial, +/// animating value changes when the node asks for it. +class PassiveGaugeWidget extends StatelessWidget { + final GaugeNode node; + final WidgetBuildSettings settings; + final List variablesOverrides; + + /// Overrides [GaugeProperties.animate], e.g. off on the editor canvas. + final bool? animate; + + const PassiveGaugeWidget({ + super.key, + required this.node, + required this.settings, + this.variablesOverrides = const [], + this.animate, + }); + + void onTap(BuildContext context) => + FunctionsRepository.triggerAction(context, node: node, TriggerType.click); + + @override + Widget build(BuildContext context) { + final ScopedValues scopedValues = ScopedValues.of( + context, + variablesOverrides: variablesOverrides, + ); + final double value = + PropertyValueDelegate.getPropertyValue( + node, + 'value', + scopedValues: scopedValues, + ) ?? + node.value; + final GaugeProperties props = node.properties; + final bool shouldAnimate = animate ?? props.animate; + + Widget dial = TweenAnimationBuilder( + tween: Tween(end: value), + duration: shouldAnimate + ? Duration(milliseconds: props.animationDurationInMillis) + : Duration.zero, + curve: Curves.easeOutCubic, + builder: (context, animated, _) => CustomPaint( + painter: GaugePainter(props: props, value: animated), + child: const SizedBox.expand(), + ), + ); + if (node.reactions.isNotEmpty && !settings.isPreview) { + dial = MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector(onTap: () => onTap(context), child: dial), + ); + } + return AdaptiveNodeBox(node: node, child: dial); + } +} + +/// A text style for chart and gauge labels in [family] (a Google Font when +/// it is one). +TextStyle chartLabelStyle({ + required String? family, + required double size, + required Color color, + FontWeight weight = FontWeight.w500, +}) { + if (family != null && GoogleFonts.asMap().containsKey(family)) { + return GoogleFonts.getFont( + family, + fontSize: size, + color: color, + fontWeight: weight, + ); + } + return TextStyle( + fontFamily: family, + fontSize: size, + color: color, + fontWeight: weight, + ); +} + +/// Paints a [GaugeProperties] dial at [value]. The dial is centered +/// horizontally and sized so the whole arc fits the box. +class GaugePainter extends CustomPainter { + GaugePainter({required this.props, required this.value}) + // Repaint when a Google Font used by the labels finishes loading. + : super(repaint: PaintingBinding.instance.systemFonts); + + final GaugeProperties props; + final double value; + + static double _rad(double deg) => deg * math.pi / 180; + + /// The arc's radius and center for [size]: the largest circle whose arc + /// (with zone bands and notch) fits, centered horizontally and placed at + /// the top. + ({double radius, Offset center}) layout(Size size) { + final double start = _rad(props.startAngle); + final double sweep = _rad(props.sweepAngle); + // Bounds of the unit arc, sampled. + double minX = 0, maxX = 0, minY = 0, maxY = 0; + for (int i = 0; i <= 64; i++) { + final double a = start + sweep * i / 64; + minX = math.min(minX, math.cos(a)); + maxX = math.max(maxX, math.cos(a)); + minY = math.min(minY, math.sin(a)); + maxY = math.max(maxY, math.sin(a)); + } + // The hub is always inside. + final double unitW = maxX - minX, unitH = maxY - minY; + final double radius = math.max( + 1, + math.min(size.width / unitW, size.height / unitH), + ); + // Centered horizontally, at the top, so readouts placed over the dial + // line up whatever the box's height. + final double usedW = unitW * radius; + final Offset center = Offset( + (size.width - usedW) / 2 - minX * radius, + -minY * radius, + ); + return (radius: radius, center: center); + } + + @override + void paint(Canvas canvas, Size size) { + if (size.isEmpty) return; + final (:radius, :center) = layout(size); + final double start = _rad(props.startAngle); + final double sweep = _rad(props.sweepAngle); + double angleOf(double v) => start + sweep * props.fraction(v); + + final double zoneW = props.zoneThickness; + final double outer = radius - 1; + final double arcR = outer - zoneW - 2 - props.thickness / 2; + + // Zone bands. + if (zoneW > 0) { + for (final GaugeZone zone in props.zones) { + final double a0 = angleOf(zone.from), a1 = angleOf(zone.to); + if (a1 <= a0) continue; + canvas.drawArc( + Rect.fromCircle(center: center, radius: outer - zoneW / 2), + a0 + 0.01, + a1 - a0 - 0.02, + false, + Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = zoneW + ..color = zone.color.toFlutterColor().withValues( + alpha: props.zoneOpacity, + ), + ); + } + } + + // Track with hairlines on both sides. + canvas.drawArc( + Rect.fromCircle(center: center, radius: arcR), + start, + sweep, + false, + Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = props.thickness * 1.25 + ..color = props.trackColor.toFlutterColor(), + ); + final Paint hairline = Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 1 + ..color = props.tickColor.toFlutterColor().withValues(alpha: 0.18); + for (final double r in [ + arcR + props.thickness * 0.625, + arcR - props.thickness * 0.625, + ]) { + canvas.drawArc( + Rect.fromCircle(center: center, radius: r), + start, + sweep, + false, + hairline, + ); + } + + // Value arc. + final double fraction = props.fraction(value); + final Paint valuePaint = Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = props.thickness + ..color = props.colorFor(value).toFlutterColor(); + if (props.segments <= 0) { + if (fraction > 0) { + canvas.drawArc( + Rect.fromCircle(center: center, radius: arcR), + start, + sweep * fraction, + false, + valuePaint, + ); + } + } else { + final double block = sweep / props.segments; + final double gap = math.min(_rad(props.segmentGap), block * 0.5); + final double filled = fraction * props.segments; + for (int i = 0; i < props.segments; i++) { + final double part = (filled - i).clamp(0.0, 1.0); + if (part <= 0) break; + canvas.drawArc( + Rect.fromCircle(center: center, radius: arcR), + start + block * i + gap / 2, + (block - gap) * part, + false, + valuePaint, + ); + } + } + + // Ticks. + if (props.majorTicks > 0) { + final int perMajor = math.max(0, props.minorTicks) + 1; + final int count = props.majorTicks * perMajor; + final Color tickColor = props.tickColor.toFlutterColor(); + for (int i = 0; i <= count; i++) { + final bool major = i % perMajor == 0; + final double a = start + sweep * i / count; + final Offset dir = Offset(math.cos(a), math.sin(a)); + final double inner = arcR - props.thickness * (major ? 1.35 : 1.0); + final double outerTick = arcR - props.thickness * 0.72; + canvas.drawLine( + center + dir * inner, + center + dir * outerTick, + Paint() + ..color = major ? tickColor : tickColor.withValues(alpha: 0.45) + ..strokeWidth = major ? 1.5 : 1, + ); + } + } + + // Labels. + if (props.labels.isNotEmpty) { + final TextStyle style = chartLabelStyle( + family: props.labelFontFamily, + size: props.labelFontSize, + color: props.labelColor.toFlutterColor(), + ); + for (final double v in props.labels) { + final double a = angleOf(v); + final Offset p = + center + + Offset(math.cos(a), math.sin(a)) * + (arcR - props.thickness * 1.35 - props.labelFontSize); + final TextPainter tp = TextPainter( + text: TextSpan(text: _label(v), style: style), + textDirection: TextDirection.ltr, + )..layout(); + tp.paint(canvas, p - Offset(tp.width / 2, tp.height / 2)); + } + } + + // Target notch. + if (props.showTarget) { + final double a = angleOf(props.target); + final Offset dir = Offset(math.cos(a), math.sin(a)); + final Offset perp = Offset(-dir.dy, dir.dx) * (zoneW + 3) * 0.75; + final Offset tip = center + dir * (outer + 1); + final Offset base = center + dir * (arcR + props.thickness * 0.4); + canvas.drawPath( + Path() + ..moveTo(base.dx, base.dy) + ..lineTo(tip.dx + perp.dx, tip.dy + perp.dy) + ..lineTo(tip.dx - perp.dx, tip.dy - perp.dy) + ..close(), + Paint()..color = props.targetColor.toFlutterColor(), + ); + } + + // Needle. + if (props.showNeedle) { + final double a = start + sweep * fraction; + final Offset dir = Offset(math.cos(a), math.sin(a)); + final double hub = radius * 0.07; + final Offset side = Offset(-dir.dy, dir.dx) * hub * 0.45; + final Offset tail = center - dir * hub * 1.4; + final Offset tip = center + dir * (arcR - props.thickness * 0.2); + final Path needle = Path() + ..moveTo(tail.dx + side.dx, tail.dy + side.dy) + ..lineTo(tip.dx, tip.dy) + ..lineTo(tail.dx - side.dx, tail.dy - side.dy) + ..close(); + canvas.save(); + if (props.needleClearance > 0) { + canvas.clipPath( + Path() + ..fillType = PathFillType.evenOdd + ..addRect(Offset.zero & size) + ..addOval( + Rect.fromCircle( + center: center, + radius: radius * props.needleClearance, + ), + ), + ); + } + canvas.drawPath( + needle, + Paint()..color = props.needleColor.toFlutterColor(), + ); + canvas.restore(); + } + } + + static String _label(double v) => + v == v.roundToDouble() ? v.toInt().toString() : v.toStringAsFixed(1); + + @override + bool shouldRepaint(GaugePainter oldDelegate) => + oldDelegate.value != value || oldDelegate.props != props; +} diff --git a/lib/src/transformers/node_transformers/passive_rectangle_transformer.dart b/lib/src/transformers/node_transformers/passive_rectangle_transformer.dart index d60a661b..dd5735da 100644 --- a/lib/src/transformers/node_transformers/passive_rectangle_transformer.dart +++ b/lib/src/transformers/node_transformers/passive_rectangle_transformer.dart @@ -423,8 +423,15 @@ void getLinearGradientAlignment(double rotationDeg) {} Gradient? retrieveGradient(PaintModel paint) { try { - final double rotation = decomposeRelativeTransform(paint.gradientTransform) - .rotation; + final RelativeTransform relative = decomposeRelativeTransform( + paint.gradientTransform, + ); + final double rotation = relative.rotation; + final TileMode tileMode = switch (paint.gradientTileMode) { + GradientTileMode.clamp => TileMode.clamp, + GradientTileMode.repeated => TileMode.repeated, + GradientTileMode.mirror => TileMode.mirror, + }; // We sort the colors to mimic the way Figma changes the order. final gradientStops = paint.gradientStops!.sorted( @@ -433,11 +440,19 @@ Gradient? retrieveGradient(PaintModel paint) { final radians = rotation * pi / 180; if (paint.type == PaintType.gradientLinear) { + // A repeating gradient spans 1 / scale of the box, so the transform's + // scale sets how many times it repeats across it (stripes). + final double span = paint.gradientTileMode == GradientTileMode.clamp + ? 1 + : 1 / max(relative.scale[0].abs(), 0.0001); return LinearGradient( + begin: Alignment.centerLeft, + end: Alignment(-1 + 2 * span, 0), colors: gradientStops .map((e) => e.color.multiplyAlpha(paint.opacity).toFlutterColor()) .toList(), stops: gradientStops.map((e) => e.position).toList(), + tileMode: tileMode, transform: GradientRotation(radians), ); } else if (paint.type == PaintType.gradientRadial) { @@ -446,6 +461,7 @@ Gradient? retrieveGradient(PaintModel paint) { .map((e) => e.color.multiplyAlpha(paint.opacity).toFlutterColor()) .toList(), stops: gradientStops.map((e) => e.position).toList(), + tileMode: tileMode, transform: GradientRotation(radians), ); } else if (paint.type == PaintType.gradientAngular) { @@ -454,6 +470,7 @@ Gradient? retrieveGradient(PaintModel paint) { .map((e) => e.color.multiplyAlpha(paint.opacity).toFlutterColor()) .toList(), stops: gradientStops.map((e) => e.position).toList(), + tileMode: tileMode, transform: GradientRotation(radians), ); } diff --git a/lib/src/transformers/passive_transformer_manager.dart b/lib/src/transformers/passive_transformer_manager.dart index 9d6e473b..57d2b6a1 100644 --- a/lib/src/transformers/passive_transformer_manager.dart +++ b/lib/src/transformers/passive_transformer_manager.dart @@ -63,6 +63,8 @@ class PassiveNodeTransformerManager extends WidgetNodeTransformerManager { 'chip': PassiveChipTransformer(getNode, this), 'segmentedButton': PassiveSegmentedButtonTransformer(getNode, this), 'external': PassiveExternalComponentTransformer(getNode, this), + 'gauge': PassiveGaugeTransformer(getNode, this), + 'chart': PassiveChartTransformer(getNode, this), }); } diff --git a/test/gauge_chart_test.dart b/test/gauge_chart_test.dart new file mode 100644 index 00000000..663b48f5 --- /dev/null +++ b/test/gauge_chart_test.dart @@ -0,0 +1,289 @@ +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:codelessly_sdk/src/transformers/utils/node_state_provider.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; + +Observable variable( + String name, + Object? value, + VariableType type, +) => Observable(VariableData(id: name, name: name, value: value, type: type)); + +Future pump( + WidgetTester tester, + CodelesslyContext context, + Widget child, +) async { + tester.view.physicalSize = const Size(800, 600); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: context, + child: MaterialApp( + home: ActionChainHost( + child: Scaffold(body: Center(child: child)), + ), + ), + ), + ); +} + +ChartNode hourly({ + Map variables = const {}, + List reactions = const [], +}) => ChartNode( + id: 'chart', + name: 'Hourly', + basicBoxLocal: NodeBox(0, 0, 400, 220), + properties: ChartProperties(), + reactions: reactions, +)..variables.addAll(variables); + +void main() { + group('niceAxis', () { + test('rounds up to 1, 2, 2.5 or 5 times a power of ten', () { + expect(niceAxis(830, 4), (1000.0, 250.0)); + expect(niceAxis(3100, 4), (4000.0, 1000.0)); + expect(niceAxis(95, 5), (100.0, 20.0)); + expect(niceAxis(0, 4), (4.0, 1.0)); + }); + + test('compact labels', () { + expect(compactNumber(750), '750'); + expect(compactNumber(1000), '1.0k'); + expect(compactNumber(12000), '12k'); + }); + }); + + group('GaugePainter', () { + test('a 240° dial anchored at the top fills the width', () { + final GaugePainter painter = GaugePainter( + props: GaugeProperties(), + value: 70, + ); + final layout = painter.layout(const Size(220, 185)); + // The arc spans cos(150°)..cos(30°) = 2 radii wide, so r = 110. + expect(layout.radius, closeTo(110, 0.5)); + expect(layout.center.dx, closeTo(110, 0.5)); + expect(layout.center.dy, closeTo(110, 0.5)); + }); + + test('a short box limits the radius by its height', () { + final layout = GaugePainter( + props: GaugeProperties(), + value: 0, + ).layout(const Size(400, 120)); + // Height spans sin(270°)..sin(30°) = 1.5 radii. + expect(layout.radius, closeTo(80, 0.5)); + }); + }); + + testWidgets('a gauge reads its bound value and paints', (tester) async { + final CodelesslyContext context = CodelesslyContext.empty() + ..variables['oee'] = variable('oee', 72.6, VariableType.decimal); + final GaugeNode node = GaugeNode( + id: 'gauge', + name: 'Gauge', + basicBoxLocal: NodeBox(0, 0, 220, 185), + properties: GaugeProperties(animate: false), + )..variables['value'] = r'${oee}'; + await pump( + tester, + context, + PassiveGaugeWidget( + node: node, + settings: const WidgetBuildSettings(debugLabel: 'test'), + ), + ); + final CustomPaint paint = tester.widget( + find + .descendant( + of: find.byType(PassiveGaugeWidget), + matching: find.byType(CustomPaint), + ) + .first, + ); + expect((paint.painter! as GaugePainter).value, 72.6); + expect(tester.takeException(), isNull); + }); + + group('ChartPainter', () { + final ChartPainter painter = ChartPainter( + props: ChartProperties(), + data: kDefaultChartSampleData, + ); + + test('categories split the plot evenly', () { + const Size size = Size(400, 220); + final Rect plot = painter.plotRect(size); + expect(plot.left, 44); + expect(plot.bottom, 198); + final double catW = plot.width / 6; + expect(painter.categoryAt(Offset(plot.left + 1, 100), size), 0); + expect(painter.categoryAt(Offset(plot.left + catW * 2.5, 100), size), 2); + expect(painter.categoryAt(Offset(plot.right - 1, 100), size), 5); + expect(painter.categoryAt(const Offset(10, 100), size), isNull); + }); + + test('lines put points on slot boundaries', () { + final ChartPainter line = ChartPainter( + props: ChartProperties(chartType: ChartType.line, xSlots: 8), + data: List.generate(7, (i) => {'actual': i * 100}), + ); + const Size size = Size(400, 220); + final Rect plot = line.plotRect(size); + expect( + line.categoryAt(Offset(plot.left + plot.width / 8 * 3, 50), size), + 3, + ); + }); + }); + + testWidgets('a chart reads its bound data and selection', (tester) async { + final CodelesslyContext context = CodelesslyContext.empty() + ..variables['hours'] = variable('hours', [ + {'label': '14', 'good': 500, 'scrap': 4, 'plan': 600}, + {'label': '15', 'good': null, 'scrap': 0, 'plan': 600}, + ], VariableType.list) + ..variables['selected'] = variable('selected', 1, VariableType.integer); + await pump( + tester, + context, + PassiveChartWidget( + node: hourly( + variables: {'data': r'${hours}', 'selectedIndex': r'${selected}'}, + ), + settings: const WidgetBuildSettings(debugLabel: 'test'), + ), + ); + final ChartPainter painter = + tester + .widget( + find + .descendant( + of: find.byType(PassiveChartWidget), + matching: find.byType(CustomPaint), + ) + .first, + ) + .painter! + as ChartPainter; + expect(painter.data.length, 2); + expect(painter.data[0]['good'], 500); + expect(painter.selectedIndex, 1); + expect(tester.takeException(), isNull); + }); + + testWidgets('tapping a bar stores its index and runs changed reactions', ( + tester, + ) async { + final Observable selected = variable( + 'selected', + 0, + VariableType.integer, + ); + final CodelesslyContext context = CodelesslyContext.empty() + ..variables['selected'] = selected; + await pump( + tester, + context, + SizedBox( + width: 400, + height: 220, + // The transformer manager provides the node's state, which + // `${value}` reads. + child: Builder( + builder: (context) { + final ChartNode node = hourly( + reactions: [ + Reaction( + trigger: TriggerModel(type: TriggerType.changed), + action: SetVariableAction( + variable: VariableData( + id: 'selected', + name: 'selected', + type: VariableType.integer, + ), + newValue: r'${value}', + ), + ), + ], + ); + return NodeStateProvider( + node: node, + child: PassiveChartWidget( + node: node, + settings: const WidgetBuildSettings(debugLabel: 'test'), + ), + ); + }, + ), + ), + ); + final Rect box = tester.getRect(find.byType(PassiveChartWidget)); + // Plot starts 44 px in; six categories over the rest. Tap the fourth. + final double catW = (box.width - 44) / 6; + await tester.tapAt(Offset(box.left + 44 + catW * 3.5, box.center.dy)); + await tester.pumpAndSettle(); + expect(selected.value.getValue(), 3); + }); + + testWidgets('an unbound chart shows its sample data', (tester) async { + await pump( + tester, + CodelesslyContext.empty(), + PassiveChartWidget( + node: hourly(), + settings: const WidgetBuildSettings(debugLabel: 'test'), + ), + ); + final ChartPainter painter = + tester + .widget( + find + .descendant( + of: find.byType(PassiveChartWidget), + matching: find.byType(CustomPaint), + ) + .first, + ) + .painter! + as ChartPainter; + expect(painter.data.length, kDefaultChartSampleData.length); + }); + + test('a repeating linear gradient spans 1 / scale of the box', () { + final PaintModel stripes = PaintModel.linearGradient( + id: 'stripes', + gradientTileMode: GradientTileMode.repeated, + // Scale 10: the gradient repeats ten times across the box. + gradientTransform: const [10, 0, 0, 10, 0, 0], + gradientStops: const [ + ColorStop(position: 0, color: ColorRGBA.black), + ColorStop(position: 0.5, color: ColorRGBA.black), + ColorStop(position: 0.5, color: ColorRGBA.white), + ColorStop(position: 1, color: ColorRGBA.white), + ], + ); + final LinearGradient gradient = + retrieveGradient(stripes)! as LinearGradient; + expect(gradient.tileMode, TileMode.repeated); + expect(gradient.begin, Alignment.centerLeft); + expect((gradient.end as Alignment).x, closeTo(-0.8, 1e-9)); + + final LinearGradient clamped = + retrieveGradient( + PaintModel.linearGradient( + id: 'c', + gradientStops: stripes.gradientStops, + ), + )! + as LinearGradient; + expect(clamped.tileMode, TileMode.clamp); + expect(clamped.end, Alignment.centerRight); + }); +} From 394f7fbc7fa12a7ec713ee6fe7d42c325a83abca Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 22:45:43 +0000 Subject: [PATCH 02/18] Evaluate computed variables; add object and slicing formula functions A computed variable (its value is a formula) reads as the formula's result, converted to its type, wherever a variable is read: bindings, formulas, conditions and CodelesslyContext.getVariableValue. Results are cached until the formula or a variable it reads (directly or through other computed variables) changes, unless it reads item, index, screen and the like. Whatever is bound to it listens to those variables too, so it stays live. A cycle stops at the variable that started it. SetVariable leaves a computed variable alone. New formula functions: obj and set build and update maps, get reads a field by a computed key, take and skip slice lists, indexOf finds an item, split and pad handle clock times. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FeVQ6NCLHS9Vrfxhii4Mj8 --- lib/src/functions/functions_repository.dart | 8 + .../passive_transformer_manager.dart | 44 +++-- .../utils/property_value_delegate.dart | 141 +++++++++++++-- lib/src/ui/codelessly_context.dart | 8 +- lib/src/utils/formula.dart | 61 +++++++ test/computed_variable_test.dart | 160 ++++++++++++++++++ test/formula_lists_test.dart | 69 ++++++++ 7 files changed, 466 insertions(+), 25 deletions(-) create mode 100644 test/computed_variable_test.dart diff --git a/lib/src/functions/functions_repository.dart b/lib/src/functions/functions_repository.dart index 86de7a90..650c8f34 100644 --- a/lib/src/functions/functions_repository.dart +++ b/lib/src/functions/functions_repository.dart @@ -1138,6 +1138,14 @@ ${response.body.contains('{') ? const JsonEncoder.withIndent(' ').convert(json. action.variable.name, ); if (variableNotifier == null) return false; + // A computed variable's value is its formula; there's nothing to set. + if (variableNotifier.value.isComputed) { + DebugLogger.instance.printInfo( + 'Not setting ${action.variable.name}: it is computed by a formula.', + name: 'FunctionsRepository', + ); + return false; + } String newValue = substituteNewValue( action.newValue, diff --git a/lib/src/transformers/passive_transformer_manager.dart b/lib/src/transformers/passive_transformer_manager.dart index 57d2b6a1..ae5faf58 100644 --- a/lib/src/transformers/passive_transformer_manager.dart +++ b/lib/src/transformers/passive_transformer_manager.dart @@ -356,10 +356,7 @@ class PassiveNodeTransformerManager extends WidgetNodeTransformerManager { } // Get corresponding variable data from codelessly context. - final ValueNotifier? listenableVariable = codelesslyContext - .findVariableByName(match.name); - // Add variable to [listenables]. - if (listenableVariable != null) listenables.add(listenableVariable); + listenables.addAll(variableListenables(codelesslyContext, match.name)); } // Traverse through all the multi-variables that the node properties are attached @@ -398,10 +395,7 @@ class PassiveNodeTransformerManager extends WidgetNodeTransformerManager { } // Get corresponding variable data from codelessly context. - final ValueNotifier? listenableVariable = codelesslyContext - .findVariableByName(match.name); - // Add variable to [listenables]. - if (listenableVariable != null) listenables.add(listenableVariable); + listenables.addAll(variableListenables(codelesslyContext, match.name)); } // Traverse through all the conditions that affects the node. @@ -428,18 +422,40 @@ class PassiveNodeTransformerManager extends WidgetNodeTransformerManager { continue; } // Get corresponding variable data from codelessly context. - final ValueNotifier? listenableVariable = - codelesslyContext.variables.values.firstWhereOrNull( - (notifier) => notifier.value.name == name, - ); - // Add variable to [listenables]. - if (listenableVariable != null) listenables.add(listenableVariable); + listenables.addAll(variableListenables(codelesslyContext, name)); } } return listenables; } + /// The notifier of the variable [name] and, for a computed variable, the + /// notifiers of every variable its formula reads (and theirs, for computed + /// ones), so whatever is bound to it rebuilds when its inputs change. + static Iterable variableListenables( + CodelesslyContext codelesslyContext, + String name, [ + Set? seen, + ]) sync* { + seen ??= {}; + if (!seen.add(name)) return; + final Observable? notifier = codelesslyContext + .findVariableByName(name); + if (notifier == null) return; + yield notifier; + final VariableData variable = notifier.value; + if (!variable.isComputed) return; + final String? expression = FormulaEvaluator.exactExpression(variable.value); + if (expression == null) return; + for (final String path in FormulaEvaluator.referencedPaths(expression)) { + final VariableMatch? match = VariableMatch.parse( + path.wrapWithVariableSyntax(), + ); + if (match == null || match.isPredefinedVariable) continue; + yield* variableListenables(codelesslyContext, match.name, seen); + } + } + Listenable? getStorageListenerFor(VariableMatch match, BuildContext context) { final localStorage = context.read()?.localDatabase; if (localStorage == null) return null; diff --git a/lib/src/transformers/utils/property_value_delegate.dart b/lib/src/transformers/utils/property_value_delegate.dart index 3f4dfd8a..5cf45bf9 100644 --- a/lib/src/transformers/utils/property_value_delegate.dart +++ b/lib/src/transformers/utils/property_value_delegate.dart @@ -345,6 +345,118 @@ class PropertyValueDelegate { return value?.typedValue(); } + /// Variables being evaluated, to stop a computed variable that reads + /// itself (directly or through others). + static final Set _evaluating = {}; + + /// The value of [variable]: its typed value, or for a computed variable + /// ([VariableData.isComputed]) the result of its formula, read in + /// [scopedValues] and converted to the variable's type. A computed + /// variable that depends on itself reads as null. + static Object? variableValue( + VariableData variable, { + required ScopedValues scopedValues, + }) { + if (!variable.isComputed) return variable.getValue(); + final String? expression = FormulaEvaluator.exactExpression(variable.value); + if (expression == null || !_evaluating.add(variable.id)) return null; + try { + // Variables are immutable and replaced when they change, so a result + // is reused while the formula and the variables it reads are the same + // objects. Formulas reading item, index, screen, ... aren't cached. + final List? inputs = _computedInputs(expression, scopedValues); + final _ComputedResult? cached = _computedCache[variable.id]; + if (inputs != null && + cached != null && + identical(cached.variable, variable) && + _sameInputs(cached.inputs, inputs)) { + return cached.value; + } + final Object? result = _computeVariable( + variable, + expression, + scopedValues, + ); + if (inputs != null) { + _computedCache[variable.id] = _ComputedResult(variable, inputs, result); + } + return result; + } finally { + _evaluating.remove(variable.id); + } + } + + static final Map _computedCache = {}; + + /// The variables [expression] reads, or null when it reads predefined + /// values (item, index, screen, ...) that change from place to place. + static List? _computedInputs( + String expression, + ScopedValues scopedValues, + ) => _inputsOf(expression, scopedValues.variables, {}); + + static List? _inputsOf( + String expression, + Map variables, + Set seen, + ) { + final List inputs = []; + for (final String path in FormulaEvaluator.referencedPaths(expression)) { + final VariableMatch? match = VariableMatch.parse( + path.wrapWithVariableSyntax(), + ); + if (match == null) continue; + if (match.isPredefinedVariable) return null; + final VariableData? input = variables[match.name]; + inputs.add(input); + // A computed input changes when its own inputs do, so they count too; + // and if it reads predefined values, so does this formula. + if (input != null && input.isComputed && seen.add(input.name)) { + final String? inner = FormulaEvaluator.exactExpression(input.value); + if (inner == null) continue; + final List? nested = _inputsOf(inner, variables, seen); + if (nested == null) return null; + inputs.addAll(nested); + } + } + return inputs; + } + + static bool _sameInputs(List a, List b) { + if (a.length != b.length) return false; + for (int i = 0; i < a.length; i++) { + if (!identical(a[i], b[i])) return false; + } + return true; + } + + static Object? _computeVariable( + VariableData variable, + String expression, + ScopedValues scopedValues, + ) { + { + final Object? value = evaluateFormula( + expression, + scopedValues: scopedValues, + ); + return switch (variable.type) { + VariableType.text || VariableType.image => + value == null ? null : FormulaEvaluator.stringify(value), + VariableType.integer => + value is num ? value.toInt() : num.tryParse('$value')?.toInt(), + VariableType.decimal => + value is num ? value.toDouble() : num.tryParse('$value')?.toDouble(), + VariableType.boolean => + value is bool ? value : bool.tryParse('$value', caseSensitive: false), + VariableType.color => + value is ColorRGBA ? value : ColorRGBA.fromHex('$value'), + VariableType.list => value is List ? value : null, + VariableType.map => value is Map ? value : null, + }; + } + } + static Object? _retrieveVariableValue( String path, { required ScopedValues scopedValues, @@ -365,7 +477,10 @@ class PropertyValueDelegate { if (variable == null) return null; if (match.hasAccessor) { if (variable.type.isText) { - final characters = variable.getValue().toString().characters.toList(); + final characters = variableValue( + variable, + scopedValues: scopedValues, + ).toString().characters.toList(); final (Object? value, _) = substituteJsonPath(match.fullPath, { match.name: characters, }); @@ -373,23 +488,20 @@ class PropertyValueDelegate { } if (!variable.type.isList) return null; final (Object? value, _) = substituteJsonPath(match.fullPath, { - match.name: variable.getValue(), + match.name: variableValue(variable, scopedValues: scopedValues), }); return value; } - if (!match.hasPath) return variable.getValue(); + if (!match.hasPath) + return variableValue(variable, scopedValues: scopedValues); - final variableProps = getVariableProperties( - variable.getValue(), - variable.type, - ); - final Object? variableValue = variable.getValue(); + final Object? current = variableValue(variable, scopedValues: scopedValues); + final variableProps = getVariableProperties(current, variable.type); final Map values = { ...variableProps, - if (variable.type.isMap && variableValue is Map) - ...variableValue, + if (variable.type.isMap && current is Map) ...current, }; final (Object? value, _) = substituteJsonPath(match.fullPath, { match.name: values, @@ -1093,3 +1205,12 @@ String formatTimeAgo(DateTime date, {DateTime? now}) { } return formatDate(date); } + +/// A computed variable's last result and what it was computed from. +class _ComputedResult { + final VariableData variable; + final List inputs; + final Object? value; + + const _ComputedResult(this.variable, this.inputs, this.value); +} diff --git a/lib/src/ui/codelessly_context.dart b/lib/src/ui/codelessly_context.dart index 48c8c976..befa9be4 100644 --- a/lib/src/ui/codelessly_context.dart +++ b/lib/src/ui/codelessly_context.dart @@ -238,7 +238,13 @@ class CodelesslyContext with ChangeNotifier, Equatable { log('[CodelesslyContext] Variable with name $name does not exist.'); return null; } - return variable.value.getValue().typedValue(); + return PropertyValueDelegate.variableValue( + variable.value, + scopedValues: ScopedValues.preview( + variables: variableNamesMap(), + codelesslyContext: this, + ), + ).typedValue(); } @override diff --git a/lib/src/utils/formula.dart b/lib/src/utils/formula.dart index 99ac5af5..886b9412 100644 --- a/lib/src/utils/formula.dart +++ b/lib/src/utils/formula.dart @@ -267,6 +267,34 @@ class FormulaEvaluator { 'without(list, other)', 'without(all, picked)', ), + FormulaFunctionDoc( + 'obj', + 'obj(key, value, ...)', + "obj('cat', c, 'min', 80) → {cat: c, min: 80}", + ), + FormulaFunctionDoc( + 'set', + 'set(map, key, value, ...)', + "set(item, 'status', 'down') → item with status down", + ), + FormulaFunctionDoc( + 'get', + 'get(map or list, key)', + "get(lineNames, item.line) → 'Weld'", + ), + FormulaFunctionDoc('take', 'take(list, count)', 'take(pareto, index + 1)'), + FormulaFunctionDoc('skip', 'skip(list, count)', 'skip(events, 25)'), + FormulaFunctionDoc( + 'indexOf', + 'indexOf(list, value or lambda)', + "indexOf(machines, m => m.id == 'PR-102') → 1", + ), + FormulaFunctionDoc( + 'split', + 'split(text, separator)', + "split('19:42', ':') → ['19', '42']", + ), + FormulaFunctionDoc('pad', 'pad(value, width, fill)', "pad(7) → '07'"), FormulaFunctionDoc('number', 'number(value)', 'number("42") → 42'), FormulaFunctionDoc('text', 'text(value)', 'text(42) → "42"'), FormulaFunctionDoc( @@ -850,6 +878,39 @@ final Map _functions = { if (!_values(_arg(a, 1), null).any((other) => _looseEquals(item, other))) item, ], + 'obj': (a) => { + for (int i = 0; i + 1 < a.length; i += 2) + FormulaEvaluator.stringify(a[i]): a[i + 1], + }, + 'set': (a) => { + if (_arg(a, 0) case final Map map) + for (final MapEntry entry in map.entries) + entry.key.toString(): entry.value, + for (int i = 1; i + 1 < a.length; i += 2) + FormulaEvaluator.stringify(a[i]): a[i + 1], + }, + 'get': (a) => _member(_arg(a, 0), _arg(a, 1)), + 'take': (a) => + _values(_arg(a, 0), null).take(math.max(0, _intArg(_arg(a, 1)))).toList(), + 'skip': (a) => + _values(_arg(a, 0), null).skip(math.max(0, _intArg(_arg(a, 1)))).toList(), + 'indexOf': (a) { + final List items = _values(_arg(a, 0), null).toList(); + final List rest = a.skip(1).toList(); + // A plain value finds an equal item; a lambda or field (and value) + // finds a matching one. + if (rest.length == 1 && rest.first is! _Closure) { + return items.indexWhere((item) => _looseEquals(item, rest.first)); + } + return items.indexWhere((item) => _matches(item, rest)); + }, + 'split': (a) => + FormulaEvaluator.stringify(_arg(a, 0)) + .split(FormulaEvaluator.stringify(_arg(a, 1) ?? ',')), + 'pad': (a) => FormulaEvaluator.stringify(_arg(a, 0)).padLeft( + _arg(a, 1) == null ? 2 : _intArg(_arg(a, 1)), + FormulaEvaluator.stringify(_arg(a, 2) ?? '0'), + ), 'number': (a) => _toNum(_arg(a, 0)), 'text': (a) => FormulaEvaluator.stringify(_arg(a, 0)), 'isEmpty': (a) => !_truthy(_functions['length']!(a)), diff --git a/test/computed_variable_test.dart b/test/computed_variable_test.dart new file mode 100644 index 00000000..7c2b05df --- /dev/null +++ b/test/computed_variable_test.dart @@ -0,0 +1,160 @@ +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'; + +Observable variable( + String name, + Object? value, + VariableType type, +) => Observable(VariableData(id: name, name: name, value: value, type: type)); + +void main() { + late Observable events; + late CodelesslyContext context; + + setUp(() { + events = variable('events', [ + {'m': 'PR-102', 'min': 14}, + {'m': 'PR-102', 'min': 23}, + {'m': 'WC-302', 'min': 26}, + ], VariableType.list); + context = CodelesslyContext.empty() + ..variables['events'] = events + ..variables['planned'] = variable('planned', 342, VariableType.integer) + ..variables['downtime'] = variable( + 'downtime', + r"${= sum(filter(events, e => e.m == 'PR-102'), 'min')}", + VariableType.integer, + ) + ..variables['availability'] = variable( + 'availability', + r'${= (planned - downtime) / planned}', + VariableType.decimal, + ) + ..variables['loopA'] = variable( + 'loopA', + r'${= loopB + 1}', + VariableType.integer, + ) + ..variables['loopB'] = variable( + 'loopB', + r'${= loopA + 1}', + VariableType.integer, + ); + }); + + ScopedValues scope() => ScopedValues.preview( + variables: context.variableNamesMap(), + codelesslyContext: context, + ); + + test('a computed variable reads as its formula, typed', () { + expect( + PropertyValueDelegate.getVariableValueFromPath( + r'${downtime}', + scopedValues: scope(), + ), + 37, + ); + expect(context.getVariableValue('downtime'), 37); + // Computed variables can read other computed variables. + expect( + PropertyValueDelegate.getVariableValueFromPath( + r'${availability}', + scopedValues: scope(), + ), + closeTo(305 / 342, 1e-9), + ); + // And formulas can read them. + expect( + PropertyValueDelegate.getVariableValueFromPath( + r'${= fixed(availability * 100, 1)}', + scopedValues: scope(), + ), + '89.2', + ); + }); + + test('it follows changes to what it reads', () { + // Read once so the results are cached. + expect( + context.getVariableValue('availability'), + closeTo(305 / 342, 1e-9), + ); + events.value = events.value.copyWith( + value: [ + {'m': 'PR-102', 'min': 50}, + ], + ); + expect(context.getVariableValue('downtime'), 50); + // Including computed variables that read it through another. + expect( + context.getVariableValue('availability'), + closeTo(292 / 342, 1e-9), + ); + }); + + test('a cycle stops instead of overflowing', () { + // loopA reads loopB, which reads loopA again: that inner read is null, + // which arithmetic counts as 0, so loopB is 1 and loopA 2. + expect(context.getVariableValue('loopA'), 2); + }); + + test('bindings to it listen to the variables its formula reads', () { + final Set listenables = + PassiveNodeTransformerManager.variableListenables( + context, + 'availability', + ).toSet(); + expect( + listenables, + containsAll([ + context.variables['availability'], + context.variables['planned'], + context.variables['downtime'], + events, + ]), + ); + // A cycle is walked once. + expect( + PassiveNodeTransformerManager.variableListenables( + context, + 'loopA', + ).length, + 2, + ); + }); + + testWidgets('setting a computed variable leaves its formula alone', ( + tester, + ) async { + late BuildContext captured; + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: context, + child: MaterialApp( + home: ActionChainHost( + child: Builder( + builder: (c) { + captured = c; + return const SizedBox.expand(); + }, + ), + ), + ), + ), + ); + final bool changed = FunctionsRepository.setVariableFromAction( + captured, + SetVariableAction( + variable: VariableData(id: 'downtime', name: 'downtime'), + newValue: '5', + ), + ); + expect(changed, isFalse); + expect(context.getVariableValue('downtime'), 37); + }); +} diff --git a/test/formula_lists_test.dart b/test/formula_lists_test.dart index 0e754c15..dfaaaa56 100644 --- a/test/formula_lists_test.dart +++ b/test/formula_lists_test.dart @@ -400,4 +400,73 @@ void main() { 'No results for milk', ); }); + + group('objects and slices', () { + test('obj builds a map from key-value pairs', () { + expect(eval("obj('cat', 'Mechanical', 'min', 80)"), { + 'cat': 'Mechanical', + 'min': 80, + }); + expect( + eval("map(cart, c => obj('name', c.name, 'total', c.price * c.qty))"), + [ + {'name': 'Tea', 'total': 8}, + {'name': 'Cake', 'total': 7}, + ], + ); + }); + + test('set copies a map with fields changed or added', () { + expect(eval("set(find(cart, 'name', 'Tea'), 'qty', 5, 'note', 'x')"), { + 'name': 'Tea', + 'price': 2, + 'qty': 5, + 'meta': {'shop': 'x'}, + 'note': 'x', + }); + // The original is unchanged. + expect((vars['cart']! as List).first, containsPair('qty', 4)); + expect( + eval( + "map(cart, c => if(c.name == 'Cake', set(c, 'qty', 0), c))[1].qty", + ), + 0, + ); + }); + + test('get reads a field or index named by a value', () { + expect(eval("get(find(cart, 'qty', 2), 'name')"), 'Cake'); + expect(eval('get(tags, limit)'), 'a'); + expect(eval("get(obj('PA', 'Press Line A'), 'PA')"), 'Press Line A'); + }); + + test('take and skip slice lists', () { + expect(eval('take(tags, 2)'), ['a', 'b']); + expect(eval('skip(tags, 3)'), ['c', 'b']); + expect(eval('take(tags, 99)').toString(), '[a, b, a, c, b]'); + expect(eval('take(tags, -1)'), isEmpty); + // A running total: the first n items. + expect(eval("sum(take(cart, 2), c => c.qty)"), 6); + }); + + test('indexOf finds an item, a match or nothing', () { + expect(eval("indexOf(tags, 'c')"), 3); + expect(eval("indexOf(cart, c => c.name == 'Cake')"), 1); + expect(eval("indexOf(cart, 'name', 'Tea')"), 0); + expect(eval("indexOf(tags, 'z')"), -1); + }); + + test('split and pad handle clock times', () { + expect(eval("split('19:42', ':')"), ['19', '42']); + expect( + eval( + "number(split('19:42', ':')[0]) * 60 + number(split('19:42', ':')[1])", + ), + 1182, + ); + expect(eval('pad(7)'), '07'); + expect(eval("pad(floor(1182 / 60)) + ':' + pad(1182 % 60)"), '19:42'); + expect(eval("pad(5, 3, ' ')"), ' 5'); + }); + }); } From d9d30b5b3a18048b1e9aa5bb47de5436eefb2493 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 02:36:31 +0000 Subject: [PATCH 03/18] Design canvas: list rows preview their own item; gauge and chart take preview scoped values VariablePreview.resolve(node, context) passes the row's IndexedItem to the editor's scope resolver, so ${item...} text in row N shows row N. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FeVQ6NCLHS9Vrfxhii4Mj8 --- .../passive_button_transformer.dart | 3 +- .../passive_chart_transformer.dart | 12 ++++--- .../passive_gauge_transformer.dart | 12 ++++--- lib/src/transformers/utils/text_utils.dart | 8 +++-- lib/src/transformers/utils/variable_span.dart | 34 ++++++++++++++++--- test/design_canvas_bindings_test.dart | 2 +- test/variable_preview_test.dart | 34 ++++++++++++++++++- 7 files changed, 87 insertions(+), 18 deletions(-) diff --git a/lib/src/transformers/node_transformers/passive_button_transformer.dart b/lib/src/transformers/node_transformers/passive_button_transformer.dart index 4e3337d2..62815cb6 100644 --- a/lib/src/transformers/node_transformers/passive_button_transformer.dart +++ b/lib/src/transformers/node_transformers/passive_button_transformer.dart @@ -131,7 +131,8 @@ class PassiveButtonWidget extends StatelessWidget { final List spans = buttonLabelTemplateOf(node) .splitMap( variableSyntaxIdentifierRegex, - onMatch: (match) => VariableSpan(variable: match[0]!, node: node), + onMatch: (match) => + VariableSpan(variable: match[0]!, node: node, context: context), onNonMatch: (text) => TextSpan(text: text), ) .toList(); diff --git a/lib/src/transformers/node_transformers/passive_chart_transformer.dart b/lib/src/transformers/node_transformers/passive_chart_transformer.dart index 2adf0701..0deb60e3 100644 --- a/lib/src/transformers/node_transformers/passive_chart_transformer.dart +++ b/lib/src/transformers/node_transformers/passive_chart_transformer.dart @@ -24,11 +24,16 @@ class PassiveChartWidget extends StatelessWidget { final WidgetBuildSettings settings; final List variablesOverrides; + /// The values bindings read instead of the build context's, such as the + /// variable defaults a design canvas shows. + final ScopedValues? scopedValues; + const PassiveChartWidget({ super.key, required this.node, required this.settings, this.variablesOverrides = const [], + this.scopedValues, }); /// The chart's items: the bound `data` list, else the sample data. @@ -76,10 +81,9 @@ class PassiveChartWidget extends StatelessWidget { @override Widget build(BuildContext context) { - final ScopedValues scopedValues = ScopedValues.of( - context, - variablesOverrides: variablesOverrides, - ); + final ScopedValues scopedValues = + this.scopedValues ?? + ScopedValues.of(context, variablesOverrides: variablesOverrides); final List> data = resolveData(node, scopedValues); final int selected = PropertyValueDelegate.getPropertyValue( diff --git a/lib/src/transformers/node_transformers/passive_gauge_transformer.dart b/lib/src/transformers/node_transformers/passive_gauge_transformer.dart index ad8bf2e4..5f66073a 100644 --- a/lib/src/transformers/node_transformers/passive_gauge_transformer.dart +++ b/lib/src/transformers/node_transformers/passive_gauge_transformer.dart @@ -24,6 +24,10 @@ class PassiveGaugeWidget extends StatelessWidget { final WidgetBuildSettings settings; final List variablesOverrides; + /// The values bindings read instead of the build context's, such as the + /// variable defaults a design canvas shows. + final ScopedValues? scopedValues; + /// Overrides [GaugeProperties.animate], e.g. off on the editor canvas. final bool? animate; @@ -32,6 +36,7 @@ class PassiveGaugeWidget extends StatelessWidget { required this.node, required this.settings, this.variablesOverrides = const [], + this.scopedValues, this.animate, }); @@ -40,10 +45,9 @@ class PassiveGaugeWidget extends StatelessWidget { @override Widget build(BuildContext context) { - final ScopedValues scopedValues = ScopedValues.of( - context, - variablesOverrides: variablesOverrides, - ); + final ScopedValues scopedValues = + this.scopedValues ?? + ScopedValues.of(context, variablesOverrides: variablesOverrides); final double value = PropertyValueDelegate.getPropertyValue( node, diff --git a/lib/src/transformers/utils/text_utils.dart b/lib/src/transformers/utils/text_utils.dart index b677d159..cda31eb3 100644 --- a/lib/src/transformers/utils/text_utils.dart +++ b/lib/src/transformers/utils/text_utils.dart @@ -100,8 +100,12 @@ class TextUtils { final spans = characters .splitMap( variableSyntaxIdentifierRegex, - onMatch: (match) => - VariableSpan(variable: match[0]!, node: node, style: style), + onMatch: (match) => VariableSpan( + variable: match[0]!, + node: node, + style: style, + context: context, + ), onNonMatch: (text) => TextSpan(text: text, style: style), ) .toList(); diff --git a/lib/src/transformers/utils/variable_span.dart b/lib/src/transformers/utils/variable_span.dart index c1feb7df..a467f50d 100644 --- a/lib/src/transformers/utils/variable_span.dart +++ b/lib/src/transformers/utils/variable_span.dart @@ -12,8 +12,21 @@ import '../../../codelessly_sdk.dart'; /// (`${auth.displayName}` shows "Display name"). abstract final class VariablePreview { /// Returns the values that [node] can see on the design canvas, or null to - /// fall back to placeholders. - static ScopedValues? Function(BaseNode? node)? scopeResolver; + /// fall back to placeholders. When [item] is given (the row of a list a + /// node is built in), `${item...}` reads that row's data. + static ScopedValues? Function(BaseNode? node, {IndexedItem? item})? + scopeResolver; + + /// The values [node] can see on the design canvas when built in + /// [context], which gives the list row it is in. + static ScopedValues? resolve(BaseNode? node, [BuildContext? context]) { + final resolver = scopeResolver; + if (resolver == null) return null; + final IndexedItem? item = context == null + ? null + : IndexedItemProvider.maybeOf(context); + return resolver(node, item: item?.item == null ? null : item); + } /// Accessors that read a property of a value rather than name it, so the /// placeholder for `${item.title.initial}` is named after `title`. @@ -37,8 +50,13 @@ abstract final class VariablePreview { static const Set _literals = {'true', 'false', 'null'}; /// The text the design canvas shows for [expression], one `${...}` match. - static String textFor(String expression, {BaseNode? node, TextStyle? style}) { - final ScopedValues? scopedValues = scopeResolver?.call(node); + static String textFor( + String expression, { + BaseNode? node, + TextStyle? style, + BuildContext? context, + }) { + final ScopedValues? scopedValues = resolve(node, context); if (scopedValues != null) { final String value = PropertyValueDelegate.substituteVariables( expression, @@ -135,11 +153,17 @@ class VariableSpan extends TextSpan { VariableSpan({ required this.variable, BaseNode? node, + BuildContext? context, super.style, super.mouseCursor, super.onEnter, super.onExit, }) : super( - text: VariablePreview.textFor(variable, node: node, style: style), + text: VariablePreview.textFor( + variable, + node: node, + style: style, + context: context, + ), ); } diff --git a/test/design_canvas_bindings_test.dart b/test/design_canvas_bindings_test.dart index 526378fc..c83b9191 100644 --- a/test/design_canvas_bindings_test.dart +++ b/test/design_canvas_bindings_test.dart @@ -52,7 +52,7 @@ void main() { testWidgets('shows the default-value result on a design canvas', ( tester, ) async { - VariablePreview.scopeResolver = (_) => defaults('signIn'); + VariablePreview.scopeResolver = (_, {item}) => defaults('signIn'); await pumpHost( tester, (context) => PassiveButtonWidget( diff --git a/test/variable_preview_test.dart b/test/variable_preview_test.dart index 02fef403..ecd5727f 100644 --- a/test/variable_preview_test.dart +++ b/test/variable_preview_test.dart @@ -1,4 +1,5 @@ import 'package:codelessly_api/codelessly_api.dart'; +import 'package:flutter/widgets.dart'; import 'package:codelessly_sdk/codelessly_sdk.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -29,7 +30,7 @@ void main() { group('defaults', () { setUp(() { - VariablePreview.scopeResolver = (node) => ScopedValues.preview( + VariablePreview.scopeResolver = (node, {item}) => ScopedValues.preview( variables: { 'userName': VariableData( id: 'v1', @@ -89,4 +90,35 @@ void main() { 'Shots', ); }); + + testWidgets('a list row shows its own item', (tester) async { + VariablePreview.scopeResolver = (node, {item}) => ScopedValues.preview( + variables: const {}, + indexedItem: item ?? IndexedItem(0, {'id': 'first'}), + ); + final texts = []; + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Column( + children: [ + for (final id in ['a', 'b']) + IndexedItemProvider( + item: IndexedItem(0, {'id': id}), + child: Builder( + builder: (context) { + texts.add( + VariablePreview.textFor(r'${item.id}', context: context), + ); + return const SizedBox(); + }, + ), + ), + ], + ), + ), + ); + expect(texts, ['a', 'b']); + expect(VariablePreview.textFor(r'${item.id}'), 'first'); + }); } From 86f65ae7e0a62d5a8987f2473377931913a68a3d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 03:07:14 +0000 Subject: [PATCH 04/18] Formulas: concat, range, thousands, duration; progress bar takes preview values concat(list, ...) joins lists (rule-raised alerts with stored ones), range(n) counts, thousands(n, decimals) writes 17,402 and duration(min) writes 6m / 2h 04m. PassiveProgressBarWidget takes scopedValues so the design canvas can bind its value and color. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FeVQ6NCLHS9Vrfxhii4Mj8 --- .../passive_progressbar_transformer.dart | 12 ++++--- lib/src/utils/formula.dart | 36 +++++++++++++++++++ test/formula_lists_test.dart | 21 +++++++++++ 3 files changed, 65 insertions(+), 4 deletions(-) diff --git a/lib/src/transformers/node_transformers/passive_progressbar_transformer.dart b/lib/src/transformers/node_transformers/passive_progressbar_transformer.dart index e0ede75e..6698b519 100644 --- a/lib/src/transformers/node_transformers/passive_progressbar_transformer.dart +++ b/lib/src/transformers/node_transformers/passive_progressbar_transformer.dart @@ -107,6 +107,10 @@ class PassiveProgressBarWidget extends StatelessWidget { final bool? animate; final ValueChanged? onChanged; + /// The values bindings read instead of the build context's, such as the + /// variable defaults a design canvas shows. + final ScopedValues? scopedValues; + const PassiveProgressBarWidget({ super.key, required this.node, @@ -114,14 +118,14 @@ class PassiveProgressBarWidget extends StatelessWidget { this.variablesOverrides = const [], this.animate, this.onChanged, + this.scopedValues, }); @override Widget build(BuildContext context) { - final ScopedValues scopedValues = ScopedValues.of( - context, - variablesOverrides: variablesOverrides, - ); + final ScopedValues scopedValues = + this.scopedValues ?? + ScopedValues.of(context, variablesOverrides: variablesOverrides); final double currentValue = PropertyValueDelegate.getPropertyValue( node, diff --git a/lib/src/utils/formula.dart b/lib/src/utils/formula.dart index 886b9412..38d2d074 100644 --- a/lib/src/utils/formula.dart +++ b/lib/src/utils/formula.dart @@ -163,6 +163,16 @@ class FormulaEvaluator { 'money(value, symbol)', r'money(price) → $1,234.50', ), + FormulaFunctionDoc( + 'thousands', + 'thousands(value, decimals)', + 'thousands(17402) → 17,402', + ), + FormulaFunctionDoc( + 'duration', + 'duration(minutes)', + "duration(134) → '2h 14m', duration(6) → '6m'", + ), FormulaFunctionDoc( 'fixed', 'fixed(value, decimals)', @@ -283,6 +293,12 @@ class FormulaEvaluator { "get(lineNames, item.line) → 'Weld'", ), FormulaFunctionDoc('take', 'take(list, count)', 'take(pareto, index + 1)'), + FormulaFunctionDoc( + 'concat', + 'concat(list, list, ...)', + 'concat(alerts, ruleAlerts) → one list', + ), + FormulaFunctionDoc('range', 'range(count)', 'range(3) → [0, 1, 2]'), FormulaFunctionDoc('skip', 'skip(list, count)', 'skip(events, 25)'), FormulaFunctionDoc( 'indexOf', @@ -482,6 +498,18 @@ String _money(Object? value, Object? symbol) { return '$sign${symbol ?? r'$'}$whole${text.substring(dot)}'; } +/// [value] with [digits] decimals (none by default) and commas between +/// thousands: 17402 → "17,402". +String _thousands(Object? value, Object? digits) { + final num amount = _num(value); + if (amount.isNaN) return ''; + final String text = amount.abs().toStringAsFixed(_num(digits ?? 0).toInt()); + final int dot = text.indexOf('.'); + final String whole = (dot < 0 ? text : text.substring(0, dot)) + .replaceAllMapped(RegExp(r'\B(?=(\d{3})+(?!\d))'), (_) => ','); + return '${amount < 0 ? '-' : ''}$whole${dot < 0 ? '' : text.substring(dot)}'; +} + num _round(Object? value, Object? digits) { final num v = _num(value); final int d = _num(digits ?? 0).toInt(); @@ -816,6 +844,12 @@ Object? _arg(List args, int index) => final Map _functions = { 'money': (a) => _money(_arg(a, 0), _arg(a, 1)), + 'thousands': (a) => _thousands(_arg(a, 0), _arg(a, 1)), + 'duration': (a) { + final int minutes = _num(_arg(a, 0)).round(); + if (minutes < 60) return '${minutes}m'; + return '${minutes ~/ 60}h ${(minutes % 60).toString().padLeft(2, '0')}m'; + }, 'fixed': (a) => _fixed(_arg(a, 0), _arg(a, 1)), 'round': (a) => _round(_arg(a, 0), _arg(a, 1)), 'floor': (a) => _num(_arg(a, 0)).floor(), @@ -890,6 +924,8 @@ final Map _functions = { FormulaEvaluator.stringify(a[i]): a[i + 1], }, 'get': (a) => _member(_arg(a, 0), _arg(a, 1)), + 'concat': (a) => [for (final list in a) ..._values(list, null)], + 'range': (a) => [for (int i = 0; i < _intArg(_arg(a, 0)); i++) i], 'take': (a) => _values(_arg(a, 0), null).take(math.max(0, _intArg(_arg(a, 1)))).toList(), 'skip': (a) => diff --git a/test/formula_lists_test.dart b/test/formula_lists_test.dart index dfaaaa56..399309a2 100644 --- a/test/formula_lists_test.dart +++ b/test/formula_lists_test.dart @@ -402,6 +402,27 @@ void main() { }); group('objects and slices', () { + test('concat joins lists and range counts', () { + expect(eval('concat(take(tags, 2), [1], take(tags, 1))'), [ + 'a', + 'b', + 1, + 'a', + ]); + expect(eval("length(concat(cart, filter(cart, 'qty', 2)))"), 3); + expect(eval('range(3)'), [0, 1, 2]); + expect(eval('range(0)'), isEmpty); + }); + + test('thousands and duration format readouts', () { + expect(eval('thousands(17402)'), '17,402'); + expect(eval('thousands(-1234.5, 1)'), '-1,234.5'); + expect(eval('thousands(999)'), '999'); + expect(eval('duration(6)'), '6m'); + expect(eval('duration(134)'), '2h 14m'); + expect(eval('duration(124)'), '2h 04m'); + }); + test('obj builds a map from key-value pairs', () { expect(eval("obj('cat', 'Mechanical', 'min', 80)"), { 'cat': 'Mechanical', From 041835b919d24c956629c820c9c82d154849ae52 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 03:26:24 +0000 Subject: [PATCH 05/18] Apply a node's pulse: PulseOpacity fades it between its faint and full opacity Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FeVQ6NCLHS9Vrfxhii4Mj8 --- .../widget_node_transformer_manager.dart | 71 ++++++++++++++- test/pulse_test.dart | 87 +++++++++++++++++++ 2 files changed, 154 insertions(+), 4 deletions(-) create mode 100644 test/pulse_test.dart diff --git a/lib/src/transformers/widget_node_transformer_manager.dart b/lib/src/transformers/widget_node_transformer_manager.dart index 87c40bd2..642fe165 100644 --- a/lib/src/transformers/widget_node_transformer_manager.dart +++ b/lib/src/transformers/widget_node_transformer_manager.dart @@ -51,11 +51,12 @@ abstract class WidgetNodeTransformerManager /// Convenience method to handle widget opacity. Widget applyWidgetOpacity(BaseNode node, Widget widget) { - if (node is! BlendMixin || node.opacity > 0.99) { - return widget; - } - + if (node is! BlendMixin) return widget; final double opacity = node.opacity < 0.99 ? node.opacity : 1; + if (node.pulse case final PulseModel pulse) { + return PulseOpacity(pulse: pulse, opacity: opacity, child: widget); + } + if (opacity == 1) return widget; return Opacity(opacity: opacity, child: widget); } @@ -591,3 +592,65 @@ class SvgIconImage extends StatelessWidget { ); } } + +/// Fades [child] from [PulseModel.minOpacity] × [opacity] to [opacity] and +/// back, over and over (skeletons, blinking indicators). +class PulseOpacity extends StatefulWidget { + /// The fade. + final PulseModel pulse; + + /// The node's own opacity, the bright end of the fade. + final double opacity; + + /// The faded widget. + final Widget child; + + /// Creates a [PulseOpacity]. + const PulseOpacity({ + super.key, + required this.pulse, + required this.opacity, + required this.child, + }); + + @override + State createState() => _PulseOpacityState(); +} + +class _PulseOpacityState extends State + with SingleTickerProviderStateMixin { + late final AnimationController controller = AnimationController( + vsync: this, + duration: _duration, + )..repeat(reverse: true); + + Duration get _duration => + Duration(milliseconds: widget.pulse.durationMillis.clamp(16, 60000)); + + @override + void didUpdateWidget(PulseOpacity oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.pulse.durationMillis != widget.pulse.durationMillis) { + controller + ..duration = _duration + ..repeat(reverse: true); + } + } + + @override + void dispose() { + controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => FadeTransition( + opacity: controller.drive( + Tween( + begin: widget.opacity * widget.pulse.minOpacity.clamp(0, 1), + end: widget.opacity, + ).chain(CurveTween(curve: Curves.easeInOut)), + ), + child: widget.child, + ); +} diff --git a/test/pulse_test.dart b/test/pulse_test.dart new file mode 100644 index 00000000..b93639f5 --- /dev/null +++ b/test/pulse_test.dart @@ -0,0 +1,87 @@ +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'; + +/// The opacity the pulse gives the node right now. +double fade(WidgetTester tester) => tester + .widget( + find.descendant( + of: find.byType(PulseOpacity), + matching: find.byType(FadeTransition), + ), + ) + .opacity + .value; + +void main() { + testWidgets('a node with a pulse fades between its faint and full opacity', ( + tester, + ) async { + final node = TextNode( + id: 'bar', + name: 'Skeleton', + basicBoxLocal: NodeBox(0, 0, 100, 20), + characters: 'Loading', + opacity: 0.8, + )..pulse = const PulseModel(minOpacity: 0.5, durationMillis: 1000); + 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: Builder( + builder: (context) => manager.buildWidgetFromNode( + node, + context, + settings: const WidgetBuildSettings(debugLabel: 'test'), + ), + ), + ), + ), + ); + + expect(find.byType(PulseOpacity), findsOneWidget); + expect(fade(tester), closeTo(0.4, 1e-6)); + await tester.pump(const Duration(milliseconds: 1000)); + expect(fade(tester), closeTo(0.8, 1e-6)); + await tester.pump(const Duration(milliseconds: 500)); + expect(fade(tester), allOf(greaterThan(0.4), lessThan(0.8))); + await tester.pump(const Duration(milliseconds: 500)); + expect(fade(tester), closeTo(0.4, 1e-6)); + }); + + testWidgets('a node without a pulse has no fade', (tester) async { + final node = TextNode( + id: 'bar', + name: 'Bar', + basicBoxLocal: NodeBox(0, 0, 100, 20), + characters: 'Ready', + ); + 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: Builder( + builder: (context) => manager.buildWidgetFromNode( + node, + context, + settings: const WidgetBuildSettings(debugLabel: 'test'), + ), + ), + ), + ), + ); + expect(find.byType(PulseOpacity), findsNothing); + }); +} From 4c943a0bc6971adfb5e0e6f6531b0fc416fb9550 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 17:08:29 +0000 Subject: [PATCH 06/18] Computed choice variables read as text Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FeVQ6NCLHS9Vrfxhii4Mj8 --- lib/src/transformers/utils/property_value_delegate.dart | 2 +- test/computed_variable_test.dart | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/lib/src/transformers/utils/property_value_delegate.dart b/lib/src/transformers/utils/property_value_delegate.dart index 5cf45bf9..1c13e9e9 100644 --- a/lib/src/transformers/utils/property_value_delegate.dart +++ b/lib/src/transformers/utils/property_value_delegate.dart @@ -441,7 +441,7 @@ class PropertyValueDelegate { scopedValues: scopedValues, ); return switch (variable.type) { - VariableType.text || VariableType.image => + VariableType.text || VariableType.image || VariableType.choice => value == null ? null : FormulaEvaluator.stringify(value), VariableType.integer => value is num ? value.toInt() : num.tryParse('$value')?.toInt(), diff --git a/test/computed_variable_test.dart b/test/computed_variable_test.dart index 7c2b05df..e52ac6bc 100644 --- a/test/computed_variable_test.dart +++ b/test/computed_variable_test.dart @@ -78,6 +78,15 @@ void main() { ); }); + test('a computed choice variable reads as the text its formula gives', () { + context.variables['worst'] = variable( + 'worst', + r"${= if(downtime > 30, 'down', 'running')}", + VariableType.choice, + ); + expect(context.getVariableValue('worst'), 'down'); + }); + test('it follows changes to what it reads', () { // Read once so the results are cached. expect( From 7e2c55cdcbe31d96ccbab80cac15c19ad4cfc718 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 23:25:57 +0000 Subject: [PATCH 07/18] Dropdown takes preview values so a design canvas shows its bound items and selection Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FeVQ6NCLHS9Vrfxhii4Mj8 --- .../node_transformers/passive_dropdown_transformer.dart | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/lib/src/transformers/node_transformers/passive_dropdown_transformer.dart b/lib/src/transformers/node_transformers/passive_dropdown_transformer.dart index 8d6ffccd..80720a4b 100644 --- a/lib/src/transformers/node_transformers/passive_dropdown_transformer.dart +++ b/lib/src/transformers/node_transformers/passive_dropdown_transformer.dart @@ -81,6 +81,10 @@ class PassiveDropdownWidget extends StatelessWidget { final int? initialValue; final WidgetBuildSettings settings; + /// The values the items and selection bindings read instead of the build + /// context's, such as the variable defaults a design canvas shows. + final ScopedValues? scopedValues; + const PassiveDropdownWidget({ super.key, required this.node, @@ -88,6 +92,7 @@ class PassiveDropdownWidget extends StatelessWidget { this.onChanged, this.initialValue, required this.settings, + this.scopedValues, }); List> buildItems(BuildContext context, List items) { @@ -183,7 +188,8 @@ class PassiveDropdownWidget extends StatelessWidget { @override Widget build(BuildContext context) { - final ScopedValues scopedValues = ScopedValues.of(context); + final ScopedValues scopedValues = + this.scopedValues ?? ScopedValues.of(context); final List items = node.properties.useDataSource ? PropertyValueDelegate.getPropertyValue( node, From 4f280188d007204f9311ea46915a20ee29569381 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 23:29:36 +0000 Subject: [PATCH 08/18] Lint: braces around a single-statement if; single quotes in a formula test Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FeVQ6NCLHS9Vrfxhii4Mj8 --- lib/src/transformers/utils/property_value_delegate.dart | 3 ++- test/formula_lists_test.dart | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/src/transformers/utils/property_value_delegate.dart b/lib/src/transformers/utils/property_value_delegate.dart index 1c13e9e9..9d838a0e 100644 --- a/lib/src/transformers/utils/property_value_delegate.dart +++ b/lib/src/transformers/utils/property_value_delegate.dart @@ -494,8 +494,9 @@ class PropertyValueDelegate { return value; } - if (!match.hasPath) + if (!match.hasPath) { return variableValue(variable, scopedValues: scopedValues); + } final Object? current = variableValue(variable, scopedValues: scopedValues); final variableProps = getVariableProperties(current, variable.type); diff --git a/test/formula_lists_test.dart b/test/formula_lists_test.dart index 399309a2..6fe31818 100644 --- a/test/formula_lists_test.dart +++ b/test/formula_lists_test.dart @@ -467,7 +467,7 @@ void main() { expect(eval('take(tags, 99)').toString(), '[a, b, a, c, b]'); expect(eval('take(tags, -1)'), isEmpty); // A running total: the first n items. - expect(eval("sum(take(cart, 2), c => c.qty)"), 6); + expect(eval('sum(take(cart, 2), c => c.qty)'), 6); }); test('indexOf finds an item, a match or nothing', () { From 8b2b0bce6e7ea6e22da2d682780fcee9168b97bb Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 23:32:07 +0000 Subject: [PATCH 09/18] Changelog: unreleased changes Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FeVQ6NCLHS9Vrfxhii4Mj8 --- CHANGELOG.md | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index fc42932b..b22e5e6e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,29 @@ +## Unreleased + +### Nodes + +- Renders `gauge` and `chart` nodes (`PassiveGaugeWidget`, + `PassiveChartWidget`); taps on a chart select a category and fire its + `changed` trigger with the index. +- Repeating and mirrored linear, radial and sweep gradients. +- A node's `pulse` fades its opacity in a loop (`PulseOpacity`). + +### Variables and formulas + +- Computed variables: a variable whose value is a `${= ...}` formula reads + as the formula's result, typed, in bindings, formulas and conditions. + Results are cached until a variable they read changes, and bindings + update when one does. Cycles stop; SetVariable leaves them alone. +- Formula functions `obj`, `set`, `get`, `take`, `skip`, `indexOf`, + `split`, `pad`, `concat`, `range`, `thousands` and `duration`. + +### Design canvas + +- `VariablePreview.resolve(node, context)` gives a list row's own item to + the editor's resolver, so each row previews its data. +- Gauge, chart, progress bar and dropdown widgets take `scopedValues`, so a + design canvas can show their bindings with its variable defaults. + ## 8.2.0 ### States From c1a554c759c81792d5d5cabeca1b8f26f694cbd2 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 23:56:05 +0000 Subject: [PATCH 10/18] Slider takes preview values so a design canvas shows its bound value Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FeVQ6NCLHS9Vrfxhii4Mj8 --- .../passive_slider_transformer.dart | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/lib/src/transformers/node_transformers/passive_slider_transformer.dart b/lib/src/transformers/node_transformers/passive_slider_transformer.dart index a7ecc97f..83434594 100644 --- a/lib/src/transformers/node_transformers/passive_slider_transformer.dart +++ b/lib/src/transformers/node_transformers/passive_slider_transformer.dart @@ -56,11 +56,16 @@ class PassiveSliderWidget extends StatelessWidget { final WidgetBuildSettings settings; final List variablesOverrides; + /// The values the slider's bindings read instead of the build context's, + /// such as the variable defaults a design canvas shows. + final ScopedValues? scopedValues; + const PassiveSliderWidget({ super.key, required this.node, required this.settings, this.variablesOverrides = const [], + this.scopedValues, }); void onChanged(BuildContext context, double internalValue) { @@ -83,10 +88,9 @@ class PassiveSliderWidget extends StatelessWidget { @override Widget build(BuildContext context) { - final ScopedValues scopedValues = ScopedValues.of( - context, - variablesOverrides: variablesOverrides, - ); + final ScopedValues scopedValues = + this.scopedValues ?? + ScopedValues.of(context, variablesOverrides: variablesOverrides); final double value = PropertyValueDelegate.getPropertyValue( node, From c51a0115b02c45985401d716c34fbd1b0f8b6ef9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 00:27:16 +0000 Subject: [PATCH 11/18] Responsive dialogs follow the screen; empty formula previews A dialog keeps its canvas's design size, so a responsive dialog showed whichever breakpoint the editor last left the canvas at. A canvas holding a responsive layout now fills the space it is given. A formula span that evaluates to empty text showed a placeholder on the design canvas; it now shows nothing. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FeVQ6NCLHS9Vrfxhii4Mj8 --- CHANGELOG.md | 4 ++ .../passive_canvas_transformer.dart | 37 ++++++++++++++-- lib/src/transformers/utils/variable_span.dart | 11 +++++ test/canvas_viewport_test.dart | 42 +++++++++++++++++++ test/variable_preview_test.dart | 5 +++ 5 files changed, 96 insertions(+), 3 deletions(-) create mode 100644 test/canvas_viewport_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index b22e5e6e..ae9fbd42 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ `changed` trigger with the index. - Repeating and mirrored linear, radial and sweep gradients. - A node's `pulse` fades its opacity in a loop (`PulseOpacity`). +- A dialog whose canvas holds a responsive layout fills the screen and + shows the layout for the screen's width, not the canvas's design width. +- On the design canvas, a formula that yields empty text shows nothing + instead of a placeholder. ### Variables and formulas diff --git a/lib/src/transformers/node_transformers/passive_canvas_transformer.dart b/lib/src/transformers/node_transformers/passive_canvas_transformer.dart index 9fe25753..52e3c123 100644 --- a/lib/src/transformers/node_transformers/passive_canvas_transformer.dart +++ b/lib/src/transformers/node_transformers/passive_canvas_transformer.dart @@ -366,6 +366,20 @@ class PassiveCanvasTransformer extends NodeWidgetTransformer { // ); // } + /// Whether [node]'s layout is responsive: a variance with breakpoints + /// within its body's first levels, where `layouts.createResponsive` puts + /// one. + bool hasResponsiveLayout(CanvasNode node) { + bool check(String id, int depth) { + final BaseNode child = getNode(id); + if (child is VarianceNode && child.isResponsive) return true; + if (depth == 0 || child is! ChildrenMixin) return false; + return child.children.any((id) => check(id, depth - 1)); + } + + return node.children.any((id) => check(id, 2)); + } + @override Widget buildWidget( CanvasNode node, @@ -377,6 +391,7 @@ class PassiveCanvasTransformer extends NodeWidgetTransformer { settings: settings, builder: (context) => CanvasViewport( node: node, + responsive: hasResponsiveLayout(node), child: switch (node.scaleMode) { ScaleMode.responsive => _wrapInScaffoldForResponsive( context: context, @@ -497,7 +512,17 @@ class CanvasViewport extends StatelessWidget { final CanvasNode node; final Widget child; - const CanvasViewport({super.key, required this.node, required this.child}); + /// Whether the canvas holds a responsive layout. Such a canvas fills the + /// space it is given even under [CanvasSizing.design], so its layout + /// follows the screen rather than the breakpoint it was last designed at. + final bool responsive; + + const CanvasViewport({ + super.key, + required this.node, + required this.child, + this.responsive = false, + }); /// The canvas box for [constraints] under [sizing], per axis. A null /// height leaves the height to the canvas content (see @@ -506,6 +531,7 @@ class CanvasViewport extends StatelessWidget { CanvasNode node, BoxConstraints constraints, { CanvasSizing sizing = CanvasSizing.fill, + bool responsive = false, }) { // 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 @@ -514,7 +540,7 @@ class CanvasViewport extends StatelessWidget { node.verticalFit == SizeFit.shrinkWrap && node.scaleMode == ScaleMode.responsive && !node.isAScaffold; - if (sizing == CanvasSizing.design) { + if (sizing == CanvasSizing.design && !responsive) { final double width = constraints.constrainWidth(node.basicBoxLocal.width); if (contentHeight) return (width: width, height: null); final double height = node.scaleMode == ScaleMode.autoScale @@ -543,7 +569,12 @@ class CanvasViewport extends StatelessWidget { final CanvasSizing sizing = CanvasSizingScope.of(context); return LayoutBuilder( builder: (context, constraints) { - final box = resolve(node, constraints, sizing: sizing); + final box = resolve( + node, + constraints, + sizing: sizing, + responsive: responsive, + ); // The width the canvas content is laid out at: the canvas box for a // responsive canvas, the design width for an autoScale one (its // content is laid out at design size and then scaled). diff --git a/lib/src/transformers/utils/variable_span.dart b/lib/src/transformers/utils/variable_span.dart index a467f50d..1a90d0df 100644 --- a/lib/src/transformers/utils/variable_span.dart +++ b/lib/src/transformers/utils/variable_span.dart @@ -58,6 +58,17 @@ abstract final class VariablePreview { }) { final ScopedValues? scopedValues = resolve(node, context); if (scopedValues != null) { + // A formula that yields empty text means "show nothing", unlike one + // that can't be evaluated (null), which gets a placeholder. + final String? formula = FormulaEvaluator.exactExpression(expression); + if (formula != null && + PropertyValueDelegate.evaluateFormula( + formula, + scopedValues: scopedValues, + ) == + '') { + return ''; + } final String value = PropertyValueDelegate.substituteVariables( expression, scopedValues: scopedValues, diff --git a/test/canvas_viewport_test.dart b/test/canvas_viewport_test.dart new file mode 100644 index 00000000..aa49c913 --- /dev/null +++ b/test/canvas_viewport_test.dart @@ -0,0 +1,42 @@ +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'; + +void main() { + final CanvasNode canvas = CanvasNode( + id: 'canvas', + name: 'Dialog', + basicBoxLocal: NodeBox(0, 0, 1440, 900), + children: const [], + properties: CanvasProperties(bodyId: 'body'), + scaleMode: ScaleMode.responsive, + ); + const BoxConstraints screen = BoxConstraints(maxWidth: 390, maxHeight: 844); + + test('a dialog canvas keeps its design size', () { + final box = CanvasViewport.resolve( + canvas, + screen, + sizing: CanvasSizing.design, + ); + expect(box.width, 390); + expect(box.height, 844); + final wide = CanvasViewport.resolve( + canvas, + const BoxConstraints(maxWidth: 1920, maxHeight: 1080), + sizing: CanvasSizing.design, + ); + expect((wide.width, wide.height), (1440.0, 900.0)); + }); + + test('a responsive dialog canvas follows the screen', () { + final box = CanvasViewport.resolve( + canvas, + const BoxConstraints(maxWidth: 1920, maxHeight: 1080), + sizing: CanvasSizing.design, + responsive: true, + ); + expect((box.width, box.height), (1920.0, 1080.0)); + }); +} diff --git a/test/variable_preview_test.dart b/test/variable_preview_test.dart index ecd5727f..4e60051b 100644 --- a/test/variable_preview_test.dart +++ b/test/variable_preview_test.dart @@ -57,6 +57,11 @@ void main() { expect(VariablePreview.textFor(r'${= price * 2}'), '9'); }); + test('show nothing for a formula that yields empty text', () { + expect(VariablePreview.textFor(r"${= if(price > 9, 'big', '')}"), ''); + expect(VariablePreview.textFor(r'${= missing.name}'), 'Name'); + }); + test('fall back to a placeholder when there is no value', () { expect(VariablePreview.textFor(r'${auth.email}'), 'Email'); }); From 2e3455a0f60f66aade7852e01129740ae9f5eb45 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 00:33:26 +0000 Subject: [PATCH 12/18] Chart timeline: draw the now marker in nowColor Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FeVQ6NCLHS9Vrfxhii4Mj8 --- .../node_transformers/passive_chart_transformer.dart | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/src/transformers/node_transformers/passive_chart_transformer.dart b/lib/src/transformers/node_transformers/passive_chart_transformer.dart index 0deb60e3..91a2136f 100644 --- a/lib/src/transformers/node_transformers/passive_chart_transformer.dart +++ b/lib/src/transformers/node_transformers/passive_chart_transformer.dart @@ -669,7 +669,9 @@ class ChartPainter extends CustomPainter { canvas.drawRect( Rect.fromLTWH(nx - 1, 0, 2, bandH), Paint() - ..color = props.axisLabelColor.toFlutterColor().withValues(alpha: 1), + ..color = (props.nowColor ?? props.axisLabelColor) + .toFlutterColor() + .withValues(alpha: 1), ); } canvas.drawRect( From e985bafea14efac8001fe27891702229a545ba85 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 00:38:17 +0000 Subject: [PATCH 13/18] Fire pointerDown and pointerUp reactions The triggers existed in the API but nothing fired them, so press and hold (start on down, cancel on release) couldn't be built. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FeVQ6NCLHS9Vrfxhii4Mj8 --- CHANGELOG.md | 2 + .../widget_node_transformer_manager.dart | 46 ++++++++++++ test/pointer_reactions_test.dart | 71 +++++++++++++++++++ 3 files changed, 119 insertions(+) create mode 100644 test/pointer_reactions_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index ae9fbd42..a65aebdd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ `changed` trigger with the index. - Repeating and mirrored linear, radial and sweep gradients. - A node's `pulse` fades its opacity in a loop (`PulseOpacity`). +- `pointerDown` and `pointerUp` reactions fire (a cancelled pointer counts + as up), so a press and hold can start on down and stop on release. - A dialog whose canvas holds a responsive layout fills the screen and shows the layout for the screen's width, not the canvas's design width. - On the design canvas, a formula that yields empty text shows nothing diff --git a/lib/src/transformers/widget_node_transformer_manager.dart b/lib/src/transformers/widget_node_transformer_manager.dart index 642fe165..d35049ec 100644 --- a/lib/src/transformers/widget_node_transformer_manager.dart +++ b/lib/src/transformers/widget_node_transformer_manager.dart @@ -230,6 +230,52 @@ abstract class WidgetNodeTransformerManager Widget wrapWithReaction(BuildContext context, BaseNode node, Widget widget) { if (node is! ReactionMixin) return widget; if (node case CanvasNode() || SpacerNode()) return widget; + return wrapWithPointerReactions( + context, + node, + _wrapWithTapReactions(context, node, widget), + ); + } + + /// Fires [node]'s pointer down and pointer up reactions, such as a press + /// and hold that starts on down and is cancelled on up. A cancelled + /// pointer (a scroll took over) counts as up. The pointer is only + /// listened to, so taps still reach the widget. + static Widget wrapWithPointerReactions( + BuildContext context, + BaseNode node, + Widget widget, + ) { + if (node is! ReactionMixin) return widget; + List reactionsFor(TriggerType type) => (node as ReactionMixin) + .reactions + .where( + (reaction) => + reaction.trigger.type == type && reaction.action.enabled, + ) + .toList(); + final List down = reactionsFor(TriggerType.pointerDown); + final List up = reactionsFor(TriggerType.pointerUp); + if (down.isEmpty && up.isEmpty) return widget; + void fire(TriggerType type, List reactions) { + if (reactions.isEmpty) return; + FunctionsRepository.triggerAction(context, type, reactions: reactions); + } + + return Listener( + behavior: HitTestBehavior.opaque, + onPointerDown: (_) => fire(TriggerType.pointerDown, down), + onPointerUp: (_) => fire(TriggerType.pointerUp, up), + onPointerCancel: (_) => fire(TriggerType.pointerUp, up), + child: widget, + ); + } + + Widget _wrapWithTapReactions( + BuildContext context, + BaseNode node, + Widget widget, + ) { if (node is CustomPropertiesMixin && node.handlesDefaultReactionsInternally) { // Node is a custom properties mixin and intends to handle reactions diff --git a/test/pointer_reactions_test.dart b/test/pointer_reactions_test.dart new file mode 100644 index 00000000..54beafe1 --- /dev/null +++ b/test/pointer_reactions_test.dart @@ -0,0 +1,71 @@ +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('pointer down and up fire their reactions, and taps still work', ( + tester, + ) async { + Reaction call(String name, TriggerType type) => Reaction( + action: CallFunctionAction(name: name), + trigger: TriggerModel(type: type), + ); + final RectangleNode button = RectangleNode( + id: 'hold', + name: 'Hold', + basicBoxLocal: NodeBox(0, 0, 200, 48), + reactions: [ + call('down', TriggerType.pointerDown), + call('up', TriggerType.pointerUp), + call('tap', TriggerType.click), + ], + )..parentID = kRootNode; + final List calls = []; + final CodelesslyContext codelesslyContext = CodelesslyContext.empty() + ..functions = { + for (final name in ['down', 'up', 'tap']) + name: (context, reference, params) => calls.add(name), + }; + final registry = NodeRegistry()..setNodes({button.id: button}); + final manager = PassiveNodeTransformerManager( + registry.getNodeByID, + (context, bounds, pageID, layoutID, canvasID) => const SizedBox(), + ); + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: codelesslyContext, + child: MaterialApp( + home: Material( + child: Align( + alignment: Alignment.topLeft, + child: Builder( + builder: (context) => manager.buildWidgetFromNode( + button, + context, + settings: const WidgetBuildSettings(debugLabel: 'test'), + ), + ), + ), + ), + ), + ), + ); + + final TestGesture gesture = await tester.startGesture( + const Offset(100, 24), + ); + await tester.pump(); + expect(calls, ['down']); + await tester.pump(const Duration(milliseconds: 1500)); + await gesture.up(); + await tester.pumpAndSettle(); + expect(calls.take(2), ['down', 'up']); + + calls.clear(); + await tester.tapAt(const Offset(100, 24)); + await tester.pumpAndSettle(); + expect(calls, containsAll(['down', 'up', 'tap'])); + }); +} From 01ecc2470a2def52cb625a882be86cb517baf556 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 00:43:10 +0000 Subject: [PATCH 14/18] Draw rectangular slider thumbs Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FeVQ6NCLHS9Vrfxhii4Mj8 --- CHANGELOG.md | 2 + .../passive_slider_transformer.dart | 91 ++++++++++++++++++- test/smoke_test.dart | 31 +++++++ 3 files changed, 119 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a65aebdd..0e87501a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ `changed` trigger with the index. - Repeating and mirrored linear, radial and sweep gradients. - A node's `pulse` fades its opacity in a loop (`PulseOpacity`). +- Rectangular slider thumbs (`RectSliderThumbShape`) with a border and a + grip line. - `pointerDown` and `pointerUp` reactions fire (a cancelled pointer counts as up), so a press and hold can start on down and stop on release. - A dialog whose canvas holds a responsive layout fills the screen and diff --git a/lib/src/transformers/node_transformers/passive_slider_transformer.dart b/lib/src/transformers/node_transformers/passive_slider_transformer.dart index 83434594..39bef552 100644 --- a/lib/src/transformers/node_transformers/passive_slider_transformer.dart +++ b/lib/src/transformers/node_transformers/passive_slider_transformer.dart @@ -175,11 +175,24 @@ class TransformerSlider extends StatelessWidget { inactiveTrackColor: node.properties.inactiveTrackColor .toFlutterColor(), thumbColor: node.properties.thumbColor.toFlutterColor(), - thumbShape: RoundSliderThumbShape( - enabledThumbRadius: node.properties.showThumb - ? node.properties.thumbRadius - : 0, - ), + thumbShape: switch (node.properties.thumbShape) { + _ when !node.properties.showThumb => const RoundSliderThumbShape( + enabledThumbRadius: 0, + ), + SliderThumbShapeEnum.circle => RoundSliderThumbShape( + enabledThumbRadius: node.properties.thumbRadius, + ), + SliderThumbShapeEnum.rectangle => RectSliderThumbShape( + size: Size( + node.properties.thumbWidth, + node.properties.thumbHeight, + ), + radius: node.properties.thumbCornerRadius, + borderColor: node.properties.thumbBorderColor?.toFlutterColor(), + borderWidth: node.properties.thumbBorderWidth, + gripColor: node.properties.thumbGripColor?.toFlutterColor(), + ), + }, trackShape: trackShape, // GappedSliderTrackShape requires a gap; 6 is the Material 3 // (2024) default. @@ -231,3 +244,71 @@ class TransformerSlider extends StatelessWidget { ); } } + +/// A rectangular slider thumb with rounded corners, an optional border and +/// an optional vertical grip line, filled with the theme's thumb color. +class RectSliderThumbShape extends SliderComponentShape { + final Size size; + final double radius; + final Color? borderColor; + final double borderWidth; + final Color? gripColor; + + const RectSliderThumbShape({ + required this.size, + this.radius = 2, + this.borderColor, + this.borderWidth = 2, + this.gripColor, + }); + + @override + Size getPreferredSize(bool isEnabled, bool isDiscrete) => size; + + @override + void paint( + PaintingContext context, + Offset center, { + required Animation activationAnimation, + required Animation enableAnimation, + required bool isDiscrete, + required TextPainter labelPainter, + required RenderBox parentBox, + required SliderThemeData sliderTheme, + required TextDirection textDirection, + required double value, + required double textScaleFactor, + required Size sizeWithOverflow, + }) { + final Canvas canvas = context.canvas; + final RRect box = RRect.fromRectAndRadius( + Rect.fromCenter(center: center, width: size.width, height: size.height), + Radius.circular(radius), + ); + canvas.drawRRect( + box.shift(const Offset(0, 2)), + Paint() + ..color = const Color(0x66000000) + ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 2), + ); + canvas.drawRRect( + box, + Paint()..color = sliderTheme.thumbColor ?? const Color(0xFF000000), + ); + if (borderColor != null && borderWidth > 0) { + canvas.drawRRect( + box.deflate(borderWidth / 2), + Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = borderWidth + ..color = borderColor!, + ); + } + if (gripColor != null) { + canvas.drawRect( + Rect.fromCenter(center: center, width: 2, height: size.height * 0.42), + Paint()..color = gripColor!, + ); + } + } +} diff --git a/test/smoke_test.dart b/test/smoke_test.dart index 99d8870a..c3320c5d 100644 --- a/test/smoke_test.dart +++ b/test/smoke_test.dart @@ -103,6 +103,37 @@ void main() { } }); + testWidgets('a rectangular slider thumb takes its size', (tester) async { + final node = SliderNode( + id: 's', + name: 's', + basicBoxLocal: NodeBox(0, 0, 300, 48), + value: 20, + properties: SliderProperties( + thumbShape: SliderThumbShapeEnum.rectangle, + thumbBorderColor: ColorRGBA.white, + thumbGripColor: ColorRGBA.white, + ), + ); + await tester.pumpWidget( + host( + TransformerSlider( + node: node, + value: 20, + onChanged: (_) {}, + settings: settings, + ), + ), + ); + expect(tester.takeException(), isNull); + final SliderThemeData theme = tester + .widget(find.byType(SliderTheme).last) + .data; + final shape = theme.thumbShape! as RectSliderThumbShape; + expect(shape.getPreferredSize(true, false), const Size(18, 24)); + expect(shape.gripColor, isNotNull); + }); + testWidgets('badge renders every type, alone and on a child', (tester) async { final child = IconNode( id: 'icon', From e861b458cd6bbf62a3325518e39075ea4a7ff816 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 00:46:13 +0000 Subject: [PATCH 15/18] Toast accent edge, corner radius and max width Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FeVQ6NCLHS9Vrfxhii4Mj8 --- CHANGELOG.md | 2 ++ lib/src/functions/app_toast.dart | 9 ++++-- test/toast_test.dart | 52 ++++++++++++++++++++++++++++++++ 3 files changed, 61 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e87501a..0fd46149 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ `changed` trigger with the index. - Repeating and mirrored linear, radial and sweep gradients. - A node's `pulse` fades its opacity in a loop (`PulseOpacity`). +- Toasts take an accent edge, corner radius and max width + (`ShowToastAction.accentColor`, `cornerRadius`, `maxWidth`). - Rectangular slider thumbs (`RectSliderThumbShape`) with a border and a grip line. - `pointerDown` and `pointerUp` reactions fire (a cancelled pointer counts diff --git a/lib/src/functions/app_toast.dart b/lib/src/functions/app_toast.dart index 51faed5e..306d991f 100644 --- a/lib/src/functions/app_toast.dart +++ b/lib/src/functions/app_toast.dart @@ -188,14 +188,19 @@ class _ToastViewState extends State<_ToastView> { decoration: TextDecoration.none, ); + final Color? accent = action.accentColor?.toFlutterColor(); Widget toast = Container( - constraints: const BoxConstraints(minHeight: 44, maxWidth: 560), + constraints: BoxConstraints(minHeight: 44, maxWidth: action.maxWidth), padding: EdgeInsets.fromLTRB(18, 10, hasButton ? 8 : 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), ), + border: accent == null + ? null + : BorderDirectional(start: BorderSide(color: accent, width: 4)), boxShadow: const [ BoxShadow( color: Color(0x33000000), diff --git a/test/toast_test.dart b/test/toast_test.dart index 940850e8..7eaab74f 100644 --- a/test/toast_test.dart +++ b/test/toast_test.dart @@ -65,4 +65,56 @@ void main() { await tester.pump(const Duration(milliseconds: 300)); expect(find.text('Second'), findsNothing); }); + + testWidgets('a toast takes its accent edge, radius and width', ( + tester, + ) async { + late BuildContext context; + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: CodelesslyContext.empty(), + child: MaterialApp( + home: Builder( + builder: (c) { + context = c; + return const SizedBox(); + }, + ), + ), + ), + ); + FunctionsRepository.triggerAction( + context, + TriggerType.click, + reactions: [ + Reaction( + action: ShowToastAction( + message: 'Alert rules saved', + duration: 1000, + accentColor: const ColorRGBA(r: 1, g: 0.83, b: 0, a: 1), + cornerRadius: 4, + maxWidth: 460, + ), + trigger: TriggerModel(type: TriggerType.click), + ), + ], + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + expect(tester.takeException(), isNull); + final Container plate = tester.widget( + find + .ancestor( + of: find.text('Alert rules saved'), + matching: find.byType(Container), + ) + .first, + ); + final BoxDecoration decoration = plate.decoration! as BoxDecoration; + expect(decoration.borderRadius, BorderRadius.circular(4)); + final border = decoration.border! as BorderDirectional; + expect(border.start.width, 4); + expect(plate.constraints!.maxWidth, 460); + await tester.pump(const Duration(seconds: 2)); + }); } From abe44b911d7b8016482af150e0ac43455e3086fb Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 00:51:54 +0000 Subject: [PATCH 16/18] Bindable obscureText; fixed-height password fields obscure A one-line text field with a fixed height expanded to fill it, which drops the line limit; Flutter only obscures one-line fields, so the text showed. obscureText also reads a variable binding or condition, so a show/hide toggle drives one field. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FeVQ6NCLHS9Vrfxhii4Mj8 --- CHANGELOG.md | 3 + .../passive_text_field_transformer.dart | 34 +++++++--- test/text_field_obscure_binding_test.dart | 63 +++++++++++++++++++ 3 files changed, 91 insertions(+), 9 deletions(-) create mode 100644 test/text_field_obscure_binding_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 0fd46149..f97ec2a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ `changed` trigger with the index. - Repeating and mirrored linear, radial and sweep gradients. - A node's `pulse` fades its opacity in a loop (`PulseOpacity`). +- A text field's `obscureText` can be bound to a variable or condition, and a + one-line field of fixed height obscures its text (it expanded to its + height, which dropped the line limit and so the obscuring). - Toasts take an accent edge, corner radius and max width (`ShowToastAction.accentColor`, `cornerRadius`, `maxWidth`). - Rectangular slider thumbs (`RectSliderThumbShape`) with a border and a 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 54f7a67d..06f6ed56 100644 --- a/lib/src/transformers/node_transformers/passive_text_field_transformer.dart +++ b/lib/src/transformers/node_transformers/passive_text_field_transformer.dart @@ -283,8 +283,6 @@ class _PassiveTextFieldWidgetState extends State { final bool enabled = context.getNodeValue(node.id, 'enabled') ?? properties.enabled; - final bool obscureText = - context.getNodeValue(node.id, 'obscureText') ?? properties.obscureText; final bool readOnly = context.getNodeValue(node.id, 'readOnly') ?? properties.readOnly; final bool showCursor = @@ -293,6 +291,15 @@ class _PassiveTextFieldWidgetState extends State { context.getNodeValue(node.id, 'maxLength') ?? properties.maxLength; final ScopedValues scopedValues = ScopedValues.of(context); + // Bindable, e.g. to a show / hide toggle: a variable, a condition or + // a node value set by an action. + final bool obscureText = + PropertyValueDelegate.getPropertyValue( + node, + 'obscureText', + scopedValues: scopedValues, + ) ?? + properties.obscureText; final TextInputType keyboardType = properties.keyboardType == TextInputTypeEnum.number @@ -310,11 +317,20 @@ class _PassiveTextFieldWidgetState extends State { !widget.settings.isPreview && (widget.autofocus ?? properties.autoFocus); - final (int? maxLines, int? minLines) = resolveTextFieldLines( - maxLines: properties.maxLines, - minLines: properties.minLines, - expands: !widget.node.isVerticalWrap, - ); + // An obscured field is one line (Flutter allows no other). A one-line + // field of fixed height would expand to its height, which drops the + // line limit and so the obscuring; it stays one line instead. + final bool singleLine = + properties.maxLines == 1 && (properties.minLines ?? 1) <= 1; + final bool obscure = obscureText && singleLine; + final bool expands = !widget.node.isVerticalWrap && !obscure; + final (int? maxLines, int? minLines) = obscure + ? (1, null) + : resolveTextFieldLines( + maxLines: properties.maxLines, + minLines: properties.minLines, + expands: expands, + ); Widget field = TextFormField( focusNode: focusNode, @@ -323,7 +339,7 @@ class _PassiveTextFieldWidgetState extends State { enableInteractiveSelection: properties.enableInteractiveSelection, enabled: enabled, controller: controller, - obscureText: maxLines == 1 && obscureText, + obscureText: obscure, readOnly: readOnly, showCursor: showCursor, keyboardType: keyboardType, @@ -352,7 +368,7 @@ class _PassiveTextFieldWidgetState extends State { ], maxLines: maxLines, minLines: minLines, - expands: !widget.node.isVerticalWrap, + expands: expands, obscuringCharacter: widget.node.properties.obscuringCharacter, style: TextUtils.retrieveTextStyleFromProp( widget.node.properties.inputStyle, diff --git a/test/text_field_obscure_binding_test.dart b/test/text_field_obscure_binding_test.dart new file mode 100644 index 00000000..47b5d8c4 --- /dev/null +++ b/test/text_field_obscure_binding_test.dart @@ -0,0 +1,63 @@ +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('obscureText follows a bound variable', (tester) async { + final TextFieldNode node = + TextFieldNode( + id: 'pin', + name: 'PIN', + basicBoxLocal: NodeBox(0, 0, 200, 48), + properties: TextFieldProperties(obscureText: true, maxLines: 1), + ) + ..parentID = kRootNode + ..variables = {'obscureText': r'${= !pinVisible}'}; + final Observable pinVisible = Observable( + VariableData( + id: 'v', + name: 'pinVisible', + value: false, + type: VariableType.boolean, + ), + ); + final CodelesslyContext ctx = CodelesslyContext.empty() + ..variables['v'] = pinVisible; + 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: ctx, + child: MaterialApp( + home: Material( + child: Builder( + builder: (context) => manager.buildWidgetFromNode( + node, + context, + settings: const WidgetBuildSettings(debugLabel: 'test'), + ), + ), + ), + ), + ), + ); + bool obscured() => + tester.widget(find.byType(TextField)).obscureText; + expect(obscured(), isTrue); + pinVisible.set( + VariableData( + id: 'v', + name: 'pinVisible', + value: true, + type: VariableType.boolean, + ), + ); + await tester.pump(); + expect(obscured(), isFalse); + }); +} From 78df8be4bbcf554c1998a283aec3d7fa89ad9ef7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 00:52:36 +0000 Subject: [PATCH 17/18] Anchored dialogs open as popovers next to their trigger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FeVQ6NCLHS9Vrfxhii4Mj8 --- CHANGELOG.md | 3 + lib/src/functions/functions_repository.dart | 70 ++- .../passive_canvas_transformer.dart | 9 +- lib/src/ui/codelessly_dialog_widget.dart | 18 +- lib/src/ui/codelessly_popover.dart | 252 ++++++++++ test/canvas_viewport_test.dart | 18 + test/popover_dialog_test.dart | 468 ++++++++++++++++++ 7 files changed, 820 insertions(+), 18 deletions(-) create mode 100644 lib/src/ui/codelessly_popover.dart create mode 100644 test/popover_dialog_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index f97ec2a8..617c3b7c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ `changed` trigger with the index. - Repeating and mirrored linear, radial and sweep gradients. - A node's `pulse` fades its opacity in a loop (`PulseOpacity`). +- A dialog anchored to its trigger opens as a popover next to the node that + ran the action, flipping to the other side when it doesn't fit and kept + inside the safe area (`CanvasSizing.content`). - A text field's `obscureText` can be bound to a variable or condition, and a one-line field of fixed height obscures its text (it expanded to its height, which dropped the line limit and so the obscuring). diff --git a/lib/src/functions/functions_repository.dart b/lib/src/functions/functions_repository.dart index 650c8f34..b180f927 100644 --- a/lib/src/functions/functions_repository.dart +++ b/lib/src/functions/functions_repository.dart @@ -13,6 +13,7 @@ import 'package:url_launcher/url_launcher.dart'; import '../../codelessly_sdk.dart'; import '../logging/stat_tracker.dart'; import '../ui/codelessly_dialog_widget.dart'; +import '../ui/codelessly_popover.dart'; import 'action_chain.dart'; import 'app_auth.dart'; import 'cloud_files.dart'; @@ -571,6 +572,10 @@ class FunctionsRepository { barrierDismissible: action.barrierDismissible, barrierColor: action.barrierColor?.toFlutterColor(), showCloseButton: action.showCloseButton, + anchor: action.anchor, + placement: action.placement, + anchorGap: action.anchorGap, + anchorOffset: action.anchorOffset, ); } @@ -583,6 +588,11 @@ class FunctionsRepository { /// 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. + /// + /// With [anchor] set to [DialogAnchor.trigger] the dialog is a popover next + /// to [context]'s widget, the node that ran the action, at [placement] + /// (see [CodelesslyPopover]). It is centered when that widget has no laid + /// out box. static Future showLayoutDialog( BuildContext context, { required String canvasId, @@ -590,6 +600,10 @@ class FunctionsRepository { bool barrierDismissible = true, Color? barrierColor, bool showCloseButton = false, + DialogAnchor anchor = DialogAnchor.none, + DialogPlacement placement = DialogPlacement.belowStart, + double anchorGap = 4, + double anchorOffset = 0, }) async { final ScopedValues scopedValues = ActionChain.scopedValuesOf(context); final parsedParams = substituteVariablesInMap(params, scopedValues); @@ -632,21 +646,51 @@ class FunctionsRepository { // A chain triggered in the dialog can finish on this layout after the // dialog closes (e.g. Navigate back, then a toast). final List openerHosts = ActionChainOpener.hostsFor(context); - final Object? result = await showDialog( - context: context, - useRootNavigator: false, - barrierDismissible: barrierDismissible, - barrierColor: barrierColor, - routeSettings: RouteSettings(arguments: parsedParams), - builder: (context) => ActionChainOpener( - openerHosts: openerHosts, - child: CodelesslyDialogWidget( - showCloseButton: showCloseButton, - builder: (context) => - CodelesslyWidget(codelessly: codelessly, layoutID: layoutId), - ), + // The context an action runs with is the widget of the node that ran it, + // which an anchored dialog opens next to. + final Rect? anchorRect = anchor == DialogAnchor.trigger + ? CodelesslyPopover.anchorRectOf( + context, + Navigator.of(context, rootNavigator: false), + ) + : null; + if (anchor == DialogAnchor.trigger && anchorRect == null) { + DebugLogger.instance.printInfo( + 'The trigger of the dialog [$canvasId] has no laid out box. Showing ' + 'the dialog centered.', + name: name, + ); + } + Widget dialog(BuildContext context) => ActionChainOpener( + openerHosts: openerHosts, + child: CodelesslyDialogWidget( + showCloseButton: showCloseButton, + anchored: anchorRect != null, + builder: (context) => + CodelesslyWidget(codelessly: codelessly, layoutID: layoutId), ), ); + final RouteSettings routeSettings = RouteSettings(arguments: parsedParams); + final Object? result = anchorRect != null + ? await CodelesslyPopover.show( + context: context, + anchorRect: anchorRect, + placement: placement, + gap: anchorGap, + offset: anchorOffset, + barrierDismissible: barrierDismissible, + barrierColor: barrierColor, + routeSettings: routeSettings, + builder: dialog, + ) + : await showDialog( + context: context, + useRootNavigator: false, + barrierDismissible: barrierDismissible, + barrierColor: barrierColor, + routeSettings: routeSettings, + builder: dialog, + ); if (context.mounted) { codelessly.notifyNavigationListeners( context, diff --git a/lib/src/transformers/node_transformers/passive_canvas_transformer.dart b/lib/src/transformers/node_transformers/passive_canvas_transformer.dart index 52e3c123..d3ad7217 100644 --- a/lib/src/transformers/node_transformers/passive_canvas_transformer.dart +++ b/lib/src/transformers/node_transformers/passive_canvas_transformer.dart @@ -455,6 +455,12 @@ enum CanvasSizing { /// given. Used where the host only centers the layout and lets it choose /// its size, such as a dialog. design, + + /// Like [design], and a canvas holding a responsive layout keeps its + /// design size too instead of filling the space. Used for a popover next + /// to the node that opened it, which is as big as it was designed (or as + /// tall as its content) wherever it opens. + content, } /// Sets the [CanvasSizing] of the canvases below it. Without one, canvases @@ -540,7 +546,8 @@ class CanvasViewport extends StatelessWidget { node.verticalFit == SizeFit.shrinkWrap && node.scaleMode == ScaleMode.responsive && !node.isAScaffold; - if (sizing == CanvasSizing.design && !responsive) { + if ((sizing == CanvasSizing.design && !responsive) || + sizing == CanvasSizing.content) { final double width = constraints.constrainWidth(node.basicBoxLocal.width); if (contentHeight) return (width: width, height: null); final double height = node.scaleMode == ScaleMode.autoScale diff --git a/lib/src/ui/codelessly_dialog_widget.dart b/lib/src/ui/codelessly_dialog_widget.dart index 86a735bc..8f72f4e6 100644 --- a/lib/src/ui/codelessly_dialog_widget.dart +++ b/lib/src/ui/codelessly_dialog_widget.dart @@ -1,16 +1,24 @@ import 'package:flutter/material.dart'; import '../../codelessly_sdk.dart'; +import 'codelessly_popover.dart'; /// A widget that wraps a canvas widget and shows it in a dialog. class CodelesslyDialogWidget extends StatelessWidget { final WidgetBuilder builder; final bool showCloseButton; + /// Whether the dialog is a popover next to the node that opened it (see + /// [CodelesslyPopover]) rather than centered on the screen. Its route + /// positions it, and its canvas keeps its design size even when it holds + /// a responsive layout. + final bool anchored; + const CodelesslyDialogWidget({ super.key, required this.builder, required this.showCloseButton, + this.anchored = false, }); @override @@ -18,15 +26,17 @@ class CodelesslyDialogWidget extends StatelessWidget { // A dialog is centered and sized by its layout, so its canvas keeps its // design size instead of filling the screen. return CanvasSizingScope( - sizing: CanvasSizing.design, + sizing: anchored ? CanvasSizing.content : CanvasSizing.design, child: Builder(builder: _buildDialog), ); } + Widget _center(Widget child) => anchored ? child : Center(child: child); + Widget _buildDialog(BuildContext context) { if (showCloseButton) { - return Center( - child: Material( + return _center( + Material( type: MaterialType.transparency, child: Stack( children: [ @@ -49,6 +59,6 @@ class CodelesslyDialogWidget extends StatelessWidget { ); } - return Center(child: builder(context)); + return _center(builder(context)); } } diff --git a/lib/src/ui/codelessly_popover.dart b/lib/src/ui/codelessly_popover.dart new file mode 100644 index 00000000..6a74234d --- /dev/null +++ b/lib/src/ui/codelessly_popover.dart @@ -0,0 +1,252 @@ +import 'dart:math' as math; + +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:flutter/material.dart'; + +/// Shows a dialog next to a trigger widget, like a popover or a menu, for a +/// [ShowDialogAction] whose [ShowDialogAction.anchor] is +/// [DialogAnchor.trigger]. +class CodelesslyPopover { + CodelesslyPopover._(); + + /// The space kept between a popover and the screen edges (after the safe + /// area). + static const double screenMargin = 8; + + /// The rect [trigger]'s widget covers in [navigator]'s overlay, where a + /// popover route lays out, or null when it has no laid out render box. + /// + /// Transforms between them (a scaled autoScale canvas, for example) are + /// applied, so the rect is the one the trigger shows on screen. + static Rect? anchorRectOf(BuildContext trigger, NavigatorState navigator) { + final RenderObject? object = trigger.findRenderObject(); + final RenderObject? overlay = navigator.overlay?.context.findRenderObject(); + if (object is! RenderBox || + !object.attached || + !object.hasSize || + overlay is! RenderBox || + !overlay.attached) { + return null; + } + return MatrixUtils.transformRect( + object.getTransformTo(overlay), + Offset.zero & object.size, + ); + } + + /// Shows [builder]'s widget next to [anchorRect] (in the overlay of the + /// navigator [useRootNavigator] picks, see [anchorRectOf]) at [placement], + /// [gap] away from it and shifted along its edge by [offset]. + /// + /// The popover is a dialog route: tapping the barrier dismisses it when + /// [barrierDismissible], and [Navigator.pop] closes it with a result. + static Future show({ + required BuildContext context, + required Rect anchorRect, + required WidgetBuilder builder, + DialogPlacement placement = DialogPlacement.belowStart, + double gap = 4, + double offset = 0, + bool barrierDismissible = true, + Color? barrierColor, + bool useRootNavigator = false, + RouteSettings? routeSettings, + }) { + final NavigatorState navigator = Navigator.of( + context, + rootNavigator: useRootNavigator, + ); + // The route sits outside the widgets that opened it; take their themes + // along as a dialog does. + final CapturedThemes themes = InheritedTheme.capture( + from: context, + to: navigator.context, + ); + final TextDirection textDirection = + Directionality.maybeOf(context) ?? TextDirection.ltr; + return showGeneralDialog( + context: context, + useRootNavigator: useRootNavigator, + barrierDismissible: barrierDismissible, + barrierLabel: MaterialLocalizations.of(context).modalBarrierDismissLabel, + barrierColor: barrierColor ?? Colors.black54, + routeSettings: routeSettings, + transitionDuration: const Duration(milliseconds: 120), + pageBuilder: (context, animation, secondaryAnimation) { + final EdgeInsets padding = + MediaQuery.paddingOf(context) + const EdgeInsets.all(screenMargin); + return CustomSingleChildLayout( + delegate: CodelesslyPopoverLayout( + anchor: anchorRect, + placement: placement, + gap: gap, + offset: offset, + textDirection: textDirection, + padding: padding, + ), + child: themes.wrap(Builder(builder: builder)), + ); + }, + transitionBuilder: (context, animation, secondaryAnimation, child) => + FadeTransition( + opacity: CurvedAnimation(parent: animation, curve: Curves.easeOut), + child: child, + ), + ); + } +} + +/// Lays a popover out next to its [anchor]: on [placement]'s side, [gap] +/// away, and lined up with the anchor as [placement] says, shifted along the +/// anchor's edge by [offset]. +/// +/// When the popover does not fit on its side, it goes on the opposite side +/// if that has more room. It is then kept inside the screen, minus +/// [padding]. +class CodelesslyPopoverLayout extends SingleChildLayoutDelegate { + /// The trigger's rect, in this layout's coordinates. + final Rect anchor; + + /// The side the popover opens on and how it lines up with [anchor]. + final DialogPlacement placement; + + /// The space between [anchor] and the popover. + final double gap; + + /// The shift along [anchor]'s edge: toward the end for a placement below + /// or above (right in a left-to-right app), down for one beside it. + final double offset; + + /// The reading direction start and end follow. + final TextDirection textDirection; + + /// The space kept free at the screen edges. + final EdgeInsets padding; + + const CodelesslyPopoverLayout({ + required this.anchor, + required this.placement, + this.gap = 4, + this.offset = 0, + this.textDirection = TextDirection.ltr, + this.padding = EdgeInsets.zero, + }); + + @override + BoxConstraints getConstraintsForChild(BoxConstraints constraints) { + final Size biggest = constraints.biggest; + return BoxConstraints.loose( + Size( + math.max(0, biggest.width - padding.horizontal), + math.max(0, biggest.height - padding.vertical), + ), + ); + } + + @override + Offset getPositionForChild(Size size, Size childSize) { + final Rect bounds = Rect.fromLTRB( + padding.left, + padding.top, + math.max(padding.left, size.width - padding.right), + math.max(padding.top, size.height - padding.bottom), + ); + final _Side side = _sideOf(placement); + final _Align align = _alignOf(placement); + double x; + double y; + + if (placement.isVertical) { + bool below = side == _Side.below; + final double spaceBelow = bounds.bottom - anchor.bottom - gap; + final double spaceAbove = anchor.top - gap - bounds.top; + if (below && childSize.height > spaceBelow && spaceAbove > spaceBelow) { + below = false; + } else if (!below && + childSize.height > spaceAbove && + spaceBelow > spaceAbove) { + below = true; + } + y = below ? anchor.bottom + gap : anchor.top - gap - childSize.height; + + final bool rtl = textDirection == TextDirection.rtl; + x = switch (align) { + _Align.start => rtl ? anchor.right - childSize.width : anchor.left, + _Align.center => anchor.center.dx - childSize.width / 2, + _Align.end => rtl ? anchor.left : anchor.right - childSize.width, + }; + x += rtl ? -offset : offset; + } else { + bool right = side == _Side.right; + final double spaceRight = bounds.right - anchor.right - gap; + final double spaceLeft = anchor.left - gap - bounds.left; + if (right && childSize.width > spaceRight && spaceLeft > spaceRight) { + right = false; + } else if (!right && + childSize.width > spaceLeft && + spaceRight > spaceLeft) { + right = true; + } + x = right ? anchor.right + gap : anchor.left - gap - childSize.width; + y = switch (align) { + _Align.start => anchor.top, + _Align.center => anchor.center.dy - childSize.height / 2, + _Align.end => anchor.bottom - childSize.height, + }; + y += offset; + } + + x = x.clamp( + bounds.left, + math.max(bounds.left, bounds.right - childSize.width), + ); + y = y.clamp( + bounds.top, + math.max(bounds.top, bounds.bottom - childSize.height), + ); + return Offset(x, y); + } + + @override + bool shouldRelayout(CodelesslyPopoverLayout oldDelegate) => + anchor != oldDelegate.anchor || + placement != oldDelegate.placement || + gap != oldDelegate.gap || + offset != oldDelegate.offset || + textDirection != oldDelegate.textDirection || + padding != oldDelegate.padding; +} + +enum _Side { below, above, right, left } + +enum _Align { start, center, end } + +_Side _sideOf(DialogPlacement placement) => switch (placement) { + DialogPlacement.belowStart || + DialogPlacement.belowCenter || + DialogPlacement.belowEnd => _Side.below, + DialogPlacement.aboveStart || + DialogPlacement.aboveCenter || + DialogPlacement.aboveEnd => _Side.above, + DialogPlacement.rightStart || + DialogPlacement.rightCenter || + DialogPlacement.rightEnd => _Side.right, + DialogPlacement.leftStart || + DialogPlacement.leftCenter || + DialogPlacement.leftEnd => _Side.left, +}; + +_Align _alignOf(DialogPlacement placement) => switch (placement) { + DialogPlacement.belowStart || + DialogPlacement.aboveStart || + DialogPlacement.rightStart || + DialogPlacement.leftStart => _Align.start, + DialogPlacement.belowCenter || + DialogPlacement.aboveCenter || + DialogPlacement.rightCenter || + DialogPlacement.leftCenter => _Align.center, + DialogPlacement.belowEnd || + DialogPlacement.aboveEnd || + DialogPlacement.rightEnd || + DialogPlacement.leftEnd => _Align.end, +}; diff --git a/test/canvas_viewport_test.dart b/test/canvas_viewport_test.dart index aa49c913..9f2ca9ff 100644 --- a/test/canvas_viewport_test.dart +++ b/test/canvas_viewport_test.dart @@ -39,4 +39,22 @@ void main() { ); expect((box.width, box.height), (1920.0, 1080.0)); }); + + test('a responsive popover canvas keeps its design size', () { + final CanvasNode menu = CanvasNode( + id: 'menu', + name: 'Menu', + basicBoxLocal: NodeBox(0, 0, 240, 160), + children: const [], + properties: CanvasProperties(bodyId: 'body'), + scaleMode: ScaleMode.responsive, + ); + final box = CanvasViewport.resolve( + menu, + const BoxConstraints(maxWidth: 1920, maxHeight: 1080), + sizing: CanvasSizing.content, + responsive: true, + ); + expect((box.width, box.height), (240.0, 160.0)); + }); } diff --git a/test/popover_dialog_test.dart b/test/popover_dialog_test.dart new file mode 100644 index 00000000..3211c2ff --- /dev/null +++ b/test/popover_dialog_test.dart @@ -0,0 +1,468 @@ +import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:codelessly_sdk/codelessly_sdk.dart'; +import 'package:codelessly_sdk/src/ui/codelessly_popover.dart'; +import 'package:codelessly_sdk/src/ui/layout_builder.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; + +const List screens = [Size(390, 844), Size(1440, 900)]; + +CanvasNode canvas( + String id, + String bodyId, { + double width = 390, + double height = 844, +}) => CanvasNode( + id: id, + name: id, + basicBoxLocal: NodeBox(0, 0, width, height), + children: [bodyId], + fills: [ + PaintModel.solid( + id: 'paint', + color: ColorRGB.white, + opacity: 1, + visible: true, + ), + ], + properties: CanvasProperties( + bodyId: bodyId, + safeArea: const SafeAreaModel.all(false), + ), +)..parentID = kRootNode; + +AutoPlaceholderNode body( + String id, + String canvasId, + List children, { + Size size = const Size(390, 844), + MainAxisAlignmentC mainAxisAlignment = MainAxisAlignmentC.start, + CrossAxisAlignmentC crossAxisAlignment = CrossAxisAlignmentC.start, +}) => AutoPlaceholderNode( + id: id, + name: id, + basicBoxLocal: NodeBox(0, 0, size.width, size.height), + children: children, + mainAxisAlignment: mainAxisAlignment, + crossAxisAlignment: crossAxisAlignment, + horizontalFit: SizeFit.expanded, + verticalFit: SizeFit.expanded, +)..parentID = canvasId; + +ButtonNode button(String id, String parent, String label, ActionModel action) => + ButtonNode( + id: id, + name: id, + basicBoxLocal: NodeBox(0, 0, 120, 40), + properties: ButtonProperties( + label: label, + buttonType: ButtonTypeEnum.filled, + labelStyle: TextProp(fontSize: 14), + ), + reactions: [ + Reaction( + action: action, + trigger: TriggerModel(type: TriggerType.click), + ), + ], + )..parentID = parent; + +SDKPublishLayout layoutOf(String id, List nodes) => SDKPublishLayout( + id: id, + pageId: 'page', + projectId: 'project', + canvases: { + nodes.first.id: {for (final node in nodes) node.id: node}, + }, + lastUpdated: DateTime(2026), + owner: 'owner', + teams: const {}, + users: const {}, + roles: const {}, + public: false, +); + +/// A 200x120 menu with a "Sign out" button that closes it. +SDKPublishLayout menuLayout() => layoutOf('menu', [ + canvas('menuCanvas', 'menuBody', width: 200, height: 120), + body('menuBody', 'menuCanvas', ['signOut'], size: const Size(200, 120)), + button( + 'signOut', + 'menuBody', + 'Sign out', + NavigationAction(navigationType: NavigationType.pop, destinationId: ''), + ), +]); + +/// The main screen, designed at [design]: an "Account" button that runs +/// [action], at the top start of the screen, or at its bottom end when +/// [atBottomEnd]. +SDKPublishLayout mainLayout( + ActionModel action, { + bool atBottomEnd = false, + Size design = const Size(390, 844), +}) => layoutOf('main', [ + canvas('main', 'mainBody', width: design.width, height: design.height), + body( + 'mainBody', + 'main', + ['account'], + size: design, + mainAxisAlignment: atBottomEnd + ? MainAxisAlignmentC.end + : MainAxisAlignmentC.start, + crossAxisAlignment: atBottomEnd + ? CrossAxisAlignmentC.end + : CrossAxisAlignmentC.start, + ), + button('account', 'mainBody', 'Account', action), +]); + +class _FakeCache extends Fake implements CacheManager {} + +class _FakeAuthManager extends Fake implements AuthManager { + @override + AuthData? get authData => AuthData( + authToken: 'token', + projectId: 'project', + timestamp: DateTime(2026), + owner: 'owner', + teams: const {}, + users: const {}, + roles: const {}, + ); +} + +class _FakeLocalRepository extends Fake implements LocalDataRepository {} + +class _FakeNetworkRepository extends Fake implements NetworkDataRepository {} + +class _FakeFirestore extends Fake implements FirebaseFirestore {} + +class _FakeLocalDatabase extends Fake implements LocalDatabase {} + +class _TestDataManager extends DataManager { + _TestDataManager(SDKPublishModel model) + : super( + 'test', + config: CodelesslyConfig(authToken: 'token'), + cacheManager: _FakeCache(), + authManager: _FakeAuthManager(), + networkDataRepository: _FakeNetworkRepository(), + localDataRepository: _FakeLocalRepository(), + firebaseFirestore: _FakeFirestore(), + errorLogger: ErrorLogger.instance, + publishModel: model, + ); + + @override + LocalDatabase get localDatabase => _FakeLocalDatabase(); +} + +/// Pumps [layouts.first] on a screen of [size]. +Future pumpLayouts( + WidgetTester tester, + List layouts, { + required Size size, +}) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = size; + addTearDown(tester.view.reset); + + final model = SDKPublishModel( + projectId: 'project', + owner: 'owner', + teams: const {}, + users: const {}, + roles: const {}, + public: false, + ); + for (final layout in layouts) { + model.layouts[layout.id] = layout; + } + final codelessly = Codelessly( + config: CodelesslyConfig(authToken: 'token'), + publishDataManager: _TestDataManager(model)..status = CStatus.loaded(), + )..status = CStatus.loaded(); + final SDKPublishLayout main = layouts.first; + await tester.pumpWidget( + MultiProvider( + providers: [ + ChangeNotifierProvider.value( + value: CodelesslyContext.empty(layoutID: main.id), + ), + Provider.value(value: codelessly), + ], + child: MaterialApp( + home: CodelesslyLayoutBuilder( + controller: CodelesslyWidgetController( + layoutID: main.id, + codelessly: codelessly, + ), + layout: main, + canvasId: main.canvases.keys.first, + ), + ), + ), + ); + await tester.pump(); +} + +Rect nodeRect(WidgetTester tester, String id) => + tester.getRect(find.byKey(ValueKey(id)).first); + +void main() { + group('an anchored dialog', () { + for (final Size size in screens) { + testWidgets('opens just below its trigger at ${size.width.toInt()}', ( + tester, + ) async { + final action = ShowDialogAction( + destinationId: 'menuCanvas', + anchor: DialogAnchor.trigger, + anchorGap: 6, + barrierColor: ColorRGBA.transparent, + ); + await pumpLayouts(tester, [ + mainLayout(action), + menuLayout(), + ], size: size); + final Rect trigger = nodeRect(tester, 'account'); + + await tester.tap(find.text('Account')); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + + final Rect menu = nodeRect(tester, 'menuCanvas'); + // The menu keeps its design size instead of filling the screen. + // On the wide screen the main layout is scaled up; the menu still + // opens at the trigger's scaled box. + expect(menu.width, moreOrLessEquals(200)); + expect(menu.height, moreOrLessEquals(120)); + expect(menu.top, moreOrLessEquals(trigger.bottom + 6)); + // Lined up with the trigger's start, kept inside the screen margin. + expect(menu.left, trigger.left.clamp(8, double.infinity)); + + // A "Navigate back" in the menu closes it. + await tester.tap(find.text('Sign out')); + await tester.pumpAndSettle(); + expect(find.text('Sign out'), findsNothing); + }); + } + + for (final Size size in screens) { + testWidgets('flips above its trigger near the bottom edge at ' + '${size.width.toInt()}', (tester) async { + final action = ShowDialogAction( + destinationId: 'menuCanvas', + anchor: DialogAnchor.trigger, + placement: DialogPlacement.belowEnd, + ); + await pumpLayouts(tester, [ + // Designed at the screen size, so the button sits on its bottom + // edge. + mainLayout(action, atBottomEnd: true, design: size), + menuLayout(), + ], size: size); + final Rect trigger = nodeRect(tester, 'account'); + expect(trigger.bottom, size.height); + + await tester.tap(find.text('Account')); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + + final Rect menu = nodeRect(tester, 'menuCanvas'); + expect(menu.width, moreOrLessEquals(200)); + expect(menu.height, moreOrLessEquals(120)); + expect(menu.bottom, moreOrLessEquals(trigger.top - 4)); + // End edges lined up, inside the screen margin. + expect(menu.right, size.width - CodelesslyPopover.screenMargin); + + // Tapping outside dismisses it. + await tester.tapAt(const Offset(20, 20)); + await tester.pumpAndSettle(); + expect(find.text('Sign out'), findsNothing); + }); + } + + testWidgets('opens under a tappable shape, such as an avatar', ( + tester, + ) async { + final action = ShowDialogAction( + destinationId: 'menuCanvas', + anchor: DialogAnchor.trigger, + placement: DialogPlacement.belowEnd, + anchorGap: 8, + ); + final RectangleNode header = RectangleNode( + id: 'header', + name: 'header', + basicBoxLocal: NodeBox(0, 0, 390, 60), + )..parentID = 'mainBody'; + final RectangleNode avatar = RectangleNode( + id: 'avatar', + name: 'avatar', + basicBoxLocal: NodeBox(0, 60, 40, 40), + reactions: [ + Reaction( + action: action, + trigger: TriggerModel(type: TriggerType.click), + ), + ], + )..parentID = 'mainBody'; + await pumpLayouts(tester, [ + layoutOf('main', [ + canvas('main', 'mainBody'), + body('mainBody', 'main', [ + 'header', + 'avatar', + ], crossAxisAlignment: CrossAxisAlignmentC.center), + header, + avatar, + ]), + menuLayout(), + ], size: const Size(390, 844)); + final Rect trigger = nodeRect(tester, 'avatar'); + expect(trigger.size, const Size(40, 40)); + + await tester.tap(find.byKey(const ValueKey('avatar')).first); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + + final Rect menu = nodeRect(tester, 'menuCanvas'); + expect(menu.top, moreOrLessEquals(trigger.bottom + 8)); + expect(menu.right, moreOrLessEquals(trigger.right)); + }); + + testWidgets('stays open on a barrier tap when not dismissible', ( + tester, + ) async { + final action = ShowDialogAction( + destinationId: 'menuCanvas', + anchor: DialogAnchor.trigger, + barrierDismissible: false, + ); + await pumpLayouts(tester, [ + mainLayout(action), + menuLayout(), + ], size: const Size(390, 844)); + await tester.tap(find.text('Account')); + await tester.pumpAndSettle(); + await tester.tapAt(const Offset(300, 700)); + await tester.pumpAndSettle(); + expect(find.text('Sign out'), findsOneWidget); + }); + }); + + for (final Size size in screens) { + testWidgets('a dialog without an anchor is still centered at ' + '${size.width.toInt()}', (tester) async { + final action = ShowDialogAction(destinationId: 'menuCanvas'); + await pumpLayouts(tester, [mainLayout(action), menuLayout()], size: size); + + await tester.tap(find.text('Account')); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + + final Rect menu = nodeRect(tester, 'menuCanvas'); + expect(menu.width, moreOrLessEquals(200)); + expect(menu.height, moreOrLessEquals(120)); + expect(menu.center.dx, moreOrLessEquals(size.width / 2)); + expect(menu.center.dy, moreOrLessEquals(size.height / 2)); + }); + } + + group('CodelesslyPopoverLayout', () { + const Size screen = Size(400, 800); + const Size child = Size(200, 100); + Offset place( + Rect anchor, + DialogPlacement placement, { + double offset = 0, + TextDirection textDirection = TextDirection.ltr, + }) => CodelesslyPopoverLayout( + anchor: anchor, + placement: placement, + gap: 4, + offset: offset, + textDirection: textDirection, + padding: const EdgeInsets.all(8), + ).getPositionForChild(screen, child); + + const Rect middle = Rect.fromLTWH(150, 400, 100, 40); + + test('places each side and alignment', () { + expect(place(middle, DialogPlacement.belowStart), const Offset(150, 444)); + expect( + place(middle, DialogPlacement.belowCenter), + const Offset(100, 444), + ); + expect(place(middle, DialogPlacement.belowEnd), const Offset(50, 444)); + expect(place(middle, DialogPlacement.aboveStart), const Offset(150, 296)); + expect( + place(const Rect.fromLTWH(20, 400, 40, 40), DialogPlacement.rightStart), + const Offset(64, 400), + ); + expect( + place( + const Rect.fromLTWH(340, 400, 40, 40), + DialogPlacement.leftCenter, + ), + const Offset(136, 370), + ); + }); + + test('start follows the reading direction and offset shifts along the ' + 'edge', () { + expect( + place( + middle, + DialogPlacement.belowStart, + textDirection: TextDirection.rtl, + ), + const Offset(50, 444), + ); + expect( + place(middle, DialogPlacement.belowStart, offset: 10), + const Offset(160, 444), + ); + }); + + test('flips to the side with more room and clamps inside the screen', () { + // No room below: opens above. + expect( + place( + const Rect.fromLTWH(150, 740, 100, 40), + DialogPlacement.belowStart, + ), + const Offset(150, 636), + ); + // No room above: opens below. + expect( + place( + const Rect.fromLTWH(150, 20, 100, 40), + DialogPlacement.aboveStart, + ), + const Offset(150, 64), + ); + // No room on the right: opens on the left. + expect( + place( + const Rect.fromLTWH(300, 400, 60, 40), + DialogPlacement.rightStart, + ), + const Offset(96, 400), + ); + // Past the right edge: pulled back inside the margin. + expect( + place( + const Rect.fromLTWH(330, 400, 60, 40), + DialogPlacement.belowStart, + ), + const Offset(192, 444), + ); + }); + }); +} From 795fe762862cd423cfce81a21026cd0a11801bcd Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 01:04:23 +0000 Subject: [PATCH 18/18] Render wrapping rows as Wrap Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FeVQ6NCLHS9Vrfxhii4Mj8 --- CHANGELOG.md | 2 + .../passive_row_column_transformer.dart | 85 ++++++++ .../passive_spacer_transformer.dart | 6 +- test/wrap_layout_test.dart | 193 ++++++++++++++++++ 4 files changed, 285 insertions(+), 1 deletion(-) create mode 100644 test/wrap_layout_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 617c3b7c..628eb1b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ `changed` trigger with the index. - Repeating and mirrored linear, radial and sweep gradients. - A node's `pulse` fades its opacity in a loop (`PulseOpacity`). +- A row with `wrap` renders as a `Wrap`: children keep their design size + and break onto new lines. - A dialog anchored to its trigger opens as a popover next to the node that ran the action, flipping to the other side when it doesn't fit and kept inside the safe area (`CanvasSizing.content`). 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 ec502800..1a0dbd62 100644 --- a/lib/src/transformers/node_transformers/passive_row_column_transformer.dart +++ b/lib/src/transformers/node_transformers/passive_row_column_transformer.dart @@ -1,5 +1,6 @@ import 'package:codelessly_api/codelessly_api.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart' show OverflowBoxFit; import '../../../codelessly_sdk.dart'; @@ -8,6 +9,33 @@ typedef WidgetInserter = Widget Function(Widget child); class PassiveRowColumnTransformer extends NodeWidgetTransformer { PassiveRowColumnTransformer(super.getNode, super.manager); + /// Whether [node] is a row that wraps its children into runs (see + /// [RowColumnNode.wrap]). It renders as a [Wrap] instead of a [Flex]. + static bool wraps(BaseNode node) => + node is RowColumnNode && node.wrapsChildren; + + /// The [WrapAlignment] of each run of a wrapping row. + static WrapAlignment wrapAlignment(MainAxisAlignmentC alignment) => + switch (alignment) { + MainAxisAlignmentC.start => WrapAlignment.start, + MainAxisAlignmentC.end => WrapAlignment.end, + MainAxisAlignmentC.center => WrapAlignment.center, + MainAxisAlignmentC.spaceBetween => WrapAlignment.spaceBetween, + MainAxisAlignmentC.spaceAround => WrapAlignment.spaceAround, + MainAxisAlignmentC.spaceEvenly => WrapAlignment.spaceEvenly, + }; + + /// The [WrapCrossAlignment] of children within a run. A [Wrap] can't + /// stretch or align baselines; both behave as start, as in the editor. + static WrapCrossAlignment wrapCrossAlignment(CrossAxisAlignmentC alignment) => + switch (alignment) { + CrossAxisAlignmentC.center => WrapCrossAlignment.center, + CrossAxisAlignmentC.end => WrapCrossAlignment.end, + CrossAxisAlignmentC.start || + CrossAxisAlignmentC.stretch || + CrossAxisAlignmentC.baseline => WrapCrossAlignment.start, + }; + static Widget buildRowColumnWidget( BaseNode rowColumnNode, { required List childrenWidgets, @@ -56,6 +84,22 @@ class PassiveRowColumnTransformer extends NodeWidgetTransformer { ), ); + // A wrapping row lays its children out in runs, like the editor's + // solver. It doesn't scroll and has no intrinsic cross measuring: a + // Wrap's runs already give it its thickness. + if (wraps(rowColumnNode)) { + final RowColumnNode wrapNode = rowColumnNode as RowColumnNode; + final Widget wrap = Wrap( + key: ValueKey(rowColumnNode.id), + alignment: wrapAlignment(wrapNode.mainAxisAlignment), + crossAxisAlignment: wrapCrossAlignment(wrapNode.crossAxisAlignment), + spacing: wrapNode.spacing, + runSpacing: wrapNode.runSpacing, + children: childrenWidgets, + ); + return _withOuterBox(wrap, outerBox); + } + final SizeFit mainFit = isRow ? rowColumnNode.horizontalFit : rowColumnNode.verticalFit; @@ -120,6 +164,12 @@ class PassiveRowColumnTransformer extends NodeWidgetTransformer { ); } + return _withOuterBox(res, outerBox); + } + + /// Puts [res] in the Row/Column's own box, [outerBox], unless the box + /// hugs on both axes. + static Widget _withOuterBox(Widget res, FitBox outerBox) { if (outerBox.width == null && outerBox.height == null && !outerBox.fillWidth && @@ -196,6 +246,14 @@ class PassiveRowColumnTransformer extends NodeWidgetTransformer { return childWidget; } + if (wraps(parentRowColumn)) { + return _wrapChildOfWrap( + node, + childWidget, + flexibleSpaceBackground: flexibleSpaceBackground, + ); + } + final AxisC mainAxis = ((parentRowColumn as RowColumnMixin).rowColumnType == RowColumnType.row ? AxisC.horizontal @@ -313,6 +371,33 @@ class PassiveRowColumnTransformer extends NodeWidgetTransformer { return childWidget; } + /// A child of a wrapping row keeps its natural size, as in the editor's + /// solver: a [Wrap] has no flex, so an expanded or flexible child is not + /// wrapped in [Expanded]/[Flexible] (which a [Wrap] can't hold), and its + /// own alignment does not apply. + /// + /// A child that fills the main axis would take the whole run width in a + /// [Wrap]; an unbounded main axis makes it take its design size instead + /// (see [FitBox]). + static Widget _wrapChildOfWrap( + BaseNode node, + Widget childWidget, { + WidgetInserter? flexibleSpaceBackground, + }) { + if (flexibleSpaceBackground != null) { + childWidget = flexibleSpaceBackground(childWidget); + } + if (node.horizontalFit == SizeFit.expanded) { + childWidget = OverflowBox( + maxWidth: double.infinity, + fit: OverflowBoxFit.deferToChild, + alignment: AlignmentDirectional.topStart, + child: childWidget, + ); + } + return childWidget; + } + @override Widget buildWidget( BaseNode node, diff --git a/lib/src/transformers/node_transformers/passive_spacer_transformer.dart b/lib/src/transformers/node_transformers/passive_spacer_transformer.dart index 2aa3138d..dc765a8c 100644 --- a/lib/src/transformers/node_transformers/passive_spacer_transformer.dart +++ b/lib/src/transformers/node_transformers/passive_spacer_transformer.dart @@ -21,7 +21,11 @@ class PassiveSpacerTransformer extends NodeWidgetTransformer { : verticalFit); final Widget spacerWidget; - if (mainFit.isFlex) { + if (PassiveRowColumnTransformer.wraps(parentNode)) { + // A wrapping row has no free space to share: the spacer takes its + // design length, as in the editor. + spacerWidget = SizedBox(width: node.outerBoxLocal.width); + } else if (mainFit.isFlex) { // Fills its share of the free space. Where the main axis is unbounded // there is none, and it keeps its design size instead (see // CodelesslyFlex and FitBox). diff --git a/test/wrap_layout_test.dart b/test/wrap_layout_test.dart new file mode 100644 index 00000000..9b9e01f5 --- /dev/null +++ b/test/wrap_layout_test.dart @@ -0,0 +1,193 @@ +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'; + +// Rows with `wrap` on render as a Wrap: children that don't fit the row's +// width start a new run, like the editor's layout solver lays them out. + +RectangleNode rect( + String id, { + double width = 50, + double height = 20, + SizeFit horizontalFit = SizeFit.fixed, + SizeFit verticalFit = SizeFit.fixed, +}) => RectangleNode( + id: id, + name: id, + basicBoxLocal: NodeBox(0, 0, width, height), + horizontalFit: horizontalFit, + verticalFit: verticalFit, +); + +RowColumnNode wrapRow( + List children, { + double width = 120, + double height = 20, + bool wrap = true, + double spacing = 10, + double runSpacing = 5, + RowColumnType type = RowColumnType.row, + SizeFit horizontalFit = SizeFit.fixed, + SizeFit verticalFit = SizeFit.shrinkWrap, + MainAxisAlignmentC mainAxisAlignment = MainAxisAlignmentC.start, + CrossAxisAlignmentC crossAxisAlignment = CrossAxisAlignmentC.start, +}) { + for (final child in children) { + child.parentID = 'row'; + } + return RowColumnNode( + id: 'row', + name: 'row', + basicBoxLocal: NodeBox(0, 0, width, height), + children: [for (final child in children) child.id], + rowColumnType: type, + horizontalFit: horizontalFit, + verticalFit: verticalFit, + mainAxisAlignment: mainAxisAlignment, + crossAxisAlignment: crossAxisAlignment, + wrap: wrap, + spacing: spacing, + runSpacing: runSpacing, + )..parentID = kRootNode; +} + +Finder rectOf(String id) => + find.byKey(ValueKey('Rectangle Transformer of $id [$id]')); + +/// Pumps [root] at the top-left of a 400x800 screen. Fails on any Flutter +/// error. +Future pump( + WidgetTester tester, + BaseNode root, + List nodes, +) async { + tester.view.physicalSize = const Size(400, 800); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + + final registry = NodeRegistry() + ..setNodes({ + for (final node in [root, ...nodes]) 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: Material( + child: Align( + alignment: Alignment.topLeft, + child: Builder( + builder: (context) => manager.buildWidgetFromNode( + root, + context, + settings: const WidgetBuildSettings(debugLabel: 'test'), + ), + ), + ), + ), + ), + ), + ); + expect(tester.takeException(), isNull); +} + +void main() { + testWidgets('a wrapping row puts children that do not fit on a new line', ( + tester, + ) async { + final children = [rect('a'), rect('b', height: 30), rect('c')]; + final row = wrapRow(children); + await pump(tester, row, children); + + expect(find.byType(Wrap), findsOneWidget); + final Wrap wrap = tester.widget(find.byType(Wrap)); + expect(wrap.spacing, 10); + expect(wrap.runSpacing, 5); + + final Offset origin = tester.getTopLeft(find.byType(Wrap)); + // a and b fit in 120 (50 + 10 + 50); c starts a second run below the + // 30 tall first run. + expect(tester.getTopLeft(rectOf('a')) - origin, Offset.zero); + expect(tester.getTopLeft(rectOf('b')) - origin, const Offset(60, 0)); + expect(tester.getTopLeft(rectOf('c')) - origin, const Offset(0, 35)); + + expect(tester.getSize(find.byType(Wrap)), const Size(120, 55)); + }); + + testWidgets('a wide enough wrapping row keeps one line', (tester) async { + final children = [rect('a'), rect('b'), rect('c')]; + final row = wrapRow(children, width: 300); + await pump(tester, row, children); + + final Offset origin = tester.getTopLeft(find.byType(Wrap)); + expect(tester.getTopLeft(rectOf('c')) - origin, const Offset(120, 0)); + expect(tester.getSize(find.byType(Wrap)).height, 20); + }); + + testWidgets('alignments map onto the Wrap', (tester) async { + final children = [rect('a'), rect('b'), rect('c')]; + final row = wrapRow( + children, + spacing: 0, + runSpacing: 0, + mainAxisAlignment: MainAxisAlignmentC.spaceBetween, + crossAxisAlignment: CrossAxisAlignmentC.stretch, + ); + await pump(tester, row, children); + + final Wrap wrap = tester.widget(find.byType(Wrap)); + expect(wrap.alignment, WrapAlignment.spaceBetween); + expect(wrap.crossAxisAlignment, WrapCrossAlignment.start); + expect(wrap.direction, Axis.horizontal); + }); + + testWidgets('expanded children and spacers keep their design size', ( + tester, + ) async { + final SpacerNode gap = SpacerNode( + id: 'gap', + name: 'gap', + basicBoxLocal: NodeBox(0, 0, 20, 20), + horizontalFit: SizeFit.expanded, + verticalFit: SizeFit.expanded, + ); + final children = [ + rect('a', width: 60, horizontalFit: SizeFit.expanded), + gap, + rect('b', width: 60, verticalFit: SizeFit.expanded), + ]; + final row = wrapRow(children, spacing: 0, runSpacing: 0); + await pump(tester, row, children); + + expect(tester.getSize(rectOf('a')), const Size(60, 20)); + expect(tester.getSize(rectOf('b')), const Size(60, 20)); + final Offset origin = tester.getTopLeft(find.byType(Wrap)); + // a (60) + gap (20) fit in 120; b starts the second run. + expect(tester.getTopLeft(rectOf('b')) - origin, const Offset(0, 20)); + }); + + testWidgets('wrap is off by default and ignored on columns', (tester) async { + final children = [rect('a'), rect('b')]; + await pump(tester, wrapRow(children, wrap: false), children); + expect(find.byType(Wrap), findsNothing); + + final columnChildren = [rect('c'), rect('d')]; + await pump( + tester, + wrapRow( + columnChildren, + type: RowColumnType.column, + verticalFit: SizeFit.fixed, + height: 100, + ), + columnChildren, + ); + expect(find.byType(Wrap), findsNothing); + }); +}