diff --git a/CHANGELOG.md b/CHANGELOG.md index 555bdd98..e3e5b41e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,67 @@ +## Unreleased + +Needs the `codelessly_api` release after 6.2.0 (signature, loop animation, +side sheet, confirm presentation, toast styling, `contentFit` and +`shrinkWrapRows`). + +### Nodes and actions + +- `PassiveSignatureTransformer` draws a `SignatureNode`: strokes in + round-cap ink, the sign line and placeholder, the error border when its + `error` binding is true. A finished stroke writes all strokes to the + bound value and fires `changed`; setting the value to `[]` clears it. +- Loop animations: nodes with a `loopAnimation` build inside a + `LoopAnimationBox` (pulse, fade, scale or spin) that `loopAnimationEnabled` + turns on and off without remounting, and that holds still under a + disabled `TickerMode` (the editor's design canvas). +- `cursorEnter`, `cursorExit` and `hover` reactions run, through a + translucent `MouseRegion`. +- Dialog canvases also open as side sheets (`DialogPresentation.sideSheet`, + `sheetWidth`, `LayoutPresentationRoute`), and bottom sheets take + `maxHeightFactor` and `showDragHandle`. What a dialog canvas passes back + on Navigate back becomes the chain's result. +- `ConfirmAction.presentation`: the built-in dialog and a `dialogId` canvas + open as a dialog, bottom sheet, adaptively or as a side sheet, like + `showDialog`. +- Toasts: `accentColor`/`accentWidth` draw a bar down the left edge, + `iconColor` colors the icon, `fontFamily` sets the font and + `cornerRadius` the corners (default still a pill). +- A node whose `disabled` binding is true and that has no Disabled + interaction state of its own fades to 40 % (it already ignored taps). + +### Forms and formulas + +- A text field's boolean `error` binding shows its error borders without a + message. +- Formulas: `left(text, n)`, `right(text, n)` and `toMap(list, key, + value)`. A lambda can take the item's index as a second parameter: + `map(steps, (s, i) => i == length(steps) - 1)`, also in filter, find, + count, any, all, sortBy, unique, sum, join and toMap. +- `isEmpty` treats null as empty, so a text variable holding "" is empty, + and the `isEmpty` / `isNotEmpty` accessors of list variables work. + +### Layout + +- `FrameNode.contentFit` lays the frame's children out at its design size + and scales them into the frame (`ScaledContent`, a filling `FittedBox`). +- The `parent` variable: `${parent.width}` and `${parent.height}` are the + size a node's parent gives it (`ParentSizeScope`), for layouts that change + with their container's width rather than the screen's. +- A spacer that flexes on its parent's main axis is wrapped in its + `Expanded`/`Flexible` by the row or column, outside every wrapper built + around it, so it takes the free space like Flutter's `Spacer` also under + a `NodeBuildDecorator` (the editor's render canvas drew it at its design + size). +- A child that fills across a row or column that hugs that axis stretches + to the size its other children give it (the row is measured with + `IntrinsicHeight` / `IntrinsicWidth`), like a card's colored edge as tall + as its text. +- A `fixedCrossAxisCount` grid keeps its count at every width and divides + the grid's width between the cells (`DesignCellGridDelegate` is gone). +- `GridDelegateProperties.shrinkWrapRows`: rows as tall as their tallest + item, built as a list of rows of `Expanded` cells in an `IntrinsicHeight` + (`GridViewBuilder.intrinsicRows`). + ## 8.3.0 Requires codelessly_api 6.2.0. diff --git a/lib/src/functions/app_confirm_dialog.dart b/lib/src/functions/app_confirm_dialog.dart index 76f330fe..6563aca0 100644 --- a/lib/src/functions/app_confirm_dialog.dart +++ b/lib/src/functions/app_confirm_dialog.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../../codelessly_sdk.dart'; +import '../ui/layout_presentation_route.dart'; import 'action_chain.dart'; /// How a [ConfirmAction]'s dialog closed. @@ -45,6 +46,10 @@ class AppConfirmDialog { params: action.params, barrierDismissible: action.barrierDismissible, barrierColor: action.barrierColor?.toFlutterColor(), + presentation: action.presentation, + sheetWidth: action.sheetWidth, + maxHeightFactor: action.maxHeightFactor, + showDragHandle: action.showDragHandle, ); final bool confirmed = ConfirmAction.isConfirmResult(result); return ( @@ -79,6 +84,42 @@ class AppConfirmDialog { // the layout. final ResolvedFeedbackTheme theme = FeedbackTheme.of(context); + Widget buildDialog(BuildContext context) => ThemedConfirmDialog( + theme: theme, + presentation: action.presentation.resolveFor(context), + title: title, + message: message, + confirmLabel: confirmLabel.isEmpty ? 'OK' : confirmLabel, + cancelLabel: cancelLabel.isEmpty ? 'Cancel' : cancelLabel, + destructive: action.destructive, + backgroundColor: action.backgroundColor?.toFlutterColor(), + textColor: action.textColor?.toFlutterColor(), + confirmColor: action.confirmColor?.toFlutterColor(), + cornerRadius: action.cornerRadius, + ); + + final DialogPresentation presentation = action.presentation.resolveFor( + context, + ); + if (presentation != DialogPresentation.dialog) { + final Object? answer = await Navigator.of(context).push( + LayoutPresentationRoute( + context: context, + presentation: presentation, + barrierDismissible: action.barrierDismissible, + barrierColor: + action.barrierColor?.toFlutterColor() ?? + Theme.of(context).dialogTheme.barrierColor ?? + Colors.black54, + sheetWidth: action.sheetWidth, + maxHeightFactor: action.maxHeightFactor, + showDragHandle: action.showDragHandle, + builder: buildDialog, + ), + ); + return (confirmed: answer == true, values: null); + } + final bool? confirmed = await showDialog( context: context, useRootNavigator: false, @@ -108,6 +149,7 @@ class ThemedConfirmDialog extends StatelessWidget { const ThemedConfirmDialog({ super.key, required this.theme, + this.presentation = DialogPresentation.dialog, required this.title, required this.message, required this.confirmLabel, @@ -122,6 +164,10 @@ class ThemedConfirmDialog extends StatelessWidget { /// The app's look. final ResolvedFeedbackTheme theme; + /// Whether it is a centered dialog or fills a bottom or side sheet. A + /// sheet's route ([LayoutPresentationRoute]) places and clips it. + final DialogPresentation presentation; + /// Title; hidden when empty. final String title; @@ -171,93 +217,123 @@ class ThemedConfirmDialog extends StatelessWidget { const Size buttonSize = Size(64, 44); const EdgeInsets buttonPadding = EdgeInsets.symmetric(horizontal: 18); + final Widget content = Semantics( + scopesRoute: true, + namesRoute: true, + explicitChildNodes: true, + label: title.isEmpty ? null : title, + child: Padding( + padding: const EdgeInsets.fromLTRB(24, 24, 24, 16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (title.isNotEmpty) + Text( + title, + style: theme.titleStyle.copyWith( + fontSize: 18, + height: 1.3, + color: onSurface, + ), + ), + if (title.isNotEmpty && message.isNotEmpty) + const SizedBox(height: 8), + if (message.isNotEmpty) + Flexible( + child: SingleChildScrollView( + child: Text( + message, + style: theme.bodyStyle.copyWith( + fontSize: 15, + height: 1.45, + color: onSurface.withValues(alpha: 0.74), + ), + ), + ), + ), + const SizedBox(height: 20), + OverflowBar( + alignment: MainAxisAlignment.end, + overflowAlignment: OverflowBarAlignment.end, + spacing: 8, + overflowSpacing: 8, + children: [ + TextButton( + key: const ValueKey('codelessly-confirm-cancel'), + onPressed: () => Navigator.of(context).pop(false), + style: TextButton.styleFrom( + foregroundColor: onSurface, + textStyle: buttonText, + shape: buttonShape, + minimumSize: buttonSize, + padding: buttonPadding, + ), + child: Text(cancelLabel), + ), + FilledButton( + key: const ValueKey('codelessly-confirm-ok'), + onPressed: () => Navigator.of(context).pop(true), + style: FilledButton.styleFrom( + backgroundColor: confirmFill, + foregroundColor: onConfirm, + textStyle: buttonText, + shape: buttonShape, + minimumSize: buttonSize, + padding: buttonPadding, + ), + child: Text(confirmLabel), + ), + ], + ), + ], + ), + ), + ); + return Theme( data: theme.apply(Theme.of(context)), - child: Dialog( - backgroundColor: surface, - surfaceTintColor: Colors.transparent, - elevation: 8, - insetPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 24), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(radius), + child: switch (presentation) { + DialogPresentation.dialog => Dialog( + backgroundColor: surface, + surfaceTintColor: Colors.transparent, + elevation: 8, + insetPadding: const EdgeInsets.symmetric( + horizontal: 24, + vertical: 24, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(radius), + ), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 400), + child: content, + ), ), - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 400), - child: Semantics( - scopesRoute: true, - namesRoute: true, - explicitChildNodes: true, - label: title.isEmpty ? null : title, - child: Padding( - padding: const EdgeInsets.fromLTRB(24, 24, 24, 16), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (title.isNotEmpty) - Text( - title, - style: theme.titleStyle.copyWith( - fontSize: 18, - height: 1.3, - color: onSurface, - ), - ), - if (title.isNotEmpty && message.isNotEmpty) - const SizedBox(height: 8), - if (message.isNotEmpty) - Flexible( - child: SingleChildScrollView( - child: Text( - message, - style: theme.bodyStyle.copyWith( - fontSize: 15, - height: 1.45, - color: onSurface.withValues(alpha: 0.74), - ), - ), - ), - ), - const SizedBox(height: 20), - OverflowBar( - alignment: MainAxisAlignment.end, - overflowAlignment: OverflowBarAlignment.end, - spacing: 8, - overflowSpacing: 8, - children: [ - TextButton( - key: const ValueKey('codelessly-confirm-cancel'), - onPressed: () => Navigator.of(context).pop(false), - style: TextButton.styleFrom( - foregroundColor: onSurface, - textStyle: buttonText, - shape: buttonShape, - minimumSize: buttonSize, - padding: buttonPadding, - ), - child: Text(cancelLabel), - ), - FilledButton( - key: const ValueKey('codelessly-confirm-ok'), - onPressed: () => Navigator.of(context).pop(true), - style: FilledButton.styleFrom( - backgroundColor: confirmFill, - foregroundColor: onConfirm, - textStyle: buttonText, - shape: buttonShape, - minimumSize: buttonSize, - padding: buttonPadding, - ), - child: Text(confirmLabel), - ), - ], - ), - ], + // A sheet is as wide as its route makes it; its content keeps clear + // of the home indicator and the bottom sheet's grab handle. + DialogPresentation.bottomSheet || + DialogPresentation.adaptive => SizedBox( + width: double.infinity, + child: Material( + color: surface, + child: SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.only(top: 8), + child: content, ), ), ), ), - ), + DialogPresentation.sideSheet => Material( + color: surface, + child: SafeArea( + left: false, + child: Align(alignment: Alignment.topLeft, child: content), + ), + ), + }, ); } } diff --git a/lib/src/functions/app_toast.dart b/lib/src/functions/app_toast.dart index 51faed5e..3675e69d 100644 --- a/lib/src/functions/app_toast.dart +++ b/lib/src/functions/app_toast.dart @@ -76,6 +76,19 @@ class AppToast { if (entry != null && entry.mounted) entry.remove(); } + /// The font of [family], e.g. `Barlow`: a Google font or one the app + /// loaded. Null when [family] is null or empty. + static TextStyle? fontOf(String? family) { + if (family == null || family.trim().isEmpty) return null; + final TextStyle style = TextUtils.retrieveTextStyle( + fontName: FontName(family: family.trim(), weight: FontWeightNumeric.w600), + ); + return TextStyle( + fontFamily: style.fontFamily, + fontFamilyFallback: style.fontFamilyFallback, + ); + } + /// The background and text colors of a toast of [style] in [theme]. static (Color background, Color foreground) colorsOf( ToastStyle style, @@ -171,38 +184,51 @@ class _ToastViewState extends State<_ToastView> { (action.backgroundColor == null ? styleForeground : ResolvedFeedbackTheme.onColor(background)); - final Widget? icon = action.icon == null + final Color? accent = action.accentColor?.toFlutterColor(); + // The icon's own color, unless [ShowToastAction.iconColor] is set, else + // the message color. + final MultiSourceIconModel? iconModel = action.iconColor == null + ? action.icon + : action.icon?.copyWith(color: action.iconColor); + final Widget? icon = iconModel == null ? null - : IconTheme.merge( - data: IconThemeData(color: textColor), - child: retrieveIconWidget(action.icon!, 18) ?? const SizedBox(), - ); + : retrieveIconWidget(iconModel, iconModel.size ?? 18, textColor); final bool hasButton = widget.onAction != null; final bool top = action.position == ToastPosition.top; final EdgeInsets safe = MediaQuery.paddingOf(context); - final TextStyle messageStyle = theme.bodyStyle.copyWith( - color: textColor, - fontSize: 14, - height: 1.35, - fontWeight: FontWeight.w600, - decoration: TextDecoration.none, - ); + final TextStyle? font = AppToast.fontOf(action.fontFamily); + final TextStyle messageStyle = theme.bodyStyle + .merge(font) + .copyWith( + color: textColor, + fontSize: 14, + height: 1.35, + fontWeight: FontWeight.w600, + decoration: TextDecoration.none, + ); + final BorderRadius radius = BorderRadius.circular( + action.cornerRadius ?? + (hasButton ? theme.buttonRadius.clamp(8, 999) : 999), + ); Widget toast = Container( + key: const ValueKey('codelessly-toast'), constraints: const BoxConstraints(minHeight: 44, maxWidth: 560), - padding: EdgeInsets.fromLTRB(18, 10, hasButton ? 8 : 18, 10), + padding: EdgeInsets.fromLTRB( + accent == null ? 18 : 14, + 10, + hasButton ? 8 : 18, + 10, + ), + // The accent bar is a left border. A border on one side can't have a + // radius, so the clip below rounds the corners. decoration: BoxDecoration( color: background, - borderRadius: BorderRadius.circular( - hasButton ? theme.buttonRadius.clamp(8, 999) : 999, - ), - boxShadow: const [ - BoxShadow( - color: Color(0x33000000), - blurRadius: 18, - offset: Offset(0, 6), - ), - ], + border: accent == null + ? null + : Border( + left: BorderSide(color: accent, width: action.accentWidth), + ), ), child: Row( mainAxisSize: MainAxisSize.min, @@ -218,10 +244,9 @@ class _ToastViewState extends State<_ToastView> { foregroundColor: textColor, minimumSize: const Size(48, 36), padding: const EdgeInsets.symmetric(horizontal: 12), - textStyle: theme.buttonStyle.copyWith( - fontSize: 14, - fontWeight: FontWeight.w700, - ), + textStyle: theme.buttonStyle + .merge(font) + .copyWith(fontSize: 14, fontWeight: FontWeight.w700), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(theme.buttonRadius), ), @@ -232,6 +257,19 @@ class _ToastViewState extends State<_ToastView> { ], ), ); + toast = DecoratedBox( + decoration: BoxDecoration( + borderRadius: radius, + boxShadow: const [ + BoxShadow( + color: Color(0x33000000), + blurRadius: 18, + offset: Offset(0, 6), + ), + ], + ), + child: ClipRRect(borderRadius: radius, child: toast), + ); toast = Semantics( liveRegion: true, container: true, diff --git a/lib/src/functions/functions_repository.dart b/lib/src/functions/functions_repository.dart index 0c1bc3fa..392ce834 100644 --- a/lib/src/functions/functions_repository.dart +++ b/lib/src/functions/functions_repository.dart @@ -13,6 +13,7 @@ import 'package:url_launcher/url_launcher.dart'; import '../../codelessly_sdk.dart'; import '../logging/stat_tracker.dart'; import '../ui/codelessly_dialog_widget.dart'; +import '../ui/layout_presentation_route.dart'; import 'action_chain.dart'; import 'app_auth.dart'; import 'cloud_files.dart'; @@ -582,11 +583,13 @@ class FunctionsRepository { } } + /// Shows [action]'s canvas. What its "Navigate back" passes back, e.g. the + /// values of a form in a sheet, is the `${result}` of the actions after it. static Future showDialogAction( BuildContext context, ShowDialogAction action, ) async { - await showLayoutDialog( + final Object? result = await showLayoutDialog( context, canvasId: action.destinationId, params: action.params, @@ -595,7 +598,13 @@ class FunctionsRepository { showCloseButton: action.showCloseButton, presentation: action.presentation, alignment: action.alignment.flutterAlignment ?? Alignment.center, + sheetWidth: action.sheetWidth, + maxHeightFactor: action.maxHeightFactor, + showDragHandle: action.showDragHandle, ); + if (result case final Map values when values.isNotEmpty) { + ActionChain.setResult(values); + } } /// The id of the published layout that holds the canvas [canvasId], if any. @@ -604,9 +613,10 @@ class FunctionsRepository { .firstWhereOrNull((layout) => layout.canvasIds.contains(canvasId)) ?.id; - /// Shows the canvas [canvasId] as a dialog and completes with what it was - /// closed with: the params of the "Navigate back" action that closed it, or - /// null when it was dismissed. + /// Shows the canvas [canvasId] as a dialog, a bottom sheet or a side sheet + /// (see [presentation]) and completes with what it was closed with: the + /// params of the "Navigate back" action that closed it, or null when it was + /// dismissed. static Future showLayoutDialog( BuildContext context, { required String canvasId, @@ -616,6 +626,9 @@ class FunctionsRepository { bool showCloseButton = false, DialogPresentation presentation = DialogPresentation.dialog, AlignmentGeometry alignment = Alignment.center, + double? sheetWidth, + double maxHeightFactor = 1, + bool showDragHandle = false, }) async { final ScopedValues scopedValues = ActionChain.scopedValuesOf(context); final parsedParams = substituteVariablesInMap(params, scopedValues); @@ -658,6 +671,11 @@ class FunctionsRepository { // A chain triggered in the dialog can finish on this layout after the // dialog closes (e.g. Navigate back, then a toast). final List openerHosts = ActionChainOpener.hostsFor(context); + final BaseNode? canvas = codelessly + .dataManager + .publishModel + ?.layouts[layoutId] + ?.canvases[canvasId]?[canvasId]; final Object? result = await presentCanvas( context, presentation: presentation, @@ -665,6 +683,10 @@ class FunctionsRepository { barrierDismissible: barrierDismissible, barrierColor: barrierColor, showCloseButton: showCloseButton, + sheetWidth: sheetWidth, + designWidth: canvas?.basicBoxLocal.width, + maxHeightFactor: maxHeightFactor, + showDragHandle: showDragHandle, routeSettings: RouteSettings(arguments: parsedParams), builder: (context) => ActionChainOpener( openerHosts: openerHosts, @@ -693,9 +715,31 @@ class FunctionsRepository { Color? barrierColor, bool showCloseButton = false, RouteSettings? routeSettings, + double? sheetWidth, + double? designWidth, + double maxHeightFactor = 1, + bool showDragHandle = false, }) { + if (presentation == DialogPresentation.sideSheet) { + return Navigator.of(context).push( + LayoutPresentationRoute( + context: context, + presentation: presentation, + barrierDismissible: barrierDismissible, + barrierColor: + barrierColor ?? + Theme.of(context).dialogTheme.barrierColor ?? + Colors.black54, + showCloseButton: showCloseButton, + sheetWidth: sheetWidth, + designWidth: designWidth, + settings: routeSettings, + builder: builder, + ), + ); + } final bool asSheet = switch (presentation) { - DialogPresentation.dialog => false, + DialogPresentation.dialog || DialogPresentation.sideSheet => false, DialogPresentation.bottomSheet => true, DialogPresentation.adaptive => (MediaQuery.maybeSizeOf(context)?.width ?? 1024) < 600, @@ -703,6 +747,7 @@ class FunctionsRepository { Widget content(BuildContext context) => CodelesslyDialogWidget( showCloseButton: showCloseButton, alignment: asSheet ? Alignment.bottomCenter : alignment, + hugHeight: asSheet, builder: builder, ); if (asSheet) { @@ -714,6 +759,12 @@ class FunctionsRepository { barrierColor: barrierColor, backgroundColor: Colors.transparent, elevation: 0, + showDragHandle: showDragHandle, + constraints: BoxConstraints( + maxHeight: + (MediaQuery.maybeSizeOf(context)?.height ?? double.infinity) * + maxHeightFactor.clamp(0, 1), + ), routeSettings: routeSettings, builder: content, ); diff --git a/lib/src/transformers/node_transformers/node_transformers.dart b/lib/src/transformers/node_transformers/node_transformers.dart index 8421a1e2..e01bc058 100644 --- a/lib/src/transformers/node_transformers/node_transformers.dart +++ b/lib/src/transformers/node_transformers/node_transformers.dart @@ -26,6 +26,7 @@ export 'passive_radio_transformer.dart'; export 'passive_rectangle_transformer.dart'; export 'passive_row_column_transformer.dart'; export 'passive_segmented_button_transformer.dart'; +export 'passive_signature_transformer.dart'; export 'passive_single_placeholder_transformer.dart'; export 'passive_slider_transformer.dart'; export 'passive_spacer_transformer.dart'; diff --git a/lib/src/transformers/node_transformers/passive_canvas_transformer.dart b/lib/src/transformers/node_transformers/passive_canvas_transformer.dart index 9fe25753..24baca1d 100644 --- a/lib/src/transformers/node_transformers/passive_canvas_transformer.dart +++ b/lib/src/transformers/node_transformers/passive_canvas_transformer.dart @@ -440,6 +440,11 @@ enum CanvasSizing { /// given. Used where the host only centers the layout and lets it choose /// its size, such as a dialog. design, + + /// The canvas fills the width it is given and keeps its design height (or + /// its content's), within the height it is given. Used by a bottom sheet, + /// which spans the screen and is as tall as its canvas. + fillWidth, } /// Sets the [CanvasSizing] of the canvases below it. Without one, canvases @@ -514,8 +519,11 @@ class CanvasViewport extends StatelessWidget { node.verticalFit == SizeFit.shrinkWrap && node.scaleMode == ScaleMode.responsive && !node.isAScaffold; - if (sizing == CanvasSizing.design) { - final double width = constraints.constrainWidth(node.basicBoxLocal.width); + if (sizing != CanvasSizing.fill) { + final double width = + sizing == CanvasSizing.fillWidth && constraints.hasBoundedWidth + ? constraints.maxWidth + : constraints.constrainWidth(node.basicBoxLocal.width); if (contentHeight) return (width: width, height: null); final double height = node.scaleMode == ScaleMode.autoScale ? autoScaleHeight(node, width) diff --git a/lib/src/transformers/node_transformers/passive_grid_view_transformer.dart b/lib/src/transformers/node_transformers/passive_grid_view_transformer.dart index 3d4fa996..2c7fa6a4 100644 --- a/lib/src/transformers/node_transformers/passive_grid_view_transformer.dart +++ b/lib/src/transformers/node_transformers/passive_grid_view_transformer.dart @@ -1,4 +1,5 @@ import 'dart:core'; +import 'dart:math' as math; import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:codelessly_api/codelessly_api.dart'; @@ -83,19 +84,18 @@ class PassiveGridViewWidget extends StatelessWidget { builder: builder, ); - /// The cross extent of one cell as the editor drew it, the same size the - /// editor constrains the item node to. - double get designCellExtent => node.calculateCrossAxisExtent( - node.scrollDirection, - node.innerBoxLocal.size, - ); - @override Widget build(BuildContext context) { if (node.children.isEmpty) { return box(canShrinkWrap: true, (context, _) => const SizedBox()); } final itemNode = node.children.first; + // Cells of a row sized to its content stretch to the tallest one, unless + // the item can't report its intrinsic size. + final bool intrinsicRows = !subtreeBlocksIntrinsics( + manager.getNode(itemNode), + manager.getNode, + ); final bool useCloudDatabase = node.useCloudDatabase; if (useCloudDatabase && !settings.isPreview) { @@ -196,7 +196,7 @@ class PassiveGridViewWidget extends StatelessWidget { return GridViewBuilder( primary: node.primary, gridDelegate: node.properties.gridDelegate, - designCellExtent: designCellExtent, + intrinsicRows: intrinsicRows, shrinkWrap: shrinkWrap, itemCount: itemCount, padding: node.padding.flutterEdgeInsets, @@ -261,7 +261,7 @@ class PassiveGridViewWidget extends StatelessWidget { (context, shrinkWrap) => GridViewBuilder( primary: node.primary, gridDelegate: node.properties.gridDelegate, - designCellExtent: designCellExtent, + intrinsicRows: intrinsicRows, shrinkWrap: shrinkWrap, itemCount: itemCount, padding: node.padding.flutterEdgeInsets, @@ -309,16 +309,17 @@ class GridViewBuilder extends StatelessWidget { final ScrollViewKeyboardDismissBehavior keyboardDismissBehavior; final GridDelegateProperties gridDelegate; - /// The cross extent of one cell at the design size. With a fixed cross - /// axis count, the grid keeps cells at least this wide; see - /// [DesignCellGridDelegate]. Null keeps the count at every size. - final double? designCellExtent; + /// With [GridDelegateProperties.shrinkWrapRows], whether the cells of a row + /// stretch to its tallest item, like a [Row] in an [IntrinsicHeight]. + /// False lays each row out at its tallest item and leaves the others at + /// their own size, for items that can't report intrinsic sizes. + final bool intrinsicRows; const GridViewBuilder({ super.key, required this.shrinkWrap, required this.gridDelegate, - this.designCellExtent, + this.intrinsicRows = true, this.itemCount, this.padding, this.physics, @@ -333,6 +334,35 @@ class GridViewBuilder extends StatelessWidget { @override Widget build(BuildContext context) { + if (gridDelegate.shrinkWrapRows) { + return DraggableScrollConfiguration( + child: switch (gridDelegate) { + FixedCrossAxisCountGridDelegateProperties props => _rows( + props.crossAxisCount, + ), + MaxCrossAxisExtentGridDelegateProperties props => LayoutBuilder( + builder: (context, constraints) { + final bool vertical = scrollDirection == Axis.vertical; + final double extent = + (vertical ? constraints.maxWidth : constraints.maxHeight) - + (vertical + ? padding?.horizontal ?? 0 + : padding?.vertical ?? 0); + // As SliverGridDelegateWithMaxCrossAxisExtent counts. + return _rows( + extent.isFinite + ? (extent / + (props.maxCrossAxisExtent + + props.crossAxisSpacing)) + .ceil() + : 1, + ); + }, + ), + }, + ); + } + return DraggableScrollConfiguration( child: GridView.builder( primary: primary, @@ -350,13 +380,12 @@ class GridViewBuilder extends StatelessWidget { keyboardDismissBehavior: keyboardDismissBehavior, gridDelegate: switch (gridDelegate) { FixedCrossAxisCountGridDelegateProperties props => - DesignCellGridDelegate( - crossAxisCount: props.crossAxisCount, - designCellExtent: designCellExtent ?? 0, + SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: math.max(1, props.crossAxisCount), + mainAxisExtent: props.mainAxisExtent, + childAspectRatio: props.childAspectRatio, crossAxisSpacing: props.crossAxisSpacing, mainAxisSpacing: props.mainAxisSpacing, - childAspectRatio: props.childAspectRatio, - mainAxisExtent: props.mainAxisExtent, ), MaxCrossAxisExtentGridDelegateProperties props => SliverGridDelegateWithMaxCrossAxisExtent( @@ -370,4 +399,65 @@ class GridViewBuilder extends StatelessWidget { ), ); } + + /// A grid whose rows (columns when it scrolls horizontally) are as long as + /// their longest item: a list of rows of [crossAxisCount] equal cells. + Widget _rows(int crossAxisCount) { + final int count = math.max(1, crossAxisCount); + final bool vertical = scrollDirection == Axis.vertical; + final int? rowCount = itemCount == null + ? null + : (itemCount! / count).ceil(); + // The spacing goes before every row but the first, on the side that + // faces the row before it. + final double spacing = gridDelegate.mainAxisSpacing; + final EdgeInsetsGeometry leading = switch ((vertical, reverse)) { + (true, false) => EdgeInsets.only(top: spacing), + (true, true) => EdgeInsets.only(bottom: spacing), + (false, false) => EdgeInsetsDirectional.only(start: spacing), + (false, true) => EdgeInsetsDirectional.only(end: spacing), + }; + + return ListView.builder( + primary: primary, + shrinkWrap: shrinkWrap, + itemCount: rowCount, + padding: padding, + physics: physics, + scrollDirection: scrollDirection, + scrollCacheExtent: cacheExtent == null + ? null + : ScrollCacheExtent.pixels(cacheExtent!), + reverse: reverse, + clipBehavior: clipBehavior, + keyboardDismissBehavior: keyboardDismissBehavior, + itemBuilder: (context, row) { + Widget line = Flex( + direction: vertical ? Axis.horizontal : Axis.vertical, + crossAxisAlignment: intrinsicRows + ? CrossAxisAlignment.stretch + : CrossAxisAlignment.start, + spacing: gridDelegate.crossAxisSpacing, + children: [ + for (int column = 0; column < count; column++) + Expanded( + child: switch (row * count + column) { + final int index + when itemCount == null || index < itemCount! => + itemBuilder!(context, index), + _ => const SizedBox.shrink(), + }, + ), + ], + ); + if (intrinsicRows) { + line = vertical + ? IntrinsicHeight(child: line) + : IntrinsicWidth(child: line); + } + if (row == 0 || spacing <= 0) return line; + return Padding(padding: leading, child: line); + }, + ); + } } diff --git a/lib/src/transformers/node_transformers/passive_row_column_transformer.dart b/lib/src/transformers/node_transformers/passive_row_column_transformer.dart index b7e72510..fcb7111b 100644 --- a/lib/src/transformers/node_transformers/passive_row_column_transformer.dart +++ b/lib/src/transformers/node_transformers/passive_row_column_transformer.dart @@ -8,6 +8,27 @@ typedef WidgetInserter = Widget Function(Widget child); class PassiveRowColumnTransformer extends NodeWidgetTransformer { PassiveRowColumnTransformer(super.getNode, super.manager); + /// Whether a child of [rowColumn], which hugs its cross axis, fills that + /// axis while another child gives it a thickness: a card's severity edge + /// as tall as its text, `IntrinsicHeight(child: Row(...))` in Flutter. + /// The row is measured to its other children's thickness first, which the + /// filling child then fills. With no such other child there is nothing to + /// fill, and the child keeps its design size (see FitBox), as the editor + /// lays it out. A spacer's thickness is not used, so it gives none. + static bool fillsAcrossWrappingRowColumn( + BaseNode rowColumn, + List childrenNodes, + ) { + final bool isRow = + (rowColumn as RowColumnMixin).rowColumnType == RowColumnType.row; + SizeFit crossFit(BaseNode child) => + isRow ? child.verticalFit : child.horizontalFit; + bool fills(BaseNode child) => + child is! SpacerNode && crossFit(child) == SizeFit.expanded; + return childrenNodes.any(fills) && + childrenNodes.any((child) => child is! SpacerNode && !fills(child)); + } + static Widget buildRowColumnWidget( BaseNode rowColumnNode, { required List childrenWidgets, @@ -88,7 +109,9 @@ class PassiveRowColumnTransformer extends NodeWidgetTransformer { final bool anyChildNeedsAlignment = childrenNodes.any( (child) => child.alignment != AlignmentModel.none, ); - if (stretches || anyChildNeedsAlignment) { + if (stretches || + anyChildNeedsAlignment || + fillsAcrossWrappingRowColumn(rowColumnNode, childrenNodes)) { final bool blocked = childrenNodes.any( (child) => subtreeBlocksIntrinsics(child, getNode), ); @@ -294,10 +317,6 @@ class PassiveRowColumnTransformer extends NodeWidgetTransformer { ); } - if (node is SpacerNode) { - return childWidget; - } - final AxisC mainAxis = (parentRowColumn.rowColumnType == RowColumnType.row ? AxisC.horizontal : AxisC.vertical); @@ -305,6 +324,18 @@ class PassiveRowColumnTransformer extends NodeWidgetTransformer { ? node.horizontalFit : node.verticalFit); + // A spacer flexes like Flutter's Spacer. The flex goes around everything + // built for it, as for any other child: a host's wrappers (the editor's + // geometry probes) would otherwise sit between the Flex and an Expanded + // inside, and the Flex would lay the spacer out at its design size. + if (node is SpacerNode) { + return switch (mainAxisFit) { + SizeFit.expanded => Expanded(flex: flex, child: childWidget), + SizeFit.flexible => Flexible(flex: flex, child: childWidget), + _ => childWidget, + }; + } + final SizeFit crossAxisFit = (mainAxis == AxisC.horizontal ? node.verticalFit : node.horizontalFit); @@ -485,7 +516,9 @@ class PassiveRowColumnWidget extends StatelessWidget { Widget sizedWidget, ) { final NodeTransformerManager manager = this.manager; - if (manager is! PassiveNodeTransformerManager) { + // A child that reads its parent's size can only tell whether it is + // hidden as it is laid out, so it keeps its place. + if (manager is! PassiveNodeTransformerManager || readsParentSize(child)) { return FlexChildVisibility(collapsed: false, child: sizedWidget); } diff --git a/lib/src/transformers/node_transformers/passive_signature_transformer.dart b/lib/src/transformers/node_transformers/passive_signature_transformer.dart new file mode 100644 index 00000000..13b33b9b --- /dev/null +++ b/lib/src/transformers/node_transformers/passive_signature_transformer.dart @@ -0,0 +1,411 @@ +import 'dart:convert'; + +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:collection/collection.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../../../codelessly_sdk.dart'; +import '../utils/node_state_provider.dart'; + +/// A stroke of a signature: its points, in the pad's coordinates. +typedef SignatureStroke = List; + +class PassiveSignatureTransformer extends NodeWidgetTransformer { + PassiveSignatureTransformer(super.getNode, super.manager); + + @override + Widget buildWidget( + SignatureNode node, + BuildContext context, + WidgetBuildSettings settings, + ) { + return PassiveSignatureWidget(node: node, settings: settings); + } + + /// Builds a signature pad that keeps its strokes to itself, for previews + /// such as the settings panel. + Widget buildPreview({ + SignatureProperties? properties, + SignatureNode? node, + double? height, + double? width, + }) { + final SignatureNode previewNode = SignatureNode( + id: '', + name: 'Signature', + basicBoxLocal: NodeBox( + 0, + 0, + width ?? node?.basicBoxLocal.width ?? kSignatureDefaultWidth, + height ?? node?.basicBoxLocal.height ?? kSignatureDefaultHeight, + ), + properties: properties ?? node?.properties ?? SignatureProperties(), + ); + return TransformerSignature( + node: previewNode, + strokes: const [], + error: previewNode.properties.error, + ); + } +} + +/// A signature pad whose strokes live in the node's bound `value` variable, +/// or its node value when nothing is bound. +/// +/// Each finished stroke writes all the strokes to it, as a list of strokes +/// of `[x, y]` points, and fires [TriggerType.changed]. Setting the +/// variable to `[]` clears the pad. +class PassiveSignatureWidget extends StatelessWidget { + final SignatureNode node; + final WidgetBuildSettings settings; + final List variablesOverrides; + + const PassiveSignatureWidget({ + super.key, + required this.node, + required this.settings, + this.variablesOverrides = const [], + }); + + /// Reads strokes from a node value or a variable: a list of strokes, or + /// its JSON text, such as `[[[10, 20], [12, 24]]]`. Returns null when + /// [value] holds no strokes, e.g. an unset variable. + static List? parseStrokes(Object? value) { + Object? json = value; + if (json is String) { + final String text = json.trim(); + if (text.isEmpty) return const []; + try { + json = jsonDecode(text); + } on FormatException { + return null; + } + } + if (json is! List) return null; + return [ + for (final List> stroke in strokesFromJson(json)) + [for (final List point in stroke) Offset(point[0], point[1])], + ]; + } + + /// [strokes] as JSON: a list of strokes of `[x, y]` points. + static List>> strokesToJson( + List strokes, + ) => [ + for (final SignatureStroke stroke in strokes) + [ + for (final Offset point in stroke) [point.dx, point.dy], + ], + ]; + + void onStrokesChanged(BuildContext context, List strokes) { + // Without a runtime, e.g. on a design canvas, the pad keeps its strokes. + if (context.read() == null) return; + final List>> value = strokesToJson(strokes); + NodeStateProvider.setState(context, value); + FunctionsRepository.setPropertyValue( + context, + node: node, + property: 'value', + value: value, + ); + FunctionsRepository.triggerAction( + context, + node: node, + TriggerType.changed, + value: value, + ); + } + + @override + Widget build(BuildContext context) { + final ScopedValues scopedValues = ScopedValues.of( + context, + variablesOverrides: variablesOverrides, + ); + final List strokes = + parseStrokes( + PropertyValueDelegate.getPropertyValue( + node, + 'value', + scopedValues: scopedValues, + ), + ) ?? + parseStrokes(node.value) ?? + const []; + final bool error = + PropertyValueDelegate.getPropertyValue( + node, + 'error', + scopedValues: scopedValues, + ) ?? + node.properties.error; + final bool enabled = + PropertyValueDelegate.getPropertyValue( + node, + 'enabled', + scopedValues: scopedValues, + ) ?? + node.properties.enabled; + + return TransformerSignature( + key: ValueKey( + 'signature ${node.id}-${IndexedItemProvider.maybeOf(context)?.index ?? ''}', + ), + node: node, + strokes: strokes, + error: error, + onChanged: enabled ? onStrokesChanged : null, + ); + } +} + +/// Draws a signature pad and records the strokes drawn on it. +/// +/// It shows [strokes] until the user draws: a finished stroke is added to +/// the ones shown and reported with [onChanged]. When [strokes] changes from +/// outside, e.g. a variable is cleared, it shows the new strokes. Without +/// [onChanged], it does not take input. +class TransformerSignature extends StatefulWidget { + final SignatureNode node; + final List strokes; + final bool error; + final void Function(BuildContext context, List strokes)? + onChanged; + + const TransformerSignature({ + super.key, + required this.node, + required this.strokes, + this.error = false, + this.onChanged, + }); + + @override + State createState() => _TransformerSignatureState(); +} + +class _TransformerSignatureState extends State { + static const ListEquality _strokesEquality = ListEquality( + ListEquality(), + ); + + late List strokes = [...widget.strokes]; + SignatureStroke? current; + + @override + void didUpdateWidget(covariant TransformerSignature oldWidget) { + super.didUpdateWidget(oldWidget); + if (!_strokesEquality.equals(oldWidget.strokes, widget.strokes) && + !_strokesEquality.equals(strokes, widget.strokes)) { + strokes = [...widget.strokes]; + current = null; + } + } + + /// Rounds [point] to a tenth of a pixel, so the strokes read back from a + /// variable's text equal the ones drawn. + Offset _round(Offset point) => Offset( + (point.dx * 10).roundToDouble() / 10, + (point.dy * 10).roundToDouble() / 10, + ); + + void onStart(DragStartDetails details) => + setState(() => current = [_round(details.localPosition)]); + + void onUpdate(DragUpdateDetails details) { + final SignatureStroke? stroke = current; + if (stroke == null) return; + final Offset point = _round(details.localPosition); + if (stroke.isNotEmpty && stroke.last == point) return; + setState(() => stroke.add(point)); + } + + void onEnd([Object? _]) { + final SignatureStroke? stroke = current; + if (stroke == null) return; + setState(() { + strokes = [...strokes, stroke]; + current = null; + }); + widget.onChanged?.call(context, strokes); + } + + @override + Widget build(BuildContext context) { + final SignatureProperties properties = widget.node.properties; + final BorderRadius borderRadius = properties.cornerRadius.borderRadius; + final Widget pad = DecoratedBox( + decoration: BoxDecoration( + color: properties.backgroundColor.toFlutterColor(), + borderRadius: borderRadius, + border: Border.all( + color: widget.error + ? properties.errorBorderColor.toFlutterColor() + : properties.borderColor.toFlutterColor(), + width: widget.error + ? properties.errorBorderWidth + : properties.borderWidth, + ), + ), + child: ClipRRect( + borderRadius: borderRadius, + child: CustomPaint( + painter: SignaturePainter( + strokes: [...strokes, ?current], + properties: properties, + textDirection: Directionality.maybeOf(context) ?? TextDirection.ltr, + ), + child: const SizedBox.expand(), + ), + ), + ); + + final Widget sized = SizedBox( + width: widget.node.basicBoxLocal.width, + height: widget.node.basicBoxLocal.height, + child: pad, + ); + + if (widget.onChanged == null) return sized; + + return RawGestureDetector( + behavior: HitTestBehavior.opaque, + gestures: { + _EagerPanGestureRecognizer: + GestureRecognizerFactoryWithHandlers<_EagerPanGestureRecognizer>( + _EagerPanGestureRecognizer.new, + (recognizer) => recognizer + ..dragStartBehavior = DragStartBehavior.down + ..onStart = onStart + ..onUpdate = onUpdate + ..onEnd = onEnd + ..onCancel = onEnd, + ), + }, + child: sized, + ); + } +} + +/// A pan recognizer that wins the gesture arena as soon as a pointer goes +/// down, so a scrolling parent does not take a stroke that starts +/// vertically. +class _EagerPanGestureRecognizer extends PanGestureRecognizer { + @override + void addAllowedPointer(PointerDownEvent event) { + super.addAllowedPointer(event); + resolvePointer(event.pointer, GestureDisposition.accepted); + } +} + +/// Paints the sign line, the placeholder while there are no strokes, and the +/// strokes, smoothed with quadratic curves through their midpoints. +class SignaturePainter extends CustomPainter { + final List strokes; + final SignatureProperties properties; + final TextDirection textDirection; + + SignaturePainter({ + required this.strokes, + required this.properties, + required this.textDirection, + }); + + @override + void paint(Canvas canvas, Size size) { + if (properties.showSignLine) paintSignLine(canvas, size); + if (strokes.isEmpty && properties.placeholder.isNotEmpty) { + paintPlaceholder(canvas, size); + } + + final Paint ink = Paint() + ..color = properties.inkColor.toFlutterColor() + ..strokeWidth = properties.inkWidth + ..strokeCap = StrokeCap.round + ..strokeJoin = StrokeJoin.round + ..style = PaintingStyle.stroke + ..isAntiAlias = true; + + for (final SignatureStroke stroke in strokes) { + if (stroke.isEmpty) continue; + if (stroke.length == 1) { + canvas.drawCircle( + stroke.first, + properties.inkWidth / 2, + Paint()..color = ink.color, + ); + continue; + } + canvas.drawPath(smoothPath(stroke), ink); + } + } + + /// A path through [points] that bends at each point with a quadratic curve + /// from the midpoint before it to the midpoint after it. + static Path smoothPath(SignatureStroke points) { + final Path path = Path()..moveTo(points.first.dx, points.first.dy); + if (points.length == 2) { + return path..lineTo(points.last.dx, points.last.dy); + } + for (int i = 1; i < points.length - 1; i++) { + final Offset point = points[i]; + final Offset mid = (point + points[i + 1]) / 2; + path.quadraticBezierTo(point.dx, point.dy, mid.dx, mid.dy); + } + return path..lineTo(points.last.dx, points.last.dy); + } + + void paintSignLine(Canvas canvas, Size size) { + const double inset = 20; + final double y = size.height - size.height * 0.24; + if (size.width <= inset * 2 || y <= 0) return; + final Paint line = Paint() + ..color = properties.signLineColor.toFlutterColor() + ..strokeWidth = 1; + canvas.drawLine(Offset(inset, y), Offset(size.width - inset, y), line); + + final TextPainter cross = TextPainter( + text: TextSpan( + text: '×', + style: TextStyle( + color: properties.signLineColor.toFlutterColor(), + fontSize: 16, + height: 1, + ), + ), + textDirection: textDirection, + )..layout(); + cross.paint(canvas, Offset(inset, y - cross.height - 4)); + cross.dispose(); + } + + void paintPlaceholder(Canvas canvas, Size size) { + final TextPainter text = TextPainter( + text: TextSpan( + text: properties.placeholder, + style: TextStyle( + color: properties.placeholderColor.toFlutterColor(), + fontSize: properties.placeholderFontSize, + fontWeight: FontWeight.w600, + letterSpacing: properties.placeholderFontSize * 0.12, + ), + ), + textAlign: TextAlign.center, + textDirection: textDirection, + maxLines: 1, + ellipsis: '…', + )..layout(maxWidth: size.width); + text.paint( + canvas, + Offset((size.width - text.width) / 2, (size.height - text.height) / 2), + ); + text.dispose(); + } + + // Strokes grow in place while the user draws. + @override + bool shouldRepaint(covariant SignaturePainter oldDelegate) => true; +} diff --git a/lib/src/transformers/node_transformers/passive_spacer_transformer.dart b/lib/src/transformers/node_transformers/passive_spacer_transformer.dart index 2aa3138d..d9803dfd 100644 --- a/lib/src/transformers/node_transformers/passive_spacer_transformer.dart +++ b/lib/src/transformers/node_transformers/passive_spacer_transformer.dart @@ -6,7 +6,13 @@ import '../../../codelessly_sdk.dart'; class PassiveSpacerTransformer extends NodeWidgetTransformer { PassiveSpacerTransformer(super.getNode, super.manager); - Widget buildSpacer(BaseNode node, int flex) { + /// The spacer's own box. In a Row or Column, a spacer that flexes on the + /// main axis fills what it is given there: its parent wraps everything + /// built for it in an [Expanded] or [Flexible] (see + /// [PassiveRowColumnTransformer.wrapChildWithSizeFits]), like Flutter's + /// [Spacer]. Where the main axis is unbounded there is nothing to share, + /// and it keeps its design size instead (see CodelesslyFlex and FitBox). + Widget buildSpacer(BaseNode node) { final BaseNode parentNode = getNode(node.parentID); if (parentNode is! RowColumnMixin) { @@ -14,34 +20,22 @@ class PassiveSpacerTransformer extends NodeWidgetTransformer { } final AxisC mainAxis = parentNode.mainAxis; - final SizeFit horizontalFit = node.horizontalFit; - final SizeFit verticalFit = node.verticalFit; - final SizeFit mainFit = (mainAxis == AxisC.horizontal - ? horizontalFit - : verticalFit); + final SizeFit mainFit = mainAxis == AxisC.horizontal + ? node.horizontalFit + : node.verticalFit; - final Widget spacerWidget; if (mainFit.isFlex) { - // Fills its share of the free space. Where the main axis is unbounded - // there is none, and it keeps its design size instead (see - // CodelesslyFlex and FitBox). - spacerWidget = Expanded( - flex: flex, - child: FitBox( - width: mainAxis.isHorizontal ? node.outerBoxLocal.width : null, - height: mainAxis.isVertical ? node.outerBoxLocal.height : null, - fillWidth: mainAxis.isHorizontal, - fillHeight: mainAxis.isVertical, - ), - ); - } else { - spacerWidget = SizedBox( - width: mainAxis.isVertical ? null : node.outerBoxLocal.width, - height: mainAxis.isHorizontal ? null : node.outerBoxLocal.height, + return FitBox( + width: mainAxis.isHorizontal ? node.outerBoxLocal.width : null, + height: mainAxis.isVertical ? node.outerBoxLocal.height : null, + fillWidth: mainAxis.isHorizontal, + fillHeight: mainAxis.isVertical, ); } - - return spacerWidget; + return SizedBox( + width: mainAxis.isVertical ? null : node.outerBoxLocal.width, + height: mainAxis.isHorizontal ? null : node.outerBoxLocal.height, + ); } @override @@ -50,6 +44,6 @@ class PassiveSpacerTransformer extends NodeWidgetTransformer { BuildContext context, WidgetBuildSettings settings, ) { - return buildSpacer(node, node.flex); + return buildSpacer(node); } } diff --git a/lib/src/transformers/node_transformers/passive_stack_transformer.dart b/lib/src/transformers/node_transformers/passive_stack_transformer.dart index 6bfee511..4d51db41 100644 --- a/lib/src/transformers/node_transformers/passive_stack_transformer.dart +++ b/lib/src/transformers/node_transformers/passive_stack_transformer.dart @@ -261,24 +261,37 @@ class PassiveStackTransformer extends NodeWidgetTransformer { ]; final AxisC? scrollAxis = scrollAxisFor(node); - final List childrenWidgets = [ - FreeformLayout( - wrapsWidth: node.isHorizontalWrap, - wrapsHeight: node.isVerticalWrap, + final Fit? contentFit = node is FrameNode ? node.contentFit : null; + Widget content = FreeformLayout( + // Scaled content is laid out at its design size. + wrapsWidth: contentFit == null && node.isHorizontalWrap, + wrapsHeight: contentFit == null && node.isVerticalWrap, + designSize: node.innerBoxLocal.size.flutterSize, + children: [ + for (final BaseNode childNode in children) + buildStackChild( + childNode, + node, + context, + manager: manager, + scrollAxis: contentFit == null ? scrollAxis : null, + settings: settings, + ), + ], + ); + if (contentFit != null) { + content = ScaledContent( designSize: node.innerBoxLocal.size.flutterSize, - children: [ - for (final BaseNode childNode in children) - buildStackChild( - childNode, - node, - context, - manager: manager, - scrollAxis: scrollAxis, - settings: settings, - ), - ], - ), - ]; + fit: contentFit.boxFit, + alignment: + (node as FrameNode).contentAlignment.flutterAlignment ?? + Alignment.center, + hugsWidth: node.isHorizontalWrap, + hugsHeight: node.isVerticalWrap, + child: content, + ); + } + final List childrenWidgets = [content]; Widget stack = manager .getTransformer() @@ -297,3 +310,57 @@ class PassiveStackTransformer extends NodeWidgetTransformer { } } } + +/// Lays [child] out at [designSize] and scales it by [fit] into the space it +/// gets, like a [FittedBox] that fills that space: a frame's content with +/// [FrameNode.contentFit]. On an axis it hugs (or that is unbounded) it +/// takes the design size. +class ScaledContent extends StatelessWidget { + /// The size [child] is laid out at. + final Size designSize; + + /// How [child] is scaled into the space. + final BoxFit fit; + + /// Where [child] sits when [fit] leaves space. + final AlignmentGeometry alignment; + + /// Whether the width hugs: it is then the design width. + final bool hugsWidth; + + /// Whether the height hugs: it is then the design height. + final bool hugsHeight; + + /// The content. + final Widget child; + + /// Creates a [ScaledContent]. + const ScaledContent({ + super.key, + required this.designSize, + required this.fit, + required this.alignment, + required this.child, + this.hugsWidth = false, + this.hugsHeight = false, + }); + + @override + Widget build(BuildContext context) => LayoutBuilder( + builder: (context, constraints) => SizedBox( + width: hugsWidth || !constraints.hasBoundedWidth + ? designSize.width + : constraints.maxWidth, + height: hugsHeight || !constraints.hasBoundedHeight + ? designSize.height + : constraints.maxHeight, + child: ClipRect( + child: FittedBox( + fit: fit, + alignment: alignment, + child: SizedBox.fromSize(size: designSize, child: child), + ), + ), + ), + ); +} diff --git a/lib/src/transformers/node_transformers/passive_text_field_transformer.dart b/lib/src/transformers/node_transformers/passive_text_field_transformer.dart index 7260acc1..139373f7 100644 --- a/lib/src/transformers/node_transformers/passive_text_field_transformer.dart +++ b/lib/src/transformers/node_transformers/passive_text_field_transformer.dart @@ -458,23 +458,36 @@ class _PassiveTextFieldWidgetState extends State { // `${= invalid ? 'Required' : ''}` that comes out empty (or blank) is // no error, so the field keeps its normal border and has no error row. // InputDecoration uses errorBorder/focusedErrorBorder only with an error. + // The template is the errorText property, or a binding of it in the + // node's variables; a set-value action's value wins over both. + final String? errorTemplate = + node.variables['errorText'] ?? decoration.errorText; final String? errorMessage = getText( context, node, property: 'errorText', - defaultValue: decoration.errorText, + defaultValue: errorTemplate, scopedValues: scopedValues, settings: settings, ); final bool hasError = errorMessage != null && errorMessage.trim().isNotEmpty; + // A true `error` binding shows the error borders without a message, for + // forms that show their errors elsewhere. + final bool errorFlag = + PropertyValueDelegate.getPropertyValue( + node, + 'error', + scopedValues: scopedValues, + ) ?? + false; final Widget? errorText = !hasError ? null : buildText( context, node, property: 'errorText', - defaultValue: decoration.errorText, + defaultValue: errorTemplate, scopedValues: scopedValues, settings: settings, variablesOverrides: widget.variablesOverrides, @@ -605,10 +618,18 @@ class _PassiveTextFieldWidgetState extends State { focusColor: decoration.focusColor.toFlutterColor(), hoverColor: decoration.hoverColor.toFlutterColor(), errorBorder: getInputBorder(decoration.errorBorder), - focusedBorder: getInputBorder(decoration.focusedBorder), + focusedBorder: getInputBorder( + errorFlag && !hasError + ? decoration.focusedErrorBorder + : decoration.focusedBorder, + ), focusedErrorBorder: getInputBorder(decoration.focusedErrorBorder), disabledBorder: getInputBorder(decoration.disabledBorder), - enabledBorder: getInputBorder(decoration.enabledBorder), + enabledBorder: getInputBorder( + errorFlag && !hasError + ? decoration.errorBorder + : decoration.enabledBorder, + ), border: getInputBorder(decoration.border), enabled: decoration.enabled, semanticCounterText: decoration.semanticCounterText, diff --git a/lib/src/transformers/passive_transformer_manager.dart b/lib/src/transformers/passive_transformer_manager.dart index b977354e..1e73817e 100644 --- a/lib/src/transformers/passive_transformer_manager.dart +++ b/lib/src/transformers/passive_transformer_manager.dart @@ -62,6 +62,7 @@ class PassiveNodeTransformerManager extends WidgetNodeTransformerManager { 'badge': PassiveBadgeTransformer(getNode, this), 'chip': PassiveChipTransformer(getNode, this), 'segmentedButton': PassiveSegmentedButtonTransformer(getNode, this), + 'signature': PassiveSignatureTransformer(getNode, this), 'external': PassiveExternalComponentTransformer(getNode, this), }); } @@ -83,6 +84,27 @@ class PassiveNodeTransformerManager extends WidgetNodeTransformerManager { ); } + // A node that reads `parent` is built with the size its parent gives + // it, so its bindings can lay it out by that size. + if (readsParentSize(node)) { + return LayoutBuilder( + key: ValueKey('${node.id}-parent-size'), + builder: (context, constraints) => ParentSizeScope.fromConstraints( + constraints, + child: Builder( + builder: (context) => _buildNode(context, node, settings), + ), + ), + ); + } + return _buildNode(context, node, settings); + } + + Widget _buildNode( + BuildContext context, + BaseNode node, + WidgetBuildSettings settings, + ) { return _wrapWithListener( context, node: node, @@ -122,9 +144,11 @@ class PassiveNodeTransformerManager extends WidgetNodeTransformerManager { if (settings.withOpacity) { widget = applyWidgetOpacity(styled, widget); } + widget = applyLoopAnimation(context, styled, widget); if (settings.withReactions) { widget = wrapWithReaction(context, styled, widget); widget = wrapWithSemantics(styled, widget); + widget = wrapWithCursorReactions(context, styled, widget); widget = applyTooltipAndZoom(context, styled, widget); } if (settings.withRotation) { diff --git a/lib/src/transformers/transformers.dart b/lib/src/transformers/transformers.dart index a9e95794..81109f83 100644 --- a/lib/src/transformers/transformers.dart +++ b/lib/src/transformers/transformers.dart @@ -10,7 +10,9 @@ export 'utils/condition_visitors.dart'; export 'utils/dash_path.dart'; export 'utils/fit_layout.dart'; export 'utils/indexed_item_provider.dart'; +export 'utils/loop_animation_box.dart'; export 'utils/node_geometry.dart'; +export 'utils/parent_size.dart'; export 'utils/property_overrides.dart'; export 'utils/property_value_delegate.dart'; export 'utils/scoped_values.dart'; diff --git a/lib/src/transformers/utils/condition_evaluator.dart b/lib/src/transformers/utils/condition_evaluator.dart index 8f757e97..c66be679 100644 --- a/lib/src/transformers/utils/condition_evaluator.dart +++ b/lib/src/transformers/utils/condition_evaluator.dart @@ -201,6 +201,9 @@ class ConditionEvaluator @override bool visitIsEmptyOperator(Object? left) { + // An empty text variable reads as null (see _visitRawValue), and an unset + // one is null too: both are empty. + if (left == null) return true; if (left is List) return left.isEmpty; if (left is String) return left.isEmpty; if (left is Map) return left.isEmpty; diff --git a/lib/src/transformers/utils/loop_animation_box.dart b/lib/src/transformers/utils/loop_animation_box.dart new file mode 100644 index 00000000..ad97f2aa --- /dev/null +++ b/lib/src/transformers/utils/loop_animation_box.dart @@ -0,0 +1,150 @@ +import 'dart:async'; + +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:flutter/widgets.dart'; + +/// Runs a node's [LoopAnimation] on [child] over and over: it scales and +/// fades [child] for [LoopAnimationType.pulse], fades it for +/// [LoopAnimationType.fade], scales it for [LoopAnimationType.scale] and +/// rotates it for [LoopAnimationType.spin], about its center. +/// +/// The animation runs on this widget's ticker, so it holds still at its +/// `from` values under a disabled [TickerMode], as on the editor's design +/// canvas. When [enabled] is false, [child] shows as designed; the widgets +/// around it stay the same, so turning the animation on and off keeps the +/// state of [child]. +class LoopAnimationBox extends StatefulWidget { + /// The animation to run. + final LoopAnimation animation; + + /// Whether the animation runs. Defaults to [LoopAnimation.enabled]. + final bool? enabled; + + /// The widget to animate. + final Widget child; + + /// Creates a [LoopAnimationBox]. + const LoopAnimationBox({ + super.key, + required this.animation, + this.enabled, + required this.child, + }); + + /// The Flutter curve of [curve]. + static Curve curveOf(LoopAnimationCurve curve) => switch (curve) { + LoopAnimationCurve.linear => Curves.linear, + LoopAnimationCurve.easeIn => Curves.easeIn, + LoopAnimationCurve.easeOut => Curves.easeOut, + LoopAnimationCurve.easeInOut => Curves.easeInOut, + LoopAnimationCurve.fastOutSlowIn => Curves.fastOutSlowIn, + LoopAnimationCurve.decelerate => Curves.decelerate, + }; + + @override + State createState() => _LoopAnimationBoxState(); +} + +class _LoopAnimationBoxState extends State + with SingleTickerProviderStateMixin { + late final AnimationController controller = AnimationController(vsync: this); + + /// Waits for [LoopAnimation.delayMs] before the first cycle. + Timer? delay; + + late Animation scale; + late Animation opacity; + late Animation turns; + + bool get enabled => widget.enabled ?? widget.animation.enabled; + + @override + void initState() { + super.initState(); + update(); + } + + @override + void didUpdateWidget(covariant LoopAnimationBox oldWidget) { + super.didUpdateWidget(oldWidget); + final bool wasEnabled = oldWidget.enabled ?? oldWidget.animation.enabled; + if (oldWidget.animation != widget.animation || wasEnabled != enabled) { + update(); + } + } + + /// Builds the animations of [LoopAnimationBox.animation] and starts it + /// over, or stops it when it is disabled. + void update() { + delay?.cancel(); + delay = null; + controller.reset(); + + final LoopAnimation animation = widget.animation; + if (!enabled) { + scale = opacity = const AlwaysStoppedAnimation(1); + turns = const AlwaysStoppedAnimation(0); + return; + } + + final Animation curved = CurvedAnimation( + parent: controller, + curve: LoopAnimationBox.curveOf(animation.curve), + ); + final LoopAnimationType type = animation.type; + scale = type.scales + ? Tween( + begin: animation.scaleFrom, + end: animation.scaleTo, + ).animate(curved) + : const AlwaysStoppedAnimation(1); + opacity = type.fades + ? Tween( + begin: animation.opacityFrom.clamp(0, 1), + end: animation.opacityTo.clamp(0, 1), + ).animate(curved) + : const AlwaysStoppedAnimation(1); + turns = type == LoopAnimationType.spin + ? Tween(begin: 0, end: animation.turns).animate(curved) + : const AlwaysStoppedAnimation(0); + + controller.duration = Duration( + milliseconds: animation.durationMs.clamp(1, 1 << 31), + ); + if (animation.delayMs > 0) { + delay = Timer(Duration(milliseconds: animation.delayMs), start); + } else { + start(); + } + } + + void start() { + if (!mounted) return; + controller.repeat(reverse: widget.animation.reverse); + } + + @override + void dispose() { + delay?.cancel(); + controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + // Only the transitions of the animation's type wrap the child, so a + // node that just spins doesn't pay for an opacity layer. + Widget child = widget.child; + final LoopAnimationType type = widget.animation.type; + if (type == LoopAnimationType.spin) { + child = RotationTransition(turns: turns, child: child); + } + if (type.scales) { + child = ScaleTransition(scale: scale, child: child); + } + if (type.fades) { + child = FadeTransition(opacity: opacity, child: child); + } + return child; + } +} diff --git a/lib/src/transformers/utils/parent_size.dart b/lib/src/transformers/utils/parent_size.dart new file mode 100644 index 00000000..a35ce77a --- /dev/null +++ b/lib/src/transformers/utils/parent_size.dart @@ -0,0 +1,87 @@ +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:flutter/widgets.dart'; + +import '../../../codelessly_sdk.dart'; + +/// Gives the nodes below it the size their parent gives them, which the +/// `parent` variable reads: `${parent.width}` and `${parent.height}`. +/// +/// It is the space a node is laid out in: the width of a column's content +/// box for a child that fills it, the width of a frame's content box for a +/// child in it. An axis that is unbounded (a row's main axis for a child +/// that doesn't flex, a scroll view's scroll axis) has no value. +class ParentSizeScope extends InheritedWidget { + /// The width the parent gives, or null when it is unbounded. + final double? width; + + /// The height the parent gives, or null when it is unbounded. + final double? height; + + /// Creates a [ParentSizeScope]. + const ParentSizeScope({ + super.key, + required this.width, + required this.height, + required super.child, + }); + + /// A scope with the bounded sides of [constraints]. + factory ParentSizeScope.fromConstraints( + BoxConstraints constraints, { + required Widget child, + }) => ParentSizeScope( + width: constraints.hasBoundedWidth ? constraints.maxWidth : null, + height: constraints.hasBoundedHeight ? constraints.maxHeight : null, + child: child, + ); + + /// The value of the `parent` variable at [context], or null when no node + /// above reads it. + static Map? valueOf(BuildContext context) { + final ParentSizeScope? scope = context + .dependOnInheritedWidgetOfExactType(); + if (scope == null) return null; + return parentVariableValue(scope.width, scope.height); + } + + @override + bool updateShouldNotify(ParentSizeScope oldWidget) => + width != oldWidget.width || height != oldWidget.height; +} + +/// The value of the `parent` variable for a parent that gives [width] and +/// [height]: `width` and `height`, each null when unbounded. +Map parentVariableValue(double? width, double? height) => { + 'width': width, + 'height': height, +}; + +/// Whether [node] reads the `parent` variable in a binding, a formula or an +/// inline `${...}` text, so it is built inside a [ParentSizeScope]. +bool readsParentSize(BaseNode node) { + bool isParent(String path) => + VariableMatch.parse(path.wrapWithVariableSyntax())?.name == 'parent'; + for (final String path in node.variables.values) { + final String? expression = FormulaEvaluator.exactExpression(path); + if (expression != null) { + if (FormulaEvaluator.referencedPaths(expression).any(isParent)) { + return true; + } + } else if (isParent(path)) { + return true; + } + } + for (final List paths in node.multipleVariables.values) { + if (paths.any(isParent)) return true; + } + for (final String template in inlineTextTemplatesOf(node)) { + if (FormulaEvaluator.referencedPathsInText(template).any(isParent)) { + return true; + } + for (final RegExpMatch match in variablePathRegex.allMatches(template)) { + final String? path = match.namedGroup('value'); + if (path != null && isParent(path)) return true; + } + } + return false; +} diff --git a/lib/src/transformers/utils/property_overrides.dart b/lib/src/transformers/utils/property_overrides.dart index e6e2e10e..66f9ca48 100644 --- a/lib/src/transformers/utils/property_overrides.dart +++ b/lib/src/transformers/utils/property_overrides.dart @@ -67,8 +67,16 @@ bool usesInteractionStates(BaseNode node) => /// states: puts the overrides of every active state (in /// [InteractionState] order, the later winning) in a [PropertyOverrideScope] /// over [builder]. A disabled node ignores the pointer, so its actions do -/// not run. +/// not run, and the cursor over it is the basic one. +/// +/// A node disabled without a [InteractionState.disabled] state of its own +/// fades to [defaultDisabledOpacity], so binding `variables['disabled']` +/// (e.g. to `${= busy != ""}`) is enough to dim it. A node with that state +/// shows the state's overrides instead. class InteractionStatesBox extends StatefulWidget { + /// The opacity of a disabled node that has no disabled state of its own. + static const double defaultDisabledOpacity = 0.4; + /// The node whose states are shown. final BaseNode node; @@ -176,6 +184,14 @@ class _InteractionStatesBoxState extends State { // A disabled node runs no actions. Always wrapped so that switching it // keeps the state below. child = IgnorePointer(ignoring: disabled, child: child); - return child; + // Without a look of its own for the disabled state, a disabled node + // fades. Always wrapped, like the IgnorePointer. + final bool dims = + disabled && !states.containsKey(InteractionState.disabled); + child = Opacity( + opacity: dims ? InteractionStatesBox.defaultDisabledOpacity : 1, + child: child, + ); + return Semantics(enabled: disabled ? false : null, child: child); } } diff --git a/lib/src/transformers/utils/property_value_delegate.dart b/lib/src/transformers/utils/property_value_delegate.dart index d955a280..ee3b17bd 100644 --- a/lib/src/transformers/utils/property_value_delegate.dart +++ b/lib/src/transformers/utils/property_value_delegate.dart @@ -442,6 +442,7 @@ class PropertyValueDelegate { 'auth' => scopedValues.appUser, 'result' => ActionChain.current?.result, 'screen' => scopedValues.screenSize?.let(screenVariableValue), + 'parent' => scopedValues.parentSize, _ => null, }; @@ -929,7 +930,7 @@ List getAccessorsForType(VariableType type) { name: 'isEmpty', type: VariableType.boolean, getValue: (value) { - final newValue = value?.toMap(); + final newValue = value?.toList(); if (newValue == null) return null; return newValue.isEmpty; }, @@ -938,7 +939,7 @@ List getAccessorsForType(VariableType type) { name: 'isNotEmpty', type: VariableType.boolean, getValue: (value) { - final newValue = value?.toMap(); + final newValue = value?.toList(); if (newValue == null) return null; return newValue.isNotEmpty; }, diff --git a/lib/src/transformers/utils/scoped_values.dart b/lib/src/transformers/utils/scoped_values.dart index 25d85b63..99d03659 100644 --- a/lib/src/transformers/utils/scoped_values.dart +++ b/lib/src/transformers/utils/scoped_values.dart @@ -31,6 +31,10 @@ class ScopedValues { /// The size of the screen, which the `screen` variable describes. late final Size? screenSize; + /// The value of the `parent` variable: the `width` and `height` the + /// node's parent gives it (see [ParentSizeScope]). + late final Map? parentSize; + // private fields. late final WeakReference? _codelesslyContextRef; // The Codelessly instance, read live: its cloud database can become @@ -77,7 +81,8 @@ class ScopedValues { nodeState = null, routeParams = {}, _appUser = null, - screenSize = null; + screenSize = null, + parentSize = null; /// Creates scoped values for a design canvas, where [variables] (by name) /// hold their default values and nothing is read from a running app. @@ -88,6 +93,7 @@ class ScopedValues { this.indexedItem, this._appUser, this.screenSize, + this.parentSize, Map? data, }) : _codelesslyContextRef = codelesslyContext == null ? null @@ -113,6 +119,7 @@ class ScopedValues { : WeakReference(codelesslyContext); indexedItem = IndexedItemProvider.maybeOf(context); screenSize = MediaQuery.maybeSizeOf(context); + parentSize = ParentSizeScope.valueOf(context); nodeState = NodeStateProvider.maybeOf(context)?.state; final instance = context.read(); // Screens the app navigates to carry their parameters; the first screen diff --git a/lib/src/transformers/utils/variable_span.dart b/lib/src/transformers/utils/variable_span.dart index 868654be..aeae73da 100644 --- a/lib/src/transformers/utils/variable_span.dart +++ b/lib/src/transformers/utils/variable_span.dart @@ -48,11 +48,13 @@ abstract final class VariablePreview { IndexedItem? item, }) { ScopedValues? scopedValues = scopeResolver?.call(node); - if (scopedValues != null && item != null) { + 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, ); } @@ -148,7 +150,8 @@ class VariableSpan extends TextSpan { /// The variable expression, such as `${user.name}`. final String variable; - /// Creates a [VariableSpan] for the [variable] expression of [node]. + /// Creates a [VariableSpan] for the [variable] expression of [node], + /// built in the list, grid or page [item] if any. 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 74083b90..37b76831 100644 --- a/lib/src/transformers/utils/viewport_sizing.dart +++ b/lib/src/transformers/utils/viewport_sizing.dart @@ -4,6 +4,8 @@ import 'package:codelessly_api/codelessly_api.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/widgets.dart'; +import 'parent_size.dart'; + /// Builds a scroll view. [shrinkWrap] says whether the view must size itself /// to its items on its scroll axis. typedef ViewportContentBuilder = Widget Function( @@ -224,82 +226,6 @@ class ListItemBox extends StatelessWidget { } } -/// A grid delegate for a fixed cross axis count that keeps cells at least as -/// wide as the editor drew them. -/// -/// The editor divides the grid's design cross extent by [crossAxisCount]. A -/// plain [SliverGridDelegateWithFixedCrossAxisCount] divides the runtime -/// extent instead, so a grid tuned to 4 columns at 1440 gets 90 px cells at -/// 390 and its items, laid out for ~350 px, overflow. This delegate fits as -/// many cells of [designCellExtent] (plus spacing) as the runtime cross -/// extent holds, at least one, and shares the extent between them. At the -/// design size that is exactly [crossAxisCount]; narrower screens get fewer -/// columns and wider ones more, and a cell is never narrower than designed -/// unless the grid itself is. -class DesignCellGridDelegate extends SliverGridDelegate { - /// The column count at the design size. - final int crossAxisCount; - - /// The cross extent of one cell at the design size. Zero or less falls - /// back to [crossAxisCount] at every size. - final double designCellExtent; - - /// See [SliverGridDelegateWithFixedCrossAxisCount.mainAxisSpacing]. - final double mainAxisSpacing; - - /// See [SliverGridDelegateWithFixedCrossAxisCount.crossAxisSpacing]. - final double crossAxisSpacing; - - /// See [SliverGridDelegateWithFixedCrossAxisCount.childAspectRatio]. - final double childAspectRatio; - - /// See [SliverGridDelegateWithFixedCrossAxisCount.mainAxisExtent]. - final double? mainAxisExtent; - - /// Creates a [DesignCellGridDelegate]. - const DesignCellGridDelegate({ - required this.crossAxisCount, - required this.designCellExtent, - this.mainAxisSpacing = 0, - this.crossAxisSpacing = 0, - this.childAspectRatio = 1, - this.mainAxisExtent, - }); - - /// The column count for a runtime cross extent of [crossAxisExtent]. - int crossAxisCountFor(double crossAxisExtent) { - if (!(designCellExtent > 0) || !crossAxisExtent.isFinite) { - return math.max(1, crossAxisCount); - } - // The epsilon keeps the design size at exactly [crossAxisCount] despite - // rounding in the design cell extent. - final double fit = - (crossAxisExtent + crossAxisSpacing) / - (designCellExtent + crossAxisSpacing); - return math.max(1, (fit + 1e-6).floor()); - } - - @override - SliverGridLayout getLayout(SliverConstraints constraints) { - return SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: crossAxisCountFor(constraints.crossAxisExtent), - mainAxisSpacing: mainAxisSpacing, - crossAxisSpacing: crossAxisSpacing, - childAspectRatio: childAspectRatio, - mainAxisExtent: mainAxisExtent, - ).getLayout(constraints); - } - - @override - bool shouldRelayout(DesignCellGridDelegate oldDelegate) => - oldDelegate.crossAxisCount != crossAxisCount || - oldDelegate.designCellExtent != designCellExtent || - oldDelegate.mainAxisSpacing != mainAxisSpacing || - oldDelegate.crossAxisSpacing != crossAxisSpacing || - oldDelegate.childAspectRatio != childAspectRatio || - oldDelegate.mainAxisExtent != mainAxisExtent; -} - /// 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] @@ -319,6 +245,10 @@ bool subtreeBlocksIntrinsics(BaseNode node, GetNode getNode) { if (node is ScrollableMixin && node is! CanvasNode && node.isScrollable) { return true; } + // Scaled content and nodes that read their parent's size are built with + // a LayoutBuilder. + if (node is FrameNode && node.contentFit != null) return true; + if (readsParentSize(node)) return true; if (node is ChildrenMixin) { for (final String id in node.children) { if (subtreeBlocksIntrinsics(getNode(id), getNode)) return true; diff --git a/lib/src/transformers/widget_node_transformer_manager.dart b/lib/src/transformers/widget_node_transformer_manager.dart index 87c40bd2..700d751f 100644 --- a/lib/src/transformers/widget_node_transformer_manager.dart +++ b/lib/src/transformers/widget_node_transformer_manager.dart @@ -59,6 +59,32 @@ abstract class WidgetNodeTransformerManager return Opacity(opacity: opacity, child: widget); } + /// Runs the [BaseNode.loopAnimation] of [node] on [widget], if it has one. + /// + /// A `loopAnimationEnabled` condition or variable turns it on and off at + /// runtime; otherwise [LoopAnimation.enabled] does. The animation holds + /// still under a disabled [TickerMode], as on the editor's design canvas. + Widget applyLoopAnimation( + BuildContext context, + BaseNode node, + Widget widget, { + ScopedValues? scopedValues, + }) { + final LoopAnimation? animation = node.loopAnimation; + if (animation == null) return widget; + final bool? enabled = PropertyValueDelegate.getPropertyValue( + node, + 'loopAnimationEnabled', + scopedValues: scopedValues ?? ScopedValues.of(context), + ); + return LoopAnimationBox( + key: ValueKey('loop animation ${node.id}'), + animation: animation, + enabled: enabled, + child: widget, + ); + } + /// Returns the visibility a condition or variable gives [node] at runtime, /// or null when nothing overrides the node's own visibility. /// @@ -293,7 +319,7 @@ abstract class WidgetNodeTransformerManager if (onClickReactions.isEmpty && onLongPressReactions.isEmpty) { return widget; } - // TODO: should handle TriggerType.hover and TriggerType.unhover too. + // Cursor triggers are handled by [wrapWithCursorReactions]. return MouseRegion( cursor: SystemMouseCursors.click, hitTestBehavior: HitTestBehavior.opaque, @@ -317,6 +343,62 @@ abstract class WidgetNodeTransformerManager } } + /// Runs the [TriggerType.cursorEnter], [TriggerType.cursorExit] and + /// [TriggerType.hover] reactions of [node] when a mouse cursor enters, + /// leaves or moves over it. + /// + /// The region covers the node's whole box, transparent parts included, and + /// lets the nodes behind it get the cursor and taps too. Touch input fires + /// none of these triggers. + Widget wrapWithCursorReactions( + BuildContext context, + BaseNode node, + Widget widget, + ) { + if (node is! ReactionMixin) return widget; + if (node case CanvasNode() || SpacerNode()) return widget; + + List reactionsOf(TriggerType type) => (node as ReactionMixin) + .reactions + .where( + (reaction) => + reaction.trigger.type == type && reaction.action.enabled, + ) + .toList(); + + final List onEnter = reactionsOf(TriggerType.cursorEnter); + final List onExit = reactionsOf(TriggerType.cursorExit); + final List onHover = reactionsOf(TriggerType.hover); + if (onEnter.isEmpty && onExit.isEmpty && onHover.isEmpty) return widget; + + return MouseRegion( + opaque: false, + hitTestBehavior: HitTestBehavior.translucent, + onEnter: onEnter.isEmpty + ? null + : (_) => FunctionsRepository.triggerAction( + context, + TriggerType.cursorEnter, + reactions: onEnter, + ), + onExit: onExit.isEmpty + ? null + : (_) => FunctionsRepository.triggerAction( + context, + TriggerType.cursorExit, + reactions: onExit, + ), + onHover: onHover.isEmpty + ? null + : (_) => FunctionsRepository.triggerAction( + context, + TriggerType.hover, + reactions: onHover, + ), + child: widget, + ); + } + BorderRadius? getBorderRadius(BaseNode node) { if (node is CornerMixin) { if (node.cornerRadius.linked && diff --git a/lib/src/ui/codelessly_dialog_widget.dart b/lib/src/ui/codelessly_dialog_widget.dart index e9af3071..7c0ccc68 100644 --- a/lib/src/ui/codelessly_dialog_widget.dart +++ b/lib/src/ui/codelessly_dialog_widget.dart @@ -11,11 +11,17 @@ class CodelesslyDialogWidget extends StatelessWidget { /// or anywhere else, e.g. a menu at the top right. final AlignmentGeometry alignment; + /// Whether the dialog is only as tall as its canvas, as a bottom sheet is, + /// so the space above it stays the sheet's scrim (a tap there dismisses + /// it). A centered dialog fills the screen to place its canvas. + final bool hugHeight; + const CodelesslyDialogWidget({ super.key, required this.builder, required this.showCloseButton, this.alignment = Alignment.center, + this.hugHeight = false, }); @override @@ -32,6 +38,7 @@ class CodelesslyDialogWidget extends StatelessWidget { if (showCloseButton) { return Align( alignment: alignment, + heightFactor: hugHeight ? 1 : null, child: Material( type: MaterialType.transparency, child: Stack( @@ -55,6 +62,10 @@ class CodelesslyDialogWidget extends StatelessWidget { ); } - return Align(alignment: alignment, child: builder(context)); + return Align( + alignment: alignment, + heightFactor: hugHeight ? 1 : null, + child: builder(context), + ); } } diff --git a/lib/src/ui/layout_presentation_route.dart b/lib/src/ui/layout_presentation_route.dart new file mode 100644 index 00000000..0a759ed6 --- /dev/null +++ b/lib/src/ui/layout_presentation_route.dart @@ -0,0 +1,331 @@ +import 'dart:math'; + +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:flutter/material.dart'; + +import '../../codelessly_sdk.dart'; +import 'codelessly_dialog_widget.dart'; + +/// The route a show-dialog action presents its canvas in: a centered dialog, +/// a bottom sheet or a side sheet (see [DialogPresentation]). +/// +/// Like a dialog route it has a barrier that [barrierDismissible] lets the +/// user tap to close it, and a "Navigate back" in the canvas pops it with its +/// params as the result. +class LayoutPresentationRoute extends PopupRoute { + /// How the canvas is presented. + final DialogPresentation presentation; + + /// Builds the canvas. + final WidgetBuilder builder; + + /// Whether to show a close button in the corner. + final bool showCloseButton; + + /// Width of a side sheet. Null takes [designWidth]. + final double? sheetWidth; + + /// The width the destination canvas was designed at. + final double? designWidth; + + /// The most of the screen's height a bottom sheet takes. + final double maxHeightFactor; + + /// Whether a bottom sheet shows a grab handle. + final bool showDragHandle; + + final CapturedThemes? _themes; + + /// Creates a [LayoutPresentationRoute]. + LayoutPresentationRoute({ + required DialogPresentation presentation, + required this.builder, + required BuildContext context, + this.showCloseButton = false, + this.sheetWidth, + this.designWidth, + this.maxHeightFactor = 1, + this.showDragHandle = false, + this.barrierDismissible = true, + this.barrierColor = Colors.black54, + super.settings, + }) : presentation = presentation.resolveFor(context), + _barrierLabel = MaterialLocalizations.of(context) + .modalBarrierDismissLabel, + _themes = InheritedTheme.capture( + from: context, + to: Navigator.of(context).context, + ); + + /// How long the canvas takes to come in. + static const Duration enterDuration = Duration(milliseconds: 320); + + /// How long the canvas takes to go away. + static const Duration exitDuration = Duration(milliseconds: 200); + + /// The width of a side sheet when neither [sheetWidth] nor [designWidth] + /// is known. + static const double defaultSheetWidth = 400; + + /// Corner radius of a bottom sheet's top corners. + static const double bottomSheetRadius = 10; + + /// Size of a bottom sheet's grab handle. + static const Size dragHandleSize = Size(40, 4); + + /// Distance of a bottom sheet's grab handle from its top edge. + static const double dragHandleTop = 8; + + /// Color of a bottom sheet's grab handle. + static const Color dragHandleColor = Color(0xFFA9ADA5); + + @override + final bool barrierDismissible; + + @override + final Color? barrierColor; + + final String _barrierLabel; + + @override + String? get barrierLabel => _barrierLabel; + + @override + Duration get transitionDuration => enterDuration; + + @override + Duration get reverseTransitionDuration => exitDuration; + + @override + Widget buildPage( + BuildContext context, + Animation animation, + Animation secondaryAnimation, + ) { + final Widget page = Semantics( + scopesRoute: true, + explicitChildNodes: true, + child: switch (presentation) { + DialogPresentation.dialog => SafeArea( + child: CodelesslyDialogWidget( + showCloseButton: showCloseButton, + builder: builder, + ), + ), + DialogPresentation.bottomSheet || + DialogPresentation.adaptive => _BottomSheet( + maxHeightFactor: maxHeightFactor, + showDragHandle: showDragHandle, + showCloseButton: showCloseButton, + builder: builder, + ), + DialogPresentation.sideSheet => _SideSheet( + width: sheetWidth ?? designWidth ?? defaultSheetWidth, + showCloseButton: showCloseButton, + builder: builder, + ), + }, + ); + return _themes?.wrap(page) ?? page; + } + + @override + Widget buildTransitions( + BuildContext context, + Animation animation, + Animation secondaryAnimation, + Widget child, + ) { + final Animation curved = CurvedAnimation( + parent: animation, + curve: Curves.easeOutCubic, + reverseCurve: Curves.easeInCubic, + ); + return switch (presentation) { + DialogPresentation.dialog => FadeTransition( + opacity: curved, + child: ScaleTransition( + scale: Tween(begin: 0.96, end: 1).animate(curved), + child: child, + ), + ), + DialogPresentation.bottomSheet || + DialogPresentation.adaptive => SlideTransition( + position: Tween( + begin: const Offset(0, 1), + end: Offset.zero, + ).animate(curved), + child: child, + ), + DialogPresentation.sideSheet => SlideTransition( + position: Tween( + begin: const Offset(1, 0), + end: Offset.zero, + ).animate(curved), + child: child, + ), + }; + } +} + +/// A close button in the top right corner of a dialog or sheet. +class _CloseButton extends StatelessWidget { + const _CloseButton(); + + @override + Widget build(BuildContext context) => Positioned( + top: 8, + right: 8, + child: Material( + type: MaterialType.transparency, + child: IconButton( + onPressed: () => Navigator.maybePop(context), + splashRadius: 16, + icon: const Icon(Icons.close), + ), + ), + ); +} + +/// A canvas anchored to the bottom of the screen, as wide as the screen and +/// as tall as the canvas, up to [maxHeightFactor] of the screen. +class _BottomSheet extends StatelessWidget { + final double maxHeightFactor; + final bool showDragHandle; + final bool showCloseButton; + final WidgetBuilder builder; + + const _BottomSheet({ + required this.maxHeightFactor, + required this.showDragHandle, + required this.showCloseButton, + required this.builder, + }); + + @override + Widget build(BuildContext context) { + final MediaQueryData media = MediaQuery.of(context); + // The keyboard pushes the sheet up. + final double keyboard = media.viewInsets.bottom; + final double maxHeight = + max(0, media.size.height - keyboard) * maxHeightFactor.clamp(0, 1); + return Padding( + padding: EdgeInsets.only(bottom: keyboard), + child: Align( + alignment: Alignment.bottomCenter, + child: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: media.size.width, + maxHeight: maxHeight, + ), + child: ClipRRect( + key: const ValueKey('Codelessly bottom sheet'), + borderRadius: const BorderRadius.vertical( + top: Radius.circular(LayoutPresentationRoute.bottomSheetRadius), + ), + child: MediaQuery.removePadding( + context: context, + removeTop: true, + child: MediaQuery.removeViewInsets( + context: context, + removeBottom: true, + child: CanvasSizingScope( + sizing: CanvasSizing.fillWidth, + child: Stack( + children: [ + Builder(builder: builder), + if (showDragHandle) + const Positioned( + top: LayoutPresentationRoute.dragHandleTop, + left: 0, + right: 0, + child: Center(child: _DragHandle()), + ), + if (showCloseButton) const _CloseButton(), + ], + ), + ), + ), + ), + ), + ), + ), + ); + } +} + +/// The grab handle at the top of a bottom sheet. +class _DragHandle extends StatelessWidget { + const _DragHandle(); + + @override + Widget build(BuildContext context) => Container( + key: const ValueKey('Codelessly sheet drag handle'), + width: LayoutPresentationRoute.dragHandleSize.width, + height: LayoutPresentationRoute.dragHandleSize.height, + decoration: BoxDecoration( + color: LayoutPresentationRoute.dragHandleColor, + borderRadius: BorderRadius.circular(2), + ), + ); +} + +/// A canvas anchored to the right of the screen, as tall as the screen and +/// [width] wide. +class _SideSheet extends StatelessWidget { + final double width; + final bool showCloseButton; + final WidgetBuilder builder; + + const _SideSheet({ + required this.width, + required this.showCloseButton, + required this.builder, + }); + + @override + Widget build(BuildContext context) { + final Size screen = MediaQuery.sizeOf(context); + return Align( + alignment: Alignment.centerRight, + child: DecoratedBox( + key: const ValueKey('Codelessly side sheet'), + decoration: const BoxDecoration( + boxShadow: [ + BoxShadow( + color: Color(0x33000000), + blurRadius: 24, + offset: Offset(-4, 0), + ), + ], + ), + child: SizedBox( + width: min(width, screen.width), + height: screen.height, + child: CanvasSizingScope( + sizing: CanvasSizing.fill, + child: Stack( + fit: StackFit.expand, + children: [ + Builder(builder: builder), + if (showCloseButton) const _CloseButton(), + ], + ), + ), + ), + ), + ); + } +} + +/// Resolves [DialogPresentation.adaptive] for the screen. +extension DialogPresentationResolve on DialogPresentation { + /// A bottom sheet on phones (narrower than 600 px) and a dialog otherwise + /// for [DialogPresentation.adaptive]; any other presentation as is. + DialogPresentation resolveFor(BuildContext context) => + this == DialogPresentation.adaptive + ? (MediaQuery.maybeSizeOf(context)?.width ?? 1024) < 600 + ? DialogPresentation.bottomSheet + : DialogPresentation.dialog + : this; +} diff --git a/lib/src/utils/formula.dart b/lib/src/utils/formula.dart index bde71f5c..ae0b91e3 100644 --- a/lib/src/utils/formula.dart +++ b/lib/src/utils/formula.dart @@ -43,7 +43,10 @@ typedef FormulaPathResolver = Object? Function(String path); /// 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. +/// function arguments. A lambda can take a second parameter for the +/// item's index: `map(steps, (s, i) => i == length(steps) - 1)`. +/// - 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'. @@ -175,6 +178,17 @@ class FormulaEvaluator { /// How to call each function, with an example, for editors to list. static const List functionDocs = [ + FormulaFunctionDoc('left', 'left(text, count)', 'left("MX4821", 2) → MX'), + FormulaFunctionDoc( + 'right', + 'right(text, count)', + 'right("1FUJGLDR5MX4821", 6) → MX4821', + ), + FormulaFunctionDoc( + 'toMap', + 'toMap(list, item => key, item => value)', + 'toMap(items, i => i.id, i => "pass") → {1a: pass, 1b: pass}', + ), FormulaFunctionDoc( 'money', 'money(value, symbol?, digits?)', @@ -276,7 +290,7 @@ class FormulaEvaluator { ), FormulaFunctionDoc( 'map', - 'map(list, field or item => value)', + 'map(list, field or item => value or (item, index) => value)', 'map(cart, i => i.price * i.qty) → [8, 16]', ), FormulaFunctionDoc( @@ -664,15 +678,16 @@ num _round(Object? value, Object? digits) { Iterable _values(Object? list, Object? selector) { if (list is! Iterable) return const []; if (selector == null) return list; - return list.map((item) => _select(item, selector)); + return list.indexed.map((entry) => _select(entry.$2, selector, entry.$1)); } /// [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. -Object? _select(Object? item, Object? selector) { +/// dots, such as `author.name`, reads nested fields. A lambda with a second +/// parameter gets [index], the item's position in its list. +Object? _select(Object? item, Object? selector, [int? index]) { if (selector == null) return item; - if (selector is _Closure) return selector(item); + if (selector is _Closure) return selector(item, index); final Object? value = _member(item, selector); if (value != null || selector is! String || !selector.contains('.')) { return value; @@ -683,14 +698,16 @@ Object? _select(Object? item, Object? selector) { /// Whether [item] matches a predicate given as function arguments [rest]: /// `(lambda)` or `(field)` match when truthy, `(lambda or field, value)` /// when the selected value equals value. No predicate matches truthy items. -bool _matches(Object? item, List rest) => rest.length < 2 - ? _truthy(_select(item, _arg(rest, 0))) - : _looseEquals(_select(item, rest[0]), rest[1]); +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]` 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).where((item) => _matches(item, rest)); + return _values(_arg(args, 0), null).indexed + .where((entry) => _matches(entry.$2, rest, entry.$1)) + .map((entry) => entry.$2); } /// Items of `args[0]` sorted by the key `args[1]`, descending when `args[2]` @@ -704,7 +721,7 @@ List _sortBy(List args) { }; final List<(int, Object?, Object?)> keyed = [ for (int i = 0; i < items.length; i++) - (i, items[i], _select(items[i], selector)), + (i, items[i], _select(items[i], selector, i)), ]; keyed.sort((x, y) { final bool xMissing = x.$3 == null || x.$3 == ''; @@ -724,8 +741,9 @@ 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)); + final Object? key = _select(item, _arg(args, 1), index++); if (keys.any((seen) => _looseEquals(seen, key))) continue; keys.add(key); items.add(item); @@ -733,17 +751,20 @@ List _unique(List args) { return items; } -/// A lambda such as `t => t.price`, ready to call with an item. +/// 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]. class _Closure { final String param; + final String? indexParam; final _Node body; final FormulaPathResolver outer; - const _Closure(this.param, this.body, this.outer); + const _Closure(this.param, this.indexParam, this.body, this.outer); static final RegExp _part = RegExp(r'\.([a-zA-Z_][a-zA-Z0-9_]*)|\[(\d+)\]'); - Object? call(Object? value) => body.eval((path) { + 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) || @@ -1029,6 +1050,49 @@ 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. +Map _toMap(List args) { + final Object? list = _arg(args, 0); + if (list is! Iterable) return {}; + final Object? keySelector = _arg(args, 1); + final Object? valueSelector = _arg(args, 2); + final Map map = {}; + int index = 0; + for (final Object? item in list) { + final Object? key = _select(item, keySelector, index); + if (key != null) { + map[FormulaEvaluator.stringify(key)] = _select( + item, + valueSelector, + index, + ); + } + index++; + } + return map; +} + final Map _functions = { 'money': (a) => _money(_arg(a, 0), _arg(a, 1), _arg(a, 2)), 'formatNumber': (a) => _grouped(_arg(a, 0), _arg(a, 1)), @@ -1095,7 +1159,10 @@ final Map _functions = { 'any': (a) => _matching(a).isNotEmpty, 'all': (a) { final List rest = a.skip(1).toList(); - return _values(_arg(a, 0), null).every((item) => _matches(item, rest)); + return _values( + _arg(a, 0), + null, + ).indexed.every((entry) => _matches(entry.$2, rest, entry.$1)); }, 'map': (a) => _values(_arg(a, 0), _arg(a, 1)).toList(), 'sortBy': _sortBy, @@ -1140,10 +1207,14 @@ final Map _functions = { 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; - return items.indexWhere( - (item) => - predicate ? _matches(item, rest) : _looseEquals(item, _arg(rest, 0)), - ); + for (int i = 0; i < items.length; i++) { + if (predicate + ? _matches(items[i], rest, i) + : _looseEquals(items[i], _arg(rest, 0))) { + return i; + } + } + return -1; }, 'object': (a) => { for (int i = 0; i + 1 < a.length; i += 2) @@ -1212,6 +1283,12 @@ final Map _functions = { ); return '${n < 0 ? '-' : ''}$whole${parts.length > 1 ? '.${parts[1]}' : ''}'; }, + 'left': (a) => _substring(_arg(a, 0), 0, _count(_arg(a, 1))), + 'right': (a) { + final int n = _count(_arg(a, 1)); + return n <= 0 ? '' : _substring(_arg(a, 0), -n, null); + }, + 'toMap': _toMap, 'number': (a) => _toNum(_arg(a, 0)), 'text': (a) => FormulaEvaluator.stringify(_arg(a, 0)), 'isEmpty': (a) => !_truthy(_functions['length']!(a)), @@ -1397,12 +1474,14 @@ class _Call extends _Node { /// functions call once per item. class _Lambda extends _Node { final String param; + final String? indexParam; final _Node body; - const _Lambda(this.param, this.body); + const _Lambda(this.param, this.body, [this.indexParam]); @override - Object? eval(FormulaPathResolver resolve) => _Closure(param, body, resolve); + Object? eval(FormulaPathResolver resolve) => + _Closure(param, indexParam, body, resolve); /// The body's paths, except those of the parameter, which aren't /// variables. @@ -1414,6 +1493,7 @@ class _Lambda extends _Node { bodyPaths.where( (path) => path != param && + path != indexParam && !path.startsWith('$param.') && !path.startsWith('$param['), ), @@ -1523,7 +1603,8 @@ 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*\))\s*=>', + 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*=>', ); _Node parseAll() { @@ -1558,13 +1639,14 @@ class _Parser { } /// A function argument: an expression, or a lambda `item => expression` - /// (also written `(item) => expression`). + /// (also written `(item) => expression`, or `(item, index) => expression` + /// to read the item's index too). _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()); + return _Lambda(head[1] ?? head[2]!, _ternary(), head[3]); } _Node _ternary() { diff --git a/lib/src/utils/utils.dart b/lib/src/utils/utils.dart index 6a8d9b0f..bf9e1828 100644 --- a/lib/src/utils/utils.dart +++ b/lib/src/utils/utils.dart @@ -17,6 +17,7 @@ const Set predefinedVariableNames = { 'auth', 'result', 'screen', + 'parent', }; /// The key under which a layout's published variables hold the project's @@ -52,6 +53,7 @@ final Set predefinedVariables = { PredefinedVariableData(name: 'auth', type: VariableType.map), PredefinedVariableData(name: 'result'), PredefinedVariableData(name: 'screen', type: VariableType.map), + PredefinedVariableData(name: 'parent', type: VariableType.map), }; /// Widths at which the `screen` variable's breakpoint changes: below 600 is diff --git a/test/condition_is_empty_test.dart b/test/condition_is_empty_test.dart new file mode 100644 index 00000000..f8780b94 --- /dev/null +++ b/test/condition_is_empty_test.dart @@ -0,0 +1,74 @@ +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:codelessly_sdk/codelessly_sdk.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// isEmpty / isNotEmpty on text variables, the way a form validates its +/// error messages before submitting. +void main() { + bool check(Map vars, String path, String operator) { + final BaseExpression expression = BaseExpression.fromJson({ + 'type': 'Expression', + 'leftPart': {'type': 'Variable', 'valueString': '\${$path}'}, + 'operator': operator, + 'rightPart': {'type': 'RawValue', 'value': ''}, + }); + return expression.accept( + ConditionEvaluator( + scopedValues: ScopedValues.preview( + variables: { + for (final MapEntry(:key, :value) in vars.entries) + key: VariableData(id: key, name: key, value: value), + }, + ), + ), + ) == + true; + } + + test('an empty text variable is empty', () { + expect(check({'error': ''}, 'error', 'isEmpty'), isTrue); + expect(check({'error': ''}, 'error', 'isNotEmpty'), isFalse); + }); + + test('a text variable with a value is not empty', () { + expect(check({'error': 'Incorrect PIN'}, 'error', 'isEmpty'), isFalse); + expect(check({'error': 'Incorrect PIN'}, 'error', 'isNotEmpty'), isTrue); + }); + + test('a missing variable is empty', () { + expect(check({}, 'missing', 'isEmpty'), isTrue); + expect(check({}, 'missing', 'isNotEmpty'), isFalse); + }); + + test('both errors empty, joined with and', () { + final BaseExpression expression = BaseExpression.fromJson({ + 'type': 'ExpressionGroup', + 'join': 'and', + 'leftExpression': { + 'type': 'Expression', + 'leftPart': {'type': 'Variable', 'valueString': r'${idError}'}, + 'operator': 'isEmpty', + 'rightPart': {'type': 'RawValue', 'value': ''}, + }, + 'rightExpression': { + 'type': 'Expression', + 'leftPart': {'type': 'Variable', 'valueString': r'${pinError}'}, + 'operator': 'isEmpty', + 'rightPart': {'type': 'RawValue', 'value': ''}, + }, + }); + expect( + expression.accept( + ConditionEvaluator( + scopedValues: ScopedValues.preview( + variables: { + 'idError': VariableData(id: 'a', name: 'idError', value: ''), + 'pinError': VariableData(id: 'b', name: 'pinError', value: ''), + }, + ), + ), + ), + isTrue, + ); + }); +} diff --git a/test/confirm_dialog_test.dart b/test/confirm_dialog_test.dart index c30a88ed..9d21b773 100644 --- a/test/confirm_dialog_test.dart +++ b/test/confirm_dialog_test.dart @@ -1,5 +1,6 @@ import 'package:codelessly_api/codelessly_api.dart'; import 'package:codelessly_sdk/codelessly_sdk.dart'; +import 'package:codelessly_sdk/src/ui/layout_presentation_route.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -59,4 +60,122 @@ void main() { expect(find.text('Leave album?'), findsOneWidget); expect(find.text('You can join again later.'), findsOneWidget); }); + + Future open( + WidgetTester tester, + ConfirmAction action, + Size size, + ) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = size; + addTearDown(tester.view.reset); + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) => TextButton( + onPressed: () => AppConfirmDialog.show(context, action), + child: const Text('Open'), + ), + ), + ), + ); + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + } + + final Finder bottomSheet = find.byKey( + const ValueKey('Codelessly bottom sheet'), + ); + final Finder sideSheet = find.byKey(const ValueKey('Codelessly side sheet')); + + testWidgets('the built-in confirm can be a bottom sheet', (tester) async { + bool? result; + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(390, 844); + addTearDown(tester.view.reset); + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) => TextButton( + onPressed: () async => result = await AppConfirmDialog.show( + context, + action.copyWith(presentation: DialogPresentation.bottomSheet), + ), + child: const Text('Open'), + ), + ), + ), + ); + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + expect(find.byType(Dialog), findsNothing); + final Rect sheet = tester.getRect(bottomSheet); + expect(sheet.left, 0); + expect(sheet.width, 390); + expect(sheet.bottom, 844); + expect(sheet.height, lessThan(400)); + expect(find.text('Leave album?'), findsOneWidget); + + await tester.tap(find.text('Leave')); + await tester.pumpAndSettle(); + expect(result, isTrue); + expect(bottomSheet, findsNothing); + }); + + testWidgets('dismissing a bottom sheet confirm cancels', (tester) async { + bool? result; + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) => TextButton( + onPressed: () async => result = await AppConfirmDialog.show( + context, + action.copyWith(presentation: DialogPresentation.bottomSheet), + ), + child: const Text('Open'), + ), + ), + ), + ); + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + await tester.tapAt(const Offset(10, 10)); + await tester.pumpAndSettle(); + expect(result, isFalse); + }); + + testWidgets('the built-in confirm can be a side sheet', (tester) async { + await open( + tester, + action.copyWith( + presentation: DialogPresentation.sideSheet, + sheetWidth: 440, + ), + const Size(1440, 900), + ); + expect(tester.takeException(), isNull); + final Rect sheet = tester.getRect(sideSheet); + expect(sheet.right, 1440); + expect(sheet.width, 440); + expect(sheet.height, 900); + expect(find.text('You can join again later.'), findsOneWidget); + }); + + test('presentation round-trips through JSON and defaults to dialog', () { + final ConfirmAction sheet = ConfirmAction.fromJson({ + 'type': 'confirm', + 'title': 'Reopen?', + 'dialogId': 'reopen', + 'presentation': 'bottomSheet', + 'maxHeightFactor': 0.8, + }); + expect(sheet.presentation, DialogPresentation.bottomSheet); + expect(sheet.maxHeightFactor, 0.8); + expect(ConfirmAction.fromJson(sheet.toJson()), sheet); + final ConfirmAction plain = ConfirmAction(title: 'Sure?'); + expect(plain.presentation, DialogPresentation.dialog); + expect(plain.toJson().containsKey('presentation'), isFalse); + expect(LayoutPresentationRoute.defaultSheetWidth, 400); + }); } diff --git a/test/cross_page_layouts_test.dart b/test/cross_page_layouts_test.dart new file mode 100644 index 00000000..b0f37099 --- /dev/null +++ b/test/cross_page_layouts_test.dart @@ -0,0 +1,303 @@ +// An app whose screens were designed on several editor pages: each page's +// canvases publish as layouts carrying that page's id, and the runtime finds +// a screen by its canvas id in any published layout. Navigating, replacing +// and showing a dialog across pages, layout groups on another page, and app +// variables shared between pages all have to work. +import 'dart:convert'; + +import 'package:cloud_firestore/cloud_firestore.dart'; +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'; + +PaintModel paint(ColorRGBA color) => + PaintModel.solid(id: 'paint', color: color.toRGB(), opacity: color.a); + +CanvasNode canvas(String id, String bodyId, {double width = 390}) => CanvasNode( + id: id, + name: id, + basicBoxLocal: NodeBox(0, 0, width, 844), + children: [bodyId], + scaleMode: ScaleMode.responsive, + fills: [paint(ColorRGBA.white)], + properties: CanvasProperties( + bodyId: bodyId, + safeArea: const SafeAreaModel.all(false), + ), +)..parentID = kRootNode; + +AutoPlaceholderNode body(String id, String canvasId, List children) => + AutoPlaceholderNode( + id: id, + name: id, + basicBoxLocal: NodeBox(0, 0, 390, 844), + children: children, + mainAxisAlignment: MainAxisAlignmentC.start, + horizontalFit: SizeFit.expanded, + verticalFit: SizeFit.expanded, + )..parentID = canvasId; + +TextNode text(String id, String parent, String characters) => TextNode( + id: id, + name: id, + basicBoxLocal: NodeBox(0, 0, 300, 24), + characters: characters, + textMixedProps: [ + StartEndProp( + start: 0, + end: characters.length, + fontSize: 16, + fills: [paint(ColorRGBA.black)], + ), + ], +)..parentID = parent; + +ButtonNode button( + String id, + String parent, + String label, + List actions, +) => ButtonNode( + id: id, + name: id, + basicBoxLocal: NodeBox(0, 0, 160, 44), + properties: ButtonProperties( + label: label, + buttonType: ButtonTypeEnum.filled, + buttonColor: ColorRGBA.black, + ), + reactions: [ + for (final action in actions) + Reaction( + action: action, + trigger: TriggerModel(type: TriggerType.click), + ), + ], +)..parentID = parent; + +/// The app variable every screen shares. +final VariableData driver = VariableData( + id: 'driverVar', + name: 'driver', + value: 'nobody', +); + +/// A layout of [pageId] holding [canvases] (each a list of nodes, canvas +/// first), round-tripped through JSON as the SDK downloads it. +SDKPublishLayout layoutOf( + String id, + String pageId, + List> canvases, { + List breakpoints = const [], +}) => SDKPublishLayout.fromJson( + jsonDecode( + jsonEncode( + SDKPublishLayout( + id: id, + pageId: pageId, + projectId: 'project', + canvases: { + for (final nodes in canvases) + nodes.first.id: {for (final node in nodes) node.id: node}, + }, + breakpoints: breakpoints, + lastUpdated: DateTime(2026), + owner: 'owner', + teams: const {}, + users: const {}, + roles: const {}, + public: false, + ).toJson(), + ), + ) as Map, +); + +/// What the editor publishes for a layout's variables: the app variables +/// under [kAppVariablesScope], copied into every layout. +SDKLayoutVariables variablesOf(SDKPublishLayout layout) => SDKLayoutVariables( + id: layout.id, + variables: { + kAppVariablesScope: {driver.id: driver}, + }, + owner: 'owner', + teams: const {}, + users: const {}, + roles: const {}, + public: false, +); + +/// Page A: the home screen. Sign in sets the app variable and goes to the +/// detail screen on page B; Replace swaps itself for it; Help shows a +/// dialog on page C. +SDKPublishLayout home() => layoutOf('homeLayout', 'pageA', [ + [ + canvas('home', 'homeBody'), + body('homeBody', 'home', ['signIn', 'replace', 'help']), + button('signIn', 'homeBody', 'Sign in', [ + SetVariableAction(variable: driver, newValue: 'Dana'), + NavigationAction( + navigationType: NavigationType.push, + destinationId: 'detailM', + ), + ]), + button('replace', 'homeBody', 'Replace', [ + NavigationAction( + navigationType: NavigationType.replace, + destinationId: 'detailM', + ), + ]), + button('help', 'homeBody', 'Help', [ + ShowDialogAction(destinationId: 'helpDialog'), + ]), + ], +]); + +/// Page B: the detail screen as a layout group, a canvas per width. +SDKPublishLayout detail() => layoutOf( + 'detailGroup', + 'pageB', + [ + [ + canvas('detailM', 'detailMBody'), + body('detailMBody', 'detailM', ['detailMText']), + text('detailMText', 'detailMBody', r'Mobile detail for ${driver}'), + ], + [ + canvas('detailD', 'detailDBody', width: 1440), + body('detailDBody', 'detailD', ['detailDText']), + text('detailDText', 'detailDBody', r'Desktop detail for ${driver}'), + ], + ], + breakpoints: const [ + Breakpoint(nodeId: 'detailM', upperBound: 1024), + Breakpoint(nodeId: 'detailD', lowerBound: 1024), + ], +); + +/// Page C: a dialog. +SDKPublishLayout help() => layoutOf('helpLayout', 'pageC', [ + [ + canvas('helpDialog', 'helpBody'), + body('helpBody', 'helpDialog', ['helpText']), + text('helpText', 'helpBody', r'Help for ${driver}'), + ], +]); + +class _FakeCache extends Fake implements CacheManager {} + +class _FakeAuthManager extends Fake implements AuthManager { + @override + AuthData? get authData => AuthData( + authToken: 'token', + projectId: 'project', + timestamp: DateTime(2026), + owner: 'owner', + teams: const {}, + users: const {}, + roles: const {}, + ); +} + +class _FakeLocalRepository extends Fake implements LocalDataRepository {} + +class _FakeNetworkRepository extends Fake implements NetworkDataRepository {} + +class _FakeFirestore extends Fake implements FirebaseFirestore {} + +class _FakeLocalDatabase extends Fake implements LocalDatabase {} + +class _TestDataManager extends DataManager { + _TestDataManager(SDKPublishModel model) + : super( + 'test', + config: CodelesslyConfig(authToken: 'token'), + cacheManager: _FakeCache(), + authManager: _FakeAuthManager(), + networkDataRepository: _FakeNetworkRepository(), + localDataRepository: _FakeLocalRepository(), + firebaseFirestore: _FakeFirestore(), + errorLogger: ErrorLogger.instance, + publishModel: model, + ); + + @override + LocalDatabase get localDatabase => _FakeLocalDatabase(); +} + +/// Pumps the home screen, with every page's layouts published, on a +/// [size] screen. +Future pumpApp(WidgetTester tester, Size size) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = size; + addTearDown(tester.view.reset); + + final model = SDKPublishModel( + projectId: 'project', + owner: 'owner', + teams: const {}, + users: const {}, + roles: const {}, + public: false, + pages: const ['pageA', 'pageB', 'pageC'], + ); + for (final layout in [home(), detail(), help()]) { + model.layouts[layout.id] = layout; + model.variables[layout.id] = variablesOf(layout); + } + final codelessly = Codelessly( + config: CodelesslyConfig(authToken: 'token'), + publishDataManager: _TestDataManager(model)..status = CStatus.loaded(), + )..status = CStatus.loaded(); + await tester.pumpWidget( + MaterialApp( + home: CodelesslyWidget(codelessly: codelessly, layoutID: 'homeLayout'), + ), + ); + await tester.pump(); +} + +void main() { + testWidgets('navigating to a canvas on another page shows the canvas of ' + 'its layout group for the width, with the app variable set on the ' + 'first page', (tester) async { + await pumpApp(tester, const Size(390, 844)); + await tester.tap(find.text('Sign in')); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + expect(find.text('Mobile detail for Dana'), findsOneWidget); + + // Back on the first page's screen. + Navigator.of(tester.element(find.text('Mobile detail for Dana'))).pop(); + await tester.pumpAndSettle(); + expect(find.text('Sign in'), findsOneWidget); + }); + + testWidgets('a layout group on another page picks its desktop canvas on ' + 'a wide screen', (tester) async { + await pumpApp(tester, const Size(1440, 900)); + await tester.tap(find.text('Sign in')); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + expect(find.text('Desktop detail for Dana'), findsOneWidget); + expect(find.textContaining('Mobile detail'), findsNothing); + }); + + testWidgets('replace swaps in a screen from another page', (tester) async { + await pumpApp(tester, const Size(390, 844)); + await tester.tap(find.text('Replace')); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + expect(find.text('Mobile detail for nobody'), findsOneWidget); + expect(find.text('Sign in'), findsNothing); + }); + + testWidgets('a dialog designed on another page opens, sharing the app ' + 'variables', (tester) async { + await pumpApp(tester, const Size(390, 844)); + await tester.tap(find.text('Help')); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + expect(find.text('Help for nobody'), findsOneWidget); + }); +} diff --git a/test/design_canvas_bindings_test.dart b/test/design_canvas_bindings_test.dart index 526378fc..f8226f23 100644 --- a/test/design_canvas_bindings_test.dart +++ b/test/design_canvas_bindings_test.dart @@ -168,4 +168,67 @@ void main() { expect(find.byKey(const Key('email')), findsNothing); }); }); + + group('a text in a list item', () { + const String note = r'${= flags[item.id].note}'; + final TextNode text = TextNode( + id: 'note', + name: 'Note', + basicBoxLocal: NodeBox(0, 0, 200, 20), + characters: note, + textMixedProps: [StartEndProp(start: 0, end: note.length)], + )..parentID = kRootNode; + + // The editor's values for the canvas: the first item of the list. + setUp( + () => VariablePreview.scopeResolver = (_) => ScopedValues.preview( + variables: { + 'flags': VariableData( + id: 'flags', + name: 'flags', + value: { + '1a': {'note': 'Tread worn'}, + '1b': {'note': 'Brake hose leak'}, + }, + type: VariableType.map, + ), + }, + indexedItem: IndexedItem(0, {'id': '1a'}), + ), + ); + + Future pumpItem(WidgetTester tester, IndexedItem? item) => + pumpHost(tester, (context) { + Widget build(BuildContext context) => TextUtils.buildTextForProps( + context, + note, + node: text, + props: text.textMixedProps, + variablesOverrides: const [], + nullSubstitutionMode: NullSubstitutionMode.noChange, + replaceVariablesWithSymbol: true, + ); + if (item == null) return build(context); + return IndexedItemProvider( + item: item, + child: Builder(builder: build), + ); + }); + + testWidgets('reads the item it is built in, as its bindings do', ( + tester, + ) async { + await pumpItem(tester, IndexedItem(1, {'id': '1b'})); + expect(find.text('Brake hose leak', findRichText: true), findsOne); + }); + + testWidgets('reads the first item when built without item data', ( + tester, + ) async { + await pumpItem(tester, IndexedItem(1, null)); + expect(find.text('Tread worn', findRichText: true), findsOne); + await pumpItem(tester, null); + expect(find.text('Tread worn', findRichText: true), findsOne); + }); + }); } diff --git a/test/flex_layout_test.dart b/test/flex_layout_test.dart index c8c75001..4e2353d5 100644 --- a/test/flex_layout_test.dart +++ b/test/flex_layout_test.dart @@ -90,6 +90,7 @@ Future> pump( required Size screen, required Host host, CodelesslyContext? codelesslyContext, + NodeBuildDecorator? decorator, }) async { tester.view.physicalSize = screen; tester.view.devicePixelRatio = 1; @@ -102,13 +103,20 @@ Future> pump( final manager = PassiveNodeTransformerManager( registry.getNodeByID, (context, bounds, pageID, layoutID, canvasID) => const SizedBox(), - ); + )..decorator = decorator; Widget built(BuildContext context) => manager.buildWidgetFromNode( root, context, settings: const WidgetBuildSettings(debugLabel: 'test'), ); - final Widget body = Builder(builder: built); + // A decorator's probes only wrap nodes under a geometry scope, as on the + // editor's render canvas. + final Widget body = decorator == null + ? Builder(builder: built) + : NodeGeometryScope( + registry: NodeGeometryRegistry(), + child: Builder(builder: built), + ); await tester.pumpWidget( ChangeNotifierProvider.value( value: codelesslyContext ?? CodelesslyContext.empty(), @@ -135,11 +143,19 @@ Future everywhere( WidgetTester tester, BaseNode root, List nodes, - void Function(Map sizes, Size screen, Host host) check, -) async { + void Function(Map sizes, Size screen, Host host) check, { + NodeBuildDecorator? decorator, +}) async { for (final Size screen in screens) { for (final Host host in Host.values) { - final sizes = await pump(tester, root, nodes, screen: screen, host: host); + final sizes = await pump( + tester, + root, + nodes, + screen: screen, + host: host, + decorator: decorator, + ); check(sizes, screen, host); } } @@ -307,6 +323,104 @@ void main() { }); }); + testWidgets( + 'a child filling across a row that wraps its height stretches to the ' + 'height the other children give the row', + (tester) async { + // A card's severity edge, as tall as its text: in Flutter, + // IntrinsicHeight(child: Row(crossAxisAlignment: stretch)). + final edge = rect( + 'edge', + '', + width: 4, + height: 10, + verticalFit: SizeFit.expanded, + ); + final title = rect('title', '', width: 120, height: 20); + final subtitle = rect('subtitle', '', width: 160, height: 30); + final text = flex( + 'text', + '', + [title, subtitle], + type: RowColumnType.column, + width: 200, + height: 50, + horizontalFit: SizeFit.expanded, + verticalFit: SizeFit.shrinkWrap, + ); + final card = flex( + 'card', + '', + [edge, text], + type: RowColumnType.row, + width: 300, + height: 40, + horizontalFit: SizeFit.expanded, + verticalFit: SizeFit.shrinkWrap, + ); + final column = flex( + 'column', + kRootNode, + [card], + type: RowColumnType.column, + width: 300, + height: 400, + isScrollable: true, + ); + await everywhere(tester, column, [card, edge, text, title, subtitle], ( + sizes, + screen, + host, + ) { + expect(sizes['edge'], const Size(4, 50), reason: '$host $screen'); + }); + }, + ); + + testWidgets( + 'a spacer filling across a row that wraps its height leaves the row as ' + 'tall as its other children', + (tester) async { + final label = rect('label', '', width: 90, height: 24); + final gap = spacer('gap', width: 10, height: 10); + final edge = rect( + 'edge', + '', + width: 4, + height: 10, + verticalFit: SizeFit.expanded, + ); + final row = flex( + 'row', + '', + [label, gap..parentID = 'row', edge], + type: RowColumnType.row, + width: 300, + height: 40, + horizontalFit: SizeFit.expanded, + verticalFit: SizeFit.shrinkWrap, + ); + final column = flex( + 'column', + kRootNode, + [row], + type: RowColumnType.column, + width: 300, + height: 400, + isScrollable: true, + ); + await everywhere(tester, column, [row, label, gap, edge], ( + sizes, + screen, + host, + ) { + // The edge shows the row's height. + expect(sizes['edge'], const Size(4, 24), reason: '$host $screen'); + expect(sizes['label'], const Size(90, 24), reason: '$host $screen'); + }); + }, + ); + testWidgets('flexible column takes its design size like a leaf (A6)', ( tester, ) async { @@ -620,4 +734,76 @@ void main() { expect(sizes['c']!.width, 100); }); }); + + // The spacer JSON the build scripts send: flex on both axes, so it works + // in a row and in a column, and none of the keys other nodes default. + SpacerNode spacerFromJson(String id) => + NodeJsonConverter().fromJson({ + 'type': 'spacer', + 'id': id, + 'name': id, + 'flex': 1, + 'basicBoxLocal': [0, 0, 100, 100], + 'horizontalFit': 'expanded', + 'verticalFit': 'expanded', + })! + as SpacerNode; + + for (final NodeBuildDecorator? decorator in [ + null, + const GeometryProbeDecorator(), + ]) { + final String host = decorator == null ? 'an app' : 'the editor'; + testWidgets('a spacer takes the free space of a row in $host', ( + tester, + ) async { + final l = rect('l', '', width: 40, height: 40); + final gap = spacerFromJson('gap'); + final r = rect('r', '', width: 40, height: 40); + final row = flex( + 'row', + kRootNode, + [l, gap, r], + type: RowColumnType.row, + width: 390, + height: 56, + ); + await everywhere(tester, row, [l, gap, r], (sizes, screen, host) { + // A tight host forces its size on the root; the others keep 390. + if (host == Host.tight) return; + expect( + tester.getTopLeft(rectOf('r')).dx - + tester.getTopLeft(rectOf('row')).dx, + 390 - 40, + reason: '$host $screen', + ); + }, decorator: decorator); + }); + + testWidgets('a spacer takes the free space of a column in $host', ( + tester, + ) async { + final top = rect('top', '', width: 40, height: 44); + final gap = spacerFromJson('gap'); + final bottom = rect('bottom', '', width: 40, height: 14); + final tile = flex( + 'tile', + kRootNode, + [top, gap, bottom], + type: RowColumnType.column, + width: 160, + height: 112, + ); + await everywhere(tester, tile, [top, gap, bottom], (sizes, screen, host) { + if (host == Host.tight) return; + expect(sizes['tile']!.height, 112, reason: '$host $screen'); + expect( + tester.getTopLeft(rectOf('bottom')).dy - + tester.getTopLeft(rectOf('tile')).dy, + 112 - 14, + reason: '$host $screen', + ); + }, decorator: decorator); + }); + } } diff --git a/test/formula_records_test.dart b/test/formula_records_test.dart new file mode 100644 index 00000000..45a7a3c8 --- /dev/null +++ b/test/formula_records_test.dart @@ -0,0 +1,80 @@ +import 'package:codelessly_sdk/codelessly_sdk.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Functions that build and update records (maps) and lists, so actions +/// can change one item of a list variable (indexOf with a predicate, obj, +/// set, setAt, merge, concat, append, take, skip) and format an odometer +/// (thousands, padLeft). +void main() { + final Map vars = { + 'defects': [ + {'id': 'DF-1', 'status': 'open', 'timeline': []}, + { + 'id': 'DF-2', + 'status': 'repaired', + 'timeline': ['a'], + }, + ], + 'id': 'DF-2', + 'user': {'id': 'u1', 'name': 'Luis'}, + 'answers': {'1a': 'pass'}, + }; + Object? eval(String expression) => FormulaEvaluator.evaluate( + expression, + (path) => path + .split('.') + .fold(vars, (value, key) => value is Map ? value[key] : null), + ); + + test('indexOf finds by lambda, field and value, or plain value', () { + expect(eval('indexOf(defects, d => d.id == id)'), 1); + expect(eval('indexOf(defects, "status", "open")'), 0); + expect(eval('indexOf(["a", "b"], "b")'), 1); + expect(eval('indexOf(defects, d => d.id == "nope")'), -1); + expect(eval('indexOf(null, d => true)'), -1); + }); + + test('obj builds a map from key/value pairs', () { + expect(eval('obj("t", 3, "by", user.name)'), {'t': 3, 'by': 'Luis'}); + expect(eval('obj()'), {}); + }); + + test('set copies a map, and setAt a list, with one entry changed', () { + expect(eval('set(answers, "1b", "fail")'), {'1a': 'pass', '1b': 'fail'}); + expect(vars['answers'], { + '1a': 'pass', + }, reason: 'the original is unchanged'); + expect(eval('setAt([1, 2, 3], 1, 9)'), [1, 9, 3]); + final Object? updated = eval( + 'setAt(defects, indexOf(defects, d => d.id == id), ' + 'set(find(defects, d => d.id == id), "status", "verified"))', + ); + expect((updated! as List)[1], { + 'id': 'DF-2', + 'status': 'verified', + 'timeline': ['a'], + }); + }); + + test('merge, concat and append combine records and lists', () { + expect(eval('merge(user, obj("role", "mechanic"))'), { + 'id': 'u1', + 'name': 'Luis', + 'role': 'mechanic', + }); + expect(eval('concat([1], [2, 3], 4)'), [1, 2, 3, 4]); + expect(eval('append([1], 2, 3)'), [1, 2, 3]); + expect(eval('append(null, 1)'), [1]); + }); + + test('take and skip slice lists', () { + expect(eval('take([1, 2, 3], 2)'), [1, 2]); + expect(eval('skip([1, 2, 3], 2)'), [3]); + }); + + test('thousands groups an odometer and padLeft pads', () { + expect(eval('thousands(412908)'), '412,908'); + expect(eval('thousands(12)'), '12'); + expect(eval('padLeft(7, 2, "0")'), '07'); + }); +} diff --git a/test/formula_text_list_test.dart b/test/formula_text_list_test.dart new file mode 100644 index 00000000..c0c97b5c --- /dev/null +++ b/test/formula_text_list_test.dart @@ -0,0 +1,159 @@ +import 'package:codelessly_sdk/codelessly_sdk.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + final Map vars = { + 'timeline': [ + {'t': '10:00', 'label': 'Reported'}, + {'t': '10:05', 'label': 'Assigned'}, + {'t': '10:05', 'label': 'Repair started'}, + ], + 'items': [ + {'id': '1a', 'name': 'Tyres'}, + {'id': '1b', 'name': 'Lights'}, + {'id': '1c', 'name': 'Mirrors'}, + ], + 'answers': {'1b': 'fail'}, + 'vin': '1FUJGLDR5LLMX4821', + 'zone': 1, + }; + Object? eval(String expression) => + FormulaEvaluator.evaluate(expression, (path) => vars[path]); + + group('reverse', () { + test('reverses a list, keeping equal items in reverse order', () { + expect(eval('map(reverse(timeline), e => e.label)'), [ + 'Repair started', + 'Assigned', + 'Reported', + ]); + }); + + test('reverses text', () => expect(eval('reverse("abc")'), 'cba')); + + test('does not change the list it reads', () { + eval('reverse(timeline)'); + expect((vars['timeline']! as List).first, { + 't': '10:00', + 'label': 'Reported', + }); + }); + + test('is null for other values', () => expect(eval('reverse(5)'), isNull)); + }); + + group('lambda index', () { + test('map passes the index as the second parameter', () { + expect(eval('map(items, (i, n) => n + 1 + ". " + i.name)'), [ + '1. Tyres', + '2. Lights', + '3. Mirrors', + ]); + }); + + test('marks the last item', () { + expect(eval('map(timeline, (e, i) => i == length(timeline) - 1)'), [ + false, + false, + true, + ]); + }); + + test('filter, find, count, any, all and indexOf get the index', () { + expect(eval('map(filter(items, (x, i) => i > 0), x => x.id)'), [ + '1b', + '1c', + ]); + expect(eval('find(items, (x, i) => i == 2).name'), 'Mirrors'); + expect(eval('count(items, (x, i) => i % 2 == 0)'), 2); + expect(eval('any(items, (x, i) => i == 5)'), false); + expect(eval('all(items, (x, i) => i < 3)'), true); + expect(eval('indexOf(items, (x, i) => x.id == "1c" && i == 2)'), 2); + }); + + test('a one-parameter lambda still works', () { + expect(eval('map(items, (x) => x.id)'), ['1a', '1b', '1c']); + }); + + test('the index parameter is not read as a variable', () { + expect( + FormulaEvaluator.referencedPaths('map(items, (x, i) => i + zone)'), + {'items', 'zone'}, + ); + }); + }); + + group('substring, left and right', () { + test('substring takes start and an optional end', () { + expect(eval('substring("MX4821", 2, 4)'), '48'); + expect(eval('substring("MX4821", 2)'), '4821'); + }); + + test('negative indexes count from the end', () { + expect(eval('substring(vin, -6)'), 'MX4821'); + expect(eval('substring("MX4821", 0, -4)'), 'MX'); + }); + + test('indexes past the ends are clamped', () { + expect(eval('substring("abc", 1, 99)'), 'bc'); + expect(eval('substring("abc", 5)'), ''); + expect(eval('substring("abc", 2, 1)'), ''); + expect(eval('substring(missing, 0, 2)'), ''); + }); + + test('left and right', () { + expect(eval('left(vin, 4)'), '1FUJ'); + expect(eval('right(vin, 6)'), 'MX4821'); + expect(eval('right("ab", 6)'), 'ab'); + expect(eval('left("ab", 0)'), ''); + expect(eval('right("ab", 0)'), ''); + expect(eval('right(1234, 2)'), '34'); + }); + }); + + group('toMap', () { + test('builds a map from key and value lambdas', () { + expect(eval('toMap(items, i => i.id, i => "pass")'), { + '1a': 'pass', + '1b': 'pass', + '1c': 'pass', + }); + }); + + test('keeps the item when there is no value selector', () { + expect(eval('toMap(items, "id")["1b"].name'), 'Lights'); + }); + + test('marks unanswered items pass and keeps answers (Pass N)', () { + expect( + eval( + 'merge(toMap(filter(items, i => !answers[i.id]), i => i.id, ' + 'i => "pass"), answers)', + ), + {'1a': 'pass', '1c': 'pass', '1b': 'fail'}, + ); + }); + + test('lambdas get the index', () { + expect(eval('toMap(items, (i, n) => n, i => i.name)'), { + '0': 'Tyres', + '1': 'Lights', + '2': 'Mirrors', + }); + }); + + test('is empty for a value that is not a list', () { + expect(eval('toMap(missing, i => i)'), {}); + }); + }); + + test('the new functions are documented', () { + final Set documented = { + for (final doc in FormulaEvaluator.functionDocs) doc.name, + }; + expect( + documented, + containsAll(['reverse', 'substring', 'left', 'right', 'toMap']), + ); + }); +} diff --git a/test/interaction_states_test.dart b/test/interaction_states_test.dart index 46cbe088..d81aec76 100644 --- a/test/interaction_states_test.dart +++ b/test/interaction_states_test.dart @@ -205,6 +205,89 @@ void main() { expect(harness.taps, ['tap']); }); + double boxOpacity(WidgetTester tester) => tester + .widget( + find + .descendant( + of: find.byType(InteractionStatesBox), + matching: find.byType(Opacity), + ) + .first, + ) + .opacity; + + testWidgets('a node disabled by a formula, with no disabled state, dims ' + 'and runs no actions', (tester) async { + final (:card, :dot) = cardWithDot(); + card.variables = {'disabled': r'${= busy == "DF-3104"}'}; + final harness = Harness(); + final Observable busy = Observable( + VariableData(id: 'b', name: 'busy', type: VariableType.text, value: ''), + ); + harness.codelesslyContext.variables['b'] = busy; + await harness.pump(tester, [card, dot]); + expect(boxOpacity(tester), 1); + + busy.set(busy.value.copyWith(value: 'DF-3104')); + await tester.pump(); + expect(boxOpacity(tester), InteractionStatesBox.defaultDisabledOpacity); + await tester.tapAt(const Offset(150, 50)); + await tester.pumpAndSettle(); + expect(harness.taps, isEmpty); + + busy.set(busy.value.copyWith(value: '')); + await tester.pump(); + expect(boxOpacity(tester), 1); + await tester.tapAt(const Offset(150, 50)); + await tester.pumpAndSettle(); + expect(harness.taps, ['tap']); + }); + + testWidgets('a node with its own disabled state does not also dim', ( + tester, + ) async { + final (:card, :dot) = cardWithDot(); + card.interactionStates = { + InteractionState.disabled: {'card': fillPatch('#808080')}, + }; + card.variables = {'disabled': r'${= true}'}; + await Harness().pump(tester, [card, dot]); + expect(boxOpacity(tester), 1); + expect(colorOf(tester, 200), grey); + }); + + testWidgets('a button disabled by a formula dims and ignores taps', ( + tester, + ) async { + final ButtonNode button = ButtonNode( + id: 'button', + name: 'Reopen', + basicBoxLocal: NodeBox(0, 0, 160, 48), + properties: ButtonProperties(label: 'Reopen'), + reactions: [ + Reaction( + action: CallFunctionAction(name: 'tap'), + trigger: TriggerModel(type: TriggerType.click), + ), + ], + )..parentID = kRootNode; + button.variables['disabled'] = r'${= busy != ""}'; + final harness = Harness(); + harness.codelesslyContext.variables['b'] = Observable( + VariableData( + id: 'b', + name: 'busy', + type: VariableType.text, + value: 'DF-3104', + ), + ); + await harness.pump(tester, [button]); + expect(boxOpacity(tester), InteractionStatesBox.defaultDisabledOpacity); + await tester.tap(find.text('Reopen'), warnIfMissed: false); + await tester.pumpAndSettle(); + expect(harness.taps, isEmpty); + }); + testWidgets('focused follows focus of an input inside the node', ( tester, ) async { diff --git a/test/loop_animation_test.dart b/test/loop_animation_test.dart new file mode 100644 index 00000000..ba1609f2 --- /dev/null +++ b/test/loop_animation_test.dart @@ -0,0 +1,285 @@ +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:codelessly_sdk/codelessly_sdk.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; + +/// The pulsing halo behind a selected walkaround marker: 58 px, growing +/// from 38 px (scale 0.655) while fading from 35% to nothing every 1.4 s. +RectangleNode halo({Map variables = const {}}) { + final RectangleNode node = RectangleNode.fromJson({ + ...RectangleNode( + id: 'halo', + name: 'Halo', + basicBoxLocal: NodeBox(0, 0, 58, 58), + fills: [PaintModel.solid(id: 'fill', color: ColorRGB.fromHex('#FFC20E'))], + ).toJson(), + 'loopAnimation': { + 'type': 'pulse', + 'durationMs': 1400, + 'scaleFrom': 0.655, + 'scaleTo': 1.0, + 'opacityFrom': 0.35, + 'opacityTo': 0.0, + }, + 'variables': variables, + }); + return node..parentID = kRootNode; +} + +Future pumpNode( + WidgetTester tester, + BaseNode node, { + CodelesslyContext? codelesslyContext, + bool tickers = true, +}) async { + final registry = NodeRegistry()..setNodes({node.id: node}); + final manager = PassiveNodeTransformerManager( + registry.getNodeByID, + (context, bounds, pageID, layoutID, canvasID) => const SizedBox(), + ); + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: codelesslyContext ?? CodelesslyContext.empty(), + child: MaterialApp( + home: TickerMode( + enabled: tickers, + child: Align( + alignment: Alignment.topLeft, + child: Builder( + builder: (context) => manager.buildWidgetFromNode( + node, + context, + settings: const WidgetBuildSettings(debugLabel: 'test'), + ), + ), + ), + ), + ), + ), + ); +} + +/// Finds the transitions [LoopAnimationBox] builds, not the route's. +Finder inBox(Type type) => find.descendant( + of: find.byType(LoopAnimationBox), + matching: find.byType(type), +); + +double scaleOf(WidgetTester tester) => + tester.widget(inBox(ScaleTransition)).scale.value; + +double opacityOf(WidgetTester tester) => + tester.widget(inBox(FadeTransition)).opacity.value; + +Observable variable(String name, Object? value) => + Observable(VariableData(id: name, name: name, value: value)); + +void main() { + group('Loop animation', () { + testWidgets('a pulse grows and fades, then starts over', (tester) async { + await pumpNode(tester, halo()); + + expect(scaleOf(tester), closeTo(0.655, 1e-6)); + expect(opacityOf(tester), closeTo(0.35, 1e-6)); + + await tester.pump(const Duration(milliseconds: 700)); + expect(scaleOf(tester), closeTo(0.8275, 1e-3)); + expect(opacityOf(tester), closeTo(0.175, 1e-3)); + + await tester.pump(const Duration(milliseconds: 650)); + expect(scaleOf(tester), greaterThan(0.95)); + expect(opacityOf(tester), lessThan(0.02)); + + // It repeats from the start rather than running back. + await tester.pump(const Duration(milliseconds: 100)); + expect(scaleOf(tester), lessThan(0.7)); + expect(opacityOf(tester), greaterThan(0.3)); + + // The layout size is the design size: the scale is paint only. + expect(tester.getSize(inBox(ScaleTransition)), const Size(58, 58)); + }); + + testWidgets('a reversing fade breathes back and forth', (tester) async { + final RectangleNode node = halo() + ..loopAnimation = const LoopAnimation( + type: LoopAnimationType.fade, + durationMs: 900, + reverse: true, + opacityFrom: 0.45, + opacityTo: 1, + ); + await pumpNode(tester, node); + expect(inBox(ScaleTransition), findsNothing); + + expect(opacityOf(tester), closeTo(0.45, 1e-6)); + await tester.pump(const Duration(milliseconds: 900)); + expect(opacityOf(tester), closeTo(1, 1e-6)); + await tester.pump(const Duration(milliseconds: 450)); + expect(opacityOf(tester), closeTo(0.725, 1e-3)); + await tester.pump(const Duration(milliseconds: 450)); + expect(opacityOf(tester), closeTo(0.45, 1e-6)); + }); + + testWidgets('a spin turns continuously after its delay', (tester) async { + final RectangleNode node = halo() + ..loopAnimation = const LoopAnimation( + type: LoopAnimationType.spin, + durationMs: 1000, + delayMs: 200, + ); + await pumpNode(tester, node); + double turns() => tester + .widget(inBox(RotationTransition)) + .turns + .value; + + await tester.pump(const Duration(milliseconds: 200)); + expect(turns(), 0); + await tester.pump(const Duration(milliseconds: 250)); + expect(turns(), closeTo(0.25, 1e-3)); + }); + + testWidgets('a disabled animation leaves the node as designed', ( + tester, + ) async { + final RectangleNode node = halo(); + node.loopAnimation = node.loopAnimation!.copyWith(enabled: false); + await pumpNode(tester, node); + + await tester.pump(const Duration(milliseconds: 700)); + expect(scaleOf(tester), 1); + expect(opacityOf(tester), 1); + expect(tester.hasRunningAnimations, isFalse); + }); + + testWidgets('a bound variable turns the animation on and off', ( + tester, + ) async { + final Observable selected = variable('selected', false); + final CodelesslyContext codelesslyContext = CodelesslyContext.empty() + ..variables['selected'] = selected; + await pumpNode( + tester, + halo(variables: {'loopAnimationEnabled': r'${selected}'}), + codelesslyContext: codelesslyContext, + ); + + await tester.pump(const Duration(milliseconds: 700)); + expect(scaleOf(tester), 1); + expect(tester.hasRunningAnimations, isFalse); + + selected.set(selected.value.copyWith(value: true)); + await tester.pump(); + expect(scaleOf(tester), closeTo(0.655, 1e-6)); + await tester.pump(const Duration(milliseconds: 700)); + expect(scaleOf(tester), closeTo(0.8275, 1e-3)); + + selected.set(selected.value.copyWith(value: false)); + await tester.pump(); + expect(scaleOf(tester), 1); + expect(opacityOf(tester), 1); + expect(tester.hasRunningAnimations, isFalse); + }); + + testWidgets('it holds still at its start under a disabled TickerMode', ( + tester, + ) async { + await pumpNode(tester, halo(), tickers: false); + + await tester.pump(const Duration(milliseconds: 700)); + expect(scaleOf(tester), closeTo(0.655, 1e-6)); + expect(opacityOf(tester), closeTo(0.35, 1e-6)); + expect(tester.binding.hasScheduledFrame, isFalse); + }); + }); + + group('Cursor triggers', () { + Reaction on(TriggerType type, String value) => Reaction( + trigger: TriggerModel(type: type), + action: SetVariableAction( + variable: VariableData(id: 'hoveredId', name: 'hoveredId'), + newValue: value, + ), + ); + + testWidgets('cursor enter and exit run their reactions', (tester) async { + final Observable hoveredId = variable('hoveredId', ''); + final CodelesslyContext codelesslyContext = CodelesslyContext.empty() + ..variables['hoveredId'] = hoveredId; + final RectangleNode card = RectangleNode( + id: 'card', + name: 'Card', + basicBoxLocal: NodeBox(0, 0, 200, 80), + reactions: [ + on(TriggerType.cursorEnter, 'truck-7'), + on(TriggerType.cursorExit, ''), + ], + )..parentID = kRootNode; + await pumpNode(tester, card, codelesslyContext: codelesslyContext); + + final TestGesture mouse = await tester.createGesture( + kind: PointerDeviceKind.mouse, + ); + addTearDown(mouse.removePointer); + await mouse.addPointer(location: const Offset(400, 400)); + await tester.pump(); + expect(hoveredId.value.value, ''); + + await mouse.moveTo(const Offset(100, 40)); + await tester.pumpAndSettle(); + expect(hoveredId.value.value, 'truck-7'); + + // Moving within the card doesn't fire again. + hoveredId.set(hoveredId.value.copyWith(value: 'other')); + await mouse.moveTo(const Offset(120, 50)); + await tester.pumpAndSettle(); + expect(hoveredId.value.value, 'other'); + + await mouse.moveTo(const Offset(400, 400)); + await tester.pumpAndSettle(); + expect(hoveredId.value.value, ''); + }); + + testWidgets('a hover region still lets taps through to click reactions', ( + tester, + ) async { + final Observable hoveredId = variable('hoveredId', ''); + final CodelesslyContext codelesslyContext = CodelesslyContext.empty() + ..variables['hoveredId'] = hoveredId; + final RectangleNode card = RectangleNode( + id: 'card', + name: 'Card', + basicBoxLocal: NodeBox(0, 0, 200, 80), + reactions: [ + on(TriggerType.cursorEnter, 'hover'), + on(TriggerType.click, 'clicked'), + ], + )..parentID = kRootNode; + await pumpNode(tester, card, codelesslyContext: codelesslyContext); + + await tester.tapAt(const Offset(100, 40)); + await tester.pumpAndSettle(); + expect(hoveredId.value.value, 'clicked'); + expect(find.byType(MouseRegion), findsWidgets); + }); + + testWidgets('nodes without cursor reactions get no extra region', ( + tester, + ) async { + final RectangleNode plain = RectangleNode( + id: 'plain', + name: 'Plain', + basicBoxLocal: NodeBox(0, 0, 200, 80), + )..parentID = kRootNode; + await pumpNode(tester, plain); + expect( + find.byWidgetPredicate( + (widget) => widget is MouseRegion && widget.onEnter != null, + ), + findsNothing, + ); + }); + }); +} diff --git a/test/parent_size_test.dart b/test/parent_size_test.dart new file mode 100644 index 00000000..018a47c5 --- /dev/null +++ b/test/parent_size_test.dart @@ -0,0 +1,135 @@ +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'; + +Finder _of(String id) => + find.byKey(ValueKey('Rectangle Transformer of $id [$id]')); + +RectangleNode _rect(String id, Map variables) => + RectangleNode(id: id, name: id, basicBoxLocal: NodeBox(0, 0, 100, 40)) + ..parentID = 'panel' + ..variables.addAll(variables); + +/// A specs panel, 24 padding, with a 4-column and a 2-column grid stand-in +/// that show by the panel's content width, and a bar half as wide. +List _panel() { + final List children = [ + _rect('four', {'visible': r'${= parent.width > 520}'}), + _rect('two', {'visible': r'${= parent.width <= 520}'}), + _rect('half', {'width': r'${= parent.width / 2}'}), + ]; + return [ + RowColumnNode( + id: 'panel', + name: 'panel', + basicBoxLocal: NodeBox(0, 0, 600, 300), + children: [for (final child in children) child.id], + rowColumnType: RowColumnType.column, + horizontalFit: SizeFit.expanded, + verticalFit: SizeFit.shrinkWrap, + crossAxisAlignment: CrossAxisAlignmentC.start, + padding: const EdgeInsetsModel.all(24), + )..parentID = kRootNode, + ...children, + ]; +} + +Future _pump(WidgetTester tester, double width) async { + tester.view.physicalSize = Size(width, 800); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final List nodes = _panel(); + final registry = NodeRegistry()..setNodes({for (final n in nodes) n.id: n}); + final manager = PassiveNodeTransformerManager( + registry.getNodeByID, + (context, bounds, pageID, layoutID, canvasID) => const SizedBox(), + ); + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: CodelesslyContext.empty(), + child: MaterialApp( + home: Scaffold( + body: Align( + alignment: Alignment.topLeft, + child: Builder( + builder: (context) => manager.buildWidgetFromNode( + nodes.first, + context, + settings: const WidgetBuildSettings(debugLabel: 'test'), + ), + ), + ), + ), + ), + ), + ); + expect(tester.takeException(), isNull); +} + +void main() { + testWidgets('a panel wider than 520 shows four columns', (tester) async { + await _pump(tester, 600); + expect(_of('four'), findsOneWidget); + expect(_of('two'), findsNothing); + // 600 - 2 * 24 padding. + expect(tester.getSize(_of('half')).width, 276); + }); + + testWidgets('a panel 520 or narrower shows two columns', (tester) async { + await _pump(tester, 560); + expect(_of('four'), findsNothing); + expect(_of('two'), findsOneWidget); + expect(tester.getSize(_of('half')).width, 256); + }); + + testWidgets('the layout follows the panel as the screen resizes', ( + tester, + ) async { + await _pump(tester, 600); + expect(_of('four'), findsOneWidget); + tester.view.physicalSize = const Size(500, 800); + await tester.pump(); + expect(tester.takeException(), isNull); + expect(_of('four'), findsNothing); + expect(_of('two'), findsOneWidget); + }); + + test('readsParentSize finds parent in bindings, formulas and text', () { + expect(readsParentSize(_rect('a', {'visible': r'${parent.width}'})), true); + expect( + readsParentSize(_rect('b', {'visible': r'${= parent.width > 9}'})), + true, + ); + expect(readsParentSize(_rect('c', {'visible': r'${screen.width}'})), false); + final TextNode text = TextNode( + id: 't', + name: 't', + basicBoxLocal: NodeBox(0, 0, 10, 10), + characters: r'Width ${= round(parent.width)}', + ); + expect(readsParentSize(text), true); + }); + + test('the design canvas can give parent a size', () { + final ScopedValues values = ScopedValues.preview( + variables: const {}, + parentSize: parentVariableValue(698, null), + ); + expect( + PropertyValueDelegate.getVariableValueFromPath( + r'${= parent.width > 520}', + scopedValues: values, + ), + isTrue, + ); + expect( + PropertyValueDelegate.getVariableValueFromPath( + r'${parent.height}', + scopedValues: values, + ), + isNull, + ); + }); +} diff --git a/test/scaled_content_test.dart b/test/scaled_content_test.dart new file mode 100644 index 00000000..45213a60 --- /dev/null +++ b/test/scaled_content_test.dart @@ -0,0 +1,145 @@ +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'; + +Finder _of(String id) => + find.byKey(ValueKey('Rectangle Transformer of $id [$id]')); + +/// A 300x660 truck diagram whose wheel sits in its bottom-right quarter. +({FrameNode truck, RectangleNode wheel}) _truck({ + Fit? fit = Fit.contain, + AlignmentModel alignment = AlignmentModel.center, + SizeFit fits = SizeFit.expanded, +}) { + final RectangleNode wheel = RectangleNode( + id: 'wheel', + name: 'wheel', + basicBoxLocal: NodeBox(150, 330, 150, 330), + positioningMode: PositioningMode.pin, + edgePins: const EdgePinsModel( + left: 150, + top: 330, + right: null, + bottom: null, + ), + )..parentID = 'truck'; + final FrameNode truck = FrameNode( + id: 'truck', + name: 'truck', + basicBoxLocal: NodeBox(0, 0, 300, 660), + children: ['wheel'], + horizontalFit: fits, + verticalFit: fits, + contentFit: fit, + contentAlignment: alignment, + )..parentID = kRootNode; + return (truck: truck, wheel: wheel); +} + +Future _pump( + WidgetTester tester, + List nodes, { + required Size box, +}) async { + tester.view.physicalSize = const Size(1440, 1600); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final registry = NodeRegistry()..setNodes({for (final n in nodes) n.id: n}); + final manager = PassiveNodeTransformerManager( + registry.getNodeByID, + (context, bounds, pageID, layoutID, canvasID) => const SizedBox(), + ); + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: CodelesslyContext.empty(), + child: MaterialApp( + home: Align( + alignment: Alignment.topLeft, + child: SizedBox.fromSize( + size: box, + child: Align( + alignment: Alignment.topLeft, + child: Builder( + builder: (context) => manager.buildWidgetFromNode( + nodes.first, + context, + settings: const WidgetBuildSettings(debugLabel: 'test'), + ), + ), + ), + ), + ), + ), + ), + ); + expect(tester.takeException(), isNull); +} + +void main() { + testWidgets('contain scales the design into a short wide box, centered', ( + tester, + ) async { + final (:truck, :wheel) = _truck(); + await _pump(tester, [truck, wheel], box: const Size(600, 330)); + // Scale 0.5: the 150x330 content sits centered in 600 wide. + expect(tester.getRect(_of('truck')), const Rect.fromLTWH(0, 0, 600, 330)); + expect( + tester.getRect(_of('wheel')), + const Rect.fromLTWH(225 + 75, 165, 75, 165), + ); + }); + + testWidgets('contain scales the design up into a bigger box', (tester) async { + final (:truck, :wheel) = _truck(); + await _pump(tester, [truck, wheel], box: const Size(600, 1320)); + expect( + tester.getRect(_of('wheel')), + const Rect.fromLTWH(300, 660, 300, 660), + ); + }); + + testWidgets('scaleDown never scales up', (tester) async { + final (:truck, :wheel) = _truck( + fit: Fit.scaleDown, + alignment: AlignmentModel.topLeft, + ); + await _pump(tester, [truck, wheel], box: const Size(600, 1320)); + expect( + tester.getRect(_of('wheel')), + const Rect.fromLTWH(150, 330, 150, 330), + ); + }); + + testWidgets('a fixed-size frame shows its content unscaled', (tester) async { + final (:truck, :wheel) = _truck(fits: SizeFit.fixed); + await _pump(tester, [truck, wheel], box: const Size(600, 1320)); + expect(tester.getRect(_of('truck')), const Rect.fromLTWH(0, 0, 300, 660)); + expect( + tester.getRect(_of('wheel')), + const Rect.fromLTWH(150, 330, 150, 330), + ); + }); + + testWidgets('without a content fit the children are not scaled', ( + tester, + ) async { + final (:truck, :wheel) = _truck(fit: null); + await _pump(tester, [truck, wheel], box: const Size(600, 330)); + expect(tester.getSize(_of('wheel')), const Size(150, 330)); + }); + + test('contentFit round-trips through JSON and is omitted when unset', () { + final (:truck, wheel: _) = _truck( + fit: Fit.scaleDown, + alignment: AlignmentModel.topCenter, + ); + final FrameNode copy = FrameNode.fromJson(truck.toJson()); + expect(copy.contentFit, Fit.scaleDown); + expect(copy.contentAlignment, AlignmentModel.topCenter); + final Map plain = _truck(fit: null).truck.toJson(); + expect(plain.containsKey('contentFit'), isFalse); + expect(plain.containsKey('contentAlignment'), isFalse); + }); +} diff --git a/test/sheet_presentation_test.dart b/test/sheet_presentation_test.dart new file mode 100644 index 00000000..59f14c97 --- /dev/null +++ b/test/sheet_presentation_test.dart @@ -0,0 +1,471 @@ +import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:codelessly_sdk/codelessly_sdk.dart'; +import 'package:codelessly_sdk/src/ui/layout_builder.dart'; +import 'package:codelessly_sdk/src/ui/layout_presentation_route.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; + +const Size phone = Size(390, 844); +const Size tablet = Size(834, 1194); +const Size desktop = Size(1440, 900); + +PaintModel paint(ColorRGBA color) => PaintModel.solid( + id: 'paint', + color: color.toRGB(), + opacity: color.a, + visible: true, +); + +CanvasNode canvas( + String id, + String bodyId, { + double width = 390, + double height = 844, + SizeFit verticalFit = SizeFit.fixed, +}) => CanvasNode( + id: id, + name: id, + basicBoxLocal: NodeBox(0, 0, width, height), + children: [bodyId], + scaleMode: ScaleMode.responsive, + verticalFit: verticalFit, + fills: [paint(ColorRGBA.white)], + properties: CanvasProperties( + bodyId: bodyId, + safeArea: const SafeAreaModel.all(false), + ), +)..parentID = kRootNode; + +AutoPlaceholderNode body( + String id, + String canvasId, + List children, { + double width = 390, + double height = 844, +}) => AutoPlaceholderNode( + id: id, + name: id, + basicBoxLocal: NodeBox(0, 0, width, height), + children: children, + mainAxisAlignment: MainAxisAlignmentC.start, + horizontalFit: SizeFit.expanded, + verticalFit: SizeFit.expanded, +)..parentID = canvasId; + +RectangleNode box(String id, String parent, double height) => + RectangleNode(id: id, name: id, basicBoxLocal: NodeBox(0, 0, 390, height)) + ..parentID = parent; + +ButtonNode button( + String id, + String parent, + String label, + List actions, +) => ButtonNode( + id: id, + name: id, + basicBoxLocal: NodeBox(0, 0, 160, 44), + properties: ButtonProperties( + label: label, + buttonType: ButtonTypeEnum.filled, + buttonColor: ColorRGBA.black, + ), + reactions: [ + for (final action in actions) + Reaction( + action: action, + trigger: TriggerModel(type: TriggerType.click), + ), + ], +)..parentID = parent; + +SDKPublishLayout layoutOf(String id, List nodes) => SDKPublishLayout( + id: id, + pageId: 'page', + projectId: 'project', + canvases: { + nodes.first.id: {for (final node in nodes) node.id: node}, + }, + lastUpdated: DateTime(2026), + owner: 'owner', + teams: const {}, + users: const {}, + roles: const {}, + public: false, +); + +/// The main screen: a button that runs [actions]. +SDKPublishLayout mainLayout(List actions) => layoutOf('main', [ + canvas('main', 'mainBody'), + body('mainBody', 'main', ['go']), + button('go', 'mainBody', 'Go', actions), +]); + +/// A sheet canvas designed at 390 wide whose height is its content: a +/// [contentHeight] tall box and a Done button that navigates back with +/// `{flag: yes}`. +SDKPublishLayout sheetLayout({ + double contentHeight = 300, + SizeFit verticalFit = SizeFit.shrinkWrap, +}) => layoutOf('sheet', [ + canvas('sheetCanvas', 'sheetBody', verticalFit: verticalFit), + body('sheetBody', 'sheetCanvas', ['content', 'done']), + box('content', 'sheetBody', contentHeight), + button('done', 'sheetBody', 'Done', [ + NavigationAction( + navigationType: NavigationType.pop, + destinationId: '', + params: {'flag': 'yes'}, + ), + ]), +]); + +class _FakeCache extends Fake implements CacheManager {} + +class _FakeAuthManager extends Fake implements AuthManager { + @override + AuthData? get authData => AuthData( + authToken: 'token', + projectId: 'project', + timestamp: DateTime(2026), + owner: 'owner', + teams: const {}, + users: const {}, + roles: const {}, + ); +} + +class _FakeLocalRepository extends Fake implements LocalDataRepository {} + +class _FakeNetworkRepository extends Fake implements NetworkDataRepository {} + +class _FakeFirestore extends Fake implements FirebaseFirestore {} + +class _FakeLocalDatabase extends Fake implements LocalDatabase {} + +class _TestDataManager extends DataManager { + _TestDataManager(SDKPublishModel model) + : super( + 'test', + config: CodelesslyConfig(authToken: 'token'), + cacheManager: _FakeCache(), + authManager: _FakeAuthManager(), + networkDataRepository: _FakeNetworkRepository(), + localDataRepository: _FakeLocalRepository(), + firebaseFirestore: _FakeFirestore(), + errorLogger: ErrorLogger.instance, + publishModel: model, + ); + + @override + LocalDatabase get localDatabase => _FakeLocalDatabase(); +} + +/// Pumps [layouts.first] on a screen of [size]. +Future pumpLayouts( + WidgetTester tester, + List layouts, { + required Size size, +}) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = size; + addTearDown(tester.view.reset); + + final model = SDKPublishModel( + projectId: 'project', + owner: 'owner', + teams: const {}, + users: const {}, + roles: const {}, + public: false, + ); + for (final layout in layouts) { + model.layouts[layout.id] = layout; + } + final codelessly = Codelessly( + config: CodelesslyConfig(authToken: 'token'), + publishDataManager: _TestDataManager(model)..status = CStatus.loaded(), + )..status = CStatus.loaded(); + final SDKPublishLayout main = layouts.first; + await tester.pumpWidget( + MultiProvider( + providers: [ + ChangeNotifierProvider.value( + value: CodelesslyContext.empty(layoutID: main.id), + ), + Provider.value(value: codelessly), + ], + child: MaterialApp( + home: CodelesslyLayoutBuilder( + controller: CodelesslyWidgetController( + layoutID: main.id, + codelessly: codelessly, + ), + layout: main, + canvasId: main.canvases.keys.first, + ), + ), + ), + ); + await tester.pump(); +} + +final Finder bottomSheet = find.byType(BottomSheet); +final Finder sideSheet = find.byKey(const ValueKey('Codelessly side sheet')); +final Finder dragHandle = find.byKey( + const ValueKey('Codelessly sheet drag handle'), +); + +void main() { + for (final Size size in [phone, tablet]) { + testWidgets('a bottom sheet spans the screen at the bottom, hugs its ' + 'content and slides up at ${size.width.toInt()}', (tester) async { + await pumpLayouts(tester, [ + mainLayout([ + ShowDialogAction( + destinationId: 'sheetCanvas', + presentation: DialogPresentation.bottomSheet, + showDragHandle: true, + ), + ]), + sheetLayout(), + ], size: size); + + await tester.tap(find.text('Go')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + // Part way in, the sheet is below its resting place. + final double midTop = tester.getRect(bottomSheet).top; + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + + final Rect sheet = tester.getRect(bottomSheet); + expect(midTop, greaterThan(sheet.top)); + expect(sheet.bottom, size.height); + expect(sheet.height, lessThan(size.height / 2)); + expect(tester.widget(bottomSheet).showDragHandle, isTrue); + }); + } + + testWidgets('a saved bottom sheet has no drag handle', (tester) async { + await pumpLayouts(tester, [ + mainLayout([ + ShowDialogAction( + destinationId: 'sheetCanvas', + presentation: DialogPresentation.bottomSheet, + ), + ]), + sheetLayout(), + ], size: phone); + await tester.tap(find.text('Go')); + await tester.pumpAndSettle(); + expect(tester.widget(bottomSheet).showDragHandle, isFalse); + }); + + testWidgets('a bottom sheet is at most 92% of the screen tall', ( + tester, + ) async { + await pumpLayouts(tester, [ + mainLayout([ + ShowDialogAction( + destinationId: 'sheetCanvas', + presentation: DialogPresentation.bottomSheet, + maxHeightFactor: 0.92, + ), + ]), + sheetLayout(contentHeight: 1200, verticalFit: SizeFit.fixed), + ], size: phone); + await tester.tap(find.text('Go')); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + final Rect sheet = tester.getRect(bottomSheet); + expect(sheet.height, lessThanOrEqualTo(844 * 0.92 + 0.01)); + expect(sheet.bottom, 844); + }); + + for (final (Size size, double? width) in [ + (tablet, 440.0), + (desktop, 480.0), + (desktop, null), + ]) { + testWidgets('a side sheet is ${width ?? 'the canvas'} wide on the right ' + 'at ${size.width.toInt()}', (tester) async { + await pumpLayouts(tester, [ + mainLayout([ + ShowDialogAction( + destinationId: 'sheetCanvas', + presentation: DialogPresentation.sideSheet, + sheetWidth: width, + ), + ]), + sheetLayout(), + ], size: size); + await tester.tap(find.text('Go')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + final double midLeft = tester.getRect(sideSheet).left; + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + + final Rect sheet = tester.getRect(sideSheet); + expect(midLeft, greaterThan(sheet.left)); + expect(sheet.width, width ?? 390); + expect(sheet.right, size.width); + expect(sheet.top, 0); + expect(sheet.height, size.height); + expect(dragHandle, findsNothing); + }); + } + + for (final presentation in DialogPresentation.values) { + testWidgets('tapping the scrim dismisses a ${presentation.name}', ( + tester, + ) async { + await pumpLayouts(tester, [ + mainLayout([ + ShowDialogAction( + destinationId: 'sheetCanvas', + presentation: presentation, + sheetWidth: 440, + ), + ShowToastAction(message: r'Closed with ${result.flag}'), + ]), + sheetLayout(), + ], size: tablet); + await tester.tap(find.text('Go')); + await tester.pumpAndSettle(); + expect(find.text('Done'), findsOneWidget); + + await tester.tapAt(const Offset(10, 10)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + expect(find.text('Done'), findsNothing); + // Dismissed, it passes nothing back. + expect(find.text('Closed with '), findsOneWidget); + await tester.pump(const Duration(seconds: 3)); + await tester.pumpAndSettle(); + }); + + testWidgets('a ${presentation.name} that is not barrier dismissible ' + 'stays open', (tester) async { + await pumpLayouts(tester, [ + mainLayout([ + ShowDialogAction( + destinationId: 'sheetCanvas', + presentation: presentation, + sheetWidth: 440, + barrierDismissible: false, + ), + ]), + sheetLayout(), + ], size: tablet); + await tester.tap(find.text('Go')); + await tester.pumpAndSettle(); + await tester.tapAt(const Offset(10, 10)); + await tester.pumpAndSettle(); + expect(find.text('Done'), findsOneWidget); + }); + + testWidgets('navigating back from a ${presentation.name} passes its params ' + 'to the next action', (tester) async { + await pumpLayouts(tester, [ + mainLayout([ + ShowDialogAction( + destinationId: 'sheetCanvas', + presentation: presentation, + ), + ShowToastAction(message: r'Flagged: ${result.flag}'), + ]), + sheetLayout(), + ], size: phone); + await tester.tap(find.text('Go')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Done')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + expect(tester.takeException(), isNull); + expect(find.text('Done'), findsNothing); + expect(find.text('Flagged: yes'), findsOneWidget); + await tester.pump(const Duration(seconds: 3)); + await tester.pumpAndSettle(); + }); + } + + testWidgets('a confirm with a canvas dialog shows it as a bottom sheet and ' + 'confirms from it', (tester) async { + await pumpLayouts(tester, [ + mainLayout([ + ConfirmAction( + title: 'Reopen?', + dialogId: 'confirmCanvas', + presentation: DialogPresentation.bottomSheet, + showDragHandle: true, + actions: [ShowToastAction(message: 'Reopened')], + ), + ]), + layoutOf('confirm', [ + canvas('confirmCanvas', 'confirmBody', verticalFit: SizeFit.shrinkWrap), + body('confirmBody', 'confirmCanvas', ['yes']), + button('yes', 'confirmBody', 'Reopen', [ + NavigationAction( + navigationType: NavigationType.pop, + destinationId: '', + params: {ConfirmAction.resultKey: true}, + ), + ]), + ]), + ], size: phone); + await tester.tap(find.text('Go')); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + final Rect sheet = tester.getRect(bottomSheet); + expect(sheet.bottom, phone.height); + expect(tester.widget(bottomSheet).showDragHandle, isTrue); + + await tester.tap(find.text('Reopen')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + expect(bottomSheet, findsNothing); + expect(find.text('Reopened'), findsOneWidget); + await tester.pump(const Duration(seconds: 3)); + await tester.pumpAndSettle(); + }); + + testWidgets('a saved show-dialog action stays a centered dialog', ( + tester, + ) async { + final action = ActionModel.fromJson({ + 'type': 'showDialog', + 'destinationId': 'dialogCanvas', + }) as ShowDialogAction; + await pumpLayouts(tester, [ + mainLayout([action]), + layoutOf('dialog', [ + canvas('dialogCanvas', 'dialogBody', width: 300, height: 200), + body('dialogBody', 'dialogCanvas', ['ok'], width: 300, height: 200), + button('ok', 'dialogBody', 'OK', [ + NavigationAction( + navigationType: NavigationType.pop, + destinationId: '', + ), + ]), + ]), + ], size: desktop); + await tester.tap(find.text('Go')); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + expect(bottomSheet, findsNothing); + expect(sideSheet, findsNothing); + final Finder card = find.byKey( + const ValueKey('Rectangle Transformer of dialogCanvas [dialogCanvas]'), + ); + expect(tester.getSize(card), const Size(300, 200)); + expect(tester.getCenter(card), desktop.center(Offset.zero)); + expect( + LayoutPresentationRoute.enterDuration, + const Duration(milliseconds: 320), + ); + }); +} diff --git a/test/signature_test.dart b/test/signature_test.dart new file mode 100644 index 00000000..93524bd3 --- /dev/null +++ b/test/signature_test.dart @@ -0,0 +1,233 @@ +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:codelessly_sdk/codelessly_sdk.dart'; +import 'package:codelessly_sdk/src/functions/action_chain.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; + +SignatureNode signature({ + Map variables = const {}, + List reactions = const [], +}) => SignatureNode( + id: 'signature', + name: 'Signature', + basicBoxLocal: NodeBox(0, 0, 320, 140), + properties: SignatureProperties(), + reactions: reactions, +)..variables.addAll(variables); + +Observable listVariable(String name, [String value = '[]']) => + Observable( + VariableData(id: name, name: name, value: value, type: VariableType.list), + ); + +Future pumpNode( + WidgetTester tester, + BaseNode node, + CodelesslyContext codelesslyContext, +) async { + final registry = NodeRegistry()..setNodes({node.id: node}); + final manager = PassiveNodeTransformerManager( + registry.getNodeByID, + (context, bounds, pageID, layoutID, canvasID) => const SizedBox(), + ); + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: codelesslyContext, + child: MaterialApp( + home: ActionChainHost( + child: Scaffold( + body: Center( + child: Builder( + builder: (context) => manager.buildWidgetFromNode( + node, + context, + settings: const WidgetBuildSettings(debugLabel: 'test'), + ), + ), + ), + ), + ), + ), + ), + ); +} + +/// The strokes the pad paints. +List> paintedStrokes(WidgetTester tester) { + final CustomPaint paint = tester.widget( + find.byWidgetPredicate( + (widget) => widget is CustomPaint && widget.painter is SignaturePainter, + ), + ); + return (paint.painter! as SignaturePainter).strokes; +} + +void main() { + testWidgets('drawing on the pad fills its variable and fires changed', ( + tester, + ) async { + final sig = listVariable('signature'); + final codelesslyContext = CodelesslyContext.empty() + ..variables['signature'] = sig + ..variables['changes'] = Observable( + VariableData( + id: 'changes', + name: 'changes', + value: '0', + type: VariableType.integer, + ), + ); + final node = signature( + variables: {'value': r'${signature}'}, + reactions: [ + Reaction( + trigger: TriggerModel(type: TriggerType.changed), + action: SetVariableAction( + variable: VariableData( + id: 'changes', + name: 'changes', + type: VariableType.integer, + ), + newValue: r'${= changes + 1}', + ), + ), + ], + ); + await pumpNode(tester, node, codelesslyContext); + + expect(find.byType(TransformerSignature), findsOneWidget); + expect(paintedStrokes(tester), isEmpty); + + final Offset start = tester.getTopLeft(find.byType(TransformerSignature)); + await tester.dragFrom(start + const Offset(40, 40), const Offset(120, 30)); + await tester.pumpAndSettle(); + + final Object? value = sig.value.getValue(); + expect(value, isA()); + expect(value as List, hasLength(1)); + expect((value.single as List).length, greaterThan(1)); + expect((value.single as List).first, [40.0, 40.0]); + expect( + codelesslyContext.variables['changes']!.value.getValue(), + 1, + reason: 'changed fires once per stroke', + ); + expect(paintedStrokes(tester), hasLength(1)); + + // A second stroke adds to the first. + await tester.dragFrom(start + const Offset(60, 90), const Offset(0, -40)); + await tester.pumpAndSettle(); + expect(sig.value.getValue() as List, hasLength(2)); + expect(codelesslyContext.variables['changes']!.value.getValue(), 2); + + // Formulas see the signature as a list. + final scoped = ScopedValues.preview(variables: {'signature': sig.value}); + expect( + PropertyValueDelegate.substituteVariables( + r'${signature.isEmpty} ${= length(signature) > 0}', + scopedValues: scoped, + nullSubstitutionMode: NullSubstitutionMode.nullValue, + ), + 'false true', + ); + + // Clearing the variable clears the pad. + sig.value = sig.value.copyWith(value: '[]'); + await tester.pumpAndSettle(); + expect(paintedStrokes(tester), isEmpty); + expect(sig.value.getValue(), isEmpty); + }); + + testWidgets('a set variable action with [] clears the pad', (tester) async { + final sig = listVariable('signature', '[[[1,2],[3,4]]]'); + final codelesslyContext = CodelesslyContext.empty() + ..variables['signature'] = sig; + await pumpNode( + tester, + signature(variables: {'value': r'${signature}'}), + codelesslyContext, + ); + expect(paintedStrokes(tester), [ + [const Offset(1, 2), const Offset(3, 4)], + ]); + + final BuildContext context = tester.element( + find.byType(TransformerSignature), + ); + await FunctionsRepository.performAction( + context, + SetVariableAction( + variable: VariableData( + id: 'signature', + name: 'signature', + type: VariableType.list, + ), + newValue: '[]', + ), + ); + await tester.pumpAndSettle(); + expect(paintedStrokes(tester), isEmpty); + }); + + testWidgets('a pad whose enabled binding is false takes no strokes', ( + tester, + ) async { + final sig = listVariable('signature'); + final node = signature( + variables: {'value': r'${signature}', 'enabled': r'${locked.toggled}'}, + ); + await pumpNode( + tester, + node, + CodelesslyContext.empty() + ..variables['signature'] = sig + ..variables['locked'] = Observable( + VariableData( + id: 'locked', + name: 'locked', + value: 'true', + type: VariableType.boolean, + ), + ), + ); + final Offset start = tester.getTopLeft(find.byType(TransformerSignature)); + await tester.dragFrom(start + const Offset(40, 40), const Offset(120, 30)); + await tester.pumpAndSettle(); + expect(sig.value.getValue(), isEmpty); + expect(paintedStrokes(tester), isEmpty); + }); + + testWidgets('without a binding the pad keeps its strokes itself', ( + tester, + ) async { + await pumpNode(tester, signature(), CodelesslyContext.empty()); + final Offset start = tester.getTopLeft(find.byType(TransformerSignature)); + await tester.dragFrom(start + const Offset(40, 40), const Offset(120, 30)); + await tester.pumpAndSettle(); + expect(paintedStrokes(tester), hasLength(1)); + }); + + testWidgets('without a runtime the pad keeps its strokes itself', ( + tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Center( + child: PassiveSignatureWidget( + node: signature(variables: {'value': r'${signature}'}), + settings: const WidgetBuildSettings( + debugLabel: 'test', + isPreview: true, + ), + ), + ), + ), + ); + final Offset start = tester.getTopLeft(find.byType(TransformerSignature)); + await tester.dragFrom(start + const Offset(40, 40), const Offset(120, 30)); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + expect(paintedStrokes(tester), hasLength(1)); + }); +} diff --git a/test/text_field_error_test.dart b/test/text_field_error_test.dart new file mode 100644 index 00000000..93692d4a --- /dev/null +++ b/test/text_field_error_test.dart @@ -0,0 +1,172 @@ +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 ColorRGBA _red = ColorRGBA(r: 1, g: 0, b: 0, a: 1); +const ColorRGBA _grey = ColorRGBA(r: 0.5, g: 0.5, b: 0.5, a: 1); + +TextFieldNode _field(Map variables, {String? errorText}) => + TextFieldNode( + id: 'field', + name: 'Field', + basicBoxLocal: NodeBox(0, 0, 300, 56), + properties: TextFieldProperties( + decoration: InputDecorationModel( + errorText: errorText, + enabledBorder: const InputBorderModel( + borderType: BorderTypeEnum.outline, + borderSide: BorderSideModel(color: _grey, width: 1), + ), + errorBorder: const InputBorderModel( + borderType: BorderTypeEnum.outline, + borderSide: BorderSideModel(color: _red, width: 2), + ), + ), + ), + )..variables.addAll(variables); + +Observable _variable( + String name, + String value, + VariableType type, +) => Observable(VariableData(id: name, name: name, value: value, type: type)); + +CodelesslyContext _context({required bool showErrors, required String note}) => + CodelesslyContext.empty() + ..variables['showErrors'] = _variable( + 'showErrors', + '$showErrors', + VariableType.boolean, + ) + ..variables['note'] = _variable('note', note, VariableType.text); + +const String _errorFormula = + r'${= showErrors && length(trim(note)) < 10 ? "Describe the repair" : ""}'; + +Future _pump( + WidgetTester tester, + TextFieldNode node, + CodelesslyContext codelesslyContext, +) async { + final registry = NodeRegistry()..setNodes({node.id: node}); + final manager = PassiveNodeTransformerManager( + registry.getNodeByID, + (context, bounds, pageID, layoutID, canvasID) => const SizedBox(), + ); + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: codelesslyContext, + child: MaterialApp( + home: Scaffold( + body: Center( + child: Builder( + builder: (context) => manager.buildWidgetFromNode( + node, + context, + settings: const WidgetBuildSettings(debugLabel: 'test'), + ), + ), + ), + ), + ), + ), + ); + await tester.pump(); + return tester.widget(find.byType(InputDecorator)).decoration; +} + +Color? _enabledBorderColor(InputDecoration decoration) => + (decoration.enabledBorder as OutlineInputBorder?)?.borderSide.color; + +void main() { + testWidgets('an errorText formula that comes out empty is no error', ( + tester, + ) async { + final decoration = await _pump( + tester, + _field({'errorText': _errorFormula}), + _context(showErrors: false, note: ''), + ); + expect(decoration.error, isNull); + expect(decoration.errorText, isNull); + expect(find.text('Describe the repair'), findsNothing); + expect(_enabledBorderColor(decoration), _grey.toFlutterColor()); + }); + + testWidgets('an errorText formula with a message shows the error', ( + tester, + ) async { + final decoration = await _pump( + tester, + _field({'errorText': _errorFormula}), + _context(showErrors: true, note: 'short'), + ); + expect(decoration.error, isNotNull); + expect(find.text('Describe the repair'), findsOneWidget); + expect(find.textContaining(r'${'), findsNothing); + }); + + testWidgets('the error follows its variables as they change', (tester) async { + final codelesslyContext = _context(showErrors: true, note: 'short'); + await _pump( + tester, + _field({'errorText': _errorFormula}), + codelesslyContext, + ); + expect(find.text('Describe the repair'), findsOneWidget); + + codelesslyContext.variables['note']!.value = codelesslyContext + .variables['note']! + .value + .copyWith(value: 'Replaced the brake pads'); + await tester.pump(); + expect(find.text('Describe the repair'), findsNothing); + }); + + testWidgets('an error binding gives the error border without a message', ( + tester, + ) async { + final decoration = await _pump( + tester, + _field({'error': r'${= showErrors && length(trim(note)) < 10}'}), + _context(showErrors: true, note: ''), + ); + expect(decoration.error, isNull); + final OutlineInputBorder border = + decoration.enabledBorder! as OutlineInputBorder; + expect(border.borderSide.color, const Color(0xFFFF0000)); + expect(border.borderSide.width, 2); + }); + + testWidgets('a false error binding keeps the normal border', (tester) async { + final decoration = await _pump( + tester, + _field({'error': r'${= showErrors && length(trim(note)) < 10}'}), + _context(showErrors: false, note: ''), + ); + expect(_enabledBorderColor(decoration), _grey.toFlutterColor()); + }); + + testWidgets('a static errorText still shows', (tester) async { + final decoration = await _pump( + tester, + _field(const {}, errorText: 'Required'), + CodelesslyContext.empty(), + ); + expect(decoration.error, isNotNull); + expect(find.text('Required'), findsOneWidget); + }); + + testWidgets('enabled and readOnly follow their bindings', (tester) async { + await _pump( + tester, + _field({'enabled': r'${= !showErrors}', 'readOnly': r'${showErrors}'}), + _context(showErrors: true, note: ''), + ); + final TextField field = tester.widget(find.byType(TextField)); + expect(field.enabled, isFalse); + expect(field.readOnly, isTrue); + }); +} diff --git a/test/text_field_lines_test.dart b/test/text_field_lines_test.dart index 308b2207..243063f4 100644 --- a/test/text_field_lines_test.dart +++ b/test/text_field_lines_test.dart @@ -142,6 +142,28 @@ void main() { _expectOnScreen(tester, size); }); + testWidgets('fixed-height password field is obscured at $label', ( + tester, + ) async { + await _pump( + tester, + _field( + maxLines: 1, + minLines: null, + verticalFit: SizeFit.fixed, + obscureText: true, + ), + size, + ); + expect(tester.takeException(), isNull); + final TextField field = tester.widget(find.byType(TextField)); + expect(field.obscureText, isTrue); + expect(field.expands, isFalse); + expect(field.maxLines, 1); + expect(tester.getSize(find.byType(TextField)).height, 120); + _expectOnScreen(tester, size); + }); + testWidgets('fixed-height field expands with lines set at $label', ( tester, ) async { diff --git a/test/toast_style_test.dart b/test/toast_style_test.dart new file mode 100644 index 00000000..b6cab603 --- /dev/null +++ b/test/toast_style_test.dart @@ -0,0 +1,129 @@ +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 ColorRGBA _yellow = ColorRGBA(r: 1, g: 0.76, b: 0.055, a: 1); + +Future _show(WidgetTester tester, ShowToastAction action) async { + late BuildContext context; + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: CodelesslyContext.empty(), + child: MaterialApp( + home: Builder( + builder: (c) { + context = c; + return const SizedBox(); + }, + ), + ), + ), + ); + AppToast.show(context, action); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); +} + +Future _finish(WidgetTester tester) async { + await tester.pump(const Duration(seconds: 5)); + await tester.pumpAndSettle(); +} + +final Finder _toast = find.byKey(const ValueKey('codelessly-toast')); + +BoxDecoration _decoration(WidgetTester tester) => + tester.widget(_toast).decoration! as BoxDecoration; + +void main() { + testWidgets('an accent color draws a bar down the left edge', (tester) async { + await _show( + tester, + ShowToastAction( + message: 'DF-3104 marked repaired', + backgroundColor: ColorRGBA.fromHex('#16191D'), + textColor: ColorRGBA.fromHex('#E8EAEC'), + accentColor: _yellow, + cornerRadius: 4, + duration: 1000, + ), + ); + final Border border = _decoration(tester).border! as Border; + expect(border.left.color, _yellow.toFlutterColor()); + expect(border.left.width, 4); + expect(border.top, BorderSide.none); + final ClipRRect clip = tester.widget( + find.ancestor(of: _toast, matching: find.byType(ClipRRect)).first, + ); + expect(clip.borderRadius, BorderRadius.circular(4)); + expect(tester.takeException(), isNull); + await _finish(tester); + }); + + testWidgets('without an accent there is no bar and the toast is a pill', ( + tester, + ) async { + await _show(tester, ShowToastAction(message: 'Saved', duration: 1000)); + expect(_decoration(tester).border, isNull); + final ClipRRect clip = tester.widget( + find.ancestor(of: _toast, matching: find.byType(ClipRRect)).first, + ); + expect(clip.borderRadius, BorderRadius.circular(999)); + await _finish(tester); + }); + + testWidgets('the icon takes the icon color and the text the font', ( + tester, + ) async { + await _show( + tester, + ShowToastAction( + message: 'Saved', + textColor: ColorRGBA.white, + iconColor: _yellow, + icon: MultiSourceIconModel.icon( + icon: CupertinoIcon(codepoint: 0xf3fb, name: 'check_mark_circled'), + size: 20, + ), + fontFamily: 'Fleet Sans', + duration: 1000, + ), + ); + final Icon icon = tester.widget( + find.descendant(of: _toast, matching: find.byType(Icon)), + ); + expect(icon.color, _yellow.toFlutterColor()); + expect(icon.size, 20); + final Text text = tester.widget(find.text('Saved')); + expect(text.style!.fontFamily, AppToast.fontOf('Fleet Sans')!.fontFamily); + expect(text.style!.color, Colors.white); + await _finish(tester); + }); + + test('the new fields round-trip through JSON and are omitted by default', () { + final ShowToastAction toast = ShowToastAction.fromJson({ + 'type': 'showToast', + 'message': 'Hi', + 'accentColor': {'r': 1, 'g': 0, 'b': 0, 'a': 1}, + 'accentWidth': 3, + 'iconColor': {'r': 0, 'g': 1, 'b': 0, 'a': 1}, + 'fontFamily': 'Barlow', + 'cornerRadius': 4, + }); + expect(toast.accentWidth, 3); + expect(toast.fontFamily, 'Barlow'); + expect(toast.cornerRadius, 4); + expect(ShowToastAction.fromJson(toast.toJson()), toast); + final Map json = ShowToastAction(message: 'Hi').toJson(); + for (final key in [ + 'accentColor', + 'accentWidth', + 'iconColor', + 'fontFamily', + 'cornerRadius', + ]) { + expect(json.containsKey(key), isFalse, reason: key); + } + }); +} diff --git a/test/transformers_test.dart b/test/transformers_test.dart index 3b21e502..1f7bf55a 100644 --- a/test/transformers_test.dart +++ b/test/transformers_test.dart @@ -38,6 +38,7 @@ List standaloneNodes() => [ for (final type in ChipType.values) ChipNode.fromProperties(ChipProperties(chipType: type)), SegmentedButtonNode.empty(), + SignatureNode.empty(), AccordionNode( id: 'accordion', name: 'Accordion', diff --git a/test/viewport_layout_test.dart b/test/viewport_layout_test.dart index 4c04e155..50a31254 100644 --- a/test/viewport_layout_test.dart +++ b/test/viewport_layout_test.dart @@ -260,31 +260,6 @@ void main() { }); }); - group('DesignCellGridDelegate', () { - test('keeps the design count at the design size', () { - // 4 columns over 1000 with 8 spacing: cells of 244. - const delegate = DesignCellGridDelegate( - crossAxisCount: 4, - designCellExtent: (1000 - 3 * 8) / 4, - crossAxisSpacing: 8, - ); - expect(delegate.crossAxisCountFor(1000), 4); - expect(delegate.crossAxisCountFor(999), 3); - expect(delegate.crossAxisCountFor(390), 1); - expect(delegate.crossAxisCountFor(100), 1); - expect(delegate.crossAxisCountFor(2000), 7); - }); - - test('falls back to the count without a design cell', () { - const delegate = DesignCellGridDelegate( - crossAxisCount: 3, - designCellExtent: 0, - ); - expect(delegate.crossAxisCountFor(390), 3); - expect(delegate.crossAxisCountFor(1440), 3); - }); - }); - group('subtreeBlocksIntrinsics', () { test('finds a viewport or scroll view below a node', () { final row = RowColumnNode( @@ -468,7 +443,7 @@ void main() { expectVisible(tester, rectOf('item'), screen); }); - forEachLayout('cells tuned at 1440 stay at least as wide as designed', ( + forEachLayout('a fixed cross axis count is kept at every width', ( tester, screen, host, @@ -476,31 +451,131 @@ void main() { await pump(tester, screen, host, grid(), [ rect('item', 'grid', width: 360, height: 360), ]); - // 4 columns of 360 at 1440; 2 at 834; 1 at 390. - final int columns = (screen.width / 360).floor(); final Rect cell = tester.getRect(rectOf('item').first); - expect(cell.width, closeTo(screen.width / columns, 0.01)); - expect(cell.width, greaterThanOrEqualTo(360)); + expect(cell.width, closeTo(screen.width / 4, 0.01)); expect(cell.height, cell.width); }); - forEachLayout('cells tuned at 390 add columns on wider screens', ( + forEachLayout('cells divide the grid width, not the design box', ( tester, screen, host, ) async { - await pump( - tester, - screen, - host, - grid(crossAxisCount: 2, width: 390, height: 844), - [rect('item', 'grid', width: 195, height: 195)], + // The grid the build scripts send: a 100 wide design box. + final GridViewNode node = + NodeJsonConverter().fromJson({ + 'type': 'gridView', + 'id': 'grid', + 'name': 'Vehicle grid', + 'basicBoxLocal': [0, 0, 100, 100], + 'horizontalFit': 'expanded', + 'verticalFit': 'shrinkWrap', + 'isScrollable': false, + 'allowedTypes': [], + 'deniedTypes': ['gridView'], + 'children': ['item'], + 'properties': { + 'itemCount': 4, + 'gridDelegate': { + 'type': 'fixedCrossAxisCount', + 'crossAxisCount': 2, + 'mainAxisSpacing': 16, + 'crossAxisSpacing': 16, + 'mainAxisExtent': 130, + }, + }, + })! + as GridViewNode + ..parentID = kRootNode; + await pump(tester, screen, host, node, [ + rect('item', 'grid', horizontalFit: SizeFit.expanded), + ]); + final List cells = [ + for (final element in rectOf('item').evaluate()) + tester.getRect(find.byWidget(element.widget)), + ]; + expect(cells, hasLength(4)); + for (final Rect cell in cells) { + expect(cell.width, closeTo((screen.width - 16) / 2, 0.01)); + expect(cell.height, 130); + } + expect(cells[1].left - cells[0].right, closeTo(16, 0.01)); + }); + + forEachLayout('a grid node with rows sized to its items', ( + tester, + screen, + host, + ) async { + final GridViewNode node = grid( + crossAxisCount: 2, + width: 390, + height: 300, + verticalFit: SizeFit.shrinkWrap, + itemCount: 4, ); - final int columns = (screen.width / 195).floor(); + node.properties.gridDelegate + ..mainAxisExtent = 130 + ..shrinkWrapRows = true; + await pump(tester, screen, host, node, [ + rect('item', 'grid', height: 70, horizontalFit: SizeFit.expanded), + ]); final Rect cell = tester.getRect(rectOf('item').first); - expect(cell.width, closeTo(screen.width / columns, 0.01)); + expect(cell.width, closeTo(screen.width / 2, 0.01)); + expect(cell.height, 70); + if (host != Host.tight) { + expect(viewportSize(tester), Size(screen.width, 140)); + } }); + for (final bool intrinsicRows in [true, false]) { + testWidgets('rows sized to their content' + '${intrinsicRows ? ' stretch their cells' : ''}', (tester) async { + const List heights = [100, 150, 80, 60, 120]; + await tester.pumpWidget( + MaterialApp( + home: Center( + child: SizedBox( + width: 316, + child: GridViewBuilder( + shrinkWrap: true, + itemCount: heights.length, + gridDelegate: FixedCrossAxisCountGridDelegateProperties( + crossAxisCount: 2, + mainAxisSpacing: 10, + crossAxisSpacing: 16, + mainAxisExtent: 130, + shrinkWrapRows: true, + ), + intrinsicRows: intrinsicRows, + scrollDirection: Axis.vertical, + reverse: false, + clipBehavior: Clip.none, + keyboardDismissBehavior: + ScrollViewKeyboardDismissBehavior.manual, + itemBuilder: (context, index) => + SizedBox(key: ValueKey(index), height: heights[index]), + ), + ), + ), + ), + ); + expect(tester.takeException(), isNull); + Rect cell(int index) => tester.getRect(find.byKey(ValueKey(index))); + // Each row is as tall as its tallest item: 150, 80, 120. + expect( + tester.getSize(find.byType(GridViewBuilder)).height, + 150 + 10 + 80 + 10 + 120, + ); + expect(cell(0).width, 150); + expect(cell(1).left - cell(0).right, 16); + expect(cell(2).top, cell(0).top + 150 + 10); + expect(cell(4).top, cell(2).top + 80 + 10); + expect(cell(0).height, intrinsicRows ? 150 : 100); + expect(cell(3).height, intrinsicRows ? 80 : 60); + }); + } + forEachLayout('shrinkWrap grid sizes to its rows', ( tester, screen,