diff --git a/CHANGELOG.md b/CHANGELOG.md index 2370c90f..8fc978c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,35 @@ Needs the `codelessly_api` release after 6.2.0 (signature, loop animation, side sheet, confirm presentation, toast styling, `contentFit` and `shrinkWrapRows`). +### Motion, triggers and lists + +- `NodeMotion` animates a node's `MotionModel`: transforms, 3D tilt, spin, + loops, entrances (from rest or `enterFrom`, replayed on `replayKey`) and + delays. +- Timer and key press triggers on screens; swipe triggers (the mouse wheel + swipes too) and double tap on frames, rectangles, buttons, icons and text. +- Stacked and wrapping lists; vertical, inverted and rectangular-thumb + sliders with bound `min`/`max`. + +### Formulas and variables + +- `ComputedVariables` helpers for editors: a computed variable's inputs, + cycles and preview values over default values. +- `duration`, `seconds`, `slug`, `luminance`, `findIndex`, `sortByDesc`, + multi-key `sortBy`, text `indexOf`/`slice`/`take`/`skip`/`reverse`, + object literals and `money(v, decimals)` / `money(v, symbol, 'auto')`. + +### Style bindings and navigation + +- Effects, stroke weight, corner radius, font weight and icon bind as + property variables (`StyleBindings`); image fills listen to the variables + in their URL. +- A screen's route is set before its load actions run, and a link's + parameters also set the variables they name. `CodelesslyWidget.initialScreen`. +- A screen variable shadows an app variable of the same name in Set + variable; dashed strokes draw their caps, and a zero pattern no longer + hangs. + ### Nodes and actions - `PassiveSignatureTransformer` draws a `SignatureNode`: strokes in diff --git a/lib/codelessly_sdk.dart b/lib/codelessly_sdk.dart index 0b3a19bf..39584502 100644 --- a/lib/codelessly_sdk.dart +++ b/lib/codelessly_sdk.dart @@ -45,7 +45,10 @@ export 'src/ui/related_documents.dart'; export 'src/ui/show_more.dart'; export 'src/ui/zoomable_box.dart'; export 'src/ui/skeleton.dart'; +export 'src/ui/node_motion.dart'; +export 'src/ui/swipe_detector.dart'; export 'src/ui/stripe_painter.dart'; +export 'src/utils/computed_variables.dart'; export 'src/utils/cupertino_icons_by_codepoint.dart'; export 'src/utils/extensions.dart'; export 'src/utils/flutter_icons_data_map.dart'; diff --git a/lib/src/codelessly.dart b/lib/src/codelessly.dart index f373a2fa..a3e57f70 100644 --- a/lib/src/codelessly.dart +++ b/lib/src/codelessly.dart @@ -7,6 +7,7 @@ import 'package:firebase_auth/firebase_auth.dart'; import 'package:firebase_core/firebase_core.dart'; import 'package:flutter/widgets.dart'; import 'package:http/http.dart'; +import 'package:provider/provider.dart'; import '../codelessly_sdk.dart'; import 'functions/app_auth.dart'; @@ -943,7 +944,7 @@ class Codelessly { _currentNavigatedCanvasId = canvasId; final ModalRoute? modal = ModalRoute.of(context); if (canvasId != null && modal is! PopupRoute) { - route.value = CodelesslyRoute.of(canvasId, modal?.settings.arguments); + route.value = _routeOf(canvasId, modal); _openPendingRoute(context, canvasId); } for (final listener in [..._navigationListeners.values]) { @@ -951,6 +952,47 @@ class Codelessly { } } + /// The route of screen [canvasId] shown by [modal]: the link that opened + /// the app when it names this screen, or the parameters it was opened + /// with. + CodelesslyRoute _routeOf(String canvasId, ModalRoute? modal) { + final CodelesslyRoute? pending = _resolveLink(pendingRoute); + if (pending != null && pending.canvasId == canvasId) return pending; + return CodelesslyRoute.of(canvasId, modal?.settings.arguments); + } + + /// Makes [route] screen [canvasId]'s before its load actions run, so they + /// can read `${route.name}`, and applies the link that opened the app to + /// it when the link names this screen (navigation listeners only hear of + /// a screen after its first frame). + void prepareRoute(BuildContext context, String canvasId) { + final ModalRoute? modal = ModalRoute.of(context); + if (modal is PopupRoute) return; + route.value = _routeOf(canvasId, modal); + final CodelesslyRoute? pending = _resolveLink(pendingRoute); + if (pending != null && pending.canvasId == canvasId) { + setVariablesFromRoute(context, pending); + } + } + + /// Sets the variables a link's parameters name to their values, so + /// `//?selectedId=kind-of-blue` opens with `selectedId` set, + /// as well as passing them as the screen's route parameters. Parameters + /// that name no variable are only route parameters. + static void setVariablesFromRoute( + BuildContext context, + CodelesslyRoute route, + ) { + if (route.params.isEmpty) return; + final CodelesslyContext? codelesslyContext = + Provider.of(context, listen: false); + if (codelesslyContext == null) return; + for (final MapEntry param in route.params.entries) { + if (codelesslyContext.findVariableByName(param.key) == null) continue; + codelesslyContext.updateVariable(param.key, param.value); + } + } + /// The link for [route]: the screen's URL path with its parameters filled /// in when it has one (`guide/bat-star` for a screen at `guide/:id`; the /// app's first screen without a path is the empty path), else @@ -971,7 +1013,9 @@ class Codelessly { if (pending == null) return; _pendingRoute = null; final CodelesslyRoute? link = _resolveLink(pending); - if (link == null || link.canvasId == canvasId) return; + if (link == null) return; + setVariablesFromRoute(context, link); + if (link.canvasId == canvasId) return; unawaited( FunctionsRepository.navigate( context, diff --git a/lib/src/functions/functions_repository.dart b/lib/src/functions/functions_repository.dart index cf3207b0..4810b85d 100644 --- a/lib/src/functions/functions_repository.dart +++ b/lib/src/functions/functions_repository.dart @@ -1356,10 +1356,21 @@ ${response.body.contains('{') ? const JsonEncoder.withIndent(' ').convert(json. final ScopedValues scopedValues = ActionChain.scopedValuesOf(context); final CodelesslyContext codelesslyContext = context .read(); - final variableNotifier = codelesslyContext.findVariableByName( - action.variable.name, + final variableNotifier = codelesslyContext.findVariable( + id: action.variable.id, + name: action.variable.name, ); if (variableNotifier == null) return false; + // A computed variable's value comes from its formula: setting it does + // nothing. + if (variableNotifier.value.isComputed) { + DebugLogger.instance.printInfo( + 'Variable ${variableNotifier.value.name} is computed from a formula ' + 'and cannot be set.', + name: name, + ); + return false; + } String newValue = substituteNewValue( action.newValue, @@ -1423,6 +1434,7 @@ ${response.body.contains('{') ? const JsonEncoder.withIndent(' ').convert(json. final ValueNotifier? variableNotifier = payload.variables[id]; if (variableNotifier == null) return false; + if (variableNotifier.value.isComputed) return false; variableNotifier.value = variableNotifier.value.copyWith(value: value); @@ -1452,6 +1464,7 @@ ${response.body.contains('{') ? const JsonEncoder.withIndent(' ').convert(json. .findVariableByName(match.name); if (propertyNotifier == null) return false; + if (propertyNotifier.value.isComputed) return false; if (match.name != match.fullPath && (propertyNotifier.value.type == VariableType.map || diff --git a/lib/src/transformers/node_transformers/passive_canvas_transformer.dart b/lib/src/transformers/node_transformers/passive_canvas_transformer.dart index 62f7e813..32c92274 100644 --- a/lib/src/transformers/node_transformers/passive_canvas_transformer.dart +++ b/lib/src/transformers/node_transformers/passive_canvas_transformer.dart @@ -738,6 +738,7 @@ class _PassiveCanvasWidgetState extends State { codelessly?.removeNavigationListener('canvas-${widget.node.id}'); _gateUser?.removeListener(_enforceSignIn); _stopWatchingValues(); + _stopTimers(); super.dispose(); } @@ -825,9 +826,76 @@ class _PassiveCanvasWidgetState extends State { super.didUpdateWidget(oldWidget); if (widget.node != oldWidget.node) { onLoadActions = collectOnLoadActions(); + if (_timersStarted) _startTimers(); } } + final List _timers = []; + bool _timersStarted = false; + + /// Runs each timer reaction every [TriggerModel.intervalMs] while the + /// canvas is shown. A tick is skipped while the previous one's actions + /// are still running, so slow actions don't pile up. + void _startTimers() { + _stopTimers(); + _timersStarted = true; + for (final Reaction reaction in widget.node.reactions.whereTriggerType( + TriggerType.timer, + )) { + if (!reaction.action.enabled) continue; + bool busy = false; + _timers.add( + Timer.periodic( + Duration(milliseconds: reaction.trigger.effectiveIntervalMs), + (_) async { + if (!mounted || busy) return; + busy = true; + try { + await FunctionsRepository.triggerAction( + context, + TriggerType.timer, + reactions: [reaction], + ); + } finally { + busy = false; + } + }, + ), + ); + } + } + + void _stopTimers() { + for (final Timer timer in _timers) { + timer.cancel(); + } + _timers.clear(); + } + + /// Runs the key press reactions for [event]'s key. Returns whether one + /// matched. + KeyEventResult _onKey(FocusNode _, KeyEvent event) { + if (event is! KeyDownEvent && event is! KeyRepeatEvent) { + return KeyEventResult.ignored; + } + final String pressed = normalizeKeyLabel(event.logicalKey.keyLabel); + final List matching = [ + for (final Reaction reaction in widget.node.reactions.whereTriggerType( + TriggerType.keyPress, + )) + if (reaction.action.enabled && + normalizeKeyLabel(reaction.trigger.key ?? '') == pressed) + reaction, + ]; + if (matching.isEmpty) return KeyEventResult.ignored; + FunctionsRepository.triggerAction( + context, + TriggerType.keyPress, + reactions: matching, + ); + return KeyEventResult.handled; + } + @override void didChangeDependencies() { super.didChangeDependencies(); @@ -844,8 +912,10 @@ class _PassiveCanvasWidgetState extends State { if (_redirected) return; } + context.read()?.prepareRoute(context, widget.node.id); triggerOnLoadActions(context); _watchValues(context); + _startTimers(); } List collectOnLoadActions() => widget.node.reactions @@ -915,7 +985,22 @@ class _PassiveCanvasWidgetState extends State { statusBarIconBrightness: brightness, statusBarBrightness: brightness, ), - child: Builder(builder: widget.builder), + child: + widget.settings.isPreview || + !widget.node.reactions.any( + (reaction) => reaction.trigger.type == TriggerType.keyPress, + ) + ? Builder(builder: widget.builder) + : Focus( + autofocus: true, + onKeyEvent: _onKey, + child: Builder(builder: widget.builder), + ), ); } } + +/// A key label in the form key press triggers compare: lower case, without +/// spaces, so `Arrow Right`, `ArrowRight` and `arrowright` all match. +String normalizeKeyLabel(String label) => + label.replaceAll(' ', '').toLowerCase(); diff --git a/lib/src/transformers/node_transformers/passive_list_view_transformer.dart b/lib/src/transformers/node_transformers/passive_list_view_transformer.dart index 84c03ae9..1694a712 100644 --- a/lib/src/transformers/node_transformers/passive_list_view_transformer.dart +++ b/lib/src/transformers/node_transformers/passive_list_view_transformer.dart @@ -75,21 +75,59 @@ class PassiveListViewWidget extends StatelessWidget { } /// Sizes the list by [ViewportLayout] around [builder]. + /// + /// A wrapping list is sized by [WrapNodeBox] instead, and the flag given + /// to [builder] says whether its runs scroll along the cross axis. Widget box(ViewportContentBuilder builder, {required bool canShrinkWrap}) => - ViewportNodeBox( - node: node, + node.properties.isWrapping + ? WrapNodeBox(node: node, builder: builder) + : ViewportNodeBox( + node: node, + scrollDirection: node.scrollDirection, + canShrinkWrap: canShrinkWrap, + builder: builder, + ); + + /// Builds one item, placed as the editor lays it out; see [ListItemBox]. + /// The item of a wrapping list keeps its own size; see [WrapItemBox]. + Widget buildItem(BaseNode item, BuildContext context) { + final Widget child = manager.buildWidgetByID( + item.id, + context, + settings: settings, + ); + if (node.properties.isWrapping) { + return WrapItemBox( + item: item, scrollDirection: node.scrollDirection, - canShrinkWrap: canShrinkWrap, - builder: builder, + itemExtent: node.properties.itemExtent, + child: child, ); + } + return ListItemBox( + item: item, + scrollDirection: node.scrollDirection, + itemExtent: node.properties.itemExtent, + child: child, + ); + } - /// Builds one item, placed as the editor lays it out; see [ListItemBox]. - Widget buildItem(BaseNode item, BuildContext context) => ListItemBox( - item: item, - scrollDirection: node.scrollDirection, - itemExtent: node.properties.itemExtent, - child: manager.buildWidgetByID(item.id, context, settings: settings), - ); + /// The items of a wrapping list; [scrolls] comes from [WrapNodeBox]. + Widget wrapped(bool scrolls, int itemCount, IndexedWidgetBuilder builder) => + WrappedItems( + itemCount: itemCount, + itemBuilder: builder, + direction: node.scrollDirection.flutterAxis, + spacing: node.properties.wrapSpacing, + runSpacing: node.properties.wrapRunSpacing, + alignment: node.properties.wrapAlignment.flutterWrapAlignment, + padding: node.padding.flutterEdgeInsets, + scrolls: scrolls, + physics: node.physics.flutterScrollPhysics(false), + keyboardDismissBehavior: + node.keyboardDismissBehavior.flutterKeyboardDismissBehavior, + clipBehavior: node.clipsContent ? Clip.hardEdge : Clip.none, + ); @override Widget build(BuildContext context) { @@ -196,6 +234,25 @@ class PassiveListViewWidget extends StatelessWidget { final int itemCount = items.itemCount; + // A wrapping list builds every item at once, so it shows the + // loaded items without paging further. + if (node.properties.isWrapping) { + final int loaded = snapshot.docs.length; + return wrapped(shrinkWrap, loaded, (context, index) { + final doc = snapshot.docs[index]; + return IndexedItemProvider( + key: ValueKey(index), + item: IndexedItem(index, {'id': doc.id, ...doc.data()}), + child: Builder( + builder: (context) => KeyedSubtree( + key: ValueKey(index), + child: buildItem(item, context), + ), + ), + ); + }); + } + // Item Count always exists. return ListViewBuilder( primary: node.primary, @@ -302,6 +359,29 @@ class PassiveListViewWidget extends StatelessWidget { itemBuilder: builder, ); + // A stacked list draws its items on top of each other, the first on + // top, inside the list's own box. It never scrolls. + if (node.properties.stacked) { + final int count = itemCount ?? 0; + return box( + canShrinkWrap: false, + (context, _) => StackedItems( + itemCount: count, + clipBehavior: node.clipsContent ? Clip.hardEdge : Clip.none, + itemBuilder: itemBuilder, + ), + ); + } + + // A wrapping list flows its items onto further lines, all of them built, + // so an endless one shows nothing. + if (node.properties.isWrapping) { + return box( + canShrinkWrap: true, + (context, scrolls) => wrapped(scrolls, itemCount ?? 0, itemBuilder), + ); + } + // An endless list with items of free extent ends after a viewport's // worth of items that take no space, so they cannot be built without // limit; see [EndlessList]. A fixed item extent is bounded by Flutter. @@ -462,3 +542,131 @@ class ListViewItemSeparator extends StatelessWidget { } } } + +/// The items of a stacked list: drawn on top of each other in the list's +/// box, each at its top center, the first item on top. +class StackedItems extends StatelessWidget { + /// Creates a [StackedItems]. + const StackedItems({ + super.key, + required this.itemCount, + required this.itemBuilder, + this.clipBehavior = Clip.none, + }); + + /// How many items. + final int itemCount; + + /// Builds an item. + final IndexedWidgetBuilder itemBuilder; + + /// Whether items moved outside the box are clipped. + final Clip clipBehavior; + + @override + Widget build(BuildContext context) => Stack( + clipBehavior: clipBehavior, + children: [ + // Built last to first, so the first item paints over the rest and + // takes taps first. + for (int index = itemCount - 1; index >= 0; index--) + Positioned.fill( + key: ValueKey(index), + child: Align( + alignment: Alignment.topCenter, + child: itemBuilder(context, index), + ), + ), + ], + ); +} + +/// The items of a wrapping list: laid out by a [Wrap] along [direction], +/// flowing onto further lines (runs) when a line is full, each item at its +/// own size. +/// +/// With [scrolls], the runs scroll along the cross axis when they don't fit +/// the box; otherwise the box grows with them. +class WrappedItems extends StatelessWidget { + /// Creates a [WrappedItems]. + const WrappedItems({ + super.key, + required this.itemCount, + required this.itemBuilder, + this.direction = Axis.horizontal, + this.spacing = 0, + this.runSpacing = 0, + this.alignment = WrapAlignment.start, + this.padding = EdgeInsets.zero, + this.scrolls = false, + this.physics, + this.keyboardDismissBehavior = ScrollViewKeyboardDismissBehavior.manual, + this.clipBehavior = Clip.none, + }); + + /// How many items. + final int itemCount; + + /// Builds an item. + final IndexedWidgetBuilder itemBuilder; + + /// The wrap direction: the list's scroll direction. + final Axis direction; + + /// The gap between two items of a line. + final double spacing; + + /// The gap between two lines. + final double runSpacing; + + /// How the items of a line are placed along it. + final WrapAlignment alignment; + + /// The list's padding, around the runs. + final EdgeInsets padding; + + /// Whether the runs scroll along the cross axis. + final bool scrolls; + + /// The physics of that scroll. + final ScrollPhysics? physics; + + /// See [ScrollView.keyboardDismissBehavior]. + final ScrollViewKeyboardDismissBehavior keyboardDismissBehavior; + + /// Whether the runs are clipped to the box. + final Clip clipBehavior; + + @override + Widget build(BuildContext context) { + final Widget wrap = Wrap( + direction: direction, + spacing: spacing, + runSpacing: runSpacing, + alignment: alignment, + children: [ + for (int index = 0; index < itemCount; index++) + itemBuilder(context, index), + ], + ); + if (scrolls) { + return DraggableScrollConfiguration( + child: SingleChildScrollView( + primary: false, + scrollDirection: direction == Axis.horizontal + ? Axis.vertical + : Axis.horizontal, + padding: padding, + physics: physics, + keyboardDismissBehavior: keyboardDismissBehavior, + clipBehavior: clipBehavior, + child: wrap, + ), + ); + } + final Widget padded = Padding(padding: padding, child: wrap); + return clipBehavior == Clip.none + ? padded + : ClipRect(clipBehavior: clipBehavior, child: padded); + } +} diff --git a/lib/src/transformers/node_transformers/passive_slider_transformer.dart b/lib/src/transformers/node_transformers/passive_slider_transformer.dart index a7ecc97f..445f06d1 100644 --- a/lib/src/transformers/node_transformers/passive_slider_transformer.dart +++ b/lib/src/transformers/node_transformers/passive_slider_transformer.dart @@ -1,3 +1,5 @@ +import 'dart:math' as math; + import 'package:codelessly_api/codelessly_api.dart'; import 'package:flutter/material.dart'; @@ -56,11 +58,17 @@ class PassiveSliderWidget extends StatelessWidget { final WidgetBuildSettings settings; final List variablesOverrides; + /// The values the slider's bound `value`, `min` and `max` read, such as a + /// design canvas's variable defaults. When null they come from [context] + /// with [variablesOverrides]. + 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 +91,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, @@ -94,6 +101,20 @@ class PassiveSliderWidget extends StatelessWidget { scopedValues: scopedValues, ) ?? node.value; + final double min = + PropertyValueDelegate.getPropertyValue( + node, + 'min', + scopedValues: scopedValues, + ) ?? + node.properties.min; + final double max = + PropertyValueDelegate.getPropertyValue( + node, + 'max', + scopedValues: scopedValues, + ) ?? + node.properties.max; return TransformerSlider( node: node, @@ -101,6 +122,8 @@ class PassiveSliderWidget extends StatelessWidget { variablesOverrides: variablesOverrides, onChanged: (value) => onChanged(context, value), value: value, + min: min, + max: max, ); } } @@ -113,6 +136,8 @@ class TransformerSlider extends StatelessWidget { required this.onChanged, required this.value, this.variablesOverrides = const [], + this.min, + this.max, }); final SliderNode node; @@ -121,8 +146,18 @@ class TransformerSlider extends StatelessWidget { final ValueChanged? onChanged; final double value; + /// The slider's minimum, when bound; [SliderProperties.min] otherwise. + final double? min; + + /// The slider's maximum, when bound; [SliderProperties.max] otherwise. + /// Never less than the minimum. + final double? max; + @override Widget build(BuildContext context) { + final double min = this.min ?? node.properties.min; + final double max = math.max(min, this.max ?? node.properties.max); + final bool vertical = node.properties.orientation == AxisC.vertical; final SliderTrackShape trackShape = switch (node.properties.trackShape) { SliderTrackShapeEnum.rectangle => const RectangularSliderTrackShape(), SliderTrackShapeEnum.roundedRectangle => @@ -158,9 +193,12 @@ class TransformerSlider extends StatelessWidget { }; final bool showsLabel = showValueIndicator != ShowValueIndicator.never; - return SizedBox( - width: node.basicBoxLocal.width, - height: node.basicBoxLocal.height, + // A vertical slider is a horizontal one turned a quarter turn + // counterclockwise, so min sits at the bottom. RotatedBox turns its + // layout and hit testing too, so the whole node box drags the thumb. + final Widget slider = SizedBox( + width: vertical ? node.basicBoxLocal.height : node.basicBoxLocal.width, + height: vertical ? node.basicBoxLocal.width : node.basicBoxLocal.height, child: SliderTheme( data: SliderThemeData( trackHeight: node.properties.trackHeight, @@ -171,11 +209,33 @@ 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) { + SliderThumbShapeEnum.circle => StrokedSliderThumbShape( + enabledThumbRadius: node.properties.showThumb + ? node.properties.thumbRadius + : 0, + strokeColor: node.properties.thumbStrokeColor?.toFlutterColor(), + strokeWidth: node.properties.thumbStrokeWidth, + ), + // The slider lays out horizontally and a vertical one is turned + // a quarter turn, so its thumb's width and height swap. + SliderThumbShapeEnum.roundedRect => RoundedRectSliderThumbShape( + size: !node.properties.showThumb + ? Size.zero + : vertical + ? Size( + node.properties.thumbHeight, + node.properties.thumbWidth, + ) + : Size( + node.properties.thumbWidth, + node.properties.thumbHeight, + ), + cornerRadius: node.properties.thumbCornerRadius, + strokeColor: node.properties.thumbStrokeColor?.toFlutterColor(), + strokeWidth: node.properties.thumbStrokeWidth, + ), + }, trackShape: trackShape, // GappedSliderTrackShape requires a gap; 6 is the Material 3 // (2024) default. @@ -208,22 +268,186 @@ class TransformerSlider extends StatelessWidget { ), ), child: Slider( - value: value.clamp(node.properties.min, node.properties.max), + value: value.clamp(min, max), onChanged: onChanged, autofocus: node.properties.autofocus, - min: node.properties.min, - max: node.properties.max, + min: min, + max: max, divisions: node.properties.isDiscrete ? node.properties.divisions : null, label: showsLabel ? getSliderLabel(node, value) : null, secondaryTrackValue: node.properties.secondaryTrackValue?.clamp( - node.properties.min, - node.properties.max, + min, + max, ), padding: node.properties.padding?.flutterEdgeInsets, ), ), ); + + final TextDirection direction = + Directionality.maybeOf(context) ?? TextDirection.ltr; + final TextDirection flipped = direction == TextDirection.ltr + ? TextDirection.rtl + : TextDirection.ltr; + // Vertical runs bottom to top in left to right text, so an inverted + // vertical slider runs top to bottom. + final Widget directed = node.properties.inverted + ? Directionality(textDirection: flipped, child: slider) + : Directionality(textDirection: direction, child: slider); + + if (!vertical) { + return node.properties.inverted ? directed : slider; + } + return SizedBox( + width: node.basicBoxLocal.width, + height: node.basicBoxLocal.height, + child: RotatedBox(quarterTurns: 3, child: directed), + ); + } +} + +/// A [RoundSliderThumbShape] with an optional stroke drawn inside its edge. +class StrokedSliderThumbShape extends RoundSliderThumbShape { + /// Creates a [StrokedSliderThumbShape]. + const StrokedSliderThumbShape({ + super.enabledThumbRadius, + this.strokeColor, + this.strokeWidth = 0, + }); + + /// Color of the stroke; no stroke when null. + final Color? strokeColor; + + /// Width of the stroke; no stroke when 0. + final double strokeWidth; + + @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, + }) { + super.paint( + context, + center, + activationAnimation: activationAnimation, + enableAnimation: enableAnimation, + isDiscrete: isDiscrete, + labelPainter: labelPainter, + parentBox: parentBox, + sliderTheme: sliderTheme, + textDirection: textDirection, + value: value, + textScaleFactor: textScaleFactor, + sizeWithOverflow: sizeWithOverflow, + ); + final Color? color = strokeColor; + if (color == null || strokeWidth <= 0 || enabledThumbRadius <= 0) return; + final double width = math.min(strokeWidth, enabledThumbRadius); + context.canvas.drawCircle( + center, + enabledThumbRadius - width / 2, + Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = width + ..color = color, + ); + } +} + +/// A rounded rectangle slider thumb, like a fader cap, with an optional +/// stroke drawn inside its edge. [size] is in the slider's own (horizontal) +/// layout: its width runs along the track. +class RoundedRectSliderThumbShape extends SliderComponentShape { + /// Creates a [RoundedRectSliderThumbShape]. + const RoundedRectSliderThumbShape({ + required this.size, + this.cornerRadius = 0, + this.strokeColor, + this.strokeWidth = 0, + this.elevation = 1, + this.pressedElevation = 6, + }); + + /// Size of the thumb; along the track, then across it. + final Size size; + + /// Radius of the thumb's corners, at most half its shorter side. + final double cornerRadius; + + /// Color of the stroke; no stroke when null. + final Color? strokeColor; + + /// Width of the stroke; no stroke when 0. + final double strokeWidth; + + /// Shadow elevation of the thumb at rest. + final double elevation; + + /// Shadow elevation of the thumb while it is dragged. + final double pressedElevation; + + @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, + }) { + if (size.isEmpty) return; + final Canvas canvas = context.canvas; + final Color color = ColorTween( + begin: sliderTheme.disabledThumbColor, + end: sliderTheme.thumbColor, + ).evaluate(enableAnimation)!; + final double radius = math.max( + 0, + math.min(cornerRadius, size.shortestSide / 2), + ); + final RRect rect = RRect.fromRectAndRadius( + Rect.fromCenter(center: center, width: size.width, height: size.height), + Radius.circular(radius), + ); + final double shadow = Tween( + begin: elevation, + end: pressedElevation, + ).evaluate(activationAnimation); + if (shadow > 0 && !debugDisableShadows) { + canvas.drawShadow(Path()..addRRect(rect), Colors.black, shadow, true); + } + canvas.drawRRect(rect, Paint()..color = color); + + final Color? stroke = strokeColor; + if (stroke == null || strokeWidth <= 0) return; + final double width = math.min(strokeWidth, size.shortestSide / 2); + canvas.drawRRect( + rect.deflate(width / 2), + Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = width + ..color = stroke, + ); } } diff --git a/lib/src/transformers/passive_transformer_manager.dart b/lib/src/transformers/passive_transformer_manager.dart index bde5e928..5897ac98 100644 --- a/lib/src/transformers/passive_transformer_manager.dart +++ b/lib/src/transformers/passive_transformer_manager.dart @@ -148,12 +148,15 @@ class PassiveNodeTransformerManager extends WidgetNodeTransformerManager { widget = applyLoopAnimation(context, styled, widget); if (settings.withReactions) { widget = wrapWithReaction(context, styled, widget); + widget = wrapWithSwipes(context, styled, widget); + widget = wrapWithDoubleTap(context, styled, widget); widget = wrapWithSemantics(styled, widget); widget = wrapWithCursorReactions(context, styled, widget); widget = applyTooltipAndZoom(context, styled, widget); } if (settings.withRotation) { widget = applyWidgetRotation(context, styled, widget); + widget = applyWidgetMotion(context, styled, widget); } if (settings.withConstraints) { widget = applyWidgetConstraints(styled, widget); @@ -202,8 +205,9 @@ class PassiveNodeTransformerManager extends WidgetNodeTransformerManager { /// variables or formulas, or set value actions give it at runtime: `fill` /// (a text's color for text nodes), `stroke`, `opacity`, `width`, /// `height`, `flex` (its share of an expanded row or column, so bars - /// can be sized proportionally) and `rotation` (in degrees). Returns - /// [node] itself when nothing changes it. + /// can be sized proportionally), `rotation` (in degrees), and the + /// properties of [StyleBindings] (effects, stroke weight, corner radius, + /// font weight, icon). Returns [node] itself when nothing changes it. BaseNode applyStyleOverrides(BuildContext context, BaseNode node) { final ScopedValues scopedValues = ScopedValues.of(context); T? value(String property) => @@ -280,6 +284,9 @@ class PassiveNodeTransformerManager extends WidgetNodeTransformerManager { patch['rotation'] = rotation.round(); } + // Effects, stroke weight, corner radius, font weight and icon. + StyleBindings.addTo(patch, node, scopedValues: scopedValues); + // The states shown around the node (a property-override variant, its // own or an ancestor's interaction states) win over bindings. final Map? statePatch = PropertyOverrideScope.patchFor( diff --git a/lib/src/transformers/transformers.dart b/lib/src/transformers/transformers.dart index 81109f83..82f208a7 100644 --- a/lib/src/transformers/transformers.dart +++ b/lib/src/transformers/transformers.dart @@ -17,6 +17,7 @@ export 'utils/property_overrides.dart'; export 'utils/property_value_delegate.dart'; export 'utils/scoped_values.dart'; export 'utils/stroke_painter.dart'; +export 'utils/style_bindings.dart'; export 'utils/text_utils.dart'; export 'utils/variable_span.dart'; export 'utils/viewport_sizing.dart'; diff --git a/lib/src/transformers/utils/stroke_painter.dart b/lib/src/transformers/utils/stroke_painter.dart index cf937610..421fecb9 100644 --- a/lib/src/transformers/utils/stroke_painter.dart +++ b/lib/src/transformers/utils/stroke_painter.dart @@ -88,8 +88,17 @@ class StrokePainter extends CustomPainter { // If the stroke is dotted, we take a completely different rendering // route. + // A pattern that never advances would loop forever, and draws nothing. + if (dashPattern.isNotEmpty && + dashPattern.fold(0, (sum, dash) => sum + max(0, dash)) <= 0) { + return; + } if (dashPattern.isNotEmpty && strokeSide != StrokeSide.all) { _paintDashedSide(canvas, size, paint); + } else if (dashPattern.isNotEmpty && + strokeCap != StrokeCapEnum.none && + strokeWidth > 0) { + _paintCappedDashes(canvas, size, paint, isLine, isHorizLine, isVertLine); } else if (dashPattern.isNotEmpty) { final Path path = Path(); @@ -136,7 +145,9 @@ class StrokePainter extends CustomPainter { while (totalDistance < metric.length) { final double dashDistance = dashArray.nextValue(); - if (draw) { + // A butt-capped dash of no length covers nothing (a stroke width of + // zero would paint a hairline instead). + if (draw && dashDistance > 0) { // Now that we're about to draw a singular dash, we need to do it // manually. We do this with tangent calculations. Luckily for us, // Flutter has a convenient method for that! @@ -239,6 +250,21 @@ class StrokePainter extends CustomPainter { }; final double length = (end - start).distance; if (length <= 0 || dashPattern.every((dash) => dash <= 0)) return; + if (strokeCap != StrokeCapEnum.none && strokeWidth > 0) { + // Round and square caps extend every dash by half the stroke width, + // so even a dash of zero length draws a dot. + paint + ..style = PaintingStyle.stroke + ..strokeWidth = strokeWidth; + _paintDashesAlong( + canvas, + Path() + ..moveTo(start.dx, start.dy) + ..lineTo(end.dx, end.dy), + paint, + ); + return; + } final LoopingList dashes = LoopingList(dashPattern); final Offset direction = (end - start) / length; paint @@ -261,6 +287,123 @@ class StrokePainter extends CustomPainter { } } + /// Paints a dashed stroke on every side whose dashes have round or square + /// caps, as SVG and Figma draw them: each dash runs along the stroke's + /// center line and its caps extend it by half the stroke width at both + /// ends, so a dash of zero length is a dot as wide as the stroke. + /// + /// Dashes without caps are painted as "needles" across the stroke (see + /// [paint]) instead; caps on those would stick out of the stroke's band. + void _paintCappedDashes( + Canvas canvas, + Size size, + Paint paint, + bool isLine, + bool isHorizLine, + bool isVertLine, + ) { + final Path path = Path(); + if (isLine) { + path + ..moveTo( + isHorizLine ? strokeWidth / 2 : 0, + isVertLine ? strokeWidth / 2 : 0, + ) + ..lineTo( + isHorizLine ? strokeWidth / 2 : size.width, + isVertLine ? strokeWidth / 2 : size.height, + ); + } else { + // The center line of the stroke: half its width inside the box for an + // inside stroke, on the edge for a centered one, and half its width + // outside for an outside one. + final double grow = switch (strokeAlign) { + StrokeAlignC.inside => -strokeWidth / 2, + StrokeAlignC.center => 0, + StrokeAlignC.outside => strokeWidth / 2, + }; + if (boxShape == BoxShape.circle) { + final double diameter = min(size.width, size.height) + grow * 2; + if (diameter <= 0) return; + path.addOval( + Rect.fromCenter( + center: Offset(size.width / 2, size.height / 2), + width: diameter, + height: diameter, + ), + ); + } else { + final RRect box = borderRadius + .toRRect(Rect.fromLTWH(0, 0, size.width, size.height)) + .inflate(grow); + if (box.width <= 0 || box.height <= 0) return; + path.addRRect(box); + } + } + paint + ..style = PaintingStyle.stroke + ..strokeWidth = strokeWidth; + _paintDashesAlong(canvas, path, paint); + } + + /// Paints [dashPattern] along [path] with [paint], whose cap is round or + /// square. Dashes shorter than [_dotLength] are painted as dots: the cap + /// alone, which a stroke of (almost) no length may not draw. + void _paintDashesAlong(Canvas canvas, Path path, Paint paint) { + final LoopingList dashes = LoopingList(dashPattern); + final Paint dotPaint = Paint() + ..color = paint.color + ..style = PaintingStyle.fill + ..isAntiAlias = paint.isAntiAlias; + for (final PathMetric metric in path.computeMetrics()) { + double distance = 0; + bool draw = true; + while (distance < metric.length) { + final double dash = max(0.0, dashes.nextValue()); + if (draw) { + final double end = min(distance + dash, metric.length); + if (end - distance < _dotLength) { + final Tangent? tangent = metric.getTangentForOffset(distance); + if (tangent != null) _paintDot(canvas, tangent, dotPaint); + } else { + canvas.drawPath(metric.extractPath(distance, end), paint); + } + } + distance += dash; + draw = !draw; + } + } + } + + /// Dashes shorter than this are painted as a dot of the cap's shape. + static const double _dotLength = 0.01; + + /// Paints a dash of no length at [tangent]: a circle as wide as the + /// stroke for a round cap, a square turned along the path for a square + /// one. + void _paintDot(Canvas canvas, Tangent tangent, Paint paint) { + final double radius = strokeWidth / 2; + if (strokeCap == StrokeCapEnum.round) { + canvas.drawCircle(tangent.position, radius, paint); + return; + } + final double length = tangent.vector.distance; + final Offset along = length == 0 + ? Offset(radius, 0) + : tangent.vector / length * radius; + final Offset across = Offset(-along.dy, along.dx); + final Offset center = tangent.position; + canvas.drawPath( + Path() + ..moveTo((center - along - across).dx, (center - along - across).dy) + ..lineTo((center + along - across).dx, (center + along - across).dy) + ..lineTo((center + along + across).dx, (center + along + across).dy) + ..lineTo((center - along + across).dx, (center - along + across).dy) + ..close(), + paint, + ); + } + @override bool shouldRepaint(covariant StrokePainter oldDelegate) => oldDelegate.borderRadius != borderRadius || diff --git a/lib/src/transformers/utils/style_bindings.dart b/lib/src/transformers/utils/style_bindings.dart new file mode 100644 index 00000000..228b6422 --- /dev/null +++ b/lib/src/transformers/utils/style_bindings.dart @@ -0,0 +1,264 @@ +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:collection/collection.dart'; + +import 'property_value_delegate.dart'; +import 'scoped_values.dart'; + +/// The style properties, beyond `fill`, `stroke`, `opacity`, `width` and +/// `height`, that conditions, bound variables or formulas can set at runtime. +/// +/// Each is a key of [BaseNode.variables] whose value is a variable path or a +/// formula, e.g. `variables['strokeWeight'] = '${= selected ? 2 : 1}'`: +/// +/// * [effectsVisible] (bool): shows or hides every effect of the node. +/// * [effectVisible] `effect-visible-` (bool): shows or hides one +/// effect. Wins over [effectsVisible]. +/// * [effectsColor] (color): the color of every drop and inner shadow. +/// * [shadowColor] `shadow-color-` (color): the color of one +/// shadow. Wins over [effectsColor]. +/// * [effectsOffsetX], [effectsOffsetY], [effectsSpread] (number): the +/// offset and spread of every drop and inner shadow. +/// * [effectsBlur] (number): the blur radius of every effect. +/// * [strokeWeight] (number): the node's stroke width. +/// * [cornerRadius] (number): the radius of every corner. A corner radius +/// JSON value (e.g. `[8, true, 'circular']`) is accepted too. +/// * [fontWeight] (number 100-900, or `w700`, `bold`, `normal`): the weight +/// of every span of a text node. +/// * [icon] (string): the name of the icon an icon node shows, in the +/// naming of its icon model (`star`, `star_border`, ...). Its family and +/// style stay. An icon model JSON map replaces the icon as a whole. +abstract final class StyleBindings { + /// Shows or hides every effect of the node. + static const String effectsVisible = 'effectsVisible'; + + /// The color of every drop and inner shadow. + static const String effectsColor = 'effectsColor'; + + /// The x offset of every drop and inner shadow. + static const String effectsOffsetX = 'effectsOffsetX'; + + /// The y offset of every drop and inner shadow. + static const String effectsOffsetY = 'effectsOffsetY'; + + /// The blur radius of every effect. + static const String effectsBlur = 'effectsBlur'; + + /// The spread of every drop and inner shadow. + static const String effectsSpread = 'effectsSpread'; + + /// The stroke width of the node. + static const String strokeWeight = 'strokeWeight'; + + /// The radius of every corner of the node. + static const String cornerRadius = 'cornerRadius'; + + /// The font weight of every span of a text node. + static const String fontWeight = 'fontWeight'; + + /// The name of the icon an icon node shows. + static const String icon = 'icon'; + + /// Shows or hides the effect [effectId]. + static String effectVisible(String effectId) => 'effect-visible-$effectId'; + + /// The color of the shadow [effectId]. + static String shadowColor(String effectId) => 'shadow-color-$effectId'; + + /// Adds to [patch], a partial node JSON, the values that the bindings of + /// these properties give [node], evaluated with [scopedValues]. A property + /// whose binding is missing, evaluates to null or gives the node's own + /// value adds nothing. + /// + /// Text spans already in [patch] (a text's bound color) keep their + /// changes. + static void addTo( + Map patch, + BaseNode node, { + required ScopedValues scopedValues, + }) { + T? value(String property) => + PropertyValueDelegate.getPropertyValue( + node, + property, + scopedValues: scopedValues, + ); + + if (node is BlendMixin && node.effects.isNotEmpty) { + final List effects = _effects(node.effects, value); + if (!const ListEquality().equals(effects, node.effects)) { + patch['effects'] = [for (final effect in effects) effect.toJson()]; + } + } + + if (node is GeometryMixin) { + final double? weight = value(strokeWeight); + if (weight != null && weight != node.strokeWeight) { + patch['strokeWeight'] = weight < 0 ? 0.0 : weight; + } + } + + if (node is CornerMixin) { + final CornerRadius? radius = _cornerRadius(value(cornerRadius)); + if (radius != null && radius != node.cornerRadius) { + patch['cornerRadius'] = radius.toJson(); + } + } + + if (node is TextNode) { + final FontWeightNumeric? weight = parseFontWeight( + value(fontWeight), + ); + if (weight != null && + node.textMixedProps.any((prop) => prop.fontName.weight != weight)) { + final List spans = + patch['textMixedProps'] as List? ?? + [for (final prop in node.textMixedProps) prop.toJson()]; + patch['textMixedProps'] = [ + for (final (int index, Map span) in spans.cast().indexed) + { + ...span, + // A span's JSON leaves out a default font name. + 'fontName': node.textMixedProps[index].fontName + .copyWith(weight: weight) + .toJson(), + }, + ]; + } + } + + if (node is IconNode) { + final IconModel? bound = _icon( + node.properties.icon.icon, + value(icon), + ); + if (bound != null && bound != node.properties.icon.icon) { + final Map? properties = patch['properties'] as Map?; + final Map current = properties ?? node.properties.toJson(); + patch['properties'] = { + ...current, + 'icon': { + ...(current['icon'] as Map? ?? node.properties.icon.toJson()), + 'icon': bound.toJson(), + }, + }; + } + } + } + + static List _effects( + List effects, + T? Function(String property) value, + ) { + final bool? allVisible = value(effectsVisible); + final ColorRGBA? color = value(effectsColor); + final double? offsetX = value(effectsOffsetX); + final double? offsetY = value(effectsOffsetY); + final double? blur = value(effectsBlur); + final double? spread = value(effectsSpread); + + return [ + for (final Effect effect in effects) + () { + final bool isShadow = + effect.type == EffectType.dropShadow || + effect.type == EffectType.innerShadow; + final bool visible = + value(effectVisible(effect.id)) ?? + allVisible ?? + effect.visible; + if (!isShadow) { + return effect.copyWith( + visible: visible, + radius: blur == null || blur < 0 ? null : blur, + ); + } + final Vec offset = effect.offset ?? const Vec(0, 0); + return effect.copyWith( + visible: visible, + color: value(shadowColor(effect.id)) ?? color, + offset: offsetX == null && offsetY == null + ? null + : Vec(offsetX ?? offset.x, offsetY ?? offset.y), + radius: blur == null || blur < 0 ? null : blur, + spread: spread, + ); + }(), + ]; + } + + static CornerRadius? _cornerRadius(Object? value) { + if (value == null) return null; + if (value is num || value is String) { + final double? radius = value is num + ? value.toDouble() + : double.tryParse(value as String); + if (radius == null) return null; + return CornerRadius.all( + RadiusModel.circular(radius < 0 ? 0 : radius), + linked: true, + ); + } + try { + return CornerRadius.fromJson(value); + } catch (_) { + return null; + } + } + + /// The font weight [value] names: a number from 100 to 900 (rounded to the + /// nearest hundred), `w700`, or a name such as `bold`, `normal` or + /// `semibold`. Null when it names none. + static FontWeightNumeric? parseFontWeight(Object? value) { + if (value == null) return null; + if (value is FontWeightNumeric) return value; + num? number = value is num ? value : null; + if (value is String) { + final String text = value.trim().toLowerCase().replaceAll( + RegExp('[ _-]'), + '', + ); + number = + num.tryParse(text) ?? + num.tryParse(text.startsWith('w') ? text.substring(1) : '') ?? + switch (text) { + 'thin' || 'hairline' => 100, + 'extralight' || 'ultralight' => 200, + 'light' => 300, + 'normal' || 'regular' || 'plain' => 400, + 'medium' => 500, + 'semibold' || 'demibold' => 600, + 'bold' => 700, + 'extrabold' || 'ultrabold' => 800, + 'black' || 'heavy' => 900, + _ => null, + }; + } + if (number == null || number.isNaN) return null; + final int index = ((number / 100).round() - 1).clamp(0, 8); + return FontWeightNumeric.values[index]; + } + + /// The icon [value] names: [current] with the name [value] gives it, or + /// the icon an icon model JSON [value] describes. + static IconModel? _icon(IconModel? current, Object? value) { + if (value == null) return null; + if (value is Map) { + try { + return IconModel.fromJson(Map.from(value)); + } catch (_) { + return null; + } + } + final String name = value.toString().trim(); + if (name.isEmpty || current == null || name == current.name) { + return null; + } + return switch (current) { + // Google serves every Material icon at version 1; a later version of + // one icon need not exist for another. + final MaterialIcon icon => icon.copyWith(name: name, version: 1), + final MaterialSymbol icon => icon.copyWith(name: name), + _ => null, + }; + } +} diff --git a/lib/src/transformers/utils/text_utils.dart b/lib/src/transformers/utils/text_utils.dart index ca5d5bda..7ed9bf37 100644 --- a/lib/src/transformers/utils/text_utils.dart +++ b/lib/src/transformers/utils/text_utils.dart @@ -375,7 +375,9 @@ class TextUtils { // Not supported yet but good to have. final List shadows = effects - .where((element) => element.type == EffectType.dropShadow) + .where( + (element) => element.type == EffectType.dropShadow && element.visible, + ) .map( (d) => Shadow( offset: Offset( diff --git a/lib/src/transformers/utils/variable_span.dart b/lib/src/transformers/utils/variable_span.dart index aeae73da..a19abd6c 100644 --- a/lib/src/transformers/utils/variable_span.dart +++ b/lib/src/transformers/utils/variable_span.dart @@ -15,6 +15,40 @@ abstract final class VariablePreview { /// fall back to placeholders. static ScopedValues? Function(BaseNode? node)? scopeResolver; + /// The values that [node] sees on the design canvas where it is built in + /// [context], or null to fall back to placeholders. + /// + /// [scopeResolver] only knows the node, and a list's item template is one + /// node that every row of the list builds: it can only give the sample + /// item of the nearest data-bound list (its first). The row being built + /// is known from [context]: its [IndexedItemProvider] gives that row's + /// own item, as at runtime, so each row previews its own data. + /// + /// A row without an item of its own (a list that only sets an item count, + /// or a row past the end of the data) keeps the sample item but reads + /// its own `index`. + static ScopedValues? scopeOf( + BaseNode? node, { + BuildContext? context, + IndexedItem? item, + }) { + final ScopedValues? values = scopeResolver?.call(node); + if (values == null) return values; + final IndexedItem? row = + item ?? (context == null ? null : IndexedItemProvider.maybeOf(context)); + if (row == null || row == values.indexedItem) return values; + return ScopedValues.preview( + variables: values.variables, + codelesslyContext: values.codelesslyContext, + screenSize: values.screenSize, + parentSize: values.parentSize, + data: values.data, + indexedItem: row.item != null || values.indexedItem == null + ? row + : IndexedItem(row.index, values.indexedItem!.item), + ); + } + /// Accessors that read a property of a value rather than name it, so the /// placeholder for `${item.title.initial}` is named after `title`. static const Set _accessorNames = { @@ -47,17 +81,7 @@ abstract final class VariablePreview { TextStyle? style, IndexedItem? item, }) { - ScopedValues? scopedValues = scopeResolver?.call(node); - if (scopedValues != null && item?.item != null) { - scopedValues = ScopedValues.preview( - variables: scopedValues.variables, - codelesslyContext: scopedValues.codelesslyContext, - screenSize: scopedValues.screenSize, - parentSize: scopedValues.parentSize, - data: scopedValues.data, - indexedItem: item, - ); - } + final ScopedValues? scopedValues = scopeOf(node, item: item); if (scopedValues != null) { final String value = PropertyValueDelegate.substituteVariables( expression, @@ -150,8 +174,8 @@ class VariableSpan extends TextSpan { /// The variable expression, such as `${user.name}`. final String variable; - /// Creates a [VariableSpan] for the [variable] expression of [node], - /// built in the list, grid or page [item] if any. + /// Creates a [VariableSpan] for the [variable] expression of [node], in + /// list row [item] when it is built for one. VariableSpan({ required this.variable, BaseNode? node, diff --git a/lib/src/transformers/utils/viewport_sizing.dart b/lib/src/transformers/utils/viewport_sizing.dart index 37b76831..2c29acdb 100644 --- a/lib/src/transformers/utils/viewport_sizing.dart +++ b/lib/src/transformers/utils/viewport_sizing.dart @@ -226,6 +226,115 @@ class ListItemBox extends StatelessWidget { } } +/// Sizes a wrapping list node (see `ListViewProperties.wrap`) and builds its +/// content. The flag given to [builder] says whether the runs must scroll +/// along the cross axis: true when the box has a set extent there. +/// +/// Unlike a viewport, a [Wrap] can measure its items on both axes, so on +/// each axis: +/// +/// * expanded: fill when the incoming extent is bounded, else the design +/// size. +/// * shrinkWrap: size to the runs (on the wrap axis, the longest line that +/// fits the incoming extent; on the cross axis, all the runs). +/// * fixed, locked, flexible: the design size. +class WrapNodeBox extends StatelessWidget { + /// The list node. + final ListViewNode node; + + /// Builds the content inside the resolved box. + final ViewportContentBuilder builder; + + /// Creates a [WrapNodeBox]. + const WrapNodeBox({super.key, required this.node, required this.builder}); + + /// The extent of [node] along one axis whose fit is [fit] and design size + /// [design], under an incoming extent that is [bounded] or not. Null + /// sizes to the runs. + static double? extent(SizeFit fit, double design, bool bounded) => + switch (fit) { + SizeFit.expanded => bounded ? double.infinity : design, + SizeFit.shrinkWrap => null, + SizeFit.fixed || SizeFit.locked || SizeFit.flexible => design, + }; + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + final double? width = extent( + node.horizontalFit, + node.basicBoxLocal.width, + constraints.hasBoundedWidth, + ); + final double? height = extent( + node.verticalFit, + node.basicBoxLocal.height, + constraints.hasBoundedHeight, + ); + final double? cross = node.scrollDirection == AxisC.horizontal + ? height + : width; + return SizedBox( + width: width, + height: height, + child: builder(context, cross != null), + ); + }, + ); + } +} + +/// Sizes the item [child] of a wrapping list: the item keeps its own size +/// on both axes. An expanded or flexible item, which has nothing to fill in +/// a run, takes its design size; with [itemExtent] the item takes that +/// extent along the wrap direction. +class WrapItemBox extends StatelessWidget { + /// The item node. + final BaseNode item; + + /// The list's scroll axis: the wrap direction. + final AxisC scrollDirection; + + /// The list's fixed item extent, if any. + final double? itemExtent; + + /// The built item. + final Widget child; + + /// Creates a [WrapItemBox]. + const WrapItemBox({ + super.key, + required this.item, + required this.scrollDirection, + required this.itemExtent, + required this.child, + }); + + static bool _fills(SizeFit fit) => + fit == SizeFit.expanded || fit == SizeFit.flexible; + + @override + Widget build(BuildContext context) { + final bool vertical = scrollDirection == AxisC.vertical; + double? width = _fills(item.horizontalFit) + ? item.outerBoxLocal.width + : null; + double? height = _fills(item.verticalFit) + ? item.outerBoxLocal.height + : null; + if (itemExtent != null) { + if (vertical) { + height = itemExtent; + } else { + width = itemExtent; + } + } + if (width == null && height == null) return child; + return SizedBox(width: width, height: height, child: child); + } +} + /// Whether [node] or any node below it builds a widget that cannot report /// intrinsic sizes: a list, grid or page view (a viewport behind a /// [LayoutBuilder]), a scrolling row, column or frame (a [LayoutBuilder] diff --git a/lib/src/transformers/widget_node_transformer_manager.dart b/lib/src/transformers/widget_node_transformer_manager.dart index c254e6ea..8eb6305f 100644 --- a/lib/src/transformers/widget_node_transformer_manager.dart +++ b/lib/src/transformers/widget_node_transformer_manager.dart @@ -169,6 +169,69 @@ abstract class WidgetNodeTransformerManager ); } + /// The motion [node] shows now: its [BaseNode.motion] with the values + /// bound through `motion.` variables resolved from [scopedValues], + /// and `motion.replayKey` resolved to text. Null when the node doesn't + /// move. + static MotionModel? resolveMotion( + BaseNode node, { + required ScopedValues scopedValues, + }) { + MotionModel? motion = node.motion; + for (final String property in MotionModel.properties) { + final String key = MotionModel.variableKey(property); + if (!node.variables.containsKey(key)) continue; + final Object? value = PropertyValueDelegate.getPropertyValue( + node, + key, + scopedValues: scopedValues, + ); + final double? number = switch (value) { + num n => n.toDouble(), + String s => double.tryParse(s.trim()), + bool b => b ? 1 : 0, + _ => null, + }; + if (number == null || !number.isFinite) continue; + motion = (motion ?? MotionModel.none).withValue(property, number); + } + const String replay = MotionModel.replayKeyProperty; + if (node.variables.containsKey(MotionModel.variableKey(replay))) { + final Object? value = PropertyValueDelegate.getPropertyValue( + node, + MotionModel.variableKey(replay), + scopedValues: scopedValues, + ); + motion = (motion ?? MotionModel.none).copyWith( + replayKey: value?.toString(), + clearReplayKey: value == null, + ); + } + return motion; + } + + /// Draws [widget] with [node]'s motion: its transform, transitions and + /// spin. [animate] false (the design canvas) shows the values without + /// animating or spinning. + Widget applyWidgetMotion( + BuildContext context, + BaseNode node, + Widget widget, { + ScopedValues? scopedValues, + bool animate = true, + }) { + if (node.motion == null && + !node.variables.keys.any((key) => key.startsWith('motion.'))) { + return widget; + } + final MotionModel? motion = resolveMotion( + node, + scopedValues: scopedValues ?? ScopedValues.of(context), + ); + if (motion == null) return widget; + return NodeMotion(motion: motion, animate: animate, child: widget); + } + /// Convenience method to handle widget margins. Widget applyWidgetMargins(BaseNode node, Widget widget) { if (node.margin == EdgeInsetsModel.zero) return widget; @@ -432,6 +495,54 @@ abstract class WidgetNodeTransformerManager } } + /// Runs [node]'s swipe reactions when it is swiped, or scrolled with a + /// mouse wheel (scrolling down swipes up, as content moves up). + Widget wrapWithSwipes(BuildContext context, BaseNode node, Widget widget) { + if (node is! ReactionMixin) return widget; + final List swipes = (node as ReactionMixin).reactions + .where( + (reaction) => + TriggerType.swipes.contains(reaction.trigger.type) && + reaction.action.enabled, + ) + .toList(); + if (swipes.isEmpty) return widget; + return SwipeDetector( + onSwipe: (TriggerType direction) => FunctionsRepository.triggerAction( + context, + direction, + reactions: swipes, + ), + child: widget, + ); + } + + /// Runs [node]'s double tap reactions. + /// + /// The double tap recognizer is only added when a double tap reaction + /// exists, since it makes single taps wait out the double tap timeout + /// (300ms) before they fire `click`. + Widget wrapWithDoubleTap(BuildContext context, BaseNode node, Widget widget) { + if (node is! ReactionMixin) return widget; + final List reactions = (node as ReactionMixin).reactions + .where( + (reaction) => + reaction.trigger.type == TriggerType.doubleTap && + reaction.action.enabled, + ) + .toList(); + if (reactions.isEmpty) return widget; + return GestureDetector( + behavior: HitTestBehavior.opaque, + onDoubleTap: () => FunctionsRepository.triggerAction( + context, + TriggerType.doubleTap, + reactions: reactions, + ), + child: widget, + ); + } + /// Runs the [TriggerType.cursorEnter], [TriggerType.cursorExit] and /// [TriggerType.hover] reactions of [node] when a mouse cursor enters, /// leaves or moves over it. diff --git a/lib/src/ui/codelessly_context.dart b/lib/src/ui/codelessly_context.dart index 48c8c976..af06b399 100644 --- a/lib/src/ui/codelessly_context.dart +++ b/lib/src/ui/codelessly_context.dart @@ -203,18 +203,33 @@ class CodelesslyContext with ChangeNotifier, Equatable { /// Returns a reverse-lookup of the [VariableData] associated with a given /// [name]. + /// + /// When a screen variable and a shared (app or page) variable have the same + /// name, the screen's own variable wins, as it does when bindings read + /// [variableNamesMap]: shared variables are loaded first, so the last + /// variable with the name is the one in use. Observable? findVariableByName(String? name) => variables.values - .firstWhereOrNull((variable) => variable.value.name == name); + .lastWhereOrNull((variable) => variable.value.name == name); + + /// The variable with [id], or else the one in use with [name]. + Observable? findVariable({String? id, String? name}) => + (id == null ? null : variables[id]) ?? findVariableByName(name); /// Allows to easily [value] of a variable with a given [name]. /// Returns false if the variable does not exist. /// Returns true if the variable was updated successfully. + /// A computed variable (see [VariableData.formula]) can't be set: this + /// returns false and leaves it unchanged. bool updateVariable(String name, Object? value) { final ValueNotifier? variable = findVariableByName(name); if (variable == null) { log('[CodelesslyContext] Variable with name $name does not exist.'); return false; } + if (variable.value.isComputed) { + log('[CodelesslyContext] Variable $name is computed and read-only.'); + return false; + } final String newValue = value == null ? '' : '$value'; // If the value is the same, then the underlying value notifier will not diff --git a/lib/src/ui/codelessly_widget.dart b/lib/src/ui/codelessly_widget.dart index 4bf8d0ce..bf6123f2 100644 --- a/lib/src/ui/codelessly_widget.dart +++ b/lib/src/ui/codelessly_widget.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:codelessly_api/codelessly_api.dart'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; @@ -58,6 +59,13 @@ class CodelesslyWidget extends StatefulWidget { /// This cannot be null if no controller is provided. final String? layoutID; + /// The screen to open first instead of the entry screen, when no + /// [layoutID] is given: a canvas id, or a canvas name matched ignoring + /// case, spaces, dashes and underscores (so `record-form` opens the + /// canvas named "Record form"). Unknown screens fall back to the entry + /// screen. A site uses this for links such as `//`. + final String? initialScreen; + /// The [Codelessly] instance to use. /// /// By default, if this is null, the global instance is used, which is @@ -161,6 +169,7 @@ class CodelesslyWidget extends StatefulWidget { CodelesslyWidget({ super.key, this.layoutID, + this.initialScreen, this.controller, this.codelessly, this.config, @@ -222,6 +231,27 @@ class CodelesslyWidget extends StatefulWidget { /// Retrieves the canvas ID of [layoutID] for a layout of [width] logical /// pixels. Returns null if the layout does not exist or has no canvas for /// that width. + /// The layout holding the screen [screen] names: a canvas id, or a canvas + /// name compared without case, spaces, dashes or underscores. Null when + /// [screen] is null or names no canvas. + static String? layoutIdForScreen(SDKPublishModel model, String? screen) { + if (screen == null || screen.trim().isEmpty) return null; + String normalize(String value) => + value.toLowerCase().replaceAll(RegExp(r'[\s_-]+'), ''); + final String wanted = normalize(Uri.decodeComponent(screen)); + for (final SDKPublishLayout layout in model.layouts.values) { + for (final MapEntry> canvas + in layout.canvases.entries) { + final BaseNode? node = canvas.value[canvas.key]; + if (canvas.key == screen || + (node != null && normalize(node.name) == wanted)) { + return layout.id; + } + } + } + return null; + } + static String? canvasIDForWidth( String? layoutID, SDKPublishModel model, @@ -364,6 +394,8 @@ class _CodelesslyWidgetState extends State { SchedulerBinding.instance.addPostFrameCallback((timeStamp) { if (!mounted) return; for (final notifier in codelesslyContext.variables.values) { + // Computed variables compute their value from the new data. + if (notifier.value.isComputed) continue; notifier.value = notifier.value.copyWith( value: widget.data[notifier.value.name], ); @@ -510,6 +542,7 @@ class _CodelesslyWidgetState extends State { final SDKPublishModel model = snapshot.data!; effectiveLayoutID = model.resolveLayoutId(_effectiveController.layoutID) ?? + CodelesslyWidget.layoutIdForScreen(model, widget.initialScreen) ?? model.entryLayoutId; if (effectiveLayoutID == null) { diff --git a/lib/src/ui/node_motion.dart b/lib/src/ui/node_motion.dart new file mode 100644 index 00000000..9c9a6c88 --- /dev/null +++ b/lib/src/ui/node_motion.dart @@ -0,0 +1,286 @@ +import 'dart:async'; +import 'dart:math' as math; + +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:flutter/widgets.dart'; + +/// The Flutter curve for a [MotionCurve]. +Curve flutterCurveOf(MotionCurve curve) => switch (curve) { + MotionCurve.linear => Curves.linear, + MotionCurve.easeInOut => Curves.easeInOut, + MotionCurve.easeOut => Curves.easeOut, + MotionCurve.easeIn => Curves.easeIn, + MotionCurve.easeOutBack => Curves.easeOutBack, + MotionCurve.easeOutCubic => Curves.easeOutCubic, +}; + +/// The transform [motion] draws, without spin, around [origin] in a box of +/// [size]. [spinTurns] adds a spin angle. +Matrix4 motionMatrix(MotionModel motion, {double spinTurns = 0}) { + final matrix = Matrix4.identity(); + if (motion.perspective > 0 && (motion.rotateX != 0 || motion.rotateY != 0)) { + matrix.setEntry(3, 2, 0.0012 * motion.perspective); + } + matrix + ..translateByDouble(motion.translateX, motion.translateY, 0, 1) + ..scaleByDouble(motion.scale, motion.scale, 1, 1) + ..rotateZ(motion.rotate * math.pi / 180 + spinTurns * 2 * math.pi) + ..rotateX(motion.rotateX * math.pi / 180) + ..rotateY(motion.rotateY * math.pi / 180); + return matrix; +} + +/// Draws [child] with a node's [motion]: a transform over its layout that +/// animates whenever its values change, plus an optional continuous spin. +/// +/// [motion] holds the values to show now, with bound variables already +/// resolved. When they change the widget animates to them over +/// [MotionModel.durationMs]. When [animate] is false (the design canvas) it +/// jumps to them and doesn't spin. +class NodeMotion extends StatefulWidget { + /// Creates a [NodeMotion]. + const NodeMotion({ + super.key, + required this.motion, + required this.child, + this.animate = true, + }); + + /// The values to show. + final MotionModel motion; + + /// Whether changes animate and spin runs. + final bool animate; + + /// The node's widget. + final Widget child; + + @override + State createState() => _NodeMotionState(); +} + +class _NodeMotionState extends State with TickerProviderStateMixin { + late final AnimationController _transition = AnimationController(vsync: this) + ..addListener(_rebuild); + late MotionModel _from = widget.motion; + late MotionModel _to = widget.motion; + + Ticker? _spinTicker; + Duration _lastTick = Duration.zero; + + /// Current spin speed and angle, in turns per second and turns. + double _speed = 0; + double _turns = 0; + + /// Holds a transition back for [MotionModel.delayMs]; until it fires the + /// node stays where the transition starts. + Timer? _delay; + + void _rebuild() => setState(() {}); + + bool get _loops => + widget.animate && + widget.motion.loop != MotionLoop.none && + widget.motion.durationMs > 0; + + @override + void initState() { + super.initState(); + final MotionModel motion = widget.motion; + if (_loops) { + _startLoop(); + } else if (widget.animate && motion.hasEntrance && motion.durationMs > 0) { + _from = motion.entranceStart; + _run(motion.durationMs); + } + _syncSpin(); + } + + /// Animates from [_from] to [_to] over [ms], after the motion's delay. + void _run(int ms) { + _delay?.cancel(); + _transition + ..stop() + ..duration = Duration(milliseconds: ms) + ..value = 0; + void start() { + if (mounted) _transition.forward(from: 0); + } + + final int delay = widget.motion.delayMs; + if (delay > 0) { + _delay = Timer(Duration(milliseconds: delay), start); + } else { + start(); + } + } + + /// Repeats between rest and the motion's values. + void _startLoop() { + _delay?.cancel(); + _from = widget.motion.atRest; + _to = widget.motion; + _transition.duration = Duration(milliseconds: widget.motion.durationMs); + void start() { + if (!mounted) return; + _transition.repeat(reverse: widget.motion.loop == MotionLoop.reverse); + } + + final int delay = widget.motion.delayMs; + if (delay > 0) { + _transition.value = 0; + _delay = Timer(Duration(milliseconds: delay), start); + } else { + start(); + } + } + + @override + void didUpdateWidget(NodeMotion oldWidget) { + super.didUpdateWidget(oldWidget); + final MotionModel next = widget.motion; + final bool replay = + widget.animate && next.replayKey != oldWidget.motion.replayKey; + if (_loops && replay) { + _startLoop(); + } else if (replay && next.durationMs > 0) { + // Plays the entrance again, wherever the node was. + _from = next.entranceStart; + _to = next; + _run(next.durationMs); + } else if (_loops) { + final MotionModel old = oldWidget.motion; + if (!_sameTransform(next, _to) || + next.loop != old.loop || + next.durationMs != old.durationMs || + !_transition.isAnimating && _delay?.isActive != true) { + _startLoop(); + } else { + _to = next; + } + } else if (!_sameTransform(next, _to) || + oldWidget.motion.loop != MotionLoop.none) { + final ms = widget.animate ? next.durationMs : 0; + if (ms <= 0) { + _delay?.cancel(); + _transition.stop(); + _transition.value = 0; + _from = next; + _to = next; + } else { + // Start from wherever the running transition has got to. + _from = _current; + _to = next; + _run(ms); + } + } else { + // Settings that don't animate (duration, curve, spin) apply at once. + _from = _from.copyWith(spin: next.spin); + _to = next; + } + _syncSpin(); + } + + static bool _sameTransform(MotionModel a, MotionModel b) => + a.translateX == b.translateX && + a.translateY == b.translateY && + a.scale == b.scale && + a.opacity == b.opacity && + a.rotate == b.rotate && + a.rotateX == b.rotateX && + a.rotateY == b.rotateY && + a.perspective == b.perspective && + a.originX == b.originX && + a.originY == b.originY; + + /// Whether a transition is running or waiting out its delay. + bool get _moving => _transition.isAnimating || _delay?.isActive == true; + + MotionModel get _current { + if (!_moving && _transition.value == 0) return _to; + final double t = flutterCurveOf(_to.curve).transform(_transition.value); + double lerp(String p) => + _from.valueOf(p) + (_to.valueOf(p) - _from.valueOf(p)) * t; + return _to.copyWith( + translateX: lerp('translateX'), + translateY: lerp('translateY'), + scale: lerp('scale'), + opacity: lerp('opacity').clamp(0, 1).toDouble(), + rotate: lerp('rotate'), + rotateX: lerp('rotateX'), + rotateY: lerp('rotateY'), + ); + } + + void _syncSpin() { + final bool wantsTicker = + widget.animate && (widget.motion.spin != 0 || _speed != 0); + if (wantsTicker && _spinTicker == null) { + _lastTick = Duration.zero; + _spinTicker = createTicker(_onSpinTick)..start(); + } else if (!widget.animate && _spinTicker != null) { + _spinTicker!.dispose(); + _spinTicker = null; + _speed = 0; + } + } + + void _onSpinTick(Duration elapsed) { + final double dt = (elapsed - _lastTick).inMicroseconds / 1e6; + _lastTick = elapsed; + if (dt <= 0 || dt > 0.25) return; + final double target = widget.motion.spin; + final int ramp = widget.motion.spinRampMs; + if (ramp <= 0) { + _speed = target; + } else { + // Ease toward the target speed, like a belt drive. + final double k = math.min(1, dt * 3000 / ramp); + _speed += (target - _speed) * k; + if ((target - _speed).abs() < 0.002) _speed = target; + } + _turns = (_turns + _speed * dt) % 1; + if (_speed == 0 && target == 0) { + _spinTicker?.dispose(); + _spinTicker = null; + } + setState(() {}); + } + + @override + void dispose() { + _delay?.cancel(); + _transition.dispose(); + _spinTicker?.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final MotionModel m = _moving ? _current : _to; + if (!m.hasTransform && _turns == 0) return widget.child; + Widget child = widget.child; + if (m.opacity < 1) { + // A fully transparent node also stops taking taps, like a hidden one. + child = IgnorePointer( + ignoring: m.opacity == 0, + child: Opacity(opacity: m.opacity, child: child), + ); + } + if (m.translateX == 0 && + m.translateY == 0 && + m.scale == 1 && + m.rotate == 0 && + m.rotateX == 0 && + m.rotateY == 0 && + _turns == 0) { + return child; + } + return Transform( + alignment: Alignment(m.originX, m.originY), + transform: motionMatrix(m, spinTurns: _turns), + child: child, + ); + } +} diff --git a/lib/src/ui/swipe_detector.dart b/lib/src/ui/swipe_detector.dart new file mode 100644 index 00000000..6de431e4 --- /dev/null +++ b/lib/src/ui/swipe_detector.dart @@ -0,0 +1,77 @@ +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/widgets.dart'; + +/// Reports swipes on [child] as [TriggerType.swipeLeft], [swipeRight], +/// [swipeUp] or [swipeDown]. A drag counts as a swipe when it travels +/// [minDistance] or is flung faster than [minVelocity]. A mouse wheel +/// scroll counts too: scrolling down swipes up, the way content moves. +class SwipeDetector extends StatefulWidget { + /// Creates a [SwipeDetector]. + const SwipeDetector({ + super.key, + required this.onSwipe, + required this.child, + this.minDistance = 40, + this.minVelocity = 300, + }); + + /// Called with the swipe's direction. + final ValueChanged onSwipe; + + /// The swiped widget. + final Widget child; + + /// How far a drag travels to count as a swipe, in logical pixels. + final double minDistance; + + /// How fast a shorter drag is flung to count, in logical pixels a second. + final double minVelocity; + + @override + State createState() => _SwipeDetectorState(); +} + +class _SwipeDetectorState extends State { + Offset _travel = Offset.zero; + DateTime _lastWheel = DateTime(0); + + static TriggerType _direction(Offset delta) => delta.dx.abs() > delta.dy.abs() + ? (delta.dx < 0 ? TriggerType.swipeLeft : TriggerType.swipeRight) + : (delta.dy < 0 ? TriggerType.swipeUp : TriggerType.swipeDown); + + void _onEnd(DragEndDetails details) { + final Offset velocity = details.velocity.pixelsPerSecond; + if (_travel.distance >= widget.minDistance) { + widget.onSwipe(_direction(_travel)); + } else if (velocity.distance >= widget.minVelocity) { + widget.onSwipe(_direction(velocity)); + } + _travel = Offset.zero; + } + + void _onWheel(PointerSignalEvent event) { + if (event is! PointerScrollEvent) return; + final DateTime now = DateTime.now(); + // A wheel sends many small scrolls; one notch is one swipe. + if (now.difference(_lastWheel).inMilliseconds < 150) return; + _lastWheel = now; + final Offset delta = event.scrollDelta; + if (delta == Offset.zero) return; + widget.onSwipe(_direction(-delta)); + } + + @override + Widget build(BuildContext context) { + return Listener( + onPointerSignal: _onWheel, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onPanStart: (_) => _travel = Offset.zero, + onPanUpdate: (DragUpdateDetails d) => _travel += d.delta, + onPanEnd: _onEnd, + child: widget.child, + ), + ); + } +} diff --git a/lib/src/utils/computed_variables.dart b/lib/src/utils/computed_variables.dart new file mode 100644 index 00000000..4af46b35 --- /dev/null +++ b/lib/src/utils/computed_variables.dart @@ -0,0 +1,250 @@ +import 'dart:convert'; + +import 'package:codelessly_api/codelessly_api.dart'; + +import '../../codelessly_sdk.dart'; + +/// Helpers for computed variables: variables whose value is a `${= ...}` +/// formula over other variables (see [VariableData.isComputed]), e.g. +/// `visible = ${= sortBy(filter(records, r => r.crate == crateId), sortBy)}`. +/// +/// A computed variable is read like any other one, by name, in bindings and +/// formulas (`${= length(visible)}`, `${visible[0].title}`), and its formula +/// is worked out whenever it is read. These helpers check a formula, find +/// the variables it reads and cycles between computed variables, and work +/// out every computed variable at once for the design canvas. +/// +/// Semantics: +/// - It reads other variables, including other computed ones, and the +/// `data` passed to the layout. +/// - Computed variables that read themselves, directly or through others, +/// form a cycle and evaluate to null. +/// - Set variable actions, two-way bindings and +/// [CodelesslyContext.updateVariable] leave them unchanged, so their +/// formula isn't overwritten by a value. +abstract final class ComputedVariables { + /// [variable]'s formula, or null for a normal variable. + static String? formulaOf(VariableData variable) => + variable.isComputed ? variable.value.trim() : null; + + static final RegExp _exactPath = RegExp(r'^\$\{([^{}]+)\}$'); + + /// The single variable path of [formula] when it is exactly one `${path}`. + static String? _exactPathOf(String formula) { + final RegExpMatch? match = _exactPath.firstMatch(formula.trim()); + if (match == null) return null; + final String path = match.group(1)!.trim(); + if (path.startsWith('=')) return null; + return path; + } + + /// The formula expression of [formula]: the inside of `${= ...}`, or the + /// whole text for a bare expression. Null when [formula] is one variable + /// path, `${path}`. + static String? expressionOf(String formula) { + final String? exact = FormulaEvaluator.exactExpression(formula); + if (exact != null) return exact; + if (_exactPathOf(formula) != null) return null; + return formula.trim(); + } + + /// Why [formula] can't be evaluated, or null when it parses. + static String? errorOf(String formula) { + final String? expression = expressionOf(formula); + if (expression == null) return null; + return FormulaEvaluator.errorOf(expression); + } + + /// The names of the variables [variable]'s formula reads, e.g. `records` + /// and `crateId`. Empty for a normal variable. + static Set inputsOf(VariableData variable) { + final String? formula = formulaOf(variable); + if (formula == null) return const {}; + final String? path = _exactPathOf(formula); + final Set paths = path != null + ? {path} + : FormulaEvaluator.referencedPaths(expressionOf(formula)!); + return { + for (final String path in paths) + if (VariableMatch.parse(path.wrapWithVariableSyntax()) + case final VariableMatch match) + match.name, + }; + } + + /// Evaluates [variable]'s formula, reading variables from [scopedValues]. + static Object? evaluate( + VariableData variable, { + required ScopedValues scopedValues, + }) { + final String? formula = formulaOf(variable); + if (formula == null) return null; + final String? path = _exactPathOf(formula); + if (path != null) { + return PropertyValueDelegate.getVariableValueFromPath( + path, + scopedValues: scopedValues, + ); + } + return PropertyValueDelegate.evaluateFormula( + expressionOf(formula)!, + scopedValues: scopedValues, + ); + } + + /// [variable] holding [result] as its value. See [ComputedVariables] for + /// how the type is chosen. + static VariableData withResult(VariableData variable, Object? result) { + final VariableType type = result == null || _fits(result, variable.type) + ? variable.type + : _typeOf(result) ?? VariableType.text; + return _rebuild(variable, type: type, value: _encode(result)); + } + + /// The names of the computed variables in [variables] (by name) that read + /// themselves, directly or through other computed variables. + static Set cyclicNames(Map variables) { + final Set cyclic = {}; + final Map> edges = { + for (final VariableData variable in variables.values) + if (variable.isComputed) + variable.name: { + for (final String input in inputsOf(variable)) + if (variables[input]?.isComputed ?? false) input, + }, + }; + // A computed variable is cyclic when it can reach itself. + for (final String start in edges.keys) { + final Set seen = {}; + final List stack = [...edges[start]!]; + while (stack.isNotEmpty) { + final String name = stack.removeLast(); + if (name == start) { + cyclic.add(start); + break; + } + if (!seen.add(name)) continue; + stack.addAll(edges[name] ?? const {}); + } + } + return cyclic; + } + + /// The computed variables of [variables] (by name) ordered so that each + /// comes after the computed variables it reads. Cyclic ones come last. + static List evaluationOrder(Map variables) { + final Set cyclic = cyclicNames(variables); + final List order = []; + final Set visited = {}; + void visit(String name) { + if (!visited.add(name)) return; + final VariableData? variable = variables[name]; + if (variable == null || !variable.isComputed) return; + for (final String input in inputsOf(variable)) { + if (!cyclic.contains(input)) visit(input); + } + order.add(name); + } + + for (final VariableData variable in variables.values) { + if (variable.isComputed && !cyclic.contains(variable.name)) { + visit(variable.name); + } + } + return [...order, ...cyclic]; + } + + /// [variables] (by name) with every computed variable holding its + /// formula's result over the others, as a design canvas previews them with + /// default values. [data] is what the formulas read as `data`. + static Map resolveAll( + Map variables, { + Map? data, + }) { + if (!variables.values.any((variable) => variable.isComputed)) { + return variables; + } + final Set cyclic = cyclicNames(variables); + final Map resolved = {...variables}; + final ScopedValues scope = ScopedValues.preview( + variables: resolved, + data: data, + ); + for (final String name in evaluationOrder(variables)) { + final VariableData variable = resolved[name]!; + resolved[name] = withResult( + variable, + cyclic.contains(name) ? null : evaluate(variable, scopedValues: scope), + ); + } + return resolved; + } + + static bool _fits(Object result, VariableType type) => switch (type) { + VariableType.text => result is String || result is num || result is bool, + VariableType.image => result is String, + VariableType.integer => + result is num && result.isFinite && result == result.roundToDouble(), + VariableType.decimal => result is num, + VariableType.boolean => result is bool, + VariableType.color => result is String || result is ColorRGBA, + VariableType.list => result is List, + VariableType.map => result is Map, + // A choice holds the name of one of its options. + VariableType.choice => result is String, + }; + + static VariableType? _typeOf(Object result) { + try { + return VariableType.fromObjectType(result); + } catch (_) { + return null; + } + } + + static String _encode(Object? result) => switch (result) { + null => '', + String() => result, + num() || bool() => FormulaEvaluator.stringify(result), + DateTime() => result.toIso8601String(), + ColorRGBA() => result.toHex(), + List() || Map() => jsonEncode( + result, + toEncodable: (value) => + value is DateTime ? value.toIso8601String() : value.toString(), + ), + _ => result.toString(), + }; + + /// A copy of [variable] of the same class with [type] and [value]. + /// [VariableData.copyWith] keeps the old value when the new one doesn't + /// sanitize, so an empty result would not clear it. + static VariableData _rebuild( + VariableData variable, { + required VariableType type, + required String value, + }) => switch (variable) { + CanvasVariableData() => CanvasVariableData( + id: variable.id, + canvasId: variable.canvasId, + name: variable.name, + value: value, + type: type, + extra: variable.extra, + ), + RuntimeVariableData() => RuntimeVariableData( + id: variable.id, + name: variable.name, + value: value, + type: type, + extra: variable.extra, + ), + _ => VariableData( + id: variable.id, + name: variable.name, + value: value, + type: type, + extra: variable.extra, + ), + }; +} diff --git a/lib/src/utils/extensions.dart b/lib/src/utils/extensions.dart index aa1b1012..71ad04fc 100644 --- a/lib/src/utils/extensions.dart +++ b/lib/src/utils/extensions.dart @@ -492,6 +492,7 @@ extension CMainAxisAlignmentHelper on MainAxisAlignmentC { } } + /// The [flutter.WrapAlignment] of the same name. flutter.WrapAlignment get flutterWrapAlignment => switch (this) { MainAxisAlignmentC.start => flutter.WrapAlignment.start, MainAxisAlignmentC.end => flutter.WrapAlignment.end, @@ -1898,11 +1899,18 @@ extension ActionModelExt on ActionModel { return _namesIn(strings, includePredefined); } + /// The variables [strings] read: their `${path}`s and the paths their + /// formulas read, such as `records` in + /// `${= map(records, r => merge(r, {plays: r.plays + 1}))}`. static Set _namesIn(List strings, bool includePredefined) { return strings - .map((text) => VariableMatch.parseAll(text)) - .expand((matches) => matches) - .map((match) => match.name) + .expand( + (text) => [ + for (final match in VariableMatch.parseAll(text)) match.name, + for (final path in FormulaEvaluator.referencedPathsInText(text)) + path.split(RegExp(r'[.\[]')).first, + ], + ) .where( (name) => includePredefined || !predefinedVariableNames.contains(name), @@ -2148,7 +2156,14 @@ extension BaseNodeExt on BaseNode { /// Collects all the variable names used in this node. Set getUsedVariableNames({bool includePredefined = false}) { final Set usedVariables = {}; - for (final path in variables.values) { + // A property bound to a formula reads the paths in it. + final Iterable boundPaths = variables.values.expand( + (path) => switch (FormulaEvaluator.exactExpression(path)) { + final String expression => FormulaEvaluator.referencedPaths(expression), + null => [path], + }, + ); + for (final path in boundPaths) { final match = VariableMatch.parse(path.wrapWithVariableSyntax()); if (match == null) continue; @@ -2169,6 +2184,28 @@ extension BaseNodeExt on BaseNode { } } + // Image fills can name their image with a variable or formula + // (`${record.art}`); the node rebuilds when it changes. + if (this is GeometryMixin) { + for (final PaintModel fill in (this as GeometryMixin).fills) { + final String? url = fill.downloadUrl; + if (url == null || !url.contains(r'${')) continue; + final Iterable names = [ + for (final match in variableSyntaxIdentifierRegex.allMatches(url)) + if (VariableMatch.parse(match[0]!) case final VariableMatch m) + m.name, + for (final path in FormulaEvaluator.referencedPathsInText(url)) + path.split('.').first, + ]; + for (final String name in names) { + if (!includePredefined && predefinedVariableNames.contains(name)) { + continue; + } + usedVariables.add(name); + } + } + } + // Get variables from actions if (this is ReactionMixin) { for (final reaction in (this as ReactionMixin).reactions) { diff --git a/lib/src/utils/formula.dart b/lib/src/utils/formula.dart index 6ff1b3f6..467fa742 100644 --- a/lib/src/utils/formula.dart +++ b/lib/src/utils/formula.dart @@ -1,15 +1,28 @@ import 'dart:math' as math; +/// The body of a formula: text and quoted strings, with braces only in +/// balanced pairs nested up to [depth] deep, such as the object literal in +/// `merge(r, {tags: {a: 1}})`. Braces inside quoted strings don't count. +String _formulaBody(int depth) { + const String atom = r'''[^{}'"]|'[^']*'|"[^"]*"'''; + String body = '(?:$atom)*'; + for (int i = 0; i < depth; i++) { + body = '(?:$atom|\\{$body\\})*'; + } + return body; +} + /// Matches a formula written as `${= expression}` in a text. /// -/// The expression can't contain braces, except inside quoted strings. +/// The expression can contain braces in balanced pairs, three levels deep, +/// for object literals such as `${= merge(r, {favorite: true})}`. final RegExp formulaRegex = RegExp( - r'''(?(?:[^{}'"]|'[^']*'|"[^"]*")*)\}''', + r'(?' + _formulaBody(3) + r')\}', ); /// Matches a text that is exactly one formula, e.g. `${= qty * price}`. final RegExp exactFormulaRegex = RegExp( - r'''^\s*(?(?:[^{}'"]|'[^']*'|"[^"]*")*)\}\s*$''', + r'^\s*(?' + _formulaBody(3) + r')\}\s*$', ); /// Resolves a variable path such as `storage.cart` or `item.price` to its @@ -19,8 +32,9 @@ typedef FormulaPathResolver = Object? Function(String path); /// Evaluates small expressions used in `${= ...}` formulas. /// /// Supports: -/// - numbers, 'single' or "double" quoted strings, true, false, null and -/// `[a, b]` lists. +/// - numbers, 'single' or "double" quoted strings, true, false, null, +/// `[a, b]` lists and `{id: 'a', 'full name': n + 1}` objects, whose keys +/// are names or quoted text. /// - variable paths like `storage.cart.length` or `item.price`, resolved with /// the same rules as `${path}`, followed by `[index]` or `.key`. /// - `+ - * / %`, comparisons `== != < <= > >=`, `&& || !` and @@ -30,35 +44,40 @@ typedef FormulaPathResolver = Object? Function(String path); /// endsWith, where, without, number, text, isEmpty, isNotEmpty, padLeft, /// padRight, substring, split, replace and coalesce. See /// [FormulaEvaluator.functions]. -/// - maps: `obj('id', 1, 'name', n)` builds one (formulas have no `{}` -/// literals), `set(map, key, value)` and `merge(a, b)` return changed +/// - text functions: padLeft, padRight, substring, replace, split, indexOf, +/// matches and slug; durations: seconds ('3:45' → 225) and duration +/// (225 → '3:45'); colors: luminance. +/// - maps: `obj('id', 1, 'name', n)` or an object literal `{id: 1, name: n}` +/// builds one, `set(map, key, value)` and `merge(a, b)` return changed /// copies, `keys` and `values` read them. With `map` this updates one /// item of a list: `map(units, u => if(u.id == id, set(u, 'hours', h), u))`. /// - more list functions: range, slice, first, last, concat and indexOf. /// - list functions that take a predicate or key: filter, where, count, -/// find, any, all, map, sortBy, unique, sum and join. The predicate is a -/// lambda, `item => expression`, or a field name, optionally with a value -/// to match, e.g. `filter(tasks, t => contains(lower(t.title), q))`, +/// find, findIndex, any, all, map, sortBy, sortByDesc, unique, sum and +/// join. The predicate is a lambda, `item => expression`, or a field name, +/// optionally with a value to match, e.g. +/// `filter(tasks, t => contains(lower(t.title), q))`, /// `filter(tasks, 'done')` or `count(tasks, 'done', false)`. A lambda's /// parameter is read like a variable inside its body (`t.title`, /// `t.tags[0]`, `t.tags.length`) and hides a variable of the same name; -/// the body can read every other variable. Lambdas are only allowed as -/// function arguments. A lambda can take a second parameter for the -/// item's index: `map(steps, (s, i) => i == length(steps) - 1)`. +/// the body can read every other variable. A lambda can take the item's +/// index as a second parameter, `(t, i) => i < 3`. Lambdas are only +/// allowed as function arguments. /// - left(text, n), right(text, n) and toMap (a map built from a list, /// `toMap(items, i => i.id, i => "pass")`). /// - math: sqrt, pow, sin, cos, tan, asin, acos, atan, atan2 (radians), /// pi, radians, degrees, clamp, and distance(lat1, lng1, lat2, lng2, /// unit) for the great-circle distance in 'km' (default) or 'mi'. -/// - building data: object(key, value, …), merge, set, keys, values, -/// flatten, compact (drops nulls and empty text), and -/// list edits that return a new list: append, setAt, insertAt, removeAt -/// and move. Set variable actions use them to change nested data, e.g. +/// - building data: object(key, value, …) or `{key: value}` literals, +/// merge, set, keys, values, flatten, compact (drops nulls and empty +/// text), and list edits that return a new list: append, setAt, +/// insertAt, removeAt and move. Set variable actions use them to change +/// nested data, e.g. /// `setAt(trip.stops, index, merge(item, object('nights', 3)))`. /// - list slicing: first, last, slice(list, start, end), take, skip, -/// reverse, range(count) or range(start, end, step) with the end -/// included, and indexOf(list, -/// value or predicate), which is -1 when nothing matches. +/// reverse (these also work on text), range(end) or range(start, end, +/// step), and indexOf(list, value or predicate), which is -1 when nothing +/// matches. /// - series helpers: interpolate, runs, minBy and maxBy. /// - date functions: now, today, wallTime, parseDate, formatDate, addMinutes, addHours, /// addDays, addMonths, diffMinutes, diffHours, diffDays, year, month, day, @@ -199,8 +218,8 @@ class FormulaEvaluator { ), FormulaFunctionDoc( 'money', - 'money(value, symbol?, digits?)', - r'money(price) → $1,234.50, money(385, "$", 0) → $385', + 'money(value, symbol, decimals) or money(value, decimals)', + r'money(price) → $1,234.50', ), FormulaFunctionDoc( 'formatNumber', @@ -266,10 +285,51 @@ class FormulaEvaluator { 'endsWith(text, suffix)', 'endsWith("photo.png", ".png") → true', ), + FormulaFunctionDoc( + 'padLeft', + 'padLeft(value, width, char)', + 'padLeft(4, 2) → 04 (numbers pad with 0, text with spaces)', + ), + FormulaFunctionDoc( + 'padRight', + 'padRight(value, width, char)', + 'padRight("A", 3, ".") → A..', + ), + FormulaFunctionDoc( + 'substring', + 'substring(text, start, end)', + 'substring("Vinyl", 0, 3) → Vin', + ), + FormulaFunctionDoc( + 'replace', + 'replace(text, from, to)', + 'replace("a-b-c", "-", " ") → a b c', + ), + FormulaFunctionDoc( + 'split', + 'split(text, separator)', + 'split("rock, jazz", ", ") → [rock, jazz]', + ), FormulaFunctionDoc( 'matches', 'matches(text, pattern)', - 'matches(email, "^[^@ ]+@[^@ ]+[.][^@ .]+\$") → true', + r'matches(code, "^\d{4}$") → true', + ), + FormulaFunctionDoc( + 'slug', + 'slug(text)', + 'slug("Kind of Blue!") → kind-of-blue', + ), + FormulaFunctionDoc( + 'seconds', + 'seconds("m:ss" or "h:mm:ss")', + 'seconds("3:45") → 225', + ), + FormulaFunctionDoc('duration', 'duration(seconds)', 'duration(225) → 3:45'), + FormulaFunctionDoc( + 'luminance', + 'luminance(color)', + 'luminance("#FFFFFF") → 1', ), FormulaFunctionDoc( 'filter', @@ -303,8 +363,18 @@ class FormulaEvaluator { ), FormulaFunctionDoc( 'sortBy', - 'sortBy(list, field or item => key, "desc")', - 'sortBy(tasks, "due") (missing keys last)', + 'sortBy(list, field or item => key, "desc", more keys…)', + 'sortBy(records, r => r.artist, r => r.year, "desc") (missing keys last)', + ), + FormulaFunctionDoc( + 'sortByDesc', + 'sortByDesc(list, field or item => key, more keys…)', + 'sortByDesc(records, "year")', + ), + FormulaFunctionDoc( + 'findIndex', + 'findIndex(list, item => condition) or findIndex(list, field, value)', + 'findIndex(records, r => r.id == selectedId) → 2 (-1 when none)', ), FormulaFunctionDoc( 'unique', @@ -316,6 +386,11 @@ class FormulaEvaluator { 'without(list, other)', 'without(all, picked)', ), + FormulaFunctionDoc( + 'concat', + 'concat(list or item, ...)', + 'concat([record.art], sleeves) or list1 + list2', + ), FormulaFunctionDoc('sqrt', 'sqrt(value)', 'sqrt(16) → 4'), FormulaFunctionDoc('pow', 'pow(base, exponent)', 'pow(2, 10) → 1024'), FormulaFunctionDoc('sin', 'sin(radians)', 'sin(pi() / 2) → 1'), @@ -685,11 +760,25 @@ Object? _member(Object? target, Object? key) { String _fixed(Object? value, Object? digits) => _num(value).toStringAsFixed(_num(digits ?? 2).toInt().clamp(0, 20)); -String _money(Object? value, Object? symbol, [Object? digits]) { +/// Formats [value] as money with [symbol] (`$` by default) and [decimals] +/// (2 by default). A number in place of the symbol is the decimals, so +/// `money(38, 0)` is "$38". Decimals `'auto'` drop them for whole amounts: +/// "$38" but "$38.50". +String _money(Object? value, Object? symbol, Object? decimals) { + if (symbol is num) (symbol, decimals) = (null, symbol); + final bool auto = + decimals is String && decimals.trim().toLowerCase() == 'auto'; final num amount = _num(value); if (amount.isNaN) return ''; + int digits = auto || decimals == null ? 2 : _intArg(decimals).clamp(0, 20); + if (auto && (amount * 100).round() % 100 == 0) digits = 0; final String sign = amount < 0 ? '-' : ''; - return '$sign${symbol ?? r'$'}${_grouped(amount.abs(), digits ?? 2)}'; + final String text = amount.abs().toStringAsFixed(digits); + final int dot = text.contains('.') ? text.indexOf('.') : text.length; + final String whole = text + .substring(0, dot) + .replaceAllMapped(RegExp(r'\B(?=(\d{3})+(?!\d))'), (_) => ','); + return '$sign${symbol ?? r'$'}$whole${text.substring(dot)}'; } /// [value] with [digits] decimals (default 0) and thousands separators, @@ -707,6 +796,167 @@ String _grouped(Object? value, Object? digits) { return '$whole${text.substring(dot)}'; } +/// [value] as text padded to [width] with the first character of [fill]: +/// zeros for numbers and spaces for text by default. A negative number keeps +/// its sign in front of zeros, so `padLeft(-4, 3)` is "-04". +String _pad(Object? value, Object? width, Object? fill, {required bool left}) { + final String text = FormulaEvaluator.stringify(value); + final int size = _intArg(width); + final String char = fill == null + ? (value is num ? '0' : ' ') + : FormulaEvaluator.stringify(fill); + if (char.isEmpty) return text; + if (left && char[0] == '0' && text.startsWith('-')) { + return '-${text.substring(1).padLeft(size - 1, '0')}'; + } + return left ? text.padLeft(size, char[0]) : text.padRight(size, char[0]); +} + +/// The part of [value]'s text from [start] up to, not including, [end] (the +/// end of the text when null). A negative index counts from the end, so +/// `substring(vin, -6)` is the last six characters. Indexes past either end +/// are clamped. +String _substring(Object? value, Object? start, Object? end) { + final String text = FormulaEvaluator.stringify(value); + final (int from, int to) = _bounds(text.length, start, end); + return text.substring(from, to); +} + +/// [start] and [end] as bounds of something [length] long, counting +/// negative values from the end, like JavaScript's `slice`. +(int, int) _bounds(int length, Object? start, Object? end) { + int bound(Object? value, int fallback) { + if (value == null) return fallback; + final int i = _intArg(value); + return (i < 0 ? length + i : i).clamp(0, length); + } + + final int from = bound(start, 0); + return (from, math.max(from, bound(end, length))); +} + +/// The items of a list, or characters of a text, from [start] up to [end]. +Object? _slice(Object? value, Object? start, Object? end) { + if (value is String) { + final (int from, int to) = _bounds(value.length, start, end); + return value.substring(from, to); + } + final List items = _values(value, null).toList(); + final (int from, int to) = _bounds(items.length, start, end); + return items.sublist(from, to); +} + +/// [value], a list or text, reversed. +Object? _reverse(Object? value) => switch (value) { + final String text => text.split('').reversed.join(), + final Iterable items => items.toList().reversed.toList(), + _ => null, +}; + +/// Lowercase [value] with every run of other characters than letters and +/// digits turned into one dash, e.g. "Kind of Blue!" is "kind-of-blue". +String _slug(Object? value) => + FormulaEvaluator.stringify(value) + .toLowerCase() + .replaceAll(RegExp(r'[^a-z0-9]+'), '-') + .replaceAll(RegExp(r'^-+|-+$'), ''); + +/// Seconds in a duration written `m:ss` or `h:mm:ss` (or plain seconds), +/// e.g. "3:45" is 225. Null when it isn't one. +int? _seconds(Object? value) { + if (value is num) return value.isFinite ? value.round() : null; + if (value is! String) return null; + String text = value.trim(); + final bool negative = text.startsWith('-'); + if (negative) text = text.substring(1); + final List parts = text.split(':'); + if (parts.length > 3 || parts.any((p) => !RegExp(r'^\d+$').hasMatch(p))) { + return null; + } + int total = 0; + for (int i = 0; i < parts.length; i++) { + final int n = int.parse(parts[i]); + if (i > 0 && (parts[i].length != 2 || n >= 60)) return null; + total = total * 60 + n; + } + return negative ? -total : total; +} + +/// [value] seconds written `m:ss`, or `h:mm:ss` from an hour, e.g. 225 is +/// "3:45" and 3725 is "1:02:05". Text such as "3:45" is read with [_seconds]. +String? _duration(Object? value) { + if (value == null) return null; + final num? total = value is String && value.contains(':') + ? _seconds(value) + : _toNum(value); + if (total == null || !total.isFinite) return null; + final int all = total.abs().round(); + String two(int n) => n.toString().padLeft(2, '0'); + final String sign = total < 0 && all > 0 ? '-' : ''; + final int hours = all ~/ 3600; + final int minutes = all % 3600 ~/ 60; + return hours > 0 + ? '$sign$hours:${two(minutes)}:${two(all % 60)}' + : '$sign$minutes:${two(all % 60)}'; +} + +/// The relative luminance of a color, from 0 (black) to 1 (white), as WCAG +/// defines it. Reads `#RGB`, `#RRGGBB` and `#AARRGGBB` text (the `#` is +/// optional) and `{r, g, b}` objects with channels from 0 to 1. Null when +/// [value] isn't a color. +num? _luminance(Object? value) { + final List channels; + if (value is Map) { + final List rgb = [ + for (final key in ['r', 'g', 'b']) _toNum(value[key]), + ]; + if (rgb.any((c) => c == null)) return null; + channels = [for (final c in rgb) c!.toDouble().clamp(0, 1)]; + } else { + String hex = FormulaEvaluator.stringify(value).trim(); + if (hex.startsWith('#')) hex = hex.substring(1); + if (hex.length == 3) hex = hex.split('').map((c) => c * 2).join(); + if (hex.length == 8) hex = hex.substring(2); + if (!RegExp(r'^[0-9a-fA-F]{6}$').hasMatch(hex)) return null; + final int rgb = int.parse(hex, radix: 16); + channels = [ + for (final shift in [16, 8, 0]) ((rgb >> shift) & 0xff) / 255, + ]; + } + final List linear = [ + for (final c in channels) + c <= 0.03928 ? c / 12.92 : math.pow((c + 0.055) / 1.055, 2.4).toDouble(), + ]; + return _round( + 0.2126 * linear[0] + 0.7152 * linear[1] + 0.0722 * linear[2], + 4, + ); +} + +/// The objects [maps] combined into a new one, later keys winning. Values +/// that aren't objects are skipped. +Map _merge(Iterable maps) => { + for (final map in maps) + if (map is Map) + for (final MapEntry(:key, :value) in map.entries) key.toString(): value, +}; + +/// A copy of [target] with [key] set to [value]: an object's field, or a +/// list's item at an index (an index out of range changes nothing). +Object? _set(Object? target, Object? key, Object? value) { + if (target is List) { + final num? index = _toNum(key); + if (index == null || index < 0 || index >= target.length) { + return [...target]; + } + return [...target]..[index.toInt()] = value; + } + return { + ..._merge([target]), + key.toString(): value, + }; +} + num _round(Object? value, Object? digits) { final num v = _num(value); final int d = _num(digits ?? 0).toInt(); @@ -717,6 +967,7 @@ num _round(Object? value, Object? digits) { /// Values of [list], or of each item's [selector] when given: a field name /// such as `'price'` or `'author.name'`, or a lambda such as `t => t.price`. +/// A lambda gets each item's index as its second parameter. Iterable _values(Object? list, Object? selector) { if (list is! Iterable) return const []; if (selector == null) return list; @@ -724,9 +975,9 @@ Iterable _values(Object? list, Object? selector) { } /// [item]'s value for [selector]: the item itself when null, the lambda's -/// result for a lambda, else the field [selector] names. A field name with -/// dots, such as `author.name`, reads nested fields. A lambda with a second -/// parameter gets [index], the item's position in its list. +/// result for a lambda (given the item and its [index]), else the field +/// [selector] names. A field name with dots, such as `author.name`, reads +/// nested fields. Object? _select(Object? item, Object? selector, [int? index]) { if (selector == null) return item; if (selector is _Closure) return selector(item, index); @@ -744,35 +995,72 @@ bool _matches(Object? item, List rest, [int? index]) => rest.length < 2 ? _truthy(_select(item, _arg(rest, 0), index)) : _looseEquals(_select(item, rest[0], index), rest[1]); +/// Items of `args[0]` with their indexes, in order. +Iterable<(int, Object?)> _indexed(List args) => + _values(_arg(args, 0), null).indexed; + /// Items of `args[0]` that match the predicate in the rest of [args]. Iterable _matching(List args) { final List rest = args.skip(1).toList(); - return _values(_arg(args, 0), null).indexed + return _indexed(args) .where((entry) => _matches(entry.$2, rest, entry.$1)) - .map((entry) => entry.$2); + .map((e) => e.$2); +} + +/// The index of the first item of `args[0]` that matches the predicate in +/// the rest of [args], or -1. +int _findIndex(List args) { + final List rest = args.skip(1).toList(); + for (final (int i, Object? item) in _indexed(args)) { + if (_matches(item, rest, i)) return i; + } + return -1; } -/// Items of `args[0]` sorted by the key `args[1]`, descending when `args[2]` -/// is `'desc'`. Equal keys keep their order and missing keys go last. -List _sortBy(List args) { +/// Whether [value] is a sort order: true for `'desc'`, false for `'asc'`, +/// null for anything else. +bool? _sortOrder(Object? value) => switch (value) { + final String order => switch (order.trim().toLowerCase()) { + 'desc' || 'descending' => true, + 'asc' || 'ascending' => false, + _ => null, + }, + _ => null, +}; + +/// Items of `args[0]` sorted by the keys in the rest of [args], each a field +/// name or lambda; later keys break ties of earlier ones. A key is followed +/// by `'desc'` (or `'asc'`) to sort it descending, and every key is +/// descending by default when [descending]. `args[1]` is always a key, so +/// `sortBy(list, null, 'desc')` sorts plain values descending. Equal keys +/// keep their order and missing keys go last. +List _sortBy(List args, {bool descending = false}) { final List items = _values(_arg(args, 0), null).toList(); - final Object? selector = _arg(args, 1); - final bool descending = switch (_arg(args, 2)) { - final String order => order.trim().toLowerCase().startsWith('desc'), - _ => false, - }; - final List<(int, Object?, Object?)> keyed = [ + final List<(Object?, bool)> keys = [(_arg(args, 1), descending)]; + for (final arg in args.skip(2)) { + if (_sortOrder(arg) case final bool desc) { + keys.last = (keys.last.$1, desc); + } else { + keys.add((arg, descending)); + } + } + final List<(int, Object?, List)> keyed = [ for (int i = 0; i < items.length; i++) - (i, items[i], _select(items[i], selector, i)), + (i, items[i], [for (final key in keys) _select(items[i], key.$1, i)]), ]; keyed.sort((x, y) { - final bool xMissing = x.$3 == null || x.$3 == ''; - final bool yMissing = y.$3 == null || y.$3 == ''; - if (xMissing || yMissing) { - return xMissing == yMissing ? x.$1 - y.$1 : (xMissing ? 1 : -1); + for (int k = 0; k < keys.length; k++) { + final Object? a = x.$3[k]; + final Object? b = y.$3[k]; + final bool aMissing = a == null || a == ''; + final bool bMissing = b == null || b == ''; + if (aMissing || bMissing) { + if (aMissing == bMissing) continue; + return aMissing ? 1 : -1; + } + final int order = _compare(a, b) ?? 0; + if (order != 0) return keys[k].$2 ? -order : order; } - final int order = _compare(x.$3, y.$3) ?? 0; - if (order != 0) return descending ? -order : order; return x.$1 - y.$1; }); return [for (final entry in keyed) entry.$2]; @@ -783,9 +1071,8 @@ List _sortBy(List args) { List _unique(List args) { final List keys = []; final List items = []; - int index = 0; - for (final item in _values(_arg(args, 0), null)) { - final Object? key = _select(item, _arg(args, 1), index++); + for (final (int i, Object? item) in _indexed(args)) { + final Object? key = _select(item, _arg(args, 1), i); if (keys.any((seen) => _looseEquals(seen, key))) continue; keys.add(key); items.add(item); @@ -793,8 +1080,8 @@ List _unique(List args) { return items; } -/// A lambda such as `t => t.price`, ready to call with an item. A lambda -/// written `(t, i) => ...` also gets the item's index as [indexParam]. +/// A lambda such as `t => t.price` or `(t, i) => i + 1`, ready to call +/// with an item and its index. class _Closure { final String param; final String? indexParam; @@ -806,16 +1093,21 @@ class _Closure { static final RegExp _part = RegExp(r'\.([a-zA-Z_][a-zA-Z0-9_]*)|\[(\d+)\]'); Object? call(Object? value, [int? index]) => body.eval((path) { - if (indexParam != null && path == indexParam) return index; - if (path == param) return value; - if (path.startsWith(param) && - (path.startsWith('.', param.length) || - path.startsWith('[', param.length))) { - Object? result = value; - for (final Match part in _part.allMatches(path, param.length)) { - result = _member(result, part[1] ?? int.parse(part[2]!)); + for (final (String? name, Object? bound) in [ + (param, value), + (indexParam, index), + ]) { + if (name == null) continue; + if (path == name) return bound; + if (path.startsWith(name) && + (path.startsWith('.', name.length) || + path.startsWith('[', name.length))) { + Object? result = bound; + for (final Match part in _part.allMatches(path, name.length)) { + result = _member(result, part[1] ?? int.parse(part[2]!)); + } + return result; } - return result; } return outer(path); }); @@ -1275,25 +1567,6 @@ Object? _distance(List a) { Object? _arg(List args, int index) => index < args.length ? args[index] : null; -/// The part of [text] from [start] up to, not including, [end] (the end of -/// the text when null). A negative index counts from the end, so -/// `substring(vin, -6)` is the last six characters. Indexes past either end -/// are clamped. -String _substring(Object? text, Object? start, Object? end) { - final String value = FormulaEvaluator.stringify(text); - final int length = value.length; - int clamp(Object? index, int fallback) { - if (index == null) return fallback; - int i = _intArg(index); - if (i < 0) i += length; - return i.clamp(0, length); - } - - final int from = clamp(start, 0); - final int to = clamp(end, length); - return to <= from ? '' : value.substring(from, to); -} - /// A map from each item of `args[0]` to a value: the key is the item's /// `args[1]` (a field name or lambda) and the value its `args[2]`, or the /// item itself when that's missing. A later item with the same key wins. @@ -1353,15 +1626,25 @@ final Map _functions = { 'upper': (a) => FormulaEvaluator.stringify(_arg(a, 0)).toUpperCase(), 'lower': (a) => FormulaEvaluator.stringify(_arg(a, 0)).toLowerCase(), 'trim': (a) => FormulaEvaluator.stringify(_arg(a, 0)).trim(), - 'startsWith': (a) => switch (_arg(a, 0)) { - final String s => s.startsWith(FormulaEvaluator.stringify(_arg(a, 1))), - _ => false, + 'padLeft': (a) => _pad(_arg(a, 0), _arg(a, 1), _arg(a, 2), left: true), + 'padRight': (a) => _pad(_arg(a, 0), _arg(a, 1), _arg(a, 2), left: false), + 'substring': (a) => _substring(_arg(a, 0), _arg(a, 1), _arg(a, 2)), + 'replace': (a) { + final String text = FormulaEvaluator.stringify(_arg(a, 0)); + final String from = FormulaEvaluator.stringify(_arg(a, 1)); + return from.isEmpty + ? text + : text.replaceAll(from, FormulaEvaluator.stringify(_arg(a, 2))); }, - 'endsWith': (a) => switch (_arg(a, 0)) { - final String s => s.endsWith(FormulaEvaluator.stringify(_arg(a, 1))), - _ => false, + 'split': (a) { + final String text = FormulaEvaluator.stringify(_arg(a, 0)); + if (text.isEmpty) return []; + // Without a separator, the text splits into its characters. + final Object? separator = _arg(a, 1); + return text.split( + separator == null ? '' : FormulaEvaluator.stringify(separator), + ); }, - // A pattern that doesn't compile matches nothing. 'matches': (a) { final Object? text = _arg(a, 0); if (text == null) return false; @@ -1372,6 +1655,18 @@ final Map _functions = { return false; } }, + 'slug': (a) => _slug(_arg(a, 0)), + 'seconds': (a) => _seconds(_arg(a, 0)), + 'duration': (a) => _duration(_arg(a, 0)), + 'luminance': (a) => _luminance(_arg(a, 0)), + 'startsWith': (a) => switch (_arg(a, 0)) { + final String s => s.startsWith(FormulaEvaluator.stringify(_arg(a, 1))), + _ => false, + }, + 'endsWith': (a) => switch (_arg(a, 0)) { + final String s => s.endsWith(FormulaEvaluator.stringify(_arg(a, 1))), + _ => false, + }, 'contains': (a) => switch (_arg(a, 0)) { final String s => s.contains(FormulaEvaluator.stringify(_arg(a, 1))), final Iterable l => l.any((v) => _looseEquals(v, _arg(a, 1))), @@ -1381,17 +1676,26 @@ final Map _functions = { 'where': (a) => _matching(a).toList(), 'filter': (a) => _matching(a).toList(), 'find': (a) => _matching(a).firstOrNull, + 'findIndex': _findIndex, 'any': (a) => _matching(a).isNotEmpty, 'all': (a) { final List rest = a.skip(1).toList(); - return _values( - _arg(a, 0), - null, - ).indexed.every((entry) => _matches(entry.$2, rest, entry.$1)); + return _indexed(a).every((entry) => _matches(entry.$2, rest, entry.$1)); }, 'map': (a) => _values(_arg(a, 0), _arg(a, 1)).toList(), 'sortBy': _sortBy, + 'sortByDesc': (a) => _sortBy(a, descending: true), + 'slice': (a) => _slice(_arg(a, 0), _arg(a, 1), _arg(a, 2)), + 'take': (a) => _slice(_arg(a, 0), 0, math.max(0, _intArg(_arg(a, 1)))), + 'skip': (a) => _slice(_arg(a, 0), math.max(0, _intArg(_arg(a, 1))), null), + 'reverse': (a) => _reverse(_arg(a, 0)), + 'merge': _merge, + 'set': (a) => _set(_arg(a, 0), _arg(a, 1), _arg(a, 2)), 'unique': _unique, + 'concat': (a) => [ + for (final value in a) + if (value is Iterable) ...value else ?value, + ], 'without': (a) => [ for (final item in _values(_arg(a, 0), null)) if (!_values(_arg(a, 1), null).any((other) => _looseEquals(item, other))) @@ -1414,21 +1718,11 @@ final Map _functions = { 'distance': _distance, 'first': (a) => _values(_arg(a, 0), null).firstOrNull, 'last': (a) => _values(_arg(a, 0), null).lastOrNull, - 'slice': (a) { - final List items = List.of(_values(_arg(a, 0), null)); - final int start = _index(_arg(a, 1), items.length, 0); - final int end = _index(_arg(a, 2), items.length, items.length); - return end <= start ? [] : items.sublist(start, end); - }, - 'take': (a) => _values(_arg(a, 0), null).take(_count(_arg(a, 1))).toList(), - 'skip': (a) => _values(_arg(a, 0), null).skip(_count(_arg(a, 1))).toList(), - 'reverse': (a) => switch (_arg(a, 0)) { - final String s => s.split('').reversed.join(), - final Iterable l => l.toList().reversed.toList(), - _ => null, - }, 'range': _range, 'indexOf': (a) { + if (_arg(a, 0) case final String text) { + return text.indexOf(FormulaEvaluator.stringify(_arg(a, 1))); + } final List items = List.of(_values(_arg(a, 0), null)); final List rest = a.skip(1).toList(); final bool predicate = rest.length > 1 || _arg(rest, 0) is _Closure; @@ -1445,14 +1739,6 @@ final Map _functions = { for (int i = 0; i + 1 < a.length; i += 2) FormulaEvaluator.stringify(a[i]): a[i + 1], }, - 'merge': (a) => { - for (final Object? m in a) - if (m is Map) ...m.map((k, v) => MapEntry(k.toString(), v)), - }, - 'set': (a) => { - if (_arg(a, 0) case final Map m) ...m.map((k, v) => MapEntry('$k', v)), - FormulaEvaluator.stringify(_arg(a, 1)): _arg(a, 2), - }, 'keys': (a) => switch (_arg(a, 0)) { final Map m => m.keys.map((k) => k.toString()).toList(), _ => [], @@ -1555,36 +1841,6 @@ final Map _functions = { for (int i = 0; i + 1 < a.length; i += 2) FormulaEvaluator.stringify(a[i]): a[i + 1], }, - // Lists. - 'concat': (a) => [ - for (final part in a) - if (part is Iterable) ...part else ?part, - ], - // Text. - 'padLeft': (a) => - FormulaEvaluator.stringify(_arg(a, 0)) - .padLeft(_intArg(_arg(a, 1)), _padChar(_arg(a, 2))), - 'padRight': (a) => - FormulaEvaluator.stringify(_arg(a, 0)) - .padRight(_intArg(_arg(a, 1)), _padChar(_arg(a, 2))), - 'substring': (a) { - final String text = FormulaEvaluator.stringify(_arg(a, 0)); - final int start = _clampIndex(_arg(a, 1), text.length, 0); - final int end = _clampIndex(_arg(a, 2), text.length, text.length); - return end <= start ? '' : text.substring(start, end); - }, - 'split': (a) { - final String text = FormulaEvaluator.stringify(_arg(a, 0)); - final Object? separator = _arg(a, 1); - if (text.isEmpty) return []; - return text.split( - separator == null ? '' : FormulaEvaluator.stringify(separator), - ); - }, - 'replace': (a) => FormulaEvaluator.stringify(_arg(a, 0)).replaceAll( - FormulaEvaluator.stringify(_arg(a, 1)), - FormulaEvaluator.stringify(_arg(a, 2)), - ), // The first argument that is neither null nor empty text. 'coalesce': (a) => a.firstWhere( (v) => v != null && !(v is String && v.isEmpty), @@ -1592,20 +1848,6 @@ final Map _functions = { ), }; -/// [value] as an index into a sequence of [length]; negative counts from the -/// end, null is [fallback]. -int _clampIndex(Object? value, int length, int fallback) { - if (value == null) return fallback; - int index = _intArg(value); - if (index < 0) index += length; - return index.clamp(0, length); -} - -String _padChar(Object? value) { - final String text = value == null ? ' ' : FormulaEvaluator.stringify(value); - return text.isEmpty ? ' ' : text; -} - // --------------------------------------------------------------------------- // Syntax tree // --------------------------------------------------------------------------- @@ -1700,37 +1942,57 @@ class _Call extends _Node { } } -/// A lambda argument, `param => body`. Evaluates to a [_Closure] that list -/// functions call once per item. +/// A lambda argument, `param => body` or `(param, index) => body`. +/// Evaluates to a [_Closure] that list functions call once per item. class _Lambda extends _Node { final String param; final String? indexParam; final _Node body; - const _Lambda(this.param, this.body, [this.indexParam]); + const _Lambda(this.param, this.indexParam, this.body); @override Object? eval(FormulaPathResolver resolve) => _Closure(param, indexParam, body, resolve); - /// The body's paths, except those of the parameter, which aren't + /// The body's paths, except those of the parameters, which aren't /// variables. @override void collectPaths(Set paths) { final Set bodyPaths = {}; body.collectPaths(bodyPaths); + bool isParam(String path, String? name) => + name != null && + (path == name || + path.startsWith('$name.') || + path.startsWith('$name[')); paths.addAll( bodyPaths.where( - (path) => - path != param && - path != indexParam && - !path.startsWith('$param.') && - !path.startsWith('$param['), + (path) => !isParam(path, param) && !isParam(path, indexParam), ), ); } } +/// An object literal, `{id: 'a', 'full name': n + 1}`. +class _MapLiteral extends _Node { + final List<(String, _Node)> entries; + + const _MapLiteral(this.entries); + + @override + Object? eval(FormulaPathResolver resolve) => { + for (final (String key, _Node value) in entries) key: value.eval(resolve), + }; + + @override + void collectPaths(Set paths) { + for (final (_, _Node value) in entries) { + value.collectPaths(paths); + } + } +} + class _Unary extends _Node { final String op; final _Node operand; @@ -1765,6 +2027,8 @@ class _Binary extends _Node { final num? na = a is String ? null : _toNum(a); final num? nb = b is String ? null : _toNum(b); if (na != null && nb != null) return _tidy(na + nb); + // Lists join: [first] + rest. + if (a is List && b is List) return [...a, ...b]; return FormulaEvaluator.stringify(a) + FormulaEvaluator.stringify(b); case '-': return _tidy(_num(a) - _num(b)); @@ -1833,8 +2097,9 @@ class _Parser { static final RegExp _number = RegExp(r'\d+(\.\d+)?|\.\d+'); static final RegExp _identifier = RegExp(r'[a-zA-Z_][a-zA-Z0-9_]*'); static final RegExp _lambdaHead = RegExp( - r'(?:([a-zA-Z_][a-zA-Z0-9_]*)|\(\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*' - r'(?:,\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*)?\))\s*=>', + r'(?:([a-zA-Z_][a-zA-Z0-9_]*)' + r'|\(\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*(?:,\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*)?\))' + r'\s*=>', ); _Node parseAll() { @@ -1870,13 +2135,13 @@ class _Parser { /// A function argument: an expression, or a lambda `item => expression` /// (also written `(item) => expression`, or `(item, index) => expression` - /// to read the item's index too). + /// to read the item's index). _Node _argument() { _skip(); final Match? head = _lambdaHead.matchAsPrefix(source, pos); if (head == null) return _ternary(); pos = head.end; - return _Lambda(head[1] ?? head[2]!, _ternary(), head[3]); + return _Lambda(head[1] ?? head[2]!, head[3], _ternary()); } _Node _ternary() { @@ -1965,6 +2230,22 @@ class _Parser { } } + /// An object literal's key: a name or quoted text. + String _key() { + _skip(); + if (pos < source.length && (source[pos] == "'" || source[pos] == '"')) { + final int end = source.indexOf(source[pos], pos + 1); + if (end < 0) throw FormatException('Unclosed text', source, pos); + final String key = source.substring(pos + 1, end); + pos = end + 1; + return key; + } + final Match? name = _identifier.matchAsPrefix(source, pos); + if (name == null) throw FormatException('Expected a key', source, pos); + pos = name.end; + return name[0]!; + } + _Node _primary() { _skip(); if (pos >= source.length) { @@ -1991,6 +2272,20 @@ class _Parser { return _ListLiteral(items); } + if (char == '{') { + pos++; + final List<(String, _Node)> entries = []; + if (!_eat('}')) { + do { + final String key = _key(); + _expect(':'); + entries.add((key, _ternary())); + } while (_eat(',')); + _expect('}'); + } + return _MapLiteral(entries); + } + if (char == "'" || char == '"') { final int end = source.indexOf(char, pos + 1); if (end < 0) throw FormatException('Unclosed text', source, pos); diff --git a/test/computed_variable_helpers_test.dart b/test/computed_variable_helpers_test.dart new file mode 100644 index 00000000..2e9ed4d1 --- /dev/null +++ b/test/computed_variable_helpers_test.dart @@ -0,0 +1,158 @@ +import 'dart:convert'; + +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:codelessly_sdk/codelessly_sdk.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; + +const String visibleFormula = + r"${= sortBy(filter(records, r => crateId == 'all' || r.crate == crateId), sortBy)}"; + +final List> records = [ + {'title': 'Banana', 'crate': 'b'}, + {'title': 'Apple', 'crate': 'a'}, + {'title': 'Cherry', 'crate': 'a'}, +]; + +CodelesslyContext recordsContext() { + final CodelesslyContext context = CodelesslyContext.empty(); + for (final VariableData variable in [ + VariableData( + id: 'records', + name: 'records', + type: VariableType.list, + value: records, + ), + VariableData(id: 'crateId', name: 'crateId', value: 'all'), + VariableData(id: 'sortBy', name: 'sortBy', value: 'title'), + VariableData( + id: 'visible', + name: 'visible', + type: VariableType.list, + value: visibleFormula, + ), + VariableData( + id: 'count', + name: 'count', + type: VariableType.integer, + value: r'${= length(visible)}', + ), + ]) { + context.variables[variable.id] = Observable(variable); + } + return context; +} + +Object? read(String path, CodelesslyContext context) => + PropertyValueDelegate.getVariableValueFromPath( + path, + scopedValues: ScopedValues.preview( + variables: const {}, + codelesslyContext: context, + ), + ); + +void main() { + testWidgets('Set variable leaves a computed variable its formula', ( + tester, + ) async { + final CodelesslyContext context = recordsContext(); + late BuildContext captured; + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: context, + child: Builder( + builder: (buildContext) { + captured = buildContext; + return const SizedBox(); + }, + ), + ), + ); + expect(read(r'${count}', context), 3); + expect(context.updateVariable('count', 10), isFalse); + + final bool set = FunctionsRepository.setVariableFromAction( + captured, + SetVariableAction( + variable: VariableData( + id: 'count', + name: 'count', + type: VariableType.integer, + ), + newValue: '42', + ), + ); + expect(set, isFalse); + expect(context.findVariableByName('count')!.value.isComputed, isTrue); + + FunctionsRepository.setVariableFromAction( + captured, + SetVariableAction( + variable: VariableData(id: 'crateId', name: 'crateId'), + newValue: 'a', + ), + ); + expect(read(r'${count}', context), 2); + }); + + test('helpers read the formula from the value', () { + final Map byName = recordsContext() + .variableNamesMap(); + expect(ComputedVariables.inputsOf(byName['visible']!), { + 'records', + 'crateId', + 'sortBy', + }); + expect(ComputedVariables.inputsOf(byName['records']!), isEmpty); + expect(ComputedVariables.errorOf(r'${= length(}'), isNotNull); + expect(ComputedVariables.errorOf(visibleFormula), isNull); + expect( + ComputedVariables.cyclicNames({ + 'a': VariableData(id: 'a', name: 'a', value: r'${= b + 1}'), + 'b': VariableData(id: 'b', name: 'b', value: r'${= a + 1}'), + 'c': VariableData(id: 'c', name: 'c', value: '1'), + }), + {'a', 'b'}, + ); + }); + + test('resolveAll previews computed variables over default values', () { + final Map resolved = ComputedVariables.resolveAll( + recordsContext().variableNamesMap(), + ); + expect(resolved['count']!.getValue(), 3); + expect((resolved['visible']!.getValue() as List).first, { + 'title': 'Apple', + 'crate': 'a', + }); + }); + + test('the formula survives the published variables JSON', () { + final SDKLayoutVariables published = SDKLayoutVariables( + id: 'layout', + variables: { + 'canvas': { + 'visible': VariableData( + id: 'visible', + name: 'visible', + type: VariableType.list, + value: visibleFormula, + ), + }, + }, + owner: 'owner', + teams: const {}, + users: const {}, + roles: const {}, + public: false, + ); + final SDKLayoutVariables decoded = SDKLayoutVariables.fromJson( + jsonDecode(jsonEncode(published.toJson())), + ); + final VariableData visible = decoded.variables['canvas']!['visible']!; + expect(visible.value, visibleFormula); + expect(visible.isComputed, isTrue); + }); +} diff --git a/test/dashed_stroke_caps_test.dart b/test/dashed_stroke_caps_test.dart new file mode 100644 index 00000000..76c9b899 --- /dev/null +++ b/test/dashed_stroke_caps_test.dart @@ -0,0 +1,176 @@ +import 'dart:typed_data'; +import 'dart:ui' as ui; + +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 'stroke_side_test.dart' show pumpNode, strokePaint; + +/// A 100x100 circle (a fully rounded box) with a stroke of tiny dashes, like +/// the strobe dots on a turntable platter. +RectangleNode platter({ + required StrokeCapEnum cap, + List dashPattern = const [0, 10], + StrokeSide side = StrokeSide.all, +}) => RectangleNode( + id: 'platter', + name: 'Platter', + basicBoxLocal: NodeBox(0, 0, 100, 100), + cornerRadius: const CornerRadius.all(RadiusModel.circular(50)), + strokes: [PaintModel.solid(id: 'stroke', color: ColorRGB.fromHex('#000000'))], + strokeWeight: 4, + strokeAlign: StrokeAlignC.inside, + strokeCap: cap, + strokeSide: side, + dashPattern: dashPattern, +)..parentID = kRootNode; + +/// Paints [painter] into a [size] image and returns how many pixels have +/// any ink, so tests need no golden files. +Future inkedPixels( + WidgetTester tester, + CustomPainter painter, { + Size size = const Size(100, 100), +}) async { + final int? count = await tester.runAsync(() async { + final ui.PictureRecorder recorder = ui.PictureRecorder(); + painter.paint(Canvas(recorder), size); + final ui.Image image = await recorder.endRecording().toImage( + size.width.toInt(), + size.height.toInt(), + ); + final ByteData bytes = (await image.toByteData())!; + int inked = 0; + for (int i = 3; i < bytes.lengthInBytes; i += 4) { + if (bytes.getUint8(i) > 0) inked++; + } + return inked; + }); + return count!; +} + +StrokePainter painterFor({ + required StrokeCapEnum cap, + List dashPattern = const [0, 10], + StrokeSide side = StrokeSide.all, +}) => StrokePainter( + strokeWidth: 4, + dashPattern: dashPattern, + strokeCap: cap, + strokeSide: side, + borderRadius: BorderRadius.circular(50), +); + +void main() { + testWidgets('Zero-length dashes with round caps paint a dot each, inside ' + 'the stroke band', (tester) async { + await pumpNode(tester, platter(cap: StrokeCapEnum.round)); + expect(tester.takeException(), isNull); + // The first dash sits at the start of the stroke's center line, which is + // half the stroke width inside the box. + expect( + strokePaint(), + paints + ..circle(radius: 2) + ..circle(radius: 2) + ..circle(radius: 2), + ); + // A circle of center line radius 48 is ~301.6 long: 31 dots, 10 apart. + final int inked = await inkedPixels( + tester, + painterFor(cap: StrokeCapEnum.round), + ); + // Each dot is a circle of radius 2 (~12.6 px). + expect(inked, greaterThan(31 * 8)); + }); + + testWidgets('Zero-length dashes with square caps paint a square each', ( + tester, + ) async { + await pumpNode(tester, platter(cap: StrokeCapEnum.square)); + expect(tester.takeException(), isNull); + expect( + strokePaint(), + paints + ..path() + ..path() + ..path(), + ); + expect( + await inkedPixels(tester, painterFor(cap: StrokeCapEnum.square)), + greaterThan(31 * 10), + ); + }); + + testWidgets('Dashes without caps and no length still paint nothing', ( + tester, + ) async { + expect(await inkedPixels(tester, painterFor(cap: StrokeCapEnum.none)), 0); + }); + + testWidgets('Tiny round dashes stay within the stroke band of the box', ( + tester, + ) async { + final int? outside = await tester.runAsync(() async { + final ui.PictureRecorder recorder = ui.PictureRecorder(); + painterFor( + cap: StrokeCapEnum.round, + dashPattern: const [0.001, 6], + ).paint(Canvas(recorder), const Size(100, 100)); + final ui.Image image = await recorder.endRecording().toImage(120, 120); + final ByteData bytes = (await image.toByteData())!; + int count = 0; + for (int y = 0; y < 120; y++) { + for (int x = 0; x < 120; x++) { + final double dx = x + 0.5 - 50, dy = y + 0.5 - 50; + final double r2 = dx * dx + dy * dy; + // Ink outside the circle, or deeper inside than the stroke reaches. + if ((r2 > 51 * 51 || r2 < 45 * 45) && + bytes.getUint8((y * 120 + x) * 4 + 3) > 0) { + count++; + } + } + } + return count; + }); + expect(outside, 0); + }); + + testWidgets('Round dashes with length paint along the stroke', ( + tester, + ) async { + final int dots = await inkedPixels( + tester, + painterFor(cap: StrokeCapEnum.round), + ); + final int dashes = await inkedPixels( + tester, + painterFor(cap: StrokeCapEnum.round, dashPattern: const [4, 6]), + ); + expect(dashes, greaterThan(dots)); + }); + + testWidgets('A dashed side with round caps draws dots too', (tester) async { + expect( + await inkedPixels( + tester, + painterFor(cap: StrokeCapEnum.round, side: StrokeSide.top), + ), + greaterThan(0), + ); + }); + + testWidgets('A pattern of zeros paints nothing and does not hang', ( + tester, + ) async { + expect( + await inkedPixels( + tester, + painterFor(cap: StrokeCapEnum.round, dashPattern: const [0, 0]), + ), + 0, + ); + }); +} diff --git a/test/design_canvas_list_items_test.dart b/test/design_canvas_list_items_test.dart new file mode 100644 index 00000000..bb0cc036 --- /dev/null +++ b/test/design_canvas_list_items_test.dart @@ -0,0 +1,152 @@ +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:codelessly_sdk/codelessly_sdk.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; + +const List> crateItems = [ + {'id': 'jazz', 'name': 'Jazz'}, + {'id': 'soul', 'name': 'Soul'}, + {'id': 'hiphop', 'name': 'Hip hop'}, +]; + +/// The crates a data-bound list shows, as a list variable's default value. +final VariableData crates = VariableData( + id: 'crates', + name: 'crates', + type: VariableType.list, + value: crateItems, +); + +/// What an editor's resolver gives a node inside the list: it knows only the +/// node, which every row shares, so its item is the list's first. +ScopedValues editorScope() => ScopedValues.preview( + variables: {'crates': crates}, + indexedItem: IndexedItem(0, crateItems.first), +); + +List crateList({bool stacked = false}) => [ + ListViewNode( + id: 'list', + name: 'list', + basicBoxLocal: NodeBox(0, 0, 300, 200), + properties: ListViewProperties(stacked: stacked), + variables: {'data': r'${crates}'}, + children: ['label'], + )..parentID = kRootNode, + TextNode( + id: 'label', + name: 'label', + basicBoxLocal: NodeBox(0, 0, 200, 24), + horizontalFit: SizeFit.shrinkWrap, + characters: r'${item.name}', + textMixedProps: [StartEndProp(start: 0, end: 12, fontSize: 16)], + )..parentID = 'list', +]; + +Future pumpCanvasList(WidgetTester tester, List nodes) async { + final registry = NodeRegistry() + ..setNodes({for (final node in nodes) node.id: node}); + final manager = PassiveNodeTransformerManager( + registry.getNodeByID, + (context, bounds, pageID, layoutID, canvasID) => const SizedBox(), + ); + await tester.pumpWidget( + // The render canvas gives its screen the variable defaults. + ChangeNotifierProvider.value( + value: CodelesslyContext.empty().copyWith( + variables: {crates.id: Observable(crates)}, + ), + child: MaterialApp( + home: Material( + child: Align( + alignment: Alignment.topLeft, + child: Builder( + builder: (context) => manager.buildWidgetFromNode( + nodes.first, + context, + settings: const WidgetBuildSettings( + debugLabel: 'design canvas', + isPreview: true, + withReactions: false, + ), + ), + ), + ), + ), + ), + ), + ); + expect(tester.takeException(), isNull); +} + +void main() { + tearDown(() => VariablePreview.scopeResolver = null); + + group('VariablePreview.scopeOf', () { + late BuildContext row; + Future pumpRow(WidgetTester tester, IndexedItem? item) async { + Widget child = Builder( + builder: (context) { + row = context; + return const SizedBox(); + }, + ); + if (item != null) child = IndexedItemProvider(item: item, child: child); + await tester.pumpWidget(child); + } + + testWidgets('gives a row its own item', (tester) async { + VariablePreview.scopeResolver = (_) => editorScope(); + await pumpRow(tester, IndexedItem(2, {'name': 'Hip hop'})); + final ScopedValues? values = VariablePreview.scopeOf(null, context: row); + expect(values?.indexedItem, IndexedItem(2, {'name': 'Hip hop'})); + // The canvas's variables stay. + expect(values?.variables.keys, contains('crates')); + }); + + testWidgets('keeps the resolver scope outside a list', (tester) async { + final ScopedValues scope = editorScope(); + VariablePreview.scopeResolver = (_) => scope; + await pumpRow(tester, null); + expect(VariablePreview.scopeOf(null, context: row), same(scope)); + expect(VariablePreview.scopeOf(null), same(scope)); + }); + + testWidgets('a row without data keeps the sample item at its own index', ( + tester, + ) async { + VariablePreview.scopeResolver = (_) => editorScope(); + await pumpRow(tester, IndexedItem(4, null)); + final IndexedItem? item = VariablePreview.scopeOf( + null, + context: row, + )?.indexedItem; + expect(item?.index, 4); + expect(item?.item, {'id': 'jazz', 'name': 'Jazz'}); + }); + + testWidgets('is null without a resolver', (tester) async { + await pumpRow(tester, IndexedItem(1, {'name': 'Soul'})); + expect(VariablePreview.scopeOf(null, context: row), isNull); + }); + }); + + group('a data-bound list on the design canvas', () { + testWidgets('previews each item in its own row', (tester) async { + VariablePreview.scopeResolver = (_) => editorScope(); + await pumpCanvasList(tester, crateList()); + expect(find.text('Jazz', findRichText: true), findsOne); + expect(find.text('Soul', findRichText: true), findsOne); + expect(find.text('Hip hop', findRichText: true), findsOne); + }); + + testWidgets('a stacked list previews each item too', (tester) async { + VariablePreview.scopeResolver = (_) => editorScope(); + await pumpCanvasList(tester, crateList(stacked: true)); + expect(find.text('Jazz', findRichText: true), findsOne); + expect(find.text('Soul', findRichText: true), findsOne); + expect(find.text('Hip hop', findRichText: true), findsOne); + }); + }); +} diff --git a/test/double_tap_hover_slider_test.dart b/test/double_tap_hover_slider_test.dart new file mode 100644 index 00000000..fc289b5f --- /dev/null +++ b/test/double_tap_hover_slider_test.dart @@ -0,0 +1,299 @@ +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/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; + +Reaction on(TriggerType type, String function) => Reaction( + trigger: TriggerModel(type: type), + action: CallFunctionAction(name: function), +); + +RectangleNode box(List reactions) => RectangleNode( + id: 'box', + name: 'box', + basicBoxLocal: NodeBox(0, 0, 100, 100), + reactions: reactions, +)..parentID = kRootNode; + +Future pumpNode( + WidgetTester tester, + BaseNode node, + List calls, { + Map> variables = const {}, +}) async { + final registry = NodeRegistry()..setNodes({node.id: node}); + final manager = PassiveNodeTransformerManager( + registry.getNodeByID, + (context, bounds, pageID, layoutID, canvasID) => const SizedBox(), + ); + final codelesslyContext = CodelesslyContext.empty() + ..functions = { + for (final name in ['tap', 'double', 'enter', 'exit', 'reset']) + name: (context, reference, params) => calls.add(name), + }; + codelesslyContext.variables.addAll(variables); + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: codelesslyContext, + child: MaterialApp( + home: Material( + child: ActionChainHost( + child: Align( + alignment: Alignment.topLeft, + child: Builder( + builder: (context) => manager.buildWidgetFromNode( + node, + context, + settings: const WidgetBuildSettings(debugLabel: 'test'), + ), + ), + ), + ), + ), + ), + ), + ); + return codelesslyContext; +} + +SliderNode slider({ + SliderProperties? properties, + List reactions = const [], + Map variables = const {}, + double width = 300, + double height = 48, +}) => SliderNode( + id: 'slider', + name: 'slider', + basicBoxLocal: NodeBox(0, 0, width, height), + value: 0, + properties: properties ?? SliderProperties(), + reactions: reactions, + variables: variables, +)..parentID = kRootNode; + +void main() { + testWidgets('A single tap still clicks when there is no double tap', ( + tester, + ) async { + final calls = []; + await pumpNode(tester, box([on(TriggerType.click, 'tap')]), calls); + await tester.tapAt(const Offset(50, 50)); + await tester.pump(); + expect(calls, ['tap']); + expect( + find.byWidgetPredicate( + (w) => w is GestureDetector && w.onDoubleTap != null, + ), + findsNothing, + ); + }); + + testWidgets('Double tap runs its reaction; single taps still click', ( + tester, + ) async { + final calls = []; + await pumpNode( + tester, + box([on(TriggerType.click, 'tap'), on(TriggerType.doubleTap, 'double')]), + calls, + ); + await tester.tapAt(const Offset(50, 50)); + await tester.pump(const Duration(milliseconds: 50)); + await tester.tapAt(const Offset(50, 50)); + await tester.pump(); + expect(calls, ['double']); + + // A lone tap waits out the double tap timeout, then clicks. + await tester.pump(const Duration(seconds: 1)); + await tester.tapAt(const Offset(50, 50)); + await tester.pump(kDoubleTapTimeout + const Duration(milliseconds: 50)); + expect(calls, ['double', 'tap']); + }); + + testWidgets('Cursor enter and exit run for a mouse, never for touch', ( + tester, + ) async { + final calls = []; + await pumpNode( + tester, + box([ + on(TriggerType.cursorEnter, 'enter'), + on(TriggerType.cursorExit, 'exit'), + ]), + calls, + ); + final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse); + await mouse.addPointer(location: const Offset(300, 300)); + await mouse.moveTo(const Offset(50, 50)); + await tester.pump(); + expect(calls, ['enter']); + await mouse.moveTo(const Offset(300, 300)); + await tester.pump(); + expect(calls, ['enter', 'exit']); + await mouse.removePointer(); + + calls.clear(); + await tester.tapAt(const Offset(50, 50)); + await tester.pump(); + expect(calls, isEmpty); + }); + + testWidgets('Double tapping a slider can reset it', (tester) async { + final calls = []; + await pumpNode( + tester, + slider(reactions: [on(TriggerType.doubleTap, 'reset')]), + calls, + ); + await tester.tapAt(const Offset(150, 24)); + await tester.pump(const Duration(milliseconds: 50)); + await tester.tapAt(const Offset(150, 24)); + await tester.pump(const Duration(milliseconds: 500)); + expect(calls, ['reset']); + }); + + testWidgets('A vertical slider fills its box and drags along it', ( + tester, + ) async { + final calls = []; + await pumpNode( + tester, + slider( + width: 48, + height: 300, + properties: SliderProperties( + min: -8, + max: 8, + orientation: AxisC.vertical, + ), + ), + calls, + ); + expect(find.byType(RotatedBox), findsOneWidget); + expect(tester.getSize(find.byType(Slider)), const Size(300, 48)); + final RenderBox rotated = tester.renderObject(find.byType(RotatedBox)); + expect(rotated.size, const Size(48, 300)); + + // Tapping near the top of a vertical slider picks a high value. + final values = []; + final Slider widget = tester.widget(find.byType(Slider)); + expect(widget.min, -8); + expect(widget.max, 8); + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Align( + alignment: Alignment.topLeft, + child: TransformerSlider( + node: slider( + width: 48, + height: 300, + properties: SliderProperties( + min: -8, + max: 8, + orientation: AxisC.vertical, + ), + ), + settings: const WidgetBuildSettings(debugLabel: 'test'), + onChanged: values.add, + value: 0, + ), + ), + ), + ), + ); + await tester.tapAt(const Offset(24, 40)); + await tester.pump(); + expect(values.last, greaterThan(4)); + + // Dragging up from the bottom moves it up through the range. + values.clear(); + await tester.dragFrom(const Offset(24, 280), const Offset(0, -260)); + await tester.pump(); + expect(values.first, lessThan(-4)); + expect(values.last, greaterThan(4)); + + // Inverted, the top is the low end. + values.clear(); + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Align( + alignment: Alignment.topLeft, + child: TransformerSlider( + node: slider( + width: 48, + height: 300, + properties: SliderProperties( + min: -8, + max: 8, + orientation: AxisC.vertical, + inverted: true, + ), + ), + settings: const WidgetBuildSettings(debugLabel: 'test'), + onChanged: values.add, + value: 0, + ), + ), + ), + ), + ); + await tester.tapAt(const Offset(24, 40)); + await tester.pump(); + expect(values.last, lessThan(-4)); + }); + + testWidgets('Slider min and max follow their bound variables', ( + tester, + ) async { + await pumpNode( + tester, + slider(variables: {'min': r'${low}', 'max': r'${high}'}), + [], + variables: { + 'low': Observable( + VariableData( + id: 'low', + name: 'low', + type: VariableType.integer, + value: '-8', + ), + ), + 'high': Observable( + VariableData( + id: 'high', + name: 'high', + type: VariableType.integer, + value: '8', + ), + ), + }, + ); + final Slider widget = tester.widget(find.byType(Slider)); + expect(widget.min, -8); + expect(widget.max, 8); + }); + + testWidgets('The slider thumb can carry a stroke', (tester) async { + await pumpNode( + tester, + slider( + properties: SliderProperties( + thumbStrokeColor: ColorRGBA.white, + thumbStrokeWidth: 2, + ), + ), + [], + ); + final SliderTheme theme = tester.widget(find.byType(SliderTheme).last); + final shape = theme.data.thumbShape as StrokedSliderThumbShape; + expect(shape.strokeWidth, 2); + expect(shape.strokeColor, Colors.white); + expect(tester.takeException(), isNull); + }); +} diff --git a/test/formula_concat_fill_vars_test.dart b/test/formula_concat_fill_vars_test.dart new file mode 100644 index 00000000..6b51e68f --- /dev/null +++ b/test/formula_concat_fill_vars_test.dart @@ -0,0 +1,35 @@ +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:codelessly_sdk/codelessly_sdk.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + final Map vars = { + 'art': 'a.jpg', + 'sleeves': ['k.jpg', 'w.jpg'], + }; + Object? eval(String expression) => + FormulaEvaluator.evaluate(expression, (path) => vars[path]); + + test('lists join with concat and +', () { + expect(eval('concat([art], sleeves)'), ['a.jpg', 'k.jpg', 'w.jpg']); + expect(eval('concat(art, sleeves, null)'), ['a.jpg', 'k.jpg', 'w.jpg']); + expect(eval('[art] + sleeves'), ['a.jpg', 'k.jpg', 'w.jpg']); + expect(eval('"a" + 1'), 'a1'); + expect(eval('1 + 2'), 3); + }); + + test('an image fill bound to a variable or formula is listened to', () { + RectangleNode node(String url) => RectangleNode( + id: 'r', + name: 'r', + basicBoxLocal: NodeBox(0, 0, 10, 10), + fills: [PaintModel(id: 'f', type: PaintType.image, downloadUrl: url)], + ); + expect(node(r'${fArt}').getUsedVariableNames(), {'fArt'}); + expect( + node(r'${= editing ? record.art : fallback}').getUsedVariableNames(), + {'editing', 'record', 'fallback'}, + ); + expect(node('https://x/y.jpg').getUsedVariableNames(), isEmpty); + }); +} diff --git a/test/formula_object_literal_paths_test.dart b/test/formula_object_literal_paths_test.dart new file mode 100644 index 00000000..30b431fc --- /dev/null +++ b/test/formula_object_literal_paths_test.dart @@ -0,0 +1,186 @@ +import 'dart:convert'; + +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 formula a crate digger counts plays with: an object literal inside a +/// lambda inside `${= }`. +const String countPlay = + r'${= map(records, r => r.id == x ? merge(r, {plays: r.plays + 1}) : r)}'; + +const List> crate = [ + {'id': 'a', 'plays': 1}, + {'id': 'b', 'plays': 4}, +]; + +CodelesslyContext crateContext() => CodelesslyContext.empty() + ..variables['records'] = Observable( + VariableData( + id: 'records', + name: 'records', + type: VariableType.list, + value: jsonEncode(crate), + ), + ) + ..variables['x'] = Observable( + VariableData(id: 'x', name: 'x', type: VariableType.text, value: 'b'), + ) + ..variables['label'] = Observable( + VariableData(id: 'label', name: 'label', type: VariableType.text), + ); + +Future pumpContext( + WidgetTester tester, + CodelesslyContext codelesslyContext, +) async { + late BuildContext captured; + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: codelesslyContext, + child: Builder( + builder: (context) { + captured = context; + return const SizedBox(); + }, + ), + ), + ); + return captured; +} + +List recordsOf(CodelesslyContext c) { + final Object? value = c.variables['records']!.value.getValue(); + return value is String ? jsonDecode(value) as List : value as List; +} + +void main() { + test('The regexes keep a formula with object literals whole', () { + expect(exactFormulaRegex.hasMatch(countPlay), isTrue); + expect(FormulaEvaluator.exactExpression(countPlay), isNotNull); + expect(formulaRegex.firstMatch('n: $countPlay!')!.group(0), countPlay); + }); + + testWidgets('A set variable action takes an object literal formula', ( + tester, + ) async { + final CodelesslyContext c = crateContext(); + final BuildContext context = await pumpContext(tester, c); + final bool set = FunctionsRepository.setVariableFromAction( + context, + SetVariableAction( + variable: VariableData( + id: 'records', + name: 'records', + type: VariableType.list, + ), + newValue: countPlay, + ), + ); + expect(set, isTrue); + expect(recordsOf(c), [ + {'id': 'a', 'plays': 1}, + {'id': 'b', 'plays': 5}, + ]); + }); + + testWidgets('A text variable set to text around such a formula', ( + tester, + ) async { + final CodelesslyContext c = crateContext(); + final BuildContext context = await pumpContext(tester, c); + FunctionsRepository.setVariableFromAction( + context, + SetVariableAction( + variable: VariableData( + id: 'label', + name: 'label', + type: VariableType.text, + ), + newValue: r'Plays: ${= merge({n: 2}, {n: 3}).n} of ${x}', + ), + ); + expect(c.variables['label']!.value.value, 'Plays: 3 of b'); + }); + + testWidgets('A property binding and a text read such a formula', ( + tester, + ) async { + final CodelesslyContext c = crateContext(); + final BuildContext context = await pumpContext(tester, c); + final ScopedValues scopedValues = ScopedValues.of(context); + final TextNode text = TextNode( + id: 'count', + name: 'count', + basicBoxLocal: NodeBox(0, 0, 100, 20), + characters: '', + variables: { + 'data': countPlay, + 'characters': + r'${= count(filter(' + r'map(records, r => merge(r, {hot: r.plays > 2})), ' + r'r => r.hot))} hot', + }, + ); + final List? mapped = PropertyValueDelegate.getPropertyValue( + text, + 'data', + scopedValues: scopedValues, + ); + expect(mapped, [ + {'id': 'a', 'plays': 1}, + {'id': 'b', 'plays': 5}, + ]); + expect( + PropertyValueDelegate.substituteVariables( + text.variables['characters']!, + nullSubstitutionMode: NullSubstitutionMode.emptyString, + scopedValues: scopedValues, + ), + '1 hot', + ); + }); + + test('Variable syntax matches keep such a formula whole', () { + expect( + [ + for (final m in variableSyntaxIdentifierRegex.allMatches( + 'Played $countPlay times by \${user.name}', + )) + m[0], + ], + [countPlay, r'${user.name}'], + ); + expect(countPlay.wrapWithVariableSyntax(), countPlay); + expect(countPlay.containsUncheckedVariablePath, isTrue); + // A quoted brace doesn't end it either, and a path still matches alone. + expect( + variableSyntaxIdentifierRegex.firstMatch(r"${= '}' + x} ${y}")![0], + r"${= '}' + x}", + ); + }); + + test('The variables an action or binding reads include its formula', () { + final RectangleNode node = RectangleNode( + id: 'row', + name: 'row', + basicBoxLocal: NodeBox(0, 0, 10, 10), + reactions: [ + Reaction( + trigger: TriggerModel(type: TriggerType.click), + action: SetVariableAction( + variable: VariableData( + id: 'records', + name: 'records', + type: VariableType.list, + ), + newValue: countPlay, + ), + ), + ], + )..variables['visible'] = r'${= count(filter(crate, c => c.on)) > 0}'; + expect(node.getUsedVariableNames(), {'records', 'x', 'crate'}); + }); +} diff --git a/test/formula_strings_objects_test.dart b/test/formula_strings_objects_test.dart new file mode 100644 index 00000000..d38796f9 --- /dev/null +++ b/test/formula_strings_objects_test.dart @@ -0,0 +1,362 @@ +import 'package:codelessly_sdk/codelessly_sdk.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + final List> records = [ + {'id': 'a', 'artist': 'Miles Davis', 'year': 1959, 'favorite': false}, + {'id': 'b', 'artist': 'Bill Evans', 'year': 1961, 'favorite': true}, + {'id': 'c', 'artist': 'Miles Davis', 'year': 1970, 'favorite': false}, + {'id': 'd', 'artist': 'Bill Evans', 'year': 1956, 'favorite': false}, + ]; + final List> tracks = [ + {'title': 'So What', 'length': '9:22'}, + {'title': 'Freddie Freeloader', 'length': '9:46'}, + {'title': 'Blue in Green', 'length': '5:37'}, + ]; + final Map vars = { + 'records': records, + 'tracks': tracks, + 'selected': 'c', + 'count': 4, + 'genres': ['rock', 'jazz', 'soul'], + 'record': records.first, + }; + + Object? resolve(String path) { + Object? value = vars; + for (final part in path.split('.')) { + value = value is Map ? value[part] : null; + } + return value; + } + + Object? eval(String expression) => + FormulaEvaluator.evaluate(expression, resolve); + + group('text', () { + test('padLeft and padRight', () { + expect(eval('padLeft(4, 2)'), '04'); + expect(eval('padLeft(count, 3)'), '004'); + expect(eval('padLeft(12, 2)'), '12'); + expect(eval('padLeft(-4, 3)'), '-04'); + expect(eval('padLeft("a", 3)'), ' a'); + expect(eval('padLeft(7, 3, "*")'), '**7'); + expect(eval('padRight("A", 3, ".")'), 'A..'); + expect(eval('padRight(5, 3)'), '500'); + }); + + test('substring clamps to the text', () { + expect(eval('substring("Vinyl", 0, 3)'), 'Vin'); + expect(eval('substring("Vinyl", 2)'), 'nyl'); + expect(eval('substring("Vinyl", 3, 99)'), 'yl'); + expect(eval('substring("Vinyl", 4, 2)'), ''); + expect(eval('substring(null, 0, 2)'), ''); + }); + + test('replace replaces every occurrence', () { + expect(eval('replace("a-b-c", "-", " ")'), 'a b c'); + expect(eval('replace("abc", "", "x")'), 'abc'); + expect(eval('replace("a.b", ".", "")'), 'ab'); + }); + + test('split', () { + expect(eval('split("rock, jazz", ", ")'), ['rock', 'jazz']); + expect(eval('split("ab")'), ['a', 'b']); + expect(eval('split("", ",")'), isEmpty); + expect(eval('length(split("a b c", " "))'), 3); + }); + + test('indexOf on text and lists', () { + expect(eval('indexOf("hello", "l")'), 2); + expect(eval('indexOf("hello", "z")'), -1); + expect(eval('indexOf(genres, "jazz")'), 1); + expect(eval('indexOf(genres, "pop")'), -1); + expect(eval('indexOf([1, 2, 3], "3")'), 2); + expect(eval('indexOf(null, 1)'), -1); + }); + + test('matches tests a regular expression', () { + expect(eval(r'matches("2026", "^\d{4}$")'), isTrue); + expect(eval(r'matches("20x6", "^\d{4}$")'), isFalse); + expect(eval('matches("abc", "(")'), isFalse); + }); + + test('slug', () { + expect(eval('slug("Kind of Blue!")'), 'kind-of-blue'); + expect(eval('slug(" -- Miles Davis & Co. --")'), 'miles-davis-co'); + expect(eval('slug("")'), ''); + }); + }); + + group('durations', () { + test('seconds reads m:ss and h:mm:ss', () { + expect(eval('seconds("3:45")'), 225); + expect(eval('seconds("0:07")'), 7); + expect(eval('seconds("1:02:05")'), 3725); + expect(eval('seconds("75:00")'), 4500); + expect(eval('seconds("225")'), 225); + expect(eval('seconds(90)'), 90); + }); + + test('seconds is null when invalid', () { + expect(eval('seconds("3:75")'), isNull); + expect(eval('seconds("3:5")'), isNull); + expect(eval('seconds("abc")'), isNull); + expect(eval('seconds("")'), isNull); + expect(eval('seconds("1:2:3:4")'), isNull); + expect(eval('seconds(null)'), isNull); + }); + + test('duration writes m:ss and h:mm:ss', () { + expect(eval('duration(225)'), '3:45'); + expect(eval('duration(5)'), '0:05'); + expect(eval('duration(3600)'), '1:00:00'); + expect(eval('duration(3725)'), '1:02:05'); + expect(eval('duration(224.6)'), '3:45'); + expect(eval('duration("3:45")'), '3:45'); + expect(eval('duration(null)'), isNull); + expect(eval('duration("x")'), isNull); + }); + + test('total running time of tracks', () { + expect(eval('duration(sum(tracks, t => seconds(t.length)))'), '24:45'); + }); + }); + + group('lists', () { + test('slice, take, skip and reverse', () { + expect(eval('slice([1, 2, 3, 4], 1, 3)'), [2, 3]); + expect(eval('slice([1, 2, 3, 4], 2)'), [3, 4]); + expect(eval('slice([1, 2, 3, 4], -2)'), [3, 4]); + expect(eval('slice([1, 2, 3, 4], 3, 1)'), isEmpty); + expect(eval('slice("Vinyl", 1, -1)'), 'iny'); + expect(eval('take([1, 2, 3], 2)'), [1, 2]); + expect(eval('take([1, 2, 3], 10)'), [1, 2, 3]); + expect(eval('take([1, 2, 3], -1)'), isEmpty); + expect(eval('skip([1, 2, 3], 2)'), [3]); + expect(eval('skip([1, 2, 3], -1)'), [1, 2, 3]); + expect(eval('take("Vinyl", 3)'), 'Vin'); + expect(eval('skip(nothing, 1)'), isEmpty); + expect(eval('reverse([1, 2, 3])'), [3, 2, 1]); + expect(eval('reverse("abc")'), 'cba'); + }); + + test('findIndex', () { + expect(eval('findIndex(records, r => r.id == selected)'), 2); + expect(eval('findIndex(records, "favorite")'), 1); + expect(eval('findIndex(records, "artist", "Bill Evans")'), 1); + expect(eval('findIndex(records, r => r.year > 2000)'), -1); + expect(eval('findIndex(nothing, r => true)'), -1); + }); + + test('lambdas take an optional index', () { + expect(eval('map(genres, (g, i) => i + 1 + ". " + g)'), [ + '1. rock', + '2. jazz', + '3. soul', + ]); + expect(eval('filter(genres, (g, i) => i != 1)'), ['rock', 'soul']); + expect(eval('find(genres, (g, i) => i == 2)'), 'soul'); + expect(eval('any(genres, (g, i) => i > 5)'), isFalse); + expect(eval('all(genres, (g, i) => i < 3)'), isTrue); + expect(eval('findIndex(genres, (g, i) => i > 0 && g == "soul")'), 2); + expect(eval('sum([10, 20, 30], (n, i) => n * i)'), 80); + expect(eval('count(genres, (g, i) => i < 2)'), 2); + expect(eval('join(genres, ", ", (g, i) => i)'), '0, 1, 2'); + // Single-parameter lambdas still work. + expect(eval('map(genres, g => upper(g))'), ['ROCK', 'JAZZ', 'SOUL']); + }); + + test('a running total', () { + expect( + eval( + 'map(tracks, (t, i) => duration(sum(take(tracks, i), ' + 'x => seconds(x.length))))', + ), + ['0:00', '9:22', '19:08'], + ); + }); + + test('index parameters are not variables', () { + expect( + FormulaEvaluator.referencedPaths('map(genres, (g, i) => i + g + n)'), + {'genres', 'n'}, + ); + }); + + test('sortBy takes several keys and directions', () { + List ids(String expression) => + (eval(expression) as List).map((r) => (r as Map)['id']).toList(); + expect(ids('sortBy(records, r => r.artist, r => r.year)'), [ + 'd', + 'b', + 'a', + 'c', + ]); + expect(ids('sortBy(records, "artist", "year", "desc")'), [ + 'b', + 'd', + 'c', + 'a', + ]); + expect(ids('sortBy(records, "artist", "desc", "year")'), [ + 'a', + 'c', + 'd', + 'b', + ]); + expect(ids('sortByDesc(records, "year")'), ['c', 'b', 'a', 'd']); + expect(ids('sortByDesc(records, "artist", "year", "asc")'), [ + 'a', + 'c', + 'd', + 'b', + ]); + expect(ids('sortBy(records, r => r.year, "desc")'), ['c', 'b', 'a', 'd']); + expect(eval('sortBy([2, 3, 1], null, "desc")'), [3, 2, 1]); + expect(eval('sortByDesc([2, 3, 1])'), [3, 2, 1]); + }); + }); + + group('objects', () { + test('object and list literals', () { + expect(eval("{id: 'a', n: 1 + 2}"), {'id': 'a', 'n': 3}); + expect(eval("{'full name': 'Bill', \"b\": [1, {c: true}]}"), { + 'full name': 'Bill', + 'b': [ + 1, + {'c': true}, + ], + }); + expect(eval('{}'), {}); + expect(eval('{a: count > 2 ? "many" : "few"}.a'), 'many'); + expect(eval('{a: 1}["a"]'), 1); + expect(eval('length([1, 2, 3])'), 3); + expect(FormulaEvaluator.errorOf('{a 1}'), isNotNull); + expect(FormulaEvaluator.referencedPaths('{a: count, b: x.y}'), { + 'count', + 'x.y', + }); + }); + + test('merge and set return new objects', () { + expect(eval('merge(record, {favorite: true, rating: 5})'), { + ...records.first, + 'favorite': true, + 'rating': 5, + }); + expect(records.first['favorite'], isFalse); + expect(eval('merge({a: 1}, {b: 2}, {a: 3})'), {'a': 3, 'b': 2}); + expect(eval('merge(null, {a: 1})'), {'a': 1}); + expect(eval('set(record, "year", 2000)')?.toString(), contains('2000')); + expect(records.first['year'], 1959); + expect(eval('set({a: 1}, "b", 2)'), {'a': 1, 'b': 2}); + expect(eval('set([1, 2, 3], 1, 9)'), [1, 9, 3]); + expect(eval('set([1, 2, 3], 5, 9)'), [1, 2, 3]); + }); + + test('toggling a favorite in a list', () { + final Object? result = eval( + 'map(records, r => r.id == selected ' + '? merge(r, {favorite: !r.favorite}) : r)', + ); + expect(result, isA()); + final List list = result as List; + expect(list.map((r) => (r as Map)['favorite']), [ + false, + true, + true, + false, + ]); + expect(identical(list[0], records[0]), isTrue); + expect(records[2]['favorite'], isFalse); + }); + + test('formulas with braces work in text', () { + expect( + FormulaEvaluator.substitute( + r'${= count(map(records, r => r.id == selected ? merge(r, {favorite: !r.favorite}) : r), "favorite")} favorites', + resolve, + ), + '2 favorites', + ); + expect( + FormulaEvaluator.exactExpression(r'${= merge({a: {b: {c: 1}}}, {})}'), + ' merge({a: {b: {c: 1}}}, {})', + ); + expect( + FormulaEvaluator.substitute(r'${= "}" + "{"} and ${= 1 + 1}', resolve), + '}{ and 2', + ); + expect( + FormulaEvaluator.referencedPathsInText( + r'Hi ${= merge(record, {n: count}).n}', + ), + {'record', 'count'}, + ); + }); + }); + + group('money and luminance', () { + test('money keeps its default and takes decimals', () { + expect(eval('money(38)'), r'$38.00'); + expect(eval('money(38, 0)'), r'$38'); + expect(eval('money(38.5, 0)'), r'$39'); + expect(eval('money(1234.5, 1)'), r'$1,234.5'); + expect(eval("money(38, '€', 0)"), '€38'); + expect(eval("money(38, null, 'auto')"), r'$38'); + expect(eval("money(38.5, '\$', 'auto')"), r'$38.50'); + expect(eval("money(-1200, '£', 'auto')"), '-£1,200'); + expect(eval("money(-3, '€')"), '-€3.00'); + }); + + test('luminance', () { + expect(eval('luminance("#FFFFFF")'), 1); + expect(eval('luminance("#000000")'), 0); + expect(eval('luminance("#FF000000")'), 0); + expect(eval('luminance("#80FFFFFF")'), 1); + expect(eval('luminance("#fff")'), 1); + expect(eval('luminance("#FF0000")'), 0.2126); + expect(eval('luminance({r: 0, g: 1, b: 0})'), 0.7152); + expect(eval('luminance("#777777")'), closeTo(0.1845, 0.0001)); + expect(eval('luminance("nope")'), isNull); + expect( + eval('luminance("#1A1A2E") > 0.5 ? "#000000" : "#FFFFFF"'), + '#FFFFFF', + ); + }); + }); + + test('new functions are documented and their examples parse', () { + final Set documented = { + for (final doc in FormulaEvaluator.functionDocs) doc.name, + }; + for (final name in [ + 'padLeft', + 'padRight', + 'substring', + 'replace', + 'split', + 'indexOf', + 'matches', + 'slug', + 'seconds', + 'duration', + 'slice', + 'take', + 'skip', + 'findIndex', + 'reverse', + 'sortByDesc', + 'merge', + 'set', + 'luminance', + ]) { + expect(documented, contains(name)); + expect(FormulaEvaluator.functions, contains(name)); + } + for (final name in FormulaEvaluator.functions) { + expect(documented, contains(name), reason: name); + } + }); +} diff --git a/test/formula_text_split_test.dart b/test/formula_text_split_test.dart new file mode 100644 index 00000000..775b758f --- /dev/null +++ b/test/formula_text_split_test.dart @@ -0,0 +1,17 @@ +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('a text splits into whole formulas and variables', () { + const String text = + r'Hi ${name}, ${= merge(r, {favorite: true}).favorite} and ${= 1 + 2}!'; + expect( + variableSyntaxIdentifierRegex.allMatches(text).map((m) => m[0]).toList(), + [ + r'${name}', + r'${= merge(r, {favorite: true}).favorite}', + r'${= 1 + 2}', + ], + ); + }); +} diff --git a/test/initial_screen_test.dart b/test/initial_screen_test.dart new file mode 100644 index 00000000..db19221f --- /dev/null +++ b/test/initial_screen_test.dart @@ -0,0 +1,48 @@ +import 'package:codelessly_sdk/codelessly_sdk.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'feedback_actions_test.dart' show body, canvas, layoutOf; + +void main() { + SDKPublishModel model() { + final m = SDKPublishModel( + projectId: 'project', + owner: 'owner', + teams: const {}, + users: const {}, + roles: const {}, + public: false, + ); + for (final layout in [ + layoutOf('home-layout', [ + canvas('home', 'homeBody')..name = 'Crates', + body('homeBody', 'home', []), + ]), + layoutOf('form-layout', [ + canvas('form', 'formBody')..name = 'Record form', + body('formBody', 'form', []), + ]), + ]) { + m.layouts[layout.id] = layout; + } + return m; + } + + test('A screen is found by canvas id or by its name, loosely', () { + final m = model(); + expect(CodelesslyWidget.layoutIdForScreen(m, 'form'), 'form-layout'); + expect(CodelesslyWidget.layoutIdForScreen(m, 'record-form'), 'form-layout'); + expect( + CodelesslyWidget.layoutIdForScreen(m, 'Record%20Form'), + 'form-layout', + ); + expect(CodelesslyWidget.layoutIdForScreen(m, 'CRATES'), 'home-layout'); + }); + + test('No or unknown screen gives no layout, so the entry one is used', () { + final m = model(); + expect(CodelesslyWidget.layoutIdForScreen(m, null), isNull); + expect(CodelesslyWidget.layoutIdForScreen(m, ''), isNull); + expect(CodelesslyWidget.layoutIdForScreen(m, 'ledger'), isNull); + }); +} diff --git a/test/node_motion_entrance_test.dart b/test/node_motion_entrance_test.dart new file mode 100644 index 00000000..e3f0f892 --- /dev/null +++ b/test/node_motion_entrance_test.dart @@ -0,0 +1,151 @@ +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'; + +Future pump( + WidgetTester tester, + MotionModel motion, { + bool animate = true, +}) => tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: NodeMotion( + motion: motion, + animate: animate, + child: const SizedBox(width: 100, height: 100), + ), + ), + ), +); + +double opacityOf(WidgetTester tester) { + final Finder finder = find.byType(Opacity); + return finder.evaluate().isEmpty + ? 1 + : tester.widget(finder.first).opacity; +} + +double scaleOf(WidgetTester tester) { + final Finder finder = find.descendant( + of: find.byType(NodeMotion), + matching: find.byType(Transform), + ); + if (finder.evaluate().isEmpty) return 1; + return tester.widget(finder.first).transform.getMaxScaleOnAxis(); +} + +/// A record that fades and settles in from slightly too big. +const MotionModel record = MotionModel( + durationMs: 400, + curve: MotionCurve.linear, + enterFrom: MotionModel(scale: 1.15, opacity: 0), +); + +void main() { + testWidgets('An entrance starts from enterFrom and settles at rest', ( + tester, + ) async { + await pump(tester, record); + expect(opacityOf(tester), 0); + expect(scaleOf(tester), closeTo(1.15, 0.001)); + await tester.pump(const Duration(milliseconds: 200)); + expect(opacityOf(tester), closeTo(0.5, 0.01)); + expect(scaleOf(tester), closeTo(1.075, 0.001)); + await tester.pump(const Duration(milliseconds: 250)); + expect(opacityOf(tester), 1); + expect(scaleOf(tester), 1); + }); + + testWidgets('An entrance from values settles on the node values', ( + tester, + ) async { + await pump( + tester, + const MotionModel( + opacity: 0.8, + durationMs: 400, + curve: MotionCurve.linear, + enter: true, + enterFrom: MotionModel(opacity: 0), + ), + ); + expect(opacityOf(tester), 0); + await tester.pump(const Duration(milliseconds: 200)); + expect(opacityOf(tester), closeTo(0.4, 0.01)); + await tester.pump(const Duration(milliseconds: 250)); + expect(opacityOf(tester), closeTo(0.8, 0.001)); + }); + + testWidgets('Changing replayKey plays the entrance again', (tester) async { + await pump(tester, record.copyWith(replayKey: 'a')); + await tester.pump(const Duration(milliseconds: 500)); + expect(opacityOf(tester), 1); + + // Same key: nothing moves. + await pump(tester, record.copyWith(replayKey: 'a')); + await tester.pump(const Duration(milliseconds: 100)); + expect(opacityOf(tester), 1); + + await pump(tester, record.copyWith(replayKey: 'b')); + expect(opacityOf(tester), 0); + expect(scaleOf(tester), closeTo(1.15, 0.001)); + await tester.pump(const Duration(milliseconds: 200)); + expect(opacityOf(tester), closeTo(0.5, 0.01)); + await tester.pump(const Duration(milliseconds: 250)); + expect(opacityOf(tester), 1); + }); + + testWidgets('A replay without enterFrom starts from rest', (tester) async { + const MotionModel motion = MotionModel( + opacity: 0.2, + durationMs: 400, + curve: MotionCurve.linear, + ); + await pump(tester, motion.copyWith(replayKey: '1')); + expect(opacityOf(tester), closeTo(0.2, 0.001), reason: 'no entrance'); + await pump(tester, motion.copyWith(replayKey: '2')); + expect(opacityOf(tester), 1); + await tester.pump(const Duration(milliseconds: 200)); + expect(opacityOf(tester), closeTo(0.6, 0.01)); + await tester.pump(const Duration(milliseconds: 250)); + expect(opacityOf(tester), closeTo(0.2, 0.001)); + }); + + testWidgets('The design canvas ignores entrances and replays', ( + tester, + ) async { + await pump(tester, record.copyWith(replayKey: 'a'), animate: false); + expect(opacityOf(tester), 1); + await pump(tester, record.copyWith(replayKey: 'b'), animate: false); + expect(opacityOf(tester), 1); + }); + + test('enterFrom and replayKey survive JSON', () { + final MotionModel motion = record.copyWith(replayKey: 'x'); + final Map json = motion.toJson(); + expect(json['enterFrom'], {'scale': 1.15, 'opacity': 0.0}); + expect(MotionModel.fromJson(json), motion); + expect(motion.copyWith(clearEnterFrom: true).enterFrom, isNull); + expect(MotionModel.none.toJson().containsKey('enterFrom'), isFalse); + }); + + test('motion.replayKey resolves from a variable to text', () { + final RectangleNode node = + RectangleNode( + id: 'disc', + name: 'Disc', + basicBoxLocal: NodeBox(0, 0, 100, 100), + ) + ..motion = record + ..variables[MotionModel.variableKey(MotionModel.replayKeyProperty)] = + r'${= 2 + 5}'; + final MotionModel resolved = WidgetNodeTransformerManager.resolveMotion( + node, + scopedValues: ScopedValues.empty(), + )!; + expect(resolved.replayKey, '7'); + expect(resolved.enterFrom, record.enterFrom); + }); +} diff --git a/test/node_motion_loop_test.dart b/test/node_motion_loop_test.dart new file mode 100644 index 00000000..c1f93b43 --- /dev/null +++ b/test/node_motion_loop_test.dart @@ -0,0 +1,149 @@ +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'; + +Future pump( + WidgetTester tester, + MotionModel motion, { + bool animate = true, +}) => tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: NodeMotion( + motion: motion, + animate: animate, + child: const SizedBox(width: 100, height: 100), + ), + ), + ), +); + +double opacityOf(WidgetTester tester) { + final Finder finder = find.byType(Opacity); + return finder.evaluate().isEmpty + ? 1 + : tester.widget(finder.first).opacity; +} + +double dxOf(WidgetTester tester) { + final Finder finder = find.descendant( + of: find.byType(NodeMotion), + matching: find.byType(Transform), + ); + if (finder.evaluate().isEmpty) return 0; + return tester.widget(finder.first).transform.getTranslation().x; +} + +void main() { + testWidgets('A back-and-forth loop pulses between rest and its values', ( + tester, + ) async { + await pump( + tester, + const MotionModel( + opacity: 0.4, + durationMs: 1000, + curve: MotionCurve.linear, + loop: MotionLoop.reverse, + ), + ); + expect(opacityOf(tester), 1); + await tester.pump(const Duration(milliseconds: 500)); + expect(opacityOf(tester), closeTo(0.7, 0.01)); + await tester.pump(const Duration(milliseconds: 500)); + expect(opacityOf(tester), closeTo(0.4, 0.01)); + await tester.pump(const Duration(milliseconds: 500)); + expect(opacityOf(tester), closeTo(0.7, 0.01)); + await tester.pump(const Duration(milliseconds: 500)); + expect(opacityOf(tester), closeTo(1, 0.01)); + // Still going. + await tester.pump(const Duration(milliseconds: 250)); + expect(opacityOf(tester), closeTo(0.85, 0.01)); + await tester.pumpWidget(const SizedBox()); + }); + + testWidgets('A restart loop jumps back to rest each time', (tester) async { + await pump( + tester, + const MotionModel( + translateX: 100, + durationMs: 1000, + curve: MotionCurve.linear, + loop: MotionLoop.restart, + ), + ); + await tester.pump(const Duration(milliseconds: 900)); + expect(dxOf(tester), closeTo(90, 0.5)); + await tester.pump(const Duration(milliseconds: 200)); + expect(dxOf(tester), closeTo(10, 0.5)); + await tester.pumpWidget(const SizedBox()); + }); + + testWidgets('An entrance animates in from rest after its delay', ( + tester, + ) async { + await pump( + tester, + const MotionModel( + translateX: 120, + durationMs: 400, + curve: MotionCurve.linear, + enter: true, + delayMs: 200, + ), + ); + expect(dxOf(tester), 0); + await tester.pump(const Duration(milliseconds: 150)); + expect(dxOf(tester), 0, reason: 'still waiting out the delay'); + await tester.pump(const Duration(milliseconds: 50)); + await tester.pump(const Duration(milliseconds: 200)); + expect(dxOf(tester), closeTo(60, 1)); + await tester.pump(const Duration(milliseconds: 300)); + expect(dxOf(tester), 120); + }); + + testWidgets('A delay holds an ordinary transition back too', (tester) async { + const MotionModel motion = MotionModel( + durationMs: 100, + curve: MotionCurve.linear, + delayMs: 300, + ); + await pump(tester, motion); + await pump(tester, motion.copyWith(translateX: 50)); + await tester.pump(const Duration(milliseconds: 200)); + expect(dxOf(tester), 0); + await tester.pump(const Duration(milliseconds: 100)); + await tester.pump(const Duration(milliseconds: 100)); + expect(dxOf(tester), 50); + }); + + testWidgets('The design canvas shows the values at rest, without looping', ( + tester, + ) async { + await pump( + tester, + const MotionModel( + opacity: 0.4, + durationMs: 1000, + loop: MotionLoop.reverse, + enter: true, + ), + animate: false, + ); + expect(opacityOf(tester), closeTo(0.4, 0.001)); + await tester.pump(const Duration(milliseconds: 500)); + expect(opacityOf(tester), closeTo(0.4, 0.001)); + }); + + test('loop, enter and delay survive JSON and are omitted at defaults', () { + const MotionModel motion = MotionModel( + loop: MotionLoop.reverse, + enter: true, + delayMs: 180, + ); + expect(MotionModel.fromJson(motion.toJson()), motion); + expect(MotionModel.none.toJson().containsKey('loop'), isFalse); + }); +} diff --git a/test/node_motion_test.dart b/test/node_motion_test.dart new file mode 100644 index 00000000..7077b2d4 --- /dev/null +++ b/test/node_motion_test.dart @@ -0,0 +1,214 @@ +import 'dart:math' as math; + +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'; + +RectangleNode box({ + MotionModel? motion, + Map variables = const {}, +}) => + RectangleNode( + id: 'box', + name: 'Box', + basicBoxLocal: NodeBox(0, 0, 100, 100), + ) + ..motion = motion + ..variables.addAll(variables); + +Matrix4? transformOf(WidgetTester tester) { + final finder = find.descendant( + of: find.byType(NodeMotion), + matching: find.byType(Transform), + ); + if (finder.evaluate().isEmpty) return null; + return tester.widget(finder.first).transform; +} + +Future pumpMotion( + WidgetTester tester, + MotionModel motion, { + bool animate = true, +}) => tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: NodeMotion( + motion: motion, + animate: animate, + child: const SizedBox(width: 100, height: 100), + ), + ), + ), +); + +void main() { + test('Motion round-trips through a node and omits defaults', () { + const motion = MotionModel(rotateX: 30, durationMs: 400, spin: 0.555); + final json = box(motion: motion).toJson(); + expect(json['motion'], {'rotateX': 30.0, 'durationMs': 400, 'spin': 0.555}); + final restored = RectangleNode.fromJson(json); + expect(restored.motion, motion); + expect(box().toJson().containsKey('motion'), isFalse); + }); + + test('Bound motion values come from variables and formulas', () { + final node = box( + motion: const MotionModel(rotateX: 5), + variables: { + MotionModel.variableKey('rotateX'): r'${= 2 * 30}', + MotionModel.variableKey('translateY'): r'${= 1 > 2 ? 0 : -12}', + MotionModel.variableKey('spin'): r'${= true ? 0.75 : 0}', + }, + ); + final resolved = WidgetNodeTransformerManager.resolveMotion( + node, + scopedValues: ScopedValues.empty(), + )!; + expect(resolved.rotateX, 60); + expect(resolved.translateY, -12); + expect(resolved.spin, 0.75); + }); + + test('A node with only bound values still moves', () { + final node = box( + variables: {MotionModel.variableKey('scale'): r'${= 0.5}'}, + ); + final resolved = WidgetNodeTransformerManager.resolveMotion( + node, + scopedValues: ScopedValues.empty(), + ); + expect(resolved?.scale, 0.5); + }); + + testWidgets('Changing a value animates to it over the duration', ( + tester, + ) async { + const start = MotionModel(durationMs: 400, curve: MotionCurve.linear); + await pumpMotion(tester, start); + expect(transformOf(tester), isNull); + + await pumpMotion(tester, start.copyWith(translateY: 100)); + await tester.pump(const Duration(milliseconds: 200)); + expect(transformOf(tester)!.getTranslation().y, closeTo(50, 1)); + await tester.pump(const Duration(milliseconds: 250)); + expect(transformOf(tester)!.getTranslation().y, closeTo(100, 0.01)); + }); + + testWidgets('A change mid-transition continues from where it is', ( + tester, + ) async { + const start = MotionModel(durationMs: 400, curve: MotionCurve.linear); + await pumpMotion(tester, start); + await pumpMotion(tester, start.copyWith(translateX: 100)); + await tester.pump(const Duration(milliseconds: 200)); + await pumpMotion(tester, start.copyWith(translateX: 0)); + await tester.pump(const Duration(milliseconds: 1)); + expect(transformOf(tester)!.getTranslation().x, closeTo(50, 1.5)); + }); + + testWidgets('Without a duration, or on the design canvas, values jump', ( + tester, + ) async { + await pumpMotion(tester, const MotionModel()); + await pumpMotion(tester, const MotionModel(translateX: 40)); + expect(transformOf(tester)!.getTranslation().x, 40); + + await pumpMotion( + tester, + const MotionModel(durationMs: 500), + animate: false, + ); + await pumpMotion( + tester, + const MotionModel(durationMs: 500, translateX: 80), + animate: false, + ); + expect(transformOf(tester)!.getTranslation().x, 80); + }); + + testWidgets('Spin ramps up, turns, and winds down when stopped', ( + tester, + ) async { + double angle() { + final m = transformOf(tester); + if (m == null) return 0; + return math.atan2(m.entry(1, 0), m.entry(0, 0)); + } + + await pumpMotion(tester, const MotionModel(spin: 1, spinRampMs: 1000)); + await tester.pump(const Duration(milliseconds: 16)); + await tester.pump(const Duration(milliseconds: 16)); + final early = angle(); + // Still ramping: far slower than a full turn per second. + expect(early.abs(), lessThan(0.1)); + + for (var i = 0; i < 120; i++) { + await tester.pump(const Duration(milliseconds: 16)); + } + expect(angle(), isNot(closeTo(early, 0.01))); + + await pumpMotion(tester, const MotionModel(spin: 0, spinRampMs: 300)); + for (var i = 0; i < 200; i++) { + await tester.pump(const Duration(milliseconds: 16)); + } + final stopped = angle(); + await tester.pump(const Duration(milliseconds: 100)); + expect(angle(), stopped); + }); + + testWidgets('The design canvas never spins', (tester) async { + await pumpMotion(tester, const MotionModel(spin: 1), animate: false); + await tester.pump(const Duration(milliseconds: 500)); + expect(transformOf(tester), isNull); + }); + + test('Tilts get perspective, flat transforms don\'t', () { + expect(motionMatrix(const MotionModel(rotateX: 20)).entry(3, 2), isNot(0)); + expect(motionMatrix(const MotionModel(translateX: 20)).entry(3, 2), 0); + expect( + motionMatrix(const MotionModel(rotateX: 20, perspective: 0)).entry(3, 2), + 0, + ); + }); + + testWidgets('Opacity animates, and a faded-out node takes no taps', ( + tester, + ) async { + var taps = 0; + Future pump(double opacity) => tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: NodeMotion( + motion: MotionModel( + opacity: opacity, + durationMs: 200, + curve: MotionCurve.linear, + ), + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => taps++, + child: const SizedBox(width: 100, height: 100), + ), + ), + ), + ), + ); + await pump(1); + await pump(0); + await tester.pump(const Duration(milliseconds: 100)); + expect( + tester.widget(find.byType(Opacity)).opacity, + closeTo(0.5, 0.05), + ); + await tester.pump(const Duration(milliseconds: 150)); + await tester.tapAt(tester.getCenter(find.byType(NodeMotion))); + expect(taps, 0); + await pump(1); + await tester.pump(const Duration(milliseconds: 250)); + await tester.tapAt(tester.getCenter(find.byType(NodeMotion))); + expect(taps, 1); + }); +} diff --git a/test/route_variables_test.dart b/test/route_variables_test.dart new file mode 100644 index 00000000..a1245764 --- /dev/null +++ b/test/route_variables_test.dart @@ -0,0 +1,100 @@ +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:codelessly_sdk/codelessly_sdk.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; + +void main() { + testWidgets('A link sets the variables its parameters name', (tester) async { + final CodelesslyContext c = CodelesslyContext.empty() + ..variables['a'] = Observable( + VariableData(id: 'a', name: 'selectedId', value: ''), + ) + ..variables['b'] = Observable( + VariableData( + id: 'b', + name: 'spinning', + type: VariableType.boolean, + value: 'false', + ), + ); + late BuildContext captured; + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: c, + child: Builder( + builder: (context) { + captured = context; + return const SizedBox(); + }, + ), + ), + ); + + Codelessly.setVariablesFromRoute( + captured, + const CodelesslyRoute('record', { + 'selectedId': 'kind-of-blue', + 'spinning': 'true', + 'from': 'link', + }), + ); + + expect(c.findVariableByName('selectedId')!.value.value, 'kind-of-blue'); + expect(c.findVariableByName('spinning')!.value.value, 'true'); + // A parameter that names no variable is only a route parameter. + expect(c.findVariableByName('from'), isNull); + }); + + testWidgets('Without a Codelessly context nothing happens', (tester) async { + late BuildContext captured; + await tester.pumpWidget( + Builder( + builder: (context) { + captured = context; + return const SizedBox(); + }, + ), + ); + Codelessly.setVariablesFromRoute( + captured, + const CodelesslyRoute('x', {'a': 'b'}), + ); + }); + + testWidgets( + 'A link to the screen shown first applies before its load actions', + (tester) async { + final Codelessly codelessly = Codelessly(); + codelessly.pendingRoute = const CodelesslyRoute( + 'crates', + {'loadState': 'loading', 'debugHold': '1'}, + ); + final CodelesslyContext c = CodelesslyContext.empty() + ..variables['s'] = Observable( + VariableData(id: 's', name: 'loadState', value: 'ready'), + ); + late BuildContext captured; + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: c, + child: Builder( + builder: (context) { + captured = context; + return const SizedBox(); + }, + ), + ), + ); + + codelessly.prepareRoute(captured, 'crates'); + expect(codelessly.route.value?.params['debugHold'], '1'); + expect(c.findVariableByName('loadState')!.value.value, 'loading'); + + // Another screen gets its own (empty) route. + codelessly.prepareRoute(captured, 'ledger'); + expect(codelessly.route.value?.canvasId, 'ledger'); + expect(codelessly.route.value?.params, isEmpty); + }, + ); +} diff --git a/test/slider_thumb_padding_test.dart b/test/slider_thumb_padding_test.dart new file mode 100644 index 00000000..f291f018 --- /dev/null +++ b/test/slider_thumb_padding_test.dart @@ -0,0 +1,150 @@ +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'; + +SliderNode slider(Map properties, {double width = 300}) => + SliderNode.fromJson({ + 'id': 'slider', + 'name': 'slider', + 'type': 'slider', + 'basicBoxLocal': [0, 0, width, 48], + 'value': 0, + 'properties': properties, + })..parentID = kRootNode; + +/// Pumps [node] and returns the value a tap at [dx] picks. +Future valueAt(WidgetTester tester, SliderNode node, double dx) async { + final values = []; + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Align( + alignment: Alignment.topLeft, + child: TransformerSlider( + node: node, + settings: const WidgetBuildSettings(debugLabel: 'test'), + onChanged: values.add, + value: 0, + ), + ), + ), + ), + ); + await tester.tapAt(Offset(dx, 24)); + await tester.pump(); + return values.last; +} + +void main() { + group('Slider padding', () { + testWidgets('Without padding, the track is inset for the thumb overlay', ( + tester, + ) async { + final SliderNode node = slider({}); + expect(node.properties.padding, isNull); + // The track starts 24px in (the overlay radius) and spans 252px. + expect(await valueAt(tester, node, 24 + 126), closeTo(50, 0.5)); + }); + + testWidgets('padding: 0 runs the track edge to edge', (tester) async { + final SliderNode node = slider({'padding': 0}); + expect(node.properties.padding, EdgeInsetsModel.zero); + expect(node.toJson()['properties'], {'padding': 0}); + expect(await valueAt(tester, node, 150), closeTo(50, 0.5)); + expect(await valueAt(tester, node, 75), closeTo(25, 0.5)); + }); + + testWidgets('Set padding insets the track by that much', (tester) async { + final SliderNode node = slider({ + 'padding': [10, 0], + }); + // 10px each side: the track is 280px wide. + expect(await valueAt(tester, node, 10 + 70), closeTo(25, 0.5)); + }); + }); + + group('Slider thumb shape', () { + testWidgets('A round thumb is the default', (tester) async { + final SliderNode node = slider({}); + expect(node.properties.thumbShape, SliderThumbShapeEnum.circle); + await valueAt(tester, node, 150); + final SliderTheme theme = tester.widget(find.byType(SliderTheme).last); + expect(theme.data.thumbShape, isA()); + }); + + testWidgets('A rounded rectangle thumb has its width and height', ( + tester, + ) async { + final SliderNode node = slider({ + 'thumbShape': 'roundedRect', + 'thumbWidth': 12, + 'thumbHeight': 30, + 'thumbCornerRadius': 3, + 'thumbStrokeColor': ColorRGBA.white.toJson(), + 'thumbStrokeWidth': 1, + }); + expect(SliderNode.fromJson(node.toJson()).properties, node.properties); + await valueAt(tester, node, 150); + final SliderTheme theme = tester.widget(find.byType(SliderTheme).last); + final shape = theme.data.thumbShape as RoundedRectSliderThumbShape; + expect(shape.size, const Size(12, 30)); + expect(shape.cornerRadius, 3); + expect(shape.strokeWidth, 1); + expect(shape.strokeColor, Colors.white); + expect(tester.takeException(), isNull); + }); + + testWidgets('A vertical slider swaps the thumb into its own layout', ( + tester, + ) async { + final SliderNode node = SliderNode.fromJson({ + 'id': 'fader', + 'name': 'fader', + 'type': 'slider', + 'basicBoxLocal': [0, 0, 48, 300], + 'value': 0, + 'properties': { + 'orientation': 'vertical', + 'thumbShape': 'roundedRect', + 'thumbWidth': 40, + 'thumbHeight': 16, + 'padding': [8, 0], + }, + })..parentID = kRootNode; + final values = []; + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Align( + alignment: Alignment.topLeft, + child: TransformerSlider( + node: node, + settings: const WidgetBuildSettings(debugLabel: 'test'), + onChanged: values.add, + value: 50, + ), + ), + ), + ), + ); + final SliderTheme theme = tester.widget(find.byType(SliderTheme).last); + final shape = theme.data.thumbShape as RoundedRectSliderThumbShape; + // Along the (turned) track first: the cap is 40 wide and 16 tall on + // screen. + expect(shape.size, const Size(16, 40)); + expect(tester.takeException(), isNull); + }); + + testWidgets('Hiding the thumb hides a rectangular one too', (tester) async { + final SliderNode node = slider({ + 'thumbShape': 'roundedRect', + 'showThumb': false, + }); + await valueAt(tester, node, 150); + final SliderTheme theme = tester.widget(find.byType(SliderTheme).last); + final shape = theme.data.thumbShape as RoundedRectSliderThumbShape; + expect(shape.size, Size.zero); + }); + }); +} diff --git a/test/stacked_list_test.dart b/test/stacked_list_test.dart new file mode 100644 index 00000000..98c1e934 --- /dev/null +++ b/test/stacked_list_test.dart @@ -0,0 +1,52 @@ +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'; + +void main() { + test('Stacked survives JSON and is off by default', () { + final props = ListViewProperties(stacked: true); + expect(ListViewProperties.fromJson(props.toJson()).stacked, isTrue); + expect(ListViewProperties().toJson().containsKey('stacked'), isFalse); + expect(props.copyWith().stacked, isTrue); + }); + + testWidgets('Stacked items overlap, and the first one is on top', ( + tester, + ) async { + final items = ['a', 'b', 'c']; + final taps = []; + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: SizedBox( + width: 200, + height: 200, + child: StackedItems( + itemCount: items.length, + itemBuilder: (context, index) => GestureDetector( + onTap: () => taps.add(items[index]), + child: Container( + width: 100, + height: 100, + color: const Color(0xFF000000), + child: Text(items[index]), + ), + ), + ), + ), + ), + ), + ); + final Offset a = tester.getTopLeft(find.text('a')); + expect(tester.getTopLeft(find.text('b')), a); + expect(tester.getTopLeft(find.text('c')), a); + // Placed at the top center of the box. + final Offset box = tester.getTopLeft(find.byType(StackedItems)); + expect(a, box + const Offset(50, 0)); + + await tester.tap(find.text('a')); + expect(taps, ['a']); + }); +} diff --git a/test/style_bindings_test.dart b/test/style_bindings_test.dart new file mode 100644 index 00000000..62932817 --- /dev/null +++ b/test/style_bindings_test.dart @@ -0,0 +1,349 @@ +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'; + +/// A chip-like rectangle whose hard shadow is hidden until it is selected. +RectangleNode chip(Map variables) => RectangleNode.fromJson({ + ...RectangleNode( + id: 'chip', + name: 'Chip', + basicBoxLocal: NodeBox(0, 0, 80, 32), + fills: [PaintModel.solid(id: 'fill', color: ColorRGB.fromHex('#FBF8F3'))], + strokes: [ + PaintModel.solid(id: 'stroke', color: ColorRGB.fromHex('#2B2420')), + ], + strokeWeight: 1, + cornerRadius: const CornerRadius.all(RadiusModel.circular(4)), + effects: [ + Effect.dropShadow( + id: 'shadow', + visible: false, + radius: 6, + color: ColorRGBA.black, + offset: const Vec(0, 4), + spread: 0, + ), + ], + ).toJson(), + 'variables': variables, +})..parentID = kRootNode; + +Observable boolVariable(String name, bool value) => Observable( + VariableData(id: name, name: name, type: VariableType.boolean, value: value), +); + +class Host { + Host(this.node, {Map> variables = const {}}) + : context = CodelesslyContext.empty()..variables.addAll(variables) { + registry.setNodes({node.id: node}); + manager = PassiveNodeTransformerManager( + registry.getNodeByID, + (context, bounds, pageID, layoutID, canvasID) => const SizedBox(), + ); + } + + final BaseNode node; + final CodelesslyContext context; + final NodeRegistry registry = NodeRegistry(); + late final PassiveNodeTransformerManager manager; + + late WidgetTester _tester; + + /// The node as its bindings style it now, after [pump]. + BaseNode get styled => manager.applyStyleOverrides( + _tester.element(find.byKey(const Key('host'))), + node, + ); + + /// Pumps the node's widget, or only a host to read [styled] in when + /// [build] is false (an icon loads its SVG from the network). + Future pump(WidgetTester tester, {bool build = true}) async { + _tester = tester; + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: context, + child: MaterialApp( + home: Material( + child: Align( + alignment: Alignment.topLeft, + child: Builder( + key: const Key('host'), + builder: (context) => build + ? manager.buildWidgetFromNode( + node, + context, + settings: const WidgetBuildSettings(debugLabel: 'test'), + ) + : const SizedBox(), + ), + ), + ), + ), + ), + ); + expect(tester.takeException(), isNull); + } +} + +List shadows(WidgetTester tester) => tester.allWidgets + .whereType() + .map((c) => c.decoration) + .whereType() + .expand((d) => d.boxShadow ?? const []) + .toList(); + +Border? border(WidgetTester tester) => tester.allWidgets + .whereType() + .map((box) => box.decoration) + .whereType() + .map((d) => d.border) + .whereType() + .firstOrNull; + +void main() { + group('effects', () { + testWidgets('effectsVisible shows a hidden shadow, and the shadow takes ' + 'the color, offset and blur bound to it', (tester) async { + final selected = boolVariable('selected', false); + final host = Host( + chip({ + 'effectsVisible': r'${= selected}', + 'effectsColor': r"${= selected ? '#2B2420' : '#000000'}", + 'effectsOffsetX': r'${= selected ? 2 : 0}', + 'effectsOffsetY': r'${= selected ? 2 : 0}', + 'effectsBlur': r'${= selected ? 0 : 6}', + }), + variables: {'selected': selected}, + ); + await host.pump(tester); + expect(shadows(tester), isEmpty); + + selected.set(selected.value.copyWith(value: true)); + await tester.pump(); + + final List painted = shadows(tester); + expect(painted, hasLength(1)); + expect(painted.single.color.toARGB32(), 0xFF2B2420); + expect(painted.single.offset, const Offset(2, 2)); + expect(painted.single.blurRadius, 0); + // The node itself is unchanged. + expect((host.node as RectangleNode).effects.single.visible, isFalse); + expect( + (host.node as RectangleNode).effects.single.offset, + const Vec(0, 4), + ); + }); + + testWidgets('effect-visible- and shadow-color- win over the ' + 'node-wide bindings', (tester) async { + final host = Host( + chip({ + 'effectsVisible': r'${= false}', + 'effect-visible-shadow': r'${= true}', + 'effectsColor': r"${= '#FF0000'}", + 'shadow-color-shadow': r"${= '#00FF00'}", + }), + ); + await host.pump(tester); + final List painted = shadows(tester); + expect(painted, hasLength(1)); + expect(painted.single.color.toARGB32(), 0xFF00FF00); + // Unbound values stay. + expect(painted.single.offset, const Offset(0, 4)); + expect(painted.single.blurRadius, 6); + }); + + testWidgets('effectsVisible hides a visible shadow', (tester) async { + final RectangleNode node = chip({'effectsVisible': r'${= 1 > 2}'}); + node.effects = [node.effects.single.copyWith(visible: true)]; + final host = Host(node); + await host.pump(tester); + expect(shadows(tester), isEmpty); + }); + + testWidgets('A node without effect bindings is not restyled', ( + tester, + ) async { + final host = Host(chip({})); + await host.pump(tester); + expect(identical(host.styled, host.node), isTrue); + }); + }); + + testWidgets('strokeWeight follows its formula', (tester) async { + final selected = boolVariable('selected', false); + final host = Host( + chip({'strokeWeight': r'${= selected ? 3 : 1}'}), + variables: {'selected': selected}, + ); + await host.pump(tester); + expect(border(tester)?.top.width, 1); + + selected.set(selected.value.copyWith(value: true)); + await tester.pump(); + expect(border(tester)?.top.width, 3); + expect((host.node as RectangleNode).strokeWeight, 1); + }); + + testWidgets('cornerRadius follows its formula', (tester) async { + final host = Host(chip({'cornerRadius': r'${= 8 * 2}'})); + await host.pump(tester); + final BorderRadius? radius = tester.allWidgets + .whereType() + .map((c) => c.decoration) + .whereType() + .map((d) => d.borderRadius) + .whereType() + .firstOrNull; + expect(radius, BorderRadius.circular(16)); + expect( + (host.styled as RectangleNode).cornerRadius, + const CornerRadius.all(RadiusModel.circular(16), linked: true), + ); + }); + + group('text', () { + TextNode text(Map variables) => TextNode.fromJson({ + ...TextNode( + id: 'text', + name: 'Text', + basicBoxLocal: NodeBox(0, 0, 100, 20), + characters: 'Oat', + textMixedProps: [ + StartEndProp( + start: 0, + end: 3, + fills: [ + PaintModel.solid( + id: 'text-fill', + color: ColorRGB.fromHex('#111111'), + ), + ], + fontName: FontName.robotoRegular, + ), + ], + ).toJson(), + 'variables': variables, + })..parentID = kRootNode; + + testWidgets('fontWeight and fill both follow their formulas', ( + tester, + ) async { + final selected = boolVariable('selected', false); + final host = Host( + text({ + 'fontWeight': r'${= selected ? 700 : 400}', + 'fill': r"${= selected ? '#C8643B' : '#111111'}", + }), + variables: {'selected': selected}, + ); + await host.pump(tester); + expect( + (host.styled as TextNode).textMixedProps.single.fontName.weight, + FontWeightNumeric.w400, + ); + + selected.set(selected.value.copyWith(value: true)); + await tester.pump(); + final StartEndProp span = (host.styled as TextNode).textMixedProps.single; + expect(span.fontName.weight, FontWeightNumeric.w700); + expect(span.fontName.family, 'Roboto'); + expect(span.fills.single.color, ColorRGB.fromHex('#C8643B')); + + final List weights = []; + for (final RichText text in tester.widgetList( + find.byType(RichText), + )) { + text.text.visitChildren((span) { + weights.add(span.style?.fontWeight); + return true; + }); + } + expect(weights, contains(FontWeight.w700)); + expect(weights, isNot(contains(FontWeight.w400))); + }); + + test('parseFontWeight reads numbers, wNNN and names', () { + expect(StyleBindings.parseFontWeight(700), FontWeightNumeric.w700); + expect(StyleBindings.parseFontWeight(650.0), FontWeightNumeric.w700); + expect(StyleBindings.parseFontWeight('400'), FontWeightNumeric.w400); + expect(StyleBindings.parseFontWeight('w600'), FontWeightNumeric.w600); + expect(StyleBindings.parseFontWeight('Bold'), FontWeightNumeric.w700); + expect( + StyleBindings.parseFontWeight('semi-bold'), + FontWeightNumeric.w600, + ); + expect(StyleBindings.parseFontWeight(1200), FontWeightNumeric.w900); + expect(StyleBindings.parseFontWeight('heavyish'), isNull); + expect(StyleBindings.parseFontWeight(null), isNull); + }); + }); + + group('icon', () { + IconNode icon(IconModel model, Map variables) => + IconNode( + id: 'icon', + name: 'Icon', + basicBoxLocal: NodeBox(0, 0, 24, 24), + icon: model, + ) + ..variables.addAll(variables) + ..parentID = kRootNode; + + testWidgets('A Material icon shows the icon name its formula gives', ( + tester, + ) async { + final selected = boolVariable('selected', false); + final host = Host( + icon( + const MaterialIcon( + name: 'star_border', + codepoint: 0xe83a, + style: MaterialIconStyle.filled, + version: 19, + ), + {'icon': r"${= selected ? 'star' : 'star_border'}"}, + ), + variables: {'selected': selected}, + ); + await host.pump(tester, build: false); + expect(identical(host.styled, host.node), isTrue); + + selected.set(selected.value.copyWith(value: true)); + await tester.pump(); + final IconNode styled = host.styled as IconNode; + final MaterialIcon shown = styled.properties.icon.icon! as MaterialIcon; + expect(shown.name, 'star'); + expect(shown.style, MaterialIconStyle.filled); + expect(shown.version, 1); + expect( + shown.toSvgUrl(), + 'https://fonts.gstatic.com/s/i/materialicons/star/v1/24px.svg', + ); + expect((host.node as IconNode).properties.icon.icon!.name, 'star_border'); + }); + + testWidgets('A Material Symbol keeps its style when renamed', ( + tester, + ) async { + final host = Host( + icon( + MaterialSymbol( + name: 'favorite', + codepoint: 0xe87d, + fontFamily: 'MaterialSymbolsRounded', + style: MaterialSymbolStyle.rounded, + ), + {'icon': r"${= 'home'}"}, + ), + ); + await host.pump(tester, build: false); + final MaterialSymbol shown = + (host.styled as IconNode).properties.icon.icon! as MaterialSymbol; + expect(shown.name, 'home'); + expect(shown.style, MaterialSymbolStyle.rounded); + }); + }); +} diff --git a/test/timer_key_swipe_triggers_test.dart b/test/timer_key_swipe_triggers_test.dart new file mode 100644 index 00000000..db5fa685 --- /dev/null +++ b/test/timer_key_swipe_triggers_test.dart @@ -0,0 +1,147 @@ +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/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; + +Reaction reaction(TriggerModel trigger, String function) => Reaction( + trigger: trigger, + action: CallFunctionAction(name: function), +); + +CanvasNode canvas(List reactions) => CanvasNode( + id: 'canvas', + name: 'canvas', + basicBoxLocal: NodeBox(0, 0, 390, 844), + children: const ['body'], + properties: CanvasProperties(bodyId: 'body'), + reactions: reactions, +); + +Future> pumpCanvas( + WidgetTester tester, + List reactions, +) async { + final calls = []; + final codelesslyContext = CodelesslyContext.empty() + ..functions = { + for (final name in ['tick', 'next', 'previous']) + name: (context, reference, params) => calls.add(name), + }; + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: codelesslyContext, + child: MaterialApp( + home: ActionChainHost( + child: PassiveCanvasWidget( + node: canvas(reactions), + settings: const WidgetBuildSettings(debugLabel: 'test'), + builder: (_) => const SizedBox.expand(), + ), + ), + ), + ), + ); + await tester.pump(); + return calls; +} + +void main() { + test('Triggers keep their interval and key through JSON', () { + final timer = TriggerModel(type: TriggerType.timer, intervalMs: 250); + expect(timer.toJson(), {'type': 'timer', 'intervalMs': 250}); + expect(TriggerModel.fromJson(timer.toJson()), timer); + final key = TriggerModel(type: TriggerType.keyPress, key: 'Arrow Right'); + expect(TriggerModel.fromJson(key.toJson()).key, 'Arrow Right'); + expect(TriggerModel(type: TriggerType.timer).effectiveIntervalMs, 1000); + }); + + test('Canvases offer timer and key press, shapes offer swipes', () { + expect( + canvas([]).triggerTypes, + containsAll([TriggerType.timer, TriggerType.keyPress]), + ); + final frame = FrameNode( + id: 'f', + name: 'f', + basicBoxLocal: NodeBox(0, 0, 1, 1), + children: const [], + ); + expect(frame.triggerTypes, containsAll(TriggerType.swipes)); + }); + + test('Key labels match however they are spelled', () { + expect(normalizeKeyLabel('Arrow Right'), normalizeKeyLabel('ArrowRight')); + expect(normalizeKeyLabel('arrowright'), 'arrowright'); + }); + + testWidgets('A timer reaction runs every interval until the canvas goes', ( + tester, + ) async { + final calls = await pumpCanvas(tester, [ + reaction(TriggerModel(type: TriggerType.timer, intervalMs: 200), 'tick'), + ]); + await tester.pump(const Duration(milliseconds: 650)); + expect(calls, ['tick', 'tick', 'tick']); + + await tester.pumpWidget(const SizedBox()); + await tester.pump(const Duration(seconds: 1)); + expect(calls, hasLength(3)); + }); + + testWidgets('A key press reaction runs for its key only', (tester) async { + final calls = await pumpCanvas(tester, [ + reaction( + TriggerModel(type: TriggerType.keyPress, key: 'Arrow Right'), + 'next', + ), + reaction( + TriggerModel(type: TriggerType.keyPress, key: 'ArrowLeft'), + 'previous', + ), + ]); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.keyA); + await tester.pump(); + expect(calls, ['next', 'previous']); + }); + + testWidgets('Swipes report their direction; taps are not swipes', ( + tester, + ) async { + final swipes = []; + var taps = 0; + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: SwipeDetector( + onSwipe: swipes.add, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => taps++, + child: const SizedBox(width: 300, height: 300), + ), + ), + ), + ), + ); + final center = tester.getCenter(find.byType(SizedBox)); + await tester.dragFrom(center, const Offset(-120, 10)); + await tester.dragFrom(center, const Offset(0, 120)); + await tester.tap(find.byType(SizedBox)); + await tester.pump(); + expect(swipes, [TriggerType.swipeLeft, TriggerType.swipeDown]); + expect(taps, 1); + + final pointer = TestPointer(1, PointerDeviceKind.mouse); + await tester.sendEventToBinding(pointer.hover(center)); + await tester.sendEventToBinding(pointer.scroll(const Offset(0, 100))); + await tester.pump(); + expect(swipes.last, TriggerType.swipeUp); + }); +} diff --git a/test/variable_shadowing_test.dart b/test/variable_shadowing_test.dart new file mode 100644 index 00000000..755279e4 --- /dev/null +++ b/test/variable_shadowing_test.dart @@ -0,0 +1,73 @@ +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() { + // Shared (app) variables load before a screen's own, so a screen variable + // of the same name shadows the app one. + CodelesslyContext contextWithShadowing() => CodelesslyContext.empty() + ..variables['app'] = Observable( + VariableData( + id: 'app', + name: 'front', + type: VariableType.integer, + value: '0', + ), + ) + ..variables['screen'] = Observable( + VariableData( + id: 'screen', + name: 'front', + type: VariableType.integer, + value: '0', + ), + ); + + test('Lookups by name find the screen variable that bindings read', () { + final c = contextWithShadowing(); + expect(c.findVariableByName('front')!.value.id, 'screen'); + expect(c.variableNamesMap()['front']!.id, 'screen'); + expect(c.findVariable(id: 'app', name: 'front')!.value.id, 'app'); + }); + + testWidgets('Set variable writes the variable the screen reads', ( + tester, + ) async { + final c = contextWithShadowing(); + late BuildContext captured; + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: c, + child: Builder( + builder: (context) { + captured = context; + return const SizedBox(); + }, + ), + ), + ); + FunctionsRepository.setVariableFromAction( + captured, + SetVariableAction( + variable: VariableData( + id: 'screen', + name: 'front', + type: VariableType.integer, + ), + newValue: r'${= front + 1}', + ), + ); + expect(c.variables['screen']!.value.value, '1'); + expect(c.variables['app']!.value.value, '0'); + // Read back through a binding, as a text would. + expect( + PropertyValueDelegate.getVariableValueFromPath( + r'${front}', + scopedValues: ScopedValues.of(captured), + ), + 1, + ); + }); +} diff --git a/test/wrap_list_test.dart b/test/wrap_list_test.dart new file mode 100644 index 00000000..256aa899 --- /dev/null +++ b/test/wrap_list_test.dart @@ -0,0 +1,222 @@ +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'; + +/// A list of [itemCount] 100x40 rectangles wrapping along [scrollDirection]. +List wrapList({ + AxisC scrollDirection = AxisC.horizontal, + SizeFit horizontalFit = SizeFit.expanded, + SizeFit verticalFit = SizeFit.shrinkWrap, + double width = 300, + double height = 40, + int itemCount = 5, + bool stacked = false, + SizeFit itemHorizontalFit = SizeFit.fixed, +}) => [ + ListViewNode( + id: 'list', + name: 'list', + basicBoxLocal: NodeBox(0, 0, width, height), + horizontalFit: horizontalFit, + verticalFit: verticalFit, + scrollDirection: scrollDirection, + padding: const EdgeInsetsModel.all(10), + properties: ListViewProperties( + itemCount: itemCount, + wrap: true, + stacked: stacked, + wrapSpacing: 8, + wrapRunSpacing: 6, + // Separators are not drawn in a wrapping list. + hasSeparator: true, + separatorSpacing: 50, + ), + children: ['item'], + )..parentID = kRootNode, + RectangleNode( + id: 'item', + name: 'item', + basicBoxLocal: NodeBox(0, 0, 100, 40), + horizontalFit: itemHorizontalFit, + )..parentID = 'list', +]; + +Finder items() => + find.byKey(const ValueKey('Rectangle Transformer of item [item]')); + +Future pump( + WidgetTester tester, + List nodes, { + Size screen = const Size(390, 844), + bool inScrollView = false, + CodelesslyContext? codelesslyContext, +}) async { + tester.view.physicalSize = screen; + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final registry = NodeRegistry() + ..setNodes({for (final node in nodes) node.id: node}); + final manager = PassiveNodeTransformerManager( + registry.getNodeByID, + (context, bounds, pageID, layoutID, canvasID) => const SizedBox(), + ); + final Widget node = Builder( + builder: (context) => manager.buildWidgetFromNode( + nodes.first, + context, + settings: const WidgetBuildSettings(debugLabel: 'test'), + ), + ); + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: codelesslyContext ?? CodelesslyContext.empty(), + child: MaterialApp( + home: Scaffold( + body: Align( + alignment: Alignment.topLeft, + child: inScrollView ? SingleChildScrollView(child: node) : node, + ), + ), + ), + ), + ); + expect(tester.takeException(), isNull); +} + +void main() { + testWidgets('a horizontal wrapping list flows onto further lines', ( + tester, + ) async { + await pump(tester, wrapList()); + expect(items(), findsNWidgets(5)); + final Offset list = tester.getTopLeft(find.byType(WrapNodeBox)); + final List rects = [ + for (int i = 0; i < 5; i++) tester.getRect(items().at(i)).shift(-list), + ]; + // 390 wide minus 20 of padding fits three 100-wide items with 8 gaps. + expect(rects.map((r) => r.topLeft), [ + const Offset(10, 10), + const Offset(118, 10), + const Offset(226, 10), + const Offset(10, 56), + const Offset(118, 56), + ]); + // Items keep their own size. + for (final r in rects) { + expect(r.size, const Size(100, 40)); + } + // The list grows with its runs: two lines, a run gap and the padding. + expect( + tester.getSize(find.byType(WrapNodeBox)), + const Size(390, 10 + 40 + 6 + 40 + 10), + ); + }); + + testWidgets('an expanded item takes its design size in a run', ( + tester, + ) async { + await pump(tester, wrapList(itemHorizontalFit: SizeFit.expanded)); + expect(tester.getSize(items().first), const Size(100, 40)); + expect( + tester.getTopLeft(items().at(3)).dy, + tester.getTopLeft(items().first).dy + 46, + ); + }); + + testWidgets('a wrapping list in a scroll view has no layout errors', ( + tester, + ) async { + await pump(tester, wrapList(), inScrollView: true); + expect(items(), findsNWidgets(5)); + expect(tester.getSize(find.byType(WrapNodeBox)).height, 106); + }); + + testWidgets('a fixed-height wrapping list scrolls its runs', (tester) async { + await pump(tester, wrapList(verticalFit: SizeFit.fixed, height: 60)); + expect(tester.getSize(find.byType(WrapNodeBox)), const Size(390, 60)); + expect( + find.descendant( + of: find.byType(WrappedItems), + matching: find.byType(SingleChildScrollView), + ), + findsOneWidget, + ); + expect(items(), findsNWidgets(5)); + }); + + testWidgets('a vertical wrapping list starts new columns', (tester) async { + await pump( + tester, + wrapList( + scrollDirection: AxisC.vertical, + horizontalFit: SizeFit.shrinkWrap, + verticalFit: SizeFit.fixed, + height: 120, + ), + ); + final Offset list = tester.getTopLeft(find.byType(WrapNodeBox)); + // 100 of height minus padding fits two 40-high items and a gap. + expect(tester.getTopLeft(items().at(1)) - list, const Offset(10, 58)); + expect(tester.getTopLeft(items().at(2)) - list, const Offset(116, 10)); + }); + + testWidgets('a wrapping list shows one item per item of its data', ( + tester, + ) async { + final List nodes = wrapList(); + final ListViewNode list = nodes.first as ListViewNode; + list.properties.itemCount = null; + list.variables['data'] = r'${crates}'; + await pump( + tester, + nodes, + codelesslyContext: CodelesslyContext.empty() + ..variables['crates'] = Observable( + VariableData( + id: 'crates', + name: 'crates', + type: VariableType.list, + value: ['a', 'b', 'c', 'd'], + ), + ), + ); + expect(items(), findsNWidgets(4)); + expect( + tester.getTopLeft(items().at(3)).dy, + tester.getTopLeft(items().first).dy + 46, + ); + }); + + testWidgets('stacked wins over wrap', (tester) async { + await pump(tester, wrapList(stacked: true, height: 100)); + expect(find.byType(StackedItems), findsOneWidget); + expect(find.byType(WrappedItems), findsNothing); + }); + + testWidgets('WrappedItems keeps item order and alignment', (tester) async { + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Align( + alignment: Alignment.topLeft, + child: SizedBox( + width: 250, + child: WrappedItems( + itemCount: 3, + spacing: 10, + alignment: WrapAlignment.center, + itemBuilder: (context, index) => + SizedBox(width: 100, height: 20, child: Text('$index')), + ), + ), + ), + ), + ); + // Two items (210) fit a 250 line, centred: 20 on each side. + expect(tester.getTopLeft(find.text('0')), const Offset(20, 0)); + expect(tester.getTopLeft(find.text('1')), const Offset(130, 0)); + expect(tester.getTopLeft(find.text('2')), const Offset(75, 20)); + }); +}