From 086dd9182e489bbf7807ffe7fbf1701e187d7698 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 22:06:26 +0000 Subject: [PATCH 01/22] Render gauge and bar chart nodes; run interval reactions PassiveGaugeWidget and PassiveBarChartWidget read their bound values, limits, lists and colors from conditions, variables and formulas. A canvas runs its interval reactions on one timer per period while it is shown, skipping a tick while the previous run is still going. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AjsYaRkTHNKszSHjd3j264 --- .../node_transformers/node_transformers.dart | 2 + .../passive_bar_chart_transformer.dart | 258 ++++++++++++ .../passive_canvas_transformer.dart | 78 ++++ .../passive_gauge_transformer.dart | 387 ++++++++++++++++++ .../passive_transformer_manager.dart | 2 + test/feedback_actions_test.dart | 15 + test/gauge_bar_chart_test.dart | 183 +++++++++ test/interval_trigger_test.dart | 107 +++++ 8 files changed, 1032 insertions(+) create mode 100644 lib/src/transformers/node_transformers/passive_bar_chart_transformer.dart create mode 100644 lib/src/transformers/node_transformers/passive_gauge_transformer.dart create mode 100644 test/gauge_bar_chart_test.dart create mode 100644 test/interval_trigger_test.dart diff --git a/lib/src/transformers/node_transformers/node_transformers.dart b/lib/src/transformers/node_transformers/node_transformers.dart index 8421a1e2..94c06deb 100644 --- a/lib/src/transformers/node_transformers/node_transformers.dart +++ b/lib/src/transformers/node_transformers/node_transformers.dart @@ -22,6 +22,8 @@ export 'passive_navigation_bar_transformer.dart'; export 'passive_page_view_transformer.dart'; export 'passive_placeholder_transformer.dart'; export 'passive_progressbar_transformer.dart'; +export 'passive_gauge_transformer.dart'; +export 'passive_bar_chart_transformer.dart'; export 'passive_radio_transformer.dart'; export 'passive_rectangle_transformer.dart'; export 'passive_row_column_transformer.dart'; diff --git a/lib/src/transformers/node_transformers/passive_bar_chart_transformer.dart b/lib/src/transformers/node_transformers/passive_bar_chart_transformer.dart new file mode 100644 index 00000000..17a4175e --- /dev/null +++ b/lib/src/transformers/node_transformers/passive_bar_chart_transformer.dart @@ -0,0 +1,258 @@ +import 'dart:convert'; + +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:flutter/material.dart'; + +import '../../../codelessly_sdk.dart'; + +class PassiveBarChartTransformer extends NodeWidgetTransformer { + PassiveBarChartTransformer(super.getNode, super.manager); + + @override + Widget buildWidget( + BarChartNode node, + BuildContext context, + WidgetBuildSettings settings, + ) { + return PassiveBarChartWidget(node: node, settings: settings); + } +} + +/// Renders a [BarChartNode], reading its bindable `values`, `labels`, +/// `highlight` and `color` from conditions and variables. +class PassiveBarChartWidget extends StatelessWidget { + final BarChartNode node; + final WidgetBuildSettings settings; + final List variablesOverrides; + + /// Overrides [BarChartProperties.animate], e.g. to hold still in the editor. + final bool? animate; + + const PassiveBarChartWidget({ + super.key, + required this.node, + required this.settings, + this.variablesOverrides = const [], + this.animate, + }); + + /// Reads a bound list: a list, or JSON text of one. + static List? _list(Object? value) { + if (value is List) return value; + if (value is String && value.trim().startsWith('[')) { + try { + final Object? decoded = jsonDecode(value); + if (decoded is List) return decoded; + } catch (_) {} + } + return null; + } + + static double? _number(Object? value) => switch (value) { + num() => value.toDouble(), + String() => double.tryParse(value.trim()), + _ => null, + }; + + @override + Widget build(BuildContext context) { + final ScopedValues scopedValues = ScopedValues.of( + context, + variablesOverrides: variablesOverrides, + ); + final BarChartProperties props = node.properties; + Object? bound(String property) => + PropertyValueDelegate.getPropertyValue( + node, + property, + scopedValues: scopedValues, + ); + + final List values = + _list(bound('values'))?.map(_number).toList() ?? node.values; + final List labels = + _list( + bound('labels'), + )?.map((e) => e == null ? '' : '$e').toList() ?? + node.labels; + final Object? boundHighlight = bound('highlight'); + final int? highlight = boundHighlight != null + ? _number(boundHighlight)?.toInt() + : (node.variables['highlight'] == null ? node.highlight : null); + final Color color = + PropertyValueDelegate.getPropertyValue( + node, + 'color', + scopedValues: scopedValues, + )?.toFlutterColor() ?? + props.color.toFlutterColor(); + + double max = props.max; + if (max <= 0) { + max = values.whereType().fold(0, (a, b) => b > a ? b : a); + } + final bool animated = animate ?? props.animate; + final bool hasLabels = labels.any((l) => l.isNotEmpty); + final bool gutter = props.gridLabels.isNotEmpty; + final double right = gutter ? props.gridLabelWidth : 0; + final TextStyle styled = TextUtils.retrieveTextStyle( + fontName: FontName( + family: props.labelFontFamily, + weight: FontWeightNumeric.w400, + ), + fontSize: props.labelFontSize, + color: props.labelColor.toFlutterColor(), + ).copyWith(height: 1.2); + + return AdaptiveNodeBox( + node: node, + child: LayoutBuilder( + builder: (context, constraints) { + final double labelHeight = hasLabels + ? props.labelFontSize * 1.2 + props.labelGap + : 0; + final double height = (constraints.maxHeight - labelHeight - 1) + .clamp(0, double.infinity); + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SizedBox( + height: height, + child: Stack( + clipBehavior: Clip.none, + children: [ + for (int g = 1; g <= props.gridLines; g++) ...[ + Positioned( + left: 0, + right: right, + bottom: height * g / props.gridLines - 0.5, + height: 1, + child: ColoredBox( + color: props.gridColor.toFlutterColor(), + ), + ), + if (gutter && g - 1 < props.gridLabels.length) + Positioned( + right: 0, + width: props.gridLabelWidth - 6, + bottom: height * g / props.gridLines - + props.labelFontSize / 2, + child: Text( + props.gridLabels[g - 1], + style: styled.copyWith(height: 1), + maxLines: 1, + softWrap: false, + ), + ), + ], + Positioned.fill( + right: right, + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + for (int i = 0; i < values.length; i++) ...[ + if (i > 0) SizedBox(width: props.barGap), + Expanded( + child: _bar( + values[i], + i, + max, + height, + highlight, + color, + animated, + ), + ), + ], + ], + ), + ), + ], + ), + ), + Container( + height: 1, + margin: EdgeInsets.only(right: right), + color: props.baselineColor.toFlutterColor(), + ), + if (hasLabels) ...[ + SizedBox(height: props.labelGap), + Padding( + padding: EdgeInsets.only(right: right), + child: Row( + children: [ + for (int i = 0; i < values.length; i++) ...[ + if (i > 0) SizedBox(width: props.barGap), + Expanded( + child: Text( + i < labels.length ? labels[i] : '', + textAlign: TextAlign.center, + maxLines: 1, + softWrap: false, + overflow: TextOverflow.visible, + style: i == highlight + ? styled.copyWith( + color: props.highlightLabelColor + .toFlutterColor(), + ) + : styled, + ), + ), + ], + ], + ), + ), + ], + ], + ); + }, + ), + ); + } + + Widget _bar( + double? value, + int index, + double max, + double height, + int? highlight, + Color color, + bool animated, + ) { + final BarChartProperties props = node.properties; + if (value == null) { + return Align( + alignment: Alignment.bottomCenter, + child: Container( + height: props.emptyHeight, + color: props.emptyColor.toFlutterColor(), + ), + ); + } + final double h = max <= 0 + ? 0 + : (height * (value / max).clamp(0.0, 1.0)).clamp( + props.emptyHeight, + height, + ); + final Color barColor = highlight == null || highlight < 0 || index == highlight + ? color + : color.withValues(alpha: color.a * props.dimmedOpacity); + return TweenAnimationBuilder( + tween: Tween(end: h), + duration: animated + ? Duration(milliseconds: props.animationDurationInMillis) + : Duration.zero, + curve: Curves.easeOutCubic, + builder: (context, hh, _) => Container( + height: hh, + decoration: BoxDecoration( + color: barColor, + borderRadius: BorderRadius.vertical( + top: Radius.circular(props.barRadius), + ), + ), + ), + ); + } +} diff --git a/lib/src/transformers/node_transformers/passive_canvas_transformer.dart b/lib/src/transformers/node_transformers/passive_canvas_transformer.dart index 9fe25753..f3ca31fa 100644 --- a/lib/src/transformers/node_transformers/passive_canvas_transformer.dart +++ b/lib/src/transformers/node_transformers/passive_canvas_transformer.dart @@ -666,8 +666,82 @@ class _PassiveCanvasWidgetState extends State { codelessly?.setSystemUIBrightness(widget.node.properties.brightness); } + /// Timers for the canvas's interval reactions, one per period. + final List _intervalTimers = []; + + /// Periods whose actions are still running, so a slow chain skips ticks + /// instead of piling up. + final Set _runningIntervals = {}; + + /// Interval actions grouped by period, in reaction order. + Map> collectIntervalActions() { + final Map> groups = {}; + for (final Reaction reaction in widget.node.reactions) { + if (reaction.trigger.type != TriggerType.interval) continue; + if (!reaction.action.enabled) continue; + groups + .putIfAbsent(reaction.trigger.effectiveIntervalMillis, () => []) + .add(reaction.action); + } + return groups; + } + + /// Starts (or restarts) a periodic timer for each interval period. Each + /// tick runs that period's actions in order on one chain, like the load + /// actions. + void startIntervals() { + stopIntervals(); + for (final MapEntry> group + in collectIntervalActions().entries) { + _intervalTimers.add( + Timer.periodic(Duration(milliseconds: group.key), (_) { + if (!mounted || _runningIntervals.contains(group.key)) return; + _runningIntervals.add(group.key); + ActionChain.run( + () => runActions(group.value), + context: context, + ).whenComplete(() => _runningIntervals.remove(group.key)); + }), + ); + } + } + + void stopIntervals() { + for (final Timer timer in _intervalTimers) { + timer.cancel(); + } + _intervalTimers.clear(); + _runningIntervals.clear(); + } + + /// Runs [actions] one after another, waiting on each blocking action. + Future runActions(List actions) async { + for (final ActionModel action in actions) { + // contextFor checks that the context it returns is mounted. + // ignore: use_build_context_synchronously + final BuildContext? actionContext = ActionChain.contextFor(context); + if (actionContext == null) return; + // ignore: use_build_context_synchronously + final result = FunctionsRepository.performAction(actionContext, action); + if (result is Future && !action.nonBlocking) { + try { + await result; + } catch (e, stackTrace) { + ErrorLogger.instance.captureException( + e, + message: 'Interval action failed', + type: 'interval_action_failed', + stackTrace: stackTrace, + ); + return; + } + } + } + } + @override void dispose() { + stopIntervals(); codelessly?.removeNavigationListener('canvas-${widget.node.id}'); _gateUser?.removeListener(_enforceSignIn); _stopWatchingValues(); @@ -758,6 +832,9 @@ class _PassiveCanvasWidgetState extends State { super.didUpdateWidget(oldWidget); if (widget.node != oldWidget.node) { onLoadActions = collectOnLoadActions(); + if (didPerformOnLoadActions && !widget.settings.isPreview) { + startIntervals(); + } } } @@ -779,6 +856,7 @@ class _PassiveCanvasWidgetState extends State { triggerOnLoadActions(context); _watchValues(context); + startIntervals(); } List collectOnLoadActions() => widget.node.reactions 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..2a43aa79 --- /dev/null +++ b/lib/src/transformers/node_transformers/passive_gauge_transformer.dart @@ -0,0 +1,387 @@ +import 'dart:math' as math; + +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:flutter/material.dart'; + +import '../../../codelessly_sdk.dart'; + +class PassiveGaugeTransformer extends NodeWidgetTransformer { + PassiveGaugeTransformer(super.getNode, super.manager); + + @override + Widget buildWidget( + GaugeNode node, + BuildContext context, + WidgetBuildSettings settings, + ) { + return PassiveGaugeWidget(node: node, settings: settings); + } +} + +/// Renders a [GaugeNode], reading its bindable `value`, `limit`, `color` +/// and `limitColor` from conditions and variables. +class PassiveGaugeWidget extends StatelessWidget { + final GaugeNode node; + final WidgetBuildSettings settings; + final List variablesOverrides; + + /// Overrides [GaugeProperties.animate], e.g. to hold still in the editor. + final bool? animate; + + const PassiveGaugeWidget({ + super.key, + required this.node, + required this.settings, + this.variablesOverrides = const [], + this.animate, + }); + + @override + Widget build(BuildContext context) { + final ScopedValues scopedValues = ScopedValues.of( + context, + variablesOverrides: variablesOverrides, + ); + final GaugeProperties props = node.properties; + final double value = + _number( + PropertyValueDelegate.getPropertyValue( + node, + 'value', + scopedValues: scopedValues, + ), + ) ?? + node.value; + final double? limit = + _number( + PropertyValueDelegate.getPropertyValue( + node, + 'limit', + scopedValues: scopedValues, + ), + ) ?? + (node.variables['limit'] == null ? node.limit : null); + final Color color = + PropertyValueDelegate.getPropertyValue( + node, + 'color', + scopedValues: scopedValues, + )?.toFlutterColor() ?? + props.color.toFlutterColor(); + final Color limitColor = + PropertyValueDelegate.getPropertyValue( + node, + 'limitColor', + scopedValues: scopedValues, + )?.toFlutterColor() ?? + props.limitColor.toFlutterColor(); + + final double range = props.max - props.min; + double fraction(double v) => + range == 0 ? 0 : ((v - props.min) / range).clamp(0.0, 1.0); + + final bool animated = animate ?? props.animate; + return AdaptiveNodeBox( + node: node, + child: TweenAnimationBuilder( + tween: Tween(end: fraction(value)), + duration: animated + ? Duration(milliseconds: props.animationDurationInMillis) + : Duration.zero, + curve: Curves.easeOutCubic, + builder: (context, v, _) => CustomPaint( + size: Size.infinite, + painter: GaugePainter( + props: props, + fraction: v, + limit: limit == null ? null : fraction(limit), + color: color, + limitColor: limitColor, + ), + ), + ), + ); + } +} + +/// Reads a number from a bound value: a number, or text that parses as one. +double? _number(Object? value) => switch (value) { + num() => value.toDouble(), + String() => double.tryParse(value.trim()), + _ => null, +}; + +/// Paints a [GaugeProperties] gauge filled to [fraction] (0 to 1), with an +/// optional limit marker at [limit] (0 to 1). +class GaugePainter extends CustomPainter { + final GaugeProperties props; + final double fraction; + final double? limit; + final Color color; + final Color limitColor; + + GaugePainter({ + required this.props, + required this.fraction, + required this.limit, + required this.color, + required this.limitColor, + }); + + static double _rad(double degrees) => degrees * math.pi / 180; + + @override + void paint(Canvas canvas, Size size) { + if (size.isEmpty) return; + switch (props.shape) { + case GaugeShape.arc: + _paintArc(canvas, size); + case GaugeShape.bar: + _paintBar(canvas, size); + } + } + + void _paintArc(Canvas canvas, Size size) { + final double d = math.min(size.width, size.height * 2); + final double stroke = props.thickness > 0 ? props.thickness : d * 0.075; + final Offset center = Offset(size.width / 2, d / 2); + final double r = d / 2 - stroke / 2 - d * 0.04; + final Rect rect = Rect.fromCircle(center: center, radius: r); + final double start = _rad(props.startAngle); + final double sweep = _rad(props.sweepAngle); + final Color track = props.trackColor.toFlutterColor(); + + if (props.segments > 0) { + final double seg = sweep / props.segments; + final double gap = _rad(props.segmentGap); + final int lit = (fraction * props.segments).round(); + for (int i = 0; i < props.segments; i++) { + canvas.drawArc( + rect, + start + i * seg + gap / 2, + math.max(0, seg - gap), + false, + Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = stroke + ..color = i < lit ? color : track, + ); + } + } else { + final Paint paint = Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = stroke; + canvas.drawArc(rect, start, sweep, false, paint..color = track); + if (fraction > 0) { + canvas.drawArc(rect, start, sweep * fraction, false, paint..color = color); + } + } + + if (props.ticks > 0) { + final Paint tick = Paint()..color = props.tickColor.toFlutterColor(); + for (int i = 0; i <= props.ticks; i++) { + final double a = start + sweep * i / props.ticks; + final bool major = + props.majorTickEvery > 0 && i % props.majorTickEvery == 0; + final double r1 = r - stroke / 2 - d * 0.03; + final double r2 = r1 - (major ? d * 0.05 : d * 0.025); + final Offset dir = Offset(math.cos(a), math.sin(a)); + tick.strokeWidth = major ? 2 : 1; + canvas.drawLine(center + dir * r1, center + dir * r2, tick); + } + } + + if (limit != null) { + final double a = start + sweep * limit!; + final Offset dir = Offset(math.cos(a), math.sin(a)); + final double outer = r + stroke / 2 + d * 0.035; + final double inner = r - stroke / 2 - d * 0.005; + canvas.drawLine( + center + dir * inner, + center + dir * outer, + Paint() + ..color = limitColor + ..strokeWidth = props.limitWidth + ..strokeCap = StrokeCap.square, + ); + } + } + + void _paintBar(Canvas canvas, Size size) { + final bool vertical = size.height > size.width; + final Radius radius = Radius.circular(props.cornerRadius); + final RRect well = RRect.fromRectAndRadius(Offset.zero & size, radius); + final Color background = props.backgroundColor.toFlutterColor(); + if (background.a > 0) canvas.drawRRect(well, Paint()..color = background); + + final double inset = props.inset; + final Rect inner = Rect.fromLTRB( + inset, + inset, + size.width - inset, + size.height - inset, + ); + final double length = vertical ? inner.height : inner.width; + final Color track = props.trackColor.toFlutterColor(); + + // The part of [inner] between fractions [from] and [to] along the bar. + Rect span(double from, double to) => vertical + ? Rect.fromLTRB( + inner.left, + inner.bottom - length * to, + inner.right, + inner.bottom - length * from, + ) + : Rect.fromLTRB( + inner.left + length * from, + inner.top, + inner.left + length * to, + inner.bottom, + ); + + Paint fillPaint(Rect rect) { + final Paint paint = Paint()..color = color; + if (props.fillStartOpacity < 1) { + final Color faded = color.withValues( + alpha: color.a * props.fillStartOpacity, + ); + paint.shader = LinearGradient( + begin: vertical ? Alignment.topCenter : Alignment.centerRight, + end: vertical ? Alignment.bottomCenter : Alignment.centerLeft, + colors: [color, faded], + ).createShader(rect); + } + return paint; + } + + if (props.segments > 0) { + final double gap = props.segmentGap; + final double seg = + (length - gap * (props.segments - 1)) / props.segments; + final int lit = (fraction * props.segments).round(); + final Radius segRadius = Radius.circular( + math.min(props.cornerRadius, seg / 2), + ); + for (int i = 0; i < props.segments; i++) { + final double from = (i * (seg + gap)) / length; + final double to = (i * (seg + gap) + seg) / length; + final Rect rect = span(from, to); + canvas.drawRRect( + RRect.fromRectAndRadius(rect, segRadius), + Paint()..color = i < lit ? color : track, + ); + } + } else { + if (track.a > 0) { + canvas.drawRRect( + RRect.fromRectAndRadius(inner, radius), + Paint()..color = track, + ); + } + if (fraction > 0) { + final Rect fill = span(0, fraction); + canvas.drawRect(fill, fillPaint(fill)); + if (props.edgeWidth > 0) { + final Rect edge = vertical + ? Rect.fromLTWH(fill.left, fill.top, fill.width, props.edgeWidth) + : Rect.fromLTWH( + fill.right - props.edgeWidth, + fill.top, + props.edgeWidth, + fill.height, + ); + canvas.drawRect( + edge, + Paint()..color = props.edgeColor.toFlutterColor(), + ); + } + } + } + + // Graduations: full width at major ticks, 35% in from each side at minor. + if (props.ticks > 1) { + final Paint tick = Paint() + ..color = props.tickColor.toFlutterColor() + ..strokeWidth = 1; + final double across = vertical ? inner.width : inner.height; + for (int i = 1; i < props.ticks; i++) { + final bool major = + props.majorTickEvery > 0 && i % props.majorTickEvery == 0; + final double w = major ? across : across * 0.35; + final double p = length * i / props.ticks; + if (vertical) { + final double y = inner.bottom - p; + canvas.drawLine(Offset(inner.left, y), Offset(inner.left + w, y), tick); + canvas.drawLine( + Offset(inner.right - w, y), + Offset(inner.right, y), + tick, + ); + } else { + final double x = inner.left + p; + canvas.drawLine(Offset(x, inner.top), Offset(x, inner.top + w), tick); + canvas.drawLine( + Offset(x, inner.bottom - w), + Offset(x, inner.bottom), + tick, + ); + } + } + } + + if (limit != null) { + final Paint paint = Paint() + ..color = limitColor + ..strokeWidth = props.limitWidth; + final double p = length * limit!; + final Offset a = vertical + ? Offset(inner.left, inner.bottom - p) + : Offset(inner.left + p, inner.top); + final Offset b = vertical + ? Offset(inner.right, inner.bottom - p) + : Offset(inner.left + p, inner.bottom); + if (props.limitStyle == GaugeLimitStyle.dashed) { + const double dash = 5; + final double total = (b - a).distance; + final Offset dir = (b - a) / total; + for (double t = 0; t < total; t += dash * 2) { + canvas.drawLine(a + dir * t, a + dir * math.min(t + dash, total), paint); + } + } else { + canvas.drawLine(a, b, paint); + } + if (props.limitPointer) { + final Path tri = vertical + ? (Path() + ..moveTo(0, a.dy - 5) + ..lineTo(5, a.dy) + ..lineTo(0, a.dy + 5) + ..close()) + : (Path() + ..moveTo(a.dx - 5, size.height) + ..lineTo(a.dx, size.height - 5) + ..lineTo(a.dx + 5, size.height) + ..close()); + canvas.drawPath(tri, Paint()..color = limitColor); + } + } + + final Color border = props.borderColor.toFlutterColor(); + if (border.a > 0) { + canvas.drawRRect( + well.deflate(0.5), + Paint() + ..style = PaintingStyle.stroke + ..color = border, + ); + } + } + + @override + bool shouldRepaint(GaugePainter old) => + old.fraction != fraction || + old.limit != limit || + old.color != color || + old.limitColor != limitColor || + old.props != props; +} diff --git a/lib/src/transformers/passive_transformer_manager.dart b/lib/src/transformers/passive_transformer_manager.dart index b977354e..23219209 100644 --- a/lib/src/transformers/passive_transformer_manager.dart +++ b/lib/src/transformers/passive_transformer_manager.dart @@ -53,6 +53,8 @@ class PassiveNodeTransformerManager extends WidgetNodeTransformerManager { 'loadingIndicator': PassiveLoadingIndicatorTransformer(getNode, this), 'dropdown': PassiveDropdownTransformer(getNode, this), 'progressBar': PassiveProgressBarTransformer(getNode, this), + 'gauge': PassiveGaugeTransformer(getNode, this), + 'barChart': PassiveBarChartTransformer(getNode, this), 'variance': PassiveVarianceTransformer(getNode, this), 'webView': PassiveWebViewTransformer(getNode, this), 'listView': PassiveListViewTransformer(getNode, this), diff --git a/test/feedback_actions_test.dart b/test/feedback_actions_test.dart index 0247d15d..980463a2 100644 --- a/test/feedback_actions_test.dart +++ b/test/feedback_actions_test.dart @@ -161,10 +161,14 @@ class _TestDataManager extends DataManager { } /// Pumps the layout [layouts.first] on a screen of [size]. +/// +/// [variables] are the first layout's variables by canvas ID (or app/page +/// scope) and variable ID. Future pumpLayouts( WidgetTester tester, List layouts, { Size size = const Size(390, 844), + Map> variables = const {}, }) async { tester.view.devicePixelRatio = 1; tester.view.physicalSize = size; @@ -181,6 +185,17 @@ Future pumpLayouts( for (final layout in layouts) { model.layouts[layout.id] = layout; } + if (variables.isNotEmpty) { + model.variables[layouts.first.id] = SDKLayoutVariables( + id: layouts.first.id, + variables: variables, + owner: 'owner', + teams: const {}, + users: const {}, + roles: const {}, + public: false, + ); + } final codelessly = Codelessly( config: CodelesslyConfig(authToken: 'token'), publishDataManager: _TestDataManager(model)..status = CStatus.loaded(), diff --git a/test/gauge_bar_chart_test.dart b/test/gauge_bar_chart_test.dart new file mode 100644 index 00000000..bf489c88 --- /dev/null +++ b/test/gauge_bar_chart_test.dart @@ -0,0 +1,183 @@ +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'; + +Future pump(WidgetTester tester, Widget child) => tester.pumpWidget( + ChangeNotifierProvider.value( + value: CodelesslyContext.empty(), + child: MaterialApp(home: Center(child: child)), + ), +); + +GaugeNode gauge(GaugeProperties props, {Map variables = const {}}) => + GaugeNode( + id: 'gauge', + name: 'Gauge', + basicBoxLocal: NodeBox(0, 0, 168, 144), + value: 20, + limit: 30, + properties: props, + )..variables.addAll(variables); + +GaugePainter painterOf(WidgetTester tester) => tester + .widgetList(find.byType(CustomPaint)) + .map((p) => p.painter) + .whereType() + .single; + +void main() { + group('Gauge', () { + testWidgets('fills to its static value and limit', (tester) async { + await pump( + tester, + PassiveGaugeWidget( + node: gauge(GaugeProperties(animate: false)), + settings: const WidgetBuildSettings(debugLabel: 'test'), + ), + ); + final GaugePainter painter = painterOf(tester); + expect(painter.fraction, closeTo(0.2, 1e-9)); + expect(painter.limit, closeTo(0.3, 1e-9)); + }); + + testWidgets('reads value, limit and colors from formulas', (tester) async { + await pump( + tester, + PassiveGaugeWidget( + node: gauge( + GaugeProperties(animate: false, min: 0, max: 1), + variables: { + 'value': r'${= 0.8 + 0.07}', + 'limit': r'${= 0.75}', + 'color': r'${= 0.87 > 0.85 ? "#FF4D4F" : "#3DD68C"}', + 'limitColor': '#F2C230', + }, + ), + settings: const WidgetBuildSettings(debugLabel: 'test'), + ), + ); + final GaugePainter painter = painterOf(tester); + expect(painter.fraction, closeTo(0.87, 1e-9)); + expect(painter.limit, closeTo(0.75, 1e-9)); + expect(painter.color.toARGB32(), 0xFFFF4D4F); + }); + + testWidgets('a limit bound to nothing hides the marker', (tester) async { + await pump( + tester, + PassiveGaugeWidget( + node: gauge( + GaugeProperties(animate: false), + variables: {'limit': r'${= rules.missing}'}, + ), + settings: const WidgetBuildSettings(debugLabel: 'test'), + ), + ); + expect(painterOf(tester).limit, isNull); + }); + + for (final shape in GaugeShape.values) { + for (final segments in [0, 24]) { + testWidgets('paints a ${shape.name} with $segments segments', ( + tester, + ) async { + await pump( + tester, + RepaintBoundary( + child: PassiveGaugeWidget( + node: gauge( + GaugeProperties( + shape: shape, + segments: segments, + ticks: 10, + majorTickEvery: 5, + limitStyle: GaugeLimitStyle.dashed, + limitPointer: true, + edgeWidth: 2, + fillStartOpacity: 0.5, + inset: 3, + borderColor: ColorRGBA.white, + backgroundColor: ColorRGBA.black, + animate: false, + ), + ), + settings: const WidgetBuildSettings(debugLabel: 'test'), + ), + ), + ); + expect(tester.takeException(), isNull); + }); + } + } + }); + + group('Bar chart', () { + BarChartNode chart({Map variables = const {}}) => + BarChartNode( + id: 'chart', + name: 'Chart', + basicBoxLocal: NodeBox(0, 0, 300, 140), + values: [10, 20, null], + labels: ['a', '', 'c'], + highlight: 1, + properties: BarChartProperties( + max: 40, + animate: false, + gridLabels: ['1', '2', '3'], + ), + )..variables.addAll(variables); + + double barHeight(WidgetTester tester, int index) { + final bars = tester + .widgetList(find.byType(Container)) + .where((c) => c.decoration is BoxDecoration) + .toList(); + return tester.getSize(find.byWidget(bars[index])).height; + } + + testWidgets('scales bars to max and dims all but the highlight', ( + tester, + ) async { + await pump( + tester, + PassiveBarChartWidget( + node: chart(), + settings: const WidgetBuildSettings(debugLabel: 'test'), + ), + ); + expect(find.text('a'), findsOneWidget); + expect(find.text('3'), findsOneWidget); + expect(barHeight(tester, 1), closeTo(barHeight(tester, 0) * 2, 0.01)); + final bars = tester + .widgetList(find.byType(Container)) + .map((c) => c.decoration) + .whereType() + .toList(); + expect(bars[0].color!.a, lessThan(bars[1].color!.a)); + }); + + testWidgets('reads values, labels and highlight from formulas', ( + tester, + ) async { + await pump( + tester, + PassiveBarChartWidget( + node: chart( + variables: { + 'values': r'${= [40, 10]}', + 'labels': r'${= ["x", "y"]}', + 'highlight': r'${= 0}', + }, + ), + settings: const WidgetBuildSettings(debugLabel: 'test'), + ), + ); + expect(find.text('x'), findsOneWidget); + expect(find.text('y'), findsOneWidget); + expect(find.text('a'), findsNothing); + expect(barHeight(tester, 0), closeTo(barHeight(tester, 1) * 4, 0.01)); + }); + }); +} diff --git a/test/interval_trigger_test.dart b/test/interval_trigger_test.dart new file mode 100644 index 00000000..56b64353 --- /dev/null +++ b/test/interval_trigger_test.dart @@ -0,0 +1,107 @@ +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:codelessly_sdk/codelessly_sdk.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'feedback_actions_test.dart' + show body, brandRegular, canvas, layoutOf, pumpLayouts, text; + +final VariableData tick = VariableData( + id: 'tick', + name: 'tick', + value: 0, + type: VariableType.integer, +); + +Reaction every(int millis, ActionModel action) => Reaction( + trigger: TriggerModel(type: TriggerType.interval, intervalMillis: millis), + action: action, +); + +SetVariableAction addOne() => SetVariableAction( + variable: tick, + newValue: '1', + numberOperation: NumberOperation.add, +); + +/// A screen showing `tick`, whose canvas runs [reactions]. +SDKPublishLayout screen(List reactions) { + final CanvasNode main = canvas('main', 'mainBody') + ..reactions.addAll(reactions); + final SDKPublishLayout layout = layoutOf('layout', [ + main, + body('mainBody', 'main', ['count']), + text('count', 'mainBody', r'Ticks ${tick}', brandRegular), + ]); + return layout; +} + +Future pumpScreen(WidgetTester tester, List reactions) async { + final SDKPublishLayout layout = screen(reactions); + final Codelessly codelessly = await pumpLayouts( + tester, + [layout], + variables: { + 'main': {tick.id: tick}, + }, + ); + expect(codelessly, isNotNull); +} + +void main() { + testWidgets('an interval reaction runs every period while shown', ( + tester, + ) async { + await pumpScreen(tester, [every(1000, addOne())]); + expect(find.text('Ticks 0'), findsOneWidget); + + await tester.pump(const Duration(milliseconds: 1000)); + await tester.pump(); + expect(find.text('Ticks 1'), findsOneWidget); + + await tester.pump(const Duration(milliseconds: 3000)); + await tester.pump(); + expect(find.text('Ticks 4'), findsOneWidget); + + // Leaving the screen stops the timer. + await tester.pumpWidget(const SizedBox()); + await tester.pump(const Duration(seconds: 5)); + }); + + testWidgets('a slow interval chain skips ticks instead of piling up', ( + tester, + ) async { + // Each run waits 2.5 s after counting, so with a 1 s period only one run + // is in flight at a time. + await pumpScreen(tester, [ + every(1000, addOne()), + every(1000, DelayAction(milliseconds: 2500)), + ]); + await tester.pump(const Duration(milliseconds: 1000)); + await tester.pump(); + expect(find.text('Ticks 1'), findsOneWidget); + await tester.pump(const Duration(milliseconds: 2000)); + await tester.pump(); + expect(find.text('Ticks 1'), findsOneWidget); + await tester.pump(const Duration(milliseconds: 1000)); + await tester.pump(); + expect(find.text('Ticks 2'), findsOneWidget); + await tester.pumpWidget(const SizedBox()); + await tester.pump(const Duration(seconds: 5)); + }); + + testWidgets('reactions with different periods run on their own timers', ( + tester, + ) async { + await pumpScreen(tester, [ + every(1000, addOne()), + every(500, addOne()), + ]); + await tester.pump(const Duration(milliseconds: 1000)); + await tester.pump(); + // 2 from the 500 ms timer, 1 from the 1 s timer. + expect(find.text('Ticks 3'), findsOneWidget); + await tester.pumpWidget(const SizedBox()); + await tester.pump(const Duration(seconds: 5)); + }); +} From 025cd126edf9f7929daefe220cd86d96a21d0b51 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 22:09:27 +0000 Subject: [PATCH 02/22] Formulas: object() records and clamp, sin, cos, sqrt, pow, pad Braces can't appear inside ${= }, so object(key, value, ...) is how a formula builds the records a list view shows, e.g. the alerts derived from live readings and rule limits. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AjsYaRkTHNKszSHjd3j264 --- lib/src/utils/formula.dart | 40 +++++++++++++++++++++++++++++++++++++- test/formula_test.dart | 29 +++++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 1 deletion(-) diff --git a/lib/src/utils/formula.dart b/lib/src/utils/formula.dart index 3a7f2582..24b50723 100644 --- a/lib/src/utils/formula.dart +++ b/lib/src/utils/formula.dart @@ -27,8 +27,12 @@ typedef FormulaPathResolver = Object? Function(String path); /// `condition ? a : b`. `+` joins text when either side is text. /// - functions: money, fixed, round, floor, ceil, abs, min, max, sum, count, /// length, if, plural, join, upper, lower, trim, contains, startsWith, -/// endsWith, where, without, number, text, isEmpty and isNotEmpty. See +/// endsWith, where, without, number, text, isEmpty, isNotEmpty, clamp, +/// sin, cos, sqrt, pow and pad (`pad(5, 2)` is "05"). See /// [FormulaEvaluator.functions]. +/// - `object(key, value, ...)` builds a map from key/value pairs, since +/// braces can't appear in a formula: a list of records for a list view is +/// `[object('title', 'Fresh', 'level', fresh), ...]`. /// - list functions that take a predicate or key: filter, where, count, /// find, any, all, map, sortBy, unique, sum and join. The predicate is a /// lambda, `item => expression`, or a field name, optionally with a value @@ -394,6 +398,25 @@ class FormulaEvaluator { 'thousands(1094) → 1,094', ), FormulaFunctionDoc('number', 'number(value)', 'number("42") → 42'), + FormulaFunctionDoc( + 'object', + 'object(key, value, …)', + "object('title', 'Black', 'level', 0.87)", + ), + FormulaFunctionDoc( + 'clamp', + 'clamp(value, min, max)', + 'clamp(1.4, 0, 1) → 1', + ), + FormulaFunctionDoc('sin', 'sin(radians)', 'sin(0) → 0'), + FormulaFunctionDoc('cos', 'cos(radians)', 'cos(0) → 1'), + FormulaFunctionDoc('sqrt', 'sqrt(value)', 'sqrt(16) → 4'), + FormulaFunctionDoc('pow', 'pow(base, exponent)', 'pow(2, 10) → 1024'), + FormulaFunctionDoc( + 'pad', + 'pad(value, width, fill)', + 'pad(5, 2) → 05', + ), FormulaFunctionDoc('text', 'text(value)', 'text(42) → "42"'), FormulaFunctionDoc( 'isEmpty', @@ -1130,6 +1153,21 @@ final Map _functions = { return '${n < 0 ? '-' : ''}$whole${parts.length > 1 ? '.${parts[1]}' : ''}'; }, 'number': (a) => _toNum(_arg(a, 0)), + 'object': (a) => { + for (int i = 0; i + 1 < a.length; i += 2) + FormulaEvaluator.stringify(a[i]): a[i + 1], + }, + 'clamp': (a) => _tidy( + _num(_arg(a, 0)).clamp(_num(_arg(a, 1)), _num(_arg(a, 2))), + ), + 'sin': (a) => math.sin(_num(_arg(a, 0))), + 'cos': (a) => math.cos(_num(_arg(a, 0))), + 'sqrt': (a) => math.sqrt(_num(_arg(a, 0))), + 'pow': (a) => _tidy(math.pow(_num(_arg(a, 0)), _num(_arg(a, 1)))), + 'pad': (a) => FormulaEvaluator.stringify(_arg(a, 0)).padLeft( + _intArg(_arg(a, 1)), + _arg(a, 2) == null ? '0' : FormulaEvaluator.stringify(_arg(a, 2)), + ), 'text': (a) => FormulaEvaluator.stringify(_arg(a, 0)), 'isEmpty': (a) => !_truthy(_functions['length']!(a)), 'isNotEmpty': (a) => _truthy(_functions['length']!(a)), diff --git a/test/formula_test.dart b/test/formula_test.dart index af8ba4a1..cb26a991 100644 --- a/test/formula_test.dart +++ b/test/formula_test.dart @@ -33,6 +33,35 @@ void main() { FormulaEvaluator.evaluate(expression, resolve); group('FormulaEvaluator', () { + test('object builds records a list can filter and sort', () { + expect(eval("object('title', 'Black', 'level', 0.87)"), { + 'title': 'Black', + 'level': 0.87, + }); + expect( + eval( + "map(sortBy(filter([object('n', 'a', 'v', 3), object('n', 'b', " + "'v', 1), object('n', 'c', 'v', 0)], r => r.v > 0), 'v'), 'n')", + ), + ['b', 'a'], + ); + expect(eval("object('k', qty * 2).k"), 4); + // A dangling key is dropped. + expect(eval("object('a', 1, 'b')"), {'a': 1}); + }); + + test('math helpers', () { + expect(eval('clamp(1.4, 0, 1)'), 1); + expect(eval('clamp(-3, 0, 1)'), 0); + expect(eval('round(sin(0), 3)'), 0); + expect(eval('cos(0)'), 1); + expect(eval('sqrt(16)'), 4); + expect(eval('pow(2, 10)'), 1024); + expect(eval('pad(5, 2)'), '05'); + expect(eval("pad(7, 3, ' ')"), ' 7'); + expect(eval('pad(123, 2)'), '123'); + }); + test('arithmetic and precedence', () { expect(eval('1 + 2 * 3'), 7); expect(eval('(1 + 2) * 3'), 9); From e28a2a926d9daace6965783bc9cf297c6b06dfed Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 22:13:39 +0000 Subject: [PATCH 03/22] Show dialogs as bottom sheets, adaptively, or anchored to the trigger A bottom sheet lays the canvas out at the screen width and its own height and slides up; adaptive picks a sheet under 600 px and a centered dialog above; anchored places the canvas at its design size just below the widget that opened it (above when there's no room), kept 8 px inside the screen, like a menu. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AjsYaRkTHNKszSHjd3j264 --- lib/src/functions/functions_repository.dart | 113 ++++++++++++++++-- lib/src/ui/codelessly_dialog_widget.dart | 82 +++++++++++++ test/dialog_placement_test.dart | 124 ++++++++++++++++++++ 3 files changed, 306 insertions(+), 13 deletions(-) create mode 100644 test/dialog_placement_test.dart diff --git a/lib/src/functions/functions_repository.dart b/lib/src/functions/functions_repository.dart index 86de7a90..6a432ce2 100644 --- a/lib/src/functions/functions_repository.dart +++ b/lib/src/functions/functions_repository.dart @@ -571,9 +571,21 @@ class FunctionsRepository { barrierDismissible: action.barrierDismissible, barrierColor: action.barrierColor?.toFlutterColor(), showCloseButton: action.showCloseButton, + placement: action.placement, + anchorGap: action.anchorGap, ); } + /// The global rectangle of the widget [context] belongs to, if it is laid + /// out. + static Rect? _globalRectOf(BuildContext context) { + final RenderObject? box = context.mounted + ? context.findRenderObject() + : null; + if (box is! RenderBox || !box.hasSize || !box.attached) return null; + return box.localToGlobal(Offset.zero) & box.size; + } + /// The id of the published layout that holds the canvas [canvasId], if any. static String? layoutIdForCanvas(Codelessly codelessly, String canvasId) => codelessly.dataManager.publishModel?.layouts.values @@ -590,6 +602,8 @@ class FunctionsRepository { bool barrierDismissible = true, Color? barrierColor, bool showCloseButton = false, + DialogPlacement placement = DialogPlacement.center, + double anchorGap = 6, }) async { final ScopedValues scopedValues = ActionChain.scopedValuesOf(context); final parsedParams = substituteVariablesInMap(params, scopedValues); @@ -632,21 +646,94 @@ 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), - ), + Widget dialog(BuildContext context) => ActionChainOpener( + openerHosts: openerHosts, + child: CodelesslyDialogWidget( + showCloseButton: showCloseButton, + builder: (context) => + CodelesslyWidget(codelessly: codelessly, layoutID: layoutId), ), ); + + final Size screen = MediaQuery.sizeOf(context); + final DialogPlacement effective = switch (placement) { + DialogPlacement.adaptive => + screen.width < 600 + ? DialogPlacement.bottomSheet + : DialogPlacement.center, + _ => placement, + }; + final Rect? anchor = effective == DialogPlacement.anchored + ? _globalRectOf(context) + : null; + + final Future shown = switch (effective) { + DialogPlacement.bottomSheet => showGeneralDialog( + context: context, + useRootNavigator: false, + barrierDismissible: barrierDismissible, + barrierLabel: 'Close', + barrierColor: barrierColor ?? Colors.black54, + transitionDuration: const Duration(milliseconds: 250), + routeSettings: RouteSettings(arguments: parsedParams), + pageBuilder: (context, _, _) => CodelesslySheetPlacement( + child: ActionChainOpener( + openerHosts: openerHosts, + child: CanvasSizingScope( + sizing: CanvasSizing.fill, + child: CodelesslyWidget( + codelessly: codelessly, + layoutID: layoutId, + ), + ), + ), + ), + transitionBuilder: (context, animation, _, child) => SlideTransition( + position: Tween(begin: const Offset(0, 1), end: Offset.zero).animate( + CurvedAnimation(parent: animation, curve: Curves.easeOutCubic), + ), + child: child, + ), + ), + DialogPlacement.anchored when anchor != null => + showGeneralDialog( + context: context, + useRootNavigator: false, + barrierDismissible: barrierDismissible, + barrierLabel: 'Close', + barrierColor: barrierColor ?? Colors.transparent, + transitionDuration: const Duration(milliseconds: 120), + routeSettings: RouteSettings(arguments: parsedParams), + pageBuilder: (context, _, _) => CodelesslyAnchoredPlacement( + anchor: anchor, + gap: anchorGap, + child: ActionChainOpener( + openerHosts: openerHosts, + child: CanvasSizingScope( + sizing: CanvasSizing.design, + child: Material( + type: MaterialType.transparency, + child: CodelesslyWidget( + codelessly: codelessly, + layoutID: layoutId, + ), + ), + ), + ), + ), + transitionBuilder: (context, animation, _, child) => + FadeTransition(opacity: animation, child: child), + ), + _ => showDialog( + context: context, + useRootNavigator: false, + barrierDismissible: barrierDismissible, + barrierColor: barrierColor, + routeSettings: RouteSettings(arguments: parsedParams), + builder: dialog, + ), + }; + final Object? result = await shown; if (context.mounted) { codelessly.notifyNavigationListeners( context, diff --git a/lib/src/ui/codelessly_dialog_widget.dart b/lib/src/ui/codelessly_dialog_widget.dart index 86a735bc..71401093 100644 --- a/lib/src/ui/codelessly_dialog_widget.dart +++ b/lib/src/ui/codelessly_dialog_widget.dart @@ -52,3 +52,85 @@ class CodelesslyDialogWidget extends StatelessWidget { return Center(child: builder(context)); } } + +/// Pins a dialog canvas to the bottom of the screen at full width, its +/// height taken from the canvas (its design height, or its content when it +/// shrink-wraps), and no taller than 92% of the screen. +class CodelesslySheetPlacement extends StatelessWidget { + final Widget child; + + const CodelesslySheetPlacement({super.key, required this.child}); + + @override + Widget build(BuildContext context) { + final Size screen = MediaQuery.sizeOf(context); + return Align( + alignment: Alignment.bottomCenter, + child: ConstrainedBox( + constraints: BoxConstraints(maxHeight: screen.height * 0.92), + child: ClipRect( + child: SingleChildScrollView( + child: SizedBox( + width: screen.width, + child: Material(type: MaterialType.transparency, child: child), + ), + ), + ), + ), + ); + } +} + +/// Places a dialog canvas at its design size just below [anchor] (a global +/// rectangle), left-aligned with it, or above it when there is no room +/// below, and always fully on screen. +class CodelesslyAnchoredPlacement extends StatelessWidget { + final Rect anchor; + final double gap; + final Widget child; + + const CodelesslyAnchoredPlacement({ + super.key, + required this.anchor, + required this.gap, + required this.child, + }); + + @override + Widget build(BuildContext context) { + return CustomSingleChildLayout( + delegate: _AnchorDelegate(anchor, gap), + child: child, + ); + } +} + +class _AnchorDelegate extends SingleChildLayoutDelegate { + final Rect anchor; + final double gap; + + _AnchorDelegate(this.anchor, this.gap); + + static const double _margin = 8; + + @override + BoxConstraints getConstraintsForChild(BoxConstraints constraints) => + constraints.loosen().deflate(const EdgeInsets.all(_margin)); + + @override + Offset getPositionForChild(Size size, Size childSize) { + double left = anchor.left; + left = left.clamp(_margin, size.width - childSize.width - _margin); + double top = anchor.bottom + gap; + if (top + childSize.height > size.height - _margin && + anchor.top - gap - childSize.height >= _margin) { + top = anchor.top - gap - childSize.height; + } + top = top.clamp(_margin, size.height - childSize.height - _margin); + return Offset(left, top); + } + + @override + bool shouldRelayout(_AnchorDelegate old) => + old.anchor != anchor || old.gap != gap; +} diff --git a/test/dialog_placement_test.dart b/test/dialog_placement_test.dart new file mode 100644 index 00000000..c0d79348 --- /dev/null +++ b/test/dialog_placement_test.dart @@ -0,0 +1,124 @@ +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:codelessly_sdk/codelessly_sdk.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'feedback_actions_test.dart' + show brandRegular, layoutOf, mainScreen, pumpLayouts, text; + +/// A 180 × 120 menu canvas with one line of text. +SDKPublishLayout menu() => layoutOf('menuLayout', [ + CanvasNode( + id: 'menu', + name: 'menu', + basicBoxLocal: NodeBox(0, 0, 180, 120), + scaleMode: ScaleMode.responsive, + children: ['menuBody'], + properties: CanvasProperties( + bodyId: 'menuBody', + safeArea: const SafeAreaModel.all(false), + ), + )..parentID = kRootNode, + AutoPlaceholderNode( + id: 'menuBody', + name: 'menuBody', + basicBoxLocal: NodeBox(0, 0, 180, 120), + children: ['item'], + mainAxisAlignment: MainAxisAlignmentC.start, + horizontalFit: SizeFit.expanded, + verticalFit: SizeFit.expanded, + )..parentID = 'menu', + text('item', 'menuBody', 'Snooze 1 hour', brandRegular), +]); + +Rect menuRect(WidgetTester tester) => tester.getRect( + find + .ancestor( + of: find.text('Snooze 1 hour'), + matching: find.byType(CodelesslyWidget), + ) + .first, +); + +Future open( + WidgetTester tester, + DialogPlacement placement, { + Size size = const Size(390, 844), +}) async { + await pumpLayouts(tester, [ + layoutOf( + 'main', + mainScreen( + ShowDialogAction( + destinationId: 'menu', + placement: placement, + anchorGap: 6, + ), + ), + ), + menu(), + ], size: size); + await tester.tap(find.text('Go')); + await tester.pumpAndSettle(); + expect(find.text('Snooze 1 hour'), findsOneWidget); +} + +void main() { + testWidgets('an anchored dialog opens just below its trigger', ( + tester, + ) async { + await open(tester, DialogPlacement.anchored); + final Rect go = tester.getRect( + find.ancestor(of: find.text('Go'), matching: find.byType(FitBox)).first, + ); + final Rect shown = menuRect(tester); + expect(shown.top, closeTo(go.bottom + 6, 1)); + expect(shown.left, closeTo(go.left.clamp(8, 390 - 180 - 8), 1)); + expect(shown.size, const Size(180, 120)); + }); + + testWidgets('a bottom sheet spans the screen width at the bottom', ( + tester, + ) async { + await open(tester, DialogPlacement.bottomSheet); + final Rect shown = menuRect(tester); + expect(shown.width, 390); + expect(shown.height, 120); + expect(shown.bottom, 844); + }); + + testWidgets('adaptive is a sheet on mobile', (tester) async { + await open(tester, DialogPlacement.adaptive); + expect(menuRect(tester).bottom, 844); + expect(menuRect(tester).width, 390); + }); + + testWidgets('adaptive is centered on desktop', (tester) async { + await open(tester, DialogPlacement.adaptive, size: const Size(1440, 900)); + final Rect shown = menuRect(tester); + expect(shown.size, const Size(180, 120)); + expect(shown.center, const Offset(720, 450)); + }); + + testWidgets('an anchored dialog without room below opens above', ( + tester, + ) async { + await pumpLayouts(tester, [ + layoutOf( + 'main', + mainScreen( + ShowDialogAction( + destinationId: 'menu', + placement: DialogPlacement.anchored, + ), + ), + ), + menu(), + ], size: const Size(390, 200)); + await tester.tap(find.text('Go')); + await tester.pumpAndSettle(); + final Rect shown = menuRect(tester); + expect(shown.top, greaterThanOrEqualTo(8)); + expect(shown.bottom, lessThanOrEqualTo(192)); + }); +} From e69e40a94c9e200895e62f29d5a3f150f1ba93e5 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 22:18:28 +0000 Subject: [PATCH 04/22] Evaluate computed variables and rebuild on what their formulas read A variable with a formula reads as the formula's value, coerced to its type, wherever variables are read (bindings, text, formulas, conditions). Widgets listen to the variables the formula reads, transitively, so derived state like the list of active alerts stays live. A formula that reaches its own variable reads null there instead of recursing, and setVariable leaves computed variables alone. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AjsYaRkTHNKszSHjd3j264 --- lib/src/functions/functions_repository.dart | 8 ++ .../passive_transformer_manager.dart | 8 +- .../utils/property_value_delegate.dart | 62 +++++++++- test/computed_variable_test.dart | 109 ++++++++++++++++++ 4 files changed, 177 insertions(+), 10 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 6a432ce2..46b5787f 100644 --- a/lib/src/functions/functions_repository.dart +++ b/lib/src/functions/functions_repository.dart @@ -1225,6 +1225,14 @@ ${response.body.contains('{') ? const JsonEncoder.withIndent(' ').convert(json. action.variable.name, ); if (variableNotifier == null) return false; + if (variableNotifier.value.isComputed) { + DebugLogger.instance.printInfo( + 'Variable ${action.variable.name} is computed from a formula and ' + "can't be set.", + name: name, + ); + 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 23219209..0de64b50 100644 --- a/lib/src/transformers/passive_transformer_manager.dart +++ b/lib/src/transformers/passive_transformer_manager.dart @@ -334,7 +334,6 @@ class PassiveNodeTransformerManager extends WidgetNodeTransformerManager { // List of value notifiers that the widget listens to. This includes node's // local values and the variables attached to node properties. final List listenables = []; - // Local node values. final ValueNotifier>? listenableNodeValues = codelesslyContext.nodeValues[node.id]; @@ -480,9 +479,10 @@ class PassiveNodeTransformerManager extends WidgetNodeTransformerManager { .findVariableByName(name); if (variable == null) return; if (!listenables.contains(variable)) listenables.add(variable); - final String? expression = FormulaEvaluator.exactExpression( - variable.value.value, - ); + // A computed variable listens to what its formula reads. + final String? expression = variable.value.isComputed + ? variable.value.formula + : FormulaEvaluator.exactExpression(variable.value.value); if (expression == null) return; for (final String path in FormulaEvaluator.referencedPaths(expression)) { final VariableMatch? match = VariableMatch.parse( diff --git a/lib/src/transformers/utils/property_value_delegate.dart b/lib/src/transformers/utils/property_value_delegate.dart index d955a280..1d25f142 100644 --- a/lib/src/transformers/utils/property_value_delegate.dart +++ b/lib/src/transformers/utils/property_value_delegate.dart @@ -254,33 +254,83 @@ class PropertyValueDelegate { ); } - /// Evaluates a formula [expression] (the part after `${=`), reading its - /// variable paths from [scopedValues]. See [FormulaEvaluator]. - /// Whether [variable] is computed: its value is a `${= ...}` formula, + /// Whether [variable] is computed: it has a formula + /// ([VariableData.isComputed]) or its value is a `${= ...}` formula, /// worked out from other variables whenever it is read. static bool isComputedVariable(VariableData variable) => + variable.isComputed || FormulaEvaluator.exactExpression(variable.value) != null; static int _computedDepth = 0; + /// Names of the computed variables being evaluated, so a formula that + /// (indirectly) reads its own variable gets null instead of looping. + static final Set _computing = {}; + /// The value of [variable]: its formula's result for a computed variable, /// else its stored value. Computed variables can read each other; a cycle /// gives null instead of recursing forever. + /// + /// A [VariableData.formula] result is coerced to the variable's type; a + /// `${= ...}` value keeps whatever its formula returns. static Object? variableValueOf( VariableData variable, ScopedValues scopedValues, ) { - final String? expression = FormulaEvaluator.exactExpression(variable.value); + final bool typed = variable.isComputed; + final String? expression = typed + ? variable.formula + : FormulaEvaluator.exactExpression(variable.value); if (expression == null) return variable.getValue(); - if (_computedDepth >= 16) return null; + if (_computedDepth >= 16 || !_computing.add(variable.name)) return null; _computedDepth++; try { - return evaluateFormula(expression, scopedValues: scopedValues); + final Object? value = evaluateFormula( + expression, + scopedValues: scopedValues, + ); + return typed ? _asType(value, variable.type) : value; } finally { _computedDepth--; + _computing.remove(variable.name); } } + /// [variableValueOf] with a named [scopedValues]. + static Object? variableValue( + VariableData variable, { + required ScopedValues scopedValues, + }) => variableValueOf(variable, scopedValues); + + static Object? _asType(Object? value, VariableType type) => switch (type) { + // A choice holds the id of one of its options. + VariableType.text || + VariableType.choice => FormulaEvaluator.stringify(value), + VariableType.integer => + value is num ? value.round() : num.tryParse('${value ?? ''}')?.round(), + VariableType.decimal => + value is num + ? value.toDouble() + : num.tryParse('${value ?? ''}')?.toDouble(), + VariableType.boolean => + value is bool + ? value + : value is String + ? bool.tryParse(value, caseSensitive: false) + : value != null && value != 0, + VariableType.list => value is List ? value : null, + VariableType.map => value is Map ? value : null, + VariableType.color => + value is ColorRGBA + ? value + : value is String + ? ColorRGBA.fromHex(value) + : null, + VariableType.image => value, + }; + + /// Evaluates a formula [expression] (the part after `${=`), reading its + /// variable paths from [scopedValues]. See [FormulaEvaluator]. static Object? evaluateFormula( String expression, { required ScopedValues scopedValues, diff --git a/test/computed_variable_test.dart b/test/computed_variable_test.dart new file mode 100644 index 00000000..b089f14d --- /dev/null +++ b/test/computed_variable_test.dart @@ -0,0 +1,109 @@ +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:codelessly_sdk/codelessly_sdk.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'feedback_actions_test.dart' + show body, brandRegular, button, canvas, layoutOf, pumpLayouts, text; + +final VariableData level = VariableData( + id: 'level', + name: 'level', + value: 0.87, + type: VariableType.decimal, +); +final VariableData limit = VariableData( + id: 'limit', + name: 'limit', + value: 0.75, + type: VariableType.decimal, +); +final VariableData over = VariableData( + id: 'over', + name: 'over', + type: VariableType.boolean, + formula: 'level > limit', +); +final VariableData label = VariableData( + id: 'label', + name: 'label', + formula: "if(over, 'ALERT ' + round(level * 100) + '%', 'OK')", +); +final VariableData alerts = VariableData( + id: 'alerts', + name: 'alerts', + type: VariableType.list, + formula: "filter([object('id', 'black', 'on', over)], a => a.on)", +); +final VariableData loop = VariableData( + id: 'loop', + name: 'loop', + type: VariableType.integer, + formula: 'loop + 1', +); + +SetVariableAction setLimit(String value) => + SetVariableAction(variable: limit, newValue: value); + +Future pumpScreen(WidgetTester tester) => pumpLayouts( + tester, + [ + layoutOf('layout', [ + canvas('main', 'mainBody'), + body('mainBody', 'main', ['status', 'count', 'raise', 'lower', 'cycle']), + text('status', 'mainBody', r'Status ${label}', brandRegular), + text('count', 'mainBody', r'Alerts ${= length(alerts)}', brandRegular), + button('raise', 'mainBody', 'Raise', setLimit('0.9')), + button('lower', 'mainBody', 'Lower', setLimit('0.5')), + text('cycle', 'mainBody', r'Loop [${loop}]', brandRegular), + ]), + ], + variables: { + 'main': { + for (final v in [level, limit, over, label, alerts, loop]) v.id: v, + }, + }, +); + +void main() { + testWidgets('computed variables follow the variables their formulas read', ( + tester, + ) async { + await pumpScreen(tester); + expect(find.text('Status ALERT 87%'), findsOneWidget); + expect(find.text('Alerts 1'), findsOneWidget); + + // Raising the limit clears the alert everywhere, through two levels of + // computed variables. + await tester.tap(find.text('Raise')); + await tester.pumpAndSettle(); + expect(find.text('Status OK'), findsOneWidget); + expect(find.text('Alerts 0'), findsOneWidget); + + await tester.tap(find.text('Lower')); + await tester.pumpAndSettle(); + expect(find.text('Status ALERT 87%'), findsOneWidget); + }); + + testWidgets('a formula that reads its own variable stops, not hangs', ( + tester, + ) async { + await pumpScreen(tester); + // The inner read of `loop` is null (0 in arithmetic), so loop + 1 is 1. + expect(find.text('Loop [1]'), findsOneWidget); + }); + + testWidgets('setting a computed variable is ignored', (tester) async { + await pumpScreen(tester); + final BuildContext context = tester.element(find.text('Raise')); + expect( + FunctionsRepository.setVariableFromAction( + context, + SetVariableAction(variable: over, newValue: 'false'), + ), + isFalse, + ); + await tester.pump(); + expect(find.text('Status ALERT 87%'), findsOneWidget); + }); +} From 0b68c52b270156be96e84051359f60227d483299 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 22:19:06 +0000 Subject: [PATCH 05/22] FormulaEvaluator.parses Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AjsYaRkTHNKszSHjd3j264 --- lib/src/utils/formula.dart | 3 +++ test/formula_test.dart | 7 +++++++ 2 files changed, 10 insertions(+) diff --git a/lib/src/utils/formula.dart b/lib/src/utils/formula.dart index 24b50723..bbad14ce 100644 --- a/lib/src/utils/formula.dart +++ b/lib/src/utils/formula.dart @@ -83,6 +83,9 @@ class FormulaEvaluator { } } + /// Whether [expression] is a well-formed formula. + static bool parses(String expression) => _parse(expression) != null; + /// The variable paths [expression] reads, e.g. `storage.cart` and `qty`. static Set referencedPaths(String expression) { final _Node? node = _parse(expression); diff --git a/test/formula_test.dart b/test/formula_test.dart index cb26a991..fc9408ae 100644 --- a/test/formula_test.dart +++ b/test/formula_test.dart @@ -50,6 +50,13 @@ void main() { expect(eval("object('a', 1, 'b')"), {'a': 1}); }); + test('parses tells well-formed formulas apart', () { + expect(FormulaEvaluator.parses('level > limit'), isTrue); + expect(FormulaEvaluator.parses('null'), isTrue); + expect(FormulaEvaluator.parses('level >'), isFalse); + expect(FormulaEvaluator.parses("if(a, 'x'"), isFalse); + }); + test('math helpers', () { expect(eval('clamp(1.4, 0, 1)'), 1); expect(eval('clamp(-3, 0, 1)'), 0); From 729e4da1c0859a022e4c0daba3e6740f0d2f98d8 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 22:32:12 +0000 Subject: [PATCH 06/22] Slider: fader thumb, end fill, notches, reading marker, selectable range A slider can draw a rectangular thumb with border and grip, fill its active track from the thumb to the end (an "above the limit" zone), mark divisions with notches under the track (every Nth, every fifth long), show a bindable marker (e.g. the current reading) and a bindable active color, keep the thumb inside a selectable part of the track, and fire changeEnd when the drag ends. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AjsYaRkTHNKszSHjd3j264 --- .../passive_slider_transformer.dart | 295 +++++++++++++++++- test/industrial_slider_test.dart | 130 ++++++++ 2 files changed, 412 insertions(+), 13 deletions(-) create mode 100644 test/industrial_slider_test.dart diff --git a/lib/src/transformers/node_transformers/passive_slider_transformer.dart b/lib/src/transformers/node_transformers/passive_slider_transformer.dart index a7ecc97f..93052bee 100644 --- a/lib/src/transformers/node_transformers/passive_slider_transformer.dart +++ b/lib/src/transformers/node_transformers/passive_slider_transformer.dart @@ -81,6 +81,15 @@ class PassiveSliderWidget extends StatelessWidget { ); } + void onChangeEnd(BuildContext context, double internalValue) { + FunctionsRepository.triggerAction( + context, + node: node, + TriggerType.changeEnd, + value: internalValue, + ); + } + @override Widget build(BuildContext context) { final ScopedValues scopedValues = ScopedValues.of( @@ -94,13 +103,31 @@ class PassiveSliderWidget extends StatelessWidget { scopedValues: scopedValues, ) ?? node.value; + final Object? marker = PropertyValueDelegate.getPropertyValue( + node, + 'markerValue', + scopedValues: scopedValues, + ); + final ColorRGBA? activeTrackColor = + PropertyValueDelegate.getPropertyValue( + node, + 'activeTrackColor', + scopedValues: scopedValues, + ); return TransformerSlider( node: node, settings: settings, variablesOverrides: variablesOverrides, onChanged: (value) => onChanged(context, value), + onChangeEnd: (value) => onChangeEnd(context, value), value: value, + markerValue: switch (marker) { + num() => marker.toDouble(), + String() => double.tryParse(marker), + _ => node.variables['markerValue'] == null ? node.markerValue : null, + }, + activeTrackColor: activeTrackColor?.toFlutterColor(), ); } } @@ -112,6 +139,9 @@ class TransformerSlider extends StatelessWidget { required this.settings, required this.onChanged, required this.value, + this.onChangeEnd, + this.markerValue, + this.activeTrackColor, this.variablesOverrides = const [], }); @@ -119,16 +149,62 @@ class TransformerSlider extends StatelessWidget { final WidgetBuildSettings settings; final List variablesOverrides; final ValueChanged? onChanged; + final ValueChanged? onChangeEnd; final double value; + /// Where to draw [SliderProperties.markerColor]'s marker, if anywhere. + final double? markerValue; + + /// Overrides [SliderProperties.activeTrackColor] (from a binding). + final Color? activeTrackColor; + + /// [v] kept within the selectable part of the track. + double _selectable(double v) { + final SliderProperties p = node.properties; + final double lo = (p.selectableMin ?? p.min).clamp(p.min, p.max); + final double hi = (p.selectableMax ?? p.max).clamp(lo, p.max); + return v.clamp(lo, hi); + } + @override Widget build(BuildContext context) { - final SliderTrackShape trackShape = switch (node.properties.trackShape) { + final SliderProperties p = node.properties; + final double range = p.max - p.min; + final double? markerFraction = markerValue == null || range == 0 + ? null + : ((markerValue! - p.min) / range).clamp(0.0, 1.0); + final SliderTrackShape baseTrackShape = switch (p.trackShape) { SliderTrackShapeEnum.rectangle => const RectangularSliderTrackShape(), SliderTrackShapeEnum.roundedRectangle => const RoundedRectSliderTrackShape(), SliderTrackShapeEnum.gapped => const GappedSliderTrackShape(), }; + // Filling from the end or drawing a marker needs our own track painter; + // otherwise the Material shapes stay as they were. + final SliderTrackShape trackShape = p.fillFromEnd || markerFraction != null + ? IndustrialSliderTrackShape( + fillFromEnd: p.fillFromEnd, + markerFraction: markerFraction, + markerColor: p.markerColor.toFlutterColor(), + markerWidth: p.markerWidth, + markerHeight: p.markerHeight, + radius: p.trackShape == SliderTrackShapeEnum.rectangle + ? 0 + : (p.trackHeight / 2), + ) + : baseTrackShape; + final SliderComponentShape thumbShape = !p.showThumb + ? const RoundSliderThumbShape(enabledThumbRadius: 0) + : p.thumbShape == SliderThumbShapeEnum.rectangle + ? RectSliderThumbShape( + width: p.thumbWidth, + height: p.thumbHeight, + radius: p.thumbCornerRadius, + borderColor: p.thumbBorderColor.toFlutterColor(), + borderWidth: p.thumbBorderWidth, + gripColor: p.thumbGripColor.toFlutterColor(), + ) + : RoundSliderThumbShape(enabledThumbRadius: p.thumbRadius); final SliderComponentShape valueIndicatorShape = switch (node .properties @@ -167,26 +243,30 @@ class TransformerSlider extends StatelessWidget { overlayColor: node.properties.showThumb ? node.properties.overlayColor.toFlutterColor() : Colors.transparent, - activeTrackColor: node.properties.activeTrackColor.toFlutterColor(), + activeTrackColor: + activeTrackColor ?? + node.properties.activeTrackColor.toFlutterColor(), inactiveTrackColor: node.properties.inactiveTrackColor .toFlutterColor(), thumbColor: node.properties.thumbColor.toFlutterColor(), - thumbShape: RoundSliderThumbShape( - enabledThumbRadius: node.properties.showThumb - ? node.properties.thumbRadius - : 0, - ), + thumbShape: thumbShape, trackShape: trackShape, // GappedSliderTrackShape requires a gap; 6 is the Material 3 // (2024) default. trackGap: node.properties.trackShape == SliderTrackShapeEnum.gapped ? 6 : null, - tickMarkShape: node.properties.isDiscrete - ? RoundSliderTickMarkShape( - tickMarkRadius: node.properties.tickMarkRadius, + tickMarkShape: !node.properties.isDiscrete + ? null + : node.properties.tickMarkStyle == SliderTickMarkStyle.notch + ? NotchSliderTickMarkShape( + divisions: node.properties.divisions ?? 0, + every: node.properties.notchEvery, + color: node.properties.inactiveTickMarkColor.toFlutterColor(), ) - : null, + : RoundSliderTickMarkShape( + tickMarkRadius: node.properties.tickMarkRadius, + ), showValueIndicator: showValueIndicator, secondaryActiveTrackColor: node.properties.secondaryActiveTrackColor ?.toFlutterColor(), @@ -208,8 +288,18 @@ class TransformerSlider extends StatelessWidget { ), ), child: Slider( - value: value.clamp(node.properties.min, node.properties.max), - onChanged: onChanged, + value: _selectable( + value.clamp(node.properties.min, node.properties.max), + ), + onChanged: onChanged == null + ? null + : (v) { + final double kept = _selectable(v); + if (kept != value) onChanged!(kept); + }, + onChangeEnd: onChangeEnd == null + ? null + : (v) => onChangeEnd!(_selectable(v)), autofocus: node.properties.autofocus, min: node.properties.min, max: node.properties.max, @@ -227,3 +317,182 @@ class TransformerSlider extends StatelessWidget { ); } } + +/// A slider track that can fill from the thumb to the end and draw a thin +/// marker at [markerFraction] of its length. +class IndustrialSliderTrackShape extends SliderTrackShape + with BaseSliderTrackShape { + const IndustrialSliderTrackShape({ + required this.fillFromEnd, + required this.markerFraction, + required this.markerColor, + required this.markerWidth, + required this.markerHeight, + required this.radius, + }); + + final bool fillFromEnd; + final double? markerFraction; + final Color markerColor; + final double markerWidth; + final double markerHeight; + final double radius; + + @override + void paint( + PaintingContext context, + Offset offset, { + required RenderBox parentBox, + required SliderThemeData sliderTheme, + required Animation enableAnimation, + required TextDirection textDirection, + required Offset thumbCenter, + Offset? secondaryOffset, + bool isDiscrete = false, + bool isEnabled = false, + }) { + final Rect track = getPreferredRect( + parentBox: parentBox, + offset: offset, + sliderTheme: sliderTheme, + isEnabled: isEnabled, + isDiscrete: isDiscrete, + ); + final Canvas canvas = context.canvas; + final Radius r = Radius.circular(radius); + canvas.drawRRect( + RRect.fromRectAndRadius(track, r), + Paint()..color = sliderTheme.inactiveTrackColor ?? Colors.grey, + ); + final Rect active = fillFromEnd + ? Rect.fromLTRB(thumbCenter.dx, track.top, track.right, track.bottom) + : Rect.fromLTRB(track.left, track.top, thumbCenter.dx, track.bottom); + if (active.width > 0) { + canvas.drawRRect( + RRect.fromRectAndRadius(active, r), + Paint()..color = sliderTheme.activeTrackColor ?? Colors.black, + ); + } + if (markerFraction != null) { + final double x = track.left + track.width * markerFraction!; + canvas.drawRect( + Rect.fromCenter( + center: Offset(x, track.center.dy), + width: markerWidth, + height: markerHeight, + ), + Paint()..color = markerColor, + ); + } + } +} + +/// A rounded-rectangle thumb, like a fader knob, with an optional border +/// and a short grip line across it. +class RectSliderThumbShape extends SliderComponentShape { + const RectSliderThumbShape({ + required this.width, + required this.height, + required this.radius, + required this.borderColor, + required this.borderWidth, + required this.gripColor, + }); + + final double width; + final double height; + final double radius; + final Color borderColor; + final double borderWidth; + final Color gripColor; + + @override + Size getPreferredSize(bool isEnabled, bool isDiscrete) => Size(width, height); + + @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 body = RRect.fromRectAndRadius( + Rect.fromCenter(center: center, width: width, height: height), + Radius.circular(radius), + ); + canvas.drawRRect(body, Paint()..color = sliderTheme.thumbColor!); + if (borderColor.a > 0 && borderWidth > 0) { + canvas.drawRRect( + body.deflate(borderWidth / 2), + Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = borderWidth + ..color = borderColor, + ); + } + if (gripColor.a > 0) { + canvas.drawRect( + Rect.fromCenter(center: center, width: width * 0.43, height: 2), + Paint()..color = gripColor, + ); + } + } +} + +/// Division marks as short lines under the track; every fifth is longer. +class NotchSliderTickMarkShape extends SliderTickMarkShape { + const NotchSliderTickMarkShape({ + required this.divisions, + required this.color, + this.every = 1, + }); + + final int divisions; + final Color color; + + /// Only every this many divisions gets a notch. + final int every; + + @override + Size getPreferredSize({ + required SliderThemeData sliderTheme, + bool isEnabled = false, + }) => const Size(1, 1); + + @override + void paint( + PaintingContext context, + Offset center, { + required RenderBox parentBox, + required SliderThemeData sliderTheme, + required Animation enableAnimation, + required TextDirection textDirection, + required Offset thumbCenter, + bool isEnabled = false, + }) { + final Rect track = + (sliderTheme.trackShape ?? const RectangularSliderTrackShape()) + .getPreferredRect(parentBox: parentBox, sliderTheme: sliderTheme); + final double fraction = track.width == 0 + ? 0 + : ((center.dx - track.left) / track.width).clamp(0.0, 1.0); + final int index = (fraction * divisions).round(); + final int step = every < 1 ? 1 : every; + if (index % step != 0) return; + final bool major = (index ~/ step) % 5 == 0; + final double top = center.dy + (sliderTheme.trackHeight ?? 4) / 2 + 3; + context.canvas.drawRect( + Rect.fromLTWH(center.dx - 0.5, top, 1, major ? 8 : 5), + Paint()..color = color, + ); + } +} diff --git a/test/industrial_slider_test.dart b/test/industrial_slider_test.dart new file mode 100644 index 00000000..c5d21a04 --- /dev/null +++ b/test/industrial_slider_test.dart @@ -0,0 +1,130 @@ +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'; + +SliderNode rule({ + double value = 75, + Map variables = const {}, + bool fillFromEnd = true, +}) => SliderNode( + id: 'slider', + name: 'Black tank limit', + basicBoxLocal: NodeBox(0, 0, 300, 32), + value: value, + markerValue: 87, + properties: SliderProperties( + min: 0, + max: 100, + divisions: 20, + isDiscrete: true, + selectableMin: 5, + selectableMax: 95, + fillFromEnd: fillFromEnd, + thumbShape: SliderThumbShapeEnum.rectangle, + thumbBorderColor: ColorRGBA.black, + thumbGripColor: ColorRGBA.black, + tickMarkStyle: SliderTickMarkStyle.notch, + notchEvery: 2, + trackHeight: 6, + trackShape: SliderTrackShapeEnum.rectangle, + ), +)..variables.addAll(variables); + +Future pump(WidgetTester tester, Widget child) => tester.pumpWidget( + ChangeNotifierProvider.value( + value: CodelesslyContext.empty(), + child: MaterialApp(home: Scaffold(body: Center(child: child))), + ), +); + +SliderThemeData theme(WidgetTester tester) => + tester.widget(find.byType(SliderTheme).last).data; + +void main() { + testWidgets('draws the fader thumb, notches, end fill and the marker', ( + tester, + ) async { + await pump( + tester, + PassiveSliderWidget( + node: rule(), + settings: const WidgetBuildSettings(debugLabel: 'test'), + ), + ); + expect(tester.takeException(), isNull); + final SliderThemeData data = theme(tester); + expect(data.thumbShape, isA()); + expect(data.tickMarkShape, isA()); + final track = data.trackShape as IndustrialSliderTrackShape; + expect(track.fillFromEnd, isTrue); + expect(track.markerFraction, closeTo(0.87, 1e-9)); + }); + + testWidgets('marker and active color follow their bindings', ( + tester, + ) async { + await pump( + tester, + PassiveSliderWidget( + node: rule( + variables: { + 'markerValue': r'${= 64}', + 'activeTrackColor': r'${= 64 > 50 ? "#FF4D4F" : "#F2C230"}', + }, + ), + settings: const WidgetBuildSettings(debugLabel: 'test'), + ), + ); + final SliderThemeData data = theme(tester); + expect( + (data.trackShape as IndustrialSliderTrackShape).markerFraction, + closeTo(0.64, 1e-9), + ); + expect(data.activeTrackColor!.toARGB32(), 0xFFFF4D4F); + }); + + testWidgets('the thumb stays in the selectable range and reports the end ' + 'of a drag', (tester) async { + final List changes = []; + final List ends = []; + await pump( + tester, + TransformerSlider( + node: rule(value: 75), + settings: const WidgetBuildSettings(debugLabel: 'test'), + value: 75, + onChanged: changes.add, + onChangeEnd: ends.add, + ), + ); + final Slider slider = tester.widget(find.byType(Slider)); + expect(slider.value, 75); + // Drag far past the left end: the value stops at 5, not 0. + await tester.drag(find.byType(Slider), const Offset(-600, 0)); + await tester.pump(); + expect(changes, isNotEmpty); + expect(changes.last, 5); + expect(ends.single, 5); + }); + + testWidgets('a plain slider keeps the Material shapes', (tester) async { + await pump( + tester, + PassiveSliderWidget( + node: SliderNode( + id: 's', + name: 's', + basicBoxLocal: NodeBox(0, 0, 300, 48), + value: 50, + properties: SliderProperties(), + ), + settings: const WidgetBuildSettings(debugLabel: 'test'), + ), + ); + final SliderThemeData data = theme(tester); + expect(data.thumbShape, isA()); + expect(data.trackShape, isA()); + }); +} From 13585ef7155a1939593c77ac728b154d72ef3fc4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 22:37:02 +0000 Subject: [PATCH 07/22] Bindable text color; alpha() formula function A text node's textColor binding or condition recolors all its spans, so readouts and labels can turn red when an alert fires. alpha(color, opacity) gives the #AARRGGBB tint of a color for soft fills. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AjsYaRkTHNKszSHjd3j264 --- lib/src/transformers/utils/text_utils.dart | 32 ++++++++++- lib/src/utils/formula.dart | 40 ++++++------- test/formula_test.dart | 3 + test/text_color_binding_test.dart | 66 ++++++++++++++++++++++ 4 files changed, 116 insertions(+), 25 deletions(-) create mode 100644 test/text_color_binding_test.dart diff --git a/lib/src/transformers/utils/text_utils.dart b/lib/src/transformers/utils/text_utils.dart index 127c6811..d01b9b3c 100644 --- a/lib/src/transformers/utils/text_utils.dart +++ b/lib/src/transformers/utils/text_utils.dart @@ -217,8 +217,22 @@ class TextUtils { hasMissingFont: hasMissingFont, ); + // A `textColor` binding or condition recolors every span. + final Color? textColor = PropertyValueDelegate.getPropertyValue( + textNode, + 'textColor', + scopedValues: ScopedValues.of( + context, + variablesOverrides: variablesOverrides, + ), + )?.toFlutterColor(); + return Text.rich( - TextSpan(children: spans), + TextSpan( + children: textColor == null + ? spans + : [for (final span in spans) recolorSpan(span, textColor)], + ), textAlign: textNode.textAlignHorizontal.toFlutter(), maxLines: textNode.maxLines, overflow: textNode.overflow.flutterOverflow, @@ -279,6 +293,22 @@ class TextUtils { ); } + /// [span] and its children drawn in [color]. + static TextSpan recolorSpan(TextSpan span, Color color) => TextSpan( + text: span.text, + style: (span.style ?? const TextStyle()).copyWith(color: color), + recognizer: span.recognizer, + semanticsLabel: span.semanticsLabel, + mouseCursor: span.mouseCursor, + onEnter: span.onEnter, + onExit: span.onExit, + locale: span.locale, + spellOut: span.spellOut, + children: span.children + ?.map((child) => child is TextSpan ? recolorSpan(child, color) : child) + .toList(), + ); + static Color? retrievePropColor(TextProp prop) => prop.fills .firstWhereOrNull((fill) => fill.color != null) ?.toFlutterColor(); diff --git a/lib/src/utils/formula.dart b/lib/src/utils/formula.dart index bbad14ce..ccb8795a 100644 --- a/lib/src/utils/formula.dart +++ b/lib/src/utils/formula.dart @@ -28,7 +28,8 @@ typedef FormulaPathResolver = Object? Function(String path); /// - functions: money, fixed, round, floor, ceil, abs, min, max, sum, count, /// length, if, plural, join, upper, lower, trim, contains, startsWith, /// endsWith, where, without, number, text, isEmpty, isNotEmpty, clamp, -/// sin, cos, sqrt, pow and pad (`pad(5, 2)` is "05"). See +/// sin, cos, sqrt, pow, pad (`pad(5, 2)` is "05") and alpha +/// (`alpha('#FF4D4F', 0.12)` is "#1FFF4D4F"). See /// [FormulaEvaluator.functions]. /// - `object(key, value, ...)` builds a map from key/value pairs, since /// braces can't appear in a formula: a list of records for a list view is @@ -402,19 +403,10 @@ class FormulaEvaluator { ), FormulaFunctionDoc('number', 'number(value)', 'number("42") → 42'), FormulaFunctionDoc( - 'object', - 'object(key, value, …)', - "object('title', 'Black', 'level', 0.87)", + 'alpha', + 'alpha(color, opacity)', + "alpha('#FF4D4F', 0.12) → #1FFF4D4F", ), - FormulaFunctionDoc( - 'clamp', - 'clamp(value, min, max)', - 'clamp(1.4, 0, 1) → 1', - ), - FormulaFunctionDoc('sin', 'sin(radians)', 'sin(0) → 0'), - FormulaFunctionDoc('cos', 'cos(radians)', 'cos(0) → 1'), - FormulaFunctionDoc('sqrt', 'sqrt(value)', 'sqrt(16) → 4'), - FormulaFunctionDoc('pow', 'pow(base, exponent)', 'pow(2, 10) → 1024'), FormulaFunctionDoc( 'pad', 'pad(value, width, fill)', @@ -981,6 +973,16 @@ Object? _distance(List a) { return unit.startsWith('mi') ? km / 1.609344 : km; } +/// [color] (`#RRGGBB` or `#AARRGGBB`) at [opacity] (0 to 1), as +/// `#AARRGGBB`. +String? _alpha(Object? color, num opacity) { + final String hex = FormulaEvaluator.stringify(color).replaceAll('#', ''); + if (hex.length != 6 && hex.length != 8) return null; + final String rgb = hex.substring(hex.length - 6).toUpperCase(); + final int a = (opacity.clamp(0, 1) * 255).round(); + return '#${a.toRadixString(16).padLeft(2, '0').toUpperCase()}$rgb'; +} + Object? _arg(List args, int index) => index < args.length ? args[index] : null; @@ -1156,17 +1158,7 @@ final Map _functions = { return '${n < 0 ? '-' : ''}$whole${parts.length > 1 ? '.${parts[1]}' : ''}'; }, 'number': (a) => _toNum(_arg(a, 0)), - 'object': (a) => { - for (int i = 0; i + 1 < a.length; i += 2) - FormulaEvaluator.stringify(a[i]): a[i + 1], - }, - 'clamp': (a) => _tidy( - _num(_arg(a, 0)).clamp(_num(_arg(a, 1)), _num(_arg(a, 2))), - ), - 'sin': (a) => math.sin(_num(_arg(a, 0))), - 'cos': (a) => math.cos(_num(_arg(a, 0))), - 'sqrt': (a) => math.sqrt(_num(_arg(a, 0))), - 'pow': (a) => _tidy(math.pow(_num(_arg(a, 0)), _num(_arg(a, 1)))), + 'alpha': (a) => _alpha(_arg(a, 0), _num(_arg(a, 1))), 'pad': (a) => FormulaEvaluator.stringify(_arg(a, 0)).padLeft( _intArg(_arg(a, 1)), _arg(a, 2) == null ? '0' : FormulaEvaluator.stringify(_arg(a, 2)), diff --git a/test/formula_test.dart b/test/formula_test.dart index fc9408ae..f6e95779 100644 --- a/test/formula_test.dart +++ b/test/formula_test.dart @@ -67,6 +67,9 @@ void main() { expect(eval('pad(5, 2)'), '05'); expect(eval("pad(7, 3, ' ')"), ' 7'); expect(eval('pad(123, 2)'), '123'); + expect(eval("alpha('#FF4D4F', 0.12)"), '#1FFF4D4F'); + expect(eval("alpha('#80ff4d4f', 1)"), '#FFFF4D4F'); + expect(eval("alpha('red', 1)"), null); }); test('arithmetic and precedence', () { diff --git a/test/text_color_binding_test.dart b/test/text_color_binding_test.dart new file mode 100644 index 00000000..da545415 --- /dev/null +++ b/test/text_color_binding_test.dart @@ -0,0 +1,66 @@ +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'; + +TextNode reading(Map variables) => TextNode( + id: 'reading', + name: 'Reading', + basicBoxLocal: NodeBox(0, 0, 120, 30), + characters: '87%', + textMixedProps: [ + StartEndProp( + start: 0, + end: 3, + fontSize: 20, + fills: [PaintModel.solid(id: 'p', color: ColorRGB.fromHex('#E9EBEE'))], + ), + ], +)..variables.addAll(variables); + +Future paintedColor(WidgetTester tester, TextNode node) async { + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: CodelesslyContext.empty(), + child: MaterialApp( + home: Center( + child: Builder( + builder: (context) => TextUtils.buildTextForTextNode( + context, + node, + variablesOverrides: const [], + nullSubstitutionMode: NullSubstitutionMode.emptyString, + replaceVariablesWithSymbol: false, + ), + ), + ), + ), + ), + ); + final RichText rich = tester.widget(find.byType(RichText)); + Color? color; + rich.text.visitChildren((span) { + if (span is TextSpan && span.text != null) { + color = span.style?.color; + return false; + } + return true; + }); + return color; +} + +void main() { + testWidgets('a textColor formula recolors the text', (tester) async { + final Color? color = await paintedColor( + tester, + reading({'textColor': r'${= 0.87 > 0.75 ? "#FF4D4F" : "#E9EBEE"}'}), + ); + expect(color?.toARGB32(), 0xFFFF4D4F); + }); + + testWidgets('without a binding the text keeps its fill', (tester) async { + final Color? color = await paintedColor(tester, reading({})); + expect(color?.toARGB32(), 0xFFE9EBEE); + }); +} From 848843ccef12ac48579316aa5fbf7266d7d07030 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 22:41:06 +0000 Subject: [PATCH 08/22] Place canvas confirm dialogs like Show Dialog Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AjsYaRkTHNKszSHjd3j264 --- lib/src/functions/app_confirm_dialog.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/src/functions/app_confirm_dialog.dart b/lib/src/functions/app_confirm_dialog.dart index 76f330fe..c8941cda 100644 --- a/lib/src/functions/app_confirm_dialog.dart +++ b/lib/src/functions/app_confirm_dialog.dart @@ -45,6 +45,7 @@ class AppConfirmDialog { params: action.params, barrierDismissible: action.barrierDismissible, barrierColor: action.barrierColor?.toFlutterColor(), + placement: action.placement, ); final bool confirmed = ConfirmAction.isConfirmResult(result); return ( From de6558faf1c14d74d07e493574ba95ec40ad0caa Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 22:42:11 +0000 Subject: [PATCH 09/22] Formulas: take, skip, keys Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AjsYaRkTHNKszSHjd3j264 --- lib/src/utils/formula.dart | 3 ++- test/formula_test.dart | 13 +++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/lib/src/utils/formula.dart b/lib/src/utils/formula.dart index ccb8795a..3aa4409a 100644 --- a/lib/src/utils/formula.dart +++ b/lib/src/utils/formula.dart @@ -29,7 +29,8 @@ typedef FormulaPathResolver = Object? Function(String path); /// length, if, plural, join, upper, lower, trim, contains, startsWith, /// endsWith, where, without, number, text, isEmpty, isNotEmpty, clamp, /// sin, cos, sqrt, pow, pad (`pad(5, 2)` is "05") and alpha -/// (`alpha('#FF4D4F', 0.12)` is "#1FFF4D4F"). See +/// (`alpha('#FF4D4F', 0.12)` is "#1FFF4D4F"), take, skip and keys (a map's +/// keys as text). See /// [FormulaEvaluator.functions]. /// - `object(key, value, ...)` builds a map from key/value pairs, since /// braces can't appear in a formula: a list of records for a list view is diff --git a/test/formula_test.dart b/test/formula_test.dart index f6e95779..b4a4d9d2 100644 --- a/test/formula_test.dart +++ b/test/formula_test.dart @@ -70,6 +70,19 @@ void main() { expect(eval("alpha('#FF4D4F', 0.12)"), '#1FFF4D4F'); expect(eval("alpha('#80ff4d4f', 1)"), '#FFFF4D4F'); expect(eval("alpha('red', 1)"), null); + expect(eval('take([1, 2, 3, 4], 3)'), [1, 2, 3]); + expect(eval('take([1], 3)'), [1]); + expect(eval('skip([1, 2, 3], 1)'), [2, 3]); + expect(eval("keys(object('a', 1, 'b', 2))"), ['a', 'b']); + expect(eval('keys(qty)'), isEmpty); + // A map read with a computed key, e.g. a snooze deadline by alert id. + expect( + eval( + "map(filter([object('id', 'a'), object('id', 'b')], r => " + "object('a', 900)[r.id] > 880), 'id')", + ), + ['a'], + ); }); test('arithmetic and precedence', () { From 246720ac10ed18cf3f30927f53fa682320240ed9 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 22:52:40 +0000 Subject: [PATCH 10/22] Toast: alignment, side margin, max width, corner radius, action color, close button, font size Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AjsYaRkTHNKszSHjd3j264 --- lib/src/functions/app_toast.dart | 53 +++++++++++---- test/toast_layout_test.dart | 110 +++++++++++++++++++++++++++++++ 2 files changed, 152 insertions(+), 11 deletions(-) create mode 100644 test/toast_layout_test.dart diff --git a/lib/src/functions/app_toast.dart b/lib/src/functions/app_toast.dart index 51faed5e..aa9a4a30 100644 --- a/lib/src/functions/app_toast.dart +++ b/lib/src/functions/app_toast.dart @@ -178,23 +178,31 @@ class _ToastViewState extends State<_ToastView> { child: retrieveIconWidget(action.icon!, 18) ?? const SizedBox(), ); final bool hasButton = widget.onAction != null; + final bool hasClose = action.showCloseButton; + final bool stretch = action.alignment == ToastAlignment.stretch; final bool top = action.position == ToastPosition.top; final EdgeInsets safe = MediaQuery.paddingOf(context); final TextStyle messageStyle = theme.bodyStyle.copyWith( color: textColor, - fontSize: 14, + fontSize: action.fontSize, height: 1.35, fontWeight: FontWeight.w600, decoration: TextDecoration.none, ); Widget toast = Container( - constraints: const BoxConstraints(minHeight: 44, maxWidth: 560), - padding: EdgeInsets.fromLTRB(18, 10, hasButton ? 8 : 18, 10), + constraints: BoxConstraints(minHeight: 44, maxWidth: action.maxWidth), + padding: EdgeInsets.fromLTRB( + 18, + hasButton || hasClose ? 6 : 10, + hasButton || hasClose ? 8 : 18, + hasButton || hasClose ? 6 : 10, + ), decoration: BoxDecoration( color: background, borderRadius: BorderRadius.circular( - hasButton ? theme.buttonRadius.clamp(8, 999) : 999, + action.cornerRadius ?? + (hasButton ? theme.buttonRadius.clamp(8, 999) : 999), ), boxShadow: const [ BoxShadow( @@ -205,17 +213,21 @@ class _ToastViewState extends State<_ToastView> { ], ), child: Row( - mainAxisSize: MainAxisSize.min, + mainAxisSize: stretch ? MainAxisSize.max : MainAxisSize.min, children: [ if (icon != null) ...[icon, const SizedBox(width: 8)], - Flexible(child: Text(widget.message, style: messageStyle)), + if (stretch) + Expanded(child: Text(widget.message, style: messageStyle)) + else + Flexible(child: Text(widget.message, style: messageStyle)), if (hasButton) ...[ const SizedBox(width: 8), TextButton( key: const ValueKey('codelessly-toast-action'), onPressed: _onActionPressed, style: TextButton.styleFrom( - foregroundColor: textColor, + foregroundColor: + action.actionColor?.toFlutterColor() ?? textColor, minimumSize: const Size(48, 36), padding: const EdgeInsets.symmetric(horizontal: 12), textStyle: theme.buttonStyle.copyWith( @@ -229,6 +241,16 @@ class _ToastViewState extends State<_ToastView> { child: Text(widget.actionLabel), ), ], + if (hasClose) + IconButton( + key: const ValueKey('codelessly-toast-close'), + onPressed: _close, + tooltip: 'Close message', + iconSize: 16, + visualDensity: VisualDensity.compact, + color: textColor, + icon: const Icon(Icons.close), + ), ], ), ); @@ -243,7 +265,13 @@ class _ToastViewState extends State<_ToastView> { offset: _visible ? Offset.zero : Offset(0, top ? -0.25 : 0.25), duration: _fade, curve: Curves.easeOutCubic, - child: Center( + child: Align( + alignment: switch (action.alignment) { + ToastAlignment.start => Alignment.centerLeft, + ToastAlignment.end => Alignment.centerRight, + ToastAlignment.center || + ToastAlignment.stretch => Alignment.center, + }, child: Material(type: MaterialType.transparency, child: toast), ), ), @@ -251,12 +279,15 @@ class _ToastViewState extends State<_ToastView> { ); return Positioned( - left: 16, - right: 16, + left: action.sideMargin, + right: action.sideMargin, top: top ? safe.top + 16 : null, bottom: top ? null : action.bottomOffset, // Only a toast with a button takes taps; the rest passes them through. - child: IgnorePointer(ignoring: !hasButton || !_visible, child: toast), + child: IgnorePointer( + ignoring: !(hasButton || hasClose) || !_visible, + child: toast, + ), ); } } diff --git a/test/toast_layout_test.dart b/test/toast_layout_test.dart new file mode 100644 index 00000000..df0f22a5 --- /dev/null +++ b/test/toast_layout_test.dart @@ -0,0 +1,110 @@ +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'; + +Future show(WidgetTester tester, ShowToastAction action, Size size) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = size; + addTearDown(tester.view.reset); + 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: action, trigger: TriggerModel(type: TriggerType.click)), + ], + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + await tester.pump(const Duration(milliseconds: 300)); +} + +Rect toastRect(WidgetTester tester) => tester.getRect( + find + .ancestor(of: find.text('Dump logged'), matching: find.byType(Container)) + .first, +); + +void main() { + testWidgets('an end-aligned toast sits against the right margin', ( + tester, + ) async { + await show( + tester, + ShowToastAction( + message: 'Dump logged', + alignment: ToastAlignment.end, + sideMargin: 24, + maxWidth: 420, + bottomOffset: 24, + cornerRadius: 4, + showCloseButton: true, + fontSize: 13, + duration: 5000, + ), + const Size(1440, 900), + ); + final Rect rect = toastRect(tester); + expect(rect.right, closeTo(1440 - 24, 0.5)); + expect(rect.bottom, closeTo(900 - 24, 0.5)); + expect(rect.width, lessThanOrEqualTo(420)); + expect(find.byKey(const ValueKey('codelessly-toast-close')), findsOneWidget); + + await tester.tap(find.byKey(const ValueKey('codelessly-toast-close'))); + await tester.pump(const Duration(milliseconds: 300)); + await tester.pump(const Duration(milliseconds: 300)); + expect(find.text('Dump logged'), findsNothing); + }); + + testWidgets('a stretched toast spans the margins', (tester) async { + await show( + tester, + ShowToastAction( + message: 'Dump logged', + alignment: ToastAlignment.stretch, + bottomOffset: 76, + duration: 1000, + ), + const Size(390, 844), + ); + final Rect rect = toastRect(tester); + expect(rect.left, closeTo(16, 0.5)); + expect(rect.right, closeTo(390 - 16, 0.5)); + expect(rect.bottom, closeTo(844 - 76, 0.5)); + await tester.pump(const Duration(seconds: 2)); + }); + + testWidgets('the button label takes the action color', (tester) async { + await show( + tester, + ShowToastAction( + message: 'Dump logged', + actionLabel: 'UNDO', + actionColor: const ColorRGBA(r: 0.76, g: 0.33, b: 0, a: 1), + duration: 1000, + ), + const Size(390, 844), + ); + final TextButton button = tester.widget(find.byType(TextButton)); + expect( + button.style!.foregroundColor!.resolve({})!.toARGB32(), + const ColorRGBA(r: 0.76, g: 0.33, b: 0, a: 1).toFlutterColor().toARGB32(), + ); + await tester.pump(const Duration(seconds: 2)); + }); +} From 74c6cd01dfd06fda8877c8fba188fb521e1cf5f9 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 02:40:23 +0000 Subject: [PATCH 11/22] Gauge and bar chart widgets take the caller's scoped values Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AjsYaRkTHNKszSHjd3j264 --- .../passive_bar_chart_transformer.dart | 28 +++++++++------ .../passive_gauge_transformer.dart | 35 ++++++++++++++----- 2 files changed, 43 insertions(+), 20 deletions(-) diff --git a/lib/src/transformers/node_transformers/passive_bar_chart_transformer.dart b/lib/src/transformers/node_transformers/passive_bar_chart_transformer.dart index 17a4175e..2e282343 100644 --- a/lib/src/transformers/node_transformers/passive_bar_chart_transformer.dart +++ b/lib/src/transformers/node_transformers/passive_bar_chart_transformer.dart @@ -25,6 +25,10 @@ class PassiveBarChartWidget extends StatelessWidget { final WidgetBuildSettings settings; final List variablesOverrides; + /// The values bindings read, when the caller has them, e.g. the editor's + /// variable defaults on the design canvas. Defaults to the context's. + final ScopedValues? scopedValues; + /// Overrides [BarChartProperties.animate], e.g. to hold still in the editor. final bool? animate; @@ -33,6 +37,7 @@ class PassiveBarChartWidget extends StatelessWidget { required this.node, required this.settings, this.variablesOverrides = const [], + this.scopedValues, this.animate, }); @@ -56,10 +61,9 @@ class PassiveBarChartWidget 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 BarChartProperties props = node.properties; Object? bound(String property) => PropertyValueDelegate.getPropertyValue( @@ -71,9 +75,7 @@ class PassiveBarChartWidget extends StatelessWidget { final List values = _list(bound('values'))?.map(_number).toList() ?? node.values; final List labels = - _list( - bound('labels'), - )?.map((e) => e == null ? '' : '$e').toList() ?? + _list(bound('labels'))?.map((e) => e == null ? '' : '$e').toList() ?? node.labels; final Object? boundHighlight = bound('highlight'); final int? highlight = boundHighlight != null @@ -111,8 +113,10 @@ class PassiveBarChartWidget extends StatelessWidget { final double labelHeight = hasLabels ? props.labelFontSize * 1.2 + props.labelGap : 0; - final double height = (constraints.maxHeight - labelHeight - 1) - .clamp(0, double.infinity); + final double height = (constraints.maxHeight - labelHeight - 1).clamp( + 0, + double.infinity, + ); return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ @@ -135,7 +139,8 @@ class PassiveBarChartWidget extends StatelessWidget { Positioned( right: 0, width: props.gridLabelWidth - 6, - bottom: height * g / props.gridLines - + bottom: + height * g / props.gridLines - props.labelFontSize / 2, child: Text( props.gridLabels[g - 1], @@ -235,7 +240,8 @@ class PassiveBarChartWidget extends StatelessWidget { props.emptyHeight, height, ); - final Color barColor = highlight == null || highlight < 0 || index == highlight + final Color barColor = + highlight == null || highlight < 0 || index == highlight ? color : color.withValues(alpha: color.a * props.dimmedOpacity); return TweenAnimationBuilder( diff --git a/lib/src/transformers/node_transformers/passive_gauge_transformer.dart b/lib/src/transformers/node_transformers/passive_gauge_transformer.dart index 2a43aa79..f4ebde5d 100644 --- a/lib/src/transformers/node_transformers/passive_gauge_transformer.dart +++ b/lib/src/transformers/node_transformers/passive_gauge_transformer.dart @@ -25,6 +25,10 @@ class PassiveGaugeWidget extends StatelessWidget { final WidgetBuildSettings settings; final List variablesOverrides; + /// The values bindings read, when the caller has them, e.g. the editor's + /// variable defaults on the design canvas. Defaults to the context's. + final ScopedValues? scopedValues; + /// Overrides [GaugeProperties.animate], e.g. to hold still in the editor. final bool? animate; @@ -33,15 +37,15 @@ class PassiveGaugeWidget extends StatelessWidget { required this.node, required this.settings, this.variablesOverrides = const [], + this.scopedValues, this.animate, }); @override Widget build(BuildContext context) { - final ScopedValues scopedValues = ScopedValues.of( - context, - variablesOverrides: variablesOverrides, - ); + final ScopedValues scopedValues = + this.scopedValues ?? + ScopedValues.of(context, variablesOverrides: variablesOverrides); final GaugeProperties props = node.properties; final double value = _number( @@ -173,7 +177,13 @@ class GaugePainter extends CustomPainter { ..strokeWidth = stroke; canvas.drawArc(rect, start, sweep, false, paint..color = track); if (fraction > 0) { - canvas.drawArc(rect, start, sweep * fraction, false, paint..color = color); + canvas.drawArc( + rect, + start, + sweep * fraction, + false, + paint..color = color, + ); } } @@ -256,8 +266,7 @@ class GaugePainter extends CustomPainter { if (props.segments > 0) { final double gap = props.segmentGap; - final double seg = - (length - gap * (props.segments - 1)) / props.segments; + final double seg = (length - gap * (props.segments - 1)) / props.segments; final int lit = (fraction * props.segments).round(); final Radius segRadius = Radius.circular( math.min(props.cornerRadius, seg / 2), @@ -311,7 +320,11 @@ class GaugePainter extends CustomPainter { final double p = length * i / props.ticks; if (vertical) { final double y = inner.bottom - p; - canvas.drawLine(Offset(inner.left, y), Offset(inner.left + w, y), tick); + canvas.drawLine( + Offset(inner.left, y), + Offset(inner.left + w, y), + tick, + ); canvas.drawLine( Offset(inner.right - w, y), Offset(inner.right, y), @@ -345,7 +358,11 @@ class GaugePainter extends CustomPainter { final double total = (b - a).distance; final Offset dir = (b - a) / total; for (double t = 0; t < total; t += dash * 2) { - canvas.drawLine(a + dir * t, a + dir * math.min(t + dash, total), paint); + canvas.drawLine( + a + dir * t, + a + dir * math.min(t + dash, total), + paint, + ); } } else { canvas.drawLine(a, b, paint); From 289da281de7f64695fe8ee15a9c17a901d6f80b2 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 03:16:24 +0000 Subject: [PATCH 12/22] Rows that hug their cross axis and stretch give hugging children the thickest child's size, as the editor's solver does Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AjsYaRkTHNKszSHjd3j264 --- .../passive_row_column_transformer.dart | 11 +++- test/stretch_row_test.dart | 53 +++++++++++++++++++ 2 files changed, 63 insertions(+), 1 deletion(-) create mode 100644 test/stretch_row_test.dart diff --git a/lib/src/transformers/node_transformers/passive_row_column_transformer.dart b/lib/src/transformers/node_transformers/passive_row_column_transformer.dart index b7e72510..6d8df7e7 100644 --- a/lib/src/transformers/node_transformers/passive_row_column_transformer.dart +++ b/lib/src/transformers/node_transformers/passive_row_column_transformer.dart @@ -312,10 +312,19 @@ class PassiveRowColumnTransformer extends NodeWidgetTransformer { // A parent that stretches its children across gives each one the full // cross axis, like the editor's solver, except a child that hugs on that // axis: the solver keeps its own size there and places it at the start. + // In a parent that hugs the cross axis too (measured with an + // IntrinsicHeight/Width), the solver stretches hugging children to the + // thickest one, so cards in a row get equal heights. + final bool parentHugsCross = + (mainAxis == AxisC.horizontal + ? parentRowColumn.verticalFit + : parentRowColumn.horizontalFit) == + SizeFit.shrinkWrap; final AlignmentGeometry? childAlignment = (alignment ?? node.alignment).data?.flutterAlignmentGeometry ?? (parentRowColumn.crossAxisAlignment == CrossAxisAlignmentC.stretch && - crossAxisFit == SizeFit.shrinkWrap + crossAxisFit == SizeFit.shrinkWrap && + !parentHugsCross ? AlignmentDirectional.topStart : null); final bool hasAlignment = childAlignment != null; diff --git a/test/stretch_row_test.dart b/test/stretch_row_test.dart new file mode 100644 index 00000000..3368b83e --- /dev/null +++ b/test/stretch_row_test.dart @@ -0,0 +1,53 @@ +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:codelessly_sdk/codelessly_sdk.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'feedback_actions_test.dart' show body, canvas, layoutOf, pumpLayouts; + +RowColumnNode card(String id, String content) => RowColumnNode( + id: id, + name: id, + rowColumnType: RowColumnType.column, + basicBoxLocal: NodeBox(0, 0, 150, 100), + horizontalFit: SizeFit.expanded, + verticalFit: SizeFit.shrinkWrap, + fills: [PaintModel.solid(id: '$id fill', color: ColorRGB.black)], + children: [content], +)..parentID = 'cards'; + +RectangleNode block(String id, String parent, double height) => RectangleNode( + id: id, + name: id, + basicBoxLocal: NodeBox(0, 0, 100, height), +)..parentID = parent; + +void main() { + testWidgets('a row that hugs its height and stretches its children gives ' + "hugging cards the tallest card's height, as the editor lays it out", ( + tester, + ) async { + await pumpLayouts(tester, [ + layoutOf('main', [ + canvas('main', 'mainBody'), + body('mainBody', 'main', ['cards']), + RowColumnNode( + id: 'cards', + name: 'cards', + rowColumnType: RowColumnType.row, + basicBoxLocal: NodeBox(0, 0, 390, 320), + horizontalFit: SizeFit.expanded, + verticalFit: SizeFit.shrinkWrap, + crossAxisAlignment: CrossAxisAlignmentC.stretch, + children: ['short', 'tall'], + )..parentID = 'mainBody', + card('short', 'shortBlock'), + block('shortBlock', 'short', 200), + card('tall', 'tallBlock'), + block('tallBlock', 'tall', 320), + ]), + ]); + expect(tester.getSize(find.byKey(const ValueKey('short')).first).height, 320); + expect(tester.getSize(find.byKey(const ValueKey('tall')).first).height, 320); + }); +} From 7f8502d655b1ffaba13c964a538ab15bef6f6485 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 03:52:03 +0000 Subject: [PATCH 13/22] Animate bound opacity changes of nodes with a transition Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AjsYaRkTHNKszSHjd3j264 --- .../widget_node_transformer_manager.dart | 13 ++- lib/src/utils/extensions.dart | 11 +++ test/node_transition_test.dart | 81 +++++++++++++++++++ 3 files changed, 103 insertions(+), 2 deletions(-) create mode 100644 test/node_transition_test.dart diff --git a/lib/src/transformers/widget_node_transformer_manager.dart b/lib/src/transformers/widget_node_transformer_manager.dart index 87c40bd2..75a95403 100644 --- a/lib/src/transformers/widget_node_transformer_manager.dart +++ b/lib/src/transformers/widget_node_transformer_manager.dart @@ -51,9 +51,18 @@ 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; + // A node with a transition always has the animated wrapper, so a change + // of its opacity (to fully opaque too) tweens instead of jumping. + if (node.transition case final NodeTransition transition?) { + return AnimatedOpacity( + opacity: node.opacity.clamp(0.0, 1.0), + duration: transition.duration, + curve: transition.curve.flutterCurve, + child: widget, + ); } + if (node.opacity > 0.99) return widget; final double opacity = node.opacity < 0.99 ? node.opacity : 1; return Opacity(opacity: opacity, child: widget); diff --git a/lib/src/utils/extensions.dart b/lib/src/utils/extensions.dart index 8cc1c817..7a43377c 100644 --- a/lib/src/utils/extensions.dart +++ b/lib/src/utils/extensions.dart @@ -2322,3 +2322,14 @@ extension TextInputActionCExt on TextInputActionC { TextInputActionC.newline => TextInputAction.newline, }; } + +extension TransitionCurveExt on TransitionCurve { + /// The Flutter [Curve] for this easing. + Curve get flutterCurve => switch (this) { + TransitionCurve.linear => Curves.linear, + TransitionCurve.easeIn => Curves.easeIn, + TransitionCurve.easeOut => Curves.easeOut, + TransitionCurve.easeInOut => Curves.easeInOut, + TransitionCurve.easeOutCubic => Curves.easeOutCubic, + }; +} diff --git a/test/node_transition_test.dart b/test/node_transition_test.dart new file mode 100644 index 00000000..7fc78c39 --- /dev/null +++ b/test/node_transition_test.dart @@ -0,0 +1,81 @@ +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:codelessly_sdk/codelessly_sdk.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'feedback_actions_test.dart' + show body, brandRegular, button, canvas, layoutOf, pumpLayouts, text; + +final VariableData bright = VariableData( + id: 'bright', + name: 'bright', + value: true, + type: VariableType.boolean, +); + +TextNode pulse({NodeTransition? transition}) => + text('pulse', 'mainBody', 'Loading', brandRegular) + ..variables['opacity'] = r'${= if(bright, 1, 0.45)}' + ..transition = transition; + +Future pumpScreen(WidgetTester tester, {NodeTransition? transition}) => + pumpLayouts( + tester, + [ + layoutOf('layout', [ + canvas('main', 'mainBody'), + body('mainBody', 'main', ['pulse', 'dim']), + pulse(transition: transition), + button( + 'dim', + 'mainBody', + 'Dim', + SetVariableAction(variable: bright, toggled: true), + ), + ]), + ], + variables: { + 'main': {bright.id: bright}, + }, + ); + +double renderedOpacity(WidgetTester tester) { + final Iterable fades = tester.widgetList( + find.ancestor(of: find.text('Loading'), matching: find.byType(FadeTransition)), + ); + return fades.first.opacity.value; +} + +void main() { + testWidgets('a node with a transition tweens a bound opacity', ( + tester, + ) async { + await pumpScreen( + tester, + transition: const NodeTransition( + durationMillis: 1000, + curve: TransitionCurve.linear, + ), + ); + expect(renderedOpacity(tester), 1); + + await tester.tap(find.text('Dim')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + expect(renderedOpacity(tester), closeTo(0.725, 0.02)); + + await tester.pump(const Duration(milliseconds: 600)); + expect(renderedOpacity(tester), closeTo(0.45, 0.001)); + }); + + testWidgets('without a transition a bound opacity jumps', (tester) async { + await pumpScreen(tester); + expect(find.byType(AnimatedOpacity), findsNothing); + await tester.tap(find.text('Dim')); + await tester.pump(); + final Opacity opacity = tester.widget( + find.ancestor(of: find.text('Loading'), matching: find.byType(Opacity)).first, + ); + expect(opacity.opacity, 0.45); + }); +} From ba5a614296e278b1749a69d35f35fba72db9a4a0 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 04:07:58 +0000 Subject: [PATCH 14/22] Formulas join lists (concat, list + list); icons can be bound to an icon name Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AjsYaRkTHNKszSHjd3j264 --- .../passive_icon_transformer.dart | 18 ++++++++--- .../transformers/transformer_functions.dart | 28 +++++++++++++++++ lib/src/utils/formula.dart | 11 +++++++ test/formula_test.dart | 5 ++++ test/icon_binding_test.dart | 30 +++++++++++++++++++ 5 files changed, 88 insertions(+), 4 deletions(-) create mode 100644 test/icon_binding_test.dart diff --git a/lib/src/transformers/node_transformers/passive_icon_transformer.dart b/lib/src/transformers/node_transformers/passive_icon_transformer.dart index e1fddacf..69983fe4 100644 --- a/lib/src/transformers/node_transformers/passive_icon_transformer.dart +++ b/lib/src/transformers/node_transformers/passive_icon_transformer.dart @@ -35,15 +35,25 @@ class PassiveIconWidget extends StatelessWidget { @override Widget build(BuildContext context) { + final ScopedValues scopedValues = ScopedValues.of(context); final ColorRGBA? iconColor = PropertyValueDelegate.getPropertyValue( node, 'iconColor', - scopedValues: ScopedValues.of(context), + scopedValues: scopedValues, ); - final MultiSourceIconModel icon = iconColor == null - ? node.properties.icon - : node.properties.icon.copyWith(color: iconColor); + // An `icon` binding names the icon to show, e.g. a list item's. + MultiSourceIconModel icon = iconWithName( + node.properties.icon, + node.variables['icon'] == null + ? null + : PropertyValueDelegate.getPropertyValue( + node, + 'icon', + scopedValues: scopedValues, + ), + ); + if (iconColor != null) icon = icon.copyWith(color: iconColor); return retrieveIconWidget(icon, node.basicBoxLocal.width) ?? SizedBox( width: node.basicBoxLocal.width, diff --git a/lib/src/transformers/transformer_functions.dart b/lib/src/transformers/transformer_functions.dart index 5a4302eb..5d5c8afe 100644 --- a/lib/src/transformers/transformer_functions.dart +++ b/lib/src/transformers/transformer_functions.dart @@ -351,6 +351,34 @@ String? getSliderLabel(SliderNode node, double value) { return node.properties.label.replaceAll('\${value}', valueString); } +/// [base] showing the Material icon named [name] instead, for an icon bound +/// to data. [name] is a Flutter `Icons` name such as `kitchen` or +/// `kitchen_outlined`: an `_outlined`, `_rounded` or `_sharp` suffix picks +/// the style. The icon is drawn from its SVG by name, so no codepoint is +/// needed. Returns [base] for an empty or malformed name. +MultiSourceIconModel iconWithName(MultiSourceIconModel base, String? name) { + final String key = (name ?? '').trim().toLowerCase(); + if (!RegExp(r'^[a-z0-9_]+$').hasMatch(key)) return base; + MaterialIconStyle style = MaterialIconStyle.filled; + String iconName = key; + for (final (String suffix, MaterialIconStyle suffixStyle) in const [ + ('_outlined', MaterialIconStyle.outlined), + ('_rounded', MaterialIconStyle.rounded), + ('_sharp', MaterialIconStyle.sharp), + ]) { + if (key.endsWith(suffix)) { + style = suffixStyle; + iconName = key.substring(0, key.length - suffix.length); + break; + } + } + return base.copyWith( + type: IconTypeEnum.icon, + icon: MaterialIcon(codepoint: 0, name: iconName, style: style), + forceIcon: true, + ); +} + Widget? retrieveIconWidget( MultiSourceIconModel icon, [ double? size, diff --git a/lib/src/utils/formula.dart b/lib/src/utils/formula.dart index 3aa4409a..8f3e25b5 100644 --- a/lib/src/utils/formula.dart +++ b/lib/src/utils/formula.dart @@ -403,6 +403,11 @@ class FormulaEvaluator { 'thousands(1094) → 1,094', ), FormulaFunctionDoc('number', 'number(value)', 'number("42") → 42'), + FormulaFunctionDoc( + 'concat', + 'concat(list, list or value, ...)', + 'concat(take(history, 23), [soc]); history + [soc] does the same', + ), FormulaFunctionDoc( 'alpha', 'alpha(color, opacity)', @@ -1103,6 +1108,10 @@ final Map _functions = { if (_arg(a, 0) case final Map m) ...m.map((k, v) => MapEntry('$k', v)), FormulaEvaluator.stringify(_arg(a, 1)): _arg(a, 2), }, + 'concat': (a) => [ + for (final Object? item in a) + if (item is Iterable) ...item else item, + ], 'keys': (a) => switch (_arg(a, 0)) { final Map m => m.keys.map((k) => k.toString()).toList(), _ => [], @@ -1344,6 +1353,8 @@ class _Binary extends _Node { final Object? b = right.eval(resolve); switch (op) { case '+': + // Lists join, and a list plus a value appends it. + if (a is Iterable) return [...a, if (b is Iterable) ...b else b]; final num? na = a is String ? null : _toNum(a); final num? nb = b is String ? null : _toNum(b); if (na != null && nb != null) return _tidy(na + nb); diff --git a/test/formula_test.dart b/test/formula_test.dart index b4a4d9d2..50f5a365 100644 --- a/test/formula_test.dart +++ b/test/formula_test.dart @@ -72,6 +72,11 @@ void main() { expect(eval("alpha('red', 1)"), null); expect(eval('take([1, 2, 3, 4], 3)'), [1, 2, 3]); expect(eval('take([1], 3)'), [1]); + expect(eval('concat([1, 2], [3], 4)'), [1, 2, 3, 4]); + expect(eval('concat(take([1, 2, 3], 2), [9])'), [1, 2, 9]); + expect(eval('[1, 2] + [3]'), [1, 2, 3]); + expect(eval('[1, 2] + 3'), [1, 2, 3]); + expect(eval("'a' + 'b'"), 'ab'); expect(eval('skip([1, 2, 3], 1)'), [2, 3]); expect(eval("keys(object('a', 1, 'b', 2))"), ['a', 'b']); expect(eval('keys(qty)'), isEmpty); diff --git a/test/icon_binding_test.dart b/test/icon_binding_test.dart new file mode 100644 index 00000000..94bbc146 --- /dev/null +++ b/test/icon_binding_test.dart @@ -0,0 +1,30 @@ +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:codelessly_sdk/codelessly_sdk.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + const MultiSourceIconModel base = MultiSourceIconModel.icon( + icon: MaterialIcon(codepoint: 0xe5e0, name: 'speed', style: MaterialIconStyle.filled), + ); + + test('a bound name picks the icon and its style', () { + final MaterialIcon icon = + iconWithName(base, 'kitchen_outlined').icon! as MaterialIcon; + expect(icon.name, 'kitchen'); + expect(icon.style, MaterialIconStyle.outlined); + expect(icon.toSvgUrl(), + 'https://fonts.gstatic.com/s/i/materialiconsoutlined/kitchen/v1/24px.svg'); + + final MaterialIcon filled = iconWithName(base, 'Air').icon! as MaterialIcon; + expect(filled.name, 'air'); + expect(filled.style, MaterialIconStyle.filled); + expect((iconWithName(base, 'power_rounded').icon! as MaterialIcon).style, + MaterialIconStyle.rounded); + }); + + test('an empty or malformed name keeps the designed icon', () { + expect(iconWithName(base, null), same(base)); + expect(iconWithName(base, ' '), same(base)); + expect(iconWithName(base, r'${item.icon}'), same(base)); + }); +} From 72099f44fada1f2b19707a842f301eb2e6f05b2e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 04:09:22 +0000 Subject: [PATCH 15/22] Formula docs: concat example parses Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AjsYaRkTHNKszSHjd3j264 --- lib/src/utils/formula.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/src/utils/formula.dart b/lib/src/utils/formula.dart index 8f3e25b5..36e090f7 100644 --- a/lib/src/utils/formula.dart +++ b/lib/src/utils/formula.dart @@ -406,7 +406,7 @@ class FormulaEvaluator { FormulaFunctionDoc( 'concat', 'concat(list, list or value, ...)', - 'concat(take(history, 23), [soc]); history + [soc] does the same', + 'concat(take(history, 23), [soc])', ), FormulaFunctionDoc( 'alpha', From ddd32885033874fdc440022bfd6bced7ab0ed1fc Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 17:34:19 +0000 Subject: [PATCH 16/22] Computed variables coerce Choice variables to their option id; tidy test import Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AjsYaRkTHNKszSHjd3j264 --- test/stretch_row_test.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/test/stretch_row_test.dart b/test/stretch_row_test.dart index 3368b83e..736ad3cf 100644 --- a/test/stretch_row_test.dart +++ b/test/stretch_row_test.dart @@ -1,5 +1,4 @@ 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'; From bb5267bf4f390bd8204e549f1c15d5a6c37721d4 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 23:29:59 +0000 Subject: [PATCH 17/22] codelessly_sdk 8.3.0 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AjsYaRkTHNKszSHjd3j264 --- CHANGELOG.md | 27 ++++++++++++++++++++++++++- pubspec.yaml | 4 ++-- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d6490ef2..257537c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,4 @@ -## Unreleased +## 8.3.0 ### Layout @@ -31,6 +31,31 @@ - A text field's `errorText` template that comes out empty is no error. - List items on the design canvas show their own item's text. +### Widgets + +- Gauges (`PassiveGaugeWidget`, `GaugePainter`) and bar charts + (`PassiveBarChartWidget`) render the new `gauge` and `barChart` nodes; both + take `scopedValues` so an editor can preview their bindings. +- Industrial slider: rectangle fader thumb with grip, fill from the thumb to + the end, notch tick marks, a value marker, a bindable active track color, + a selectable range and the `changeEnd` trigger. +- Text nodes take a bound `textColor`; icons take an `icon` binding naming a + Material icon (`iconWithName`). +- Nodes with a `transition` animate runtime opacity changes. +- A row or column that hugs its cross axis and stretches its children gives + hugging children the thickest child's size, as the editor lays it out. + +### Actions and variables + +- Interval reactions run on a timer per period while a canvas shows. +- Dialogs open centered, as a bottom sheet, adaptively or anchored under the + trigger; canvas confirms take the same placement. +- Toasts: alignment, side margin, max width, corner radius, action color, + close button and font size. +- Computed variables also come from `VariableData.formula`, coerced to the + variable's type; `setVariable` leaves computed variables alone. +- Formulas: `pad`, `alpha`, `concat` and list `+`; `FormulaEvaluator.parses`. + ## 8.2.0 ### States diff --git a/pubspec.yaml b/pubspec.yaml index ea7839c4..fb46ccb2 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: codelessly_sdk description: Codelessly CloudUI™ - Server-driven UI for Flutter. Supercharge your Flutter apps with dynamic UI and real-time updates. Build and publish UI without code! -version: 8.2.0 +version: 8.3.0 homepage: https://codelessly.com/ repository: https://github.com/Codelessly/CodelesslySDK issue_tracker: https://github.com/Codelessly/CodelesslySDK/issues @@ -28,7 +28,7 @@ dependencies: flutter: sdk: flutter # Codelessly - codelessly_api: ^6.1.0 + codelessly_api: ^6.2.0 json_annotation: ^4.12.0 # Firebase From c5c5de39663e7e6e9d30491cf4d4a7042b082b7f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 23:58:04 +0000 Subject: [PATCH 18/22] Format and lint for CI: dart format, unused import, single quotes Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AjsYaRkTHNKszSHjd3j264 --- lib/src/utils/formula.dart | 6 +----- test/formula_math_test.dart | 4 ++-- test/gauge_bar_chart_test.dart | 20 +++++++++++--------- test/icon_binding_test.dart | 18 +++++++++++++----- test/industrial_slider_test.dart | 8 ++++---- test/interval_trigger_test.dart | 5 +---- test/node_transition_test.dart | 10 +++++++--- test/stretch_row_test.dart | 18 +++++++++++------- test/toast_layout_test.dart | 16 +++++++++++++--- 9 files changed, 63 insertions(+), 42 deletions(-) diff --git a/lib/src/utils/formula.dart b/lib/src/utils/formula.dart index 36e090f7..2d595c01 100644 --- a/lib/src/utils/formula.dart +++ b/lib/src/utils/formula.dart @@ -413,11 +413,7 @@ class FormulaEvaluator { 'alpha(color, opacity)', "alpha('#FF4D4F', 0.12) → #1FFF4D4F", ), - FormulaFunctionDoc( - 'pad', - 'pad(value, width, fill)', - 'pad(5, 2) → 05', - ), + FormulaFunctionDoc('pad', 'pad(value, width, fill)', 'pad(5, 2) → 05'), FormulaFunctionDoc('text', 'text(value)', 'text(42) → "42"'), FormulaFunctionDoc( 'isEmpty', diff --git a/test/formula_math_test.dart b/test/formula_math_test.dart index 0e45bb58..dbfc8cb5 100644 --- a/test/formula_math_test.dart +++ b/test/formula_math_test.dart @@ -40,7 +40,7 @@ void main() { test('distance between list items', () { expect( eval( - "round(distance(first(stops).lat, first(stops).lng, " + 'round(distance(first(stops).lat, first(stops).lng, ' "find(stops, 'id', 'bryce').lat, find(stops, 'id', 'bryce').lng, 'mi'))", ), inInclusiveRange(60, 70), @@ -77,7 +77,7 @@ void main() { expect(eval('flatten([[1, 2], [3], 4])'), [1, 2, 3, 4]); expect(eval("compact([1, null, '', 2])"), [1, 2]); expect( - eval("compact(flatten(map(stops, s => [s.nights > 2 ? s.id : null])))"), + eval('compact(flatten(map(stops, s => [s.nights > 2 ? s.id : null])))'), ['zion', 'moab'], ); }); diff --git a/test/gauge_bar_chart_test.dart b/test/gauge_bar_chart_test.dart index bf489c88..8fb956e7 100644 --- a/test/gauge_bar_chart_test.dart +++ b/test/gauge_bar_chart_test.dart @@ -11,15 +11,17 @@ Future pump(WidgetTester tester, Widget child) => tester.pumpWidget( ), ); -GaugeNode gauge(GaugeProperties props, {Map variables = const {}}) => - GaugeNode( - id: 'gauge', - name: 'Gauge', - basicBoxLocal: NodeBox(0, 0, 168, 144), - value: 20, - limit: 30, - properties: props, - )..variables.addAll(variables); +GaugeNode gauge( + GaugeProperties props, { + Map variables = const {}, +}) => GaugeNode( + id: 'gauge', + name: 'Gauge', + basicBoxLocal: NodeBox(0, 0, 168, 144), + value: 20, + limit: 30, + properties: props, +)..variables.addAll(variables); GaugePainter painterOf(WidgetTester tester) => tester .widgetList(find.byType(CustomPaint)) diff --git a/test/icon_binding_test.dart b/test/icon_binding_test.dart index 94bbc146..dba37574 100644 --- a/test/icon_binding_test.dart +++ b/test/icon_binding_test.dart @@ -4,7 +4,11 @@ import 'package:flutter_test/flutter_test.dart'; void main() { const MultiSourceIconModel base = MultiSourceIconModel.icon( - icon: MaterialIcon(codepoint: 0xe5e0, name: 'speed', style: MaterialIconStyle.filled), + icon: MaterialIcon( + codepoint: 0xe5e0, + name: 'speed', + style: MaterialIconStyle.filled, + ), ); test('a bound name picks the icon and its style', () { @@ -12,14 +16,18 @@ void main() { iconWithName(base, 'kitchen_outlined').icon! as MaterialIcon; expect(icon.name, 'kitchen'); expect(icon.style, MaterialIconStyle.outlined); - expect(icon.toSvgUrl(), - 'https://fonts.gstatic.com/s/i/materialiconsoutlined/kitchen/v1/24px.svg'); + expect( + icon.toSvgUrl(), + 'https://fonts.gstatic.com/s/i/materialiconsoutlined/kitchen/v1/24px.svg', + ); final MaterialIcon filled = iconWithName(base, 'Air').icon! as MaterialIcon; expect(filled.name, 'air'); expect(filled.style, MaterialIconStyle.filled); - expect((iconWithName(base, 'power_rounded').icon! as MaterialIcon).style, - MaterialIconStyle.rounded); + expect( + (iconWithName(base, 'power_rounded').icon! as MaterialIcon).style, + MaterialIconStyle.rounded, + ); }); test('an empty or malformed name keeps the designed icon', () { diff --git a/test/industrial_slider_test.dart b/test/industrial_slider_test.dart index c5d21a04..45de016e 100644 --- a/test/industrial_slider_test.dart +++ b/test/industrial_slider_test.dart @@ -35,7 +35,9 @@ SliderNode rule({ Future pump(WidgetTester tester, Widget child) => tester.pumpWidget( ChangeNotifierProvider.value( value: CodelesslyContext.empty(), - child: MaterialApp(home: Scaffold(body: Center(child: child))), + child: MaterialApp( + home: Scaffold(body: Center(child: child)), + ), ), ); @@ -62,9 +64,7 @@ void main() { expect(track.markerFraction, closeTo(0.87, 1e-9)); }); - testWidgets('marker and active color follow their bindings', ( - tester, - ) async { + testWidgets('marker and active color follow their bindings', (tester) async { await pump( tester, PassiveSliderWidget( diff --git a/test/interval_trigger_test.dart b/test/interval_trigger_test.dart index 56b64353..d20a843e 100644 --- a/test/interval_trigger_test.dart +++ b/test/interval_trigger_test.dart @@ -93,10 +93,7 @@ void main() { testWidgets('reactions with different periods run on their own timers', ( tester, ) async { - await pumpScreen(tester, [ - every(1000, addOne()), - every(500, addOne()), - ]); + await pumpScreen(tester, [every(1000, addOne()), every(500, addOne())]); await tester.pump(const Duration(milliseconds: 1000)); await tester.pump(); // 2 from the 500 ms timer, 1 from the 1 s timer. diff --git a/test/node_transition_test.dart b/test/node_transition_test.dart index 7fc78c39..1ca3cb02 100644 --- a/test/node_transition_test.dart +++ b/test/node_transition_test.dart @@ -1,5 +1,4 @@ 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'; @@ -41,7 +40,10 @@ Future pumpScreen(WidgetTester tester, {NodeTransition? transition}) => double renderedOpacity(WidgetTester tester) { final Iterable fades = tester.widgetList( - find.ancestor(of: find.text('Loading'), matching: find.byType(FadeTransition)), + find.ancestor( + of: find.text('Loading'), + matching: find.byType(FadeTransition), + ), ); return fades.first.opacity.value; } @@ -74,7 +76,9 @@ void main() { await tester.tap(find.text('Dim')); await tester.pump(); final Opacity opacity = tester.widget( - find.ancestor(of: find.text('Loading'), matching: find.byType(Opacity)).first, + find + .ancestor(of: find.text('Loading'), matching: find.byType(Opacity)) + .first, ); expect(opacity.opacity, 0.45); }); diff --git a/test/stretch_row_test.dart b/test/stretch_row_test.dart index 736ad3cf..e1b87306 100644 --- a/test/stretch_row_test.dart +++ b/test/stretch_row_test.dart @@ -15,11 +15,9 @@ RowColumnNode card(String id, String content) => RowColumnNode( children: [content], )..parentID = 'cards'; -RectangleNode block(String id, String parent, double height) => RectangleNode( - id: id, - name: id, - basicBoxLocal: NodeBox(0, 0, 100, height), -)..parentID = parent; +RectangleNode block(String id, String parent, double height) => + RectangleNode(id: id, name: id, basicBoxLocal: NodeBox(0, 0, 100, height)) + ..parentID = parent; void main() { testWidgets('a row that hugs its height and stretches its children gives ' @@ -46,7 +44,13 @@ void main() { block('tallBlock', 'tall', 320), ]), ]); - expect(tester.getSize(find.byKey(const ValueKey('short')).first).height, 320); - expect(tester.getSize(find.byKey(const ValueKey('tall')).first).height, 320); + expect( + tester.getSize(find.byKey(const ValueKey('short')).first).height, + 320, + ); + expect( + tester.getSize(find.byKey(const ValueKey('tall')).first).height, + 320, + ); }); } diff --git a/test/toast_layout_test.dart b/test/toast_layout_test.dart index df0f22a5..88249f2e 100644 --- a/test/toast_layout_test.dart +++ b/test/toast_layout_test.dart @@ -4,7 +4,11 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:provider/provider.dart'; -Future show(WidgetTester tester, ShowToastAction action, Size size) async { +Future show( + WidgetTester tester, + ShowToastAction action, + Size size, +) async { tester.view.devicePixelRatio = 1; tester.view.physicalSize = size; addTearDown(tester.view.reset); @@ -26,7 +30,10 @@ Future show(WidgetTester tester, ShowToastAction action, Size size) async context, TriggerType.click, reactions: [ - Reaction(action: action, trigger: TriggerModel(type: TriggerType.click)), + Reaction( + action: action, + trigger: TriggerModel(type: TriggerType.click), + ), ], ); await tester.pump(); @@ -63,7 +70,10 @@ void main() { expect(rect.right, closeTo(1440 - 24, 0.5)); expect(rect.bottom, closeTo(900 - 24, 0.5)); expect(rect.width, lessThanOrEqualTo(420)); - expect(find.byKey(const ValueKey('codelessly-toast-close')), findsOneWidget); + expect( + find.byKey(const ValueKey('codelessly-toast-close')), + findsOneWidget, + ); await tester.tap(find.byKey(const ValueKey('codelessly-toast-close'))); await tester.pump(const Duration(milliseconds: 300)); From 71c113d8f80f73723d1c03af4b914ab5ff18e4a1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 00:21:52 +0000 Subject: [PATCH 19/22] Apply a bound cornerRadius at runtime Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AjsYaRkTHNKszSHjd3j264 --- CHANGELOG.md | 2 + .../passive_transformer_manager.dart | 15 ++++- test/corner_radius_binding_test.dart | 64 +++++++++++++++++++ 3 files changed, 78 insertions(+), 3 deletions(-) create mode 100644 test/corner_radius_binding_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 257537c3..9cd2ef2f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,8 @@ - Text nodes take a bound `textColor`; icons take an `icon` binding naming a Material icon (`iconWithName`). - Nodes with a `transition` animate runtime opacity changes. +- Nodes with corners take a `cornerRadius` binding: a number or + `[top-left, top-right, bottom-right, bottom-left]`. - A row or column that hugs its cross axis and stretches its children gives hugging children the thickest child's size, as the editor lays it out. diff --git a/lib/src/transformers/passive_transformer_manager.dart b/lib/src/transformers/passive_transformer_manager.dart index 0de64b50..4bffbc8f 100644 --- a/lib/src/transformers/passive_transformer_manager.dart +++ b/lib/src/transformers/passive_transformer_manager.dart @@ -177,9 +177,9 @@ class PassiveNodeTransformerManager extends WidgetNodeTransformerManager { /// Returns [node] with the style properties that conditions, bound /// variables or formulas, or set value actions give it at runtime: `fill` - /// (a text's color for text nodes), `stroke`, `opacity`, `width`, - /// `height`, `flex` (its share of an expanded row or column, so bars - /// can be sized proportionally) and `rotation` (in degrees). Returns + /// (a text's color for text nodes), `stroke`, `cornerRadius`, `opacity`, + /// `width`, `height`, `flex` (its share of an expanded row or column, so + /// bars can be sized proportionally) and `rotation` (in degrees). Returns /// [node] itself when nothing changes it. BaseNode applyStyleOverrides(BuildContext context, BaseNode node) { final ScopedValues scopedValues = ScopedValues.of(context); @@ -223,6 +223,15 @@ class PassiveNodeTransformerManager extends WidgetNodeTransformerManager { ]; } } + if (node is CornerMixin) { + // A number, or [top-left, top-right, bottom-right, bottom-left], e.g. + // a dialog squared at the bottom as a mobile sheet: + // `${= screen.isMobile ? [6, 6, 0, 0] : 6}`. + final CornerRadius? radius = value('cornerRadius'); + if (radius != null && radius != node.cornerRadius) { + patch['cornerRadius'] = radius.toJson(); + } + } if (node is BlendMixin) { final double? opacity = value('opacity'); if (opacity != null && opacity != node.opacity) { diff --git a/test/corner_radius_binding_test.dart b/test/corner_radius_binding_test.dart new file mode 100644 index 00000000..97d522b5 --- /dev/null +++ b/test/corner_radius_binding_test.dart @@ -0,0 +1,64 @@ +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() { + // A dialog is a bottom sheet on phones, squared at the bottom, and a card + // rounded on every corner above. + testWidgets('corner radius follows a bound formula', (tester) async { + final RectangleNode node = RectangleNode.fromJson({ + ...RectangleNode( + id: 'dialog', + name: 'Dialog', + basicBoxLocal: NodeBox(0, 0, 40, 40), + ).toJson(), + 'variables': {'cornerRadius': r'${= sheet ? [6, 6, 0, 0] : 6}'}, + }); + node.parentID = kRootNode; + final registry = NodeRegistry()..setNodes({node.id: node}); + final manager = PassiveNodeTransformerManager( + registry.getNodeByID, + (context, bounds, pageID, layoutID, canvasID) => const SizedBox(), + ); + final Observable sheet = Observable( + VariableData( + id: 'sheet', + name: 'sheet', + type: VariableType.boolean, + value: true, + ), + ); + final CodelesslyContext codelesslyContext = CodelesslyContext.empty() + ..variables['sheet'] = sheet; + + late BuildContext context; + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: codelesslyContext, + child: MaterialApp( + home: Builder( + builder: (c) { + context = c; + return const SizedBox(); + }, + ), + ), + ), + ); + + CornerRadius radius() => (manager.applyStyleOverrides( + context, + node, + ) as CornerMixin).cornerRadius; + + expect(radius().tl, const RadiusModel.circular(6)); + expect(radius().tr, const RadiusModel.circular(6)); + expect(radius().br, RadiusModel.zero); + expect(radius().bl, RadiusModel.zero); + + sheet.set(sheet.value.copyWith(value: false)); + expect(radius(), const CornerRadius.all(RadiusModel.circular(6))); + }); +} From 6aea6ebe6e9956121b663b872b93d4fb2bc83ec7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 00:24:20 +0000 Subject: [PATCH 20/22] ScopedValues.preview takes sample route params Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AjsYaRkTHNKszSHjd3j264 --- CHANGELOG.md | 2 ++ lib/src/transformers/utils/scoped_values.dart | 5 ++- test/preview_route_params_test.dart | 32 +++++++++++++++++++ 3 files changed, 38 insertions(+), 1 deletion(-) create mode 100644 test/preview_route_params_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 9cd2ef2f..e6915c58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,8 @@ - Text nodes take a bound `textColor`; icons take an `icon` binding naming a Material icon (`iconWithName`). - Nodes with a `transition` animate runtime opacity changes. +- `ScopedValues.preview` takes `routeParams`, so a design canvas can show + `${route.…}` with sample params. - Nodes with corners take a `cornerRadius` binding: a number or `[top-left, top-right, bottom-right, bottom-left]`. - A row or column that hugs its cross axis and stretches its children gives diff --git a/lib/src/transformers/utils/scoped_values.dart b/lib/src/transformers/utils/scoped_values.dart index 25d85b63..d3aaf962 100644 --- a/lib/src/transformers/utils/scoped_values.dart +++ b/lib/src/transformers/utils/scoped_values.dart @@ -82,6 +82,8 @@ class ScopedValues { /// Creates scoped values for a design canvas, where [variables] (by name) /// hold their default values and nothing is read from a running app. /// [codelesslyContext] supplies conditions; the caller keeps it alive. + /// [routeParams] are sample params for `${route.…}`, such as the ones a + /// dialog canvas is designed with. ScopedValues.preview({ required Map variables, CodelesslyContext? codelesslyContext, @@ -89,6 +91,7 @@ class ScopedValues { this._appUser, this.screenSize, Map? data, + Map? routeParams, }) : _codelesslyContextRef = codelesslyContext == null ? null : WeakReference(codelesslyContext), @@ -97,7 +100,7 @@ class ScopedValues { _dataOverrides = data, _previewVariables = variables, nodeState = null, - routeParams = {}; + routeParams = routeParams ?? {}; /// Creates a new scoped values instance from a given context. ScopedValues.of( diff --git a/test/preview_route_params_test.dart b/test/preview_route_params_test.dart new file mode 100644 index 00000000..766fd4bd --- /dev/null +++ b/test/preview_route_params_test.dart @@ -0,0 +1,32 @@ +import 'package:codelessly_sdk/codelessly_sdk.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('design canvas values read sample route params', () { + final ScopedValues values = ScopedValues.preview( + variables: const {}, + routeParams: const {'title': 'Log black tank dump?', 'danger': true}, + ); + expect( + PropertyValueDelegate.getVariableValueFromPath( + r'${route.title}', + scopedValues: values, + ), + 'Log black tank dump?', + ); + expect( + PropertyValueDelegate.getVariableValueFromPath( + r'${= upper(route.title)}', + scopedValues: values, + ), + 'LOG BLACK TANK DUMP?', + ); + expect( + PropertyValueDelegate.getVariableValueFromPath( + r'${route.danger}', + scopedValues: values, + ), + isTrue, + ); + }); +} From 9d265770d5ced0ed6a98cd4aeedc68ddf323e8d8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 01:27:36 +0000 Subject: [PATCH 21/22] An icon bound to a name loads its own version's SVG Material Icons SVGs are served per version, from the version an icon was added in, so a bound name like 'air' (v10) asked for v1 and drew nothing. iconWithName looks the version up in a table generated from the Google Fonts icon metadata. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AjsYaRkTHNKszSHjd3j264 --- CHANGELOG.md | 2 + .../transformers/transformer_functions.dart | 9 +- lib/src/utils/material_icon_versions.dart | 1903 +++++++++++++++++ test/icon_binding_test.dart | 9 +- 4 files changed, 1921 insertions(+), 2 deletions(-) create mode 100644 lib/src/utils/material_icon_versions.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index e6915c58..c0d71332 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,8 @@ - Nodes with a `transition` animate runtime opacity changes. - `ScopedValues.preview` takes `routeParams`, so a design canvas can show `${route.…}` with sample params. +- An icon bound to a name loads the SVG of that icon's version + (`materialIconVersions`), so icons added after version 1 (`air`) draw. - Nodes with corners take a `cornerRadius` binding: a number or `[top-left, top-right, bottom-right, bottom-left]`. - A row or column that hugs its cross axis and stretches its children gives diff --git a/lib/src/transformers/transformer_functions.dart b/lib/src/transformers/transformer_functions.dart index 5d5c8afe..4a74a4a3 100644 --- a/lib/src/transformers/transformer_functions.dart +++ b/lib/src/transformers/transformer_functions.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; import '../utils/extensions.dart'; +import '../utils/material_icon_versions.dart'; import 'transformers.dart'; /// The colour a button of [type] has when its buttonColor is unset (null): @@ -374,7 +375,13 @@ MultiSourceIconModel iconWithName(MultiSourceIconModel base, String? name) { } return base.copyWith( type: IconTypeEnum.icon, - icon: MaterialIcon(codepoint: 0, name: iconName, style: style), + // The icon's SVG is served from the version the icon was added in. + icon: MaterialIcon( + codepoint: 0, + name: iconName, + style: style, + version: materialIconVersions[iconName] ?? 1, + ), forceIcon: true, ); } diff --git a/lib/src/utils/material_icon_versions.dart b/lib/src/utils/material_icon_versions.dart new file mode 100644 index 00000000..d0cc8f73 --- /dev/null +++ b/lib/src/utils/material_icon_versions.dart @@ -0,0 +1,1903 @@ +// The latest version of each Material Icons icon above version 1, from the +// Google Fonts icon metadata (fonts.google.com/metadata/icons). The SVG of +// an icon is served per version (`/s/i/materialicons/air/v10/24px.svg`) and +// only from the version the icon was added in, so an icon named by data +// ([iconWithName]) needs its version. Icons not listed are at version 1. + +/// Material Icons version by icon name, for icons above version 1. +const Map materialIconVersions = { + '10k': 10, + '10mp': 10, + '11mp': 10, + '12mp': 10, + '13mp': 10, + '14mp': 10, + '15mp': 10, + '16mp': 10, + '17mp': 10, + '18mp': 10, + '19mp': 10, + '1k': 10, + '1k_plus': 10, + '1x_mobiledata': 9, + '20mp': 10, + '21mp': 10, + '22mp': 10, + '23mp': 10, + '24mp': 10, + '2k': 10, + '2k_plus': 10, + '2mp': 10, + '30fps': 10, + '30fps_select': 10, + '360': 14, + '3d_rotation': 17, + '3g_mobiledata': 9, + '3k': 10, + '3k_plus': 10, + '3mp': 10, + '3p': 11, + '4g_mobiledata': 9, + '4g_plus_mobiledata': 9, + '4k': 11, + '4k_plus': 10, + '4mp': 10, + '5g': 13, + '5k': 10, + '5k_plus': 10, + '5mp': 10, + '60fps': 10, + '60fps_select': 10, + '6_ft_apart': 9, + '6k': 10, + '6k_plus': 10, + '6mp': 10, + '7k': 10, + '7k_plus': 10, + '7mp': 10, + '8k': 11, + '8k_plus': 11, + '8mp': 10, + '9k': 10, + '9k_plus': 10, + '9mp': 10, + 'ac_unit': 12, + 'access_alarm': 12, + 'access_alarms': 12, + 'access_time': 12, + 'access_time_filled': 10, + 'accessibility': 13, + 'accessibility_new': 13, + 'accessible': 13, + 'accessible_forward': 13, + 'account_balance': 19, + 'account_balance_wallet': 12, + 'account_box': 13, + 'account_circle': 18, + 'account_tree': 12, + 'ad_units': 13, + 'adb': 12, + 'add': 21, + 'add_a_photo': 14, + 'add_alarm': 13, + 'add_alert': 16, + 'add_box': 13, + 'add_business': 17, + 'add_call': 10, + 'add_chart': 10, + 'add_circle': 13, + 'add_circle_outline': 16, + 'add_comment': 11, + 'add_ic_call': 12, + 'add_link': 11, + 'add_location': 16, + 'add_location_alt': 13, + 'add_moderator': 12, + 'add_photo_alternate': 12, + 'add_reaction': 9, + 'add_road': 12, + 'add_shopping_cart': 15, + 'add_task': 6, + 'add_to_drive': 18, + 'add_to_home_screen': 12, + 'add_to_photos': 12, + 'add_to_queue': 11, + 'addchart': 13, + 'adf_scanner': 2, + 'adjust': 12, + 'admin_panel_settings': 13, + 'ads_click': 3, + 'agriculture': 11, + 'air': 10, + 'airline_seat_flat': 12, + 'airline_seat_flat_angled': 12, + 'airline_seat_individual_suite': 11, + 'airline_seat_legroom_extra': 12, + 'airline_seat_legroom_normal': 12, + 'airline_seat_legroom_reduced': 12, + 'airline_seat_recline_extra': 12, + 'airline_seat_recline_normal': 12, + 'airline_stops': 2, + 'airlines': 2, + 'airplane_ticket': 11, + 'airplanemode_active': 16, + 'airplanemode_inactive': 17, + 'airplay': 13, + 'airport_shuttle': 12, + 'alarm': 12, + 'alarm_add': 12, + 'alarm_off': 12, + 'alarm_on': 12, + 'album': 12, + 'align_horizontal_center': 6, + 'align_horizontal_left': 7, + 'align_horizontal_right': 7, + 'align_vertical_bottom': 6, + 'align_vertical_center': 6, + 'align_vertical_top': 6, + 'all_inbox': 11, + 'all_inclusive': 11, + 'all_out': 12, + 'alt_route': 9, + 'alternate_email': 13, + 'analytics': 12, + 'anchor': 7, + 'android': 15, + 'animation': 14, + 'announcement': 18, + 'aod': 10, + 'apartment': 11, + 'api': 7, + 'app_blocking': 13, + 'app_registration': 11, + 'app_settings_alt': 17, + 'approval': 11, + 'apps': 12, + 'apps_outage': 2, + 'architecture': 11, + 'archive': 16, + 'area_chart': 3, + 'arrow_back': 17, + 'arrow_back_ios': 14, + 'arrow_back_ios_new': 6, + 'arrow_circle_down': 8, + 'arrow_circle_left': 2, + 'arrow_circle_right': 2, + 'arrow_circle_up': 8, + 'arrow_downward': 17, + 'arrow_drop_down': 16, + 'arrow_drop_down_circle': 12, + 'arrow_drop_up': 12, + 'arrow_forward': 13, + 'arrow_forward_ios': 16, + 'arrow_left': 12, + 'arrow_right': 12, + 'arrow_right_alt': 13, + 'arrow_upward': 11, + 'art_track': 13, + 'article': 13, + 'aspect_ratio': 12, + 'assessment': 12, + 'assignment': 13, + 'assignment_ind': 12, + 'assignment_late': 15, + 'assignment_return': 14, + 'assignment_returned': 12, + 'assignment_turned_in': 16, + 'assistant': 12, + 'assistant_direction': 11, + 'assistant_navigation': 10, + 'assistant_photo': 12, + 'atm': 12, + 'attach_email': 16, + 'attach_file': 16, + 'attach_money': 17, + 'attachment': 15, + 'attractions': 10, + 'attribution': 11, + 'audiotrack': 12, + 'auto_awesome': 14, + 'auto_awesome_mosaic': 14, + 'auto_awesome_motion': 14, + 'auto_delete': 11, + 'auto_fix_high': 14, + 'auto_fix_normal': 14, + 'auto_fix_off': 13, + 'auto_graph': 4, + 'auto_stories': 13, + 'autofps_select': 10, + 'autorenew': 12, + 'av_timer': 12, + 'baby_changing_station': 8, + 'back_hand': 3, + 'backpack': 8, + 'backspace': 13, + 'backup': 15, + 'backup_table': 11, + 'badge': 11, + 'bakery_dining': 11, + 'balcony': 4, + 'ballot': 14, + 'bar_chart': 12, + 'batch_prediction': 12, + 'bathroom': 10, + 'bathtub': 11, + 'battery_alert': 12, + 'battery_charging_full': 12, + 'battery_full': 12, + 'battery_saver': 9, + 'battery_std': 12, + 'battery_unknown': 13, + 'beach_access': 12, + 'bed': 9, + 'bedroom_baby': 9, + 'bedroom_child': 9, + 'bedroom_parent': 9, + 'bedtime': 14, + 'beenhere': 12, + 'bento': 6, + 'bike_scooter': 11, + 'biotech': 11, + 'blender': 9, + 'blinds': 3, + 'block': 16, + 'block_flipped': 12, + 'bloodtype': 10, + 'bluetooth': 11, + 'bluetooth_audio': 12, + 'bluetooth_connected': 12, + 'bluetooth_disabled': 17, + 'bluetooth_drive': 9, + 'bluetooth_searching': 13, + 'blur_circular': 12, + 'blur_linear': 12, + 'blur_off': 12, + 'blur_on': 12, + 'bolt': 10, + 'book': 14, + 'book_online': 10, + 'bookmark': 17, + 'bookmark_add': 3, + 'bookmark_added': 3, + 'bookmark_border': 12, + 'bookmark_remove': 3, + 'bookmarks': 12, + 'border_all': 12, + 'border_bottom': 12, + 'border_clear': 12, + 'border_color': 15, + 'border_horizontal': 12, + 'border_inner': 12, + 'border_left': 12, + 'border_outer': 12, + 'border_right': 12, + 'border_style': 12, + 'border_top': 12, + 'border_vertical': 12, + 'branding_watermark': 13, + 'breakfast_dining': 11, + 'brightness_1': 19, + 'brightness_2': 12, + 'brightness_3': 16, + 'brightness_4': 16, + 'brightness_5': 12, + 'brightness_6': 12, + 'brightness_7': 13, + 'brightness_auto': 12, + 'brightness_high': 13, + 'brightness_low': 12, + 'brightness_medium': 12, + 'broken_image': 11, + 'browser_not_supported': 12, + 'browser_updated': 2, + 'brunch_dining': 11, + 'brush': 13, + 'bubble_chart': 12, + 'bug_report': 12, + 'build': 12, + 'build_circle': 14, + 'bungalow': 4, + 'burst_mode': 12, + 'bus_alert': 15, + 'business': 12, + 'business_center': 15, + 'cabin': 4, + 'cable': 10, + 'cached': 12, + 'cake': 13, + 'calculate': 11, + 'calendar_today': 12, + 'calendar_view_day': 15, + 'calendar_view_month': 10, + 'calendar_view_week': 10, + 'call': 17, + 'call_end': 16, + 'call_made': 13, + 'call_merge': 13, + 'call_missed': 13, + 'call_missed_outgoing': 15, + 'call_received': 13, + 'call_split': 13, + 'call_to_action': 11, + 'camera': 12, + 'camera_alt': 12, + 'camera_enhance': 12, + 'camera_front': 12, + 'camera_indoor': 9, + 'camera_outdoor': 9, + 'camera_rear': 13, + 'camera_roll': 12, + 'cameraswitch': 10, + 'campaign': 12, + 'cancel': 16, + 'cancel_presentation': 14, + 'cancel_schedule_send': 12, + 'candlestick_chart': 2, + 'car_rental': 10, + 'car_repair': 10, + 'card_giftcard': 14, + 'card_membership': 14, + 'card_travel': 13, + 'carpenter': 6, + 'cases': 11, + 'casino': 13, + 'cast': 18, + 'cast_connected': 13, + 'cast_for_education': 14, + 'castle': 2, + 'catching_pokemon': 4, + 'category': 12, + 'celebration': 11, + 'cell_wifi': 14, + 'center_focus_strong': 12, + 'center_focus_weak': 12, + 'chair': 10, + 'chair_alt': 9, + 'chalet': 4, + 'change_circle': 8, + 'change_history': 11, + 'charging_station': 8, + 'chat': 19, + 'chat_bubble': 13, + 'chat_bubble_outline': 16, + 'check': 18, + 'check_box': 16, + 'check_box_outline_blank': 16, + 'check_circle': 18, + 'check_circle_outline': 12, + 'checklist': 3, + 'checklist_rtl': 3, + 'checkroom': 8, + 'chevron_left': 16, + 'chevron_right': 16, + 'child_care': 12, + 'child_friendly': 12, + 'chrome_reader_mode': 12, + 'church': 2, + 'circle': 11, + 'circle_notifications': 11, + 'class': 11, + 'clean_hands': 7, + 'cleaning_services': 12, + 'clear': 12, + 'clear_all': 12, + 'close': 19, + 'close_fullscreen': 7, + 'closed_caption': 12, + 'closed_caption_disabled': 8, + 'closed_caption_off': 11, + 'cloud': 12, + 'cloud_circle': 12, + 'cloud_done': 12, + 'cloud_download': 12, + 'cloud_off': 16, + 'cloud_queue': 12, + 'cloud_upload': 12, + 'cloudy_snowing': 2, + 'co2': 3, + 'code': 11, + 'code_off': 4, + 'coffee': 9, + 'coffee_maker': 9, + 'collections': 12, + 'collections_bookmark': 12, + 'color_lens': 16, + 'colorize': 12, + 'comment': 19, + 'comment_bank': 15, + 'comments_disabled': 3, + 'commute': 12, + 'compare': 12, + 'compare_arrows': 14, + 'compass_calibration': 12, + 'compost': 3, + 'compress': 10, + 'computer': 12, + 'confirmation_number': 15, + 'connect_without_contact': 7, + 'connected_tv': 11, + 'connecting_airports': 2, + 'construction': 11, + 'contact_mail': 13, + 'contact_page': 6, + 'contact_phone': 13, + 'contact_support': 13, + 'contactless': 11, + 'contacts': 11, + 'content_copy': 17, + 'content_cut': 17, + 'content_paste': 14, + 'content_paste_go': 2, + 'content_paste_off': 4, + 'content_paste_search': 2, + 'control_camera': 12, + 'control_point': 13, + 'control_point_duplicate': 12, + 'cookie': 2, + 'copy_all': 8, + 'copyright': 17, + 'coronavirus': 7, + 'corporate_fare': 7, + 'cottage': 4, + 'countertops': 6, + 'create': 16, + 'create_new_folder': 11, + 'credit_card': 12, + 'credit_card_off': 4, + 'credit_score': 10, + 'crib': 4, + 'crop': 12, + 'crop_16_9': 12, + 'crop_3_2': 12, + 'crop_5_4': 12, + 'crop_7_5': 12, + 'crop_din': 12, + 'crop_free': 12, + 'crop_landscape': 12, + 'crop_original': 13, + 'crop_portrait': 12, + 'crop_rotate': 12, + 'crop_square': 12, + 'cruelty_free': 3, + 'dangerous': 12, + 'dark_mode': 4, + 'dashboard': 13, + 'dashboard_customize': 11, + 'data_array': 2, + 'data_exploration': 3, + 'data_object': 2, + 'data_saver_off': 10, + 'data_saver_on': 10, + 'data_usage': 12, + 'date_range': 12, + 'deck': 11, + 'dehaze': 12, + 'delete': 17, + 'delete_forever': 15, + 'delete_outline': 11, + 'delete_sweep': 12, + 'delivery_dining': 15, + 'departure_board': 11, + 'description': 12, + 'design_services': 12, + 'desktop_access_disabled': 11, + 'desktop_mac': 16, + 'desktop_windows': 12, + 'details': 16, + 'developer_board': 13, + 'developer_board_off': 4, + 'developer_mode': 12, + 'device_hub': 12, + 'device_thermostat': 11, + 'device_unknown': 12, + 'devices': 16, + 'devices_other': 12, + 'dialer_sip': 12, + 'dialpad': 12, + 'diamond': 2, + 'dining': 9, + 'dinner_dining': 10, + 'directions': 12, + 'directions_bike': 13, + 'directions_boat': 17, + 'directions_boat_filled': 16, + 'directions_bus': 12, + 'directions_bus_filled': 10, + 'directions_car': 18, + 'directions_car_filled': 10, + 'directions_off': 12, + 'directions_railway': 13, + 'directions_railway_filled': 10, + 'directions_run': 13, + 'directions_subway': 12, + 'directions_subway_filled': 10, + 'directions_transit': 12, + 'directions_transit_filled': 10, + 'directions_walk': 13, + 'dirty_lens': 15, + 'disabled_by_default': 6, + 'disabled_visible': 3, + 'disc_full': 12, + 'dns': 12, + 'do_disturb': 10, + 'do_disturb_alt': 11, + 'do_disturb_off': 10, + 'do_disturb_on': 10, + 'do_not_disturb': 11, + 'do_not_disturb_alt': 12, + 'do_not_disturb_off': 11, + 'do_not_disturb_on': 11, + 'do_not_disturb_on_total_silence': 15, + 'do_not_step': 8, + 'do_not_touch': 8, + 'dock': 12, + 'document_scanner': 3, + 'domain': 16, + 'domain_disabled': 12, + 'domain_verification': 11, + 'done': 19, + 'done_all': 12, + 'done_outline': 12, + 'donut_large': 13, + 'donut_small': 12, + 'door_back': 10, + 'door_front': 10, + 'door_sliding': 10, + 'doorbell': 10, + 'double_arrow': 11, + 'downhill_skiing': 4, + 'download': 10, + 'download_done': 10, + 'download_for_offline': 9, + 'downloading': 9, + 'drafts': 17, + 'drag_handle': 14, + 'drag_indicator': 15, + 'draw': 6, + 'drive_eta': 12, + 'drive_file_move': 19, + 'drive_file_move_outline': 12, + 'drive_file_move_rtl': 3, + 'drive_file_rename_outline': 12, + 'drive_folder_upload': 11, + 'dry': 8, + 'dry_cleaning': 10, + 'duo': 12, + 'dvr': 13, + 'dynamic_feed': 15, + 'dynamic_form': 7, + 'e_mobiledata': 9, + 'earbuds': 9, + 'earbuds_battery': 9, + 'east': 7, + 'edgesensor_high': 10, + 'edgesensor_low': 10, + 'edit': 12, + 'edit_attributes': 12, + 'edit_calendar': 5, + 'edit_location': 16, + 'edit_location_alt': 8, + 'edit_note': 5, + 'edit_notifications': 4, + 'edit_off': 12, + 'edit_road': 11, + 'egg': 2, + 'egg_alt': 2, + 'eject': 11, + 'elderly': 7, + 'electric_bike': 14, + 'electric_car': 14, + 'electric_moped': 14, + 'electric_rickshaw': 15, + 'electric_scooter': 14, + 'electrical_services': 12, + 'elevator': 8, + 'email': 18, + 'emergency': 5, + 'emoji_emotions': 11, + 'emoji_events': 13, + 'emoji_food_beverage': 11, + 'emoji_nature': 11, + 'emoji_objects': 11, + 'emoji_people': 11, + 'emoji_symbols': 11, + 'emoji_transportation': 11, + 'engineering': 11, + 'enhanced_encryption': 19, + 'equalizer': 12, + 'error': 20, + 'error_outline': 16, + 'escalator': 8, + 'escalator_warning': 8, + 'euro': 12, + 'euro_symbol': 13, + 'ev_station': 11, + 'event': 19, + 'event_available': 17, + 'event_busy': 20, + 'event_note': 14, + 'event_seat': 19, + 'exit_to_app': 13, + 'expand': 11, + 'expand_circle_down': 2, + 'expand_less': 12, + 'expand_more': 14, + 'explicit': 12, + 'explore': 12, + 'explore_off': 12, + 'exposure': 12, + 'exposure_neg_1': 13, + 'exposure_neg_2': 13, + 'exposure_plus_1': 12, + 'exposure_plus_2': 12, + 'exposure_zero': 12, + 'extension': 12, + 'extension_off': 4, + 'face': 15, + 'face_retouching_natural': 16, + 'face_retouching_off': 15, + 'fact_check': 14, + 'family_restroom': 9, + 'fast_forward': 18, + 'fast_rewind': 12, + 'fastfood': 12, + 'favorite': 17, + 'favorite_border': 12, + 'fax': 2, + 'featured_play_list': 12, + 'featured_video': 12, + 'feed': 10, + 'feedback': 12, + 'female': 3, + 'fence': 6, + 'festival': 10, + 'fiber_dvr': 13, + 'fiber_manual_record': 16, + 'fiber_new': 13, + 'fiber_pin': 12, + 'fiber_smart_record': 12, + 'file_copy': 12, + 'file_download': 11, + 'file_download_done': 11, + 'file_download_off': 4, + 'file_present': 10, + 'file_upload': 11, + 'filter': 12, + 'filter_1': 12, + 'filter_2': 12, + 'filter_3': 12, + 'filter_4': 12, + 'filter_5': 12, + 'filter_6': 12, + 'filter_7': 12, + 'filter_8': 12, + 'filter_9': 12, + 'filter_9_plus': 13, + 'filter_alt': 11, + 'filter_b_and_w': 13, + 'filter_center_focus': 12, + 'filter_drama': 12, + 'filter_frames': 13, + 'filter_hdr': 13, + 'filter_list': 12, + 'filter_list_alt': 10, + 'filter_none': 13, + 'filter_tilt_shift': 14, + 'filter_vintage': 13, + 'find_in_page': 12, + 'find_replace': 12, + 'fingerprint': 12, + 'fire_extinguisher': 7, + 'fireplace': 11, + 'first_page': 11, + 'fit_screen': 15, + 'fitbit': 2, + 'fitness_center': 12, + 'flag': 18, + 'flaky': 13, + 'flare': 12, + 'flash_auto': 12, + 'flash_off': 12, + 'flash_on': 12, + 'flashlight_off': 10, + 'flashlight_on': 10, + 'flatware': 9, + 'flight': 18, + 'flight_class': 2, + 'flight_land': 13, + 'flight_takeoff': 13, + 'flip': 12, + 'flip_camera_android': 12, + 'flip_camera_ios': 12, + 'flip_to_back': 12, + 'flip_to_front': 12, + 'flutter_dash': 5, + 'fmd_bad': 15, + 'fmd_good': 15, + 'foggy': 2, + 'folder': 16, + 'folder_open': 13, + 'folder_shared': 16, + 'folder_special': 11, + 'follow_the_signs': 7, + 'font_download': 11, + 'font_download_off': 4, + 'food_bank': 7, + 'forest': 2, + 'format_align_center': 12, + 'format_align_justify': 12, + 'format_align_left': 13, + 'format_align_right': 13, + 'format_bold': 12, + 'format_clear': 12, + 'format_color_fill': 14, + 'format_color_reset': 11, + 'format_color_text': 17, + 'format_indent_decrease': 13, + 'format_indent_increase': 13, + 'format_italic': 12, + 'format_line_spacing': 12, + 'format_list_bulleted': 13, + 'format_list_numbered': 12, + 'format_list_numbered_rtl': 12, + 'format_paint': 12, + 'format_quote': 12, + 'format_shapes': 12, + 'format_size': 12, + 'format_strikethrough': 12, + 'format_textdirection_l_to_r': 13, + 'format_textdirection_r_to_l': 13, + 'format_underlined': 13, + 'fort': 2, + 'forum': 19, + 'forward': 17, + 'forward_10': 13, + 'forward_30': 13, + 'forward_5': 13, + 'forward_to_inbox': 9, + 'foundation': 6, + 'free_breakfast': 11, + 'free_cancellation': 5, + 'front_hand': 3, + 'fullscreen': 12, + 'fullscreen_exit': 12, + 'functions': 12, + 'g_mobiledata': 9, + 'g_translate': 15, + 'gamepad': 12, + 'games': 12, + 'garage': 10, + 'gavel': 14, + 'generating_tokens': 5, + 'gesture': 12, + 'get_app': 12, + 'gif': 14, + 'gif_box': 3, + 'gite': 4, + 'golf_course': 11, + 'gpp_bad': 10, + 'gpp_good': 9, + 'gpp_maybe': 10, + 'gps_fixed': 12, + 'gps_not_fixed': 12, + 'gps_off': 12, + 'grade': 16, + 'gradient': 12, + 'grading': 12, + 'grain': 12, + 'graphic_eq': 13, + 'grass': 7, + 'grid_3x3': 10, + 'grid_4x4': 10, + 'grid_goldenratio': 10, + 'grid_off': 12, + 'grid_on': 12, + 'grid_view': 12, + 'group': 19, + 'group_add': 18, + 'group_off': 5, + 'group_remove': 3, + 'group_work': 12, + 'groups': 6, + 'h_mobiledata': 9, + 'h_plus_mobiledata': 9, + 'hail': 11, + 'handyman': 12, + 'hardware': 10, + 'hd': 12, + 'hdr_auto': 10, + 'hdr_auto_select': 10, + 'hdr_enhanced_select': 11, + 'hdr_off': 11, + 'hdr_off_select': 10, + 'hdr_on': 11, + 'hdr_on_select': 10, + 'hdr_plus': 10, + 'hdr_strong': 12, + 'hdr_weak': 12, + 'headphones': 9, + 'headphones_battery': 9, + 'headset': 12, + 'headset_mic': 12, + 'headset_off': 12, + 'healing': 12, + 'health_and_safety': 7, + 'hearing': 12, + 'hearing_disabled': 14, + 'heart_broken': 2, + 'height': 11, + 'help': 13, + 'help_center': 9, + 'help_outline': 12, + 'hevc': 10, + 'hide_image': 10, + 'hide_source': 9, + 'high_quality': 12, + 'highlight': 14, + 'highlight_alt': 12, + 'highlight_off': 17, + 'hiking': 4, + 'history': 12, + 'history_edu': 11, + 'history_toggle_off': 9, + 'hive': 2, + 'holiday_village': 4, + 'home': 16, + 'home_filled': 14, + 'home_max': 10, + 'home_mini': 10, + 'home_repair_service': 12, + 'home_work': 13, + 'horizontal_distribute': 6, + 'horizontal_rule': 13, + 'horizontal_split': 15, + 'hot_tub': 12, + 'hotel': 18, + 'hotel_class': 5, + 'hourglass_bottom': 11, + 'hourglass_disabled': 12, + 'hourglass_empty': 15, + 'hourglass_full': 15, + 'hourglass_top': 11, + 'house': 11, + 'house_siding': 6, + 'houseboat': 4, + 'how_to_reg': 14, + 'how_to_vote': 15, + 'http': 11, + 'https': 16, + 'hub': 2, + 'hvac': 12, + 'ice_skating': 4, + 'icecream': 11, + 'image': 16, + 'image_aspect_ratio': 12, + 'image_not_supported': 13, + 'image_search': 12, + 'imagesearch_roller': 11, + 'import_contacts': 14, + 'import_export': 12, + 'important_devices': 11, + 'inbox': 18, + 'incomplete_circle': 3, + 'indeterminate_check_box': 18, + 'info': 20, + 'info_outline': 16, + 'input': 13, + 'insert_chart': 12, + 'insert_chart_outlined': 12, + 'insert_comment': 13, + 'insert_drive_file': 17, + 'insert_emoticon': 15, + 'insert_invitation': 12, + 'insert_link': 12, + 'insert_page_break': 2, + 'insert_photo': 12, + 'insights': 12, + 'integration_instructions': 11, + 'interests': 2, + 'interpreter_mode': 2, + 'inventory': 13, + 'inventory_2': 9, + 'invert_colors': 13, + 'invert_colors_off': 12, + 'ios_share': 13, + 'iron': 4, + 'iso': 11, + 'kayaking': 4, + 'kebab_dining': 2, + 'key': 2, + 'keyboard': 13, + 'keyboard_alt': 15, + 'keyboard_arrow_down': 16, + 'keyboard_arrow_left': 17, + 'keyboard_arrow_right': 17, + 'keyboard_arrow_up': 16, + 'keyboard_backspace': 13, + 'keyboard_capslock': 13, + 'keyboard_double_arrow_down': 2, + 'keyboard_double_arrow_left': 2, + 'keyboard_double_arrow_right': 2, + 'keyboard_double_arrow_up': 2, + 'keyboard_hide': 12, + 'keyboard_return': 13, + 'keyboard_tab': 13, + 'keyboard_voice': 17, + 'king_bed': 12, + 'kitchen': 11, + 'kitesurfing': 4, + 'label': 17, + 'label_important': 20, + 'label_important_outline': 16, + 'label_off': 13, + 'landscape': 12, + 'language': 12, + 'laptop': 13, + 'laptop_chromebook': 12, + 'laptop_mac': 12, + 'laptop_windows': 12, + 'last_page': 13, + 'launch': 17, + 'layers': 12, + 'layers_clear': 12, + 'leaderboard': 6, + 'leak_add': 12, + 'leak_remove': 12, + 'legend_toggle': 8, + 'lens': 12, + 'lens_blur': 10, + 'library_add': 13, + 'library_add_check': 17, + 'library_books': 14, + 'library_music': 13, + 'light': 9, + 'light_mode': 4, + 'lightbulb': 18, + 'lightbulb_outline': 20, + 'line_axis': 2, + 'line_style': 14, + 'line_weight': 14, + 'linear_scale': 17, + 'link': 21, + 'link_off': 11, + 'linked_camera': 15, + 'liquor': 10, + 'list': 17, + 'list_alt': 13, + 'live_help': 13, + 'live_tv': 11, + 'living': 9, + 'local_activity': 19, + 'local_airport': 14, + 'local_atm': 12, + 'local_bar': 12, + 'local_cafe': 11, + 'local_car_wash': 12, + 'local_convenience_store': 16, + 'local_dining': 21, + 'local_drink': 12, + 'local_fire_department': 14, + 'local_florist': 12, + 'local_gas_station': 12, + 'local_grocery_store': 12, + 'local_hospital': 12, + 'local_hotel': 13, + 'local_laundry_service': 16, + 'local_library': 12, + 'local_mall': 12, + 'local_movies': 12, + 'local_offer': 19, + 'local_parking': 12, + 'local_pharmacy': 12, + 'local_phone': 17, + 'local_pizza': 12, + 'local_play': 12, + 'local_police': 13, + 'local_post_office': 12, + 'local_printshop': 17, + 'local_see': 16, + 'local_shipping': 12, + 'local_taxi': 12, + 'location_city': 12, + 'location_disabled': 12, + 'location_off': 11, + 'location_on': 15, + 'location_pin': 7, + 'location_searching': 12, + 'lock': 18, + 'lock_clock': 11, + 'lock_open': 16, + 'lock_outline': 13, + 'login': 12, + 'logo_dev': 2, + 'logout': 12, + 'looks': 13, + 'looks_3': 12, + 'looks_4': 12, + 'looks_5': 12, + 'looks_6': 13, + 'looks_one': 12, + 'looks_two': 12, + 'loop': 12, + 'loupe': 12, + 'low_priority': 18, + 'loyalty': 12, + 'lte_mobiledata': 9, + 'lte_plus_mobiledata': 9, + 'luggage': 7, + 'lunch_dining': 11, + 'lyrics': 3, + 'mail': 16, + 'mail_outline': 12, + 'male': 3, + 'man': 2, + 'manage_accounts': 10, + 'manage_search': 10, + 'map': 16, + 'maps_home_work': 10, + 'maps_ugc': 13, + 'margin': 11, + 'mark_as_unread': 10, + 'mark_chat_read': 8, + 'mark_chat_unread': 8, + 'mark_email_read': 8, + 'mark_email_unread': 8, + 'markunread': 12, + 'markunread_mailbox': 12, + 'masks': 7, + 'maximize': 11, + 'media_bluetooth_off': 10, + 'media_bluetooth_on': 10, + 'mediation': 15, + 'medical_services': 12, + 'medication': 10, + 'medication_liquid': 2, + 'meeting_room': 17, + 'memory': 12, + 'menu': 13, + 'menu_book': 12, + 'menu_open': 13, + 'merge_type': 13, + 'message': 13, + 'mic': 13, + 'mic_external_off': 11, + 'mic_external_on': 11, + 'mic_none': 12, + 'mic_off': 14, + 'microwave': 6, + 'military_tech': 11, + 'minimize': 11, + 'miscellaneous_services': 12, + 'missed_video_call': 13, + 'mms': 12, + 'mobile_friendly': 12, + 'mobile_off': 12, + 'mobile_screen_share': 12, + 'mobiledata_off': 10, + 'mode': 10, + 'mode_comment': 12, + 'mode_edit': 11, + 'mode_edit_outline': 10, + 'mode_night': 10, + 'mode_of_travel': 2, + 'mode_standby': 9, + 'model_training': 13, + 'monetization_on': 12, + 'money': 11, + 'money_off': 12, + 'money_off_csred': 11, + 'monitor': 11, + 'monitor_heart': 2, + 'monitor_weight': 10, + 'monochrome_photos': 11, + 'mood': 16, + 'mood_bad': 12, + 'moped': 14, + 'more': 13, + 'more_horiz': 13, + 'more_time': 11, + 'more_vert': 19, + 'mosque': 2, + 'motion_photos_auto': 15, + 'motion_photos_off': 14, + 'motion_photos_on': 18, + 'motion_photos_pause': 8, + 'motion_photos_paused': 17, + 'mouse': 12, + 'move_down': 2, + 'move_to_inbox': 15, + 'move_up': 2, + 'movie': 12, + 'movie_creation': 12, + 'movie_filter': 13, + 'moving': 4, + 'mp': 15, + 'multiline_chart': 13, + 'multiple_stop': 7, + 'museum': 11, + 'music_note': 12, + 'music_off': 12, + 'music_video': 15, + 'my_location': 12, + 'nat': 11, + 'nature': 12, + 'nature_people': 12, + 'navigate_before': 13, + 'navigate_next': 13, + 'navigation': 12, + 'near_me': 12, + 'near_me_disabled': 6, + 'nearby_error': 10, + 'nearby_off': 10, + 'network_cell': 16, + 'network_check': 12, + 'network_locked': 17, + 'network_wifi': 16, + 'new_label': 3, + 'new_releases': 12, + 'next_plan': 16, + 'next_week': 14, + 'nfc': 12, + 'night_shelter': 6, + 'nightlife': 10, + 'nightlight': 10, + 'nightlight_round': 10, + 'nights_stay': 11, + 'no_accounts': 10, + 'no_backpack': 6, + 'no_cell': 8, + 'no_drinks': 8, + 'no_encryption': 17, + 'no_encryption_gmailerrorred': 10, + 'no_flash': 8, + 'no_food': 8, + 'no_luggage': 7, + 'no_meals': 7, + 'no_meals_ouline': 6, + 'no_meeting_room': 11, + 'no_photography': 8, + 'no_sim': 12, + 'no_stroller': 9, + 'no_transfer': 7, + 'noise_aware': 2, + 'noise_control_off': 2, + 'nordic_walking': 4, + 'north': 7, + 'north_east': 7, + 'north_west': 7, + 'not_accessible': 13, + 'not_interested': 12, + 'not_listed_location': 17, + 'not_started': 13, + 'note': 12, + 'note_add': 13, + 'note_alt': 10, + 'notes': 12, + 'notification_add': 5, + 'notification_important': 11, + 'notifications': 18, + 'notifications_active': 13, + 'notifications_none': 12, + 'notifications_off': 12, + 'notifications_paused': 12, + 'numbers': 2, + 'offline_bolt': 18, + 'offline_pin': 18, + 'offline_share': 12, + 'ondemand_video': 11, + 'online_prediction': 12, + 'opacity': 15, + 'open_in_browser': 12, + 'open_in_full': 7, + 'open_in_new': 21, + 'open_in_new_off': 4, + 'open_with': 12, + 'other_houses': 4, + 'outbound': 8, + 'outbox': 10, + 'outdoor_grill': 11, + 'outgoing_mail': 11, + 'outlet': 7, + 'outlined_flag': 12, + 'padding': 11, + 'pages': 11, + 'pageview': 12, + 'paid': 9, + 'palette': 16, + 'pan_tool': 14, + 'panorama': 17, + 'panorama_fish_eye': 13, + 'panorama_horizontal': 12, + 'panorama_horizontal_select': 11, + 'panorama_photosphere': 11, + 'panorama_photosphere_select': 12, + 'panorama_vertical': 13, + 'panorama_vertical_select': 12, + 'panorama_wide_angle': 14, + 'panorama_wide_angle_select': 12, + 'paragliding': 4, + 'park': 10, + 'party_mode': 12, + 'password': 10, + 'pattern': 11, + 'pause': 16, + 'pause_circle': 10, + 'pause_circle_filled': 15, + 'pause_circle_outline': 19, + 'pause_presentation': 12, + 'payment': 12, + 'payments': 12, + 'pedal_bike': 14, + 'pending': 17, + 'pending_actions': 7, + 'people': 23, + 'people_alt': 21, + 'people_outline': 12, + 'perm_camera_mic': 12, + 'perm_contact_calendar': 13, + 'perm_data_setting': 12, + 'perm_device_information': 13, + 'perm_identity': 12, + 'perm_media': 15, + 'perm_phone_msg': 12, + 'perm_scan_wifi': 12, + 'person': 17, + 'person_add': 16, + 'person_add_alt': 11, + 'person_add_alt_1': 13, + 'person_add_disabled': 11, + 'person_off': 4, + 'person_outline': 19, + 'person_pin': 13, + 'person_pin_circle': 17, + 'person_remove': 12, + 'person_remove_alt_1': 13, + 'person_search': 12, + 'personal_injury': 3, + 'personal_video': 13, + 'pest_control': 12, + 'pest_control_rodent': 12, + 'pets': 12, + 'phishing': 2, + 'phone': 12, + 'phone_android': 12, + 'phone_bluetooth_speaker': 12, + 'phone_callback': 13, + 'phone_disabled': 12, + 'phone_enabled': 13, + 'phone_forwarded': 13, + 'phone_in_talk': 12, + 'phone_iphone': 12, + 'phone_locked': 19, + 'phone_missed': 13, + 'phone_paused': 13, + 'phonelink': 12, + 'phonelink_erase': 12, + 'phonelink_lock': 15, + 'phonelink_off': 12, + 'phonelink_ring': 12, + 'phonelink_setup': 13, + 'photo': 12, + 'photo_album': 14, + 'photo_camera': 18, + 'photo_camera_back': 11, + 'photo_camera_front': 11, + 'photo_filter': 12, + 'photo_library': 12, + 'photo_size_select_actual': 12, + 'photo_size_select_large': 12, + 'photo_size_select_small': 12, + 'piano': 4, + 'piano_off': 4, + 'picture_as_pdf': 12, + 'picture_in_picture': 12, + 'picture_in_picture_alt': 12, + 'pie_chart': 11, + 'pie_chart_outline': 10, + 'pie_chart_outlined': 10, + 'pin': 10, + 'pin_drop': 17, + 'pin_end': 3, + 'pin_invoke': 3, + 'pivot_table_chart': 11, + 'pix': 2, + 'place': 17, + 'plagiarism': 11, + 'play_arrow': 16, + 'play_circle': 9, + 'play_circle_filled': 14, + 'play_circle_outline': 19, + 'play_disabled': 10, + 'play_for_work': 12, + 'play_lesson': 10, + 'playlist_add': 16, + 'playlist_add_check': 17, + 'playlist_add_check_circle': 2, + 'playlist_add_circle': 2, + 'playlist_play': 14, + 'plumbing': 12, + 'plus_one': 17, + 'podcasts': 9, + 'point_of_sale': 8, + 'policy': 12, + 'poll': 13, + 'polymer': 12, + 'pool': 12, + 'portable_wifi_off': 12, + 'portrait': 12, + 'post_add': 11, + 'power': 12, + 'power_input': 12, + 'power_off': 12, + 'power_settings_new': 12, + 'precision_manufacturing': 10, + 'pregnant_woman': 14, + 'present_to_all': 11, + 'preview': 7, + 'price_change': 10, + 'price_check': 10, + 'print': 16, + 'print_disabled': 12, + 'priority_high': 19, + 'privacy_tip': 12, + 'private_connectivity': 5, + 'production_quantity_limits': 7, + 'psychology': 11, + 'public': 12, + 'public_off': 8, + 'publish': 12, + 'published_with_changes': 6, + 'punch_clock': 2, + 'push_pin': 13, + 'qr_code': 12, + 'qr_code_2': 6, + 'qr_code_scanner': 7, + 'query_builder': 13, + 'query_stats': 4, + 'question_answer': 12, + 'queue': 12, + 'queue_music': 18, + 'queue_play_next': 14, + 'quickreply': 12, + 'quiz': 10, + 'r_mobiledata': 9, + 'radar': 10, + 'radio': 12, + 'radio_button_checked': 13, + 'radio_button_unchecked': 13, + 'railway_alert': 10, + 'ramen_dining': 10, + 'rate_review': 11, + 'raw_off': 10, + 'raw_on': 10, + 'read_more': 13, + 'real_estate_agent': 3, + 'receipt': 12, + 'receipt_long': 12, + 'recent_actors': 12, + 'recommend': 11, + 'record_voice_over': 12, + 'recycling': 3, + 'redeem': 12, + 'redo': 13, + 'reduce_capacity': 6, + 'refresh': 16, + 'remember_me': 10, + 'remove': 16, + 'remove_circle': 19, + 'remove_circle_outline': 12, + 'remove_done': 10, + 'remove_from_queue': 13, + 'remove_moderator': 11, + 'remove_red_eye': 12, + 'remove_shopping_cart': 12, + 'reorder': 16, + 'repeat': 12, + 'repeat_on': 12, + 'repeat_one': 12, + 'repeat_one_on': 12, + 'replay': 13, + 'replay_10': 14, + 'replay_30': 14, + 'replay_5': 14, + 'replay_circle_filled': 12, + 'reply': 20, + 'reply_all': 17, + 'report': 18, + 'report_gmailerrorred': 11, + 'report_off': 12, + 'report_problem': 18, + 'request_page': 7, + 'request_quote': 7, + 'reset_tv': 10, + 'restart_alt': 10, + 'restaurant': 16, + 'restaurant_menu': 12, + 'restore': 12, + 'restore_from_trash': 12, + 'restore_page': 12, + 'reviews': 10, + 'rice_bowl': 6, + 'ring_volume': 13, + 'roofing': 6, + 'room': 19, + 'room_preferences': 7, + 'room_service': 11, + 'rotate_90_degrees_ccw': 12, + 'rotate_90_degrees_cw': 2, + 'rotate_left': 13, + 'rotate_right': 13, + 'rounded_corner': 15, + 'route': 2, + 'router': 13, + 'rowing': 14, + 'rss_feed': 12, + 'rsvp': 10, + 'rtt': 12, + 'rule': 8, + 'rule_folder': 7, + 'run_circle': 11, + 'running_with_errors': 4, + 'rv_hookup': 12, + 'safety_divider': 7, + 'sailing': 4, + 'sanitizer': 6, + 'satellite': 12, + 'save': 12, + 'save_alt': 12, + 'save_as': 2, + 'saved_search': 11, + 'savings': 5, + 'scanner': 12, + 'scatter_plot': 12, + 'schedule': 17, + 'schedule_send': 14, + 'schema': 4, + 'school': 12, + 'science': 17, + 'score': 12, + 'screen_lock_landscape': 16, + 'screen_lock_portrait': 17, + 'screen_lock_rotation': 16, + 'screen_rotation': 12, + 'screen_search_desktop': 10, + 'screen_share': 12, + 'screenshot': 10, + 'sd': 10, + 'sd_card': 12, + 'sd_card_alert': 11, + 'sd_storage': 12, + 'search': 17, + 'search_off': 12, + 'security': 12, + 'security_update': 11, + 'security_update_good': 10, + 'security_update_warning': 10, + 'segment': 11, + 'select_all': 12, + 'self_improvement': 11, + 'sell': 10, + 'send': 20, + 'send_and_archive': 11, + 'send_time_extension': 2, + 'send_to_mobile': 11, + 'sensor_door': 8, + 'sensor_window': 8, + 'sensors': 4, + 'sensors_off': 5, + 'sentiment_dissatisfied': 11, + 'sentiment_neutral': 11, + 'sentiment_satisfied': 11, + 'sentiment_satisfied_alt': 11, + 'sentiment_very_dissatisfied': 11, + 'sentiment_very_satisfied': 11, + 'set_meal': 6, + 'settings': 19, + 'settings_accessibility': 10, + 'settings_applications': 11, + 'settings_backup_restore': 11, + 'settings_bluetooth': 11, + 'settings_brightness': 12, + 'settings_cell': 11, + 'settings_ethernet': 11, + 'settings_input_antenna': 11, + 'settings_input_component': 11, + 'settings_input_composite': 11, + 'settings_input_hdmi': 11, + 'settings_input_svideo': 11, + 'settings_overscan': 11, + 'settings_phone': 15, + 'settings_power': 11, + 'settings_remote': 11, + 'settings_suggest': 20, + 'settings_system_daydream': 12, + 'settings_voice': 11, + 'share': 18, + 'share_location': 17, + 'shield': 11, + 'shield_moon': 2, + 'shop': 12, + 'shop_2': 9, + 'shop_two': 11, + 'shopping_bag': 8, + 'shopping_basket': 12, + 'shopping_cart': 18, + 'short_text': 15, + 'shortcut': 10, + 'show_chart': 13, + 'shower': 9, + 'shuffle': 14, + 'shuffle_on': 12, + 'shutter_speed': 11, + 'sick': 6, + 'signal_cellular_0_bar': 15, + 'signal_cellular_4_bar': 12, + 'signal_cellular_alt': 12, + 'signal_cellular_connected_no_internet_0_bar': 13, + 'signal_cellular_connected_no_internet_4_bar': 13, + 'signal_cellular_no_sim': 11, + 'signal_cellular_nodata': 10, + 'signal_cellular_null': 12, + 'signal_cellular_off': 12, + 'signal_wifi_0_bar': 15, + 'signal_wifi_4_bar': 12, + 'signal_wifi_4_bar_lock': 13, + 'signal_wifi_bad': 10, + 'signal_wifi_connected_no_internet_4': 10, + 'signal_wifi_off': 17, + 'signal_wifi_statusbar_4_bar': 9, + 'signal_wifi_statusbar_connected_no_internet_4': 11, + 'signal_wifi_statusbar_null': 9, + 'sim_card': 12, + 'sim_card_alert': 11, + 'sim_card_download': 9, + 'single_bed': 11, + 'sip': 10, + 'skateboarding': 4, + 'skip_next': 16, + 'skip_previous': 16, + 'sledding': 5, + 'slideshow': 12, + 'slow_motion_video': 11, + 'smart_button': 7, + 'smart_display': 9, + 'smart_screen': 9, + 'smart_toy': 9, + 'smartphone': 16, + 'smoke_free': 12, + 'smoking_rooms': 12, + 'sms': 12, + 'sms_failed': 13, + 'snippet_folder': 7, + 'snooze': 12, + 'snowboarding': 4, + 'snowing': 2, + 'snowmobile': 4, + 'snowshoeing': 4, + 'soap': 8, + 'social_distance': 7, + 'sort': 13, + 'sort_by_alpha': 11, + 'soup_kitchen': 2, + 'source': 7, + 'south': 7, + 'south_america': 2, + 'south_east': 7, + 'south_west': 7, + 'spa': 12, + 'space_bar': 11, + 'space_dashboard': 3, + 'speaker': 16, + 'speaker_group': 12, + 'speaker_notes': 13, + 'speaker_notes_off': 13, + 'speaker_phone': 15, + 'speed': 12, + 'spellcheck': 12, + 'splitscreen': 11, + 'spoke': 2, + 'sports': 11, + 'sports_bar': 7, + 'sports_baseball': 11, + 'sports_basketball': 11, + 'sports_cricket': 11, + 'sports_esports': 11, + 'sports_football': 12, + 'sports_golf': 11, + 'sports_handball': 11, + 'sports_hockey': 11, + 'sports_kabaddi': 11, + 'sports_mma': 11, + 'sports_motorsports': 11, + 'sports_rugby': 11, + 'sports_score': 10, + 'sports_soccer': 11, + 'sports_tennis': 11, + 'sports_volleyball': 11, + 'square_foot': 11, + 'stacked_bar_chart': 11, + 'stacked_line_chart': 6, + 'stairs': 8, + 'star': 20, + 'star_border': 19, + 'star_border_purple500': 10, + 'star_half': 22, + 'star_outline': 11, + 'star_purple500': 10, + 'star_rate': 12, + 'stars': 12, + 'stay_current_landscape': 12, + 'stay_current_portrait': 12, + 'stay_primary_landscape': 12, + 'stay_primary_portrait': 12, + 'sticky_note_2': 7, + 'stop': 12, + 'stop_circle': 15, + 'stop_screen_share': 13, + 'storage': 13, + 'store': 11, + 'store_mall_directory': 12, + 'storefront': 17, + 'storm': 10, + 'straighten': 12, + 'stream': 11, + 'streetview': 12, + 'strikethrough_s': 14, + 'stroller': 8, + 'style': 12, + 'subdirectory_arrow_left': 11, + 'subdirectory_arrow_right': 11, + 'subject': 13, + 'subscript': 13, + 'subscriptions': 11, + 'subtitles': 12, + 'subtitles_off': 11, + 'subway': 11, + 'summarize': 10, + 'sunny': 2, + 'sunny_snowing': 2, + 'superscript': 12, + 'supervised_user_circle': 12, + 'supervisor_account': 13, + 'support': 11, + 'support_agent': 12, + 'surfing': 4, + 'surround_sound': 13, + 'swap_calls': 12, + 'swap_horiz': 12, + 'swap_horizontal_circle': 12, + 'swap_vert': 12, + 'swap_vertical_circle': 13, + 'swipe': 12, + 'switch_access_shortcut': 2, + 'switch_access_shortcut_add': 2, + 'switch_account': 11, + 'switch_camera': 12, + 'switch_left': 8, + 'switch_right': 9, + 'switch_video': 12, + 'synagogue': 2, + 'sync': 17, + 'sync_alt': 12, + 'sync_disabled': 16, + 'sync_problem': 21, + 'system_security_update': 10, + 'system_security_update_good': 10, + 'system_security_update_warning': 10, + 'system_update': 12, + 'system_update_alt': 13, + 'tab': 12, + 'tab_unselected': 12, + 'table_bar': 2, + 'table_chart': 12, + 'table_restaurant': 2, + 'table_rows': 17, + 'table_view': 8, + 'tablet': 12, + 'tablet_android': 14, + 'tablet_mac': 12, + 'tag': 10, + 'tag_faces': 19, + 'takeout_dining': 16, + 'tap_and_play': 12, + 'tapas': 6, + 'task': 10, + 'task_alt': 6, + 'taxi_alert': 11, + 'temple_buddhist': 2, + 'temple_hindu': 2, + 'terrain': 12, + 'text_fields': 14, + 'text_format': 12, + 'text_rotate_up': 12, + 'text_rotate_vertical': 12, + 'text_rotation_angledown': 12, + 'text_rotation_angleup': 12, + 'text_rotation_down': 12, + 'text_rotation_none': 12, + 'text_snippet': 8, + 'textsms': 12, + 'texture': 12, + 'theater_comedy': 11, + 'theaters': 12, + 'thermostat': 11, + 'thermostat_auto': 10, + 'thumb_down': 18, + 'thumb_down_alt': 11, + 'thumb_down_off_alt': 13, + 'thumb_up': 18, + 'thumb_up_alt': 11, + 'thumb_up_off_alt': 13, + 'thumbs_up_down': 12, + 'time_to_leave': 12, + 'timelapse': 12, + 'timeline': 13, + 'timer': 13, + 'timer_10': 12, + 'timer_10_select': 15, + 'timer_3': 12, + 'timer_3_select': 15, + 'timer_off': 13, + 'tips_and_updates': 3, + 'title': 11, + 'toc': 13, + 'today': 17, + 'toggle_off': 12, + 'toggle_on': 12, + 'token': 2, + 'toll': 12, + 'tonality': 12, + 'topic': 7, + 'touch_app': 13, + 'tour': 12, + 'toys': 14, + 'track_changes': 11, + 'traffic': 12, + 'train': 11, + 'tram': 11, + 'transfer_within_a_station': 12, + 'transform': 12, + 'transgender': 3, + 'transit_enterexit': 12, + 'translate': 16, + 'travel_explore': 8, + 'trending_down': 18, + 'trending_flat': 19, + 'trending_up': 19, + 'trip_origin': 12, + 'troubleshoot': 9, + 'try': 9, + 'tty': 8, + 'tune': 12, + 'tungsten': 10, + 'turned_in': 11, + 'turned_in_not': 11, + 'tv': 16, + 'tv_off': 11, + 'two_wheeler': 14, + 'umbrella': 8, + 'unarchive': 13, + 'undo': 13, + 'unfold_less': 12, + 'unfold_more': 12, + 'unpublished': 7, + 'unsubscribe': 12, + 'upcoming': 9, + 'update': 13, + 'update_disabled': 8, + 'upgrade': 12, + 'upload': 10, + 'upload_file': 10, + 'usb': 12, + 'usb_off': 4, + 'vaccines': 2, + 'verified': 11, + 'verified_user': 18, + 'vertical_align_bottom': 12, + 'vertical_align_center': 12, + 'vertical_align_top': 12, + 'vertical_distribute': 6, + 'vertical_split': 15, + 'vibration': 12, + 'video_call': 11, + 'video_camera_back': 10, + 'video_camera_front': 10, + 'video_label': 11, + 'video_library': 13, + 'video_settings': 12, + 'video_stable': 10, + 'videocam': 16, + 'videocam_off': 12, + 'videogame_asset': 11, + 'videogame_asset_off': 4, + 'view_agenda': 14, + 'view_array': 13, + 'view_carousel': 13, + 'view_column': 13, + 'view_comfy': 13, + 'view_compact': 12, + 'view_day': 13, + 'view_headline': 11, + 'view_in_ar': 11, + 'view_list': 14, + 'view_module': 13, + 'view_quilt': 14, + 'view_sidebar': 13, + 'view_stream': 13, + 'view_week': 13, + 'vignette': 12, + 'villa': 4, + 'visibility': 16, + 'visibility_off': 12, + 'voice_chat': 12, + 'voice_over_off': 11, + 'voicemail': 12, + 'volume_down': 13, + 'volume_down_alt': 3, + 'volume_mute': 13, + 'volume_off': 17, + 'volume_up': 13, + 'volunteer_activism': 10, + 'vpn_key': 12, + 'vpn_lock': 17, + 'vrpano': 10, + 'wallpaper': 13, + 'warning': 17, + 'warning_amber': 16, + 'wash': 8, + 'watch': 12, + 'watch_later': 17, + 'water': 11, + 'water_damage': 6, + 'water_drop': 3, + 'waterfall_chart': 11, + 'waves': 11, + 'waving_hand': 3, + 'wb_auto': 12, + 'wb_cloudy': 12, + 'wb_incandescent': 12, + 'wb_iridescent': 11, + 'wb_shade': 10, + 'wb_sunny': 12, + 'wb_twighlight': 10, + 'wb_twilight': 9, + 'wc': 11, + 'web': 13, + 'web_asset': 12, + 'web_asset_off': 4, + 'web_stories': 4, + 'weekend': 12, + 'west': 7, + 'whatsapp': 2, + 'whatshot': 17, + 'wheelchair_pickup': 8, + 'where_to_vote': 16, + 'widgets': 13, + 'wifi': 12, + 'wifi_calling': 11, + 'wifi_calling_3': 10, + 'wifi_lock': 13, + 'wifi_off': 12, + 'wifi_protected_setup': 12, + 'wifi_tethering': 12, + 'wifi_tethering_error': 3, + 'wifi_tethering_off': 10, + 'window': 9, + 'wine_bar': 6, + 'woman': 2, + 'work': 12, + 'work_off': 12, + 'work_outline': 12, + 'workspace_premium': 3, + 'workspaces': 9, + 'workspaces_filled': 13, + 'workspaces_outline': 13, + 'wrap_text': 13, + 'wrong_location': 17, + 'wysiwyg': 8, + 'yard': 9, + 'youtube_searched_for': 12, + 'zoom_in': 11, + 'zoom_out': 11, + 'zoom_out_map': 17, +}; diff --git a/test/icon_binding_test.dart b/test/icon_binding_test.dart index dba37574..89d66d62 100644 --- a/test/icon_binding_test.dart +++ b/test/icon_binding_test.dart @@ -18,12 +18,19 @@ void main() { expect(icon.style, MaterialIconStyle.outlined); expect( icon.toSvgUrl(), - 'https://fonts.gstatic.com/s/i/materialiconsoutlined/kitchen/v1/24px.svg', + 'https://fonts.gstatic.com/s/i/materialiconsoutlined/kitchen/v11/24px.svg', ); final MaterialIcon filled = iconWithName(base, 'Air').icon! as MaterialIcon; expect(filled.name, 'air'); expect(filled.style, MaterialIconStyle.filled); + // Its SVG is only served from the version it was added in. + expect( + filled.toSvgUrl(), + 'https://fonts.gstatic.com/s/i/materialicons/air/v10/24px.svg', + ); + // Icons at version 1 aren't listed. + expect((iconWithName(base, 'abc').icon! as MaterialIcon).version, 1); expect( (iconWithName(base, 'power_rounded').icon! as MaterialIcon).style, MaterialIconStyle.rounded, From 040d2039fc055709e0c8ed39e789a6f2a1a397e1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 01:29:28 +0000 Subject: [PATCH 22/22] PassiveTextFieldWidget takes the scoped values its bound text reads Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AjsYaRkTHNKszSHjd3j264 --- CHANGELOG.md | 2 + .../passive_text_field_transformer.dart | 12 +++-- test/text_field_scoped_values_test.dart | 45 +++++++++++++++++++ 3 files changed, 56 insertions(+), 3 deletions(-) create mode 100644 test/text_field_scoped_values_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index c0d71332..1ccaf34b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,6 +46,8 @@ `${route.…}` with sample params. - An icon bound to a name loads the SVG of that icon's version (`materialIconVersions`), so icons added after version 1 (`air`) draw. +- `PassiveTextFieldWidget.scopedValues`: the values a field's bound and + initial text read, so an editor can show variable defaults. - Nodes with corners take a `cornerRadius` binding: a number or `[top-left, top-right, bottom-right, bottom-left]`. - A row or column that hugs its cross axis and stretches its children gives 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 7e6f2d71..afebb749 100644 --- a/lib/src/transformers/node_transformers/passive_text_field_transformer.dart +++ b/lib/src/transformers/node_transformers/passive_text_field_transformer.dart @@ -139,6 +139,10 @@ class PassiveTextFieldWidget extends StatefulWidget { final FormFieldValidator? validator; final bool? autofocus; + /// The values the field's bound and initial text read, in place of the + /// running app's: an editor's design canvas passes its variable defaults. + final ScopedValues? scopedValues; + PassiveTextFieldWidget({ super.key, required this.node, @@ -152,6 +156,7 @@ class PassiveTextFieldWidget extends StatefulWidget { this.autovalidateMode, this.validator, this.autofocus, + this.scopedValues, }) : variablesOverrides = variables ?? []; @override @@ -171,7 +176,7 @@ class _PassiveTextFieldWidgetState extends State { return PropertyValueDelegate.getPropertyValue( widget.node, 'inputValue', - scopedValues: ScopedValues.of(context), + scopedValues: widget.scopedValues ?? ScopedValues.of(context), ); } @@ -181,7 +186,7 @@ class _PassiveTextFieldWidgetState extends State { return PropertyValueDelegate.substituteVariables( text, nullSubstitutionMode: widget.settings.nullSubstitutionMode, - scopedValues: ScopedValues.of(context), + scopedValues: widget.scopedValues ?? ScopedValues.of(context), ); } @@ -203,7 +208,8 @@ class _PassiveTextFieldWidgetState extends State { super.didUpdateWidget(oldWidget); // Update controller text if this text field is bound to a variable. - final ScopedValues scopedValues = ScopedValues.of(context); + final ScopedValues scopedValues = + widget.scopedValues ?? ScopedValues.of(context); // Get the value of bound variable and update the controller text if it's // different from the current controller text. diff --git a/test/text_field_scoped_values_test.dart b/test/text_field_scoped_values_test.dart new file mode 100644 index 00000000..7db733b9 --- /dev/null +++ b/test/text_field_scoped_values_test.dart @@ -0,0 +1,45 @@ +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() { + // A design canvas shows a form field bound to a variable with the + // variable's default value. + testWidgets('a text field shows its bound value from given scoped values', ( + tester, + ) async { + final TextFieldNode node = TextFieldNode( + id: 'name', + name: 'Name', + basicBoxLocal: NodeBox(0, 0, 200, 44), + properties: TextFieldProperties(), + )..variables['inputValue'] = r'${rigName}'; + + await tester.pumpWidget( + ChangeNotifierProvider( + create: (_) => CodelesslyContext.empty(), + child: MaterialApp( + home: Material( + child: PassiveTextFieldWidget( + node: node, + settings: const WidgetBuildSettings(debugLabel: 'test'), + scopedValues: ScopedValues.preview( + variables: { + 'rigName': VariableData( + id: 'rigName', + name: 'rigName', + value: 'Juniper', + ), + }, + ), + ), + ), + ), + ), + ); + + expect(find.text('Juniper'), findsOneWidget); + }); +}