diff --git a/CHANGELOG.md b/CHANGELOG.md index 555bdd98..33ddd64f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,38 @@ Requires codelessly_api 6.2.0. - Row/column `spacing`, and `wrap` with `runSpacing` (`CodelesslyWrap`). Children hidden by a visibility formula take no space and no gap. - `flex` and `rotation` can be bound to data like fill, opacity and size. +- An expanded child whose bound `flex` is 0 takes no space, where it used to + keep a sliver. + +### Signing + +- Signature nodes take a signature by finger, pen or mouse and write its + strokes to the bound `value` as the pen goes down and lifts, firing + `changed`. Setting the variable redraws the box: `[]` clears it, and a + saved signature replays it. Read-only boxes only show. +- The hold trigger runs its actions once the node has been held for its + duration. Letting go or moving off drains it. While it is held, the node + and the nodes inside read the progress, 0 to 1, as `${hold}`, to fill a + bar or change a label. +- `strokeWeight` can be bound, set by a condition or changed by an action, + for a border that thickens when picked. + +### Dialogs + +- A responsive layout in a centered dialog shows the layout for the screen, + as a layout group in a dialog picks its canvas. It went by the dialog's + own width, so a 520 wide dialog always showed its mobile layout. +- Show dialog actions present their canvas by `presentation`: a centered + dialog, a bottom sheet (full width, as tall as its content up to 90% of + the screen), a sheet on mobile and a dialog on larger screens, or full + screen. `CanvasSizing.sheet` sizes a canvas for a sheet. + +### Icons + +- An icon's `iconName` can be bound to data, set by a condition or changed by + an action. It names a Material Symbol, so one icon node can show the + icon each list item asks for; a Material Icon becomes the symbol of the + same style. ### Formulas and variables @@ -20,6 +52,11 @@ Requires codelessly_api 6.2.0. `padLeft`, `padRight`, `substring`, `split`, `replace`, `coalesce`, `formatNumber` and `matches` (regular expressions). `money` takes a digit count. +- Record helpers: `unset`, `prepend`, `updateWhere`, `removeWhere`, + `findIndex` and `newId`; `set` also takes a list and an index, and + `indexOf` and `slice` also take text. They return new values, so a set + variable action can write a nested change back, e.g. `updateWhere(logs, + l => l.id == logId, l => set(l, 'crew', append(l.crew, entry)))`. - A map's own key wins over a built-in accessor of the same name (`${rig.length}` reads the field). diff --git a/lib/src/functions/functions_repository.dart b/lib/src/functions/functions_repository.dart index 0c1bc3fa..bce68a10 100644 --- a/lib/src/functions/functions_repository.dart +++ b/lib/src/functions/functions_repository.dart @@ -694,28 +694,51 @@ class FunctionsRepository { bool showCloseButton = false, RouteSettings? routeSettings, }) { + final Size? screen = MediaQuery.maybeSizeOf(context); final bool asSheet = switch (presentation) { - DialogPresentation.dialog => false, + DialogPresentation.dialog || DialogPresentation.fullScreen => false, DialogPresentation.bottomSheet => true, - DialogPresentation.adaptive => - (MediaQuery.maybeSizeOf(context)?.width ?? 1024) < 600, + DialogPresentation.adaptive => (screen?.width ?? 1024) < 600, }; - Widget content(BuildContext context) => CodelesslyDialogWidget( - showCloseButton: showCloseButton, - alignment: asSheet ? Alignment.bottomCenter : alignment, - builder: builder, - ); + Widget content(BuildContext context, CanvasSizing sizing) => + CodelesslyDialogWidget( + showCloseButton: showCloseButton, + sizing: sizing, + alignment: asSheet ? Alignment.bottomCenter : alignment, + builder: builder, + ); if (asSheet) { + // As wide as the screen and as tall as its content, up to 90% of the + // screen; the canvas draws its own background and corners. return showModalBottomSheet( context: context, useRootNavigator: false, isScrollControlled: true, isDismissible: barrierDismissible, + enableDrag: barrierDismissible, barrierColor: barrierColor, backgroundColor: Colors.transparent, elevation: 0, + constraints: screen == null + ? null + : BoxConstraints(maxHeight: screen.height * 0.9), + routeSettings: routeSettings, + builder: (context) => content(context, CanvasSizing.sheet), + ); + } + if (presentation == DialogPresentation.fullScreen) { + return showGeneralDialog( + context: context, + useRootNavigator: false, + barrierDismissible: barrierDismissible, + barrierLabel: MaterialLocalizations.of(context) + .modalBarrierDismissLabel, + barrierColor: barrierColor ?? Colors.black, routeSettings: routeSettings, - builder: content, + transitionDuration: const Duration(milliseconds: 200), + transitionBuilder: (context, animation, _, child) => + FadeTransition(opacity: animation, child: child), + pageBuilder: (context, _, _) => content(context, CanvasSizing.fill), ); } return showDialog( @@ -724,7 +747,7 @@ class FunctionsRepository { barrierDismissible: barrierDismissible, barrierColor: barrierColor, routeSettings: routeSettings, - builder: content, + builder: (context) => content(context, CanvasSizing.design), ); } diff --git a/lib/src/transformers/node_transformers/node_transformers.dart b/lib/src/transformers/node_transformers/node_transformers.dart index 8421a1e2..aa4979b4 100644 --- a/lib/src/transformers/node_transformers/node_transformers.dart +++ b/lib/src/transformers/node_transformers/node_transformers.dart @@ -27,6 +27,7 @@ export 'passive_rectangle_transformer.dart'; export 'passive_row_column_transformer.dart'; export 'passive_segmented_button_transformer.dart'; export 'passive_single_placeholder_transformer.dart'; +export 'passive_signature_transformer.dart'; export 'passive_slider_transformer.dart'; export 'passive_spacer_transformer.dart'; export 'passive_stack_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..19f7704b 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 spans the width it is given and keeps its design height (or + /// its content's height when it shrink-wraps vertically), within the + /// height it is given. Used for bottom sheets. + sheet, } /// 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.design || sizing == CanvasSizing.sheet) { + final double width = + sizing == CanvasSizing.sheet && 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) @@ -544,11 +552,15 @@ class CanvasViewport extends StatelessWidget { return LayoutBuilder( builder: (context, constraints) { final box = resolve(node, constraints, sizing: sizing); - // The width the canvas content is laid out at: the canvas box for a + // The width that picks responsive layouts: the canvas box for a // responsive canvas, the design width for an autoScale one (its - // content is laid out at design size and then scaled). + // content is laid out at design size and then scaled). A centered + // dialog keeps its design width on every screen, so it goes by the + // screen, as a layout group in a dialog picks its canvas. final Widget content = CanvasWidthScope( - width: node.scaleMode == ScaleMode.autoScale + width: sizing == CanvasSizing.design + ? MediaQuery.sizeOf(context).width + : node.scaleMode == ScaleMode.autoScale ? node.basicBoxLocal.width : box.width, child: child, diff --git a/lib/src/transformers/node_transformers/passive_icon_transformer.dart b/lib/src/transformers/node_transformers/passive_icon_transformer.dart index e1fddacf..d907079a 100644 --- a/lib/src/transformers/node_transformers/passive_icon_transformer.dart +++ b/lib/src/transformers/node_transformers/passive_icon_transformer.dart @@ -35,15 +35,24 @@ class PassiveIconWidget extends StatelessWidget { @override Widget build(BuildContext context) { + final ScopedValues scopedValues = ScopedValues.of(context); final ColorRGBA? iconColor = PropertyValueDelegate.getPropertyValue( node, 'iconColor', - scopedValues: ScopedValues.of(context), + scopedValues: scopedValues, ); - final MultiSourceIconModel icon = iconColor == null + final String? iconName = PropertyValueDelegate.getPropertyValue( + node, + 'iconName', + scopedValues: scopedValues, + ); + MultiSourceIconModel icon = iconColor == null ? node.properties.icon : node.properties.icon.copyWith(color: iconColor); + if (iconName != null && iconName.trim().isNotEmpty) { + icon = withIconName(icon, iconName.trim()); + } return retrieveIconWidget(icon, node.basicBoxLocal.width) ?? SizedBox( width: node.basicBoxLocal.width, @@ -51,3 +60,32 @@ class PassiveIconWidget extends StatelessWidget { ); } } + +/// [icon] showing the Material Symbol [name] instead, for an `iconName` +/// bound to data. Material Symbols load by name alone (Material Icons need +/// each icon's version), so a Material Icon becomes the Material Symbol of +/// the same style, filled when it was filled. +MultiSourceIconModel withIconName(MultiSourceIconModel icon, String name) { + final IconModel? current = icon.icon; + if (current is MaterialSymbol) { + return icon.copyWith(icon: current.copyWith(name: name), show: true); + } + final MaterialIconStyle? style = current is MaterialIcon + ? current.style + : null; + return icon.copyWith( + type: IconTypeEnum.icon, + show: true, + icon: MaterialSymbol( + codepoint: 0, + fontFamily: 'MaterialSymbols', + name: name, + style: switch (style) { + MaterialIconStyle.rounded => MaterialSymbolStyle.rounded, + MaterialIconStyle.sharp => MaterialSymbolStyle.sharp, + _ => MaterialSymbolStyle.outlined, + }, + ), + fill: style == MaterialIconStyle.filled ? 1 : icon.fill, + ); +} 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..15e190cd 100644 --- a/lib/src/transformers/node_transformers/passive_row_column_transformer.dart +++ b/lib/src/transformers/node_transformers/passive_row_column_transformer.dart @@ -494,17 +494,36 @@ class PassiveRowColumnWidget extends StatelessWidget { if (sizedWidget is! Flexible) { return FlexChildVisibility(collapsed: hidden, child: sizedWidget); } - // Without flex the hidden child would be laid out with unbounded + // A flex bound to data (a bar's share of a gauge) or set by a + // condition replaces the node's own: the row lays out the Flexible, so + // the child's own style overrides can't change its share. One bound to + // no share (an empty progress fill) takes no space. + final num? boundFlex = child.variables.containsKey('flex') + ? PropertyValueDelegate.getPropertyValue( + child, + 'flex', + scopedValues: ScopedValues.of(context), + ) + : null; + final bool empty = boundFlex != null && boundFlex <= 0; + final bool collapsed = hidden || empty; + final int flex = boundFlex != null && boundFlex > 0 + ? boundFlex.round() + : sizedWidget.flex; + final bool isRow = + node is RowColumnMixin && + (node as RowColumnMixin).rowColumnType == RowColumnType.row; + // Without flex the collapsed child would be laid out with unbounded // main-axis constraints, which a scrollable inside it can't take // (a hidden grid computed NaN offsets), so it gets no space at all. return FlexChildVisibility( - collapsed: hidden, + collapsed: collapsed, child: Flexible( - flex: hidden ? 0 : sizedWidget.flex, + flex: collapsed ? 0 : flex, fit: sizedWidget.fit, child: SizedBox( - width: hidden ? 0 : null, - height: hidden ? 0 : null, + width: hidden || (empty && isRow) ? 0 : null, + height: hidden || (empty && !isRow) ? 0 : null, child: sizedWidget.child, ), ), 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..843dc552 --- /dev/null +++ b/lib/src/transformers/node_transformers/passive_signature_transformer.dart @@ -0,0 +1,230 @@ +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:flutter/material.dart'; + +import '../../../codelessly_sdk.dart'; + +/// A signature drawn as strokes of `[x, y]` points normalized to the box. +typedef SignatureStrokes = 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); + } +} + +/// Reads a signature from a bound value: a list of strokes, each a list of +/// `[x, y]` pairs. Anything else reads as no signature. +SignatureStrokes parseSignatureStrokes(Object? value) { + if (value is! List) return []; + final SignatureStrokes strokes = []; + for (final Object? stroke in value) { + if (stroke is! List) continue; + final List> points = []; + for (final Object? point in stroke) { + if (point is List && point.length >= 2) { + final Object? x = point[0]; + final Object? y = point[1]; + if (x is num && y is num) points.add([x.toDouble(), y.toDouble()]); + } + } + if (points.isNotEmpty) strokes.add(points); + } + return strokes; +} + +class PassiveSignatureWidget extends StatefulWidget { + final SignatureNode node; + final WidgetBuildSettings settings; + + const PassiveSignatureWidget({ + super.key, + required this.node, + required this.settings, + }); + + @override + State createState() => _PassiveSignatureWidgetState(); +} + +class _PassiveSignatureWidgetState extends State { + /// The stroke being drawn, kept here until the finger lifts so a moving + /// pen repaints only this box. + List>? _stroke; + + /// The strokes there were when the current stroke began. + SignatureStrokes _base = []; + + /// The strokes this box last wrote, for a box with no bound value. + SignatureStrokes? _written; + + SignatureNode get node => widget.node; + + SignatureStrokes _boundStrokes(ScopedValues scopedValues) { + if (!node.variables.containsKey('value')) return _written ?? node.strokes; + return parseSignatureStrokes( + PropertyValueDelegate.getPropertyValue( + node, + 'value', + scopedValues: scopedValues, + ), + ); + } + + List _normalize(Offset p, Size size) => [ + double.parse((p.dx / size.width).clamp(0, 1).toStringAsFixed(4)), + double.parse((p.dy / size.height).clamp(0, 1).toStringAsFixed(4)), + ]; + + void _write(SignatureStrokes strokes) { + _written = strokes; + FunctionsRepository.setPropertyValue( + context, + node: node, + property: 'value', + value: strokes, + ); + FunctionsRepository.triggerAction( + context, + node: node, + TriggerType.changed, + value: strokes, + ); + } + + @override + Widget build(BuildContext context) { + final ScopedValues scopedValues = ScopedValues.of(context); + final SignatureStrokes strokes = _boundStrokes(scopedValues); + final bool readOnly = + PropertyValueDelegate.getPropertyValue( + node, + 'readOnly', + scopedValues: scopedValues, + ) ?? + node.properties.readOnly; + final Color ink = + (PropertyValueDelegate.getPropertyValue( + node, + 'inkColor', + scopedValues: scopedValues, + ) ?? + node.properties.inkColor) + .toFlutterColor(); + final bool interactive = + !readOnly && node.enabled && !widget.settings.isPreview; + + return SizedBox( + width: node.basicBoxLocal.width, + height: node.basicBoxLocal.height, + child: LayoutBuilder( + builder: (context, constraints) { + final Size size = constraints.biggest; + final Widget paint = CustomPaint( + size: size, + painter: SignaturePainter( + strokes: [...strokes, ?_stroke], + color: ink, + strokeWidth: node.properties.strokeWidth, + ), + ); + final String label = readOnly + ? 'Signature' + : strokes.isEmpty + ? 'Signature pad, empty' + : 'Signature pad, signed'; + if (!interactive) { + return Semantics(container: true, label: label, child: paint); + } + return Semantics( + container: true, + label: label, + child: MouseRegion( + cursor: SystemMouseCursors.precise, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onPanStart: (details) { + _base = strokes; + setState( + () => _stroke = [_normalize(details.localPosition, size)], + ); + // Writing the first point at once lets a "sign here" hint + // and the sign button react as the pen goes down. + _write([...strokes, _stroke!]); + }, + onPanUpdate: (details) => setState( + () => _stroke?.add(_normalize(details.localPosition, size)), + ), + onPanEnd: (_) => _finishStroke(), + onPanCancel: _finishStroke, + child: paint, + ), + ), + ); + }, + ), + ); + } + + void _finishStroke() { + final List>? stroke = _stroke; + if (stroke == null) return; + setState(() => _stroke = null); + _write([..._base, stroke]); + } + + @override + void didUpdateWidget(PassiveSignatureWidget oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.node.id != node.id) { + _stroke = null; + _written = null; + } + } +} + +/// Paints signature strokes with round ink, points normalized to the size. +class SignaturePainter extends CustomPainter { + final SignatureStrokes strokes; + final Color color; + final double strokeWidth; + + SignaturePainter({ + required this.strokes, + required this.color, + required this.strokeWidth, + }); + + @override + void paint(Canvas canvas, Size size) { + final Paint ink = Paint() + ..color = color + ..strokeWidth = strokeWidth + ..style = PaintingStyle.stroke + ..strokeCap = StrokeCap.round + ..strokeJoin = StrokeJoin.round; + for (final List> stroke in strokes) { + if (stroke.isEmpty) continue; + Offset at(List p) => + Offset(p[0] * size.width, p[1] * size.height); + final Offset first = at(stroke.first); + final Path path = Path()..moveTo(first.dx, first.dy); + // A tap leaves a dot. + if (stroke.length == 1) path.lineTo(first.dx + 0.1, first.dy); + for (final List p in stroke.skip(1)) { + final Offset o = at(p); + path.lineTo(o.dx, o.dy); + } + canvas.drawPath(path, ink); + } + } + + @override + bool shouldRepaint(SignaturePainter oldDelegate) => true; +} diff --git a/lib/src/transformers/passive_transformer_manager.dart b/lib/src/transformers/passive_transformer_manager.dart index b977354e..e5acf0dd 100644 --- a/lib/src/transformers/passive_transformer_manager.dart +++ b/lib/src/transformers/passive_transformer_manager.dart @@ -30,6 +30,7 @@ class PassiveNodeTransformerManager extends WidgetNodeTransformerManager { 'appBar': PassiveAppBarTransformer(getNode, this), 'navigationBar': PassiveNavigationBarTransformer(getNode, this), 'switch': PassiveSwitchTransformer(getNode, this), + 'signature': PassiveSignatureTransformer(getNode, this), 'slider': PassiveSliderTransformer(getNode, this), 'placeholder': PassivePlaceholderTransformer(getNode, this), 'singlePlaceholder': PassiveSinglePlaceholderTransformer(getNode, this), @@ -83,7 +84,7 @@ class PassiveNodeTransformerManager extends WidgetNodeTransformerManager { ); } - return _wrapWithListener( + final Widget built = _wrapWithListener( context, node: node, builder: (context) { @@ -96,6 +97,9 @@ class PassiveNodeTransformerManager extends WidgetNodeTransformerManager { return _buildDecorated(context, node, settings); }, ); + return settings.withReactions + ? wrapWithInteractionScopes(context, node, built) + : built; } /// Whether [node] has interaction states, or its `selected` or `disabled` @@ -175,9 +179,9 @@ class PassiveNodeTransformerManager extends WidgetNodeTransformerManager { /// Returns [node] with the style properties that conditions, bound /// variables or formulas, or set value actions give it at runtime: `fill` - /// (a text's color for text nodes), `stroke`, `opacity`, `width`, - /// `height`, `flex` (its share of an expanded row or column, so bars - /// can be sized proportionally) and `rotation` (in degrees). Returns + /// (a text's color for text nodes), `stroke`, `strokeWeight`, `opacity`, + /// `width`, `height`, `flex` (its share of an expanded row or column, so + /// bars can be sized proportionally) and `rotation` (in degrees). Returns /// [node] itself when nothing changes it. BaseNode applyStyleOverrides(BuildContext context, BaseNode node) { final ScopedValues scopedValues = ScopedValues.of(context); @@ -205,6 +209,10 @@ class PassiveNodeTransformerManager extends WidgetNodeTransformerManager { for (final paint in geometry.strokes.skip(1)) paint.toJson(), ]; } + final double? strokeWeight = value('strokeWeight'); + if (strokeWeight != null && strokeWeight != geometry.strokeWeight) { + patch['strokeWeight'] = math.max(0, strokeWeight).toDouble(); + } } if (node is TextNode) { // A text's fill is its color: it replaces the color of every span. diff --git a/lib/src/transformers/transformers.dart b/lib/src/transformers/transformers.dart index a9e95794..cc5c16d8 100644 --- a/lib/src/transformers/transformers.dart +++ b/lib/src/transformers/transformers.dart @@ -9,6 +9,7 @@ export 'utils/condition_evaluator.dart'; export 'utils/condition_visitors.dart'; export 'utils/dash_path.dart'; export 'utils/fit_layout.dart'; +export 'utils/hold_to_confirm.dart'; export 'utils/indexed_item_provider.dart'; export 'utils/node_geometry.dart'; export 'utils/property_overrides.dart'; diff --git a/lib/src/transformers/utils/hold_to_confirm.dart b/lib/src/transformers/utils/hold_to_confirm.dart new file mode 100644 index 00000000..8ea3143e --- /dev/null +++ b/lib/src/transformers/utils/hold_to_confirm.dart @@ -0,0 +1,139 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/widgets.dart'; + +/// Shares how far a [HoldToConfirm] has been held, 0 to 1, with the nodes +/// inside it, which read it as the `hold` variable. +class HoldProgressProvider extends InheritedWidget { + /// How far the hold has progressed, from 0 to 1. + final double progress; + + /// Creates a [HoldProgressProvider]. + const HoldProgressProvider({ + super.key, + required this.progress, + required super.child, + }); + + /// The progress of the nearest [HoldToConfirm] above [context], if any. + static double? maybeOf(BuildContext context) => context + .dependOnInheritedWidgetOfExactType() + ?.progress; + + @override + bool updateShouldNotify(HoldProgressProvider oldWidget) => + progress != oldWidget.progress; +} + +/// Calls [onConfirmed] once [child] has been pressed for [duration]. +/// +/// The progress fills from 0 to 1 while the pointer is down and drains back +/// in [drainDuration] when it is released early or moves away (a scroll, for +/// example). Assistive technologies confirm with a long press. +class HoldToConfirm extends StatefulWidget { + /// How long the hold must last. + final Duration duration; + + /// How long an unfinished hold takes to drain back to 0. + final Duration drainDuration; + + /// Called when a hold completes. + final VoidCallback onConfirmed; + + /// The widget being held. + final Widget child; + + /// Creates a [HoldToConfirm]. + const HoldToConfirm({ + super.key, + required this.duration, + required this.onConfirmed, + required this.child, + this.drainDuration = const Duration(milliseconds: 250), + }); + + @override + State createState() => _HoldToConfirmState(); +} + +class _HoldToConfirmState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _controller = AnimationController( + vsync: this, + duration: widget.duration, + )..addStatusListener(_onStatus); + + int? _pointer; + Offset _downAt = Offset.zero; + + @override + void didUpdateWidget(HoldToConfirm oldWidget) { + super.didUpdateWidget(oldWidget); + _controller.duration = widget.duration; + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + void _onStatus(AnimationStatus status) { + if (status != AnimationStatus.completed) return; + _pointer = null; + _controller.value = 0; + widget.onConfirmed(); + } + + void _down(PointerDownEvent event) { + if (_pointer != null) return; + if (event.kind == PointerDeviceKind.mouse && + event.buttons != kPrimaryMouseButton) { + return; + } + _pointer = event.pointer; + _downAt = event.position; + _controller.forward(); + } + + void _move(PointerMoveEvent event) { + if (event.pointer != _pointer) return; + final double slop = event.kind == PointerDeviceKind.touch + ? kTouchSlop + : kPrecisePointerHitSlop * 4; + if ((event.position - _downAt).distance > slop) _release(); + } + + void _up(PointerEvent event) { + if (event.pointer == _pointer) _release(); + } + + void _release() { + _pointer = null; + if (_controller.isCompleted || _controller.value == 0) return; + _controller.animateBack( + 0, + duration: widget.drainDuration * _controller.value, + curve: Curves.easeOut, + ); + } + + @override + Widget build(BuildContext context) { + return Semantics( + onLongPress: widget.onConfirmed, + child: Listener( + behavior: HitTestBehavior.opaque, + onPointerDown: _down, + onPointerMove: _move, + onPointerUp: _up, + onPointerCancel: _up, + child: AnimatedBuilder( + animation: _controller, + child: widget.child, + builder: (context, child) => + HoldProgressProvider(progress: _controller.value, child: child!), + ), + ), + ); + } +} diff --git a/lib/src/transformers/utils/property_value_delegate.dart b/lib/src/transformers/utils/property_value_delegate.dart index d955a280..078ba6c8 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), + 'hold' => scopedValues.holdProgress ?? 0.0, _ => null, }; diff --git a/lib/src/transformers/utils/scoped_values.dart b/lib/src/transformers/utils/scoped_values.dart index 25d85b63..01b3de86 100644 --- a/lib/src/transformers/utils/scoped_values.dart +++ b/lib/src/transformers/utils/scoped_values.dart @@ -17,6 +17,10 @@ class ScopedValues { /// The exposed internal value of a node in the widget tree. late final Object? nodeState; + /// How far the nearest hold-to-confirm above this context has been held, + /// 0 to 1, or null outside one. + late final double? holdProgress; + /// The route params for this route/page for given context. late final Map routeParams; @@ -75,6 +79,7 @@ class ScopedValues { _dataOverrides = null, indexedItem = null, nodeState = null, + holdProgress = null, routeParams = {}, _appUser = null, screenSize = null; @@ -97,6 +102,7 @@ class ScopedValues { _dataOverrides = data, _previewVariables = variables, nodeState = null, + holdProgress = null, routeParams = {}; /// Creates a new scoped values instance from a given context. @@ -114,6 +120,7 @@ class ScopedValues { indexedItem = IndexedItemProvider.maybeOf(context); screenSize = MediaQuery.maybeSizeOf(context); nodeState = NodeStateProvider.maybeOf(context)?.state; + holdProgress = HoldProgressProvider.maybeOf(context); final instance = context.read(); // Screens the app navigates to carry their parameters; the first screen // shows in the host's route and takes them from the app's link. diff --git a/lib/src/transformers/widget_node_transformer_manager.dart b/lib/src/transformers/widget_node_transformer_manager.dart index 87c40bd2..4e6b72c6 100644 --- a/lib/src/transformers/widget_node_transformer_manager.dart +++ b/lib/src/transformers/widget_node_transformer_manager.dart @@ -225,8 +225,44 @@ abstract class WidgetNodeTransformerManager } } + /// Wraps the whole build of a [node] with hold reactions so they run once + /// it has been held, sharing the progress as `hold`. [child] builds the + /// node below the scope so the node's own bindings see it too. + Widget wrapWithInteractionScopes( + BuildContext context, + BaseNode node, + Widget child, + ) { + if (node is! ReactionMixin) return child; + if (node case CanvasNode() || SpacerNode()) return child; + final List onHoldReactions = (node as ReactionMixin).reactions + .where( + (reaction) => + reaction.trigger.type == TriggerType.hold && + reaction.action.enabled, + ) + .toList(); + if (onHoldReactions.isEmpty) return child; + return HoldToConfirm( + duration: Duration(milliseconds: onHoldReactions.first.trigger.duration), + onConfirmed: () => FunctionsRepository.triggerAction( + context, + TriggerType.hold, + reactions: onHoldReactions, + ), + child: child, + ); + } + /// Convenience method to handle widget reactions. - Widget wrapWithReaction(BuildContext context, BaseNode node, Widget widget) { + Widget wrapWithReaction(BuildContext context, BaseNode node, Widget widget) => + _wrapWithTapReactions(context, node, widget); + + Widget _wrapWithTapReactions( + BuildContext context, + BaseNode node, + Widget widget, + ) { if (node is! ReactionMixin) return widget; if (node case CanvasNode() || SpacerNode()) return widget; if (node is CustomPropertiesMixin && diff --git a/lib/src/ui/codelessly_dialog_widget.dart b/lib/src/ui/codelessly_dialog_widget.dart index e9af3071..3b2ec016 100644 --- a/lib/src/ui/codelessly_dialog_widget.dart +++ b/lib/src/ui/codelessly_dialog_widget.dart @@ -11,27 +11,38 @@ class CodelesslyDialogWidget extends StatelessWidget { /// or anywhere else, e.g. a menu at the top right. final AlignmentGeometry alignment; + /// How the canvas is sized: [CanvasSizing.design] for a dialog, + /// [CanvasSizing.sheet] for a bottom sheet, [CanvasSizing.fill] for a + /// full-screen dialog. + final CanvasSizing sizing; + const CodelesslyDialogWidget({ super.key, required this.builder, required this.showCloseButton, this.alignment = Alignment.center, + this.sizing = CanvasSizing.design, }); @override Widget build(BuildContext context) { - // A dialog is centered and sized by its layout, so its canvas keeps its - // design size instead of filling the screen. + // A dialog is placed and sized by its layout, so its canvas keeps its + // design size instead of filling the screen; a sheet takes the screen's + // width and a full-screen dialog all of it. return CanvasSizingScope( - sizing: CanvasSizing.design, + sizing: sizing, child: Builder(builder: _buildDialog), ); } + /// A sheet sits on the bottom edge wherever the action places a dialog. + AlignmentGeometry get _alignment => + sizing == CanvasSizing.sheet ? Alignment.bottomCenter : alignment; + Widget _buildDialog(BuildContext context) { if (showCloseButton) { return Align( - alignment: alignment, + alignment: _alignment, child: Material( type: MaterialType.transparency, child: Stack( @@ -55,6 +66,7 @@ class CodelesslyDialogWidget extends StatelessWidget { ); } - return Align(alignment: alignment, child: builder(context)); + if (sizing == CanvasSizing.fill) return builder(context); + return Align(alignment: _alignment, child: builder(context)); } } diff --git a/lib/src/utils/extensions.dart b/lib/src/utils/extensions.dart index 8cc1c817..b373801e 100644 --- a/lib/src/utils/extensions.dart +++ b/lib/src/utils/extensions.dart @@ -493,6 +493,32 @@ extension CMainAxisAlignmentHelper on MainAxisAlignmentC { } } +extension CWrapMainAxisAlignmentHelper on MainAxisAlignmentC { + /// The [flutter.WrapAlignment] of a wrapping row/column: used both for the + /// children within a run and for the runs within the cross axis. + flutter.WrapAlignment get flutterWrapAlignment => switch (this) { + MainAxisAlignmentC.start => flutter.WrapAlignment.start, + MainAxisAlignmentC.end => flutter.WrapAlignment.end, + MainAxisAlignmentC.center => flutter.WrapAlignment.center, + MainAxisAlignmentC.spaceBetween => flutter.WrapAlignment.spaceBetween, + MainAxisAlignmentC.spaceAround => flutter.WrapAlignment.spaceAround, + MainAxisAlignmentC.spaceEvenly => flutter.WrapAlignment.spaceEvenly, + }; +} + +extension CWrapCrossAxisAlignmentHelper on CrossAxisAlignmentC { + /// The [flutter.WrapCrossAlignment] of a wrapping row/column. A run is as + /// thick as its thickest child, so there is nothing to stretch to; stretch + /// and baseline fall back to start. + flutter.WrapCrossAlignment get flutterWrapCrossAlignment => switch (this) { + CrossAxisAlignmentC.center => flutter.WrapCrossAlignment.center, + CrossAxisAlignmentC.end => flutter.WrapCrossAlignment.end, + CrossAxisAlignmentC.start || + CrossAxisAlignmentC.stretch || + CrossAxisAlignmentC.baseline => flutter.WrapCrossAlignment.start, + }; +} + extension CCrossAxisAlignmentHelper on CrossAxisAlignmentC { flutter.CrossAxisAlignment get flutterAxis { switch (this) { diff --git a/lib/src/utils/formula.dart b/lib/src/utils/formula.dart index bde71f5c..74b14b85 100644 --- a/lib/src/utils/formula.dart +++ b/lib/src/utils/formula.dart @@ -25,7 +25,7 @@ typedef FormulaPathResolver = Object? Function(String path); /// the same rules as `${path}`, followed by `[index]` or `.key`. /// - `+ - * / %`, comparisons `== != < <= > >=`, `&& || !` and /// `condition ? a : b`. `+` joins text when either side is text. -/// - functions: money, fixed, round, floor, ceil, abs, min, max, sum, count, +/// - functions: money, fixed, thousands, round, floor, ceil, abs, min, max, sum, count, /// length, if, plural, join, upper, lower, trim, contains, startsWith, /// endsWith, where, without, number, text, isEmpty, isNotEmpty, padLeft, /// padRight, substring, split, replace and coalesce. See @@ -55,6 +55,11 @@ typedef FormulaPathResolver = Object? Function(String path); /// - list slicing: first, last, slice(list, start, end), take, skip, /// reverse, range(end) or range(start, end, step), and indexOf(list, /// value or predicate), which is -1 when nothing matches. +/// - record helpers: unset, prepend, updateWhere, removeWhere, findIndex +/// and newId; set also takes a list +/// and an index, and indexOf and slice also take text. Like the list +/// edits they return new values, e.g. `updateWhere(logs, l => l.id == +/// logId, l => set(l, 'crew', append(l.crew, entry)))`. /// - date functions: now, today, parseDate, formatDate, addMinutes, addHours, /// addDays, addMonths, diffMinutes, diffHours, diffDays, year, month, day, /// hour, minute, weekday and daysInMonth. Dates are ISO 8601 text such as @@ -369,7 +374,7 @@ class FormulaEvaluator { ), FormulaFunctionDoc( 'set', - 'set(map, key, value)', + 'set(map, key, value) or set(list, index, value)', "set(rig, 'length', 36) → rig, 36 ft long", ), FormulaFunctionDoc('keys', 'keys(map)', 'keys(rig) → [name, length, …]'), @@ -414,6 +419,29 @@ class FormulaEvaluator { 'thousands(value, decimals)', 'thousands(1094) → 1,094', ), + FormulaFunctionDoc('unset', 'unset(map, key)', 'unset(task, "draft")'), + FormulaFunctionDoc( + 'prepend', + 'prepend(list, item)', + 'prepend(notes, note)', + ), + FormulaFunctionDoc( + 'updateWhere', + 'updateWhere(list, item => condition, item => newItem) or ' + 'updateWhere(list, field, value, item => newItem or map to merge)', + 'updateWhere(tasks, "id", id, t => set(t, "done", !t.done))', + ), + FormulaFunctionDoc( + 'removeWhere', + 'removeWhere(list, item => condition) or removeWhere(list, field, value)', + 'removeWhere(tasks, "id", id)', + ), + FormulaFunctionDoc( + 'findIndex', + 'findIndex(list, item => condition) or findIndex(list, field, value)', + 'findIndex(tasks, "id", id) → 2 (-1 when missing)', + ), + FormulaFunctionDoc('newId', 'newId(prefix)', 'newId("task") → task_k3x9f2'), FormulaFunctionDoc('number', 'number(value)', 'number("42") → 42'), FormulaFunctionDoc('text', 'text(value)', 'text(42) → "42"'), FormulaFunctionDoc( @@ -631,7 +659,7 @@ String _fixed(Object? value, Object? digits) => String _money(Object? value, Object? symbol, [Object? digits]) { final num amount = _num(value); - if (amount.isNaN) return ''; + if (amount.isNaN || amount.isInfinite) return ''; final String sign = amount < 0 ? '-' : ''; return '$sign${symbol ?? r'$'}${_grouped(amount.abs(), digits ?? 2)}'; } @@ -980,6 +1008,53 @@ String? _formatDate(Object? value, Object? pattern) { typedef _Function = Object? Function(List args); +/// [value] as a list: a copy of a list, an empty list for null, else a list +/// of that one value. +List _listOf(Object? value) => switch (value) { + null => [], + final Iterable l => [...l], + _ => [value], +}; + +/// [value] as a map with text keys: a copy of a map, else empty. +Map _mapOf(Object? value) => switch (value) { + final Map m => {for (final e in m.entries) '${e.key}': e.value}, + _ => {}, +}; + +/// A copy of [target] with [key] set to [value]: a map key, or a list index +/// (an index past the end appends). +Object? _set(Object? target, Object? key, Object? value) { + if (target is Iterable) { + final List list = [...target]; + final int? index = _toNum(key)?.toInt(); + if (index == null || index < 0) return list; + if (index >= list.length) return list..add(value); + return list..[index] = value; + } + return _mapOf(target)..[FormulaEvaluator.stringify(key)] = value; +} + +/// `updateWhere(list, predicate…, update)`: items matching the predicate +/// (a lambda, or a field and value) are replaced by `update(item)` when it +/// is a lambda, or merged with it when it is a map. +List _updateWhere(List args) { + if (args.length < 3) return _listOf(_arg(args, 0)); + final Object? update = args.last; + final List predicate = args.sublist(1, args.length - 1); + return [ + for (final item in _values(_arg(args, 0), null)) + if (!_matches(item, predicate)) + item + else if (update is _Closure) + update(item) + else if (update is Map) + {..._mapOf(item), ..._mapOf(update)} + else + update, + ]; +} + /// A list index from [value]: negative counts from the end, and the result /// is clamped to `0..length`. [fallback] when [value] isn't a number. int _index(Object? value, int length, int fallback) { @@ -990,6 +1065,19 @@ int _index(Object? value, int length, int fallback) { return (i < 0 ? length + i : i).clamp(0, length); } +final math.Random _idRandom = math.Random(); + +/// A short random id, such as `log_k3x9f2a1`, for new records. +String _newId(Object? prefix) { + const String chars = 'abcdefghijklmnopqrstuvwxyz0123456789'; + final String id = String.fromCharCodes([ + for (int i = 0; i < 8; i++) + chars.codeUnitAt(_idRandom.nextInt(chars.length)), + ]); + final String p = FormulaEvaluator.stringify(prefix); + return p.isEmpty ? id : '${p}_$id'; +} + int _count(Object? value) { final num? n = _toNum(value); return n == null || n.isNaN || n < 0 ? 0 : n.toInt(); @@ -1123,6 +1211,11 @@ final Map _functions = { 'first': (a) => _values(_arg(a, 0), null).firstOrNull, 'last': (a) => _values(_arg(a, 0), null).lastOrNull, 'slice': (a) { + if (_arg(a, 0) case final String s) { + final int start = _index(_arg(a, 1), s.length, 0); + final int end = _index(_arg(a, 2), s.length, s.length); + return end <= start ? '' : s.substring(start, end); + } final List items = List.of(_values(_arg(a, 0), null)); final int start = _index(_arg(a, 1), items.length, 0); final int end = _index(_arg(a, 2), items.length, items.length); @@ -1137,6 +1230,9 @@ final Map _functions = { }, 'range': _range, 'indexOf': (a) { + if (_arg(a, 0) case final String s) { + return s.indexOf(FormulaEvaluator.stringify(_arg(a, 1))); + } final List items = List.of(_values(_arg(a, 0), null)); final List rest = a.skip(1).toList(); final bool predicate = rest.length > 1 || _arg(rest, 0) is _Closure; @@ -1153,10 +1249,7 @@ final Map _functions = { for (final Object? m in a) if (m is Map) ...m.map((k, v) => MapEntry(k.toString(), v)), }, - 'set': (a) => { - if (_arg(a, 0) case final Map m) ...m.map((k, v) => MapEntry('$k', v)), - FormulaEvaluator.stringify(_arg(a, 1)): _arg(a, 2), - }, + 'set': (a) => _set(_arg(a, 0), _arg(a, 1), _arg(a, 2)), 'keys': (a) => switch (_arg(a, 0)) { final Map m => m.keys.map((k) => k.toString()).toList(), _ => [], @@ -1212,6 +1305,23 @@ final Map _functions = { ); return '${n < 0 ? '-' : ''}$whole${parts.length > 1 ? '.${parts[1]}' : ''}'; }, + 'unset': (a) => + _mapOf(_arg(a, 0))..remove(FormulaEvaluator.stringify(_arg(a, 1))), + 'prepend': (a) => [_arg(a, 1), ..._listOf(_arg(a, 0))], + 'updateWhere': _updateWhere, + 'removeWhere': (a) { + final List rest = a.skip(1).toList(); + return [ + for (final item in _values(_arg(a, 0), null)) + if (!_matches(item, rest)) item, + ]; + }, + 'findIndex': (a) { + final List rest = a.skip(1).toList(); + final List list = _listOf(_arg(a, 0)); + return list.indexWhere((item) => _matches(item, rest)); + }, + 'newId': (a) => _newId(_arg(a, 0)), 'number': (a) => _toNum(_arg(a, 0)), 'text': (a) => FormulaEvaluator.stringify(_arg(a, 0)), 'isEmpty': (a) => !_truthy(_functions['length']!(a)), diff --git a/lib/src/utils/utils.dart b/lib/src/utils/utils.dart index 6a8d9b0f..e18ca26f 100644 --- a/lib/src/utils/utils.dart +++ b/lib/src/utils/utils.dart @@ -17,6 +17,7 @@ const Set predefinedVariableNames = { 'auth', 'result', 'screen', + 'hold', }; /// 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: 'hold', type: VariableType.decimal), }; /// Widths at which the `screen` variable's breakpoint changes: below 600 is diff --git a/test/dialog_sizing_test.dart b/test/dialog_sizing_test.dart new file mode 100644 index 00000000..14236ee3 --- /dev/null +++ b/test/dialog_sizing_test.dart @@ -0,0 +1,116 @@ +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:codelessly_sdk/codelessly_sdk.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:codelessly_sdk/src/ui/codelessly_dialog_widget.dart'; + +CanvasNode canvas({SizeFit verticalFit = SizeFit.fixed}) => CanvasNode( + id: 'sheet', + name: 'Sheet', + basicBoxLocal: NodeBox(0, 0, 520, 600), + verticalFit: verticalFit, + scaleMode: ScaleMode.responsive, + properties: CanvasProperties(bodyId: 'body'), + children: const ['body'], +); + +void main() { + const BoxConstraints phone = BoxConstraints(maxWidth: 390, maxHeight: 760); + + test('a sheet spans the width and keeps its design height', () { + final box = CanvasViewport.resolve( + canvas(), + phone, + sizing: CanvasSizing.sheet, + ); + expect(box.width, 390); + expect(box.height, 600); + }); + + test('a shrink-wrapping sheet is as tall as its content', () { + final box = CanvasViewport.resolve( + canvas(verticalFit: SizeFit.shrinkWrap), + phone, + sizing: CanvasSizing.sheet, + ); + expect(box.width, 390); + expect(box.height, isNull); + }); + + test('a dialog keeps its design width; full screen fills', () { + const BoxConstraints desktop = BoxConstraints( + maxWidth: 1440, + maxHeight: 900, + ); + expect( + CanvasViewport.resolve( + canvas(), + desktop, + sizing: CanvasSizing.design, + ).width, + 520, + ); + final full = CanvasViewport.resolve(canvas(), desktop); + expect((full.width, full.height), (1440, 900)); + }); + + testWidgets('a sheet sits at the bottom, a dialog in the middle', ( + tester, + ) async { + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + Future place(CanvasSizing sizing) async { + await tester.pumpWidget( + MaterialApp( + home: CodelesslyDialogWidget( + showCloseButton: false, + sizing: sizing, + builder: (_) => + const SizedBox(key: ValueKey('box'), width: 390, height: 200), + ), + ), + ); + return tester.getTopLeft(find.byKey(const ValueKey('box'))); + } + + expect((await place(CanvasSizing.sheet)).dy, 844 - 200); + expect((await place(CanvasSizing.design)).dy, (844 - 200) / 2); + }); + + testWidgets('responsive layouts in a dialog go by the screen, in a sheet ' + 'by the sheet', (tester) async { + tester.view.physicalSize = const Size(1440, 900); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + Future widthIn(CanvasSizing sizing) async { + double? width; + await tester.pumpWidget( + MaterialApp( + home: CanvasSizingScope( + sizing: sizing, + child: Center( + child: SizedBox( + width: sizing == CanvasSizing.sheet ? 600 : 1440, + child: CanvasViewport( + node: canvas(), + child: Builder( + builder: (context) { + width = CanvasWidthScope.maybeOf(context); + return const SizedBox(); + }, + ), + ), + ), + ), + ), + ), + ); + return width; + } + + // The 520 wide dialog shows the desktop layout on a desktop screen. + expect(await widthIn(CanvasSizing.design), 1440); + expect(await widthIn(CanvasSizing.sheet), 600); + }); +} diff --git a/test/flex_binding_test.dart b/test/flex_binding_test.dart new file mode 100644 index 00000000..a85f6ef2 --- /dev/null +++ b/test/flex_binding_test.dart @@ -0,0 +1,86 @@ +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'; + +RectangleNode bar(String id, {String? flex}) => + RectangleNode( + id: id, + name: id, + basicBoxLocal: NodeBox(0, 0, 10, 20), + horizontalFit: SizeFit.expanded, + ) + ..parentID = 'row' + ..variables.addAll({'flex': ?flex}); + +Future> pumpRow( + WidgetTester tester, + List bars, +) async { + final RowColumnNode row = RowColumnNode( + id: 'row', + name: 'row', + basicBoxLocal: NodeBox(0, 0, 300, 20), + children: [for (final b in bars) b.id], + rowColumnType: RowColumnType.row, + )..parentID = kRootNode; + final registry = NodeRegistry() + ..setNodes({ + for (final node in [row, ...bars]) node.id: node, + }); + final manager = PassiveNodeTransformerManager( + registry.getNodeByID, + (context, bounds, pageID, layoutID, canvasID) => const SizedBox(), + ); + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: CodelesslyContext.empty(), + child: MaterialApp( + home: Material( + child: Align( + alignment: Alignment.topLeft, + child: Builder( + builder: (context) => manager.buildWidgetFromNode( + row, + context, + settings: const WidgetBuildSettings(debugLabel: 'test'), + ), + ), + ), + ), + ), + ), + ); + expect(tester.takeException(), isNull); + return { + for (final b in bars) + b.id: tester + .getSize( + find.byKey(ValueKey('Rectangle Transformer of ${b.id} [${b.id}]')), + ) + .width, + }; +} + +void main() { + testWidgets('expanded children share the free space by their bound flex', ( + tester, + ) async { + // A temperature gauge: 52 below the band, 16 in it, 32 above it. + final widths = await pumpRow(tester, [ + bar('low', flex: r'${= 52}'), + bar('band', flex: r'${= 68 - 52}'), + bar('high', flex: r'${= 100 - 68}'), + ]); + expect(widths['low'], closeTo(156, 0.5)); + expect(widths['band'], closeTo(48, 0.5)); + expect(widths['high'], closeTo(96, 0.5)); + }); + + testWidgets('without a binding a child keeps its own flex', (tester) async { + final widths = await pumpRow(tester, [bar('a'), bar('b', flex: r'${= 2}')]); + expect(widths['a'], closeTo(100, 0.5)); + expect(widths['b'], closeTo(200, 0.5)); + }); +} diff --git a/test/formula_records_test.dart b/test/formula_records_test.dart new file mode 100644 index 00000000..c1321b7b --- /dev/null +++ b/test/formula_records_test.dart @@ -0,0 +1,129 @@ +import 'package:codelessly_sdk/codelessly_sdk.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + final Map vars = { + 'logId': 'l2', + 'crewId': 'c2', + 'logs': [ + { + 'id': 'l1', + 'crew': [ + {'id': 'c1', 'n': 3}, + ], + }, + { + 'id': 'l2', + 'crew': [ + {'id': 'c1', 'n': 2}, + {'id': 'c2', 'n': 5}, + ], + }, + ], + 'tags': ['a', 'b', 'c', 'd'], + 'name': 'Keystone', + }; + + Object? eval(String expression) => + FormulaEvaluator.evaluate(expression, (path) { + Object? value = vars; + for (final part in path.split('.')) { + value = value is Map ? value[part] : null; + } + return value; + }); + + test('obj, set, unset and merge build maps without braces', () { + expect(eval('obj("company", name, "n", 4)'), { + 'company': 'Keystone', + 'n': 4, + }); + expect(eval('set(obj("a", 1), "b", 2)'), {'a': 1, 'b': 2}); + expect(eval('unset(obj("a", 1, "b", 2), "a")'), {'b': 2}); + expect(eval('merge(obj("a", 1, "b", 2), obj("b", 3), obj("c", 4))'), { + 'a': 1, + 'b': 3, + 'c': 4, + }); + expect(eval('set(tags, 1, "x")'), ['a', 'x', 'c', 'd']); + expect(eval('set(tags, 9, "z")'), ['a', 'b', 'c', 'd', 'z']); + }); + + test('append, prepend and concat return new lists', () { + expect(eval('append(tags, "e", "f")'), ['a', 'b', 'c', 'd', 'e', 'f']); + expect(eval('prepend(tags, "z")'), ['z', 'a', 'b', 'c', 'd']); + expect(eval('concat(tags, ["e"], null)'), ['a', 'b', 'c', 'd', 'e']); + expect(eval('append(null, 1)'), [1]); + expect(vars['tags'], [ + 'a', + 'b', + 'c', + 'd', + ], reason: 'arguments are not changed'); + }); + + test('updateWhere and removeWhere change matching records', () { + expect(eval('updateWhere(logs, "id", logId, obj("signed", true))'), [ + (vars['logs'] as List)[0], + {...((vars['logs'] as List)[1] as Map), 'signed': true}, + ]); + // A nested update: one crew entry inside one log. + final Object? result = eval( + 'updateWhere(logs, l => l.id == logId, l => set(l, "crew", ' + 'updateWhere(l.crew, c => c.id == crewId, c => set(c, "n", c.n + 1))))', + ); + expect(((result as List)[1] as Map)['crew'], [ + {'id': 'c1', 'n': 2}, + {'id': 'c2', 'n': 6}, + ]); + expect((result[0] as Map)['crew'], [ + {'id': 'c1', 'n': 3}, + ]); + expect(eval('length(removeWhere(logs, "id", "l1"))'), 1); + expect(eval('removeWhere(tags, t => t == "b" || t == "c")'), ['a', 'd']); + }); + + test('findIndex, indexOf, slice, take, skip, first, last, range', () { + expect(eval('findIndex(logs, "id", "l2")'), 1); + expect(eval('findIndex(logs, l => l.id == "nope")'), -1); + expect(eval('indexOf(tags, "c")'), 2); + expect(eval('indexOf("hardhat", "hat")'), 4); + expect(eval('slice(tags, 1, 3)'), ['b', 'c']); + expect(eval('slice(tags, -2)'), ['c', 'd']); + expect(eval('slice("Thursday", 0, 3)'), 'Thu'); + expect(eval('take(tags, 2)'), ['a', 'b']); + expect(eval('take(tags, 10)'), ['a', 'b', 'c', 'd']); + expect(eval('skip(tags, 3)'), ['d']); + expect(eval('reverse(tags)'), ['d', 'c', 'b', 'a']); + expect(eval('reverse("abc")'), 'cba'); + expect(eval('first(tags)'), 'a'); + expect(eval('last(tags)'), 'd'); + expect(eval('first(null)'), isNull); + expect(eval('range(3)'), [0, 1, 2]); + expect(eval('range(2, 5)'), [2, 3, 4]); + }); + + test('newId makes distinct prefixed ids', () { + final Object? a = eval('newId("crew")'); + final Object? b = eval('newId("crew")'); + expect(a, matches(RegExp(r'^crew_[a-z0-9]{8}$'))); + expect(a, isNot(b)); + expect(eval('newId()'), matches(RegExp(r'^[a-z0-9]{8}$'))); + }); + + test('thousands groups digits; money still shows cents', () { + expect(eval('thousands(921)'), '921'); + expect(eval('thousands(12500)'), '12,500'); + expect(eval('thousands(-1234567.891, 2)'), '-1,234,567.89'); + expect(eval('thousands("x")'), isNull); + expect(eval('money(1234.5)'), r'$1,234.50'); + expect(eval('money(-5, "€")'), '-€5.00'); + }); + + test('every function is documented', () { + expect( + FormulaEvaluator.functions.toSet(), + FormulaEvaluator.functionDocs.map((d) => d.name).toSet(), + ); + }); +} diff --git a/test/hold_to_confirm_test.dart b/test/hold_to_confirm_test.dart new file mode 100644 index 00000000..4d7bd8ef --- /dev/null +++ b/test/hold_to_confirm_test.dart @@ -0,0 +1,112 @@ +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'; + +RectangleNode bar(String id, String flex) => + RectangleNode( + id: id, + name: id, + basicBoxLocal: NodeBox(0, 0, 10, 40), + horizontalFit: SizeFit.expanded, + ) + ..parentID = 'button' + ..variables['flex'] = flex; + +/// A 400 wide hold-to-sign button: its `fill` bar grows with `${hold}`. +Future pumpButton(WidgetTester tester) async { + final RowColumnNode button = RowColumnNode( + id: 'button', + name: 'button', + basicBoxLocal: NodeBox(0, 0, 400, 40), + children: const ['fill', 'rest'], + rowColumnType: RowColumnType.row, + reactions: [ + Reaction( + action: ShowToastAction(message: 'Log signed', duration: 1000), + trigger: TriggerModel(type: TriggerType.hold, duration: 1000), + ), + ], + )..parentID = kRootNode; + final nodes = [ + button, + bar('fill', r'${= round(hold * 1000)}'), + bar('rest', r'${= 1000 - round(hold * 1000)}'), + ]; + 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: Material( + child: Align( + alignment: Alignment.topLeft, + child: Builder( + builder: (context) => manager.buildWidgetFromNode( + button, + context, + settings: const WidgetBuildSettings(debugLabel: 'test'), + ), + ), + ), + ), + ), + ), + ); +} + +double fillWidth(WidgetTester tester) => tester + .getSize(find.byKey(const ValueKey('Rectangle Transformer of fill [fill]'))) + .width; + +void main() { + test('the hold trigger round-trips its duration', () { + final trigger = TriggerModel(type: TriggerType.hold, duration: 800); + expect(trigger.toJson(), {'type': 'hold', 'duration': 800}); + expect(TriggerModel.fromJson(trigger.toJson()), trigger); + expect( + TriggerModel.fromJson({'type': 'hold'}).duration, + kDefaultHoldDuration, + ); + }); + + testWidgets('holding fills, releasing drains, and a full hold confirms', ( + tester, + ) async { + await pumpButton(tester); + expect(fillWidth(tester), 0); + + final gesture = await tester.startGesture(const Offset(200, 20)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + expect(fillWidth(tester), closeTo(200, 2)); + + // Let go halfway: it drains back and nothing fires. + await gesture.up(); + await tester.pumpAndSettle(); + expect(fillWidth(tester), 0); + expect(find.text('Log signed'), findsNothing); + + // Moving off while holding cancels too, like a scroll would. + final drag = await tester.startGesture(const Offset(200, 20)); + await tester.pump(const Duration(milliseconds: 300)); + await drag.moveBy(const Offset(0, 60)); + await tester.pumpAndSettle(); + expect(fillWidth(tester), 0); + await drag.up(); + + final hold = await tester.startGesture(const Offset(200, 20)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 1050)); + await tester.pump(const Duration(milliseconds: 300)); + expect(find.text('Log signed'), findsOneWidget); + expect(fillWidth(tester), 0); + await hold.up(); + await tester.pumpAndSettle(const Duration(seconds: 2)); + }); +} diff --git a/test/icon_name_binding_test.dart b/test/icon_name_binding_test.dart new file mode 100644 index 00000000..9f030d6f --- /dev/null +++ b/test/icon_name_binding_test.dart @@ -0,0 +1,86 @@ +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:codelessly_sdk/codelessly_sdk.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + MultiSourceIconModel model(IconModel icon) => + MultiSourceIconModel(icon: icon, show: true, size: 18); + + test('a Material Icon shows the named Material Symbol in the same style', () { + final MultiSourceIconModel icon = withIconName( + model( + const MaterialIcon( + codepoint: 0xe0c3, + fontFamily: 'MaterialIcons', + name: 'help_outline', + style: MaterialIconStyle.rounded, + version: 12, + ), + ), + 'water_drop', + ); + expect(icon.icon, isA()); + expect( + icon.icon!.toSvgUrl(), + contains('/materialsymbolsrounded/water_drop/'), + ); + expect(icon.size, 18); + expect(icon.show, isTrue); + }); + + test('a filled Material Icon becomes a filled outlined symbol', () { + final MultiSourceIconModel icon = withIconName( + model( + const MaterialIcon( + codepoint: 0, + fontFamily: 'MaterialIcons', + name: 'add', + style: MaterialIconStyle.filled, + ), + ), + 'thunderstorm', + ); + expect(icon.fill, 1); + expect( + icon.icon!.toSvgUrl(fill: icon.fill), + contains('/materialsymbolsoutlined/thunderstorm/fill1/'), + ); + }); + + test('a Material Symbol keeps its style and swaps its name', () { + final MultiSourceIconModel icon = withIconName( + model( + MaterialSymbol( + codepoint: 0, + fontFamily: 'MaterialSymbols', + name: 'home', + style: MaterialSymbolStyle.sharp, + ), + ), + 'air', + ); + expect(icon.icon!.toSvgUrl(), contains('/materialsymbolssharp/air/')); + }); + + test('icon nodes declare iconName and iconColor as bindable', () { + final IconNode node = IconNode( + id: 'i', + name: 'Sky', + basicBoxLocal: NodeBox(0, 0, 18, 18), + properties: IconProperties( + icon: model( + const MaterialIcon( + codepoint: 0, + fontFamily: 'MaterialIcons', + name: 'help', + style: MaterialIconStyle.outlined, + ), + ), + ), + ); + expect( + node.propertyVariables.map((v) => v.name), + containsAll(['iconName', 'iconColor']), + ); + }); +} diff --git a/test/signature_node_test.dart b/test/signature_node_test.dart new file mode 100644 index 00000000..99296190 --- /dev/null +++ b/test/signature_node_test.dart @@ -0,0 +1,165 @@ +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:codelessly_sdk/codelessly_sdk.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; + +/// A 300×100 signature box bound to the `sig` variable. +Future pumpPad( + WidgetTester tester, { + Object? initial = const [], + bool readOnly = false, +}) async { + final ctx = CodelesslyContext.empty() + ..variables['sig'] = Observable( + VariableData(id: 'sig', name: 'sig', type: VariableType.list), + ); + if (initial != null) { + ctx.variables['sig']!.value = ctx.variables['sig']!.value.copyWith( + value: initial, + ); + } + final SignatureNode pad = + SignatureNode( + id: 'pad', + name: 'pad', + basicBoxLocal: NodeBox(0, 0, 300, 100), + properties: SignatureProperties(readOnly: readOnly), + ) + ..parentID = kRootNode + ..variables['value'] = r'${sig}'; + final registry = NodeRegistry()..setNodes({'pad': pad}); + final manager = PassiveNodeTransformerManager( + registry.getNodeByID, + (context, bounds, pageID, layoutID, canvasID) => const SizedBox(), + ); + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: ctx, + child: MaterialApp( + home: Material( + child: Align( + alignment: Alignment.topLeft, + child: Builder( + builder: (context) => manager.buildWidgetFromNode( + pad, + context, + settings: const WidgetBuildSettings(debugLabel: 'test'), + ), + ), + ), + ), + ), + ), + ); + return ctx; +} + +SignatureStrokes painted(WidgetTester tester) => + (tester + .widget( + find.descendant( + of: find.byType(PassiveSignatureWidget), + matching: find.byType(CustomPaint), + ), + ) + .painter + as SignaturePainter) + .strokes; + +void main() { + test('strokes parse from bound data and ignore junk', () { + expect( + parseSignatureStrokes([ + [ + [0.1, 0.2], + [1, 0], + ], + 'junk', + [], + [ + ['x', 1], + ], + ]), + [ + [ + [0.1, 0.2], + [1.0, 0.0], + ], + ], + ); + expect(parseSignatureStrokes(null), isEmpty); + }); + + test('a signature node round-trips through JSON', () { + final node = SignatureNode( + id: 'pad', + name: 'Signature', + basicBoxLocal: NodeBox(0, 0, 300, 100), + properties: SignatureProperties( + inkColor: ColorRGBA.white, + readOnly: true, + ), + ); + final json = node.toJson(); + expect(json['type'], 'signature'); + final back = NodeJsonConverter.instance.fromJson(json)! as SignatureNode; + expect(back.properties.readOnly, isTrue); + expect(back.properties.inkColor, ColorRGBA.white); + expect(back.properties.strokeWidth, 2.6); + }); + + testWidgets('drawing writes normalized strokes to the bound variable', ( + tester, + ) async { + final ctx = await pumpPad(tester); + await tester.dragFrom(const Offset(30, 50), const Offset(150, 20)); + await tester.pumpAndSettle(); + + final strokes = parseSignatureStrokes( + ctx.variables['sig']!.value.getValue(), + ); + expect(strokes, hasLength(1)); + expect(strokes.first.first[0], closeTo(0.1, 0.01)); + expect(strokes.first.first[1], closeTo(0.5, 0.01)); + expect(strokes.first.last[0], closeTo(0.6, 0.02)); + expect(strokes.first.length, greaterThan(2)); + expect(painted(tester), hasLength(1)); + + await tester.dragFrom(const Offset(200, 80), const Offset(40, 0)); + await tester.pumpAndSettle(); + expect( + parseSignatureStrokes(ctx.variables['sig']!.value.getValue()), + hasLength(2), + ); + + // Clearing the variable clears the box. + ctx.variables['sig']!.value = ctx.variables['sig']!.value.copyWith( + value: const [], + ); + await tester.pumpAndSettle(); + expect(painted(tester), isEmpty); + }); + + testWidgets('a read-only box replays a signature and ignores the pen', ( + tester, + ) async { + final ctx = await pumpPad( + tester, + readOnly: true, + initial: [ + [ + [0.1, 0.5], + [0.9, 0.5], + ], + ], + ); + expect(painted(tester), hasLength(1)); + await tester.dragFrom(const Offset(30, 20), const Offset(150, 20)); + await tester.pumpAndSettle(); + expect( + parseSignatureStrokes(ctx.variables['sig']!.value.getValue()), + hasLength(1), + ); + }); +} diff --git a/test/style_overrides_test.dart b/test/style_overrides_test.dart index b82eb8cd..b3a40044 100644 --- a/test/style_overrides_test.dart +++ b/test/style_overrides_test.dart @@ -149,4 +149,51 @@ void main() { ), ); }); + + testWidgets('a bound strokeWeight thickens the border', (tester) async { + final RectangleNode node = RectangleNode.fromJson({ + ...RectangleNode( + id: 'tile', + name: 'Tile', + basicBoxLocal: NodeBox(0, 0, 40, 40), + strokes: [ + PaintModel.solid(id: 's', color: ColorRGB.fromHex('#FFC21A')), + ], + strokeWeight: 1, + ).toJson(), + 'variables': {'strokeWeight': r'${= if(picked, 3, 1)}'}, + })..parentID = kRootNode; + final manager = PassiveNodeTransformerManager( + (id) => node, + (context, bounds, pageID, layoutID, canvasID) => const SizedBox(), + ); + final Observable picked = Observable( + VariableData( + id: 'picked', + name: 'picked', + type: VariableType.boolean, + value: false, + ), + ); + late double weight; + Future pump() => tester.pumpWidget( + ChangeNotifierProvider.value( + value: CodelesslyContext.empty()..variables['picked'] = picked, + child: Builder( + builder: (context) { + weight = (manager.applyStyleOverrides( + context, + node, + ) as GeometryMixin).strokeWeight; + return const SizedBox(); + }, + ), + ), + ); + await pump(); + expect(weight, 1); + picked.set(picked.value.copyWith(value: true)); + await pump(); + expect(weight, 3); + }); }