diff --git a/CHANGELOG.md b/CHANGELOG.md index 555bdd98..1948a4cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,40 @@ +## 8.4.0 + +Requires codelessly_api 6.3.0. + +### Layout + +- A node's `x` and `y` in a freeform parent can be bound to data. +- Stacked lists (`ListViewProperties.layout: stack`) place each item at its + bound `x`/`y`, for diagrams drawn from data. +- `${parent.item}` and `${parent.index}` read the item of the list one + level out. +- A canvas in a dialog, sheet or popover picks its responsive layout by + the screen width. + +### Actions and triggers + +- Drag, drag end, pointer down and pointer up reactions, with + `${result.dx}`, `${result.deltaX}`, `${result.x}` and the like. +- Key Down reactions on screens, with `${result.key}` and modifiers. +- Show dialog as a popover menu next to the node that opened it, or as a + full height bottom sheet whose canvas scrolls its own content. +- Copy to Clipboard actions copy their text, variables substituted. +- A show dialog popover with an end alignment lines up with its anchor's + right edge. + +### Formulas and variables + +- Functions: `with`, `at`, `flatMap` and `feetInches`. +- `auth` reads as signed out where there is no app user (design canvases + and previews). +- A computed text that gives nothing shows nothing. +- A formula that gives empty text shows nothing on the design canvas. + +### Layout fixes + +- A freeform child is placed at its bound width and height. + ## 8.3.0 Requires codelessly_api 6.2.0. diff --git a/lib/src/functions/functions_repository.dart b/lib/src/functions/functions_repository.dart index 0c1bc3fa..8cedf337 100644 --- a/lib/src/functions/functions_repository.dart +++ b/lib/src/functions/functions_repository.dart @@ -5,6 +5,7 @@ import 'package:codelessly_api/codelessly_api.dart'; import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:http/http.dart' as http; import 'package:provider/provider.dart'; import 'package:rfc_6901/rfc_6901.dart'; @@ -152,9 +153,25 @@ class FunctionsRepository { return runCloudData(context, action as CloudDataAction); case ActionType.cloudBatch: return runCloudBatch(context, action as CloudBatchAction); + case ActionType.copyToClipboard: + return copyToClipboard(context, action as CopyToClipboardAction); } } + /// Copies the text of [action], its variables substituted. + static Future copyToClipboard( + BuildContext context, + CopyToClipboardAction action, + ) async { + final String text = PropertyValueDelegate.substituteVariables( + action.text, + nullSubstitutionMode: NullSubstitutionMode.emptyString, + scopedValues: ActionChain.scopedValuesOf(context), + ); + await Clipboard.setData(ClipboardData(text: text)); + return true; + } + /// How long [action] waits: its [DelayAction.duration] formula when that /// gives a number, else its fixed [DelayAction.milliseconds]. static int delayMilliseconds(DelayAction action, ScopedValues scopedValues) { @@ -595,9 +612,30 @@ class FunctionsRepository { showCloseButton: action.showCloseButton, presentation: action.presentation, alignment: action.alignment.flutterAlignment ?? Alignment.center, + fullHeight: action.fullHeight, ); } + /// The rect of the node that [context] belongs to (the node that ran the + /// action), which a popover opens next to. Null when it has no laid-out + /// box. + static Rect? anchorRectOf(BuildContext context) { + if (!context.mounted) return null; + final RenderObject? object = context.findRenderObject(); + if (object is! RenderBox || !object.hasSize || !object.attached) { + return null; + } + // In the coordinates of the navigator the popover opens in, which need + // not start at the screen's corner (a nested navigator under a bar). + final RenderObject? overlay = Navigator.maybeOf(context)?.overlay?.context + .findRenderObject(); + return object.localToGlobal( + Offset.zero, + ancestor: overlay is RenderBox && overlay.attached ? overlay : null, + ) & + object.size; + } + /// The id of the published layout that holds the canvas [canvasId], if any. static String? layoutIdForCanvas(Codelessly codelessly, String canvasId) => codelessly.dataManager.publishModel?.layouts.values @@ -616,6 +654,7 @@ class FunctionsRepository { bool showCloseButton = false, DialogPresentation presentation = DialogPresentation.dialog, AlignmentGeometry alignment = Alignment.center, + bool fullHeight = false, }) async { final ScopedValues scopedValues = ActionChain.scopedValuesOf(context); final parsedParams = substituteVariablesInMap(params, scopedValues); @@ -666,6 +705,12 @@ class FunctionsRepository { barrierColor: barrierColor, showCloseButton: showCloseButton, routeSettings: RouteSettings(arguments: parsedParams), + fullHeight: fullHeight, + // Where the node that opened a popover is now: the opener may rebuild + // or go away while the popover shows. + anchor: presentation == DialogPresentation.popover + ? anchorRectOf(context) + : null, builder: (context) => ActionChainOpener( openerHosts: openerHosts, child: CodelesslyWidget(codelessly: codelessly, layoutID: layoutId), @@ -681,9 +726,10 @@ class FunctionsRepository { return result; } - /// Opens what [builder] builds as a dialog at [alignment] or as a bottom - /// sheet, per [presentation] (adaptive: a sheet below 600 px wide), and - /// completes with the value it is closed with. + /// Opens what [builder] builds as a dialog at [alignment], as a bottom + /// sheet, or as a popover menu next to [anchor], per [presentation] + /// (adaptive: a sheet below 600 px wide), and completes with the value it + /// is closed with. A [fullHeight] sheet takes 90% of the screen. static Future presentCanvas( BuildContext context, { required WidgetBuilder builder, @@ -693,18 +739,44 @@ class FunctionsRepository { Color? barrierColor, bool showCloseButton = false, RouteSettings? routeSettings, + Rect? anchor, + bool fullHeight = false, }) { + if (presentation == DialogPresentation.popover) { + return showGeneralDialog( + context: context, + useRootNavigator: false, + barrierDismissible: barrierDismissible, + barrierLabel: 'Close', + barrierColor: barrierColor ?? Colors.transparent, + transitionDuration: const Duration(milliseconds: 120), + routeSettings: routeSettings, + pageBuilder: (context, _, _) => CodelesslyPopoverWidget( + anchor: anchor, + builder: builder, + // A popover aligned to the end (right) side lines up with the + // anchor's right edge. + alignEnd: (alignment.resolve(TextDirection.ltr).x) > 0, + ), + transitionBuilder: (context, animation, _, child) => + FadeTransition(opacity: animation, child: child), + ); + } final bool asSheet = switch (presentation) { - DialogPresentation.dialog => false, + DialogPresentation.dialog || DialogPresentation.popover => false, DialogPresentation.bottomSheet => true, DialogPresentation.adaptive => (MediaQuery.maybeSizeOf(context)?.width ?? 1024) < 600, }; - Widget content(BuildContext context) => CodelesslyDialogWidget( - showCloseButton: showCloseButton, - alignment: asSheet ? Alignment.bottomCenter : alignment, - builder: builder, - ); + // A full height sheet is as tall as it can be, with the canvas filling + // it, so the canvas scrolls its own content under a fixed header. + Widget content(BuildContext context) => asSheet && fullHeight + ? CodelesslyBottomSheetWidget(builder: builder) + : CodelesslyDialogWidget( + showCloseButton: showCloseButton, + alignment: asSheet ? Alignment.bottomCenter : alignment, + builder: builder, + ); if (asSheet) { return showModalBottomSheet( context: context, @@ -1375,6 +1447,7 @@ ${response.body.contains('{') ? const JsonEncoder.withIndent(' ').convert(json. ReactionMixin? node, dynamic value, List? reactions, + Object? result, }) async { final filteredReactions = (reactions ?? node?.reactions ?? []).where( (reaction) => reaction.trigger.type == type, @@ -1405,9 +1478,11 @@ ${response.body.contains('{') ? const JsonEncoder.withIndent(' ').convert(json. // The chain captures the trigger's scope and host now, so its actions // read the item that was tapped and still run if the row unmounts. + // [result] is what `${result}` starts as, e.g. a drag's deltas. return ActionChain.run( () => _runReactions(context, filteredReactions, value), context: context, + result: result, ); } diff --git a/lib/src/transformers/node_transformers/passive_canvas_transformer.dart b/lib/src/transformers/node_transformers/passive_canvas_transformer.dart index 9fe25753..08db5366 100644 --- a/lib/src/transformers/node_transformers/passive_canvas_transformer.dart +++ b/lib/src/transformers/node_transformers/passive_canvas_transformer.dart @@ -544,15 +544,17 @@ class CanvasViewport extends StatelessWidget { return LayoutBuilder( builder: (context, constraints) { final box = resolve(node, constraints, sizing: sizing); - // The width the canvas content is laid out at: the canvas box for a - // responsive canvas, the design width for an autoScale one (its - // content is laid out at design size and then scaled). - final Widget content = CanvasWidthScope( - width: node.scaleMode == ScaleMode.autoScale - ? node.basicBoxLocal.width - : box.width, - child: child, - ); + // The width that picks the canvas's responsive layouts: the canvas + // box for a responsive canvas, the design width for an autoScale one + // (its content is laid out at design size and then scaled). A canvas + // at its design size in a dialog, sheet or popover isn't the screen: + // a 520 wide dialog on a desktop shows its desktop layout. + final double width = sizing == CanvasSizing.design + ? MediaQuery.maybeSizeOf(context)?.width ?? box.width + : node.scaleMode == ScaleMode.autoScale + ? node.basicBoxLocal.width + : box.width; + final Widget content = CanvasWidthScope(width: width, child: child); if (box.height != null) { return SizedBox(width: box.width, height: box.height, child: content); } @@ -671,6 +673,7 @@ class _PassiveCanvasWidgetState extends State { codelessly?.removeNavigationListener('canvas-${widget.node.id}'); _gateUser?.removeListener(_enforceSignIn); _stopWatchingValues(); + HardwareKeyboard.instance.removeHandler(_onKey); super.dispose(); } @@ -758,6 +761,10 @@ class _PassiveCanvasWidgetState extends State { super.didUpdateWidget(oldWidget); if (widget.node != oldWidget.node) { onLoadActions = collectOnLoadActions(); + if (!widget.settings.isPreview && didPerformOnLoadActions) { + HardwareKeyboard.instance.removeHandler(_onKey); + _listenToKeys(); + } } } @@ -779,8 +786,83 @@ class _PassiveCanvasWidgetState extends State { triggerOnLoadActions(context); _watchValues(context); + _listenToKeys(); + } + + /// The screen's "Key Down" reactions, while it listens to the keyboard. + List _keyReactions = const []; + + /// Runs the screen's "Key Down" actions for keys pressed (and held) while + /// it is the screen on top and no text field has focus. + void _listenToKeys() { + _keyReactions = [ + for (final Reaction reaction in widget.node.reactions) + if (reaction.trigger.type == TriggerType.keyDown && + reaction.action.enabled) + reaction, + ]; + if (_keyReactions.isNotEmpty) { + HardwareKeyboard.instance.addHandler(_onKey); + } + } + + bool _onKey(KeyEvent event) { + if (event is KeyUpEvent || !mounted) return false; + if (!(ModalRoute.of(context)?.isCurrent ?? true)) return false; + final BuildContext? focused = FocusManager.instance.primaryFocus?.context; + if (focused != null && + (focused.widget is EditableText || + focused.findAncestorWidgetOfExactType() != null)) { + return false; + } + // Shift, Control, Alt and Meta alone are modifiers of the next key. + if (_modifierKeys.contains(event.logicalKey)) return false; + final String key = keyTriggerLabel(event.logicalKey); + final List reactions = [ + for (final Reaction reaction in _keyReactions) + if (reaction.trigger.key == null || + reaction.trigger.key!.isEmpty || + reaction.trigger.key!.toLowerCase() == key.toLowerCase()) + reaction, + ]; + if (reactions.isEmpty) return false; + final HardwareKeyboard keyboard = HardwareKeyboard.instance; + FunctionsRepository.triggerAction( + context, + TriggerType.keyDown, + reactions: reactions, + result: { + 'key': key, + 'shift': keyboard.isShiftPressed, + 'alt': keyboard.isAltPressed, + 'meta': keyboard.isMetaPressed || keyboard.isControlPressed, + }, + ); + // A reaction for a specific key takes it (an arrow doesn't also scroll + // the page); a reaction for any key lets it through, so Tab and the + // browser's shortcuts keep working. + return reactions.any( + (reaction) => reaction.trigger.key?.isNotEmpty ?? false, + ); } + static final Set _modifierKeys = { + LogicalKeyboardKey.shift, + LogicalKeyboardKey.shiftLeft, + LogicalKeyboardKey.shiftRight, + LogicalKeyboardKey.control, + LogicalKeyboardKey.controlLeft, + LogicalKeyboardKey.controlRight, + LogicalKeyboardKey.alt, + LogicalKeyboardKey.altLeft, + LogicalKeyboardKey.altRight, + LogicalKeyboardKey.meta, + LogicalKeyboardKey.metaLeft, + LogicalKeyboardKey.metaRight, + LogicalKeyboardKey.capsLock, + LogicalKeyboardKey.fn, + }; + List collectOnLoadActions() => widget.node.reactions .whereTriggerType(TriggerType.load) .map((e) => e.action) @@ -854,3 +936,13 @@ class _PassiveCanvasWidgetState extends State { ); } } + +/// The label a "Key Down" trigger's `${result.key}` reads for [key]: the +/// character of a character key (`a`, `1`, `/`), else the key's name +/// without spaces (`ArrowLeft`, `Delete`, `Escape`, `Enter`, `Space`). +String keyTriggerLabel(LogicalKeyboardKey key) { + if (key == LogicalKeyboardKey.space) return 'Space'; + final String label = key.keyLabel; + if (label.length == 1) return label.toLowerCase(); + return label.replaceAll(' ', ''); +} 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..e584a6aa 100644 --- a/lib/src/transformers/node_transformers/passive_list_view_transformer.dart +++ b/lib/src/transformers/node_transformers/passive_list_view_transformer.dart @@ -1,6 +1,7 @@ import 'dart:core'; import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:collection/collection.dart'; import 'package:codelessly_api/codelessly_api.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; @@ -122,9 +123,10 @@ class PassiveListViewWidget extends StatelessWidget { ); } - // A loaded query always has a known length, so it can shrink-wrap. + // A loaded query always has a known length, so it can shrink-wrap; + // a stacked list's Stack has only positioned children and can't. return box( - canShrinkWrap: true, + canShrinkWrap: node.properties.layout != ListLayout.stack, (context, shrinkWrap) => CloudQueryBuilder( ref: query.ref, query: query.query, @@ -196,6 +198,19 @@ class PassiveListViewWidget extends StatelessWidget { final int itemCount = items.itemCount; + if (node.properties.layout == ListLayout.stack) { + return StackedListItems( + node: node, + item: item, + values: [ + for (int index = 0; index < itemCount; index++) + if (!items.isButton(index)) items.dataAt(index), + ], + manager: manager, + settings: settings, + ); + } + // Item Count always exists. return ListViewBuilder( primary: node.primary, @@ -259,6 +274,21 @@ class PassiveListViewWidget extends StatelessWidget { itemCount = data?.length ?? node.properties.itemCount; } + if (node.properties.layout == ListLayout.stack) { + return box( + canShrinkWrap: false, + (context, _) => StackedListItems( + node: node, + item: item, + values: [ + for (int i = 0; i < (itemCount ?? 0); i++) data?.elementAtOrNull(i), + ], + manager: manager, + settings: settings, + ), + ); + } + Widget itemBuilder(BuildContext context, int index) => IndexedItemProvider( key: ValueKey(index), item: IndexedItem(index, data?.elementAtOrNull(index)), @@ -342,6 +372,146 @@ class PassiveListViewWidget extends StatelessWidget { } } +/// The items of a [ListLayout.stack] list: layered in the list's padded box, +/// each placed at its item node's bound `x`/`y`, evaluated for that item, or +/// at the item node's design offset when not bound. +class StackedListItems extends StatelessWidget { + /// The list. + final ListViewNode node; + + /// The list's item node, built once per value. + final BaseNode item; + + /// One value per item, read as `${item}` by the item's nodes. + final List values; + + final NodeTransformerManager manager; + final WidgetBuildSettings settings; + + const StackedListItems({ + super.key, + required this.node, + required this.item, + required this.values, + required this.manager, + required this.settings, + }); + + /// Where [item] starts on an axis: its bound value, else its design offset + /// from [list]'s padded box. + static double offsetOf( + BaseNode item, + BaseNode list, + String property, + ScopedValues scopedValues, + ) { + final num? bound = PropertyValueDelegate.getPropertyValue( + item, + property, + scopedValues: scopedValues, + ); + if (bound != null && bound.isFinite) return bound.toDouble(); + return property == 'x' + ? item.outerBoxLocal.x - list.innerBoxLocal.edgeLeft + : item.outerBoxLocal.y - list.innerBoxLocal.edgeTop; + } + + @override + Widget build(BuildContext context) { + final List listenables = + manager is PassiveNodeTransformerManager + ? (manager as PassiveNodeTransformerManager).listenablesFor( + context, + item, + ) + : const []; + return Padding( + padding: node.padding.flutterEdgeInsets, + child: Stack( + clipBehavior: node.clipsContent ? Clip.hardEdge : Clip.none, + children: [ + for (int index = 0; index < values.length; index++) + IndexedItemProvider( + key: ValueKey(index), + item: IndexedItem(index, values[index]), + // A component builder between the provider and the Positioned, + // so the offset reads this item and rebuilds when the + // variables it uses change. + child: _StackedItem( + listenables: listenables, + builder: (context) { + final ScopedValues scope = ScopedValues.of(context); + return Positioned( + key: ValueKey('stacked item $index'), + left: offsetOf(item, node, 'x', scope), + top: offsetOf(item, node, 'y', scope), + child: manager.buildWidgetByID( + item.id, + context, + settings: settings, + ), + ); + }, + ), + ), + ], + ), + ); + } +} + +/// Rebuilds [builder] when [listenables] change. Builds no render object, so +/// the [Positioned] it returns reaches the [Stack]. +class _StackedItem extends StatefulWidget { + final List listenables; + final WidgetBuilder builder; + + const _StackedItem({required this.listenables, required this.builder}); + + @override + State<_StackedItem> createState() => _StackedItemState(); +} + +class _StackedItemState extends State<_StackedItem> { + Listenable? _listenable; + + void _changed() => setState(() {}); + + void _listen() { + _listenable?.removeListener(_changed); + _listenable = widget.listenables.isEmpty + ? null + : Listenable.merge(widget.listenables); + _listenable?.addListener(_changed); + } + + @override + void initState() { + super.initState(); + _listen(); + } + + @override + void didUpdateWidget(covariant _StackedItem old) { + super.didUpdateWidget(old); + if (!const ListEquality().equals( + old.listenables, + widget.listenables, + )) { + _listen(); + } + } + + @override + void dispose() { + _listenable?.removeListener(_changed); + super.dispose(); + } + + @override + Widget build(BuildContext context) => widget.builder(context); +} + class ListViewBuilder extends StatelessWidget { final bool shrinkWrap; final int? itemCount; diff --git a/lib/src/transformers/node_transformers/passive_stack_transformer.dart b/lib/src/transformers/node_transformers/passive_stack_transformer.dart index 6bfee511..8fde0fdb 100644 --- a/lib/src/transformers/node_transformers/passive_stack_transformer.dart +++ b/lib/src/transformers/node_transformers/passive_stack_transformer.dart @@ -32,11 +32,47 @@ class PassiveStackTransformer extends NodeWidgetTransformer { key: ValueKey( 'Static Stack child [${node.id}](${node.name})<${node.type}> on parent [${parent.id}](${parent.name})<${parent.type}>', ), - placement: placementFor(node, parent, scrollAxis: scrollAxis), + placement: placementFor( + withBoundSize(context, node), + parent, + scrollAxis: scrollAxis, + ), child: child, ); } + /// [node] with the `width` and `height` its variables, formulas or + /// conditions give it, so the parent places it at that size (a meter's + /// fill, a to-scale dimension line). [node] itself when neither is bound. + static BaseNode withBoundSize(BuildContext context, BaseNode node) { + final ScopedValues scopedValues = ScopedValues.of(context); + final CodelesslyContext? codelesslyContext = scopedValues.codelesslyContext; + bool bound(String property) => + node.variables.containsKey(property) || + codelesslyContext?.conditions.findByNodeProperty(node.id, property) != + null; + if (!bound('width') && !bound('height')) return node; + double? value(String property) { + final num? v = PropertyValueDelegate.getPropertyValue( + node, + property, + scopedValues: scopedValues, + ); + return v != null && v.isFinite ? math.max(0, v.toDouble()) : null; + } + + final NodeBox box = node.basicBoxLocal; + final double width = value('width') ?? box.width; + final double height = value('height') ?? box.height; + if (width == box.width && height == box.height) return node; + final BaseNode? sized = NodeJsonConverter().fromJson({ + ...node.toJson(), + 'basicBoxLocal': NodeBox(box.x, box.y, width, height).toJson(), + }); + if (sized == null) return node; + return sized..parentID = node.parentID; + } + /// How [node] is placed inside the freeform container [parent], derived /// from the same data the editor's solver uses: /// diff --git a/lib/src/transformers/passive_transformer_manager.dart b/lib/src/transformers/passive_transformer_manager.dart index b977354e..02c8299a 100644 --- a/lib/src/transformers/passive_transformer_manager.dart +++ b/lib/src/transformers/passive_transformer_manager.dart @@ -129,6 +129,7 @@ class PassiveNodeTransformerManager extends WidgetNodeTransformerManager { } if (settings.withRotation) { widget = applyWidgetRotation(context, styled, widget); + widget = applyBoundOffset(context, styled, widget); } if (settings.withConstraints) { widget = applyWidgetConstraints(styled, widget); @@ -272,6 +273,58 @@ class PassiveNodeTransformerManager extends WidgetNodeTransformerManager { return styled..parentID = node.parentID; } + /// Moves [widget] to the node's bound `x`/`y` (variables, formulas or + /// conditions): the distance from the parent's padded top-left to the + /// node's outer box, as in the editor. The node is laid out at its design + /// place and painted (and hit tested) at the bound one, so bind it on + /// nodes pinned to the top-left of a freeform parent. An item of a + /// [ListLayout.stack] list is placed by the list instead. + Widget applyBoundOffset(BuildContext context, BaseNode node, Widget widget) { + if (!node.variables.containsKey('x') && + !node.variables.containsKey('y') && + !_hasPositionCondition(context, node)) { + return widget; + } + final BaseNode? parent = node.parentID.isEmpty + ? null + : tryGetNode(node.parentID); + if (parent == null) return widget; + if (parent is ListViewNode && + parent.properties.layout == ListLayout.stack) { + return widget; + } + final ScopedValues scopedValues = ScopedValues.of(context); + double delta(String property) { + final num? bound = PropertyValueDelegate.getPropertyValue( + node, + property, + scopedValues: scopedValues, + ); + if (bound == null || !bound.isFinite) return 0; + final double design = property == 'x' + ? node.outerBoxLocal.x - parent.innerBoxLocal.edgeLeft + : node.outerBoxLocal.y - parent.innerBoxLocal.edgeTop; + return bound.toDouble() - design; + } + + // Always wrapped, even unmoved: a wrapper that came and went as the + // value crossed its design position would rebuild the node, ending a + // drag that moves it. + return Transform.translate( + offset: Offset(delta('x'), delta('y')), + child: widget, + ); + } + + bool _hasPositionCondition(BuildContext context, BaseNode node) { + final CodelesslyContext? codelesslyContext = ScopedValues.of(context) + .codelesslyContext; + if (codelesslyContext == null) return false; + return codelesslyContext.conditions.findByNodeProperty(node.id, 'x') != + null || + codelesslyContext.conditions.findByNodeProperty(node.id, 'y') != null; + } + @override Widget buildWidgetByID( String id, diff --git a/lib/src/transformers/utils/condition_evaluator.dart b/lib/src/transformers/utils/condition_evaluator.dart index 8f757e97..38d73c20 100644 --- a/lib/src/transformers/utils/condition_evaluator.dart +++ b/lib/src/transformers/utils/condition_evaluator.dart @@ -320,6 +320,9 @@ class ConditionEvaluator @override R? visitDelayAction(DelayAction action) => null; + @override + R? visitCopyToClipboardAction(CopyToClipboardAction action) => null; + @override R? visitCloudDataAction(CloudDataAction action) => null; @@ -479,6 +482,9 @@ class ConditionPrinter @override void visitDelayAction(DelayAction action) {} + @override + void visitCopyToClipboardAction(CopyToClipboardAction action) {} + @override void visitCloudDataAction(CloudDataAction action) {} diff --git a/lib/src/transformers/utils/condition_visitors.dart b/lib/src/transformers/utils/condition_visitors.dart index ccd67e18..2134ed1b 100644 --- a/lib/src/transformers/utils/condition_visitors.dart +++ b/lib/src/transformers/utils/condition_visitors.dart @@ -138,6 +138,9 @@ class ConditionVariablesVisitor @override Set? visitDelayAction(DelayAction action) => {}; + @override + Set? visitCopyToClipboardAction(CopyToClipboardAction action) => {}; + @override Set? visitCloudDataAction(CloudDataAction action) => {}; @@ -231,6 +234,9 @@ class ConditionNodesVisitor @override String? visitDelayAction(DelayAction action) => null; + @override + String? visitCopyToClipboardAction(CopyToClipboardAction action) => null; + @override String? visitCloudDataAction(CloudDataAction action) => null; diff --git a/lib/src/transformers/utils/indexed_item_provider.dart b/lib/src/transformers/utils/indexed_item_provider.dart index c12ccba3..40993812 100644 --- a/lib/src/transformers/utils/indexed_item_provider.dart +++ b/lib/src/transformers/utils/indexed_item_provider.dart @@ -13,6 +13,24 @@ class IndexedItemProvider extends InheritedWidget { static IndexedItem? maybeOf(BuildContext context) => context.dependOnInheritedWidgetOfExactType()?.item; + /// The item of the next list out, for a list nested in another list's + /// item, or null. It does not make [context] depend on it: the inner list + /// is rebuilt with its outer item anyway. + static IndexedItem? parentOf(BuildContext context) { + final InheritedElement? inner = context + .getElementForInheritedWidgetOfExactType(); + if (inner == null) return null; + IndexedItem? parent; + inner.visitAncestorElements((element) { + if (element.widget case final IndexedItemProvider provider) { + parent = provider.item; + return false; + } + return true; + }); + return parent; + } + @override bool updateShouldNotify(covariant IndexedItemProvider oldWidget) => oldWidget.item != item; diff --git a/lib/src/transformers/utils/property_value_delegate.dart b/lib/src/transformers/utils/property_value_delegate.dart index d955a280..01315e08 100644 --- a/lib/src/transformers/utils/property_value_delegate.dart +++ b/lib/src/transformers/utils/property_value_delegate.dart @@ -7,6 +7,7 @@ import 'package:rfc_6901/rfc_6901.dart'; import '../../../codelessly_sdk.dart'; import '../../functions/action_chain.dart'; +import '../../functions/app_auth.dart'; /// Represents what to do when a variable path evaluates to null when /// substituting variables in a text. @@ -399,6 +400,8 @@ class PropertyValueDelegate { : variable.type; if (match.hasAccessor) { if (type.isText) { + // A computed text whose formula gives nothing has no characters. + if (variableValue == null) return null; final characters = variableValue.toString().characters.toList(); final (Object? value, _) = substituteJsonPath(match.fullPath, { match.name: characters, @@ -436,10 +439,19 @@ class PropertyValueDelegate { 'data' => scopedValues.data, 'item' => scopedValues.indexedItem?.item, 'index' => scopedValues.indexedItem?.index, + 'parent' => + scopedValues.parentIndexedItem == null + ? null + : { + 'item': scopedValues.parentIndexedItem!.item, + 'index': scopedValues.parentIndexedItem!.index, + }, 'value' => scopedValues.nodeState, 'storage' => scopedValues.localStorage?.getAll(), 'route' => scopedValues.routeParams, - 'auth' => scopedValues.appUser, + // No Codelessly instance (the editor's design canvas, previews) means + // no app user: read as signed out rather than unknown. + 'auth' => scopedValues.appUser ?? AppAuth.userToMap(null), 'result' => ActionChain.current?.result, 'screen' => scopedValues.screenSize?.let(screenVariableValue), _ => null, diff --git a/lib/src/transformers/utils/scoped_values.dart b/lib/src/transformers/utils/scoped_values.dart index 25d85b63..7f855bcb 100644 --- a/lib/src/transformers/utils/scoped_values.dart +++ b/lib/src/transformers/utils/scoped_values.dart @@ -14,6 +14,10 @@ class ScopedValues { /// The indexed item provider if it exists in this context. late final IndexedItem? indexedItem; + /// The item of the list around [indexedItem]'s list, for a list nested in + /// another list's item: what `${parent.item}` and `${parent.index}` read. + late final IndexedItem? parentIndexedItem; + /// The exposed internal value of a node in the widget tree. late final Object? nodeState; @@ -74,6 +78,7 @@ class ScopedValues { _variablesOverrides = null, _dataOverrides = null, indexedItem = null, + parentIndexedItem = null, nodeState = null, routeParams = {}, _appUser = null, @@ -86,6 +91,7 @@ class ScopedValues { required Map variables, CodelesslyContext? codelesslyContext, this.indexedItem, + this.parentIndexedItem, this._appUser, this.screenSize, Map? data, @@ -112,6 +118,7 @@ class ScopedValues { ? null : WeakReference(codelesslyContext); indexedItem = IndexedItemProvider.maybeOf(context); + parentIndexedItem = IndexedItemProvider.parentOf(context); screenSize = MediaQuery.maybeSizeOf(context); nodeState = NodeStateProvider.maybeOf(context)?.state; final instance = context.read(); diff --git a/lib/src/transformers/utils/variable_span.dart b/lib/src/transformers/utils/variable_span.dart index 868654be..21ce4747 100644 --- a/lib/src/transformers/utils/variable_span.dart +++ b/lib/src/transformers/utils/variable_span.dart @@ -65,6 +65,18 @@ abstract final class VariablePreview { if (value.isNotEmpty && !variableSyntaxIdentifierRegex.hasMatch(value)) { return value; } + // A formula that gives empty text (`${= if(x, 'a', '')}`) shows + // nothing, not a placeholder. + final String? formula = FormulaEvaluator.exactExpression(expression); + if (value.isEmpty && + formula != null && + PropertyValueDelegate.evaluateFormula( + formula, + scopedValues: scopedValues, + ) == + '') { + return ''; + } } final String placeholder = placeholderFor(expression); // A name can be wider than a fixed-width slot; show a compact marker diff --git a/lib/src/transformers/widget_node_transformer_manager.dart b/lib/src/transformers/widget_node_transformer_manager.dart index 87c40bd2..f67fa701 100644 --- a/lib/src/transformers/widget_node_transformer_manager.dart +++ b/lib/src/transformers/widget_node_transformer_manager.dart @@ -183,7 +183,7 @@ abstract class WidgetNodeTransformerManager final List descendants = []; void collect(BaseNode parent) { for (final String id in parent.childrenOrEmpty) { - final BaseNode? child = _tryGetNode(id); + final BaseNode? child = tryGetNode(id); if (child == null) continue; descendants.add(child); collect(child); @@ -217,7 +217,7 @@ abstract class WidgetNodeTransformerManager return ''; } - BaseNode? _tryGetNode(String id) { + BaseNode? tryGetNode(String id) { try { return getNode(id); } catch (_) { @@ -227,6 +227,37 @@ abstract class WidgetNodeTransformerManager /// Convenience method to handle widget reactions. Widget wrapWithReaction(BuildContext context, BaseNode node, Widget widget) { + final Widget tappable = _wrapWithTapReactions(context, node, widget); + if (node is! ReactionMixin || node is CanvasNode) return tappable; + final List reactions = (node as ReactionMixin).reactions; + List of(TriggerType type) => [ + for (final reaction in reactions) + if (reaction.trigger.type == type && reaction.action.enabled) reaction, + ]; + final List onDrag = of(TriggerType.drag); + final List onDragEnd = of(TriggerType.dragEnd); + final List onPointerDown = of(TriggerType.pointerDown); + final List onPointerUp = of(TriggerType.pointerUp); + if (onDrag.isEmpty && + onDragEnd.isEmpty && + onPointerDown.isEmpty && + onPointerUp.isEmpty) { + return tappable; + } + return ReactionDragDetector( + onDrag: onDrag, + onDragEnd: onDragEnd, + onPointerDown: onPointerDown, + onPointerUp: onPointerUp, + child: tappable, + ); + } + + Widget _wrapWithTapReactions( + BuildContext context, + BaseNode node, + Widget widget, + ) { if (node is! ReactionMixin) return widget; if (node case CanvasNode() || SpacerNode()) return widget; if (node is CustomPropertiesMixin && @@ -591,3 +622,97 @@ class SvgIconImage extends StatelessWidget { ); } } + +/// Runs a node's drag and pointer reactions. Each action of a [TriggerType.drag] +/// reaction runs on every move with `${result}` set to: +/// +/// - `deltaX`, `deltaY`: how far the pointer moved since the last event, +/// - `dx`, `dy`: how far it moved since the drag started, +/// - `x`, `y`: where the pointer is, relative to the node's top-left. +/// +/// [TriggerType.dragEnd] reactions run once when the pointer lifts after a +/// drag, with the drag's total `dx`, `dy` and its `velocityX`, `velocityY` +/// (px/s). [TriggerType.pointerDown] and [TriggerType.pointerUp] run when a +/// pointer touches or leaves the node, with its local `x`, `y`. +class ReactionDragDetector extends StatefulWidget { + final List onDrag; + final List onDragEnd; + final List onPointerDown; + final List onPointerUp; + final Widget child; + + const ReactionDragDetector({ + super.key, + required this.onDrag, + required this.onDragEnd, + required this.onPointerDown, + required this.onPointerUp, + required this.child, + }); + + @override + State createState() => _ReactionDragDetectorState(); +} + +class _ReactionDragDetectorState extends State { + Offset _total = Offset.zero; + + void _run(List reactions, TriggerType type, Map result) { + if (reactions.isEmpty) return; + FunctionsRepository.triggerAction( + context, + type, + reactions: reactions, + result: result, + ); + } + + @override + Widget build(BuildContext context) { + final bool drags = widget.onDrag.isNotEmpty || widget.onDragEnd.isNotEmpty; + Widget child = widget.child; + if (drags) { + child = MouseRegion( + cursor: SystemMouseCursors.grab, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onPanStart: (_) => _total = Offset.zero, + onPanUpdate: (details) { + _total += details.delta; + _run(widget.onDrag, TriggerType.drag, { + 'deltaX': details.delta.dx, + 'deltaY': details.delta.dy, + 'dx': _total.dx, + 'dy': _total.dy, + 'x': details.localPosition.dx, + 'y': details.localPosition.dy, + }); + }, + onPanEnd: (details) => _run(widget.onDragEnd, TriggerType.dragEnd, { + 'dx': _total.dx, + 'dy': _total.dy, + 'velocityX': details.velocity.pixelsPerSecond.dx, + 'velocityY': details.velocity.pixelsPerSecond.dy, + }), + child: child, + ), + ); + } + if (widget.onPointerDown.isNotEmpty || widget.onPointerUp.isNotEmpty) { + child = Listener( + onPointerDown: (event) => _run( + widget.onPointerDown, + TriggerType.pointerDown, + {'x': event.localPosition.dx, 'y': event.localPosition.dy}, + ), + onPointerUp: (event) => _run( + widget.onPointerUp, + TriggerType.pointerUp, + {'x': event.localPosition.dx, 'y': event.localPosition.dy}, + ), + child: child, + ); + } + return child; + } +} diff --git a/lib/src/ui/codelessly_dialog_widget.dart b/lib/src/ui/codelessly_dialog_widget.dart index e9af3071..ffc0bf1b 100644 --- a/lib/src/ui/codelessly_dialog_widget.dart +++ b/lib/src/ui/codelessly_dialog_widget.dart @@ -58,3 +58,118 @@ class CodelesslyDialogWidget extends StatelessWidget { return Align(alignment: alignment, child: builder(context)); } } + +/// A layout shown as a full height bottom sheet: the screen's full width +/// and 90% of its height, with the canvas filling it, so the canvas scrolls +/// its own content (a fixed header over a scrolling list). The layout draws +/// its own background, corners and grab handle; the sheet is transparent. +class CodelesslyBottomSheetWidget extends StatelessWidget { + final WidgetBuilder builder; + + /// The share of the screen height the sheet takes. + static const double heightFactor = 0.9; + + const CodelesslyBottomSheetWidget({super.key, required this.builder}); + + @override + Widget build(BuildContext context) { + final MediaQueryData media = MediaQuery.of(context); + return Padding( + // Keep inputs above the keyboard. + padding: EdgeInsets.only(bottom: media.viewInsets.bottom), + child: SizedBox( + height: media.size.height * heightFactor, + child: Builder(builder: builder), + ), + ); + } +} + +/// A layout shown as a popover menu next to [anchor] (the rect, in global +/// coordinates, of the node that opened it): below it and left-aligned with +/// it (right-aligned with [alignEnd]), the other way when that would run off +/// the screen, and above it when there is no room below. The canvas keeps +/// its design size. +class CodelesslyPopoverWidget extends StatelessWidget { + final Rect? anchor; + final WidgetBuilder builder; + + /// Whether the popover's right edge lines up with the anchor's, as for a + /// menu opened from a button at the right of a card. + final bool alignEnd; + + /// Gap between the anchor and the popover, and the screen margin. + static const double gap = 6; + + const CodelesslyPopoverWidget({ + super.key, + required this.anchor, + required this.builder, + this.alignEnd = false, + }); + + @override + Widget build(BuildContext context) { + return CustomSingleChildLayout( + delegate: _PopoverLayout(anchor, alignEnd: alignEnd), + child: CanvasSizingScope( + sizing: CanvasSizing.design, + child: Builder(builder: builder), + ), + ); + } +} + +class _PopoverLayout extends SingleChildLayoutDelegate { + final Rect? anchor; + final bool alignEnd; + + const _PopoverLayout(this.anchor, {this.alignEnd = false}); + + @override + BoxConstraints getConstraintsForChild(BoxConstraints constraints) => + constraints.loosen().deflate( + const EdgeInsets.all(CodelesslyPopoverWidget.gap), + ); + + @override + Offset getPositionForChild(Size size, Size childSize) => + popoverPosition(anchor, size, childSize, alignEnd: alignEnd); + + @override + bool shouldRelayout(covariant _PopoverLayout old) => + old.anchor != anchor || old.alignEnd != alignEnd; +} + +/// Where a popover of [child] size goes in a [screen] for [anchor]; centered +/// when there is no anchor. See [CodelesslyPopoverWidget.alignEnd]. +Offset popoverPosition( + Rect? anchor, + Size screen, + Size child, { + bool alignEnd = false, +}) { + const double m = CodelesslyPopoverWidget.gap; + if (anchor == null) { + return Offset( + (screen.width - child.width) / 2, + (screen.height - child.height) / 2, + ); + } + final double start = anchor.left, end = anchor.right - child.width; + double left = alignEnd ? end : start; + if (!alignEnd && left + child.width > screen.width - m) left = end; + if (alignEnd && left < m) left = start; + left = left.clamp( + m, + (screen.width - child.width - m).clamp(m, double.infinity), + ); + final bool below = + anchor.bottom + m + child.height <= screen.height - m || + anchor.top - m - child.height < m; + final double top = below ? anchor.bottom + m : anchor.top - m - child.height; + return Offset( + left, + top.clamp(m, (screen.height - child.height - m).clamp(m, double.infinity)), + ); +} diff --git a/lib/src/utils/extensions.dart b/lib/src/utils/extensions.dart index 8cc1c817..dbed2d21 100644 --- a/lib/src/utils/extensions.dart +++ b/lib/src/utils/extensions.dart @@ -1836,6 +1836,9 @@ extension ActionModelExt on ActionModel { }; case ActionType.delay: return {}; + case ActionType.copyToClipboard: + strings.add((this as CopyToClipboardAction).text); + return _namesIn(strings, includePredefined); case ActionType.cloudData: final CloudDataAction action = this as CloudDataAction; strings.addAll([ diff --git a/lib/src/utils/formula.dart b/lib/src/utils/formula.dart index bde71f5c..99f36d19 100644 --- a/lib/src/utils/formula.dart +++ b/lib/src/utils/formula.dart @@ -34,7 +34,9 @@ typedef FormulaPathResolver = Object? Function(String path); /// literals), `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. +/// - more list functions: range, slice, first, last, concat, indexOf, `at(l, +/// i)`, `flatMap(l, lambda)`, and `with(m, key, value)` to copy a map with +/// keys set. /// - 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 @@ -185,6 +187,11 @@ class FormulaEvaluator { 'formatNumber(value, digits?)', 'formatNumber(2481.64, 1) → 2,481.6', ), + FormulaFunctionDoc( + 'feetInches', + 'feetInches(feet)', + 'feetInches(6.67) → 6′ 8″', + ), FormulaFunctionDoc( 'fixed', 'fixed(value, decimals)', @@ -523,6 +530,17 @@ class FormulaEvaluator { 'coalesce(value, fallback, ...)', 'coalesce(route.date, today())', ), + FormulaFunctionDoc( + 'with', + 'with(map, key, value, …)', + "with(item, 'x', item.x + 1) → item with x changed", + ), + FormulaFunctionDoc('at', 'at(list, index)', 'at(tasks, 2)'), + FormulaFunctionDoc( + 'flatMap', + 'flatMap(list, item => list)', + 'flatMap(orders, o => o.items) → every item', + ), ]; } @@ -651,6 +669,16 @@ String _grouped(Object? value, Object? digits) { return '$whole${text.substring(dot)}'; } +/// Feet as feet and inches rounded to the inch: 6.67 -> 6′ 8″, 7 -> 7′. +String _feetInches(Object? value) { + final num feet = _num(value); + if (feet.isNaN || feet.isInfinite) return ''; + final int inches = (feet * 12).round(); + final String sign = inches < 0 ? '-' : ''; + final int f = inches.abs() ~/ 12, i = inches.abs() % 12; + return i == 0 ? '$sign$f′' : '$sign$f′ $i″'; +} + num _round(Object? value, Object? digits) { final num v = _num(value); final int d = _num(digits ?? 0).toInt(); @@ -1032,6 +1060,7 @@ Object? _arg(List args, int index) => final Map _functions = { 'money': (a) => _money(_arg(a, 0), _arg(a, 1), _arg(a, 2)), 'formatNumber': (a) => _grouped(_arg(a, 0), _arg(a, 1)), + 'feetInches': (a) => _feetInches(_arg(a, 0)), 'fixed': (a) => _fixed(_arg(a, 0), _arg(a, 1)), 'round': (a) => _round(_arg(a, 0), _arg(a, 1)), 'floor': (a) => _num(_arg(a, 0)).floor(), @@ -1216,6 +1245,22 @@ final Map _functions = { 'text': (a) => FormulaEvaluator.stringify(_arg(a, 0)), 'isEmpty': (a) => !_truthy(_functions['length']!(a)), 'isNotEmpty': (a) => _truthy(_functions['length']!(a)), + // Map and list editing beyond obj/set/concat below, for formulas that build + // the value a set variable action stores. + 'with': (a) => { + if (_arg(a, 0) case final Map m) ...m, + for (int i = 1; i + 1 < a.length; i += 2) + FormulaEvaluator.stringify(a[i]): a[i + 1], + }, + 'at': (a) { + final int index = _intArg(_arg(a, 1)); + // indexOf's -1 (not found) reads nothing rather than failing. + return index < 0 ? null : _values(_arg(a, 0), null).elementAtOrNull(index); + }, + 'flatMap': (a) => [ + for (final v in _values(_arg(a, 0), _arg(a, 1))) + if (v is Iterable) ...v else ?v, + ], 'now': (a) => _dateText(DateTime.now()), 'today': (a) { final DateTime now = DateTime.now(); diff --git a/lib/src/utils/utils.dart b/lib/src/utils/utils.dart index 6a8d9b0f..eee706e2 100644 --- a/lib/src/utils/utils.dart +++ b/lib/src/utils/utils.dart @@ -12,6 +12,7 @@ const Set predefinedVariableNames = { 'index', 'value', 'item', + 'parent', 'storage', 'route', 'auth', @@ -46,6 +47,7 @@ final Set predefinedVariables = { PredefinedVariableData(name: 'data', type: VariableType.map), PredefinedVariableData(name: 'index', type: VariableType.integer), PredefinedVariableData(name: 'item'), + PredefinedVariableData(name: 'parent', type: VariableType.map), PredefinedVariableData(name: 'value'), PredefinedVariableData(name: 'storage', type: VariableType.map), PredefinedVariableData(name: 'route', type: VariableType.map), diff --git a/pubspec.yaml b/pubspec.yaml index fb46ccb2..52c576e7 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: codelessly_sdk description: Codelessly CloudUI™ - Server-driven UI for Flutter. Supercharge your Flutter apps with dynamic UI and real-time updates. Build and publish UI without code! -version: 8.3.0 +version: 8.4.0 homepage: https://codelessly.com/ repository: https://github.com/Codelessly/CodelesslySDK issue_tracker: https://github.com/Codelessly/CodelesslySDK/issues @@ -28,7 +28,7 @@ dependencies: flutter: sdk: flutter # Codelessly - codelessly_api: ^6.2.0 + codelessly_api: ^6.3.0 json_annotation: ^4.12.0 # Firebase diff --git a/test/auth_default_test.dart b/test/auth_default_test.dart new file mode 100644 index 00000000..6d8c3fba --- /dev/null +++ b/test/auth_default_test.dart @@ -0,0 +1,18 @@ +import 'package:codelessly_sdk/codelessly_sdk.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Without a Codelessly instance (the editor's design canvas, previews) the +/// `auth` variable reads as signed out, so signed-in-only nodes hide. +void main() { + test('auth reads as signed out with no app user', () { + final ScopedValues scope = ScopedValues.preview(variables: {}); + String eval(String text) => PropertyValueDelegate.substituteVariables( + text, + scopedValues: scope, + nullSubstitutionMode: NullSubstitutionMode.emptyString, + ); + expect(eval(r'${auth.isSignedIn}'), 'false'); + expect(eval(r'${= !auth.isSignedIn}'), 'true'); + expect(eval(r'${auth.uid}'), ''); + }); +} diff --git a/test/computed_text_test.dart b/test/computed_text_test.dart new file mode 100644 index 00000000..783da369 --- /dev/null +++ b/test/computed_text_test.dart @@ -0,0 +1,20 @@ +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:codelessly_sdk/codelessly_sdk.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('a computed text that gives nothing has no characters', () { + final ScopedValues scope = ScopedValues.preview( + variables: { + 'title': VariableData(id: 'title', name: 'title', value: r'${= null}'), + }, + ); + expect( + PropertyValueDelegate.getVariableValueFromPath( + 'title[0]', + scopedValues: scope, + ), + isNull, + ); + }); +} diff --git a/test/copy_to_clipboard_test.dart b/test/copy_to_clipboard_test.dart new file mode 100644 index 00000000..aebcf570 --- /dev/null +++ b/test/copy_to_clipboard_test.dart @@ -0,0 +1,50 @@ +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:codelessly_sdk/codelessly_sdk.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; + +void main() { + testWidgets('copy to clipboard copies the text with its variables', ( + tester, + ) async { + final List copied = []; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + (MethodCall call) async { + if (call.method == 'Clipboard.setData') { + copied.add((call.arguments as Map)['text']); + } + return null; + }, + ); + final CodelesslyContext codelesslyContext = CodelesslyContext.empty() + ..variables['homeId'] = Observable( + VariableData( + id: 'homeId', + name: 'homeId', + type: VariableType.text, + value: 'h_meadow', + ), + ); + late BuildContext context; + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: codelesslyContext, + child: Builder( + builder: (c) { + context = c; + return const SizedBox(); + }, + ), + ), + ); + final bool done = await FunctionsRepository.copyToClipboard( + context, + CopyToClipboardAction(text: r'https://nook.homes/homes/${homeId}'), + ); + expect(done, isTrue); + expect(copied, ['https://nook.homes/homes/h_meadow']); + }); +} diff --git a/test/formula_editing_test.dart b/test/formula_editing_test.dart new file mode 100644 index 00000000..f74e78c5 --- /dev/null +++ b/test/formula_editing_test.dart @@ -0,0 +1,103 @@ +import 'package:codelessly_sdk/codelessly_sdk.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Map building and list editing functions, which let a set variable action +/// store a changed list: e.g. moving one placement of a floor plan. +void main() { + final Map vars = { + 'plan': [ + {'uid': 'a', 'x': 1, 'w': 2}, + {'uid': 'b', 'x': 4, 'w': 2}, + {'uid': 'c', 'x': 5, 'w': 1}, + ], + 'sel': 'b', + 'drag': {'dx': 30, 'dy': -12}, + }; + Object? eval(String e) => FormulaEvaluator.evaluate(e, (p) { + Object? v = vars; + for (final part in p.split('.')) { + v = v is Map ? v[part] : null; + } + return v; + }); + + test('obj and with build maps', () { + expect(eval("obj('id', 'x1', 'x', 2 + 1)"), {'id': 'x1', 'x': 3}); + expect(eval("with(first(plan), 'x', 9, 'rot', 90)"), { + 'uid': 'a', + 'x': 9, + 'w': 2, + 'rot': 90, + }); + expect(eval("merge(obj('a', 1), obj('b', 2), obj('a', 3))"), { + 'a': 3, + 'b': 2, + }); + expect(eval("keys(obj('a', 1, 'b', 2))"), ['a', 'b']); + }); + + test('list editing', () { + expect(eval("length(append(plan, obj('uid', 'd')))"), 4); + expect(eval("map(removeAt(plan, 0), 'uid')"), ['b', 'c']); + expect(eval("map(setAt(plan, 1, obj('uid', 'z')), 'uid')"), [ + 'a', + 'z', + 'c', + ]); + expect(eval("map(insertAt(plan, 0, obj('uid', 'z')), 'uid')"), [ + 'z', + 'a', + 'b', + 'c', + ]); + expect(eval('slice(range(10), 2, 5)'), [2, 3, 4]); + expect(eval('range(2, 5)'), [2, 3, 4]); + expect(eval('concat([1, 2], [3], 4)'), [1, 2, 3, 4]); + expect(eval('reverse([1, 2, 3])'), [3, 2, 1]); + expect(eval('first(plan).uid + last(plan).uid + at(plan, 1).uid'), 'acb'); + // Out of range edits leave the list as it was. + expect(eval('length(removeAt(plan, 7))'), 3); + }); + + test('indexOf by value, field and lambda', () { + expect(eval('indexOf(plan, p => p.uid == sel)'), 1); + expect(eval("indexOf(plan, 'uid', 'c')"), 2); + expect(eval('indexOf([3, 4], 4)'), 1); + expect(eval("indexOf(['a', 'b', 'c'], 'b')"), 1); + expect(eval("at(plan, indexOf(plan, p => p.uid == 'nope'))"), isNull); + expect(eval('length(range(1000000))'), 10000); + expect(eval("indexOf(plan, p => p.uid == 'nope')"), -1); + }); + + test('moving the selected item by a drag delta', () { + final Object? moved = eval( + "map(plan, p => if(p.uid == sel, with(p, 'x', clamp(round((p.x + drag.dx / 10) / 0.25) * 0.25, 0, 8)), p))", + ); + expect((moved as List).map((p) => (p as Map)['x']), [1, 7, 5]); + }); + + test('flatMap over index pairs finds overlaps', () { + // Items overlap when their [x, x + w) ranges intersect. + final Object? pairs = eval( + "flatMap(range(length(plan)), i => map(filter(range(i + 1, length(plan)), j => plan[i].x < plan[j].x + plan[j].w && plan[j].x < plan[i].x + plan[i].w), j => plan[i].uid + '-' + plan[j].uid))", + ); + expect(pairs, ['b-c']); + }); + + test('clamp, pow, sqrt', () { + expect(eval('clamp(12, 0, 8)'), 8); + expect(eval('clamp(-1, 0, 8)'), 0); + expect(eval('pow(2, 10)'), 1024); + expect(eval('sqrt(16)'), 4); + }); + + test('feet-inches, and money without cents', () { + expect(eval(r"money(57030, '$', 0)"), r'$57,030'); + expect(eval('money(1234.5)'), r'$1,234.50'); + expect(eval('thousands(6016)'), '6,016'); + expect(eval('thousands(1234.567, 1)'), '1,234.6'); + expect(eval('feetInches(6.6667)'), '6′ 8″'); + expect(eval('feetInches(7)'), '7′'); + expect(eval('feetInches(0.25)'), '0′ 3″'); + }); +} diff --git a/test/key_trigger_test.dart b/test/key_trigger_test.dart new file mode 100644 index 00000000..5d90d542 --- /dev/null +++ b/test/key_trigger_test.dart @@ -0,0 +1,85 @@ +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:codelessly_sdk/codelessly_sdk.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; + +/// A screen's "Key Down" actions run for keys pressed while it shows, with +/// the key and modifiers in `${result}`. +void main() { + test('key labels', () { + expect(keyTriggerLabel(LogicalKeyboardKey.keyR), 'r'); + expect(keyTriggerLabel(LogicalKeyboardKey.arrowLeft), 'ArrowLeft'); + expect(keyTriggerLabel(LogicalKeyboardKey.delete), 'Delete'); + expect(keyTriggerLabel(LogicalKeyboardKey.escape), 'Escape'); + expect(keyTriggerLabel(LogicalKeyboardKey.space), 'Space'); + }); + + testWidgets('a key down reaction reads the key and its modifiers', ( + tester, + ) async { + Reaction setLast(String value, {String? key}) => Reaction( + action: SetVariableAction( + variable: VariableData(id: 'last', name: 'last'), + newValue: value, + ), + trigger: TriggerModel(type: TriggerType.keyDown, key: key), + ); + final CanvasNode canvas = CanvasNode( + id: 'canvas', + name: 'canvas', + basicBoxLocal: NodeBox(0, 0, 390, 844), + children: ['body'], + properties: CanvasProperties( + bodyId: 'body', + safeArea: const SafeAreaModel.all(false), + ), + reactions: [setLast(r'${result.key} ${result.shift}')], + )..parentID = kRootNode; + final AutoPlaceholderNode body = AutoPlaceholderNode( + id: 'body', + name: 'body', + basicBoxLocal: NodeBox(0, 0, 390, 844), + children: const [], + horizontalFit: SizeFit.expanded, + verticalFit: SizeFit.expanded, + )..parentID = 'canvas'; + final registry = NodeRegistry() + ..setNodes({canvas.id: canvas, body.id: body}); + final manager = PassiveNodeTransformerManager( + registry.getNodeByID, + (context, bounds, pageID, layoutID, canvasID) => const SizedBox(), + ); + final Observable last = Observable( + VariableData(id: 'last', name: 'last'), + ); + final CodelesslyContext codelesslyContext = CodelesslyContext.empty() + ..variables['last'] = last; + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: codelesslyContext, + child: MaterialApp( + home: Builder( + builder: (context) => manager.buildWidgetFromNode( + canvas, + context, + settings: const WidgetBuildSettings(debugLabel: 'test'), + ), + ), + ), + ), + ); + await tester.pump(); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); + await tester.pumpAndSettle(); + expect(last.value.value, 'ArrowLeft false'); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.keyR); + await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft); + await tester.pumpAndSettle(); + expect(last.value.value, 'r true'); + }); +} diff --git a/test/popover_position_test.dart b/test/popover_position_test.dart new file mode 100644 index 00000000..4b4bf302 --- /dev/null +++ b/test/popover_position_test.dart @@ -0,0 +1,60 @@ +import 'dart:ui'; + +import 'package:codelessly_sdk/src/ui/codelessly_dialog_widget.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// A popover opens below its anchor, left-aligned, and flips to stay on the +/// screen. +void main() { + const Size screen = Size(390, 844); + const Size menu = Size(240, 300); + + test('below and left-aligned', () { + expect( + popoverPosition(const Rect.fromLTWH(20, 100, 120, 40), screen, menu), + const Offset(20, 146), + ); + }); + + test('right-aligned near the right edge', () { + expect( + popoverPosition(const Rect.fromLTWH(330, 10, 44, 44), screen, menu), + const Offset(134, 60), + ); + }); + + test('above when there is no room below', () { + expect( + popoverPosition(const Rect.fromLTWH(20, 700, 120, 40), screen, menu), + const Offset(20, 394), + ); + }); + + test('aligned to the end, right edges line up', () { + expect( + popoverPosition( + const Rect.fromLTWH(300, 100, 30, 30), + screen, + menu, + alignEnd: true, + ), + const Offset(90, 136), + ); + }); + + test('aligned to the end, left-aligned near the left edge', () { + expect( + popoverPosition( + const Rect.fromLTWH(10, 100, 30, 30), + screen, + menu, + alignEnd: true, + ), + const Offset(10, 136), + ); + }); + + test('centered without an anchor', () { + expect(popoverPosition(null, screen, menu), const Offset(75, 272)); + }); +} diff --git a/test/responsive_variance_test.dart b/test/responsive_variance_test.dart index a002803b..425bfbb4 100644 --- a/test/responsive_variance_test.dart +++ b/test/responsive_variance_test.dart @@ -227,6 +227,21 @@ void main() { expect(builtRectangles(nodes), idsOf(ScreenBreakpoint.mobile, 2)); }); + testWidgets('a canvas in a dialog picks by the screen width', (tester) async { + // A 520 wide dialog canvas on a desktop screen shows its desktop layout. + final nodes = responsivePage(canvasWidth: 520); + await pumpPage( + tester, + nodes, + screen: const Size(1440, 900), + wrap: (child) => Center( + child: CanvasSizingScope(sizing: CanvasSizing.design, child: child), + ), + ); + expect(tester.takeException(), isNull); + expect(builtRectangles(nodes), idsOf(ScreenBreakpoint.desktop, 4)); + }); + testWidgets('an autoScale canvas picks by its design width', (tester) async { final nodes = responsivePage( canvasWidth: 1440, diff --git a/test/sheet_height_test.dart b/test/sheet_height_test.dart new file mode 100644 index 00000000..95f25e04 --- /dev/null +++ b/test/sheet_height_test.dart @@ -0,0 +1,42 @@ +import 'package:codelessly_sdk/src/ui/codelessly_dialog_widget.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// A full height sheet gives the canvas 90% of the screen so it scrolls its +/// own content. +void main() { + Future constraintsIn(WidgetTester tester) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(400, 1000); + addTearDown(tester.view.reset); + late BoxConstraints given; + await tester.pumpWidget( + MediaQuery( + data: const MediaQueryData(size: Size(400, 1000)), + child: Directionality( + textDirection: TextDirection.ltr, + child: Align( + alignment: Alignment.bottomCenter, + child: CodelesslyBottomSheetWidget( + builder: (context) => LayoutBuilder( + builder: (context, constraints) { + given = constraints; + return const SizedBox(height: 100); + }, + ), + ), + ), + ), + ), + ); + return given; + } + + testWidgets('a full height sheet gives its canvas 90% of the screen', ( + tester, + ) async { + final constraints = await constraintsIn(tester); + expect(constraints.maxHeight, 900); + expect(constraints.minHeight, 900); + }); +} diff --git a/test/stacked_list_test.dart b/test/stacked_list_test.dart new file mode 100644 index 00000000..532ccf4f --- /dev/null +++ b/test/stacked_list_test.dart @@ -0,0 +1,360 @@ +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:codelessly_sdk/src/functions/action_chain.dart'; +import 'package:provider/provider.dart'; + +/// A floor plan drawn from data: a stacked list whose items are placed, +/// sized and rotated by their item's fields, and dragged by drag reactions. +void main() { + final List> plan = [ + {'id': 'bed', 'x': 0, 'y': 0, 'w': 6, 'd': 5, 'rot': 0}, + {'id': 'sofa', 'x': 10, 'y': 4.5, 'w': 6, 'd': 3, 'rot': 90}, + ]; + + ({NodeRegistry registry, ListViewNode list, RectangleNode item}) build({ + List reactions = const [], + }) { + final RectangleNode item = RectangleNode.fromJson({ + ...RectangleNode( + id: 'item', + name: 'Plan item', + basicBoxLocal: NodeBox(0, 0, 20, 20), + fills: [PaintModel.solid(id: 'f', color: ColorRGB.fromHex('#EBDCC3'))], + reactions: reactions, + ).toJson(), + 'variables': { + 'x': r'${= item.x * scale}', + 'y': r'${= item.y * scale}', + 'width': r'${= item.w * scale}', + 'height': r'${= item.d * scale}', + 'rotation': r'${item.rot}', + }, + }); + final ListViewNode list = ListViewNode( + id: 'plan', + name: 'Plan', + basicBoxLocal: NodeBox(0, 0, 400, 200), + padding: const EdgeInsetsModel.all(10), + properties: ListViewProperties(layout: ListLayout.stack), + children: ['item'], + variables: {'data': r'${plan}'}, + ); + item.parentID = list.id; + list.parentID = kRootNode; + final registry = NodeRegistry()..setNodes({list.id: list, item.id: item}); + return (registry: registry, list: list, item: item); + } + + Future pump( + WidgetTester tester, + NodeRegistry registry, + BaseNode root, { + Map functions = const {}, + }) async { + final manager = PassiveNodeTransformerManager( + registry.getNodeByID, + (context, bounds, pageID, layoutID, canvasID) => const SizedBox(), + ); + final CodelesslyContext codelesslyContext = CodelesslyContext.empty() + ..functions = functions + ..variables['plan'] = Observable( + VariableData( + id: 'plan', + name: 'plan', + type: VariableType.list, + value: plan, + ), + ) + ..variables['scale'] = Observable( + VariableData( + id: 'scale', + name: 'scale', + type: VariableType.decimal, + value: 10.0, + ), + ); + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: codelesslyContext, + child: MaterialApp( + home: Align( + alignment: Alignment.topLeft, + child: SizedBox( + width: 400, + height: 200, + child: Builder( + builder: (context) => manager.buildWidgetFromNode( + root, + context, + settings: const WidgetBuildSettings(debugLabel: 'test'), + ), + ), + ), + ), + ), + ), + ); + return codelesslyContext; + } + + Finder items() => find.byWidgetPredicate( + (w) => w is Positioned && '${w.key}'.contains('stacked item'), + ); + + testWidgets('items are placed and sized by their item fields', ( + tester, + ) async { + final t = build(); + await pump(tester, t.registry, t.list); + + expect(items(), findsNWidgets(2)); + final List placed = tester + .widgetList(items()) + .toList(); + expect([placed[0].left, placed[0].top], [0, 0]); + expect([placed[1].left, placed[1].top], [100, 45]); + // Inside the list's 10 px padding. + expect(tester.getTopLeft(items().at(1)), const Offset(110, 55)); + expect(tester.getSize(items().at(0)), const Size(60, 50)); + }); + + testWidgets('a bound rotation turns the item', (tester) async { + final t = build(); + await pump(tester, t.registry, t.list); + + final Iterable turned = tester + .widgetList(find.byType(Transform)) + .where((tr) => tr.transform.getRotation().entry(0, 1) != 0); + // Only the sofa (rot 90) is rotated. + expect(turned, hasLength(1)); + expect( + turned.single.transform.getRotation().entry(0, 1), + closeTo(-1, 1e-9), + ); + }); + + testWidgets('moving an item when its data changes', (tester) async { + final t = build(); + final codelesslyContext = await pump(tester, t.registry, t.list); + + final Observable v = codelesslyContext.variables['plan']!; + v.set( + v.value.copyWith( + value: [ + {...plan[0], 'x': 4}, + plan[1], + ], + ), + ); + await tester.pump(); + expect(tester.widgetList(items()).first.left, 40); + }); + + testWidgets('drag reactions get deltas in result', (tester) async { + final List drags = []; + final List ends = []; + final t = build( + reactions: [ + Reaction( + action: CallFunctionAction(name: 'drag'), + trigger: TriggerModel(type: TriggerType.drag), + ), + Reaction( + action: CallFunctionAction(name: 'end'), + trigger: TriggerModel(type: TriggerType.dragEnd), + ), + ], + ); + await pump( + tester, + t.registry, + t.list, + functions: { + 'drag': (context, reference, params) => + drags.add(ActionChain.current!.result! as Map), + 'end': (context, reference, params) => + ends.add(ActionChain.current!.result! as Map), + }, + ); + + final TestGesture gesture = await tester.startGesture( + tester.getCenter(items().first), + ); + await gesture.moveBy(const Offset(30, 0)); + await gesture.moveBy(const Offset(10, 5)); + await gesture.up(); + await tester.pump(); + + expect(drags, isNotEmpty); + expect(drags.last['dx'], 40 - drags.first['dx'] + drags.first['deltaX']); + expect(drags.last['deltaX'], 10); + expect(drags.last['deltaY'], 5); + expect(ends.single['dy'], drags.last['dy']); + }); + + testWidgets('a bound x/y moves a node in a freeform parent', (tester) async { + final RectangleNode pin = RectangleNode.fromJson({ + ...RectangleNode( + id: 'pin', + name: 'Pin', + basicBoxLocal: NodeBox(20, 20, 10, 10), + ).toJson(), + 'variables': {'x': r'${= scale * 3}', 'y': r'${= 5}'}, + }); + final FrameNode frame = FrameNode( + id: 'frame', + name: 'Map', + basicBoxLocal: NodeBox(0, 0, 400, 200), + children: ['pin'], + ); + pin.parentID = frame.id; + frame.parentID = kRootNode; + final registry = NodeRegistry()..setNodes({frame.id: frame, pin.id: pin}); + await pump(tester, registry, frame); + + final Finder translated = find.byWidgetPredicate( + (w) => w is Transform && w.transform.getTranslation().x != 0, + ); + expect(translated, findsOneWidget); + final translation = tester + .widget(translated) + .transform + .getTranslation(); + // Design place (20, 20) to bound (30, 5). + expect([translation.x, translation.y], [10, -15]); + }); + + testWidgets('a bound size sizes a node pinned in a freeform parent', ( + tester, + ) async { + final RectangleNode bar = RectangleNode.fromJson({ + ...RectangleNode( + id: 'bar', + name: 'Meter fill', + basicBoxLocal: NodeBox(0, 0, 300, 8), + positioningMode: PositioningMode.pin, + edgePins: const EdgePinsModel( + left: 0, + top: 0, + right: null, + bottom: null, + ), + ).toJson(), + 'variables': {'width': r'${= scale * 12}'}, + }); + final FrameNode frame = FrameNode( + id: 'frame', + name: 'Meter', + basicBoxLocal: NodeBox(0, 0, 400, 200), + children: ['bar'], + ); + bar.parentID = frame.id; + frame.parentID = kRootNode; + final registry = NodeRegistry()..setNodes({frame.id: frame, bar.id: bar}); + await pump(tester, registry, frame); + + final Finder child = find.byWidgetPredicate( + (w) => w is FreeformChild && '${w.key}'.contains('[bar]'), + ); + expect(tester.getSize(child), const Size(120, 8)); + }); + + testWidgets('a nested list reads its outer item as parent', (tester) async { + // Homes, each with its own scale and placements. + final RectangleNode dot = RectangleNode.fromJson({ + ...RectangleNode( + id: 'dot', + name: 'Dot', + basicBoxLocal: NodeBox(0, 0, 4, 4), + ).toJson(), + 'variables': { + 'x': r'${= item.x * parent.item.scale}', + 'y': r'${= parent.index * 1}', + }, + }); + final ListViewNode inner = ListViewNode( + id: 'inner', + name: 'Inner', + basicBoxLocal: NodeBox(0, 0, 200, 40), + properties: ListViewProperties(layout: ListLayout.stack), + children: ['dot'], + variables: {'data': r'${item.placements}'}, + ); + final ListViewNode outer = ListViewNode( + id: 'outer', + name: 'Outer', + basicBoxLocal: NodeBox(0, 0, 200, 200), + properties: ListViewProperties(itemExtent: 40), + children: ['inner'], + variables: {'data': r'${homes}'}, + ); + dot.parentID = inner.id; + inner.parentID = outer.id; + outer.parentID = kRootNode; + final registry = NodeRegistry() + ..setNodes({outer.id: outer, inner.id: inner, dot.id: dot}); + final manager = PassiveNodeTransformerManager( + registry.getNodeByID, + (context, bounds, pageID, layoutID, canvasID) => const SizedBox(), + ); + final CodelesslyContext codelesslyContext = CodelesslyContext.empty() + ..variables['homes'] = Observable( + VariableData( + id: 'homes', + name: 'homes', + type: VariableType.list, + value: [ + { + 'scale': 10, + 'placements': [ + {'x': 1}, + {'x': 2}, + ], + }, + { + 'scale': 20, + 'placements': [ + {'x': 1}, + ], + }, + ], + ), + ); + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: codelesslyContext, + child: MaterialApp( + home: Align( + alignment: Alignment.topLeft, + child: SizedBox( + width: 200, + height: 200, + child: Builder( + builder: (context) => manager.buildWidgetFromNode( + outer, + context, + settings: const WidgetBuildSettings(debugLabel: 'test'), + ), + ), + ), + ), + ), + ), + ); + final List dots = tester + .widgetList(items()) + .toList(); + expect( + [ + for (final d in dots) [d.left, d.top], + ], + [ + [10, 0], + [20, 0], + [20, 1], + ], + ); + }); +} diff --git a/test/variable_preview_test.dart b/test/variable_preview_test.dart index f1ce98ff..c8be2799 100644 --- a/test/variable_preview_test.dart +++ b/test/variable_preview_test.dart @@ -71,6 +71,19 @@ void main() { expect(VariablePreview.textFor(r'${= price * 2}'), '9'); }); + test('show a formula that gives empty text as empty', () { + expect(VariablePreview.textFor(r"${= if(price > 9, 'x', '')}"), ''); + expect(VariablePreview.textFor(r"${= ''}"), ''); + VariablePreview.scopeResolver = (node) => ScopedValues.preview( + variables: const {}, + indexedItem: IndexedItem(0, {'published': false}), + ); + expect( + VariablePreview.textFor(r"${= if(item.published, 'Published', '')}"), + '', + ); + }); + test('fall back to a placeholder when there is no value', () { expect(VariablePreview.textFor(r'${auth.email}'), 'Email'); });