diff --git a/CHANGELOG.md b/CHANGELOG.md index 7aff3e76..9ff5117f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,24 @@ +## Unreleased + +- Screen readers: a tappable frame or shape reads as a button. One with no + text inside (an icon button) is named after its node, or its icon when the + node keeps a default name like "Frame 3". Nodes no longer offer a long + press that does nothing. +- Show toast action: shows a message near the bottom of the app over every + route, with variables and formulas in the text. A new toast replaces the + one on screen. +- Formulas: `${= expression}` in any text, bound property, condition or + action value computes a result, e.g. `${= money(qty * price)}`, + `${= sum(storage.cart, 'total')}` or `${= count > 1 ? 'drinks' : 'drink'}`. + They support arithmetic, comparisons, `&& || !`, `a ? b : c`, lists, and + the functions money, fixed, round, floor, ceil, abs, min, max, sum, count, + length, if, plural, join, upper, lower, contains, number, text, isEmpty and + isNotEmpty. A value that is exactly one formula keeps its type. Nodes + rebuild when the variables a formula reads change. +- Style properties at runtime: `fill`, `stroke`, `opacity`, `width` and + `height` follow conditions, variables or formulas bound to them, and set + value actions. + ## 7.2.0 - Require codelessly_api 5.2.0. diff --git a/example/pubspec.yaml b/example/pubspec.yaml index e0640bdc..dd3aa05b 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -23,3 +23,12 @@ dev_dependencies: flutter: uses-material-design: true + +# Temporary: the show toast action needs codelessly_api from +# Codelessly/CodelesslyAPI#37, merged but not yet published. Remove once +# codelessly_api is released. +dependency_overrides: + codelessly_api: + git: + url: https://github.com/Codelessly/CodelesslyAPI.git + ref: master diff --git a/lib/codelessly_sdk.dart b/lib/codelessly_sdk.dart index 0fb7c904..2d4c9f00 100644 --- a/lib/codelessly_sdk.dart +++ b/lib/codelessly_sdk.dart @@ -37,6 +37,7 @@ export 'src/utils/extensions.dart'; export 'src/utils/flutter_icons_data_map.dart'; export 'src/utils/flutter_icons_names_map.dart'; export 'src/utils/font_parser.dart'; +export 'src/utils/formula.dart'; export 'src/utils/image_builder.dart'; export 'src/utils/observable.dart'; export 'src/utils/rgb_to_color.dart'; diff --git a/lib/src/functions/app_toast.dart b/lib/src/functions/app_toast.dart new file mode 100644 index 00000000..021e2bde --- /dev/null +++ b/lib/src/functions/app_toast.dart @@ -0,0 +1,156 @@ +import 'dart:async'; + +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:flutter/material.dart'; + +import '../../codelessly_sdk.dart'; + +/// Shows [ShowToastAction] messages: a pill near the bottom of the screen +/// that fades in, stays for the action's duration and fades out. A new toast +/// replaces the one on screen. +class AppToast { + AppToast._(); + + static OverlayEntry? _current; + + /// Shows [action]'s message. Returns false when there is no overlay to + /// show it in. + static bool show(BuildContext context, ShowToastAction action) { + final OverlayState? overlay = Overlay.maybeOf(context, rootOverlay: true); + if (overlay == null) return false; + + // Read variables now: the action that follows may navigate away. + final String message = PropertyValueDelegate.substituteVariables( + action.message, + scopedValues: ScopedValues.of(context), + nullSubstitutionMode: NullSubstitutionMode.emptyString, + ); + + hide(); + late final OverlayEntry entry; + entry = OverlayEntry( + builder: (context) => _ToastView( + action: action, + message: message, + onDone: () { + if (_current == entry) _current = null; + if (entry.mounted) entry.remove(); + }, + ), + ); + _current = entry; + overlay.insert(entry); + return true; + } + + /// Removes the toast on screen, if any. + static void hide() { + final OverlayEntry? entry = _current; + _current = null; + if (entry != null && entry.mounted) entry.remove(); + } +} + +class _ToastView extends StatefulWidget { + const _ToastView({ + required this.action, + required this.message, + required this.onDone, + }); + + final ShowToastAction action; + final String message; + final VoidCallback onDone; + + @override + State<_ToastView> createState() => _ToastViewState(); +} + +class _ToastViewState extends State<_ToastView> { + static const Duration _fade = Duration(milliseconds: 220); + + bool _visible = false; + Timer? _timer; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) setState(() => _visible = true); + }); + _timer = Timer(Duration(milliseconds: widget.action.duration), () { + if (!mounted) return; + setState(() => _visible = false); + _timer = Timer(_fade, widget.onDone); + }); + } + + @override + void dispose() { + _timer?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final ShowToastAction action = widget.action; + final Color textColor = action.textColor.toFlutterColor(); + final Widget? icon = action.icon == null + ? null + : retrieveIconWidget(action.icon!, 18); + return Positioned( + left: 16, + right: 16, + bottom: action.bottomOffset, + child: IgnorePointer( + child: Semantics( + liveRegion: true, + label: widget.message, + child: AnimatedOpacity( + opacity: _visible ? 1 : 0, + duration: _fade, + child: Center( + child: Material( + type: MaterialType.transparency, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 18, + vertical: 12, + ), + decoration: BoxDecoration( + color: action.backgroundColor.toFlutterColor(), + borderRadius: BorderRadius.circular(999), + boxShadow: const [ + BoxShadow( + color: Color(0x33000000), + blurRadius: 18, + offset: Offset(0, 6), + ), + ], + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (icon != null) ...[icon, const SizedBox(width: 8)], + Flexible( + child: Text( + widget.message, + style: DefaultTextStyle.of(context).style.copyWith( + color: textColor, + fontSize: 14, + fontWeight: FontWeight.w600, + decoration: TextDecoration.none, + ), + ), + ), + ], + ), + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/src/functions/functions_repository.dart b/lib/src/functions/functions_repository.dart index 4537e3e2..8ca0a6d2 100644 --- a/lib/src/functions/functions_repository.dart +++ b/lib/src/functions/functions_repository.dart @@ -15,6 +15,7 @@ import '../logging/stat_tracker.dart'; import '../ui/codelessly_dialog_widget.dart'; import 'action_chain.dart'; import 'app_auth.dart'; +import 'app_toast.dart'; import 'cloud_files.dart'; /// Enum representing the types of API requests. @@ -134,6 +135,8 @@ class FunctionsRepository { action as CloudFilesAction, runActions: (actions) => runActions(context, actions), ); + case ActionType.showToast: + return AppToast.show(context, action as ShowToastAction); } } @@ -942,6 +945,48 @@ ${response.body.contains('{') ? const JsonEncoder.withIndent(' ').convert(json. payload.nodeValues[action.nodeID]!.set(updateValues, notify: notify); } + /// Substitutes variables in the [template] of a value to set. For list and + /// map values written as JSON, each JSON value is substituted on its own + /// (see [PropertyValueDelegate.substituteJsonValue]), so text holding + /// quotes or newlines can't break the JSON. Everything else is substituted + /// as text. + static String substituteNewValue( + String template, + VariableType type, + ScopedValues scopedValues, + ) { + if (type.isList || type.isMap) { + // A whole-value formula such as `${= item}` keeps its list or map. + final String? expression = FormulaEvaluator.exactExpression(template); + if (expression != null) { + final Object? value = PropertyValueDelegate.evaluateFormula( + expression, + scopedValues: scopedValues, + ); + if (value is List || value is Map) { + return jsonEncode( + value, + toEncodable: (value) => + value is DateTime ? value.toIso8601String() : value.toString(), + ); + } + } + final Object? parsed = tryJsonDecode(template.trim()); + if (parsed is List || parsed is Map) { + return jsonEncode( + PropertyValueDelegate.substituteJsonValue(parsed, scopedValues), + toEncodable: (value) => + value is DateTime ? value.toIso8601String() : value.toString(), + ); + } + } + return PropertyValueDelegate.substituteVariables( + template, + nullSubstitutionMode: NullSubstitutionMode.emptyString, + scopedValues: scopedValues, + ); + } + /// Sets given [action.newValue] for given [action.variable] to a variable /// from [CodelesslyContext.variables]. /// Returns `true` if variable was found and updated, `false` otherwise. @@ -958,10 +1003,10 @@ ${response.body.contains('{') ? const JsonEncoder.withIndent(' ').convert(json. ); if (variableNotifier == null) return false; - String newValue = PropertyValueDelegate.substituteVariables( + String newValue = substituteNewValue( action.newValue, - nullSubstitutionMode: NullSubstitutionMode.emptyString, - scopedValues: scopedValues, + variableNotifier.value.type, + scopedValues, ); Object? currentValue = variableNotifier.value.getValue(); @@ -1275,10 +1320,10 @@ ${response.body.contains('{') ? const JsonEncoder.withIndent(' ').convert(json. scopedValues: scopedValues, ); - final newValue = PropertyValueDelegate.substituteVariables( + final newValue = substituteNewValue( action.newValue, - nullSubstitutionMode: NullSubstitutionMode.emptyString, - scopedValues: scopedValues, + action.variableType, + scopedValues, ); final match = VariableMatch.parse(storageKey.wrapWithVariableSyntax()); diff --git a/lib/src/transformers/passive_transformer_manager.dart b/lib/src/transformers/passive_transformer_manager.dart index e62cd531..7c0108c1 100644 --- a/lib/src/transformers/passive_transformer_manager.dart +++ b/lib/src/transformers/passive_transformer_manager.dart @@ -1,3 +1,5 @@ +import 'dart:math' as math; + import 'package:codelessly_api/codelessly_api.dart'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; @@ -78,29 +80,31 @@ class PassiveNodeTransformerManager extends WidgetNodeTransformerManager { context, node: node, builder: (context) { + final BaseNode styled = applyStyleOverrides(context, node); Widget widget = getTransformerByNode(node) - .buildWidget(node, context, settings); + .buildWidget(styled, context, settings); if (settings.withOpacity) { - widget = applyWidgetOpacity(node, widget); + widget = applyWidgetOpacity(styled, widget); } if (settings.withReactions) { - widget = wrapWithReaction(context, node, widget); + widget = wrapWithReaction(context, styled, widget); + widget = wrapWithSemantics(styled, widget); } if (settings.withRotation) { - widget = applyWidgetRotation(context, node, widget); + widget = applyWidgetRotation(context, styled, widget); } if (settings.withConstraints) { - widget = applyWidgetConstraints(node, widget); + widget = applyWidgetConstraints(styled, widget); } if (settings.withMargins) { - widget = applyWidgetMargins(node, widget); + widget = applyWidgetMargins(styled, widget); } if (settings.withVisibility) { widget = applyWidgetVisibility( context, - node, + styled, widget, maintainState: true, ); @@ -111,6 +115,81 @@ 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` and + /// `height`. Returns [node] itself when + /// nothing changes it. + BaseNode applyStyleOverrides(BuildContext context, BaseNode node) { + final ScopedValues scopedValues = ScopedValues.of(context); + T? value(String property) => + PropertyValueDelegate.getPropertyValue( + node, + property, + scopedValues: scopedValues, + ); + + final Map patch = {}; + if (node is GeometryMixin) { + final GeometryMixin geometry = node; + final PaintModel? fill = value('fill'); + if (fill != null && fill != geometry.fills.firstOrNull) { + patch['fills'] = [ + fill.copyWith(visible: true).toJson(), + for (final paint in geometry.fills.skip(1)) paint.toJson(), + ]; + } + final PaintModel? stroke = value('stroke'); + if (stroke != null && stroke != geometry.strokes.firstOrNull) { + patch['strokes'] = [ + stroke.copyWith(visible: true).toJson(), + for (final paint in geometry.strokes.skip(1)) paint.toJson(), + ]; + } + } + if (node is TextNode) { + // A text's fill is its color: it replaces the color of every span. + final PaintModel? fill = value('fill'); + final PaintModel? current = + node.textMixedProps.firstOrNull?.fills.firstOrNull; + if (fill != null && fill != current) { + patch['textMixedProps'] = [ + for (final prop in node.textMixedProps) + { + ...prop.toJson(), + 'fills': [fill.copyWith(visible: true).toJson()], + }, + ]; + } + } + if (node is BlendMixin) { + final double? opacity = value('opacity'); + if (opacity != null && opacity != node.opacity) { + patch['opacity'] = opacity.clamp(0, 1).toDouble(); + } + } + final NodeBox box = node.basicBoxLocal; + final double? width = value('width'); + final double? height = value('height'); + if ((width != null && width != box.width) || + (height != null && height != box.height)) { + patch['basicBoxLocal'] = NodeBox( + box.x, + box.y, + math.max(0, width ?? box.width), + math.max(0, height ?? box.height), + ).toJson(); + } + + if (patch.isEmpty) return node; + final BaseNode? styled = NodeJsonConverter().fromJson({ + ...node.toJson(), + ...patch, + }); + if (styled == null) return node; + return styled..parentID = node.parentID; + } + @override Widget buildWidgetByID( String id, @@ -180,7 +259,15 @@ class PassiveNodeTransformerManager extends WidgetNodeTransformerManager { // Traverse through all the variables that the node properties are attached // to. - for (final String variablePath in node.variables.values.toSet()) { + // A property bound to a formula listens to the paths the formula reads. + final Set boundPaths = { + for (final String path in node.variables.values) + if (FormulaEvaluator.exactExpression(path) case final String expression) + ...FormulaEvaluator.referencedPaths(expression) + else + path, + }; + for (final String variablePath in boundPaths) { final match = VariableMatch.parse(variablePath.wrapWithVariableSyntax()); if (match == null) continue; @@ -218,6 +305,8 @@ class PassiveNodeTransformerManager extends WidgetNodeTransformerManager { .allMatches(node.characters) .map((match) => match.namedGroup('value')) .nonNulls, + if (node is TextNode) + ...FormulaEvaluator.referencedPathsInText(node.characters), }; for (final String variablePath in multiplePaths) { final match = VariableMatch.parse(variablePath.wrapWithVariableSyntax()); diff --git a/lib/src/transformers/utils/condition_evaluator.dart b/lib/src/transformers/utils/condition_evaluator.dart index cd579546..ce0cf1d5 100644 --- a/lib/src/transformers/utils/condition_evaluator.dart +++ b/lib/src/transformers/utils/condition_evaluator.dart @@ -62,6 +62,14 @@ class ConditionEvaluator // A part that is exactly one path compares by its value as is, so lists // and maps keep their type for operators like contains and isEmpty. final String path = part.valueString.trim(); + // A part that is exactly one formula keeps its value's type too. + final String? expression = FormulaEvaluator.exactExpression(path); + if (expression != null) { + return PropertyValueDelegate.evaluateFormula( + expression, + scopedValues: scopedValues, + ); + } if (RegExp(r'^\$\{[^{}]+\}$').hasMatch(path)) { final Object? raw = PropertyValueDelegate.getVariableValueFromPath( @@ -70,17 +78,26 @@ class ConditionEvaluator ); if (raw is List || raw is Map) return raw; } - final value = part.valueString.splitMapJoinRegex( - variablePathRegex, - onMatch: (match) { - final Object? value = PropertyValueDelegate.getVariableValueFromPath( - match[0]!, - scopedValues: scopedValues, + final value = + FormulaEvaluator.substitute( + part.valueString, + (path) => PropertyValueDelegate.getVariableValueFromPath( + path, + scopedValues: scopedValues, + ), + nullText: 'null', + ).splitMapJoinRegex( + variablePathRegex, + onMatch: (match) { + final Object? value = + PropertyValueDelegate.getVariableValueFromPath( + match[0]!, + scopedValues: scopedValues, + ); + + return value?.typedValue() ?? 'null'; + }, ); - - return value?.typedValue() ?? 'null'; - }, - ); return _visitRawValue(value); } @@ -251,6 +268,9 @@ class ConditionEvaluator @override R? visitLinkAction(LinkAction action) => null; + @override + R? visitShowToastAction(ShowToastAction action) => null; + @override R? visitNavigationAction(NavigationAction action) => null; @@ -362,6 +382,9 @@ class ConditionPrinter @override void visitLinkAction(LinkAction action) {} + @override + void visitShowToastAction(ShowToastAction action) {} + @override void visitNavigationAction(NavigationAction action) {} diff --git a/lib/src/transformers/utils/condition_visitors.dart b/lib/src/transformers/utils/condition_visitors.dart index 1564dd06..dadcbba3 100644 --- a/lib/src/transformers/utils/condition_visitors.dart +++ b/lib/src/transformers/utils/condition_visitors.dart @@ -1,5 +1,7 @@ import 'package:codelessly_api/codelessly_api.dart'; +import '../../utils/formula.dart'; + /// A visitor that returns the list of variable names used in a condition. class ConditionVariablesVisitor implements @@ -62,12 +64,14 @@ class ConditionVariablesVisitor @override Set visitVariablePart(VariablePart part) { - return variablePathRegex - .allMatches(part.valueString) - .map((match) => match.namedGroup('name')) - .nonNulls - .where((element) => !excludedVariableNames.contains(element)) - .toSet(); + return { + ...variablePathRegex + .allMatches(part.valueString) + .map((match) => match.namedGroup('name')) + .nonNulls, + ...FormulaEvaluator.referencedPathsInText(part.valueString) + .map((path) => path.split(RegExp(r'[.\[]')).first), + }.where((element) => !excludedVariableNames.contains(element)).toSet(); } @override @@ -79,6 +83,9 @@ class ConditionVariablesVisitor @override Set? visitLinkAction(LinkAction action) => {}; + @override + Set? visitShowToastAction(ShowToastAction action) => {}; + @override Set? visitNavigationAction(NavigationAction action) => {}; @@ -169,6 +176,9 @@ class ConditionNodesVisitor @override String? visitLinkAction(LinkAction action) => null; + @override + String? visitShowToastAction(ShowToastAction action) => null; + @override String? visitNavigationAction(NavigationAction action) => null; diff --git a/lib/src/transformers/utils/property_value_delegate.dart b/lib/src/transformers/utils/property_value_delegate.dart index 3be42119..6bee63c4 100644 --- a/lib/src/transformers/utils/property_value_delegate.dart +++ b/lib/src/transformers/utils/property_value_delegate.dart @@ -174,11 +174,23 @@ class PropertyValueDelegate { ); } + /// Substitutes variables in a decoded JSON [value]: see + /// [substituteVariablesInJson]. + static Object? substituteJsonValue( + Object? value, + ScopedValues scopedValues, + ) => _substituteJsonValue(value, scopedValues); + static Object? _substituteJsonValue( Object? value, ScopedValues scopedValues, ) { return switch (value) { + String() when FormulaEvaluator.exactExpression(value) != null => + evaluateFormula( + FormulaEvaluator.exactExpression(value)!, + scopedValues: scopedValues, + ), String() => RegExp(r'^\$\{[^{}]+\}$').hasMatch(value.trim()) ? _retrieveVariableValue(value.trim(), scopedValues: scopedValues) @@ -211,6 +223,16 @@ class PropertyValueDelegate { required NullSubstitutionMode nullSubstitutionMode, }) { if (text.trim().isEmpty) return text; + // Formulas first, so `${= ...}` isn't read as a path. + text = FormulaEvaluator.substitute( + text, + (path) => + getVariableValueFromPath(path, scopedValues: scopedValues), + nullText: switch (nullSubstitutionMode) { + NullSubstitutionMode.nullValue => 'null', + _ => '', + }, + ); return text.splitMapJoinRegex( variablePathRegex, onNonMatch: (text) => text, @@ -232,6 +254,17 @@ class PropertyValueDelegate { ); } + /// Evaluates a formula [expression] (the part after `${=`), reading its + /// variable paths from [scopedValues]. See [FormulaEvaluator]. + static Object? evaluateFormula( + String expression, { + required ScopedValues scopedValues, + }) => FormulaEvaluator.evaluate( + expression, + (path) => + getVariableValueFromPath(path, scopedValues: scopedValues), + ); + /// Retrieves the value of a variable [path]. This also supports predefined /// variables such as data, index, and item. /// @@ -246,6 +279,15 @@ class PropertyValueDelegate { String path, { required ScopedValues scopedValues, }) { + final String? expression = FormulaEvaluator.exactExpression(path); + if (expression != null) { + final Object? value = evaluateFormula( + expression, + scopedValues: scopedValues, + ); + if (R == String) return FormulaEvaluator.stringify(value) as R?; + return value?.typedValue(); + } final match = VariableMatch.parse(path.wrapWithVariableSyntax()); if (match == null) return null; diff --git a/lib/src/transformers/widget_node_transformer_manager.dart b/lib/src/transformers/widget_node_transformer_manager.dart index 93c04537..99ada0f5 100644 --- a/lib/src/transformers/widget_node_transformer_manager.dart +++ b/lib/src/transformers/widget_node_transformer_manager.dart @@ -131,6 +131,73 @@ abstract class WidgetNodeTransformerManager ); } + static final RegExp _defaultNodeName = RegExp( + r'^(frame|rectangle|row|column|stack|group|icon|container|ellipse|image|' + r'auto layout|layout|node)\s*\d*$', + caseSensitive: false, + ); + + /// Tells screen readers that a tappable node is a button. A node with text + /// inside reads as that text; one without (an icon button) reads as its + /// node name, or its icon's name when the node still has a default name. + Widget wrapWithSemantics(BaseNode node, Widget widget) { + if (node is! ReactionMixin) return widget; + if (node case CanvasNode() || SpacerNode()) return widget; + if (node is CustomPropertiesMixin && + node.handlesDefaultReactionsInternally) { + return widget; + } + final bool tappable = (node as ReactionMixin).reactions.any( + (reaction) => + reaction.trigger.type == TriggerType.click && reaction.action.enabled, + ); + if (!tappable) return widget; + + final List descendants = []; + void collect(BaseNode parent) { + for (final String id in parent.childrenOrEmpty) { + final BaseNode? child = _tryGetNode(id); + if (child == null) continue; + descendants.add(child); + collect(child); + } + } + + collect(node); + + final bool hasText = descendants.any((child) => child is TextNode); + final String label = hasText ? '' : semanticLabelFor(node, descendants); + return MergeSemantics( + child: Semantics( + button: true, + label: label.isEmpty ? null : label, + child: widget, + ), + ); + } + + /// The name a screen reader announces for [node] when it has no text. + static String semanticLabelFor(BaseNode node, List descendants) { + final String name = node.name.trim(); + if (name.isNotEmpty && !_defaultNodeName.hasMatch(name)) return name; + for (final BaseNode child in descendants) { + if (child is! IconNode) continue; + final String? iconName = child.properties.icon.icon?.name; + if (iconName != null && iconName.isNotEmpty) { + return iconName.replaceAll('_', ' '); + } + } + return ''; + } + + BaseNode? _tryGetNode(String id) { + try { + return getNode(id); + } catch (_) { + return null; + } + } + /// Convenience method to handle widget reactions. Widget wrapWithReaction(BuildContext context, BaseNode node, Widget widget) { if (node is! ReactionMixin) return widget; @@ -175,11 +242,13 @@ abstract class WidgetNodeTransformerManager TriggerType.click, reactions: onClickReactions, ), - onLongPress: () => FunctionsRepository.triggerAction( - context, - TriggerType.longPress, - reactions: onLongPressReactions, - ), + onLongPress: onLongPressReactions.isEmpty + ? null + : () => FunctionsRepository.triggerAction( + context, + TriggerType.longPress, + reactions: onLongPressReactions, + ), borderRadius: getBorderRadius(node), overlayColor: inkWell.overlayColor != null ? WidgetStatePropertyAll( @@ -208,11 +277,13 @@ abstract class WidgetNodeTransformerManager TriggerType.click, reactions: onClickReactions, ), - onLongPress: () => FunctionsRepository.triggerAction( - context, - TriggerType.longPress, - reactions: onLongPressReactions, - ), + onLongPress: onLongPressReactions.isEmpty + ? null + : () => FunctionsRepository.triggerAction( + context, + TriggerType.longPress, + reactions: onLongPressReactions, + ), child: widget, ), ); diff --git a/lib/src/utils/extensions.dart b/lib/src/utils/extensions.dart index cb4e2d53..08ab9da9 100644 --- a/lib/src/utils/extensions.dart +++ b/lib/src/utils/extensions.dart @@ -1679,6 +1679,9 @@ extension ActionModelExt on ActionModel { case ActionType.link: final LinkAction action = this as LinkAction; strings.addAll([action.url]); + case ActionType.showToast: + final ShowToastAction action = this as ShowToastAction; + strings.addAll([action.message]); case ActionType.submit: final SubmitAction action = this as SubmitAction; strings.addAll([action.apiKey, action.primaryTextField]); diff --git a/lib/src/utils/formula.dart b/lib/src/utils/formula.dart new file mode 100644 index 00000000..9425995b --- /dev/null +++ b/lib/src/utils/formula.dart @@ -0,0 +1,687 @@ +import 'dart:math' as math; + +/// Matches a formula written as `${= expression}` in a text. +/// +/// The expression can't contain braces, except inside quoted strings. +final RegExp formulaRegex = RegExp( + r'''(?(?:[^{}'"]|'[^']*'|"[^"]*")*)\}''', +); + +/// Matches a text that is exactly one formula, e.g. `${= qty * price}`. +final RegExp exactFormulaRegex = RegExp( + r'''^\s*(?(?:[^{}'"]|'[^']*'|"[^"]*")*)\}\s*$''', +); + +/// Resolves a variable path such as `storage.cart` or `item.price` to its +/// value. +typedef FormulaPathResolver = Object? Function(String path); + +/// Evaluates small expressions used in `${= ...}` formulas. +/// +/// Supports: +/// - numbers, 'single' or "double" quoted strings, true, false, null and +/// `[a, b]` lists. +/// - variable paths like `storage.cart.length` or `item.price`, resolved with +/// 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, +/// length, if, plural, join, upper, lower, contains, where, without, +/// number, text, isEmpty and isNotEmpty. See [FormulaEvaluator.functions]. +/// +/// Null counts as 0 in arithmetic and as empty text when joining text. An +/// invalid expression evaluates to null. +class FormulaEvaluator { + const FormulaEvaluator._(); + + static final Map _cache = {}; + + /// Evaluates [expression], resolving variable paths with [resolve]. + static Object? evaluate(String expression, FormulaPathResolver resolve) { + final _Node? node = _parse(expression); + if (node == null) return null; + try { + return node.eval(resolve); + } catch (_) { + return null; + } + } + + /// The variable paths [expression] reads, e.g. `storage.cart` and `qty`. + static Set referencedPaths(String expression) { + final _Node? node = _parse(expression); + if (node == null) return {}; + final Set paths = {}; + node.collectPaths(paths); + return paths; + } + + /// The variable paths read by every formula in [text]. + static Set referencedPathsInText(String text) => { + for (final match in formulaRegex.allMatches(text)) + ...referencedPaths(match.namedGroup('expr')!), + }; + + /// Replaces every `${= ...}` formula in [text] with its result as text. + /// A formula that evaluates to null becomes [nullText]. + static String substitute( + String text, + FormulaPathResolver resolve, { + String nullText = '', + }) { + if (!text.contains(r'${=')) return text; + return text.splitMapJoin( + formulaRegex, + onMatch: (match) { + final Object? value = evaluate( + (match as RegExpMatch).namedGroup('expr')!, + resolve, + ); + return value == null ? nullText : stringify(value); + }, + onNonMatch: (text) => text, + ); + } + + /// The expression of [text] when it is exactly one formula, else null. + static String? exactExpression(String text) => + exactFormulaRegex.firstMatch(text)?.namedGroup('expr'); + + /// Formats [value] as text: whole numbers drop their decimals and other + /// numbers lose floating point noise, e.g. 17.200000000000003 is "17.2". + static String stringify(Object? value) { + if (value == null) return ''; + if (value is num) { + if (value.isNaN || value.isInfinite) return ''; + if (value == value.roundToDouble()) return value.toInt().toString(); + String text = value.toStringAsFixed(10); + text = text.replaceFirst(RegExp(r'0+$'), ''); + return text.endsWith('.') ? text.substring(0, text.length - 1) : text; + } + if (value is List) return value.map(stringify).join(', '); + return value.toString(); + } + + /// Parses [expression], caching the result. Returns null when it's invalid. + static _Node? _parse(String expression) { + final String key = expression.trim(); + if (_cache.containsKey(key)) return _cache[key]; + _Node? node; + try { + node = _Parser(key).parseAll(); + } catch (_) { + node = null; + } + if (_cache.length > 2000) _cache.clear(); + return _cache[key] = node; + } + + /// Names of the functions formulas can call. + static Iterable get functions => _functions.keys; +} + +// --------------------------------------------------------------------------- +// Values +// --------------------------------------------------------------------------- + +bool _truthy(Object? value) => switch (value) { + null => false, + bool() => value, + num() => value != 0, + String() => value.isNotEmpty && value.toLowerCase() != 'false', + Iterable() => value.isNotEmpty, + Map() => value.isNotEmpty, + _ => true, +}; + +/// Reads [value] as a number: numbers as is, numeric text parsed, null as 0. +num? _toNum(Object? value) => switch (value) { + null => 0, + num() => value, + bool() => value ? 1 : 0, + String() => value.trim().isEmpty ? 0 : num.tryParse(value.trim()), + _ => null, +}; + +num _num(Object? value) => _toNum(value) ?? double.nan; + +/// Keeps whole results as ints so `2 * 3` prints "6". +num _tidy(num value) => + value is double && value.isFinite && value == value.roundToDouble() + ? value.toInt() + : value; + +bool _looseEquals(Object? a, Object? b) { + if (a == null || b == null) return a == b; + final num? na = a is String ? num.tryParse(a) : _toNum(a); + final num? nb = b is String ? num.tryParse(b) : _toNum(b); + if ((a is num || b is num) && na != null && nb != null) return na == nb; + if (a is bool || b is bool) return _truthy(a) == _truthy(b); + return a.toString() == b.toString(); +} + +int _compare(Object? a, Object? b) { + final num? na = a is String ? num.tryParse(a) : _toNum(a); + final num? nb = b is String ? num.tryParse(b) : _toNum(b); + if (na != null && nb != null) return na.compareTo(nb); + return (a?.toString() ?? '').compareTo(b?.toString() ?? ''); +} + +Object? _member(Object? target, Object? key) { + if (target == null) return null; + if (target is List) { + if (key == 'length') return target.length; + if (key == 'first') return target.isEmpty ? null : target.first; + if (key == 'last') return target.isEmpty ? null : target.last; + final num? index = _toNum(key); + if (index == null) return null; + final int i = index.toInt(); + return i >= 0 && i < target.length ? target[i] : null; + } + if (target is Map) { + if (target.containsKey(key)) return target[key]; + if (key == 'length') return target.length; + return target[key.toString()]; + } + if (target is String) { + if (key == 'length') return target.length; + final num? index = _toNum(key); + if (index == null) return null; + final int i = index.toInt(); + return i >= 0 && i < target.length ? target[i] : null; + } + return null; +} + +String _fixed(Object? value, Object? digits) => + _num(value).toStringAsFixed(_num(digits ?? 2).toInt().clamp(0, 20)); + +String _money(Object? value, Object? symbol) { + final num amount = _num(value); + if (amount.isNaN) return ''; + final String sign = amount < 0 ? '-' : ''; + final String text = amount.abs().toStringAsFixed(2); + final int dot = text.indexOf('.'); + final String whole = text + .substring(0, dot) + .replaceAllMapped(RegExp(r'\B(?=(\d{3})+(?!\d))'), (_) => ','); + return '$sign${symbol ?? r'$'}$whole${text.substring(dot)}'; +} + +num _round(Object? value, Object? digits) { + final num v = _num(value); + final int d = _num(digits ?? 0).toInt(); + if (d <= 0) return v.round(); + final num factor = math.pow(10, d); + return _tidy((v * factor).round() / factor); +} + +/// Values of [list], or of each item's [field] when given. +Iterable _values(Object? list, Object? field) { + if (list is! Iterable) return const []; + if (field == null) return list; + return list.map((item) => _member(item, field)); +} + +typedef _Function = Object? Function(List args); + +Object? _arg(List args, int index) => + index < args.length ? args[index] : null; + +final Map _functions = { + 'money': (a) => _money(_arg(a, 0), _arg(a, 1)), + 'fixed': (a) => _fixed(_arg(a, 0), _arg(a, 1)), + 'round': (a) => _round(_arg(a, 0), _arg(a, 1)), + 'floor': (a) => _num(_arg(a, 0)).floor(), + 'ceil': (a) => _num(_arg(a, 0)).ceil(), + 'abs': (a) => _num(_arg(a, 0)).abs(), + 'min': (a) => (a.length == 1 && a.first is Iterable ? a.first as Iterable : a) + .map(_num) + .reduce(math.min), + 'max': (a) => (a.length == 1 && a.first is Iterable ? a.first as Iterable : a) + .map(_num) + .reduce(math.max), + 'sum': (a) => _tidy( + _values(_arg(a, 0), _arg(a, 1)).fold(0, (s, v) => s + _num(v)), + ), + 'count': (a) { + if (a.length < 3) return _values(_arg(a, 0), null).length; + return _values( + _arg(a, 0), + _arg(a, 1), + ).where((v) => _looseEquals(v, _arg(a, 2))).length; + }, + 'length': (a) => switch (_arg(a, 0)) { + final String s => s.length, + final Iterable l => l.length, + final Map m => m.length, + _ => 0, + }, + 'if': (a) => _truthy(_arg(a, 0)) ? _arg(a, 1) : _arg(a, 2), + 'plural': (a) => + _num(_arg(a, 0)) == 1 ? _arg(a, 1) : (_arg(a, 2) ?? '${_arg(a, 1)}s'), + 'join': (a) => _values(_arg(a, 0), _arg(a, 2)) + .map(FormulaEvaluator.stringify) + .where((s) => s.isNotEmpty) + .join((_arg(a, 1) ?? ', ').toString()), + 'upper': (a) => FormulaEvaluator.stringify(_arg(a, 0)).toUpperCase(), + 'lower': (a) => FormulaEvaluator.stringify(_arg(a, 0)).toLowerCase(), + 'contains': (a) => switch (_arg(a, 0)) { + final String s => s.contains(FormulaEvaluator.stringify(_arg(a, 1))), + final Iterable l => l.any((v) => _looseEquals(v, _arg(a, 1))), + final Map m => m.containsKey(_arg(a, 1)), + _ => false, + }, + 'where': (a) => [ + for (final item in _values(_arg(a, 0), null)) + if (a.length < 3 + ? _truthy(_member(item, _arg(a, 1))) + : _looseEquals(_member(item, _arg(a, 1)), _arg(a, 2))) + item, + ], + 'without': (a) => [ + for (final item in _values(_arg(a, 0), null)) + if (!_values(_arg(a, 1), null).any((other) => _looseEquals(item, other))) + item, + ], + 'number': (a) => _toNum(_arg(a, 0)), + 'text': (a) => FormulaEvaluator.stringify(_arg(a, 0)), + 'isEmpty': (a) => !_truthy(_functions['length']!(a)), + 'isNotEmpty': (a) => _truthy(_functions['length']!(a)), +}; + +// --------------------------------------------------------------------------- +// Syntax tree +// --------------------------------------------------------------------------- + +sealed class _Node { + const _Node(); + + Object? eval(FormulaPathResolver resolve); + + void collectPaths(Set paths) {} +} + +class _Literal extends _Node { + final Object? value; + + const _Literal(this.value); + + @override + Object? eval(FormulaPathResolver resolve) => value; +} + +class _Path extends _Node { + final String path; + + const _Path(this.path); + + @override + Object? eval(FormulaPathResolver resolve) => resolve(path); + + @override + void collectPaths(Set paths) => paths.add(path); +} + +class _ListLiteral extends _Node { + final List<_Node> items; + + const _ListLiteral(this.items); + + @override + Object? eval(FormulaPathResolver resolve) => [ + for (final item in items) item.eval(resolve), + ]; + + @override + void collectPaths(Set paths) { + for (final item in items) { + item.collectPaths(paths); + } + } +} + +class _Member extends _Node { + final _Node target; + final _Node key; + + const _Member(this.target, this.key); + + @override + Object? eval(FormulaPathResolver resolve) => + _member(target.eval(resolve), key.eval(resolve)); + + @override + void collectPaths(Set paths) { + target.collectPaths(paths); + key.collectPaths(paths); + } +} + +class _Call extends _Node { + final String name; + final List<_Node> args; + + const _Call(this.name, this.args); + + @override + Object? eval(FormulaPathResolver resolve) { + // `if` only evaluates the branch it returns. + if (name == 'if') { + _Node? arg(int i) => i < args.length ? args[i] : null; + return _truthy(arg(0)?.eval(resolve)) + ? arg(1)?.eval(resolve) + : arg(2)?.eval(resolve); + } + return _functions[name]!([for (final arg in args) arg.eval(resolve)]); + } + + @override + void collectPaths(Set paths) { + for (final arg in args) { + arg.collectPaths(paths); + } + } +} + +class _Unary extends _Node { + final String op; + final _Node operand; + + const _Unary(this.op, this.operand); + + @override + Object? eval(FormulaPathResolver resolve) { + final Object? value = operand.eval(resolve); + return op == '!' ? !_truthy(value) : _tidy(-_num(value)); + } + + @override + void collectPaths(Set paths) => operand.collectPaths(paths); +} + +class _Binary extends _Node { + final String op; + final _Node left; + final _Node right; + + const _Binary(this.op, this.left, this.right); + + @override + Object? eval(FormulaPathResolver resolve) { + final Object? a = left.eval(resolve); + if (op == '&&') return _truthy(a) ? right.eval(resolve) : a; + if (op == '||') return _truthy(a) ? a : right.eval(resolve); + final Object? b = right.eval(resolve); + switch (op) { + case '+': + final num? na = a is String ? null : _toNum(a); + final num? nb = b is String ? null : _toNum(b); + if (na != null && nb != null) return _tidy(na + nb); + return FormulaEvaluator.stringify(a) + FormulaEvaluator.stringify(b); + case '-': + return _tidy(_num(a) - _num(b)); + case '*': + return _tidy(_num(a) * _num(b)); + case '/': + final num divisor = _num(b); + return divisor == 0 ? null : _tidy(_num(a) / divisor); + case '%': + final num divisor = _num(b); + return divisor == 0 ? null : _tidy(_num(a) % divisor); + case '==': + return _looseEquals(a, b); + case '!=': + return !_looseEquals(a, b); + case '<': + return _compare(a, b) < 0; + case '<=': + return _compare(a, b) <= 0; + case '>': + return _compare(a, b) > 0; + case '>=': + return _compare(a, b) >= 0; + } + return null; + } + + @override + void collectPaths(Set paths) { + left.collectPaths(paths); + right.collectPaths(paths); + } +} + +class _Ternary extends _Node { + final _Node condition; + final _Node then; + final _Node otherwise; + + const _Ternary(this.condition, this.then, this.otherwise); + + @override + Object? eval(FormulaPathResolver resolve) => _truthy(condition.eval(resolve)) + ? then.eval(resolve) + : otherwise.eval(resolve); + + @override + void collectPaths(Set paths) { + condition.collectPaths(paths); + then.collectPaths(paths); + otherwise.collectPaths(paths); + } +} + +// --------------------------------------------------------------------------- +// Parser +// --------------------------------------------------------------------------- + +class _Parser { + final String source; + int pos = 0; + + _Parser(this.source); + + static final RegExp _number = RegExp(r'\d+(\.\d+)?|\.\d+'); + static final RegExp _identifier = RegExp(r'[a-zA-Z_][a-zA-Z0-9_]*'); + + _Node parseAll() { + final _Node node = _ternary(); + _skip(); + if (pos != source.length) { + throw FormatException('Unexpected "${source[pos]}"', source, pos); + } + return node; + } + + void _skip() { + while (pos < source.length && source[pos].trim().isEmpty) { + pos++; + } + } + + bool _eat(String token) { + _skip(); + if (!source.startsWith(token, pos)) return false; + // Don't read `=` of `==` or `<=` as a separate token. + if ((token == '<' || token == '>' || token == '!') && + source.startsWith('=', pos + 1)) { + return false; + } + pos += token.length; + return true; + } + + void _expect(String token) { + if (!_eat(token)) throw FormatException('Expected "$token"', source, pos); + } + + _Node _ternary() { + final _Node condition = _or(); + if (!_eat('?')) return condition; + final _Node then = _ternary(); + _expect(':'); + return _Ternary(condition, then, _ternary()); + } + + _Node _or() { + _Node node = _and(); + while (_eat('||')) { + node = _Binary('||', node, _and()); + } + return node; + } + + _Node _and() { + _Node node = _equality(); + while (_eat('&&')) { + node = _Binary('&&', node, _equality()); + } + return node; + } + + _Node _equality() { + _Node node = _comparison(); + while (true) { + if (_eat('==')) { + node = _Binary('==', node, _comparison()); + } else if (_eat('!=')) { + node = _Binary('!=', node, _comparison()); + } else { + return node; + } + } + } + + _Node _comparison() { + _Node node = _additive(); + while (true) { + final String? op = ['<=', '>=', '<', '>'].where(_eat).firstOrNull; + if (op == null) return node; + node = _Binary(op, node, _additive()); + } + } + + _Node _additive() { + _Node node = _multiplicative(); + while (true) { + final String? op = ['+', '-'].where(_eat).firstOrNull; + if (op == null) return node; + node = _Binary(op, node, _multiplicative()); + } + } + + _Node _multiplicative() { + _Node node = _unary(); + while (true) { + final String? op = ['*', '/', '%'].where(_eat).firstOrNull; + if (op == null) return node; + node = _Binary(op, node, _unary()); + } + } + + _Node _unary() { + if (_eat('!')) return _Unary('!', _unary()); + if (_eat('-')) return _Unary('-', _unary()); + return _postfix(_primary()); + } + + _Node _postfix(_Node node) { + while (true) { + if (_eat('[')) { + node = _Member(node, _ternary()); + _expect(']'); + } else if (_eat('.')) { + final Match? name = _identifier.matchAsPrefix(source, pos); + if (name == null) throw FormatException('Expected a name', source, pos); + pos = name.end; + node = _Member(node, _Literal(name[0])); + } else { + return node; + } + } + } + + _Node _primary() { + _skip(); + if (pos >= source.length) { + throw FormatException('Unexpected end', source, pos); + } + final String char = source[pos]; + + if (char == '(') { + pos++; + final _Node node = _ternary(); + _expect(')'); + return node; + } + + if (char == '[') { + pos++; + final List<_Node> items = []; + if (!_eat(']')) { + do { + items.add(_ternary()); + } while (_eat(',')); + _expect(']'); + } + return _ListLiteral(items); + } + + if (char == "'" || char == '"') { + final int end = source.indexOf(char, pos + 1); + if (end < 0) throw FormatException('Unclosed text', source, pos); + final String text = source.substring(pos + 1, end); + pos = end + 1; + return _Literal(text); + } + + final Match? number = _number.matchAsPrefix(source, pos); + if (number != null) { + pos = number.end; + return _Literal(num.parse(number[0]!)); + } + + final Match? identifier = _identifier.matchAsPrefix(source, pos); + if (identifier == null) { + throw FormatException('Unexpected "$char"', source, pos); + } + pos = identifier.end; + final String name = identifier[0]!; + + switch (name) { + case 'true': + return const _Literal(true); + case 'false': + return const _Literal(false); + case 'null': + return const _Literal(null); + } + + if (_eat('(')) { + if (!_functions.containsKey(name)) { + throw FormatException('Unknown function "$name"', source, pos); + } + final List<_Node> args = []; + if (!_eat(')')) { + do { + args.add(_ternary()); + } while (_eat(',')); + _expect(')'); + } + return _Call(name, args); + } + + // A variable path: name followed by `.key` and `[digits]` parts, read as + // one path so accessors like `.length` and `.initial` work as in `${}`. + final StringBuffer path = StringBuffer(name); + while (true) { + final Match? part = RegExp(r'\.[a-zA-Z][a-zA-Z0-9_]*|\[\d+\]') + .matchAsPrefix(source, pos); + if (part == null) break; + path.write(part[0]); + pos = part.end; + } + return _Path(path.toString()); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 219f375a..8429ee72 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -92,3 +92,12 @@ false_secrets: - /lib/src/firebase_options_prod.dart scripts: scripts.yaml + +# Temporary: the show toast action needs codelessly_api from +# Codelessly/CodelesslyAPI#37, merged but not yet published. Remove once +# codelessly_api is released. +dependency_overrides: + codelessly_api: + git: + url: https://github.com/Codelessly/CodelesslyAPI.git + ref: master diff --git a/test/formula_test.dart b/test/formula_test.dart new file mode 100644 index 00000000..07426f83 --- /dev/null +++ b/test/formula_test.dart @@ -0,0 +1,218 @@ +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:codelessly_sdk/codelessly_sdk.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + final Map vars = { + 'qty': 2, + 'price': 5.25, + 'name': 'Ava', + 'shots': '3', + 'cart': [ + {'name': 'Cortado', 'qty': 1, 'total': 4.75}, + {'name': 'Oat latte', 'qty': 2, 'total': 12.9}, + ], + 'syrups': ['vanilla', 'honey'], + }; + Object? resolve(String path) { + final parts = RegExp(r'[a-zA-Z_]\w*|\d+').allMatches(path).toList(); + Object? value = vars[parts.first[0]]; + for (final part in parts.skip(1).map((m) => m[0]!)) { + value = switch (value) { + final List list when part == 'length' => list.length, + final List list => list[int.parse(part)], + final Map map => map[part], + _ => null, + }; + } + return value; + } + + Object? eval(String expression) => + FormulaEvaluator.evaluate(expression, resolve); + + group('FormulaEvaluator', () { + test('arithmetic and precedence', () { + expect(eval('1 + 2 * 3'), 7); + expect(eval('(1 + 2) * 3'), 9); + expect(eval('qty * price'), 10.5); + expect(eval('10 / 4'), 2.5); + expect(eval('10 / 0'), isNull); + expect(eval('7 % 3'), 1); + expect(eval('-qty + 1'), -1); + expect(eval('shots * 0.9'), closeTo(2.7, 1e-9)); + expect(eval('missing + 1'), 1); + }); + + test('text', () { + expect(eval("'Hi ' + name"), 'Hi Ava'); + expect(eval('"x" + 2'), 'x2'); + expect(eval("upper(name) + lower('B')"), 'AVAb'); + expect(eval("plural(qty, 'drink')"), 'drinks'); + expect(eval("plural(1, 'drink')"), 'drink'); + expect(eval("join(syrups, ' · ')"), 'vanilla · honey'); + expect(eval("join(cart, ', ', 'name')"), 'Cortado, Oat latte'); + }); + + test('comparisons, logic and ternary', () { + expect(eval('qty > 1 && price < 6'), true); + expect(eval('qty == 2'), true); + expect(eval('shots == 3'), true); + expect(eval("name != 'Ava'"), false); + expect(eval('!qty'), false); + expect(eval("qty > 1 ? 'many' : 'one'"), 'many'); + expect(eval("qty > 5 ? 'a' : qty > 1 ? 'b' : 'c'"), 'b'); + expect(eval('missing || 4'), 4); + }); + + test('functions', () { + expect(eval('money(qty * price)'), r'$10.50'); + expect(eval('money(1234567.891)'), r'$1,234,567.89'); + expect(eval("money(-3, '€')"), '-€3.00'); + expect(eval('fixed(price, 1)'), '5.3'); + expect(eval('round(2.345, 2)'), 2.35); + expect(eval('round(2.5)'), 3); + expect(eval('floor(2.9) + ceil(2.1)'), 5); + expect(eval('min(3, 1, 2)'), 1); + expect(eval('max([3, 9, 2])'), 9); + expect(eval("sum(cart, 'total')"), 17.65); + expect(eval("sum(cart, 'qty')"), 3); + expect(eval('count(cart)'), 2); + expect(eval("count(cart, 'qty', 2)"), 1); + expect(eval('length(syrups)'), 2); + expect(eval("contains(syrups, 'honey')"), true); + expect(eval("if(qty > 1, 'yes', 'no')"), 'yes'); + expect(eval('isEmpty(cart)'), false); + expect(eval("number('4.5') + 1"), 5.5); + expect(eval("where(cart, 'qty', 2)"), [ + {'name': 'Oat latte', 'qty': 2, 'total': 12.9}, + ]); + expect(eval("length(where(cart, 'qty'))"), 2); + expect(eval("without(syrups, ['honey'])"), ['vanilla']); + }); + + test('member access', () { + expect(eval('cart[1].name'), 'Oat latte'); + expect(eval('cart[qty - 1].total'), 12.9); + expect(eval('cart.length'), 2); + expect(eval('syrups[5]'), isNull); + }); + + test('invalid expressions evaluate to null', () { + expect(eval('1 +'), isNull); + expect(eval('nope(1)'), isNull); + expect(eval("'open"), isNull); + }); + + test('substitute and stringify', () { + expect( + FormulaEvaluator.substitute(r'Total ${= money(qty * price)}!', resolve), + r'Total $10.50!', + ); + expect(FormulaEvaluator.substitute(r'${= 0.1 + 0.2}', resolve), '0.3'); + expect(FormulaEvaluator.substitute(r'${= 3 * 2.0}', resolve), '6'); + expect( + FormulaEvaluator.substitute(r"${= qty > 1 ? '}' : '{'}", resolve), + '}', + ); + expect(FormulaEvaluator.substitute(r'\${= 1}', resolve), r'\${= 1}'); + expect(FormulaEvaluator.exactExpression(r' ${= a + b} '), ' a + b'); + expect(FormulaEvaluator.exactExpression(r'${= a} and ${b}'), isNull); + }); + + test('referenced paths', () { + expect( + FormulaEvaluator.referencedPaths( + "money(sum(storage.cart, 'total')) + item.name + cart[index].qty", + ), + {'storage.cart', 'item.name', 'cart', 'index'}, + ); + expect( + FormulaEvaluator.referencedPathsInText(r'${= a + 1} ${b} ${= c}'), + {'a', 'c'}, + ); + }); + }); + + testWidgets('formulas read variables through PropertyValueDelegate', ( + tester, + ) async { + final List variables = [ + VariableData( + id: 'qty', + name: 'qty', + type: VariableType.integer, + value: 2, + ), + VariableData( + id: 'lines', + name: 'lines', + type: VariableType.list, + value: [ + {'total': 8.6}, + {'total': 11.85}, + ], + ), + ]; + late ScopedValues values; + await tester.pumpWidget( + IndexedItemProvider( + item: IndexedItem(0, {'price': 4.25, 'name': 'Cortado'}), + child: Builder( + builder: (context) { + values = ScopedValues.of(context, variablesOverrides: variables); + return const SizedBox(); + }, + ), + ), + ); + + String sub(String text) => PropertyValueDelegate.substituteVariables( + text, + scopedValues: values, + nullSubstitutionMode: NullSubstitutionMode.emptyString, + ); + + expect(sub(r'${qty} × ${= money(item.price * qty)}'), r'2 × $8.50'); + expect(sub(r"${= sum(lines, 'total')}"), '20.45'); + expect(sub(r'${= lines.length} ${item.name}'), '2 Cortado'); + expect( + PropertyValueDelegate.getVariableValueFromPath( + r'${= qty * 3}', + scopedValues: values, + ), + 6, + ); + expect( + PropertyValueDelegate.getVariableValueFromPath( + r'${= qty > 1}', + scopedValues: values, + ), + true, + ); + expect( + FunctionsRepository.substituteNewValue( + r'[{"name": "${item.name}", "unit": "${= item.price * qty}"}]', + VariableType.list, + values, + ), + '[{"name":"Cortado","unit":8.5}]', + ); + expect( + FunctionsRepository.substituteNewValue( + r'${= item}', + VariableType.map, + values, + ), + '{"price":4.25,"name":"Cortado"}', + ); + expect( + PropertyValueDelegate.substituteVariablesInJson( + r'{"total": "${= item.price * qty}", "label": "${= item.name} x${qty}"}', + scopedValues: values, + ), + {'total': 8.5, 'label': 'Cortado x2'}, + ); + }); +} diff --git a/test/semantics_test.dart b/test/semantics_test.dart new file mode 100644 index 00000000..e9432117 --- /dev/null +++ b/test/semantics_test.dart @@ -0,0 +1,78 @@ +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 tappable(String id, String name) => RectangleNode( + id: id, + name: name, + basicBoxLocal: NodeBox(0, 0, 40, 40), + reactions: [ + Reaction( + action: LinkAction(url: ''), + trigger: TriggerModel(type: TriggerType.click), + ), + ], +)..parentID = kRootNode; + +Future pumpNode(WidgetTester tester, BaseNode node) 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.empty(), + child: MaterialApp( + home: Align( + alignment: Alignment.topLeft, + child: Builder( + builder: (context) => manager.buildWidgetFromNode( + node, + context, + settings: const WidgetBuildSettings(debugLabel: 'test'), + ), + ), + ), + ), + ), + ); +} + +void main() { + testWidgets('a tappable node without text reads as a named button', ( + tester, + ) async { + final SemanticsHandle handle = tester.ensureSemantics(); + await pumpNode(tester, tappable('back', 'Back to menu')); + + expect( + tester.getSemantics(find.bySemanticsLabel('Back to menu')), + matchesSemantics( + label: 'Back to menu', + isButton: true, + hasTapAction: true, + ), + ); + handle.dispose(); + }); + + test('a default node name is not used as a label', () { + expect( + WidgetNodeTransformerManager.semanticLabelFor( + tappable('r', 'Rectangle 2'), + const [], + ), + '', + ); + expect( + WidgetNodeTransformerManager.semanticLabelFor( + tappable('r', 'Add to bag'), + const [], + ), + 'Add to bag', + ); + }); +} diff --git a/test/style_overrides_test.dart b/test/style_overrides_test.dart new file mode 100644 index 00000000..67fe0c8e --- /dev/null +++ b/test/style_overrides_test.dart @@ -0,0 +1,88 @@ +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:codelessly_sdk/codelessly_sdk.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; + +void main() { + testWidgets('fill, opacity and size follow bound formulas', (tester) async { + final RectangleNode node = RectangleNode.fromJson({ + ...RectangleNode( + id: 'cup', + name: 'Cup layer', + basicBoxLocal: NodeBox(0, 0, 40, 40), + fills: [ + PaintModel.solid(id: 'fill', color: ColorRGB.fromHex('#EDE3D6')), + ], + ).toJson(), + 'variables': { + 'fill': r"${= qty > 1 ? '#C8643B' : '#EDE3D6'}", + 'height': r'${= qty * 20}', + 'opacity': r'${= qty > 2 ? 0.5 : 1}', + }, + }); + node.parentID = kRootNode; + final registry = NodeRegistry()..setNodes({node.id: node}); + final manager = PassiveNodeTransformerManager( + registry.getNodeByID, + (context, bounds, pageID, layoutID, canvasID) => const SizedBox(), + ); + final Observable qty = Observable( + VariableData( + id: 'qty', + name: 'qty', + type: VariableType.integer, + value: 1, + ), + ); + final CodelesslyContext codelesslyContext = CodelesslyContext.empty() + ..variables['qty'] = qty; + + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: codelesslyContext, + child: MaterialApp( + home: Align( + alignment: Alignment.topLeft, + child: Builder( + builder: (context) => manager.buildWidgetFromNode( + node, + context, + settings: const WidgetBuildSettings(debugLabel: 'test'), + ), + ), + ), + ), + ), + ); + + Color? color() => tester.allWidgets + .map( + (w) => switch (w) { + final Ink ink => ink.decoration, + final DecoratedBox box => box.decoration, + _ => null, + }, + ) + .whereType() + .map((decoration) => decoration.color) + .nonNulls + .firstOrNull; + final Finder box = find.byWidgetPredicate( + (w) => w is Container && w.decoration is BoxDecoration, + ); + + expect(tester.getSize(box).height, 20); + expect(color(), const Color(0xFFEDE3D6)); + expect(find.byType(Opacity), findsNothing); + + qty.set(qty.value.copyWith(value: 3)); + await tester.pump(); + + expect(tester.getSize(box).height, 60); + expect(color(), const Color(0xFFC8643B)); + expect(tester.widget(find.byType(Opacity)).opacity, 0.5); + // The node itself is unchanged. + expect(node.basicBoxLocal.height, 40); + }); +} diff --git a/test/toast_test.dart b/test/toast_test.dart new file mode 100644 index 00000000..940850e8 --- /dev/null +++ b/test/toast_test.dart @@ -0,0 +1,68 @@ +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:codelessly_sdk/codelessly_sdk.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; + +void main() { + testWidgets('showToast shows the message with variables, then hides it', ( + tester, + ) async { + late BuildContext context; + final ctx = CodelesslyContext.empty() + ..variables['drink'] = Observable( + VariableData(id: 'drink', name: 'drink', value: 'Cortado'), + ); + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: ctx, + child: MaterialApp( + home: Builder( + builder: (c) { + context = c; + return const SizedBox(); + }, + ), + ), + ), + ); + + final reactions = [ + Reaction( + action: ShowToastAction( + message: r'Added ${drink} to your order', + duration: 1000, + ), + trigger: TriggerModel(type: TriggerType.click), + ), + ]; + FunctionsRepository.triggerAction( + context, + TriggerType.click, + reactions: reactions, + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + expect(find.text('Added Cortado to your order'), findsOneWidget); + + // A second toast replaces the first. + FunctionsRepository.triggerAction( + context, + TriggerType.click, + reactions: [ + Reaction( + action: ShowToastAction(message: 'Second', duration: 500), + trigger: TriggerModel(type: TriggerType.click), + ), + ], + ); + await tester.pump(); + expect(find.text('Added Cortado to your order'), findsNothing); + expect(find.text('Second'), findsOneWidget); + + await tester.pump(const Duration(milliseconds: 800)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + expect(find.text('Second'), findsNothing); + }); +}