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