diff --git a/CHANGELOG.md b/CHANGELOG.md index e3e5b41e..2370c90f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -62,6 +62,65 @@ side sheet, confirm presentation, toast styling, `contentFit` and item, built as a list of rows of `Expanded` cells in an `IntrinsicHeight` (`GridViewBuilder.intrinsicRows`). +### Runtime UI, semantics, links and charts + +- A text's color can be bound with a `textColor` variable or formula. +- Formulas: `range(start, end, step)` includes its end (`range(3, 7)` is + `[3, 4, 5, 6, 7]`; `range(n)` is still n items from 0) and steps through + dates in minutes; `interpolate`, `runs`, `minBy`, `maxBy`, `wallTime`, and + `now(offset)` / `today(offset)` at a UTC offset. `FormulaClock.pinned` lets a host pin the + clock they read (previews, reproducible screenshots). +- API calls: the response (or the error) is the chain's `${result}`; query + parameters are sent; `onSuccess`/`onError` actions run, and with + `onError` a failure no longer stops the chain; the handlers still run when + the widget that made the call has unmounted (a dialog closed mid-load). + `Codelessly(client:)` takes an HTTP client. +- Load-time API calls run in order with the canvas's other load actions. +- A row aligned to text baselines gets a text baseline (it asserted). +- Any node can bind `semanticLabel` and `semanticSelected` for screen + readers. A labelled button reads only its label and keeps its tap; a + labelled field or dropdown keeps its own semantics. +- A pop with a destination goes there when there is nothing to pop. +- Image fills honour `PaintModel.saturation` (greyscale at 0). +- Links: a `CodelesslyRoute` path is matched against the screens' URL paths + (`CanvasProperties.path`, e.g. `guide/:id`) before it is read as a screen + id, and `Codelessly.pathOf` gives the address for a route + (`CanvasLocation` does the matching). +- A line chart's axis title and readout units can be bound. +- An expanded dropdown no longer cuts its value at its design width. +- A toast's semantics are stable from its first frame, so its button can be + tapped on the web. +- Toasts, the built-in confirm dialog and dropdown menus take the new + `ShowToastAction`, `ConfirmAction` and `DropdownProperties.menuStyle` + styling: toast action color, close button, radius, border, hard shadows, + fonts, icon size and per-screen-size bottom offsets; confirm dialog border, + shadows, fonts and outlined, shadowed buttons; and a dropdown menu drawn as + a card anchored under the field with a border, shadows, a highlighted + selected row with a check mark and per-item images (`itemImage`). +- `FeedbackTheme` has `border`, `shadows`, `buttonBorder` and + `buttonShadows`; `FeedbackTheme.fromNodes` reads the outline and shadow + most of a layout's tappable shapes share for the confirm dialog's buttons. +- A data-source dropdown finds its selected item by content, so a map value + selects the equal item. +- Render `LineChartNode` with `PassiveLineChartTransformer` and + `PassiveLineChartWidget`: bands, grid, gradient area, area bands, a + polyline or Catmull-Rom curve, reference lines, x axis, markers, vertical + lines and a hover/tap/drag/arrow-key scrub cursor with a readout card. + `LineChartGeometry` holds the pure parts (dates, domain, interpolation, + Catmull-Rom control points, readout text and placement). +- `FormulaEvaluator.formatDate` exposes the `formatDate` formula function. +- A dialog whose canvas is responsive and holds a responsive layout (a + `VarianceNode` with breakpoints) fills the screen instead of keeping its + design size, so its variant follows the screen width and each variant + places itself (a full-screen sheet on mobile, a centered card on desktop). + Dialogs with plain fixed-size canvases are unchanged. +- In a dialog (`TapThroughScope`), taps on parts of the layout where nothing + paints or reacts (a canvas without a background, an empty frame) reach the + barrier, so a dismissible full-screen dialog closes on them. +- A `squeezable` node that wraps its content on a Row/Column's main axis is + laid out as `Flexible(fit: FlexFit.loose)`: it hugs its content and + shrinks to the room left when the Row/Column runs out of space. + ## 8.3.0 Requires codelessly_api 6.2.0. diff --git a/lib/codelessly_sdk.dart b/lib/codelessly_sdk.dart index ffaef123..0b3a19bf 100644 --- a/lib/codelessly_sdk.dart +++ b/lib/codelessly_sdk.dart @@ -35,6 +35,7 @@ export 'src/transformers/transformers.dart'; export 'src/transformers/utils/blend_mask.dart'; export 'src/transformers/utils/cloud_list_items.dart'; export 'src/transformers/utils/query_status.dart'; +export 'src/ui/canvas_location.dart'; export 'src/ui/codelessly_context.dart'; export 'src/ui/codelessly_route.dart'; export 'src/ui/codelessly_widget.dart'; diff --git a/lib/src/codelessly.dart b/lib/src/codelessly.dart index 0550d124..f373a2fa 100644 --- a/lib/src/codelessly.dart +++ b/lib/src/codelessly.dart @@ -46,7 +46,7 @@ class Codelessly { /// Initialization is needed before usage. static Codelessly get instance => _instance; - final Client _client = CodelesslyHttpClient(); + final Client _client; /// Returns the HTTP client used by this SDK instance. Client get client => _client; @@ -262,8 +262,7 @@ class Codelessly { final Map _navigationListeners = {}; /// The screen shown and its parameters, for the browser's address: a host - /// listens and writes [CodelesslyRoute.toPath] to the URL. Dialogs don't - /// change it. + /// listens and writes [pathOf] it to the URL. Dialogs don't change it. final ValueNotifier route = ValueNotifier(null); /// A screen to open once the app's first screen shows, e.g. the one a @@ -285,7 +284,31 @@ class Codelessly { /// the host's own route, which carries no parameters, so a link to it /// would otherwise lose them. Null when the link names another screen. Map? linkParamsFor(String? layoutId) { - return _maybeDataManager?.publishModel?.linkParamsFor(_link, layoutId); + return _maybeDataManager?.publishModel?.linkParamsFor( + _resolveLink(_link), + layoutId, + ); + } + + /// [link] with its URL path, if it has one, matched against the screens' + /// URL paths: the screen it names and the parameters the path and query + /// give. A path no screen's URL path matches is read as a screen id. + CodelesslyRoute? _resolveLink(CodelesslyRoute? link) { + final String? path = link?.path; + final SDKPublishModel? model = _maybeDataManager?.publishModel; + if (link == null || path == null || model == null) return link; + final CanvasLocationMatch? match = CanvasLocation.match( + model, + Uri( + path: path, + queryParameters: link.params.isEmpty ? null : link.params, + ).toString(), + ); + if (match == null) return link; + return CodelesslyRoute(match.canvasId, { + for (final MapEntry param in match.params.entries) + param.key: '${param.value}', + }); } final Map _breakpointsListeners = {}; @@ -312,7 +335,10 @@ class Codelessly { DataManager? publishDataManager, DataManager? previewDataManager, DataManager? templateDataManager, - }) { + + // The HTTP client for API calls and data loading (tests pass a mock). + Client? client, + }) : _client = client ?? CodelesslyHttpClient() { _config = config; _cacheManager = cacheManager; _authManager = authManager; @@ -925,20 +951,31 @@ class Codelessly { } } + /// The link for [route]: the screen's URL path with its parameters filled + /// in when it has one (`guide/bat-star` for a screen at `guide/:id`; the + /// app's first screen without a path is the empty path), else + /// [CodelesslyRoute.toPath]. + String pathOf(CodelesslyRoute route) { + final SDKPublishModel? model = dataManager.publishModel; + return (model == null + ? null + : CanvasLocation.locationOf(model, route.canvasId, route.params)) ?? + route.toPath(); + } + /// Opens [pendingRoute] over the first screen shown, unless it is that - /// screen. + /// screen. A link's path is matched against the screens' URL paths first, + /// then read as a screen id. void _openPendingRoute(BuildContext context, String canvasId) { final CodelesslyRoute? pending = _pendingRoute; if (pending == null) return; _pendingRoute = null; - if (pending.canvasId == canvasId) return; + final CodelesslyRoute? link = _resolveLink(pending); + if (link == null || link.canvasId == canvasId) return; unawaited( FunctionsRepository.navigate( context, - NavigationAction( - destinationId: pending.canvasId, - params: pending.params, - ), + NavigationAction(destinationId: link.canvasId, params: link.params), codelessly: this, ), ); diff --git a/lib/src/functions/app_confirm_dialog.dart b/lib/src/functions/app_confirm_dialog.dart index 6563aca0..2311ee16 100644 --- a/lib/src/functions/app_confirm_dialog.dart +++ b/lib/src/functions/app_confirm_dialog.dart @@ -13,8 +13,9 @@ typedef ConfirmOutcome = ({bool confirmed, Map? values}); /// /// Without a [ConfirmAction.dialogId] the dialog is built here and takes the /// look of the layout that shows it ([FeedbackTheme.of]): its font, surface, -/// text and primary colors and its buttons' corner radius. The action's own -/// colors and radius override that look. +/// text and primary colors and its buttons' corner radius, outline and +/// shadows. The action's own colors, radii, borders, shadows and text styles +/// override that look. /// /// With a [ConfirmAction.dialogId] the dialog is that canvas. A button in it /// answers with a "Navigate back" action whose params hold @@ -136,6 +137,21 @@ class AppConfirmDialog { textColor: action.textColor?.toFlutterColor(), confirmColor: action.confirmColor?.toFlutterColor(), cornerRadius: action.cornerRadius, + border: action.border == null ? null : borderSideOf(action.border!), + shadows: action.shadows == null ? null : boxShadowsOf(action.shadows), + titleStyle: action.titleStyle, + messageStyle: action.messageStyle, + buttonStyle: action.buttonStyle, + buttonRadius: action.buttonRadius, + buttonBorder: action.buttonBorder == null + ? null + : borderSideOf(action.buttonBorder!), + buttonShadows: action.buttonShadows == null + ? null + : boxShadowsOf(action.buttonShadows), + confirmTextColor: action.confirmTextColor?.toFlutterColor(), + cancelColor: action.cancelColor?.toFlutterColor(), + cancelTextColor: action.cancelTextColor?.toFlutterColor(), ), ); return (confirmed: confirmed ?? false, values: null); @@ -159,6 +175,17 @@ class ThemedConfirmDialog extends StatelessWidget { this.textColor, this.confirmColor, this.cornerRadius, + this.border, + this.shadows, + this.titleStyle, + this.messageStyle, + this.buttonStyle, + this.buttonRadius, + this.buttonBorder, + this.buttonShadows, + this.confirmTextColor, + this.cancelColor, + this.cancelTextColor, }); /// The app's look. @@ -195,6 +222,40 @@ class ThemedConfirmDialog extends StatelessWidget { /// Overrides the dialog's corner radius. final double? cornerRadius; + /// Overrides the theme's dialog outline. + final BorderSide? border; + + /// Overrides the theme's dialog shadows; set, they replace the elevation. + final List? shadows; + + /// Changes to the title's font, size and color. + final TextStyleModel? titleStyle; + + /// Changes to the message's font, size and color. + final TextStyleModel? messageStyle; + + /// Changes to the buttons' font, size and label color. + final TextStyleModel? buttonStyle; + + /// Overrides the theme's button corner radius. + final double? buttonRadius; + + /// Overrides the theme's button outline. + final BorderSide? buttonBorder; + + /// Overrides the theme's button shadows. The cancel button takes them only + /// when it has a [cancelColor]. + final List? buttonShadows; + + /// Label color of the confirm button. + final Color? confirmTextColor; + + /// Fill of the cancel button; null leaves it a text button. + final Color? cancelColor; + + /// Label color of the cancel button. + final Color? cancelTextColor; + @override Widget build(BuildContext context) { final Color surface = backgroundColor ?? theme.surface; @@ -205,17 +266,67 @@ class ThemedConfirmDialog extends StatelessWidget { : ResolvedFeedbackTheme.onColor(surface)); final Color confirmFill = confirmColor ?? (destructive ? theme.error : theme.primary); - final Color onConfirm = ResolvedFeedbackTheme.onColor(confirmFill); final double radius = cornerRadius ?? theme.radius; + final double buttonCorner = buttonRadius ?? theme.buttonRadius; + final BorderSide buttonSide = + buttonBorder ?? theme.buttonBorder ?? BorderSide.none; + final List buttonShadowList = + buttonShadows ?? theme.buttonShadows ?? const []; final OutlinedBorder buttonShape = RoundedRectangleBorder( - borderRadius: BorderRadius.circular(theme.buttonRadius), + borderRadius: BorderRadius.circular(buttonCorner), ); - final TextStyle buttonText = theme.buttonStyle.copyWith( - fontSize: 15, - height: 1.2, + final TextStyle buttonText = buttonStyle.applyTo( + theme.buttonStyle.copyWith(fontSize: 15, height: 1.2), ); + final Color onConfirm = + buttonStyle?.color?.toFlutterColor() ?? + confirmTextColor ?? + ResolvedFeedbackTheme.onColor(confirmFill); + final Color onCancel = + buttonStyle?.color?.toFlutterColor() ?? cancelTextColor ?? onSurface; const Size buttonSize = Size(64, 44); const EdgeInsets buttonPadding = EdgeInsets.symmetric(horizontal: 18); + final BorderSide? dialogSide = border ?? theme.border; + final List? dialogShadows = shadows ?? theme.shadows; + + Widget withShadow(Widget button) => buttonShadowList.isEmpty + ? button + : DecoratedBox( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(buttonCorner), + boxShadow: buttonShadowList, + ), + child: button, + ); + + final Widget cancel = TextButton( + key: const ValueKey('codelessly-confirm-cancel'), + onPressed: () => Navigator.of(context).pop(false), + style: TextButton.styleFrom( + foregroundColor: onCancel, + backgroundColor: cancelColor, + textStyle: buttonText, + shape: buttonShape, + side: buttonSide, + minimumSize: buttonSize, + padding: buttonPadding, + ), + child: Text(cancelLabel), + ); + final Widget confirm = FilledButton( + key: const ValueKey('codelessly-confirm-ok'), + onPressed: () => Navigator.of(context).pop(true), + style: FilledButton.styleFrom( + backgroundColor: confirmFill, + foregroundColor: onConfirm, + textStyle: buttonText, + shape: buttonShape, + side: buttonSide, + minimumSize: buttonSize, + padding: buttonPadding, + ), + child: Text(confirmLabel), + ); final Widget content = Semantics( scopesRoute: true, @@ -231,10 +342,12 @@ class ThemedConfirmDialog extends StatelessWidget { if (title.isNotEmpty) Text( title, - style: theme.titleStyle.copyWith( - fontSize: 18, - height: 1.3, - color: onSurface, + style: titleStyle.applyTo( + theme.titleStyle.copyWith( + fontSize: 18, + height: 1.3, + color: onSurface, + ), ), ), if (title.isNotEmpty && message.isNotEmpty) @@ -244,10 +357,12 @@ class ThemedConfirmDialog extends StatelessWidget { child: SingleChildScrollView( child: Text( message, - style: theme.bodyStyle.copyWith( - fontSize: 15, - height: 1.45, - color: onSurface.withValues(alpha: 0.74), + style: messageStyle.applyTo( + theme.bodyStyle.copyWith( + fontSize: 15, + height: 1.45, + color: onSurface.withValues(alpha: 0.74), + ), ), ), ), @@ -256,34 +371,12 @@ class ThemedConfirmDialog extends StatelessWidget { OverflowBar( alignment: MainAxisAlignment.end, overflowAlignment: OverflowBarAlignment.end, - spacing: 8, + // Outlined or shadowed buttons need room between them. + spacing: buttonShadowList.isEmpty ? 8 : 12, overflowSpacing: 8, children: [ - TextButton( - key: const ValueKey('codelessly-confirm-cancel'), - onPressed: () => Navigator.of(context).pop(false), - style: TextButton.styleFrom( - foregroundColor: onSurface, - textStyle: buttonText, - shape: buttonShape, - minimumSize: buttonSize, - padding: buttonPadding, - ), - child: Text(cancelLabel), - ), - FilledButton( - key: const ValueKey('codelessly-confirm-ok'), - onPressed: () => Navigator.of(context).pop(true), - style: FilledButton.styleFrom( - backgroundColor: confirmFill, - foregroundColor: onConfirm, - textStyle: buttonText, - shape: buttonShape, - minimumSize: buttonSize, - padding: buttonPadding, - ), - child: Text(confirmLabel), - ), + cancelColor != null ? withShadow(cancel) : cancel, + withShadow(confirm), ], ), ], @@ -291,24 +384,44 @@ class ThemedConfirmDialog extends StatelessWidget { ), ); + // Shadows of its own replace the dialog's elevation: they are drawn + // around a box that carries its fill and outline. + Widget dialogBody = ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 400), + child: content, + ); + if (dialogShadows != null) { + dialogBody = DecoratedBox( + key: const ValueKey('codelessly-confirm-surface'), + decoration: BoxDecoration( + color: surface, + borderRadius: BorderRadius.circular(radius), + border: dialogSide == null ? null : Border.fromBorderSide(dialogSide), + boxShadow: dialogShadows, + ), + child: dialogBody, + ); + } + return Theme( data: theme.apply(Theme.of(context)), child: switch (presentation) { DialogPresentation.dialog => Dialog( - backgroundColor: surface, + backgroundColor: dialogShadows != null ? Colors.transparent : surface, surfaceTintColor: Colors.transparent, - elevation: 8, + shadowColor: dialogShadows != null ? Colors.transparent : null, + elevation: dialogShadows != null ? 0 : 8, insetPadding: const EdgeInsets.symmetric( horizontal: 24, vertical: 24, ), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(radius), + side: dialogShadows == null + ? dialogSide ?? BorderSide.none + : BorderSide.none, ), - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 400), - child: content, - ), + child: dialogBody, ), // A sheet is as wide as its route makes it; its content keeps clear // of the home indicator and the bottom sheet's grab handle. diff --git a/lib/src/functions/app_toast.dart b/lib/src/functions/app_toast.dart index 3675e69d..ecebed0b 100644 --- a/lib/src/functions/app_toast.dart +++ b/lib/src/functions/app_toast.dart @@ -11,8 +11,9 @@ import 'action_chain.dart'; /// new toast replaces the one on screen. /// /// The toast takes the app's look ([FeedbackTheme.of]): its font, and the -/// color of its [ShowToastAction.style]. It can carry a button, e.g. Undo, -/// that runs [ShowToastAction.actions]. +/// color of its [ShowToastAction.style]. The action's own colors, fonts, +/// radius, border and shadows override that look. It can carry a button, +/// e.g. Undo, that runs [ShowToastAction.actions], and a close (×) button. class AppToast { AppToast._(); @@ -140,6 +141,11 @@ class _ToastViewState extends State<_ToastView> { static const Duration _fade = Duration(milliseconds: 220); bool _visible = false; + + /// Fading out: taps pass through. Fading in, the toast already takes them: + /// its semantics stay as they are from the first frame, since the web + /// engine keeps a node's stacking only while its siblings don't change. + bool _closing = false; Timer? _timer; @override @@ -154,7 +160,10 @@ class _ToastViewState extends State<_ToastView> { void _close() { if (!mounted) return; _timer?.cancel(); - setState(() => _visible = false); + setState(() { + _visible = false; + _closing = true; + }); _timer = Timer(_fade, widget.onDone); } @@ -192,20 +201,48 @@ class _ToastViewState extends State<_ToastView> { : action.icon?.copyWith(color: action.iconColor); final Widget? icon = iconModel == null ? null - : retrieveIconWidget(iconModel, iconModel.size ?? 18, textColor); + : retrieveIconWidget( + iconModel, + // A toast icon size of its own wins over the icon's. + action.iconSize != 18 ? action.iconSize : iconModel.size ?? 18, + textColor, + ); final bool hasButton = widget.onAction != null; + final bool hasClose = action.showClose; final bool top = action.position == ToastPosition.top; - final EdgeInsets safe = MediaQuery.paddingOf(context); + final MediaQueryData media = MediaQuery.of(context); final TextStyle? font = AppToast.fontOf(action.fontFamily); - final TextStyle messageStyle = theme.bodyStyle - .merge(font) - .copyWith( - color: textColor, - fontSize: 14, - height: 1.35, - fontWeight: FontWeight.w600, - decoration: TextDecoration.none, - ); + final TextStyle messageStyle = action.messageStyle.applyTo( + theme.bodyStyle + .merge(font) + .copyWith( + color: textColor, + fontSize: 14, + height: 1.35, + fontWeight: FontWeight.w600, + decoration: TextDecoration.none, + ), + ); + final Color actionColor = + action.actionColor?.toFlutterColor() ?? messageStyle.color!; + final TextStyle actionStyle = action.actionStyle.applyTo( + theme.buttonStyle + .merge(font) + .copyWith( + fontSize: 14, + fontWeight: FontWeight.w700, + color: actionColor, + decoration: TextDecoration.none, + ), + ); + final Color closeColor = + action.closeColor?.toFlutterColor() ?? messageStyle.color!; + final double buttonRadius = action.cornerRadius != null + ? (action.cornerRadius! / 2).clamp(0, theme.buttonRadius).toDouble() + : theme.buttonRadius; + final BorderSide? outline = action.border == null + ? null + : borderSideOf(action.border!); final BorderRadius radius = BorderRadius.circular( action.cornerRadius ?? @@ -213,21 +250,27 @@ class _ToastViewState extends State<_ToastView> { ); Widget toast = Container( key: const ValueKey('codelessly-toast'), - constraints: const BoxConstraints(minHeight: 44, maxWidth: 560), - padding: EdgeInsets.fromLTRB( - accent == null ? 18 : 14, - 10, - hasButton ? 8 : 18, - 10, - ), - // The accent bar is a left border. A border on one side can't have a - // radius, so the clip below rounds the corners. + constraints: BoxConstraints(minHeight: 44, maxWidth: action.maxWidth), + padding: + action.padding?.flutterEdgeInsets ?? + EdgeInsets.fromLTRB( + accent == null ? 18 : 14, + 10, + hasButton || hasClose ? 8 : 18, + 10, + ), + // The accent bar is a left border. A border that differs by side can't + // have a radius, so the clip below rounds the corners. decoration: BoxDecoration( color: background, + borderRadius: accent == null && outline != null ? radius : null, border: accent == null - ? null + ? (outline == null ? null : Border.fromBorderSide(outline)) : Border( left: BorderSide(color: accent, width: action.accentWidth), + top: outline ?? BorderSide.none, + right: outline ?? BorderSide.none, + bottom: outline ?? BorderSide.none, ), ), child: Row( @@ -241,32 +284,58 @@ class _ToastViewState extends State<_ToastView> { key: const ValueKey('codelessly-toast-action'), onPressed: _onActionPressed, style: TextButton.styleFrom( - foregroundColor: textColor, + foregroundColor: actionColor, minimumSize: const Size(48, 36), padding: const EdgeInsets.symmetric(horizontal: 12), - textStyle: theme.buttonStyle - .merge(font) - .copyWith(fontSize: 14, fontWeight: FontWeight.w700), + textStyle: actionStyle, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(theme.buttonRadius), + borderRadius: BorderRadius.circular(buttonRadius), ), ), child: Text(widget.actionLabel), ), ], + if (hasClose) ...[ + SizedBox(width: hasButton ? 0 : 8), + Semantics( + button: true, + label: 'Dismiss', + excludeSemantics: true, + child: IconButton( + key: const ValueKey('codelessly-toast-close'), + onPressed: _close, + tooltip: null, + iconSize: 18, + color: closeColor, + constraints: const BoxConstraints.tightFor( + width: 36, + height: 36, + ), + padding: EdgeInsets.zero, + style: IconButton.styleFrom( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(buttonRadius), + ), + ), + icon: const Icon(Icons.close), + ), + ), + ], ], ), ); toast = DecoratedBox( decoration: BoxDecoration( borderRadius: radius, - boxShadow: const [ - BoxShadow( - color: Color(0x33000000), - blurRadius: 18, - offset: Offset(0, 6), - ), - ], + boxShadow: action.shadows != null + ? boxShadowsOf(action.shadows) + : const [ + BoxShadow( + color: Color(0x33000000), + blurRadius: 18, + offset: Offset(0, 6), + ), + ], ), child: ClipRRect(borderRadius: radius, child: toast), ); @@ -276,6 +345,7 @@ class _ToastViewState extends State<_ToastView> { label: widget.message, child: AnimatedOpacity( opacity: _visible ? 1 : 0, + alwaysIncludeSemantics: true, duration: _fade, child: AnimatedSlide( offset: _visible ? Offset.zero : Offset(0, top ? -0.25 : 0.25), @@ -291,10 +361,13 @@ class _ToastViewState extends State<_ToastView> { return Positioned( left: 16, right: 16, - top: top ? safe.top + 16 : null, - bottom: top ? null : action.bottomOffset, + top: top ? media.padding.top + 16 : null, + bottom: top ? null : action.bottomOffsetFor(media.size.width), // Only a toast with a button takes taps; the rest passes them through. - child: IgnorePointer(ignoring: !hasButton || !_visible, child: toast), + child: IgnorePointer( + ignoring: !(hasButton || hasClose) || _closing, + child: toast, + ), ); } } diff --git a/lib/src/functions/functions_repository.dart b/lib/src/functions/functions_repository.dart index 392ce834..cf3207b0 100644 --- a/lib/src/functions/functions_repository.dart +++ b/lib/src/functions/functions_repository.dart @@ -110,7 +110,7 @@ class FunctionsRepository { if (result is Future) return result.then((_) => true); return true; case ActionType.callApi: - return makeApiRequestFromAction(action as ApiCallAction, context); + return callApi(context, action as ApiCallAction); case ActionType.setStorage: return setStorageFromAction(context, action as SetStorageAction); case ActionType.setCloudStorage: @@ -332,6 +332,66 @@ class FunctionsRepository { return true; } + /// Calls [action]'s API, then runs its onSuccess actions after a 2xx + /// response or its onError actions after any other response or a failed + /// request. Both read the response as `${result}`. A failed request stops + /// the chain only when there are no onError actions to handle it. + static Future callApi( + BuildContext context, + ApiCallAction action, [ + Observable? variable, + ]) async { + http.Response? response; + Object? failure; + StackTrace? failureStack; + try { + response = await makeApiRequestFromAction(action, context, variable); + } catch (error, stackTrace) { + failure = error; + failureStack = stackTrace; + } + // The widget that made the call can be gone by now (a dialog closed + // while it loaded); its handlers then run on the chain's opener. + // ignore: use_build_context_synchronously + final BuildContext? outcomeContext = ActionChain.contextFor(context); + if (outcomeContext == null) return response; + await handleApiOutcome( + // ignore: use_build_context_synchronously + outcomeContext, + action, + response: response, + failure: failure, + failureStack: failureStack, + ); + return response; + } + + /// Runs [action]'s onSuccess actions after a 2xx [response], or its onError + /// actions otherwise. Rethrows [failure] (a request that didn't complete) + /// when there are no onError actions, so the chain stops as before. + static Future handleApiOutcome( + BuildContext context, + ApiCallAction action, { + http.Response? response, + Object? failure, + StackTrace? failureStack, + }) async { + final bool ok = + failure == null && + response != null && + response.statusCode >= 200 && + response.statusCode < 300; + if (ok) { + if (action.onSuccess.isNotEmpty) { + await runActions(context, action.onSuccess); + } + } else if (action.onError.isNotEmpty) { + await runActions(context, action.onError); + } else if (failure != null) { + Error.throwWithStackTrace(failure, failureStack ?? StackTrace.current); + } + } + static Future makeApiRequestFromAction( ApiCallAction action, BuildContext context, [ @@ -386,7 +446,7 @@ class FunctionsRepository { return makeApiRequest( context: context, method: apiData.method, - url: _applyApiInputs(apiData.url, params, scopedValues), + url: buildApiUrl(apiData, params, scopedValues), headers: _generateMapFromPairs(apiData.headers, params, scopedValues), body: apiData.bodyType == RequestBodyType.form ? _generateMapFromPairs(apiData.formFields, params, scopedValues) @@ -395,6 +455,28 @@ class FunctionsRepository { ); } + /// The URL [api] requests with the inputs [params]: its URL with inputs + /// and variables substituted, plus its query parameters (used, non-empty + /// keys; same substitution), which win over ones written in the URL. + static String buildApiUrl( + HttpApiData api, + Map params, + ScopedValues scopedValues, + ) { + final String url = _applyApiInputs(api.url, params, scopedValues); + final Map query = _generateMapFromPairs( + api.queryParams, + params, + scopedValues, + ); + if (query.isEmpty) return url; + final Uri? uri = Uri.tryParse(url); + if (uri == null) return url; + return uri + .replace(queryParameters: {...uri.queryParameters, ...query}) + .toString(); + } + static Map _generateMapFromPairs( List pairs, Map parameters, @@ -488,7 +570,18 @@ class FunctionsRepository { final String? myCanvasId = effectiveCodelessly.currentNavigatedCanvasId; if (action.navigationType == NavigationType.pop) { - await Navigator.maybePop(context, parsedParams); + final bool popped = await Navigator.maybePop(context, parsedParams); + // Nothing to go back to (the screen was opened directly): a pop with a + // destination goes there instead. + if (!popped && action.destinationId.isNotEmpty && context.mounted) { + return navigate( + context, + action.copyWith(navigationType: NavigationType.replace), + codelessly: codelessly, + codelesslyController: codelesslyController, + scopedValues: scopedValues, + ); + } } else { // Check if a layout exists for the action's [destinationId]. final String? layoutId = @@ -894,10 +987,13 @@ class FunctionsRepository { printResponse(response); + final Map responseValue = ApiResponseVariableUtils.fromResponse(response); + // The next actions of the chain read the response as `${result}`, e.g. + // `${result.data}` or `${result.isError}`, even when the API has no + // variable or another call overwrites it, as in a forEach. + ActionChain.setResult(responseValue); if (variable != null) { - variable.value = variable.value.copyWith( - value: ApiResponseVariableUtils.fromResponse(response), - ); + variable.value = variable.value.copyWith(value: responseValue); DebugLogger.instance.printInfo( '${variable.value.name} updated with success state.', name: name, @@ -913,10 +1009,14 @@ class FunctionsRepository { } catch (error, stackTrace) { DebugLogger.instance.printInfo(error.toString(), name: name); DebugLogger.instance.printInfo(stackTrace.toString(), name: name); + final Map errorValue = ApiResponseVariableUtils.error( + url, + error, + data: existingData, + ); + ActionChain.setResult(errorValue); if (variable != null) { - variable.value = variable.value.copyWith( - value: ApiResponseVariableUtils.error(url, error, data: existingData), - ); + variable.value = variable.value.copyWith(value: errorValue); DebugLogger.instance.printInfo( '${variable.value.name} updated with error state.', name: name, diff --git a/lib/src/transformers/node_transformers/line_chart_geometry.dart b/lib/src/transformers/node_transformers/line_chart_geometry.dart new file mode 100644 index 00000000..23237be8 --- /dev/null +++ b/lib/src/transformers/node_transformers/line_chart_geometry.dart @@ -0,0 +1,359 @@ +import 'dart:math' as math; +import 'dart:ui'; + +import 'package:codelessly_api/codelessly_api.dart'; + +import '../../utils/formula.dart'; + +/// The x and y bounds of a line chart's plot, in data units. Date x values +/// are epoch milliseconds of their wall-clock time (see +/// [LineChartGeometry.parseX]). +class ChartDomain { + final double xMin; + final double xMax; + final double yMin; + final double yMax; + + const ChartDomain(this.xMin, this.xMax, this.yMin, this.yMax); + + double get xSpan => xMax - xMin; + + double get ySpan => yMax - yMin; + + @override + bool operator ==(Object other) => + other is ChartDomain && + other.xMin == xMin && + other.xMax == xMax && + other.yMin == yMin && + other.yMax == yMax; + + @override + int get hashCode => Object.hash(xMin, xMax, yMin, yMax); + + @override + String toString() => 'ChartDomain(x: $xMin..$xMax, y: $yMin..$yMax)'; +} + +/// Maps data values of a [domain] to pixels of a [plot] rectangle. +class ChartScale { + final ChartDomain domain; + final Rect plot; + + const ChartScale(this.domain, this.plot); + + /// The pixel x of data value [x]. + double px(double x) => + plot.left + (x - domain.xMin) / domain.xSpan * plot.width; + + /// The pixel y of data value [y]. + double py(double y) => + plot.bottom - (y - domain.yMin) / domain.ySpan * plot.height; + + /// The pixel position of data point [point]. + Offset toPixel(Offset point) => Offset(px(point.dx), py(point.dy)); + + /// The data x at pixel x [px], clamped to the domain. + double xAt(double px) { + if (plot.width <= 0) return domain.xMin; + final double x = domain.xMin + (px - plot.left) / plot.width * domain.xSpan; + return x.clamp(domain.xMin, domain.xMax).toDouble(); + } +} + +/// One cubic Bézier segment of a curve, from the previous segment's end. +typedef CubicSegment = ({Offset c1, Offset c2, Offset end}); + +/// Pure functions behind the line chart: reading data, the domain, +/// interpolation, the Catmull-Rom curve and readout text. +abstract final class LineChartGeometry { + /// Reads a number from a num or a numeric string. + static double? parseNum(Object? value) => switch (value) { + num() when value.isFinite => value.toDouble(), + String() => double.tryParse(value.trim()), + _ => null, + }; + + /// Reads a date from a DateTime or an ISO string such as + /// `2026-10-22 00:30` or `2026-10-22T00:30:00`, as its wall-clock time in + /// a UTC DateTime: no timezone conversion happens, so the hour read is the + /// hour written. + static DateTime? parseDate(Object? value) { + final DateTime? date = switch (value) { + DateTime() => value, + String() when double.tryParse(value.trim()) == null => DateTime.tryParse( + value.trim(), + ), + _ => null, + }; + if (date == null) return null; + return DateTime.utc( + date.year, + date.month, + date.day, + date.hour, + date.minute, + date.second, + date.millisecond, + ); + } + + /// Whether [value] is a date rather than a number. + static bool isDate(Object? value) => parseDate(value) != null; + + /// Reads an x value: a number, or a date as epoch milliseconds of its + /// wall-clock time. + static double? parseX(Object? value) => + parseNum(value) ?? parseDate(value)?.millisecondsSinceEpoch.toDouble(); + + /// The wall-clock date of a date axis x value. + static DateTime dateOf(double x) => + DateTime.fromMillisecondsSinceEpoch(x.round(), isUtc: true); + + /// Reads the points of [data], a list of maps, with x from [xField] and y + /// from [yField], sorted by x. Items without a valid x or y are skipped. + static List points(List? data, String xField, String yField) { + final List points = [ + for (final Object? item in data ?? const []) + if (item is Map) + if ((parseX(item[xField]), parseNum(item[yField])) case ( + final double x, + final double y, + )) + Offset(x, y), + ]; + points.sort((a, b) => a.dx.compareTo(b.dx)); + return points; + } + + /// Whether the x values of [data] are dates, judged by its first item + /// with an x value. + static bool hasDateX(List? data, String xField) { + for (final Object? item in data ?? const []) { + if (item is Map && item[xField] != null) return isDate(item[xField]); + } + return false; + } + + /// The domain of [points]: each bound is its override when given, else + /// the data's extent. An empty span is widened so the scale stays finite. + static ChartDomain domain( + List points, { + double? xMin, + double? xMax, + double? yMin, + double? yMax, + }) { + double extent(Iterable values, double Function(double, double) f) => + values.isEmpty ? 0 : values.reduce(f); + final Iterable xs = points.map((p) => p.dx); + final Iterable ys = points.map((p) => p.dy); + double x0 = xMin ?? extent(xs, math.min); + double x1 = xMax ?? extent(xs, math.max); + double y0 = yMin ?? extent(ys, math.min); + double y1 = yMax ?? extent(ys, math.max); + if (x1 <= x0) (x0, x1) = (x0 - 0.5, x0 + 0.5); + if (y1 <= y0) (y0, y1) = (y0 - 0.5, y0 + 0.5); + return ChartDomain(x0, x1, y0, y1); + } + + /// The plot rectangle of a chart of [size] with the given insets. + static Rect plotRect( + Size size, { + required double left, + required double top, + required double right, + required double bottom, + }) => Rect.fromLTRB( + left, + top, + math.max(left, size.width - right), + math.max(top, size.height - bottom), + ); + + /// The y of [points] (sorted by x) at [x], interpolated linearly between + /// neighbours and held at the first and last values outside them. Null + /// when there are no points. + static double? interpolate(List points, double x) { + if (points.isEmpty) return null; + if (x <= points.first.dx) return points.first.dy; + if (x >= points.last.dx) return points.last.dy; + int lo = 0, hi = points.length - 1; + while (hi - lo > 1) { + final int mid = (lo + hi) >> 1; + if (points[mid].dx <= x) { + lo = mid; + } else { + hi = mid; + } + } + final Offset a = points[lo], b = points[hi]; + if (b.dx == a.dx) return b.dy; + return a.dy + (b.dy - a.dy) * (x - a.dx) / (b.dx - a.dx); + } + + /// The control points of the Catmull-Rom segment from [p1] to [p2], with + /// [p0] before and [p3] after: `p1 + (p2 - p0) / 6` and + /// `p2 - (p3 - p1) / 6`. + static (Offset, Offset) catmullRomControls( + Offset p0, + Offset p1, + Offset p2, + Offset p3, + ) => (p1 + (p2 - p0) / 6, p2 - (p3 - p1) / 6); + + /// The cubic segments of a Catmull-Rom spline through [points], starting + /// at the first point. The neighbours of the end points are the end + /// points themselves. + static List catmullRom(List points) { + Offset at(int i) => points[i.clamp(0, points.length - 1)]; + return [ + for (int i = 0; i < points.length - 1; i++) + if (catmullRomControls(at(i - 1), at(i), at(i + 1), at(i + 2)) case ( + final Offset c1, + final Offset c2, + )) + (c1: c1, c2: c2, end: at(i + 1)), + ]; + } + + /// The curve through pixel [points]: a Catmull-Rom spline when [smooth], + /// else a polyline. + static Path curvePath(List points, {required bool smooth}) { + final Path path = Path(); + if (points.isEmpty) return path; + path.moveTo(points.first.dx, points.first.dy); + if (smooth) { + for (final CubicSegment s in catmullRom(points)) { + path.cubicTo(s.c1.dx, s.c1.dy, s.c2.dx, s.c2.dy, s.end.dx, s.end.dy); + } + } else { + for (final Offset p in points.skip(1)) { + path.lineTo(p.dx, p.dy); + } + } + return path; + } + + /// The area under [curve] (through pixel [points]) down to [bottom]. + static Path areaPath(Path curve, List points, double bottom) => + Path.from(curve) + ..lineTo(points.last.dx, bottom) + ..lineTo(points.first.dx, bottom) + ..close(); + + /// [x] as text: a date formatted with the date [pattern] (as the + /// `formatDate` formula function does) on a date axis, else the number + /// without trailing zeros. + static String formatX(double x, {required bool isDate, String? pattern}) { + if (isDate) { + return FormulaEvaluator.formatDate( + dateOf(x), + pattern == null || pattern.isEmpty ? 'h:mm a' : pattern, + ) ?? + ''; + } + return FormulaEvaluator.stringify( + x == x.roundToDouble() ? x.round() : double.parse(x.toStringAsFixed(4)), + ); + } + + /// [y] times [scale] with [decimals] decimals, followed by [suffix]. + static String formatY( + double y, { + double scale = 1, + int decimals = 1, + String suffix = '', + }) => '${(y * scale).toStringAsFixed(decimals.clamp(0, 20))}$suffix'; + + /// Whether the curve rises at [x]: compares the value at [x] with the one + /// [lookahead] further, or [lookahead] before when that is past the data. + static bool rising(List points, double x, double lookahead) { + final double here = interpolate(points, x) ?? 0; + if (points.isNotEmpty && x + lookahead > points.last.dx) { + return here > (interpolate(points, x - lookahead) ?? here); + } + return (interpolate(points, x + lookahead) ?? here) > here; + } + + /// How far ahead [rising] looks for a readout's trend: 10 minutes on a + /// date axis, else 1 % of the domain. + static double trendLookahead(ChartDomain domain, {required bool isDate}) => + isDate + ? const Duration(minutes: 10).inMilliseconds.toDouble() + : domain.xSpan / 100; + + /// The two lines of the scrub readout at [x]: the formatted x, and the + /// interpolated y with an optional trend arrow. + static (String, String) readout( + List points, + double x, { + required ChartDomain domain, + required bool isDate, + String? xFormat, + double yScale = 1, + int yDecimals = 1, + String ySuffix = '', + bool trend = false, + }) { + final double y = interpolate(points, x) ?? 0; + final String arrow = !trend + ? '' + : rising(points, x, trendLookahead(domain, isDate: isDate)) + ? ' ↑' + : ' ↓'; + return ( + formatX(x, isDate: isDate, pattern: xFormat), + '${formatY(y, scale: yScale, decimals: yDecimals, suffix: ySuffix)}' + '$arrow', + ); + } + + /// Where the readout card of [size] goes for the cursor at pixel [point]: + /// 12 px right of it, or left when it would pass the plot's right edge, + /// and above the point, clamped to the plot. + static Rect readoutRect(Offset point, Size size, Rect plot) { + double left = point.dx + 12; + if (left + size.width > plot.right) left = point.dx - 12 - size.width; + final double top = (point.dy - size.height - 12) + .clamp(plot.top, math.max(plot.top, plot.bottom - size.height)) + .toDouble(); + return Rect.fromLTWH(left, top, size.width, size.height); + } + + /// Reads a color: a hex string (`#RRGGBB` or `#AARRGGBB`), a [ColorRGBA] + /// or a [Color], with its alpha multiplied by [opacity] when given. + static Color? parseColor(Object? value, [Object? opacity]) { + final Color? color = switch (value) { + Color() => value, + ColorRGB() => Color.from( + alpha: value is ColorRGBA ? value.a : 1, + red: value.r, + green: value.g, + blue: value.b, + ), + String() => switch (ColorRGBA.fromHex(value.trim())) { + final ColorRGBA c => Color.from( + alpha: c.a, + red: c.r, + green: c.g, + blue: c.b, + ), + null => null, + }, + _ => null, + }; + final double? alpha = parseNum(opacity); + if (color == null || alpha == null) return color; + return color.withValues(alpha: color.a * alpha.clamp(0, 1)); + } + + /// Reads a dash pattern `[on, off]`, falling back to [fallback]. + static (double, double) parseDash(Object? value, (double, double) fallback) { + if (value is List && value.length >= 2) { + final double? on = parseNum(value[0]), off = parseNum(value[1]); + if (on != null && off != null && on > 0 && off >= 0) return (on, off); + } + return fallback; + } +} diff --git a/lib/src/transformers/node_transformers/node_transformers.dart b/lib/src/transformers/node_transformers/node_transformers.dart index e01bc058..413adb66 100644 --- a/lib/src/transformers/node_transformers/node_transformers.dart +++ b/lib/src/transformers/node_transformers/node_transformers.dart @@ -15,6 +15,8 @@ export 'passive_external_component_transformer.dart'; export 'passive_floating_action_button_transformer.dart'; export 'passive_grid_view_transformer.dart'; export 'passive_icon_transformer.dart'; +export 'line_chart_geometry.dart'; +export 'passive_line_chart_transformer.dart'; export 'passive_list_tile_transformer.dart'; export 'passive_list_view_transformer.dart'; export 'passive_loading_indicator_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 24baca1d..62f7e813 100644 --- a/lib/src/transformers/node_transformers/passive_canvas_transformer.dart +++ b/lib/src/transformers/node_transformers/passive_canvas_transformer.dart @@ -195,8 +195,15 @@ class PassiveCanvasTransformer extends NodeWidgetTransformer { body: body, ); } else { + final Color? background = retrieveBackgroundColor(context, node); + // Under a TapThroughScope, a canvas without a background takes no taps + // on its empty parts, so they reach its dialog's barrier. + final bool transparent = + (background == null || background.a == 0) && + TapThroughScope.of(context); scaffold = Material( - color: retrieveBackgroundColor(context, node), + type: transparent ? MaterialType.transparency : MaterialType.canvas, + color: transparent ? null : background, child: body, ); } @@ -377,6 +384,9 @@ class PassiveCanvasTransformer extends NodeWidgetTransformer { settings: settings, builder: (context) => CanvasViewport( node: node, + followsScreen: + node.scaleMode == ScaleMode.responsive && + hasResponsiveLayout(node, getNode), child: switch (node.scaleMode) { ScaleMode.responsive => _wrapInScaffoldForResponsive( context: context, @@ -393,6 +403,20 @@ class PassiveCanvasTransformer extends NodeWidgetTransformer { ); } + /// Whether [node] or a node under it is a responsive layout (a + /// [VarianceNode] whose variants map to breakpoints), in any variant. + static bool hasResponsiveLayout(BaseNode node, GetNode getNode) { + if (node is VarianceNode) { + if (node.isResponsive) return true; + return node.allVariantsChildren.any( + (id) => hasResponsiveLayout(getNode(id), getNode), + ); + } + return node.childrenOrEmpty.any( + (id) => hasResponsiveLayout(getNode(id), getNode), + ); + } + static Color? retrieveBackgroundColor(BuildContext context, CanvasNode node) { final fills = [ for (final fill in node.fills) @@ -439,6 +463,13 @@ enum CanvasSizing { /// The canvas keeps the size it was designed at, within the space it is /// given. Used where the host only centers the layout and lets it choose /// its size, such as a dialog. + /// + /// A responsive canvas with a responsive layout in it (see + /// [PassiveCanvasTransformer.hasResponsiveLayout]) fills the space + /// instead, as with [fill]: its variant follows the screen, and each + /// variant places itself in it (a full-screen sheet on a phone, a + /// centered card on a desktop). Give such a canvas no background so the + /// barrier shows around what its layout draws. design, /// The canvas fills the width it is given and keeps its design height (or @@ -467,6 +498,22 @@ class CanvasSizingScope extends InheritedWidget { sizing != oldWidget.sizing; } +/// Makes the empty parts of the layout below let taps through to what is +/// behind it: the parts where neither a node's fills, strokes or content +/// paint nor a reactive node is (a transparent canvas, an empty frame). Set +/// by `CodelesslyDialogWidget`, so a tap beside a full-screen dialog's +/// layout reaches the barrier and closes a dismissible dialog. +class TapThroughScope extends InheritedWidget { + const TapThroughScope({super.key, required super.child}); + + /// Whether a [TapThroughScope] is above [context]. + static bool of(BuildContext context) => + context.getInheritedWidgetOfExactType() != null; + + @override + bool updateShouldNotify(TapThroughScope oldWidget) => false; +} + /// The width the canvas above is laid out at, which picks the variant a /// responsive [VarianceNode] shows (see [VarianceNode.variantForWidth]), the /// same width the editor's design canvas has. @@ -500,9 +547,19 @@ class CanvasWidthScope extends InheritedWidget { /// on an unbounded axis it gets its design size (see [CanvasSizing.fill]). class CanvasViewport extends StatelessWidget { final CanvasNode node; + + /// Whether the canvas fills the space under [CanvasSizing.design] too: a + /// responsive canvas that holds a responsive layout. + final bool followsScreen; + final Widget child; - const CanvasViewport({super.key, required this.node, required this.child}); + const CanvasViewport({ + super.key, + required this.node, + this.followsScreen = false, + required this.child, + }); /// The canvas box for [constraints] under [sizing], per axis. A null /// height leaves the height to the canvas content (see @@ -548,7 +605,9 @@ class CanvasViewport extends StatelessWidget { @override Widget build(BuildContext context) { - final CanvasSizing sizing = CanvasSizingScope.of(context); + final CanvasSizing sizing = followsScreen + ? CanvasSizing.fill + : CanvasSizingScope.of(context); return LayoutBuilder( builder: (context, constraints) { final box = resolve(node, constraints, sizing: sizing); @@ -795,8 +854,6 @@ class _PassiveCanvasWidgetState extends State { .where( (action) => action.enabled && - // Because calling api is handled in LayoutBuilder. - action.type != ActionType.callApi && action.type != ActionType.navigation && action.type != ActionType.submit && action.type != ActionType.link, diff --git a/lib/src/transformers/node_transformers/passive_dropdown_transformer.dart b/lib/src/transformers/node_transformers/passive_dropdown_transformer.dart index 8d6ffccd..45f55b0a 100644 --- a/lib/src/transformers/node_transformers/passive_dropdown_transformer.dart +++ b/lib/src/transformers/node_transformers/passive_dropdown_transformer.dart @@ -1,4 +1,5 @@ import 'package:codelessly_api/codelessly_api.dart'; +import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -90,6 +91,75 @@ class PassiveDropdownWidget extends StatelessWidget { required this.settings, }); + /// The label of the item provided at [context]. + String labelOf(BuildContext context, Object? value) { + if (!node.properties.useDataSource) return '$value'; + final ScopedValues scopedValues = ScopedValues.of(context); + final String labelText = + PropertyValueDelegate.getPropertyValue( + node, + 'itemLabel', + scopedValues: scopedValues, + ) ?? + node.properties.itemLabel; + return PropertyValueDelegate.substituteVariables( + labelText, + nullSubstitutionMode: settings.nullSubstitutionMode, + scopedValues: scopedValues, + ); + } + + /// The image before the label of the item provided at [context], if the + /// dropdown has one. + Widget? imageOf(BuildContext context) { + final DropdownProperties props = node.properties; + if (props.itemImage.trim().isEmpty) return null; + final String url = PropertyValueDelegate.substituteVariables( + props.itemImage, + nullSubstitutionMode: NullSubstitutionMode.emptyString, + scopedValues: ScopedValues.of(context), + ).trim(); + final double size = props.itemImageSize; + return Padding( + padding: const EdgeInsetsDirectional.only(end: 10), + child: SizedBox.square( + dimension: size, + child: url.isEmpty + ? null + : ClipRRect( + borderRadius: BorderRadius.circular(props.itemImageRadius), + child: Image.network( + url, + key: const ValueKey('codelessly-dropdown-item-image'), + width: size, + height: size, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) => + SizedBox.square(dimension: size), + ), + ), + ), + ); + } + + /// An item's row: its image, if any, and its label. + Widget itemContent(BuildContext context, Object? value, TextStyle style) { + final Widget? image = imageOf(context); + final Widget label = Text( + labelOf(context, value), + style: style, + overflow: TextOverflow.ellipsis, + ); + if (image == null) return label; + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + image, + Flexible(child: label), + ], + ); + } + List> buildItems(BuildContext context, List items) { return [ for (final (index, value) in items.indexed) @@ -103,30 +173,13 @@ class PassiveDropdownWidget extends StatelessWidget { child: IndexedItemProvider( item: IndexedItem(index, value), child: Builder( - builder: (context) { - String label = '$value'; - if (node.properties.useDataSource) { - String? labelText = - PropertyValueDelegate.getPropertyValue( - node, - 'itemLabel', - scopedValues: ScopedValues.of(context), - ); - labelText ??= node.properties.itemLabel; - label = PropertyValueDelegate.substituteVariables( - labelText, - nullSubstitutionMode: settings.nullSubstitutionMode, - scopedValues: ScopedValues.of(context), - ); - } - return Text( - label, - style: TextUtils.retrieveTextStyleFromProp( - node.properties.itemTextStyle, - ), - overflow: TextOverflow.ellipsis, - ); - }, + builder: (context) => itemContent( + context, + value, + TextUtils.retrieveTextStyleFromProp( + node.properties.itemTextStyle, + ), + ), ), ), ), @@ -145,36 +198,23 @@ class PassiveDropdownWidget extends StatelessWidget { child: IndexedItemProvider( item: IndexedItem(index, value), child: Builder( - builder: (context) { - String label = '$value'; - if (node.properties.useDataSource) { - String? labelText = - PropertyValueDelegate.getPropertyValue( - node, - 'itemLabel', - scopedValues: ScopedValues.of(context), - ); - labelText ??= node.properties.itemLabel; - label = PropertyValueDelegate.substituteVariables( - labelText, - nullSubstitutionMode: settings.nullSubstitutionMode, - scopedValues: ScopedValues.of(context), - ); - } - return Container( - constraints: BoxConstraints( - maxWidth: - node.basicBoxLocal.width - node.properties.iconSize, - ), - child: Text( - label, - style: TextUtils.retrieveTextStyleFromProp( - node.properties.selectedItemTextStyle, - ), - overflow: TextOverflow.ellipsis, + builder: (context) => Container( + // An expanded dropdown gives the value the field's width; the + // design width only caps one that sizes to its value. + constraints: node.properties.expanded + ? null + : BoxConstraints( + maxWidth: + node.basicBoxLocal.width - node.properties.iconSize, + ), + child: itemContent( + context, + value, + TextUtils.retrieveTextStyleFromProp( + node.properties.selectedItemTextStyle, ), - ); - }, + ), + ), ), ), ), @@ -200,6 +240,14 @@ class PassiveDropdownWidget extends StatelessWidget { scopedValues: scopedValues, ) ?? node.value; + // Data source items are maps and lists read anew on each build: the + // selected one is the item equal to the value by content. + const DeepCollectionEquality equality = DeepCollectionEquality(); + final List matches = [ + for (final Object? item in items) + if (equality.equals(item, value)) item, + ]; + value = matches.length == 1 ? matches.single : null; return AdaptiveNodeBox( node: node, @@ -211,7 +259,7 @@ class PassiveDropdownWidget extends StatelessWidget { child: Builder( builder: (context) { final child = DropdownButton( - value: items.isEmpty || items.count(value) != 1 ? null : value, + value: value, isDense: node.properties.dense, isExpanded: node.properties.expanded, autofocus: node.properties.autoFocus, @@ -260,13 +308,265 @@ class PassiveDropdownWidget extends StatelessWidget { selectedItemBuilder: (context) => selectedItemBuilder(context, items), ); + Widget field = child; + final DropdownMenuStyleModel? menuStyle = node.properties.menuStyle; + if (menuStyle != null) { + // The field stays the dropdown button; the menu it opens is + // a card anchored under it. + field = _AnchoredDropdownMenu( + widget: this, + style: menuStyle, + items: items, + value: value, + enabled: node.properties.enabled, + child: child, + ); + } if (node.horizontalFit.isWrap) { - return IntrinsicWidth(child: child); + return IntrinsicWidth(child: field); } - return child; + return field; + }, + ), + ), + ); + } +} + +/// Opens a [DropdownMenuStyleModel]-styled menu anchored under [child], the +/// dropdown's field, in place of the platform's dropdown menu. +class _AnchoredDropdownMenu extends StatefulWidget { + const _AnchoredDropdownMenu({ + required this.widget, + required this.style, + required this.items, + required this.value, + required this.enabled, + required this.child, + }); + + final PassiveDropdownWidget widget; + final DropdownMenuStyleModel style; + final List items; + final Object? value; + final bool enabled; + final Widget child; + + @override + State<_AnchoredDropdownMenu> createState() => _AnchoredDropdownMenuState(); +} + +class _AnchoredDropdownMenuState extends State<_AnchoredDropdownMenu> { + final OverlayPortalController _portal = OverlayPortalController(); + final LayerLink _link = LayerLink(); + Size _fieldSize = Size.zero; + bool _below = true; + + void _open() { + if (!widget.enabled || _portal.isShowing) return; + final RenderBox? box = context.findRenderObject() as RenderBox?; + if (box == null || !box.hasSize) return; + final double screenHeight = MediaQuery.sizeOf(context).height; + final double bottom = box.localToGlobal(Offset.zero).dy + box.size.height; + setState(() { + _fieldSize = box.size; + _below = + bottom + widget.style.offset + widget.style.maxHeight <= + screenHeight || + bottom < screenHeight / 2; + }); + _portal.show(); + widget.widget.onTap?.call(); + } + + void _close() { + if (_portal.isShowing) setState(_portal.hide); + } + + void _pick(int index, Object value) { + _close(); + widget.widget.onChanged?.call(index, value); + } + + @override + Widget build(BuildContext context) { + return OverlayPortal( + controller: _portal, + overlayChildBuilder: _buildMenu, + child: CompositedTransformTarget( + link: _link, + child: FocusableActionDetector( + enabled: widget.enabled, + mouseCursor: widget.enabled + ? SystemMouseCursors.click + : MouseCursor.defer, + actions: { + ActivateIntent: CallbackAction( + onInvoke: (_) => _open(), + ), }, + child: Semantics( + button: true, + enabled: widget.enabled, + expanded: _portal.isShowing, + onTap: widget.enabled ? _open : null, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: _open, + // The button only draws the field; the menu is this one. + child: IgnorePointer(child: ExcludeFocus(child: widget.child)), + ), + ), ), ), ); } + + Widget _buildMenu(BuildContext context) { + final DropdownMenuStyleModel style = widget.style; + final DropdownProperties props = widget.widget.node.properties; + final double radius = + style.radius ?? props.borderRadius.borderRadius.topLeft.x; + final BorderSide side = style.border == null + ? BorderSide.none + : borderSideOf(style.border!); + final List shadows = style.shadows != null + ? boxShadowsOf(style.shadows) + : kElevationToShadow[props.elevation.clamp(0, 24)] ?? + kElevationToShadow[8]!; + final TextStyle itemStyle = TextUtils.retrieveTextStyleFromProp( + props.itemTextStyle, + ); + final TextStyle selectedStyle = style.selectedTextStyle.applyTo(itemStyle); + final Color? hover = + style.hoverItemColor?.toFlutterColor() ?? + props.hoverColor?.toFlutterColor(); + final EdgeInsets itemPadding = + style.itemPadding?.flutterEdgeInsets ?? + const EdgeInsets.symmetric(horizontal: 14); + + final Widget menu = Container( + key: const ValueKey('codelessly-dropdown-menu'), + width: _fieldSize.width < style.minWidth + ? style.minWidth + : _fieldSize.width, + constraints: BoxConstraints(maxHeight: style.maxHeight), + decoration: BoxDecoration( + color: + style.color?.toFlutterColor() ?? + props.dropdownColor.toFlutterColor(), + borderRadius: BorderRadius.circular(radius), + border: side == BorderSide.none ? null : Border.fromBorderSide(side), + boxShadow: shadows, + ), + child: ClipRRect( + borderRadius: BorderRadius.circular( + (radius - side.width).clamp(0, double.infinity), + ), + child: Material( + type: MaterialType.transparency, + child: SingleChildScrollView( + padding: + style.padding?.flutterEdgeInsets ?? + const EdgeInsets.symmetric(vertical: 6), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (final (index, item) in widget.items.indexed) + IndexedItemProvider( + item: IndexedItem(index, item), + child: Builder( + builder: (context) { + final bool selected = item == widget.value; + final TextStyle textStyle = selected + ? selectedStyle + : itemStyle; + return Semantics( + selected: selected, + button: true, + child: Material( + color: selected + ? style.selectedItemColor?.toFlutterColor() ?? + Colors.transparent + : Colors.transparent, + child: InkWell( + key: ValueKey('codelessly-dropdown-item-$index'), + hoverColor: hover, + splashColor: props.splashColor?.toFlutterColor(), + onTap: () => _pick(index, item), + child: Container( + height: style.itemHeight, + padding: itemPadding, + alignment: + props + .itemAlignment + .flutterAlignmentGeometry ?? + AlignmentDirectional.centerStart, + child: Row( + children: [ + ?widget.widget.imageOf(context), + Expanded( + child: Text( + widget.widget.labelOf(context, item), + style: textStyle, + overflow: TextOverflow.ellipsis, + ), + ), + if (selected && style.showCheck) + Padding( + padding: + const EdgeInsetsDirectional.only( + start: 8, + ), + child: Icon( + Icons.check, + key: const ValueKey( + 'codelessly-dropdown-check', + ), + size: style.checkSize, + color: + style.checkColor + ?.toFlutterColor() ?? + textStyle.color, + ), + ), + ], + ), + ), + ), + ), + ); + }, + ), + ), + ], + ), + ), + ), + ), + ); + + return Stack( + children: [ + Positioned.fill( + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: _close, + ), + ), + CompositedTransformFollower( + link: _link, + showWhenUnlinked: false, + targetAnchor: _below ? Alignment.bottomLeft : Alignment.topLeft, + followerAnchor: _below ? Alignment.topLeft : Alignment.bottomLeft, + offset: Offset(0, _below ? style.offset : -style.offset), + child: Align( + alignment: _below ? Alignment.topLeft : Alignment.bottomLeft, + child: menu, + ), + ), + ], + ); + } } diff --git a/lib/src/transformers/node_transformers/passive_line_chart_transformer.dart b/lib/src/transformers/node_transformers/passive_line_chart_transformer.dart new file mode 100644 index 00000000..13ebd6f4 --- /dev/null +++ b/lib/src/transformers/node_transformers/passive_line_chart_transformer.dart @@ -0,0 +1,811 @@ +import 'dart:math' as math; +import 'dart:ui' as ui; + +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../../../codelessly_sdk.dart'; + +/// Builds [LineChartNode]s as a [PassiveLineChartWidget]. +class PassiveLineChartTransformer extends NodeWidgetTransformer { + PassiveLineChartTransformer(super.getNode, super.manager); + + @override + Widget buildWidget( + LineChartNode node, + BuildContext context, + WidgetBuildSettings settings, + ) => PassiveLineChartWidget(node: node, settings: settings); + + /// Builds a preview of a chart with [properties] and no data. + Widget buildPreview({ + LineChartProperties? properties, + LineChartNode? node, + double? width, + double? height, + WidgetBuildSettings settings = const WidgetBuildSettings( + debugLabel: 'buildPreview', + ), + }) { + final LineChartNode previewNode = LineChartNode( + id: '', + name: 'Line Chart', + basicBoxLocal: NodeBox( + 0, + 0, + width ?? kLineChartDefaultWidth, + height ?? kLineChartDefaultHeight, + ), + retainedOuterBoxLocal: NodeBox( + 0, + 0, + width ?? kLineChartDefaultWidth, + height ?? kLineChartDefaultHeight, + ), + properties: properties ?? node?.properties, + variables: {...?node?.variables}, + ); + return PassiveLineChartWidget(node: previewNode, settings: settings); + } +} + +/// A full-height band of a line chart, e.g. night shading. +typedef ChartBand = ({double from, double to, Color color}); + +/// A horizontal grid line with an optional label. +typedef ChartYTick = ({double y, String? label, Color? color}); + +/// The part of the area under the curve inside [from, to] and below +/// [below], with an optional bar on the x axis. +typedef ChartAreaBand = ({ + double from, + double to, + double below, + Color color, + Color? barColor, +}); + +/// A dashed horizontal line. +typedef ChartReferenceLine = ({ + double y, + Color color, + double width, + (double, double) dash, +}); + +/// A tick and label on the x axis. +typedef ChartXTick = ({double x, String label}); + +/// A circle on a point with an optional label above or below it. +typedef ChartMarker = ({ + double x, + double y, + String? label, + bool filled, + bool above, +}); + +/// A dashed vertical line with an optional tag and dot on the curve. +typedef ChartVerticalLine = ({ + double x, + Color? color, + (double, double) dash, + String? label, + bool dotOnCurve, + Color? dotColor, +}); + +/// Everything a [LineChartPainter] draws, resolved from a [LineChartNode] +/// and its bindings. +class LineChartSpec { + final List points; + final bool isDate; + final ChartDomain domain; + final EdgeInsets insets; + final bool smooth; + final List bands; + final List yTicks; + final String yAxisTitle; + final List areaBands; + final List referenceLines; + final List xTicks; + final List markers; + final List verticalLines; + final Color lineColor; + final double lineWidth; + final Color areaTopColor; + final Color areaBottomColor; + final Color gridColor; + final Color zeroLineColor; + final Color axisColor; + final Color markerHollowColor; + final Color tagBackground; + final Color cursorColor; + final Color cursorRingFill; + final Color tooltipBackground; + final Color tooltipShadowColor; + final TextStyle axisLabelStyle; + final TextStyle axisTitleStyle; + final TextStyle markerLabelStyle; + final TextStyle tagTextStyle; + final TextStyle tooltipLabelStyle; + final TextStyle tooltipValueStyle; + final String tooltipXFormat; + final double tooltipYScale; + final int tooltipYDecimals; + final String tooltipYSuffix; + final bool tooltipTrend; + + const LineChartSpec({ + required this.points, + required this.isDate, + required this.domain, + required this.insets, + required this.smooth, + required this.bands, + required this.yTicks, + required this.yAxisTitle, + required this.areaBands, + required this.referenceLines, + required this.xTicks, + required this.markers, + required this.verticalLines, + required this.lineColor, + required this.lineWidth, + required this.areaTopColor, + required this.areaBottomColor, + required this.gridColor, + required this.zeroLineColor, + required this.axisColor, + required this.markerHollowColor, + required this.tagBackground, + required this.cursorColor, + required this.cursorRingFill, + required this.tooltipBackground, + required this.tooltipShadowColor, + required this.axisLabelStyle, + required this.axisTitleStyle, + required this.markerLabelStyle, + required this.tagTextStyle, + required this.tooltipLabelStyle, + required this.tooltipValueStyle, + required this.tooltipXFormat, + required this.tooltipYScale, + required this.tooltipYDecimals, + required this.tooltipYSuffix, + required this.tooltipTrend, + }); + + /// Resolves [node]'s properties and bindings with [scopedValues]. + factory LineChartSpec.resolve(LineChartNode node, ScopedValues scopedValues) { + final LineChartProperties props = node.properties; + + Object? bound(String name) { + final String? path = node.variables[name]; + if (path == null || path.isEmpty) return null; + return PropertyValueDelegate.getVariableValueFromPath( + path, + scopedValues: scopedValues, + ); + } + + List maps(String name) => [ + if (bound(name) case final List list) + for (final Object? item in list) + if (item is Map) item, + ]; + + Color color(String name, ColorRGBA fallback) => + PropertyValueDelegate.getPropertyValue( + node, + name, + scopedValues: scopedValues, + )?.toFlutterColor() ?? + fallback.toFlutterColor(); + + String? text(Object? value) => + value == null ? null : FormulaEvaluator.stringify(value); + + final Object? data = bound('data'); + final List? list = data is List ? data : null; + final List points = LineChartGeometry.points( + list, + props.xField, + props.yField, + ); + final Object? xMinBinding = bound('xMin'); + final bool isDate = list != null && points.isNotEmpty + ? LineChartGeometry.hasDateX(list, props.xField) + : LineChartGeometry.isDate(xMinBinding); + final ChartDomain domain = LineChartGeometry.domain( + points, + xMin: LineChartGeometry.parseX(xMinBinding) ?? props.xMin, + xMax: LineChartGeometry.parseX(bound('xMax')) ?? props.xMax, + yMin: LineChartGeometry.parseNum(bound('yMin')) ?? props.yMin, + yMax: LineChartGeometry.parseNum(bound('yMax')) ?? props.yMax, + ); + final Color lineColor = color('lineColor', props.lineColor); + double? x(Object? value) => LineChartGeometry.parseX(value); + double? n(Object? value) => LineChartGeometry.parseNum(value); + Color? c(Map item, [String key = 'color']) => + LineChartGeometry.parseColor(item[key], item['opacity']); + + return LineChartSpec( + points: points, + isDate: isDate, + domain: domain, + insets: EdgeInsets.fromLTRB( + props.padLeft, + props.padTop, + props.padRight, + props.padBottom, + ), + smooth: props.smooth, + bands: [ + for (final Map b in maps('bands')) + if ((x(b['from']), x(b['to']), c(b)) case ( + final double from, + final double to, + final Color color, + )) + (from: from, to: to, color: color), + ], + yTicks: [ + for (final Map t in maps('yTicks')) + if (n(t['y']) case final double y) + ( + y: y, + label: text(t['label']), + color: LineChartGeometry.parseColor(t['color']), + ), + ], + yAxisTitle: text(bound('yAxisTitle')) ?? props.yAxisTitle, + areaBands: [ + for (final Map b in maps('areaBands')) + if ((x(b['from']), x(b['to']), n(b['below']), c(b)) case ( + final double from, + final double to, + final double below, + final Color color, + )) + ( + from: from, + to: to, + below: below, + color: color, + barColor: LineChartGeometry.parseColor(b['barColor']), + ), + ], + referenceLines: [ + for (final Map r in maps('referenceLines')) + if (n(r['y']) case final double y) + ( + y: y, + color: c(r) ?? props.axisColor.toFlutterColor(), + width: n(r['width']) ?? 1.5, + dash: LineChartGeometry.parseDash(r['dash'], (5, 5)), + ), + ], + xTicks: [ + for (final Map t in maps('xTicks')) + if (x(t['x']) case final double tx) + (x: tx, label: text(t['label']) ?? ''), + ], + markers: [ + for (final Map m in maps('markers')) + if ((x(m['x']), n(m['y'])) case (final double mx, final double my)) + ( + x: mx, + y: my, + label: text(m['label']), + filled: m['filled'] == true, + above: m['above'] == true, + ), + ], + verticalLines: [ + for (final Map v in maps('verticalLines')) + if (x(v['x']) case final double vx) + ( + x: vx, + color: c(v), + dash: LineChartGeometry.parseDash(v['dash'], (3, 3)), + label: text(v['label']), + dotOnCurve: v['dotOnCurve'] == true, + dotColor: LineChartGeometry.parseColor(v['dotColor']), + ), + ], + lineColor: lineColor, + lineWidth: props.lineWidth, + areaTopColor: color('areaTopColor', props.areaTopColor), + areaBottomColor: color('areaBottomColor', props.areaBottomColor), + gridColor: color('gridColor', props.gridColor), + zeroLineColor: color('zeroLineColor', props.zeroLineColor), + axisColor: color('axisColor', props.axisColor), + markerHollowColor: color('markerHollowColor', props.markerHollowColor), + tagBackground: color('tagBackground', props.tagBackground), + cursorColor: color('cursorColor', props.cursorColor), + cursorRingFill: color('cursorRingFill', props.cursorRingFill), + tooltipBackground: color('tooltipBackground', props.tooltipBackground), + tooltipShadowColor: color('tooltipShadowColor', props.tooltipShadowColor), + axisLabelStyle: TextUtils.retrieveTextStyleFromProp(props.axisLabelStyle), + axisTitleStyle: TextUtils.retrieveTextStyleFromProp(props.axisTitleStyle), + markerLabelStyle: TextUtils.retrieveTextStyleFromProp( + props.markerLabelStyle, + ), + tagTextStyle: TextUtils.retrieveTextStyleFromProp(props.tagTextStyle), + tooltipLabelStyle: TextUtils.retrieveTextStyleFromProp( + props.tooltipLabelStyle, + ), + tooltipValueStyle: TextUtils.retrieveTextStyleFromProp( + props.tooltipValueStyle, + ), + tooltipXFormat: props.tooltipXFormat, + // The readout's units can follow a setting (feet or metres). + tooltipYScale: n(bound('tooltipYScale')) ?? props.tooltipYScale, + tooltipYDecimals: + n(bound('tooltipYDecimals'))?.round() ?? props.tooltipYDecimals, + tooltipYSuffix: text(bound('tooltipYSuffix')) ?? props.tooltipYSuffix, + tooltipTrend: props.tooltipTrend, + ); + } + + /// The plot rectangle of a chart of [size]. + Rect plotRect(Size size) => LineChartGeometry.plotRect( + size, + left: insets.left, + top: insets.top, + right: insets.right, + bottom: insets.bottom, + ); + + /// The interpolated y of the curve at [x]. + double? valueAt(double x) => LineChartGeometry.interpolate(points, x); + + /// The scrub readout's two lines at [x]. + (String, String) readout(double x) => LineChartGeometry.readout( + points, + x, + domain: domain, + isDate: isDate, + xFormat: tooltipXFormat, + yScale: tooltipYScale, + yDecimals: tooltipYDecimals, + ySuffix: tooltipYSuffix, + trend: tooltipTrend, + ); + + /// The semantics value while the cursor is at [x], e.g. + /// `Cursor 4:30 PM, 1.9 ft`. + String cursorSemantics(double x) { + final String xText = LineChartGeometry.formatX( + x, + isDate: isDate, + pattern: tooltipXFormat, + ); + final String yText = LineChartGeometry.formatY( + valueAt(x) ?? 0, + scale: tooltipYScale, + decimals: tooltipYDecimals, + suffix: tooltipYSuffix, + ); + return 'Cursor $xText, $yText'; + } +} + +/// Renders a [LineChartNode], with a scrub cursor when +/// [LineChartProperties.scrubEnabled] is set. +class PassiveLineChartWidget extends StatefulWidget { + final LineChartNode node; + final WidgetBuildSettings settings; + final List variablesOverrides; + + const PassiveLineChartWidget({ + super.key, + required this.node, + required this.settings, + this.variablesOverrides = const [], + }); + + @override + State createState() => _PassiveLineChartWidgetState(); +} + +class _PassiveLineChartWidgetState extends State { + final FocusNode _focus = FocusNode(debugLabel: 'lineChart'); + + /// The cursor's x in data units, or null when not scrubbing. + double? _cursor; + + /// The domain [_cursor] was set in. A new domain clears the cursor. + ChartDomain? _cursorDomain; + + @override + void dispose() { + _focus.dispose(); + super.dispose(); + } + + void _setCursor(double? x, ChartDomain domain) { + if (x == _cursor) return; + setState(() { + _cursor = x; + _cursorDomain = domain; + }); + } + + @override + Widget build(BuildContext context) { + final ScopedValues scopedValues = ScopedValues.of( + context, + variablesOverrides: widget.variablesOverrides, + ); + final LineChartNode node = widget.node; + final LineChartSpec spec = LineChartSpec.resolve(node, scopedValues); + if (_cursorDomain != spec.domain) _cursor = null; + final double? cursor = node.properties.scrubEnabled ? _cursor : null; + + final String semanticLabel = + PropertyValueDelegate.getPropertyValue( + node, + 'semanticLabel', + scopedValues: scopedValues, + ) ?? + node.properties.semanticLabel; + + return AdaptiveNodeBox( + node: node, + child: LayoutBuilder( + builder: (context, constraints) { + final Size size = constraints.biggest.isFinite + ? constraints.biggest + : Size(node.basicBoxLocal.width, node.basicBoxLocal.height); + Widget chart = SizedBox.fromSize( + size: size, + child: CustomPaint( + painter: LineChartPainter(spec: spec, cursorX: cursor), + ), + ); + if (node.properties.scrubEnabled) { + chart = _interactive(chart, spec, size); + } + return Semantics( + container: true, + label: semanticLabel.isEmpty ? null : semanticLabel, + value: cursor == null ? null : spec.cursorSemantics(cursor), + child: chart, + ); + }, + ), + ); + } + + Widget _interactive(Widget chart, LineChartSpec spec, Size size) { + final ChartScale scale = ChartScale(spec.domain, spec.plotRect(size)); + void scrubTo(Offset local) => _setCursor(scale.xAt(local.dx), spec.domain); + + return Focus( + focusNode: _focus, + onKeyEvent: (node, event) { + if (event is KeyUpEvent) return KeyEventResult.ignored; + final LogicalKeyboardKey key = event.logicalKey; + if (key == LogicalKeyboardKey.arrowRight || + key == LogicalKeyboardKey.arrowLeft) { + final double step = + widget.node.properties.scrubStep * + (spec.isDate ? Duration.millisecondsPerMinute : 1); + final double start = + _cursor ?? + (spec.verticalLines.isNotEmpty + ? spec.verticalLines.first.x + : (spec.domain.xMin + spec.domain.xMax) / 2); + final double next = + start + (key == LogicalKeyboardKey.arrowRight ? step : -step); + _setCursor( + next.clamp(spec.domain.xMin, spec.domain.xMax).toDouble(), + spec.domain, + ); + return KeyEventResult.handled; + } + if (key == LogicalKeyboardKey.escape && _cursor != null) { + _setCursor(null, spec.domain); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + }, + child: MouseRegion( + cursor: SystemMouseCursors.precise, + onHover: (event) => scrubTo(event.localPosition), + onExit: (_) => _setCursor(null, spec.domain), + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTapDown: (details) { + _focus.requestFocus(); + scrubTo(details.localPosition); + }, + onHorizontalDragStart: (details) => scrubTo(details.localPosition), + onHorizontalDragUpdate: (details) => scrubTo(details.localPosition), + child: chart, + ), + ), + ); + } +} + +/// Paints a [LineChartSpec], with the scrub cursor at [cursorX] when set. +class LineChartPainter extends CustomPainter { + final LineChartSpec spec; + final double? cursorX; + + LineChartPainter({required this.spec, this.cursorX}); + + static TextPainter _text(String text, TextStyle style) => TextPainter( + text: TextSpan(text: text, style: style), + textDirection: TextDirection.ltr, + )..layout(); + + static void _dashedLine( + Canvas canvas, + Offset from, + Offset to, + (double, double) dash, + Paint paint, + ) { + final (double on, double off) = dash; + final double length = (to - from).distance; + if (length == 0) return; + final Offset unit = (to - from) / length; + for (double d = 0; d < length; d += on + off) { + canvas.drawLine( + from + unit * d, + from + unit * math.min(d + on, length), + paint, + ); + } + } + + @override + void paint(Canvas canvas, Size size) { + final Rect plot = spec.plotRect(size); + if (plot.width <= 0 || plot.height <= 0) return; + final ChartScale scale = ChartScale(spec.domain, plot); + final double Function(double) px = scale.px, py = scale.py; + + // 1. Bands. + for (final ChartBand band in spec.bands) { + canvas.drawRect( + Rect.fromLTRB( + px(band.from), + plot.top, + px(band.to), + plot.bottom, + ).intersect(plot), + Paint()..color = band.color, + ); + } + + // 2. Horizontal grid, y labels and the y axis title. + final Paint grid = Paint()..strokeWidth = 1; + for (final ChartYTick tick in spec.yTicks) { + final double y = py(tick.y); + grid.color = + tick.color ?? (tick.y == 0 ? spec.zeroLineColor : spec.gridColor); + canvas.drawLine(Offset(plot.left, y), Offset(plot.right, y), grid); + if (tick.label case final String label when label.isNotEmpty) { + final TextPainter tp = _text(label, spec.axisLabelStyle); + tp.paint(canvas, Offset(plot.left - tp.width - 10, y - tp.height / 2)); + } + } + if (spec.yAxisTitle.isNotEmpty) { + final TextPainter tp = _text(spec.yAxisTitle, spec.axisTitleStyle); + tp.paint(canvas, Offset(plot.left - tp.width - 10, plot.top - 20)); + } + + final List points = [ + for (final Offset p in spec.points) scale.toPixel(p), + ]; + if (points.length >= 2) { + // 3. Area under the curve. + final Path curve = LineChartGeometry.curvePath( + points, + smooth: spec.smooth, + ); + final Path area = LineChartGeometry.areaPath(curve, points, plot.bottom); + canvas.drawPath( + area, + Paint() + ..shader = ui.Gradient.linear( + Offset(0, plot.top), + Offset(0, plot.bottom), + [spec.areaTopColor, spec.areaBottomColor], + ), + ); + + // 4. Area bands. + for (final ChartAreaBand band in spec.areaBands) { + canvas.save(); + canvas.clipRect( + Rect.fromLTRB( + px(band.from), + py(band.below), + px(band.to), + plot.bottom, + ), + ); + canvas.drawPath(area, Paint()..color = band.color); + canvas.restore(); + if (band.barColor case final Color barColor) { + canvas.drawRect( + Rect.fromLTRB( + px(band.from), + plot.bottom - 4, + px(band.to), + plot.bottom, + ), + Paint()..color = barColor, + ); + } + } + + // 5. The curve. + canvas.drawPath( + curve, + Paint() + ..color = spec.lineColor + ..style = PaintingStyle.stroke + ..strokeWidth = spec.lineWidth + ..strokeJoin = StrokeJoin.round, + ); + } + + // 6. Reference lines. + for (final ChartReferenceLine line in spec.referenceLines) { + final double y = py(line.y); + _dashedLine( + canvas, + Offset(plot.left, y), + Offset(plot.right, y), + line.dash, + Paint() + ..color = line.color + ..strokeWidth = line.width, + ); + } + + // 7. X axis, ticks and labels. + final Paint axis = Paint() + ..color = spec.axisColor + ..strokeWidth = 1.5; + for (int i = 0; i < spec.xTicks.length; i++) { + final ChartXTick tick = spec.xTicks[i]; + final double x = px(tick.x); + final TextPainter tp = _text(tick.label, spec.axisLabelStyle); + final double dx = i == 0 + ? 0 + : i == spec.xTicks.length - 1 + ? -tp.width + : -tp.width / 2; + tp.paint(canvas, Offset(x + dx, plot.bottom + 8)); + canvas.drawLine(Offset(x, plot.bottom), Offset(x, plot.bottom + 4), axis); + } + canvas.drawLine( + Offset(plot.left, plot.bottom), + Offset(plot.right, plot.bottom), + axis, + ); + + // 8. Markers. + final Paint outline = Paint() + ..color = spec.lineColor + ..style = PaintingStyle.stroke + ..strokeWidth = 2; + for (final ChartMarker marker in spec.markers) { + final Offset p = Offset(px(marker.x), py(marker.y)); + canvas.drawCircle( + p, + 5, + Paint() + ..color = marker.filled ? spec.lineColor : spec.markerHollowColor, + ); + canvas.drawCircle(p, 5, outline); + if (marker.label case final String label when label.isNotEmpty) { + final TextPainter tp = _text(label, spec.markerLabelStyle); + final double ly = marker.above ? p.dy - tp.height - 8 : p.dy + 8; + final double lx = (p.dx - tp.width / 2) + .clamp(plot.left, math.max(plot.left, plot.right - tp.width)) + .toDouble(); + tp.paint( + canvas, + Offset(lx, math.max(0, math.min(ly, plot.bottom - tp.height - 6))), + ); + } + } + + // 9. Vertical lines. + for (final ChartVerticalLine line in spec.verticalLines) { + final double x = px(line.x); + if (x < plot.left - 0.5 || x > plot.right + 0.5) continue; + _dashedLine( + canvas, + Offset(x, plot.top), + Offset(x, plot.bottom), + line.dash, + Paint() + ..color = line.color ?? spec.axisColor + ..strokeWidth = 1.5, + ); + if (line.label case final String label when label.isNotEmpty) { + final TextPainter tp = _text(label, spec.tagTextStyle); + final Rect box = Rect.fromCenter( + center: Offset(x, plot.top - 12), + width: tp.width + 10, + height: tp.height + 6, + ); + canvas.drawRRect( + RRect.fromRectAndRadius(box, const Radius.circular(2)), + Paint()..color = spec.tagBackground, + ); + tp.paint(canvas, Offset(box.left + 5, box.top + 3)); + } + if (line.dotOnCurve) { + if (spec.valueAt(line.x) case final double y) { + final Offset dot = Offset(x, py(y)); + canvas.drawCircle( + dot, + 6, + Paint()..color = line.dotColor ?? spec.lineColor, + ); + canvas.drawCircle(dot, 6, outline); + } + } + } + + // 10. Scrub cursor and readout. + if (cursorX case final double cx) { + final double? value = spec.valueAt(cx); + if (value == null) return; + final Offset p = Offset(px(cx), py(value)); + canvas.drawLine( + Offset(p.dx, plot.top), + Offset(p.dx, plot.bottom), + Paint() + ..color = spec.cursorColor + ..strokeWidth = 1.5, + ); + canvas.drawCircle(p, 7, Paint()..color = spec.cursorRingFill); + canvas.drawCircle( + p, + 7, + Paint() + ..color = spec.cursorColor + ..style = PaintingStyle.stroke + ..strokeWidth = 3, + ); + final (String xText, String yText) = spec.readout(cx); + final TextPainter a = _text(xText, spec.tooltipLabelStyle); + final TextPainter b = _text(yText, spec.tooltipValueStyle); + final Rect card = LineChartGeometry.readoutRect( + p, + Size(math.max(a.width, b.width) + 20, a.height + b.height + 14), + plot, + ); + canvas.drawRRect( + RRect.fromRectAndRadius( + card.shift(const Offset(3, 3)), + const Radius.circular(3), + ), + Paint()..color = spec.tooltipShadowColor, + ); + canvas.drawRRect( + RRect.fromRectAndRadius(card, const Radius.circular(3)), + Paint()..color = spec.tooltipBackground, + ); + a.paint(canvas, Offset(card.left + 10, card.top + 7)); + b.paint(canvas, Offset(card.left + 10, card.top + 7 + a.height + 2)); + } + } + + @override + bool shouldRepaint(LineChartPainter oldDelegate) => + oldDelegate.spec != spec || oldDelegate.cursorX != cursorX; +} diff --git a/lib/src/transformers/node_transformers/passive_rectangle_transformer.dart b/lib/src/transformers/node_transformers/passive_rectangle_transformer.dart index 4ec5efb7..0c02850a 100644 --- a/lib/src/transformers/node_transformers/passive_rectangle_transformer.dart +++ b/lib/src/transformers/node_transformers/passive_rectangle_transformer.dart @@ -158,16 +158,33 @@ class PassiveRectangleWidget extends StatelessWidget { size: node.basicBoxLocal.size, child: Container( clipBehavior: getClipBehavior(node), - decoration: BoxDecoration( - boxShadow: retrieveBoxShadow(node, scopedValues), - borderRadius: getBorderRadius(node), - ), + // This box only clips and casts shadows; the node's fills, strokes + // and children take taps where they paint. Under a TapThroughScope, + // its empty parts let taps through. + decoration: TapThroughScope.of(context) + ? _TapThroughDecoration( + boxShadow: retrieveBoxShadow(node, scopedValues), + borderRadius: getBorderRadius(node), + ) + : BoxDecoration( + boxShadow: retrieveBoxShadow(node, scopedValues), + borderRadius: getBorderRadius(node), + ), child: child, ), ); } } +/// A [BoxDecoration] that takes no taps itself. +class _TapThroughDecoration extends BoxDecoration { + const _TapThroughDecoration({super.boxShadow, super.borderRadius}); + + @override + bool hitTest(Size size, Offset position, {TextDirection? textDirection}) => + false; +} + List wrapWithInkWell( BuildContext context, BaseNode node, @@ -852,6 +869,12 @@ Widget buildFill( } } + if (paint.saturation != 1) { + child = ColorFiltered( + colorFilter: ColorFilter.matrix(saturationMatrix(paint.saturation)), + child: child, + ); + } if (modifiedOpacity != 1) { child = Opacity(opacity: modifiedOpacity, child: child); } @@ -864,6 +887,20 @@ Widget buildFill( } } +/// A color matrix that scales color saturation by [saturation]: 1 keeps the +/// colors, 0 leaves grey at each pixel's luminance (Rec. 709 weights). +List saturationMatrix(double saturation) { + const double r = 0.2126, g = 0.7152, b = 0.0722; + final double s = saturation; + final double i = 1 - s; + return [ + r * i + s, g * i, b * i, 0, 0, // + r * i, g * i + s, b * i, 0, 0, // + r * i, g * i, b * i + s, 0, 0, // + 0, 0, 0, 1, 0, // + ]; +} + class RelativeTransform { final List translation; final double rotation; 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 fcb7111b..777d886c 100644 --- a/lib/src/transformers/node_transformers/passive_row_column_transformer.dart +++ b/lib/src/transformers/node_transformers/passive_row_column_transformer.dart @@ -120,12 +120,17 @@ class PassiveRowColumnTransformer extends NodeWidgetTransformer { } } + final CrossAxisAlignment crossAxisAlignment = stretchFallsBack + ? CrossAxisAlignment.start + : rowColumnNode.crossAxisAlignment.flutterAxis; Widget res = CodelesslyFlex( direction: isRow ? Axis.horizontal : Axis.vertical, mainAxisAlignment: rowColumnNode.mainAxisAlignment.flutterAxis, - crossAxisAlignment: stretchFallsBack - ? CrossAxisAlignment.start - : rowColumnNode.crossAxisAlignment.flutterAxis, + crossAxisAlignment: crossAxisAlignment, + // Flex asserts a baseline to align to; texts align on the alphabetic one. + textBaseline: crossAxisAlignment == CrossAxisAlignment.baseline + ? TextBaseline.alphabetic + : null, mainAxisSize: mainFit == SizeFit.shrinkWrap ? MainAxisSize.min : MainAxisSize.max, @@ -442,6 +447,12 @@ class PassiveRowColumnTransformer extends NodeWidgetTransformer { } } + // A squeezable node that wraps its content hugs it while it fits and + // shrinks to the room the Row/Column has left when it doesn't. + if (mainAxisFit == SizeFit.shrinkWrap && node.squeezable) { + childWidget = Flexible(flex: flex, child: childWidget); + } + return childWidget; } diff --git a/lib/src/transformers/passive_transformer_manager.dart b/lib/src/transformers/passive_transformer_manager.dart index 1e73817e..bde5e928 100644 --- a/lib/src/transformers/passive_transformer_manager.dart +++ b/lib/src/transformers/passive_transformer_manager.dart @@ -53,6 +53,7 @@ class PassiveNodeTransformerManager extends WidgetNodeTransformerManager { 'loadingIndicator': PassiveLoadingIndicatorTransformer(getNode, this), 'dropdown': PassiveDropdownTransformer(getNode, this), 'progressBar': PassiveProgressBarTransformer(getNode, this), + 'lineChart': PassiveLineChartTransformer(getNode, this), 'variance': PassiveVarianceTransformer(getNode, this), 'webView': PassiveWebViewTransformer(getNode, this), 'listView': PassiveListViewTransformer(getNode, this), diff --git a/lib/src/transformers/utils/property_value_delegate.dart b/lib/src/transformers/utils/property_value_delegate.dart index ee3b17bd..bbaab85d 100644 --- a/lib/src/transformers/utils/property_value_delegate.dart +++ b/lib/src/transformers/utils/property_value_delegate.dart @@ -1118,7 +1118,7 @@ String formatDate(DateTime date) { /// Formats [date] relative to now: `Just now`, `5 min ago`, `2 hours ago`, /// `3 days ago`, then [formatDate] after a week. String formatTimeAgo(DateTime date, {DateTime? now}) { - final Duration diff = (now ?? DateTime.now()).difference(date); + final Duration diff = (now ?? FormulaClock.now()).difference(date); if (diff.inMinutes < 1) return 'Just now'; if (diff.inHours < 1) return '${diff.inMinutes} min ago'; if (diff.inDays < 1) { diff --git a/lib/src/transformers/utils/text_utils.dart b/lib/src/transformers/utils/text_utils.dart index 127c6811..ca5d5bda 100644 --- a/lib/src/transformers/utils/text_utils.dart +++ b/lib/src/transformers/utils/text_utils.dart @@ -39,7 +39,9 @@ class TextUtils { context, characters, node: node, - color: retrievePropColor(prop), + color: + boundTextColor(context, node, variablesOverrides) ?? + retrievePropColor(prop), fontSize: prop.fontSize, letterSpacing: prop.letterSpacing, fontName: prop.fontName, @@ -279,6 +281,26 @@ class TextUtils { ); } + /// The color bound to a text node's `textColor` property (a variable, + /// path or formula giving a color such as `#1F2B2E`), which recolors every + /// span of the text. Null when nothing is bound or there's no [context]. + static Color? boundTextColor( + BuildContext? context, + BaseNode? node, + List variablesOverrides, + ) { + if (context == null || node == null) return null; + if (node.variables['textColor'] == null) return null; + return PropertyValueDelegate.getPropertyValue( + node, + 'textColor', + scopedValues: ScopedValues.of( + context, + variablesOverrides: variablesOverrides, + ), + )?.toFlutterColor(); + } + static Color? retrievePropColor(TextProp prop) => prop.fills .firstWhereOrNull((fill) => fill.color != null) ?.toFlutterColor(); diff --git a/lib/src/transformers/widget_node_transformer_manager.dart b/lib/src/transformers/widget_node_transformer_manager.dart index 700d751f..c254e6ea 100644 --- a/lib/src/transformers/widget_node_transformer_manager.dart +++ b/lib/src/transformers/widget_node_transformer_manager.dart @@ -194,6 +194,16 @@ abstract class WidgetNodeTransformerManager /// 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) { + final String? customLabel = node.variables['semanticLabel']; + final String? customSelected = node.variables['semanticSelected']; + if ((customLabel?.trim().isNotEmpty ?? false) || customSelected != null) { + return _wrapWithCustomSemantics( + node, + widget, + label: customLabel, + selected: customSelected, + ); + } if (node is! ReactionMixin) return widget; if (node case CanvasNode() || SpacerNode()) return widget; if (node is CustomPropertiesMixin && @@ -229,6 +239,85 @@ abstract class WidgetNodeTransformerManager ); } + /// Semantics a node's `semanticLabel` variable gives it (a text with paths + /// or formulas): one node with that label, replacing what the children + /// read. A button (a node that reacts to taps) keeps its tap and long press + /// on that node; a widget (a field, a dropdown) keeps its own semantics, + /// read after the label. A `semanticSelected` formula marks it selected, + /// e.g. the chosen tab or chip. + Widget _wrapWithCustomSemantics( + BaseNode node, + Widget widget, { + String? label, + String? selected, + }) { + List reactionsOf(TriggerType type) => [ + if (node is ReactionMixin) + for (final Reaction reaction in (node as ReactionMixin).reactions) + if (reaction.trigger.type == type && reaction.action.enabled) + reaction, + ]; + final List taps = reactionsOf(TriggerType.click); + final List longPresses = reactionsOf(TriggerType.longPress); + final bool tappable = taps.isNotEmpty; + final bool widgetHandlesInput = + node is CustomPropertiesMixin && node.handlesDefaultReactionsInternally; + return Builder( + builder: (context) { + final ScopedValues scopedValues = ScopedValues.of(context); + final String? text = label == null || label.trim().isEmpty + ? null + : PropertyValueDelegate.substituteVariables( + label, + scopedValues: scopedValues, + nullSubstitutionMode: NullSubstitutionMode.emptyString, + ); + final bool? isSelected = selected == null + ? null + : PropertyValueDelegate.getVariableValueFromPath( + selected, + scopedValues: scopedValues, + ); + if (widgetHandlesInput) { + // A field or dropdown's state and actions live on its own + // semantics, which merge in after the label. + return MergeSemantics( + child: Semantics( + container: true, + selected: isSelected, + label: text, + child: widget, + ), + ); + } + return Semantics( + container: true, + button: tappable ? true : null, + selected: isSelected, + label: text, + // The children's text and the tap handler below are left out, so + // the node's own tap and long press are given here. + excludeSemantics: text != null, + onTap: text != null && tappable + ? () => FunctionsRepository.triggerAction( + context, + TriggerType.click, + reactions: taps, + ) + : null, + onLongPress: text != null && longPresses.isNotEmpty + ? () => FunctionsRepository.triggerAction( + context, + TriggerType.longPress, + reactions: longPresses, + ) + : null, + 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(); diff --git a/lib/src/ui/canvas_location.dart b/lib/src/ui/canvas_location.dart new file mode 100644 index 00000000..dec387b8 --- /dev/null +++ b/lib/src/ui/canvas_location.dart @@ -0,0 +1,152 @@ +import 'dart:convert'; + +import 'package:codelessly_api/codelessly_api.dart'; + +import '../model/sdk_publish_model.dart'; + +/// The canvas a location opens and the route params it passes to it. +class CanvasLocationMatch { + /// The layout that holds [canvasId]. + final String layoutId; + + /// The canvas whose path matched. + final String canvasId; + + /// The path params, such as `id` for `guide/:id`, over the query params. + final Map params; + + /// Creates a [CanvasLocationMatch]. + const CanvasLocationMatch({ + required this.layoutId, + required this.canvasId, + required this.params, + }); +} + +/// Maps locations (URL paths relative to a published app's root) to canvases +/// by their [CanvasProperties.path], and back. +abstract final class CanvasLocation { + /// The segments of a path or path pattern, without empty ones. + static List segmentsOf(String path) => path + .split('/') + .map((segment) => segment.trim()) + .where((segment) => segment.isNotEmpty) + .toList(); + + /// The canvas node of [canvasId] in [layout], if loaded. + static CanvasNode? canvasOf(SDKPublishLayout layout, String canvasId) { + final BaseNode? node = layout.canvases[canvasId]?[canvasId]; + return node is CanvasNode ? node : null; + } + + /// The params [pattern] takes from [segments], or null when it does not + /// match them. + static Map? matchPattern( + List pattern, + List segments, + ) { + if (pattern.length != segments.length) return null; + final Map params = {}; + for (int i = 0; i < pattern.length; i++) { + if (pattern[i].startsWith(':') && pattern[i].length > 1) { + params[pattern[i].substring(1)] = segments[i]; + } else if (pattern[i] != segments[i]) { + return null; + } + } + return params; + } + + /// The canvas of [model] whose path matches [location], such as + /// `guide/bat-star?x=1` for a canvas at `guide/:id`. Its params are the path + /// params (`id: bat-star`) over the query params (`x: 1`). When several + /// canvases match, the one with the most literal segments wins, so + /// `guide/new` beats `guide/:id`. Null when no canvas matches. + static CanvasLocationMatch? match(SDKPublishModel model, String location) { + final Uri uri = Uri.parse(location.trim()); + final List segments = uri.pathSegments + .where((segment) => segment.isNotEmpty) + .toList(); + if (segments.isEmpty) return null; + + CanvasLocationMatch? best; + int bestLiterals = -1; + for (final SDKPublishLayout layout in model.layouts.values) { + if (model.disabledLayouts.contains(layout.id)) continue; + for (final String canvasId in layout.canvasIds) { + final CanvasNode? canvas = canvasOf(layout, canvasId); + if (canvas == null || !canvas.properties.hasPath) continue; + final List pattern = segmentsOf(canvas.properties.path!); + final Map? params = matchPattern(pattern, segments); + if (params == null) continue; + final int literals = pattern.length - params.length; + if (literals <= bestLiterals) continue; + bestLiterals = literals; + best = CanvasLocationMatch( + layoutId: layout.id, + canvasId: canvasId, + params: {...uri.queryParameters, ...params}, + ); + } + } + return best; + } + + /// The location of the screen [destinationId] (a canvas or layout id) opened + /// with [params]: its path with `:name` segments filled from [params], and + /// the other params as the query. The entry screen without a path is at the + /// root (an empty path). Null when the screen has no path or [params] lack + /// one of its path params. + static String? locationOf( + SDKPublishModel model, + String destinationId, + Map params, + ) { + final SDKPublishLayout? layout = + model.layouts[destinationId] ?? + model.layouts.values + .where((layout) => layout.canvasIds.contains(destinationId)) + .firstOrNull; + if (layout == null) return null; + + // The destination canvas's path, else that of a canvas for another + // breakpoint of its layout. + String? path = canvasOf(layout, destinationId)?.properties.path; + if (path == null || path.trim().isEmpty) { + path = layout.canvasIds + .map((canvasId) => canvasOf(layout, canvasId)?.properties.path) + .firstWhere( + (path) => path != null && path.trim().isNotEmpty, + orElse: () => null, + ); + } + if (path == null && model.entryLayoutId != layout.id) return null; + + final Map query = {...params}; + final List segments = []; + for (final String segment in segmentsOf(path ?? '')) { + if (segment.startsWith(':') && segment.length > 1) { + final String value = _stringOf(query.remove(segment.substring(1))); + if (value.isEmpty) return null; + segments.add(value); + } else { + segments.add(segment); + } + } + query.removeWhere((key, value) => value == null); + + return Uri( + pathSegments: segments, + queryParameters: query.isEmpty + ? null + : query.map((key, value) => MapEntry(key, _stringOf(value))), + ).toString(); + } + + static String _stringOf(Object? value) => switch (value) { + null => '', + String() => value, + Map() || List() => jsonEncode(value), + _ => '$value', + }; +} diff --git a/lib/src/ui/codelessly_dialog_widget.dart b/lib/src/ui/codelessly_dialog_widget.dart index 7c0ccc68..fbdfe33e 100644 --- a/lib/src/ui/codelessly_dialog_widget.dart +++ b/lib/src/ui/codelessly_dialog_widget.dart @@ -27,10 +27,12 @@ class CodelesslyDialogWidget extends StatelessWidget { @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. + // design size instead of filling the screen, unless it holds a + // responsive layout (see CanvasSizing.design). Taps on its empty parts + // reach the barrier. return CanvasSizingScope( sizing: CanvasSizing.design, - child: Builder(builder: _buildDialog), + child: TapThroughScope(child: Builder(builder: _buildDialog)), ); } diff --git a/lib/src/ui/codelessly_route.dart b/lib/src/ui/codelessly_route.dart index 28e2fc74..64e670b3 100644 --- a/lib/src/ui/codelessly_route.dart +++ b/lib/src/ui/codelessly_route.dart @@ -1,16 +1,23 @@ /// A screen of a Codelessly app and the parameters it was opened with, as a -/// link: `{canvasId}?key=value`. Hosts put it in the browser's address -/// (see [Codelessly.route]) and hand it back on a reload (see -/// [Codelessly.pendingRoute]), so a reload opens the same screen with the -/// same parameters. +/// link: `{canvasId}?key=value`, or the screen's own URL path when it has one +/// (`guide/bat-star` for a screen at `guide/:id`, see +/// [Codelessly.pathOf]). Hosts put it in the browser's address (see +/// [Codelessly.route]) and hand it back on a reload or a shared link (see +/// [Codelessly.pendingRoute]), so it opens the same screen with the same +/// parameters. class CodelesslyRoute { - /// The screen (canvas) id. + /// The screen (canvas) id. For a link read from a URL path, its last + /// segment, which [path] may resolve to another screen. final String canvasId; /// The parameters the screen was opened with (`${route.key}`). final Map params; - const CodelesslyRoute(this.canvasId, [this.params = const {}]); + /// The whole path of a link read by [tryParse], such as `guide/bat-star`, + /// matched against the screens' URL paths when it opens. + final String? path; + + const CodelesslyRoute(this.canvasId, [this.params = const {}, this.path]); /// The route of a screen opened with [arguments], the navigation action's /// parameters. Values that aren't text, numbers or yes/no are left out: @@ -33,7 +40,11 @@ class CodelesslyRoute { if (segment.isNotEmpty) segment, ]; if (segments.isEmpty) return null; - return CodelesslyRoute(segments.last, uri.queryParameters); + return CodelesslyRoute( + segments.last, + uri.queryParameters, + segments.join('/'), + ); } /// The link: `{canvasId}` and its parameters as a query. diff --git a/lib/src/ui/feedback_theme.dart b/lib/src/ui/feedback_theme.dart index 2e424273..a37f2486 100644 --- a/lib/src/ui/feedback_theme.dart +++ b/lib/src/ui/feedback_theme.dart @@ -1,5 +1,6 @@ import 'package:codelessly_api/codelessly_api.dart'; import 'package:collection/collection.dart'; +import 'package:flutter/foundation.dart' show listEquals; import 'package:flutter/material.dart'; import '../../codelessly_sdk.dart'; @@ -28,6 +29,10 @@ class FeedbackTheme extends ThemeExtension { this.warning, this.radius, this.buttonRadius, + this.border, + this.shadows, + this.buttonBorder, + this.buttonShadows, }); /// Font of messages, without size or color. @@ -66,6 +71,18 @@ class FeedbackTheme extends ThemeExtension { /// Corner radius of buttons. final double? buttonRadius; + /// Outline of dialogs. + final BorderSide? border; + + /// Shadows of dialogs, in place of their elevation. + final List? shadows; + + /// Outline of buttons. + final BorderSide? buttonBorder; + + /// Shadows of filled buttons. + final List? buttonShadows; + /// Fills this theme's missing values from [other]. FeedbackTheme merge(FeedbackTheme? other) { if (other == null) return this; @@ -82,6 +99,10 @@ class FeedbackTheme extends ThemeExtension { warning: warning ?? other.warning, radius: radius ?? other.radius, buttonRadius: buttonRadius ?? other.buttonRadius, + border: border ?? other.border, + shadows: shadows ?? other.shadows, + buttonBorder: buttonBorder ?? other.buttonBorder, + buttonShadows: buttonShadows ?? other.buttonShadows, ); } @@ -132,6 +153,10 @@ class FeedbackTheme extends ThemeExtension { final Map textColors = {}; final Map primaryVotes = {}; final Map radiusVotes = {}; + // Outlines and shadows of tappable shapes, keyed by their look. + final Map buttonBorderVotes = {}; + final Map, int)> buttonShadowVotes = {}; + int tappableShapes = 0; TextProp? buttonLabel; Color? surface; @@ -179,8 +204,34 @@ class FeedbackTheme extends ThemeExtension { (Reaction r) => r.trigger.type == TriggerType.click, ): // A tappable shape with a solid fill is a button too. - final Color? fill = _solidFill((scene as GeometryMixin).fills); - if (fill != null) vote(primaryVotes, fill.toARGB32(), 1); + final GeometryMixin shape = scene as GeometryMixin; + final Color? fill = _solidFill(shape.fills); + if (fill != null) { + vote(primaryVotes, fill.toARGB32(), 1); + tappableShapes++; + final Color? stroke = shape.strokeWeight > 0 + ? _solidFill(shape.strokes) + : null; + if (stroke != null) { + final BorderSide side = BorderSide( + color: stroke, + width: shape.strokeWeight, + ); + buttonBorderVotes[side] = (buttonBorderVotes[side] ?? 0) + 1; + } + if (scene is BlendMixin) { + final List shadows = boxShadowsOf( + (scene as BlendMixin).effects, + ); + if (shadows.isNotEmpty) { + final String key = shadows.join(); + buttonShadowVotes[key] = ( + shadows, + (buttonShadowVotes[key]?.$2 ?? 0) + 1, + ); + } + } + } if (scene is CornerMixin) { vote( radiusVotes, @@ -235,6 +286,17 @@ class FeedbackTheme extends ThemeExtension { (MapEntry e) => e.value, )?.key; + // Outlined, stamped buttons: when most tappable shapes share an outline + // or a shadow, so do the dialog's buttons. + final MapEntry? buttonBorder = maxBy( + buttonBorderVotes.entries, + (MapEntry e) => e.value, + ); + final (List, int)? buttonShadows = maxBy( + buttonShadowVotes.values, + ((List, int) e) => e.$2, + ); + return FeedbackTheme( bodyStyle: bodyStyle, titleStyle: titleStyle, @@ -245,6 +307,14 @@ class FeedbackTheme extends ThemeExtension { surface: surface, onSurface: onSurface == null ? null : Color(onSurface), buttonRadius: buttonRadius?.toDouble(), + buttonBorder: + buttonBorder != null && buttonBorder.value * 2 > tappableShapes + ? buttonBorder.key + : null, + buttonShadows: + buttonShadows != null && buttonShadows.$2 * 2 > tappableShapes + ? buttonShadows.$1 + : null, ); } @@ -284,6 +354,10 @@ class FeedbackTheme extends ThemeExtension { Color? warning, double? radius, double? buttonRadius, + BorderSide? border, + List? shadows, + BorderSide? buttonBorder, + List? buttonShadows, }) => FeedbackTheme( bodyStyle: bodyStyle ?? this.bodyStyle, titleStyle: titleStyle ?? this.titleStyle, @@ -297,6 +371,10 @@ class FeedbackTheme extends ThemeExtension { warning: warning ?? this.warning, radius: radius ?? this.radius, buttonRadius: buttonRadius ?? this.buttonRadius, + border: border ?? this.border, + shadows: shadows ?? this.shadows, + buttonBorder: buttonBorder ?? this.buttonBorder, + buttonShadows: buttonShadows ?? this.buttonShadows, ); @override @@ -315,6 +393,10 @@ class FeedbackTheme extends ThemeExtension { warning: Color.lerp(warning, other.warning, t), radius: t < 0.5 ? radius : other.radius, buttonRadius: t < 0.5 ? buttonRadius : other.buttonRadius, + border: t < 0.5 ? border : other.border, + shadows: BoxShadow.lerpList(shadows, other.shadows, t), + buttonBorder: t < 0.5 ? buttonBorder : other.buttonBorder, + buttonShadows: BoxShadow.lerpList(buttonShadows, other.buttonShadows, t), ); } @@ -332,7 +414,11 @@ class FeedbackTheme extends ThemeExtension { other.success == success && other.warning == warning && other.radius == radius && - other.buttonRadius == buttonRadius; + other.buttonRadius == buttonRadius && + other.border == border && + listEquals(other.shadows, shadows) && + other.buttonBorder == buttonBorder && + listEquals(other.buttonShadows, buttonShadows); @override int get hashCode => Object.hash( @@ -348,6 +434,10 @@ class FeedbackTheme extends ThemeExtension { warning, radius, buttonRadius, + border, + shadows == null ? null : Object.hashAll(shadows!), + buttonBorder, + buttonShadows == null ? null : Object.hashAll(buttonShadows!), ); } @@ -368,6 +458,10 @@ class ResolvedFeedbackTheme { required this.warning, required this.radius, required this.buttonRadius, + required this.border, + required this.shadows, + required this.buttonBorder, + required this.buttonShadows, }); factory ResolvedFeedbackTheme._resolve(FeedbackTheme t, ThemeData theme) { @@ -402,6 +496,10 @@ class ResolvedFeedbackTheme { warning: t.warning ?? const Color(0xFFF2A600), radius: t.radius ?? (buttonRadius + 8).clamp(12, 28).toDouble(), buttonRadius: buttonRadius, + border: t.border, + shadows: t.shadows, + buttonBorder: t.buttonBorder, + buttonShadows: t.buttonShadows, ); } @@ -444,6 +542,18 @@ class ResolvedFeedbackTheme { /// Corner radius of buttons. final double buttonRadius; + /// Outline of dialogs, if any. + final BorderSide? border; + + /// Shadows of dialogs in place of their elevation, if set. + final List? shadows; + + /// Outline of buttons, if any. + final BorderSide? buttonBorder; + + /// Shadows of filled buttons, if any. + final List? buttonShadows; + /// Black or white, whichever reads on [background]. static Color onColor(Color background) => ThemeData.estimateBrightnessForColor(background) == Brightness.dark @@ -496,3 +606,63 @@ class FeedbackThemeScope extends InheritedWidget { bool updateShouldNotify(FeedbackThemeScope oldWidget) => theme != oldWidget.theme; } + +/// The visible drop shadows of [effects] as [BoxShadow]s. +List boxShadowsOf(Iterable? effects) => [ + for (final Effect effect in effects ?? const []) + if (effect.type == EffectType.dropShadow && effect.visible) + BoxShadow( + color: effect.color?.toFlutterColor() ?? const Color(0x40000000), + offset: Offset( + effect.offset?.x.toDouble() ?? 0, + effect.offset?.y.toDouble() ?? 0, + ), + blurRadius: effect.radius, + spreadRadius: effect.spread ?? 0, + ), +]; + +/// [model] as a Flutter [BorderSide]; [BorderSide.none] for a border of +/// style none. +BorderSide borderSideOf(BorderSideModel model) => + model.style == BorderStyleEnum.none || model.width <= 0 + ? BorderSide.none + : BorderSide(color: model.color.toFlutterColor(), width: model.width); + +/// Applies a [TextStyleModel] to a Flutter [TextStyle]. +extension TextStyleModelApply on TextStyleModel? { + /// [base] with this style's values in place of its own. A font family + /// loads like a text node's, Google Fonts included. + TextStyle applyTo(TextStyle base) { + final TextStyleModel? model = this; + if (model == null) return base; + TextStyle style = base; + final FontName? font = model.fontName; + if (font != null) { + final TextStyle loaded = TextUtils.retrieveTextStyle(fontName: font); + style = style.copyWith( + fontFamily: loaded.fontFamily, + fontFamilyFallback: loaded.fontFamilyFallback, + fontWeight: loaded.fontWeight, + fontStyle: loaded.fontStyle, + ); + } else { + if (model.fontWeight != null) { + style = style.copyWith( + fontWeight: FontWeight.values[model.fontWeight!.index], + ); + } + if (model.italic != null) { + style = style.copyWith( + fontStyle: model.italic! ? FontStyle.italic : FontStyle.normal, + ); + } + } + return style.copyWith( + fontSize: model.fontSize, + height: model.lineHeight, + letterSpacing: model.letterSpacing, + color: model.color?.toFlutterColor(), + ); + } +} diff --git a/lib/src/ui/layout_builder.dart b/lib/src/ui/layout_builder.dart index 7c893eee..a5f836e9 100644 --- a/lib/src/ui/layout_builder.dart +++ b/lib/src/ui/layout_builder.dart @@ -1,5 +1,3 @@ -import 'dart:async'; - import 'package:codelessly_api/codelessly_api.dart'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; @@ -292,9 +290,9 @@ class _CodelesslyLayoutBuilderState extends State { } /// This loads all the apis for the layout as variables to be available - /// in the layout. Any api that is defined on canvas' load action will be - /// called right away. Otherwise, the api will be available as a variable - /// with idle state. + /// in the layout. An api called by a canvas load action starts in the + /// loading state (the canvas calls it in order with its other load + /// actions); any other api is available as a variable with idle state. void loadApisAndItsVariables(BuildContext context) { final apiCallActions = canvasNode.reactions .whereTriggerType(TriggerType.load) @@ -334,32 +332,6 @@ class _CodelesslyLayoutBuilderState extends State { // Override default values of variables with values provided in data. final notifier = Observable(variable); codelesslyContext.variables[variable.id] = notifier; - - // Make api request right away if it is a canvas action. - if (canvasAction != null) { - DebugLogger.instance.printInfo( - 'Making api request for onLoad canvas action', - name: CodelesslyLayoutRetriever.name, - ); - // The variable carries the error state; report the failure instead - // of leaving it as an unhandled async error. - unawaited( - FunctionsRepository.makeApiRequestFromAction( - canvasAction, - context, - notifier, - ).then( - (_) {}, - onError: (Object e, StackTrace stackTrace) => - ErrorLogger.instance.captureException( - e, - message: 'onLoad API call failed', - type: 'api_call_failed', - stackTrace: stackTrace, - ), - ), - ); - } } } diff --git a/lib/src/utils/extensions.dart b/lib/src/utils/extensions.dart index 8cc1c817..aa1b1012 100644 --- a/lib/src/utils/extensions.dart +++ b/lib/src/utils/extensions.dart @@ -491,6 +491,15 @@ extension CMainAxisAlignmentHelper on MainAxisAlignmentC { return flutter.MainAxisAlignment.spaceEvenly; } } + + 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 CCrossAxisAlignmentHelper on CrossAxisAlignmentC { @@ -508,6 +517,15 @@ extension CCrossAxisAlignmentHelper on CrossAxisAlignmentC { return flutter.CrossAxisAlignment.baseline; } } + + /// A [flutter.Wrap] has no stretch or baseline: those align to the start. + flutter.WrapCrossAlignment get flutterWrapAlignment => switch (this) { + CrossAxisAlignmentC.end => flutter.WrapCrossAlignment.end, + CrossAxisAlignmentC.center => flutter.WrapCrossAlignment.center, + CrossAxisAlignmentC.start || + CrossAxisAlignmentC.stretch || + CrossAxisAlignmentC.baseline => flutter.WrapCrossAlignment.start, + }; } extension AxisHelperExt on AxisC { diff --git a/lib/src/utils/formula.dart b/lib/src/utils/formula.dart index ae0b91e3..6ff1b3f6 100644 --- a/lib/src/utils/formula.dart +++ b/lib/src/utils/formula.dart @@ -56,9 +56,11 @@ typedef FormulaPathResolver = Object? Function(String path); /// and move. Set variable actions use them to change nested data, e.g. /// `setAt(trip.stops, index, merge(item, object('nights', 3)))`. /// - list slicing: first, last, slice(list, start, end), take, skip, -/// reverse, range(end) or range(start, end, step), and indexOf(list, +/// reverse, range(count) or range(start, end, step) with the end +/// included, and indexOf(list, /// value or predicate), which is -1 when nothing matches. -/// - date functions: now, today, parseDate, formatDate, addMinutes, addHours, +/// - series helpers: interpolate, runs, minBy and maxBy. +/// - date functions: now, today, wallTime, parseDate, formatDate, addMinutes, addHours, /// addDays, addMonths, diffMinutes, diffHours, diffDays, year, month, day, /// hour, minute, weekday and daysInMonth. Dates are ISO 8601 text such as /// `2026-10-21T14:30:00.000`; a date argument can also be epoch @@ -88,6 +90,12 @@ class FormulaEvaluator { } } + /// Formats [date] (a DateTime, an ISO string, epoch milliseconds or a + /// timestamp) with a date [pattern] as the `formatDate` formula function + /// does, e.g. `h:mm a`. Returns null when [date] isn't a date. + static String? formatDate(Object? date, [String? pattern]) => + _formatDate(date, pattern); + /// The variable paths [expression] reads, e.g. `storage.cart` and `qty`. static Set referencedPaths(String expression) { final _Node? node = _parse(expression); @@ -363,8 +371,9 @@ class FormulaEvaluator { ), FormulaFunctionDoc( 'range', - 'range(end) or range(start, end, step)', - 'range(0, 70, 10) → [0, 10, …, 60]', + 'range(count) or range(start, end, step), end included (dates: step ' + 'in minutes)', + 'range(3, 7) → [3, 4, 5, 6, 7]; range(3) → [0, 1, 2]', ), FormulaFunctionDoc( 'indexOf', @@ -440,8 +449,41 @@ class FormulaEvaluator { 'isNotEmpty(text or list)', 'isNotEmpty(cart) → true', ), - FormulaFunctionDoc('now', 'now()', 'now() → 2026-10-21T14:30:00.000'), - FormulaFunctionDoc('today', 'today()', 'today() → 2026-10-21T00:00:00.000'), + FormulaFunctionDoc( + 'now', + 'now(utcOffset?)', + 'now() → 2026-10-21T14:30:00.000, now("-07:00") at UTC−7', + ), + FormulaFunctionDoc( + 'today', + 'today(utcOffset?)', + 'today() → 2026-10-21T00:00:00.000', + ), + FormulaFunctionDoc( + 'wallTime', + 'wallTime(dateWithOffset)', + 'wallTime("2026-10-22T07:25:17-07:00") → 2026-10-22T07:25:17.000', + ), + FormulaFunctionDoc( + 'interpolate', + 'interpolate(points, xField, yField, x)', + 'interpolate(tides, "t", "v", now())', + ), + FormulaFunctionDoc( + 'runs', + 'runs(list, item => condition)', + 'runs(steps, s => s.h <= 1) → [{first, last, items, count}]', + ), + FormulaFunctionDoc( + 'minBy', + 'minBy(list, field or item => key)', + 'minBy(lows, "v")', + ), + FormulaFunctionDoc( + 'maxBy', + 'maxBy(list, field or item => key)', + 'maxBy(highs, "v")', + ), FormulaFunctionDoc( 'parseDate', 'parseDate(text or millis)', @@ -999,6 +1041,177 @@ String? _formatDate(Object? value, Object? pattern) { return out.toString(); } +// --------------------------------------------------------------------------- +// Series, text and object helpers +// --------------------------------------------------------------------------- + +/// The most items [range] returns, so a typo can't build a huge list. +const int _maxRange = 10000; + +/// Numbers from [start] through [end] by [step], or, when [start] is a date, +/// dates from [start] through [end] every [step] minutes (default 1). +/// The times from [start] to [end], both included, [step] minutes apart +/// (default 1), as ISO 8601 text: `range(sunrise, sunset, 5)`. +List _dateRange(Object? start, Object? end, Object? step) { + final DateTime? from = _toDate(start), to = _toDate(end); + final num minutes = _num(step ?? 1); + if (from == null || to == null || !(minutes > 0)) return const []; + final Duration delta = Duration(microseconds: (minutes * 60e6).round()); + return [ + for (DateTime t = from; !t.isAfter(to); t = t.add(delta)) _dateText(t), + ].take(_maxRange).toList(); +} + +/// A point's x as a number: dates as epoch milliseconds. +num? _xValue(Object? value) { + if (_isDateLike(value)) return _toDate(value)?.millisecondsSinceEpoch; + final num? n = value is String ? num.tryParse(value.trim()) : _toNum(value); + return n != null && n.isFinite ? n : null; +} + +/// The y of the series [list] at [x], interpolated linearly between the two +/// items whose [xField] values surround it. Items must be sorted by x. Null +/// outside the series. +num? _interpolate(List args) { + final Object? list = _arg(args, 0); + final Object? xField = _arg(args, 1), yField = _arg(args, 2); + final num? x = _xValue(_arg(args, 3)); + if (list is! List || list.isEmpty || x == null) return null; + num? xOf(int i) => _xValue(_select(list[i], xField)); + num? yOf(int i) { + final Object? y = _select(list[i], yField); + return y is String ? num.tryParse(y.trim()) : _toNum(y); + } + + int lo = 0, hi = list.length - 1; + final num? first = xOf(lo), last = xOf(hi); + if (first == null || last == null || x < first || x > last) return null; + while (hi - lo > 1) { + final int mid = (lo + hi) >> 1; + final num? m = xOf(mid); + if (m == null) return null; + if (m <= x) { + lo = mid; + } else { + hi = mid; + } + } + final num? x0 = xOf(lo), x1 = xOf(hi), y0 = yOf(lo), y1 = yOf(hi); + if (x0 == null || x1 == null || y0 == null || y1 == null) return null; + if (x1 == x0) return y0; + return y0 + (y1 - y0) * (x - x0) / (x1 - x0); +} + +/// Runs of consecutive items of `args[0]` that match the predicate in the +/// rest of [args], each as `{first, last, items, count}`. +List _runs(List args) { + final List rest = args.skip(1).toList(); + final List out = []; + List current = []; + void close() { + if (current.isEmpty) return; + out.add({ + 'first': current.first, + 'last': current.last, + 'items': current, + 'count': current.length, + }); + current = []; + } + + for (final Object? item in _values(_arg(args, 0), null)) { + if (_matches(item, rest)) { + current.add(item); + } else { + close(); + } + } + close(); + return out; +} + +/// The item of `args[0]` with the smallest ([max] false) or largest key +/// `args[1]` (a field or lambda). Items without a key are skipped. +Object? _extremeBy(List args, {required bool max}) { + Object? best; + int? bestKey; + Object? bestValue; + for (final Object? item in _values(_arg(args, 0), null)) { + final Object? key = _select(item, _arg(args, 1)); + if (key == null) continue; + if (bestKey == null) { + best = item; + bestValue = key; + bestKey = 0; + continue; + } + final int? order = _compare(key, bestValue); + if (order != null && (max ? order > 0 : order < 0)) { + best = item; + bestValue = key; + } + } + return best; +} + +/// The UTC offset [value] gives, as hours (`-7`, `5.5`) or text (`-07:00`, +/// `+0530`, `Z`), or null. +Duration? _offset(Object? value) { + if (value == null) return null; + if (value is num) return Duration(minutes: (value * 60).round()); + final String text = value.toString().trim(); + if (text == 'Z' || text.toUpperCase() == 'UTC') return Duration.zero; + final Match? m = RegExp(r'^([+-])(\d{1,2}):?(\d{2})?$').firstMatch(text); + if (m == null) { + final num? hours = num.tryParse(text); + return hours == null ? null : Duration(minutes: (hours * 60).round()); + } + final int minutes = int.parse(m[2]!) * 60 + int.parse(m[3] ?? '0'); + return Duration(minutes: m[1] == '-' ? -minutes : minutes); +} + +/// The instant formulas take as now (`now()`, `today()`). +/// +/// A host can pin it to show an app at a given moment, for previews and +/// reproducible screenshots of date-dependent screens (the Codelessly site +/// pins it from a `?now=` URL parameter). +abstract final class FormulaClock { + /// The pinned instant, or null for the device clock. + static DateTime? pinned; + + /// Now: [pinned], or the device clock. + static DateTime now() => pinned ?? DateTime.now(); +} + +/// The wall-clock time now at the UTC [offset] (see [_offset]), or local +/// time without one. Returned without a zone, like other formula dates. +DateTime _nowAt(Object? offset) { + final Duration? delta = _offset(offset); + if (delta == null) return FormulaClock.now().toLocal(); + final DateTime utc = FormulaClock.now().toUtc().add(delta); + return DateTime( + utc.year, + utc.month, + utc.day, + utc.hour, + utc.minute, + utc.second, + utc.millisecond, + ); +} + +/// The wall-clock part of an ISO date-time written with an offset, e.g. +/// `2026-10-22T07:25:17-07:00` → `2026-10-22T07:25:17.000`, so a time an +/// API gives in a place's zone reads as that place's local time. +String? _wallTime(Object? value) { + final String text = FormulaEvaluator.stringify(value).trim(); + final Match? m = RegExp( + r'^(\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(?::\d{2}(?:[.,]\d+)?)?)', + ).firstMatch(text); + if (m == null) return _dateText(_toDate(text)); + return _dateText(DateTime.tryParse(m[1]!.replaceFirst(' ', 'T'))); +} + typedef _Function = Object? Function(List args); /// A list index from [value]: negative counts from the end, and the result @@ -1016,17 +1229,29 @@ int _count(Object? value) { return n == null || n.isNaN || n < 0 ? 0 : n.toInt(); } -/// `range(end)` is 0 up to [end], `range(start, end, step)` counts by step. -/// Capped at 10,000 items. -List _range(List a) { - final bool single = a.length < 2; - final num start = single ? 0 : _num(_arg(a, 0)); - final num end = single ? _num(_arg(a, 0)) : _num(_arg(a, 1)); +/// `range(n)`: n numbers from 0, `[0, 1, …, n - 1]`. `range(start, end, +/// step)`: from [start] to [end], both included, [step] apart (default 1, or +/// -1 counting down). Dates step in minutes. Capped at 10,000 items. +List _range(List a) { + if (a.length < 2) { + final num count = _num(_arg(a, 0)); + if (count.isNaN) return []; + return [for (int i = 0; i < count; i++) i].take(_maxRange).toList(); + } + if (_isDateLike(_arg(a, 0))) { + return _dateRange(_arg(a, 0), _arg(a, 1), _arg(a, 2)); + } + final num start = _num(_arg(a, 0)); + final num end = _num(_arg(a, 1)); final num step = a.length > 2 ? _num(_arg(a, 2)) : (end < start ? -1 : 1); - if (start.isNaN || end.isNaN || step.isNaN || step == 0) return []; - return [for (num v = start; step > 0 ? v < end : v > end; v += step) _tidy(v)] - .take(10000) - .toList(); + if (!start.isFinite || !end.isFinite || !step.isFinite || step == 0) { + return []; + } + // A little slack keeps an end reached by fractional steps (0.1) in. + return [ + for (num v = start; step > 0 ? v <= end + 1e-9 : v >= end - 1e-9; v += step) + _tidy(v), + ].take(_maxRange).toList(); } /// Great-circle distance between two points in degrees, in km, or in miles @@ -1293,11 +1518,16 @@ final Map _functions = { 'text': (a) => FormulaEvaluator.stringify(_arg(a, 0)), 'isEmpty': (a) => !_truthy(_functions['length']!(a)), 'isNotEmpty': (a) => _truthy(_functions['length']!(a)), - 'now': (a) => _dateText(DateTime.now()), + 'now': (a) => _dateText(_nowAt(_arg(a, 0))), 'today': (a) { - final DateTime now = DateTime.now(); + final DateTime now = _nowAt(_arg(a, 0)); return _dateText(DateTime(now.year, now.month, now.day)); }, + 'wallTime': (a) => _wallTime(_arg(a, 0)), + 'interpolate': _interpolate, + 'runs': _runs, + 'minBy': (a) => _extremeBy(a, max: false), + 'maxBy': (a) => _extremeBy(a, max: true), 'parseDate': (a) => _dateText(_toDate(_arg(a, 0))), 'formatDate': (a) => _formatDate(_arg(a, 0), _arg(a, 1)), 'addMinutes': (a) => diff --git a/test/api_call_after_unmount_test.dart b/test/api_call_after_unmount_test.dart new file mode 100644 index 00000000..cc16c2df --- /dev/null +++ b/test/api_call_after_unmount_test.dart @@ -0,0 +1,146 @@ +import 'dart:async'; + +import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:codelessly_sdk/codelessly_sdk.dart'; +import 'package:codelessly_sdk/src/functions/action_chain.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:provider/provider.dart'; + +class _FakeCache extends Fake implements CacheManager {} + +class _FakeAuthManager extends Fake implements AuthManager {} + +class _FakeLocalRepository extends Fake implements LocalDataRepository {} + +class _FakeNetworkRepository extends Fake implements NetworkDataRepository {} + +class _FakeFirestore extends Fake implements FirebaseFirestore {} + +class _TestDataManager extends DataManager { + _TestDataManager(SDKPublishModel model, CodelesslyConfig config) + : super( + 'test', + config: config, + cacheManager: _FakeCache(), + authManager: _FakeAuthManager(), + networkDataRepository: _FakeNetworkRepository(), + localDataRepository: _FakeLocalRepository(), + firebaseFirestore: _FakeFirestore(), + errorLogger: ErrorLogger.instance, + publishModel: model, + ); +} + +void main() { + testWidgets('an API call finishes its chain after the dialog that started ' + 'it closes', (tester) async { + final Completer answer = Completer(); + final List calls = []; + final model = SDKPublishModel( + projectId: 'project', + owner: 'owner', + teams: const {}, + users: const {}, + roles: const {}, + public: false, + ); + model.apis['sun'] = HttpApiData( + id: 'sun', + name: 'Sun times', + directory: '', + url: 'https://example.com/sun', + owner: 'owner', + teams: const {}, + users: const {}, + roles: const {}, + public: false, + ); + final config = CodelesslyConfig(authToken: 'token'); + final codelessly = Codelessly( + config: config, + client: MockClient((request) async { + await answer.future; + return http.Response('{"sunrise": "7:25"}', 200, request: request); + }), + publishDataManager: _TestDataManager(model, config), + ); + final codelesslyContext = CodelesslyContext.empty() + ..functions = { + 'close': (context, reference, params) => Navigator.of(context).pop(), + 'record': (context, reference, params) => + calls.add('${params['what']}'), + }; + late BuildContext pageContext; + await tester.pumpWidget( + Provider.value( + value: codelessly, + child: ChangeNotifierProvider.value( + value: codelesslyContext, + child: MaterialApp( + home: ActionChainHost( + child: Builder( + builder: (context) { + pageContext = context; + return const Scaffold(); + }, + ), + ), + ), + ), + ), + ); + // A settings dialog: picking a setting reloads data, and the dialog is + // closed before the data arrives. + final openerHosts = ActionChainOpener.hostsFor(pageContext); + unawaited( + showDialog( + context: pageContext, + builder: (context) => ActionChainOpener( + openerHosts: openerHosts, + child: ActionChainHost( + child: Builder( + builder: (context) => TextButton( + onPressed: () => FunctionsRepository.triggerAction( + context, + TriggerType.click, + reactions: [ + Reaction( + action: ApiCallAction( + apiId: 'sun', + onSuccess: [ + CallFunctionAction( + name: 'record', + params: {'what': 'loaded'}, + ), + ], + ), + trigger: TriggerModel(type: TriggerType.click), + ), + ], + ), + child: const Text('Reload'), + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Reload')); + await tester.pump(); + Navigator.of(pageContext).pop(); + await tester.pumpAndSettle(); + expect(find.text('Reload'), findsNothing); + + await tester.runAsync(() async { + answer.complete(); + await Future.delayed(const Duration(milliseconds: 50)); + }); + await tester.pump(); + expect(calls, ['loaded']); + }); +} diff --git a/test/api_call_outcome_test.dart b/test/api_call_outcome_test.dart new file mode 100644 index 00000000..289a1e3c --- /dev/null +++ b/test/api_call_outcome_test.dart @@ -0,0 +1,111 @@ +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:http/http.dart' as http; +import 'package:provider/provider.dart'; + +void main() { + late List calls; + late BuildContext context; + + Future pump(WidgetTester tester) async { + calls = []; + final codelesslyContext = CodelesslyContext.empty() + ..functions['record'] = (context, reference, params) => + calls.add('${params['what']}'); + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: codelesslyContext, + child: MaterialApp( + home: Builder( + builder: (c) { + context = c; + return const SizedBox(); + }, + ), + ), + ), + ); + } + + ApiCallAction api() => ApiCallAction( + apiId: 'tides', + onSuccess: [ + CallFunctionAction(name: 'record', params: {'what': 'success'}), + ], + onError: [ + CallFunctionAction(name: 'record', params: {'what': 'error'}), + ], + ); + + http.Response response(int status) => http.Response( + '{}', + status, + request: http.Request('GET', Uri.parse('https://x.dev')), + ); + + testWidgets('onSuccess runs after a 2xx response', (tester) async { + await pump(tester); + await tester.runAsync( + () => FunctionsRepository.handleApiOutcome( + context, + api(), + response: response(200), + ), + ); + expect(calls, ['success']); + }); + + testWidgets('onError runs after an error response', (tester) async { + await pump(tester); + await tester.runAsync( + () => FunctionsRepository.handleApiOutcome( + context, + api(), + response: response(503), + ), + ); + expect(calls, ['error']); + }); + + testWidgets( + 'onError handles a failed request instead of stopping the chain', + (tester) async { + await pump(tester); + await tester.runAsync( + () => FunctionsRepository.handleApiOutcome( + context, + api(), + failure: http.ClientException('offline'), + ), + ); + expect(calls, ['error']); + }, + ); + + testWidgets('without onError a failed request still throws', (tester) async { + await pump(tester); + Object? thrown; + await tester.runAsync(() async { + try { + await FunctionsRepository.handleApiOutcome( + context, + ApiCallAction(apiId: 'tides'), + failure: http.ClientException('offline'), + ); + } catch (e) { + thrown = e; + } + }); + expect(thrown, isA()); + }); + + test('onSuccess and onError round-trip through JSON', () { + final ApiCallAction action = ApiCallAction.fromJson(api().toJson()); + expect(action.onSuccess.single, isA()); + expect((action.onError.single as CallFunctionAction).params, { + 'what': 'error', + }); + }); +} diff --git a/test/api_call_result_test.dart b/test/api_call_result_test.dart new file mode 100644 index 00000000..7da49344 --- /dev/null +++ b/test/api_call_result_test.dart @@ -0,0 +1,152 @@ +import 'package:codelessly_sdk/codelessly_sdk.dart'; +import 'package:codelessly_sdk/src/functions/action_chain.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:provider/provider.dart'; + +/// Pumps a context that has a [Codelessly] whose HTTP client is [client]. +Future pumpContext( + WidgetTester tester, + http.Client client, +) async { + late BuildContext captured; + await tester.pumpWidget( + Provider.value( + value: Codelessly(client: client), + child: ChangeNotifierProvider.value( + value: CodelesslyContext.empty(), + child: MaterialApp( + home: Builder( + builder: (context) { + captured = context; + return const SizedBox(); + }, + ), + ), + ), + ), + ); + return captured; +} + +void main() { + queryParamTests(); + + testWidgets('An API call leaves its response as the chain result', ( + WidgetTester tester, + ) async { + final context = await pumpContext( + tester, + MockClient( + (request) async => http.Response( + '{"results": {"sunrise": "7:25"}}', + 200, + request: request, + ), + ), + ); + Object? result; + await tester.runAsync( + () => ActionChain.run(() async { + await FunctionsRepository.makeApiRequest( + method: HttpMethod.get, + url: 'https://example.com/sun', + headers: const {}, + body: null, + context: context, + ); + result = ActionChain.current?.result; + }), + ); + expect(result, isA()); + final Map map = result! as Map; + expect(map['isSuccess'], true); + expect(map['statusCode'], 200); + expect((map['data'] as Map)['results'], {'sunrise': '7:25'}); + }); + + testWidgets('A failed API call leaves an error result before it throws', ( + WidgetTester tester, + ) async { + final context = await pumpContext( + tester, + MockClient((request) async => throw http.ClientException('offline')), + ); + Object? result; + await tester.runAsync( + () => ActionChain.run(() async { + try { + await FunctionsRepository.makeApiRequest( + method: HttpMethod.get, + url: 'https://example.com/sun', + headers: const {}, + body: null, + context: context, + ); + } catch (_) {} + result = ActionChain.current?.result; + }), + ); + expect((result! as Map)['isError'], true); + }); +} + +HttpApiData api(String url, List query) => + HttpApiData.fromJson({ + 'name': 'Tides', + 'directory': '', + 'owner': 'o', + 'teams': [], + 'users': [], + 'roles': {}, + 'public': false, + 'url': url, + 'queryParams': [for (final q in query) q.toJson()], + }); + +void queryParamTests() { + testWidgets('An API URL gets its query parameters and inputs', ( + WidgetTester tester, + ) async { + final context = await pumpContext( + tester, + MockClient((request) async => http.Response('', 200)), + ); + final String url = FunctionsRepository.buildApiUrl( + api('https://api.example.com/data?format=json', const [ + HttpKeyValuePair(key: 'station', value: r'${station}'), + HttpKeyValuePair(key: 'interval', value: 'hilo'), + HttpKeyValuePair(key: 'unused', value: 'x', isUsed: false), + HttpKeyValuePair(key: '', value: 'blank key'), + ]), + {'station': '9414131'}, + ScopedValues.of(context), + ); + final Uri uri = Uri.parse(url); + expect(uri.path, '/data'); + expect(uri.queryParameters, { + 'format': 'json', + 'station': '9414131', + 'interval': 'hilo', + }); + }); + + testWidgets('An API without query parameters keeps its URL', ( + WidgetTester tester, + ) async { + final context = await pumpContext( + tester, + MockClient((request) async => http.Response('', 200)), + ); + expect( + FunctionsRepository.buildApiUrl( + api(r'https://x.dev/a?d=${day}', const []), + {'day': '2026-10-22'}, + ScopedValues.of(context), + ), + 'https://x.dev/a?d=2026-10-22', + ); + }); +} diff --git a/test/canvas_location_test.dart b/test/canvas_location_test.dart new file mode 100644 index 00000000..d20b33b6 --- /dev/null +++ b/test/canvas_location_test.dart @@ -0,0 +1,274 @@ +import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:codelessly_sdk/codelessly_sdk.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +class _FakeCache extends Fake implements CacheManager {} + +class _FakeAuthManager extends Fake implements AuthManager { + @override + AuthData? get authData => AuthData( + authToken: 'token', + projectId: 'project', + timestamp: DateTime(2026), + owner: 'owner', + teams: const {}, + users: const {}, + roles: const {}, + ); +} + +class _FakeLocalRepository extends Fake implements LocalDataRepository {} + +class _FakeNetworkRepository extends Fake implements NetworkDataRepository {} + +class _FakeFirestore extends Fake implements FirebaseFirestore {} + +class _FakeLocalDatabase extends Fake implements LocalDatabase {} + +class _TestDataManager extends DataManager { + _TestDataManager(SDKPublishModel model) + : super( + 'test', + config: CodelesslyConfig(authToken: 'token'), + cacheManager: _FakeCache(), + authManager: _FakeAuthManager(), + networkDataRepository: _FakeNetworkRepository(), + localDataRepository: _FakeLocalRepository(), + firebaseFirestore: _FakeFirestore(), + errorLogger: ErrorLogger.instance, + publishModel: model, + ); + + @override + LocalDatabase get localDatabase => _FakeLocalDatabase(); +} + +TextNode _text(String id, String parent, String characters) => TextNode( + id: id, + name: id, + basicBoxLocal: NodeBox(0, 0, 300, 24), + characters: characters, + textMixedProps: [StartEndProp(start: 0, end: characters.length)], +)..parentID = parent; + +ButtonNode _button(String id, String parent, ActionModel action) => ButtonNode( + id: id, + name: id, + basicBoxLocal: NodeBox(0, 0, 160, 44), + properties: ButtonProperties(label: id), + reactions: [ + Reaction( + action: action, + trigger: TriggerModel(type: TriggerType.click), + ), + ], +)..parentID = parent; + +/// A layout of one canvas [id] at [path] that shows [children]. +SDKPublishLayout _screen( + String id, { + String? path, + List children = const [], +}) { + final String body = '${id}Body'; + final List kids = [for (final child in children) child(body)]; + final List nodes = [ + CanvasNode( + id: id, + name: id, + basicBoxLocal: NodeBox(0, 0, 390, 844), + children: [body], + properties: CanvasProperties( + bodyId: body, + safeArea: const SafeAreaModel.all(false), + path: path, + ), + )..parentID = kRootNode, + AutoPlaceholderNode( + id: body, + name: body, + basicBoxLocal: NodeBox(0, 0, 390, 844), + children: [for (final kid in kids) kid.id], + mainAxisAlignment: MainAxisAlignmentC.start, + horizontalFit: SizeFit.expanded, + verticalFit: SizeFit.expanded, + )..parentID = id, + ...kids, + ]; + return SDKPublishLayout( + id: id, + pageId: 'page', + projectId: 'project', + canvases: { + id: {for (final node in nodes) node.id: node}, + }, + lastUpdated: DateTime(2026), + owner: 'owner', + teams: const {}, + users: const {}, + roles: const {}, + public: false, + ); +} + +/// The Tidepool screens: Tides (the entry, no path), the guide, a creature +/// and the journal. +SDKPublishModel _model({ActionModel? tidesAction}) { + final model = SDKPublishModel( + projectId: 'project', + owner: 'owner', + teams: const {}, + users: const {}, + roles: const {}, + public: false, + entryLayoutId: 'tides', + ); + for (final layout in [ + _screen( + 'tides', + children: [ + (body) => _text('tidesTitle', body, 'Tides'), + if (tidesAction != null) (body) => _button('Go', body, tidesAction), + ], + ), + _screen( + 'guide', + path: 'guide', + children: [(body) => _text('guideTitle', body, 'Guide')], + ), + _screen( + 'creature', + path: '/guide/:id/', + children: [ + (body) => _text('creatureId', body, r'id=${route.id}'), + (body) => _text('creatureFrom', body, r'from=${route.from}'), + ], + ), + _screen('newCreature', path: 'guide/new'), + _screen('journal', path: 'journal'), + _screen('settings'), + ]) { + model.layouts[layout.id] = layout; + } + return model; +} + +/// Opens [model]'s app with [pendingRoute], as a site does for a link. +Future _pumpApp( + WidgetTester tester, + SDKPublishModel model, { + CodelesslyRoute? pendingRoute, +}) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(390, 844); + addTearDown(tester.view.reset); + + final codelessly = Codelessly( + config: CodelesslyConfig(authToken: 'token'), + publishDataManager: _TestDataManager(model)..status = CStatus.loaded(), + )..status = CStatus.loaded(); + codelessly.pendingRoute = pendingRoute; + await tester.pumpWidget( + MaterialApp( + home: CodelesslyWidget( + controller: CodelesslyWidgetController(codelessly: codelessly), + ), + ), + ); + await tester.pumpAndSettle(); + return codelessly; +} + +void main() { + group('CanvasLocation', () { + final SDKPublishModel model = _model(); + + test('matches a canvas by its path', () { + final CanvasLocationMatch? match = CanvasLocation.match( + model, + '/guide/bat-star?from=tides&id=ignored', + ); + expect(match?.layoutId, 'creature'); + expect(match?.params, {'id': 'bat-star', 'from': 'tides'}); + expect(CanvasLocation.match(model, 'journal/')?.canvasId, 'journal'); + expect(CanvasLocation.match(model, 'guide')?.canvasId, 'guide'); + }); + + test('prefers literal segments over params', () { + expect(CanvasLocation.match(model, 'guide/new')?.canvasId, 'newCreature'); + }); + + test('matches nothing for the root or an unknown path', () { + expect(CanvasLocation.match(model, ''), isNull); + expect(CanvasLocation.match(model, '/'), isNull); + expect(CanvasLocation.match(model, 'guide/bat-star/more'), isNull); + expect(CanvasLocation.match(model, 'kraken'), isNull); + }); + + test('fills a path with params', () { + expect( + CanvasLocation.locationOf(model, 'creature', { + 'id': 'sea star', + 'from': 'tides', + 'none': null, + }), + 'guide/sea%20star?from=tides', + ); + expect(CanvasLocation.locationOf(model, 'journal', {}), 'journal'); + // The entry screen is at the root. + expect(CanvasLocation.locationOf(model, 'tides', {}), ''); + expect(CanvasLocation.locationOf(model, 'tides', {'a': 1}), '?a=1'); + // No path, or a path param missing. + expect(CanvasLocation.locationOf(model, 'settings', {}), isNull); + expect(CanvasLocation.locationOf(model, 'creature', {}), isNull); + }); + }); + + group('links', () { + testWidgets('a link to a URL path opens that screen with its params', ( + tester, + ) async { + await _pumpApp( + tester, + _model(), + pendingRoute: CodelesslyRoute.tryParse('guide/bat-star?from=link'), + ); + expect(find.text('id=bat-star'), findsOneWidget); + expect(find.text('from=link'), findsOneWidget); + }); + + testWidgets('a link to a screen id still opens it', (tester) async { + await _pumpApp( + tester, + _model(), + pendingRoute: CodelesslyRoute.tryParse('creature?id=kraken'), + ); + expect(find.text('id=kraken'), findsOneWidget); + }); + + testWidgets("the address shows the screen's URL path", (tester) async { + final Codelessly codelessly = await _pumpApp( + tester, + _model( + tidesAction: NavigationAction( + navigationType: NavigationType.push, + destinationId: 'creature', + params: {'id': 'kraken', 'from': 'tides'}, + ), + ), + ); + expect(codelessly.pathOf(codelessly.route.value!), ''); + await tester.tap(find.text('Go')); + await tester.pumpAndSettle(); + expect(find.text('id=kraken'), findsOneWidget); + expect( + codelessly.pathOf(codelessly.route.value!), + 'guide/kraken?from=tides', + ); + // A screen without a URL path falls back to its id. + expect(codelessly.pathOf(const CodelesslyRoute('settings')), 'settings'); + }); + }); +} diff --git a/test/confirm_dialog_test.dart b/test/confirm_dialog_test.dart index 9d21b773..0a00f54c 100644 --- a/test/confirm_dialog_test.dart +++ b/test/confirm_dialog_test.dart @@ -178,4 +178,149 @@ void main() { expect(plain.toJson().containsKey('presentation'), isFalse); expect(LayoutPresentationRoute.defaultSheetWidth, 400); }); + + testWidgets('a styled dialog takes its border, stamped shadows, fonts and ' + 'outlined buttons', (tester) async { + const Color ink = Color(0xFF1F2B2E); + const Color paper = Color(0xFFF3ECDD); + const Color card = Color(0xFFFBF7EE); + const Color inkSoft = Color(0xFF4A5558); + const Color danger = Color(0xFFB3392B); + Map stamp(double d) => { + 'type': 'dropShadow', + 'radius': 0, + 'spread': 0, + 'color': '#1F2B2E', + 'offset': {'x': d, 'y': d}, + }; + final ConfirmAction styled = ActionModel.fromJson({ + 'type': 'confirm', + 'title': 'Delete this sighting?', + 'message': 'It leaves your log for good.', + 'confirmLabel': 'Delete', + 'destructive': true, + 'backgroundColor': '#F3ECDD', + 'textColor': '#1F2B2E', + 'cornerRadius': 6, + 'border': {'color': '#1F2B2E', 'width': 1.5}, + 'shadows': [stamp(6)], + 'titleStyle': { + 'fontFamily': 'Fraunces', + 'fontWeight': 'w600', + 'fontSize': 19, + }, + 'messageStyle': {'fontFamily': 'Work Sans', 'color': '#4A5558'}, + 'buttonStyle': {'fontFamily': 'Work Sans', 'fontWeight': 'w600'}, + 'buttonRadius': 4, + 'buttonBorder': {'color': '#1F2B2E', 'width': 1.5}, + 'buttonShadows': [stamp(3)], + 'confirmColor': '#B3392B', + 'confirmTextColor': '#F3ECDD', + 'cancelColor': '#FBF7EE', + 'cancelTextColor': '#1F2B2E', + }) as ConfirmAction; + + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) => TextButton( + onPressed: () => AppConfirmDialog.show(context, styled), + child: const Text('Open'), + ), + ), + ), + ); + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + + // A paper card with an ink outline and a hard shadow, no elevation. + final Dialog dialog = tester.widget(find.byType(Dialog)); + expect(dialog.elevation, 0); + final BoxDecoration surface = + tester + .widget( + find.byKey(const ValueKey('codelessly-confirm-surface')), + ) + .decoration + as BoxDecoration; + expect(surface.color, paper); + expect(surface.borderRadius, BorderRadius.circular(6)); + expect( + surface.border, + const Border.fromBorderSide(BorderSide(color: ink, width: 1.5)), + ); + expect(surface.boxShadow, const [ + BoxShadow(color: ink, offset: Offset(6, 6)), + ]); + + TextStyle styleOf(String text) => tester + .widget( + find.descendant(of: find.text(text), matching: find.byType(RichText)), + ) + .text + .style!; + String? family(String name, FontWeightNumeric weight) => + TextUtils.retrieveTextStyle( + fontName: FontName(family: name, weight: weight), + ).fontFamily; + final TextStyle title = styleOf('Delete this sighting?'); + expect(title.fontFamily, family('Fraunces', FontWeightNumeric.w600)); + expect(title.fontSize, 19); + expect(title.color, ink); + final TextStyle message = styleOf('It leaves your log for good.'); + expect(message.fontFamily, family('Work Sans', FontWeightNumeric.w400)); + expect(message.color, inkSoft); + expect( + styleOf('Delete').fontFamily, + family('Work Sans', FontWeightNumeric.w600), + ); + + // Both buttons are outlined with a 4 radius; the confirm is danger red + // and the cancel a card, each with a stamped shadow. + final ButtonStyle ok = tester + .widget( + find.byKey(const ValueKey('codelessly-confirm-ok')), + ) + .style!; + final ButtonStyle cancel = tester + .widget( + find.byKey(const ValueKey('codelessly-confirm-cancel')), + ) + .style!; + expect(ok.backgroundColor!.resolve({}), danger); + expect(ok.foregroundColor!.resolve({}), paper); + expect(cancel.backgroundColor!.resolve({}), card); + expect(cancel.foregroundColor!.resolve({}), ink); + for (final ButtonStyle style in [ok, cancel]) { + expect(style.side!.resolve({}), const BorderSide(color: ink, width: 1.5)); + expect( + (style.shape!.resolve({})! as RoundedRectangleBorder).borderRadius, + BorderRadius.circular(4), + ); + } + for (final String key in [ + 'codelessly-confirm-ok', + 'codelessly-confirm-cancel', + ]) { + final BoxDecoration shadow = + tester + .widget( + find + .ancestor( + of: find.byKey(ValueKey(key)), + matching: find.byType(DecoratedBox), + ) + .first, + ) + .decoration + as BoxDecoration; + expect(shadow.boxShadow, const [ + BoxShadow(color: ink, offset: Offset(3, 3)), + ]); + } + + await tester.tap(find.text('Delete')); + await tester.pumpAndSettle(); + expect(find.byType(Dialog), findsNothing); + }); } diff --git a/test/dropdown_menu_test.dart b/test/dropdown_menu_test.dart new file mode 100644 index 00000000..f462ff57 --- /dev/null +++ b/test/dropdown_menu_test.dart @@ -0,0 +1,222 @@ +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:codelessly_sdk/codelessly_sdk.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; + +const Color ink = Color(0xFF1F2B2E); +const Color card = Color(0xFFFBF7EE); +const Color foam = Color(0xFFD4E7DF); +const Color tide = Color(0xFF1F6F78); + +const List> species = [ + {'name': 'Ochre sea star', 'image': 'https://plates.test/ochre.png'}, + {'name': 'Giant green anemone', 'image': 'https://plates.test/anemone.png'}, + {'name': 'Purple urchin', 'image': 'https://plates.test/urchin.png'}, +]; + +/// The Tidepool species menu: a card under the field with an ink outline, +/// a stamped shadow and the selected row on foam with a check mark. +final Map menuStyle = { + 'color': '#FBF7EE', + 'border': {'color': '#1F2B2E', 'width': 1.5}, + 'radius': 4, + 'shadows': [ + { + 'type': 'dropShadow', + 'radius': 0, + 'spread': 0, + 'color': '#1F2B2E', + 'offset': {'x': 4, 'y': 4}, + }, + ], + 'itemHeight': 40, + 'selectedItemColor': '#D4E7DF', + 'selectedTextStyle': {'fontWeight': 'w600'}, + 'showCheck': true, + 'checkColor': '#1F6F78', + 'minWidth': 240, +}; + +DropdownNode dropdown({Map? style, Object? value}) => + DropdownNode( + id: 'species', + name: 'Species', + basicBoxLocal: NodeBox(0, 0, 200, 44), + value: value, + properties: DropdownProperties.fromJson({ + 'items': [], + 'useDataSource': true, + 'itemLabel': r'${item.name}', + 'itemImage': r'${item.image}', + 'itemImageSize': 28, + 'menuStyle': ?style, + }), + )..variables['data'] = 'species'; + +Future> pumpDropdown( + WidgetTester tester, + DropdownNode node, +) async { + final List<(int, Object)> changes = []; + final CodelesslyContext ctx = CodelesslyContext.empty() + ..variables['species'] = Observable( + VariableData( + id: 'species', + name: 'species', + value: species, + type: VariableType.list, + ), + ); + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: ctx, + child: MaterialApp( + home: Scaffold( + body: Align( + alignment: Alignment.topLeft, + child: Padding( + padding: const EdgeInsets.all(20), + child: SizedBox( + width: 200, + height: 44, + child: PassiveDropdownWidget( + node: node, + settings: const WidgetBuildSettings(debugLabel: 'test'), + onChanged: (index, value) => changes.add((index, value)), + ), + ), + ), + ), + ), + ), + ), + ); + return changes; +} + +void main() { + testWidgets('a styled dropdown opens an anchored, stamped menu card', ( + tester, + ) async { + final List<(int, Object)> changes = await pumpDropdown( + tester, + dropdown(style: menuStyle, value: species[1]), + ); + // The field shows the selected item with its plate. + expect(find.text('Giant green anemone'), findsOneWidget); + + await tester.tap(find.byType(PassiveDropdownWidget)); + await tester.pumpAndSettle(); + final Finder menu = find.byKey(const ValueKey('codelessly-dropdown-menu')); + expect(menu, findsOneWidget); + + // Under the field, at least 240 wide. + final Rect field = tester.getRect(find.byType(PassiveDropdownWidget)); + final Rect box = tester.getRect(menu); + expect(box.top, field.bottom + 6); + expect(box.left, field.left); + expect(box.width, 240); + + final BoxDecoration decoration = + tester.widget(menu).decoration! as BoxDecoration; + expect(decoration.color, card); + expect(decoration.borderRadius, BorderRadius.circular(4)); + expect( + decoration.border, + const Border.fromBorderSide(BorderSide(color: ink, width: 1.5)), + ); + expect(decoration.boxShadow, const [ + BoxShadow(color: ink, offset: Offset(4, 4)), + ]); + + // Rows: each with its plate, the selected one on foam, bold, checked. + Finder inMenu(Finder finder) => find.descendant(of: menu, matching: finder); + expect(inMenu(find.text('Ochre sea star')), findsOneWidget); + final List urls = tester + .widgetList(inMenu(find.byType(Image))) + .map((image) => (image.image as NetworkImage).url) + .toList(); + expect(urls, [for (final s in species) s['image']]); + expect( + tester + .getSize( + inMenu(find.byKey(const ValueKey('codelessly-dropdown-item-0'))), + ) + .height, + 40, + ); + + final Material selectedRow = tester.widget( + find + .ancestor( + of: find.byKey(const ValueKey('codelessly-dropdown-item-1')), + matching: find.byType(Material), + ) + .first, + ); + expect(selectedRow.color, foam); + final Material otherRow = tester.widget( + find + .ancestor( + of: find.byKey(const ValueKey('codelessly-dropdown-item-0')), + matching: find.byType(Material), + ) + .first, + ); + expect(otherRow.color, Colors.transparent); + final RichText selectedLabel = tester.widget( + find.descendant( + of: inMenu(find.text('Giant green anemone')), + matching: find.byType(RichText), + ), + ); + expect(selectedLabel.text.style!.fontWeight, FontWeight.w600); + final Icon check = tester.widget( + find.byKey(const ValueKey('codelessly-dropdown-check')), + ); + expect(check.icon, Icons.check); + expect(check.color, tide); + expect( + find.descendant( + of: find.byKey(const ValueKey('codelessly-dropdown-item-1')), + matching: find.byKey(const ValueKey('codelessly-dropdown-check')), + ), + findsOneWidget, + ); + + // Picking a row closes the menu and reports it. + await tester.tap(inMenu(find.text('Purple urchin'))); + await tester.pumpAndSettle(); + expect(menu, findsNothing); + expect(changes.single.$1, 2); + expect(changes.single.$2, species[2]); + + // Tapping outside closes it without a change. + await tester.tap(find.byType(PassiveDropdownWidget)); + await tester.pumpAndSettle(); + expect(menu, findsOneWidget); + await tester.tapAt(const Offset(700, 500)); + await tester.pumpAndSettle(); + expect(menu, findsNothing); + expect(changes, hasLength(1)); + }); + + testWidgets('a dropdown without a menu style keeps the platform menu', ( + tester, + ) async { + await pumpDropdown(tester, dropdown(value: species[0])); + await tester.tap(find.byType(PassiveDropdownWidget)); + await tester.pumpAndSettle(); + expect( + find.byKey(const ValueKey('codelessly-dropdown-menu')), + findsNothing, + ); + // The platform menu lists the items, with their plates. + expect(find.text('Purple urchin'), findsWidgets); + expect( + find.byKey(const ValueKey('codelessly-dropdown-item-image')), + findsWidgets, + ); + }); +} diff --git a/test/dropdown_value_width_test.dart b/test/dropdown_value_width_test.dart new file mode 100644 index 00000000..f9e87868 --- /dev/null +++ b/test/dropdown_value_width_test.dart @@ -0,0 +1,52 @@ +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:codelessly_sdk/codelessly_sdk.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; + +void main() { + testWidgets('an expanded dropdown shows its value across the field, ' + 'whatever its design width', (tester) async { + const String spot = 'Fitzgerald'; + // Laid out at 300 wide, stored with a narrower design box. + final node = DropdownNode( + id: 'spot', + name: 'Spot', + basicBoxLocal: NodeBox(0, 0, 90, 44), + value: spot, + properties: DropdownProperties( + items: const [spot, 'Point Pinos'], + expanded: true, + ), + ); + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: CodelesslyContext.empty(), + child: MaterialApp( + home: Scaffold( + body: Align( + alignment: Alignment.topLeft, + child: SizedBox( + width: 300, + height: 44, + child: PassiveDropdownWidget( + node: node, + settings: const WidgetBuildSettings(debugLabel: 'test'), + ), + ), + ), + ), + ), + ), + ); + final Size text = tester.getSize(find.text(spot).first); + final TextPainter full = TextPainter( + text: TextSpan( + text: spot, + style: tester.widget(find.text(spot).first).style, + ), + textDirection: TextDirection.ltr, + )..layout(); + expect(text.width, greaterThanOrEqualTo(full.width - 0.5)); + }); +} diff --git a/test/feedback_actions_test.dart b/test/feedback_actions_test.dart index 0247d15d..d39f3b9d 100644 --- a/test/feedback_actions_test.dart +++ b/test/feedback_actions_test.dart @@ -197,6 +197,9 @@ Future pumpLayouts( value: CodelesslyContext.empty(layoutID: main.id), ), Provider.value(value: codelessly), + ChangeNotifierProvider.value( + value: controller, + ), ], child: MaterialApp( home: CodelesslyLayoutBuilder( @@ -222,6 +225,58 @@ TextStyle styleOfText(WidgetTester tester, String text) { } void main() { + group('pop', () { + testWidgets('with nothing to go back to goes to its destination', ( + tester, + ) async { + final guide = layoutOf('guide', [ + canvas('guideCanvas', 'guideBody'), + body('guideBody', 'guideCanvas', ['guideText']), + text('guideText', 'guideBody', 'The guide', brandRegular), + ]); + await pumpLayouts(tester, [ + layoutOf( + 'main', + mainScreen( + NavigationAction( + navigationType: NavigationType.pop, + destinationId: 'guideCanvas', + ), + ), + ), + guide, + ]); + expect(find.byType(CodelesslyWidget), findsNothing); + await tester.tap(find.text('Go')); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + // The main screen was replaced by the guide's. + expect(find.text('Your album'), findsNothing); + expect( + tester + .widget(find.byType(CodelesslyWidget)) + .controller + ?.layoutID, + 'guide', + ); + }); + + testWidgets('without a destination does nothing at the first screen', ( + tester, + ) async { + await pumpLayouts(tester, [ + layoutOf( + 'main', + mainScreen(NavigationAction(navigationType: NavigationType.pop)), + ), + ]); + await tester.tap(find.text('Go')); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + expect(find.text('Your album'), findsOneWidget); + }); + }); + group('FeedbackTheme', () { test('reads the font, primary, surface and text color of a layout', () { final FeedbackTheme theme = FeedbackTheme.fromNodes( @@ -235,6 +290,57 @@ void main() { expect(theme.buttonRadius, 6); }); + test('reads the outline and hard shadow most tappable shapes share', () { + RectangleNode stamped(String id, {bool outlined = true}) => RectangleNode( + id: id, + name: id, + basicBoxLocal: NodeBox(0, 0, 160, 44), + fills: [paint(brandPrimary)], + strokes: [if (outlined) paint(brandText)], + strokeWeight: outlined ? 1.5 : 0, + effects: [ + Effect.dropShadow( + id: 'stamp', + radius: 0, + spread: 0, + color: brandText, + offset: const Vec(3, 3), + ), + ], + reactions: [ + Reaction( + action: ShowToastAction(message: 'x'), + trigger: TriggerModel(type: TriggerType.click), + ), + ], + )..parentID = 'mainBody'; + + final FeedbackTheme theme = FeedbackTheme.fromNodes([ + ...mainScreen(ShowToastAction(message: 'x')), + stamped('a'), + stamped('b'), + stamped('c', outlined: false), + ]); + expect( + theme.buttonBorder, + BorderSide(color: brandText.toFlutterColor(), width: 1.5), + ); + expect(theme.buttonShadows, [ + BoxShadow( + color: brandText.toFlutterColor(), + offset: const Offset(3, 3), + ), + ]); + expect(theme.border, isNull); + + // A layout whose buttons have none keeps plain buttons. + final FeedbackTheme plain = FeedbackTheme.fromNodes( + mainScreen(ShowToastAction(message: 'x')), + ); + expect(plain.buttonBorder, isNull); + expect(plain.buttonShadows, isNull); + }); + test('the app\'s extension wins over the layout', () { final FeedbackTheme layout = FeedbackTheme.fromNodes( mainScreen(ShowToastAction(message: 'x')), diff --git a/test/fixtures/day_strip_grid.json b/test/fixtures/day_strip_grid.json new file mode 100644 index 00000000..2a8bd657 --- /dev/null +++ b/test/fixtures/day_strip_grid.json @@ -0,0 +1 @@ +{"0SfOJNhvYxUvAF6ovb6w": {"variables": {"data": "${= map(range(0, 6), i => formatDate(addDays(formatDate(now(sunDays[0].offset), 'yyyy-MM-dd'), i), 'yyyy-MM-dd'))}"}, "id": "0SfOJNhvYxUvAF6ovb6w", "name": "Day strip", "horizontalFit": "expanded", "verticalFit": "shrinkWrap", "basicBoxLocal": [40, 138, 1104, 87], "margin": [0, 20, 0, 0], "children": ["0SfOJNhv3Sjvz06eUrBb"], "allowedTypes": [], "deniedTypes": ["gridView"], "isScrollable": false, "useFlutterListView": false, "type": "gridView", "properties": {"gridDelegate": {"crossAxisSpacing": 7, "mainAxisExtent": 87, "crossAxisCount": 7, "type": "fixedCrossAxisCount"}}}, "0SfOJNhv3Sjvz06eUrBb": {"id": "0SfOJNhv3Sjvz06eUrBb", "name": "Day cell holder", "horizontalFit": "expanded", "verticalFit": "expanded", "reactions": [{"name": "Tap", "action": {"type": "setVariable", "variable": {"id": "0SfNPIrIeU9ATTBR1dsp", "name": "selectedDay"}, "newValue": "${item}"}, "trigger": {"type": "click"}, "id": "0SfOJNiHYwCnjABRyvma"}], "basicBoxLocal": [0, 0, 151.714, 87], "children": ["0SfOJNhuBhTxoaJNoRE9", "0SfOJNhvCIoovS8unN6r"], "type": "frame"}, "0SfOJNhuBhTxoaJNoRE9": {"variables": {"visible": "${= selectedDay == item}"}, "id": "0SfOJNhuBhTxoaJNoRE9", "name": "Rectangle", "edgePins": {"left": 3, "top": 3, "right": 0, "bottom": 0}, "positioningMode": "pin", "basicBoxLocal": [3, 3, 148.714, 84], "fills": [{"id": "p288", "type": "solid", "color": "#D9573B"}], "cornerRadius": [4, true, "circular"], "type": "rectangle"}, "0SfOJNhvCIoovS8unN6r": {"variables": {"fill-p286": "${= selectedDay == item ? '#1F2B2E' : '#FBF7EE'}"}, "id": "0SfOJNhvCIoovS8unN6r", "name": "Day cell", "edgePins": {"left": 0, "top": 0, "right": 3, "bottom": 3}, "positioningMode": "pin", "basicBoxLocal": [0, 0, 148.714, 84], "padding": [0, 10], "children": ["0SfOJNhulkVxCHU9Y2eE", "0SfOJNhuD2OGWfkLrY_o", "0SfOJNhv6HHFBqroJZz3", "0SfOJNhvYnpLzJFoVBdp"], "fills": [{"id": "p286", "type": "solid", "color": "#FBF7EE"}], "strokes": [{"id": "p287", "type": "solid", "color": "#1F2B2E"}], "strokeWeight": 1.5, "cornerRadius": [4, true, "circular"], "rowColumnType": "column", "mainAxisAlignment": "start", "type": "rowColumn"}, "0SfOJNhulkVxCHU9Y2eE": {"variables": {"textColor": "${= selectedDay == item ? '#9CC5B5' : '#4A5558'}"}, "id": "0SfOJNhulkVxCHU9Y2eE", "name": "${= item == formatDate(now(sunDays[0].of\u2026", "horizontalFit": "shrinkWrap", "verticalFit": "shrinkWrap", "characters": "${= item == formatDate(now(sunDays[0].offset), 'yyyy-MM-dd') ? 'TODAY' : upper(formatDate(item, 'EEE'))}", "textMixedProps": [{"fills": [{"id": "p281", "type": "solid", "color": "#1F2B2E"}], "fontSize": 10, "letterSpacing": {"value": 1.2}, "fontName": {"family": "IBM Plex Mono", "style": "Medium", "weight": "w500"}, "lineHeight": {"value": 14}, "start": 0, "end": 104}], "basicBoxLocal": [56.357, 10, 36, 14], "type": "text"}, "0SfOJNhuD2OGWfkLrY_o": {"variables": {"textColor": "${= selectedDay == item ? '#F3ECDD' : '#1F2B2E'}"}, "id": "0SfOJNhuD2OGWfkLrY_o", "name": "${= day(item)}", "horizontalFit": "shrinkWrap", "verticalFit": "shrinkWrap", "characters": "${= day(item)}", "textMixedProps": [{"fills": [{"id": "p282", "type": "solid", "color": "#1F2B2E"}], "fontSize": 22, "letterSpacing": {"value": -0.2}, "fontName": {"family": "Fraunces", "style": "SemiBold", "weight": "w600"}, "lineHeight": {"value": 28}, "start": 0, "end": 14}], "basicBoxLocal": [61.544, 26, 25.626, 28], "margin": [0, 2, 0, 0], "type": "text"}, "0SfOJNhv6HHFBqroJZz3": {"variables": {"visible": "${= find(days, d => d.date == item).lowest != null}"}, "id": "0SfOJNhv6HHFBqroJZz3", "name": "Row Column", "horizontalFit": "shrinkWrap", "basicBoxLocal": [64.457, 56, 19.8, 16], "margin": [0, 2, 0, 0], "children": ["0SfOJNhviuRCJI6AxerP", "0SfOJNhvse2Ai7eqH4Q0"], "rowColumnType": "row", "type": "rowColumn"}, "0SfOJNhviuRCJI6AxerP": {"variables": {"visible": "${= length(find(days, d => d.date == item).windows) > 0}"}, "id": "0SfOJNhviuRCJI6AxerP", "name": "Row Column", "basicBoxLocal": [-5.5, 4.5, 7, 7], "margin": [0, 0, 4, 0], "fills": [{"id": "p283", "type": "solid", "color": "#D9573B"}], "cornerRadius": [4, true, "circular"], "rowColumnType": "column", "mainAxisAlignment": "start", "crossAxisAlignment": "start", "type": "rowColumn"}, "0SfOJNhvse2Ai7eqH4Q0": {"variables": {"textColor": "${= selectedDay == item ? '#F3ECDD' : '#4A5558'}"}, "id": "0SfOJNhvse2Ai7eqH4Q0", "name": "${= (storage.units == 'm' ? replace(fixe\u2026", "horizontalFit": "shrinkWrap", "verticalFit": "shrinkWrap", "characters": "${= (storage.units == 'm' ? replace(fixed((find(days, d => d.date == item).lowest.v) * 0.3048, 2), '-0.00', '0.00') : replace(fixed(find(days, d => d.date == item).lowest.v, 1), '-0.0', '0.0'))}", "textMixedProps": [{"fills": [{"id": "p284", "type": "solid", "color": "#1F2B2E"}], "fontSize": 11, "fontName": {"family": "IBM Plex Mono", "style": "Medium", "weight": "w500"}, "lineHeight": {"value": 18}, "start": 0, "end": 194}], "basicBoxLocal": [0, -1, 19.8, 18], "type": "text"}, "0SfOJNhvYnpLzJFoVBdp": {"variables": {"visible": "${= find(days, d => d.date == item).lowest == null}"}, "id": "0SfOJNhvYnpLzJFoVBdp", "name": "Rectangle", "basicBoxLocal": [59.357, 60, 30, 8], "margin": [0, 6, 0, 2], "fills": [{"id": "p285", "type": "solid", "color": "#E9DFCB"}], "cornerRadius": [2, true, "circular"], "type": "rectangle"}} \ No newline at end of file diff --git a/test/flex_layout_test.dart b/test/flex_layout_test.dart index 4e2353d5..90787eba 100644 --- a/test/flex_layout_test.dart +++ b/test/flex_layout_test.dart @@ -162,6 +162,26 @@ Future everywhere( } void main() { + testWidgets( + 'a row aligned to baselines lays out (it needs a text baseline)', + (tester) async { + final a = rect('a', '', width: 40, height: 30); + final b = rect('b', '', width: 40, height: 10); + final row = flex( + 'row', + kRootNode, + [a, b], + type: RowColumnType.row, + width: 200, + height: 40, + crossAxisAlignment: CrossAxisAlignmentC.baseline, + ); + await everywhere(tester, row, [a, b], (sizes, screen, host) { + expect(sizes['a'], const Size(40, 30)); + }); + }, + ); + testWidgets('a shrinkWrap row with an expanded child inside a row (E1, A7)', ( tester, ) async { @@ -806,4 +826,117 @@ void main() { }, decorator: decorator); }); } + + group('a squeezable shrinkWrap child (loose Flexible)', () { + TextNode label(String id, String characters, {bool squeezable = false}) => + TextNode( + id: id, + name: id, + basicBoxLocal: NodeBox(0, 0, 120, 10), + horizontalFit: SizeFit.shrinkWrap, + verticalFit: SizeFit.shrinkWrap, + characters: characters, + maxLines: 1, + overflow: TextOverflowC.ellipsis, + textMixedProps: [ + StartEndProp(start: 0, end: characters.length, fontSize: 10), + ], + )..squeezable = squeezable; + + RenderParagraph paragraphOf(WidgetTester tester, String characters) => + tester.renderObject( + find.byWidgetPredicate( + (widget) => + widget is RichText && widget.text.toPlainText() == characters, + ), + ); + + // A name and its count in a 200 wide row; the test font is 10 px a + // character. + Future pumpRow(WidgetTester tester, String characters, Size screen) { + final name = label('name', characters, squeezable: true); + final count = label('count', 'x3'); + final row = flex( + 'row', + kRootNode, + [name, count], + type: RowColumnType.row, + width: 200, + height: 10, + ); + return pump(tester, row, [name, count], screen: screen, host: Host.loose); + } + + for (final screen in screens) { + testWidgets('hugs a short name and ellipsizes a long one at ' + '${screen.width.toInt()}', (tester) async { + await pumpRow(tester, 'Ann', screen); + final RenderParagraph short = paragraphOf(tester, 'Ann'); + expect(short.size.width, 30); + expect(short.didExceedMaxLines, isFalse); + expect( + tester.getTopLeft(find.text('x3')).dx, + tester.getTopRight(find.text('Ann')).dx, + ); + + const String long = 'Bartholomew Montgomeryfitzgerald'; + await pumpRow(tester, long, screen); + final RenderParagraph name = paragraphOf(tester, long); + // The name gives up what the count needs, and ellipsizes. + expect(name.size.width, 180); + expect(name.didExceedMaxLines, isTrue); + expect(paragraphOf(tester, 'x3').size.width, 20); + expect( + tester.getTopRight(find.text('x3')).dx, + tester.getTopRight(rectOf('row')).dx, + ); + }); + } + + // A dialog body: a header and a card that is as tall as its content, up + // to the height the column has left. + testWidgets('a card hugs its content up to the room left', (tester) async { + for (final int items in [1, 6]) { + final header = rect('header', '', width: 300, height: 50); + final children = [ + for (int i = 0; i < items; i++) + rect('item$i', '', width: 300, height: 100), + ]; + final card = flex( + 'card', + '', + children, + type: RowColumnType.column, + width: 300, + height: 100, + verticalFit: SizeFit.shrinkWrap, + isScrollable: true, + )..squeezable = true; + final column = flex( + 'column', + kRootNode, + [header, card], + type: RowColumnType.column, + width: 300, + height: 400, + ); + for (final screen in screens) { + final sizes = await pump( + tester, + column, + [header, card, ...children], + screen: screen, + host: Host.loose, + ); + expect(sizes['column'], const Size(300, 400)); + expect( + sizes['card'], + Size(300, items == 1 ? 100 : 350), + reason: '$items items at $screen', + ); + expect(sizes['item0'], const Size(300, 100)); + } + } + }); + }); } diff --git a/test/formula_clock_test.dart b/test/formula_clock_test.dart new file mode 100644 index 00000000..3e70f08a --- /dev/null +++ b/test/formula_clock_test.dart @@ -0,0 +1,25 @@ +import 'package:codelessly_sdk/codelessly_sdk.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + tearDown(() => FormulaClock.pinned = null); + + String eval(String formula) => FormulaEvaluator.stringify( + FormulaEvaluator.evaluate(formula, (path) => null), + ); + + test('a pinned clock is what now() and today() read', () { + FormulaClock.pinned = DateTime.parse('2026-10-22T13:20:00-07:00'); + expect( + eval("formatDate(now('-07:00'), 'EEE, MMM d · h:mm a')"), + 'Thu, Oct 22 · 1:20 PM', + ); + expect(eval("formatDate(today('-07:00'), 'yyyy-MM-dd')"), '2026-10-22'); + expect(eval("formatDate(now('Z'), 'HH:mm')"), '20:20'); + }); + + test('without a pin, now() is the device clock', () { + final String year = '${DateTime.now().year}'; + expect(eval("formatDate(now(), 'yyyy')"), year); + }); +} diff --git a/test/formula_maps_text_test.dart b/test/formula_maps_text_test.dart index 73220076..030a649d 100644 --- a/test/formula_maps_text_test.dart +++ b/test/formula_maps_text_test.dart @@ -52,9 +52,9 @@ void main() { group('lists', () { test('range counts up to, not including, the end', () { expect(eval('range(4)'), [0, 1, 2, 3]); - expect(eval('range(2, 5)'), [2, 3, 4]); + expect(eval('range(2, 5)'), [2, 3, 4, 5]); expect(eval('range(0, 10, 3)'), [0, 3, 6, 9]); - expect(eval('range(3, 0)'), [3, 2, 1]); + expect(eval('range(3, 0)'), [3, 2, 1, 0]); expect(eval('range(0)'), isEmpty); }); diff --git a/test/formula_math_test.dart b/test/formula_math_test.dart index dbfc8cb5..16a06b65 100644 --- a/test/formula_math_test.dart +++ b/test/formula_math_test.dart @@ -123,8 +123,8 @@ void main() { test('range', () { expect(eval('range(4)'), [0, 1, 2, 3]); - expect(eval('range(0, 70, 10)'), [0, 10, 20, 30, 40, 50, 60]); - expect(eval('range(3, 0)'), [3, 2, 1]); + expect(eval('range(0, 70, 10)'), [0, 10, 20, 30, 40, 50, 60, 70]); + expect(eval('range(3, 0)'), [3, 2, 1, 0]); expect(eval('range(1, 5, 0)'), isEmpty); }); diff --git a/test/formula_series_test.dart b/test/formula_series_test.dart new file mode 100644 index 00000000..0a8d580f --- /dev/null +++ b/test/formula_series_test.dart @@ -0,0 +1,185 @@ +import 'package:codelessly_sdk/codelessly_sdk.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Evaluates [expression] with [vars] as its variables. +Object? eval(String expression, [Map vars = const {}]) => + FormulaEvaluator.evaluate(expression, (path) => vars[path]); + +void main() { + // Half-hourly tide heights, NOAA style. + final List> tides = [ + {'t': '2026-10-22 14:00', 'v': '2.0'}, + {'t': '2026-10-22 14:30', 'v': '1.4'}, + {'t': '2026-10-22 15:00', 'v': '0.8'}, + {'t': '2026-10-22 15:30', 'v': '0.6'}, + {'t': '2026-10-22 16:00', 'v': '1.2'}, + {'t': '2026-10-22 16:30', 'v': '1.8'}, + ]; + + group('text', () { + test('substring clamps and defaults its end', () { + expect(eval('substring("2026-10-22 02:58", 0, 10)'), '2026-10-22'); + expect(eval('substring("abc", 1)'), 'bc'); + expect(eval('substring("abc", 2, 99)'), 'c'); + }); + + test('replace and split', () { + expect(eval('replace("a-b-c", "-", " ")'), 'a b c'); + expect(eval('split("a,b,c", ",")'), ['a', 'b', 'c']); + }); + }); + + group('lists', () { + test('slice takes a range, negative from the end', () { + expect(eval('slice([1, 2, 3, 4], 1, 3)'), [2, 3]); + expect(eval('slice([1, 2, 3, 4], -2)'), [3, 4]); + expect(eval('slice([1, 2], 5)'), isEmpty); + }); + + test('range counts numbers through the end', () { + expect(eval('range(0, 6, 2)'), [0, 2, 4, 6]); + expect(eval('range(-1, 1)'), [-1, 0, 1]); + expect(eval('range(3, 1, -1)'), [3, 2, 1]); + expect(eval('range(0, 1, 0)'), isEmpty); + }); + + test('range steps dates by minutes', () { + expect(eval('range("2026-10-22T07:25:00", "2026-10-22T07:40:00", 5)'), [ + '2026-10-22T07:25:00.000', + '2026-10-22T07:30:00.000', + '2026-10-22T07:35:00.000', + '2026-10-22T07:40:00.000', + ]); + }); + + test('runs groups consecutive matches', () { + final Object? r = eval('runs([1, 3, 4, 0, 5, 6, 7], n => n > 2)'); + expect(r, [ + { + 'first': 3, + 'last': 4, + 'items': [3, 4], + 'count': 2, + }, + { + 'first': 5, + 'last': 7, + 'items': [5, 6, 7], + 'count': 3, + }, + ]); + }); + + test('minBy and maxBy return the item', () { + expect(eval('minBy(tides, "v")', {'tides': tides}), { + 't': '2026-10-22 15:30', + 'v': '0.6', + }); + expect( + eval('maxBy(tides, t => number(t.v)).t', {'tides': tides}), + '2026-10-22 14:00', + ); + }); + + test('obj builds a map', () { + expect(eval('obj("from", 1, "to", "b")'), {'from': 1, 'to': 'b'}); + expect(eval('map([1, 2], n => obj("x", n * 2))'), [ + {'x': 2}, + {'x': 4}, + ]); + }); + }); + + group('interpolate', () { + test('reads between two points of a date series', () { + expect( + eval('interpolate(tides, "t", "v", "2026-10-22T14:15")', { + 'tides': tides, + }), + closeTo(1.7, 1e-9), + ); + expect( + eval('interpolate(tides, "t", "v", "2026-10-22 15:30")', { + 'tides': tides, + }), + closeTo(0.6, 1e-9), + ); + }); + + test('is null outside the series', () { + expect( + eval('interpolate(tides, "t", "v", "2026-10-22T13:00")', { + 'tides': tides, + }), + isNull, + ); + }); + + test('works on numbers', () { + expect( + eval( + 'interpolate([obj("x", 0, "y", 0), obj("x", 10, "y", 5)], "x", "y", 4)', + ), + closeTo(2, 1e-9), + ); + }); + }); + + group('time zones', () { + test('wallTime keeps the clock time of an offset date-time', () { + expect( + eval('wallTime("2026-10-22T07:25:17-07:00")'), + '2026-10-22T07:25:17.000', + ); + expect(eval('wallTime("2026-10-22 18:23")'), '2026-10-22T18:23:00.000'); + }); + + test('now and today take a UTC offset', () { + final DateTime utc = DateTime.now().toUtc(); + final DateTime minus7 = DateTime.parse(eval('now("-07:00")')! as String); + final DateTime expected = utc.subtract(const Duration(hours: 7)); + expect(minus7.isUtc, false); + expect( + DateTime.utc( + minus7.year, + minus7.month, + minus7.day, + minus7.hour, + minus7.minute, + ) + .difference( + DateTime.utc( + expected.year, + expected.month, + expected.day, + expected.hour, + expected.minute, + ), + ) + .inMinutes + .abs(), + lessThanOrEqualTo(1), + ); + expect(eval('now(5.5)'), isA()); + expect((eval('today(-7)')! as String).endsWith('T00:00:00.000'), true); + }); + }); + + test( + 'a daylight pooling window composes from range, interpolate and runs', + () { + // Five-minute steps from "sunrise" to "sunset", kept where the tide is at + // or below 1.0 ft, as the reference app's pooling windows. + final Object? windows = eval( + 'filter(runs(map(range("2026-10-22T14:00", "2026-10-22T16:30", 5), ' + 's => obj("t", s, "h", interpolate(tides, "t", "v", s))), ' + 's => s.h <= 1), r => diffMinutes(r.first.t, r.last.t) >= 20)', + {'tides': tides}, + ); + expect(windows, hasLength(1)); + final Map window = (windows! as List).single as Map; + expect((window['first'] as Map)['t'], '2026-10-22T14:50:00.000'); + expect((window['last'] as Map)['t'], '2026-10-22T15:50:00.000'); + }, + ); +} diff --git a/test/grid_cell_stretch_test.dart b/test/grid_cell_stretch_test.dart new file mode 100644 index 00000000..9b5766e4 --- /dev/null +++ b/test/grid_cell_stretch_test.dart @@ -0,0 +1,59 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'responsive_variance_test.dart' show pumpPage; + +void main() { + testWidgets('day strip grid cells fill their columns', (tester) async { + final Map raw = jsonDecode( + File('test/fixtures/day_strip_grid.json').readAsStringSync(), + ); + final nodes = { + for (final e in raw.entries) + e.key as String: NodeJsonConverter().fromJson(e.value as Map)!, + }; + for (final node in nodes.values) { + for (final child in node.childrenOrEmpty) { + nodes[child]!.parentID = node.id; + } + } + const gridId = '0SfOJNhvYxUvAF6ovb6w'; + nodes['canvas'] = CanvasNode( + id: 'canvas', + name: 'canvas', + basicBoxLocal: NodeBox(0, 0, 1104, 400), + children: ['body'], + properties: CanvasProperties( + bodyId: 'body', + safeArea: const SafeAreaModel.all(false), + ), + )..parentID = kRootNode; + nodes['body'] = AutoPlaceholderNode( + id: 'body', + name: 'body', + basicBoxLocal: NodeBox(0, 0, 1104, 400), + children: [gridId], + )..parentID = 'canvas'; + nodes[gridId]! + ..parentID = 'body' + ..variables.clear(); + // Seven items without a data source. + final grid = nodes[gridId]! as GridViewNode; + grid.properties.itemCount = 7; + nodes['0SfOJNhuBhTxoaJNoRE9']!.variables.clear(); + await pumpPage(tester, nodes, screen: const Size(1104, 400)); + expect(tester.takeException(), isNull); + // The coral block behind each cell is pinned 3 px in on the top left. + final block = nodes['0SfOJNhuBhTxoaJNoRE9']!; + final blocks = find.byKey( + ValueKey('Rectangle Transformer of ${block.debugLabel}'), + ); + expect(blocks, findsWidgets); + // (1104 - 6 * 7) / 7 = 151.7 per column, less the 3 px offset. + expect(tester.getSize(blocks.first).width, closeTo(148.7, 1)); + }); +} diff --git a/test/image_saturation_test.dart b/test/image_saturation_test.dart new file mode 100644 index 00000000..ad755e5d --- /dev/null +++ b/test/image_saturation_test.dart @@ -0,0 +1,25 @@ +import 'package:codelessly_sdk/codelessly_sdk.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Applies a 4x5 color matrix to an RGB color (alpha 1). +List apply(List m, List rgb) => [ + for (int row = 0; row < 3; row++) + m[row * 5] * rgb[0] + m[row * 5 + 1] * rgb[1] + m[row * 5 + 2] * rgb[2], +]; + +void main() { + test('saturation 0 turns colors into grey of the same luminance', () { + final grey = apply(saturationMatrix(0), [1, 0, 0]); + for (final channel in grey) { + expect(channel, closeTo(0.2126, 1e-9)); + } + final white = apply(saturationMatrix(0), [1, 1, 1]); + for (final channel in white) { + expect(channel, closeTo(1, 1e-9)); + } + }); + + test('saturation 1 keeps colors', () { + expect(apply(saturationMatrix(1), [0.2, 0.5, 0.9]), [0.2, 0.5, 0.9]); + }); +} diff --git a/test/line_chart_test.dart b/test/line_chart_test.dart new file mode 100644 index 00000000..ba923f87 --- /dev/null +++ b/test/line_chart_test.dart @@ -0,0 +1,392 @@ +import 'dart:convert'; +import 'dart:ui'; + +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:codelessly_sdk/codelessly_sdk.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; + +/// Half-hourly tide heights on 2026-10-22, from midnight to midnight. +final List> tides = [ + for (int i = 0; i <= 48; i++) + { + 't': + '2026-10-22 ${(i ~/ 2 % 24).toString().padLeft(2, '0')}:' + '${i.isOdd ? '30' : '00'}', + 'ft': i <= 24 ? i / 4 : (48 - i) / 4, + }, +]..last['t'] = '2026-10-23 00:00'; + +double ms(String date) => LineChartGeometry.parseX(date)!; + +LineChartNode chart({ + Map variables = const {}, + LineChartProperties? properties, +}) => LineChartNode( + id: 'chart', + name: 'Chart', + basicBoxLocal: NodeBox(0, 0, 400, 240), + properties: properties, + variables: {...variables}, +); + +VariableData listVariable(String name, List value) => VariableData( + id: name, + name: name, + value: jsonEncode(value), + type: VariableType.list, +); + +Future pumpChart( + WidgetTester tester, + LineChartNode node, { + List variables = const [], +}) async { + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: CodelesslyContext.empty(), + child: MaterialApp( + home: Scaffold( + body: Align( + alignment: Alignment.topLeft, + child: PassiveLineChartWidget( + node: node, + settings: const WidgetBuildSettings(debugLabel: 'test'), + variablesOverrides: variables, + ), + ), + ), + ), + ), + ); +} + +LineChartPainter painterOf(WidgetTester tester) => tester + .widgetList(find.byType(CustomPaint)) + .map((paint) => paint.painter) + .whereType() + .single; + +void main() { + group('LineChartGeometry', () { + test('reads numbers, numeric strings and wall-clock dates', () { + expect(LineChartGeometry.parseX(3), 3); + expect(LineChartGeometry.parseX('2.5'), 2.5); + final double space = ms('2026-10-22 00:30'); + expect(space, DateTime.utc(2026, 10, 22, 0, 30).millisecondsSinceEpoch); + expect(ms('2026-10-22T00:30:00'), space); + expect(LineChartGeometry.dateOf(space).hour, 0); + expect(LineChartGeometry.dateOf(space).minute, 30); + expect(LineChartGeometry.parseX('soon'), isNull); + expect(LineChartGeometry.isDate('2026-10-22 00:30'), isTrue); + expect(LineChartGeometry.isDate('12'), isFalse); + }); + + test('reads and sorts points, skipping invalid items', () { + final points = LineChartGeometry.points( + [ + {'x': 2, 'y': '4'}, + {'x': 1, 'y': 1}, + {'x': 'nope', 'y': 1}, + {'x': 3}, + 'junk', + ], + 'x', + 'y', + ); + expect(points, [const Offset(1, 1), const Offset(2, 4)]); + }); + + test('domain defaults to the data extent and takes overrides', () { + final points = [const Offset(0, -2), const Offset(10, 6)]; + expect(LineChartGeometry.domain(points), const ChartDomain(0, 10, -2, 6)); + expect( + LineChartGeometry.domain(points, yMin: -3, xMax: 12), + const ChartDomain(0, 12, -3, 6), + ); + expect( + LineChartGeometry.domain([const Offset(1, 1)]), + const ChartDomain(0.5, 1.5, 0.5, 1.5), + ); + }); + + test('interpolates linearly and holds the ends', () { + final points = [ + const Offset(0, 0), + const Offset(10, 10), + const Offset(20, 0), + ]; + expect(LineChartGeometry.interpolate(points, 5), 5); + expect(LineChartGeometry.interpolate(points, 15), 5); + expect(LineChartGeometry.interpolate(points, -1), 0); + expect(LineChartGeometry.interpolate(points, 25), 0); + expect(LineChartGeometry.interpolate(const [], 1), isNull); + }); + + test('Catmull-Rom control points clamp at the ends', () { + final points = [ + const Offset(0, 0), + const Offset(6, 6), + const Offset(12, 0), + ]; + final segments = LineChartGeometry.catmullRom(points); + expect(segments, hasLength(2)); + // p0 is clamped to p1 for the first segment: c1 = p1 + (p2 - p1) / 6. + expect(segments[0].c1, const Offset(1, 1)); + // c2 = p2 - (p3 - p1) / 6 = (6, 6) - (12, 0) / 6. + expect(segments[0].c2, const Offset(4, 6)); + expect(segments[0].end, const Offset(6, 6)); + // The last segment's p3 is clamped to p2. + expect(segments[1].c2, const Offset(11, 1)); + expect(segments[1].end, const Offset(12, 0)); + }); + + test('maps data to pixels inside the plot', () { + final plot = LineChartGeometry.plotRect( + const Size(400, 240), + left: 44, + top: 30, + right: 14, + bottom: 30, + ); + expect(plot, const Rect.fromLTRB(44, 30, 386, 210)); + final scale = ChartScale(const ChartDomain(0, 10, 0, 6), plot); + expect(scale.toPixel(const Offset(0, 0)), const Offset(44, 210)); + expect(scale.toPixel(const Offset(10, 6)), const Offset(386, 30)); + expect(scale.xAt(44 + 342 / 2), 5); + expect(scale.xAt(-100), 0); + }); + + test('formats the readout for a date axis', () { + final points = LineChartGeometry.points(tides, 't', 'ft'); + final domain = LineChartGeometry.domain(points); + final (String x, String y) = LineChartGeometry.readout( + points, + ms('2026-10-22 04:30'), + domain: domain, + isDate: true, + xFormat: 'h:mm a', + ySuffix: ' ft', + trend: true, + ); + expect(x, '4:30 AM'); + expect(y, '2.3 ft ↑'); + + final (String x2, String y2) = LineChartGeometry.readout( + points, + ms('2026-10-22 16:15'), + domain: domain, + isDate: true, + xFormat: 'EEE h:mm a', + yScale: 0.3048, + yDecimals: 2, + ySuffix: ' m', + trend: true, + ); + expect(x2, 'Thu 4:15 PM'); + // 16:15 is 32.5 half hours, (48 - 32.5) / 4 = 3.875 ft = 1.18 m. + expect(y2, '1.18 m ↓'); + }); + + test('formats number axes and places the readout card', () { + expect(LineChartGeometry.formatX(3, isDate: false), '3'); + expect(LineChartGeometry.formatX(2.5, isDate: false), '2.5'); + const plot = Rect.fromLTRB(44, 30, 386, 210); + expect( + LineChartGeometry.readoutRect( + const Offset(100, 150), + const Size(80, 40), + plot, + ), + const Rect.fromLTWH(112, 98, 80, 40), + ); + // Flipped near the right edge and clamped to the plot's top. + expect( + LineChartGeometry.readoutRect( + const Offset(350, 40), + const Size(80, 40), + plot, + ), + const Rect.fromLTWH(258, 30, 80, 40), + ); + }); + + test('reads colors with opacity and dash patterns', () { + final color = LineChartGeometry.parseColor('#2A3A44', 0.08)!; + expect(color.a, closeTo(0.08, 0.001)); + expect((color.r * 255).round(), 0x2A); + expect(LineChartGeometry.parseColor('#80FF0000')!.a, closeTo(0.5, 0.01)); + expect(LineChartGeometry.parseColor('nope'), isNull); + expect(LineChartGeometry.parseDash([4, 2], (5, 5)), (4.0, 2.0)); + expect(LineChartGeometry.parseDash(null, (5, 5)), (5.0, 5.0)); + }); + }); + + group('PassiveLineChartWidget', () { + testWidgets('renders bound points, lists and domain overrides', ( + tester, + ) async { + await pumpChart( + tester, + chart( + properties: LineChartProperties( + xField: 't', + yField: 'ft', + smooth: true, + ), + variables: { + 'data': r'${tides}', + 'yMin': r'${= -1}', + 'yMax': r'${= 7}', + 'xMax': r'${= "2026-10-23 00:00"}', + 'markers': r'${markers}', + 'verticalLines': r'${now}', + }, + ), + variables: [ + listVariable('tides', tides), + listVariable('markers', [ + {'x': '2026-10-22 12:00', 'y': 6, 'label': 'H 6.0', 'above': true}, + ]), + listVariable('now', [ + {'x': '2026-10-22 09:00', 'label': 'NOW', 'dotOnCurve': true}, + ]), + ], + ); + expect(tester.takeException(), isNull); + final spec = painterOf(tester).spec; + expect(spec.points, hasLength(49)); + expect(spec.isDate, isTrue); + expect( + spec.domain, + ChartDomain(ms('2026-10-22 00:00'), ms('2026-10-23 00:00'), -1, 7), + ); + expect(spec.markers.single.label, 'H 6.0'); + expect(spec.verticalLines.single.dotOnCurve, isTrue); + expect(spec.valueAt(ms('2026-10-22 09:00')), 4.5); + }); + + testWidgets('binds the data list from a formula', (tester) async { + await pumpChart( + tester, + chart( + variables: { + 'data': r'${= filter(points, p => p.y > 1)}', + 'lineColor': r'${= "#FF0000"}', + }, + ), + variables: [ + listVariable('points', [ + {'x': 1, 'y': 0}, + {'x': 2, 'y': 3}, + {'x': 3, 'y': 5}, + {'x': 4, 'y': 2}, + ]), + ], + ); + expect(tester.takeException(), isNull); + final spec = painterOf(tester).spec; + expect(spec.points, [ + const Offset(2, 3), + const Offset(3, 5), + const Offset(4, 2), + ]); + expect(spec.isDate, isFalse); + expect(spec.domain, const ChartDomain(2, 4, 2, 5)); + expect(spec.lineColor.toARGB32(), 0xFFFF0000); + }); + + testWidgets('scrubs with hover and arrow keys and reads out the value', ( + tester, + ) async { + final node = chart( + properties: LineChartProperties( + xField: 't', + yField: 'ft', + scrubEnabled: true, + scrubStep: 30, + tooltipYSuffix: ' ft', + tooltipTrend: true, + semanticLabel: 'Tide chart', + ), + variables: {'data': r'${tides}'}, + ); + await pumpChart(tester, node, variables: [listVariable('tides', tides)]); + expect(painterOf(tester).cursorX, isNull); + + final gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(location: Offset.zero); + addTearDown(gesture.removePointer); + // Halfway across the plot (44 to 386) is noon. + await gesture.moveTo(const Offset(44 + 342 / 2, 120)); + await tester.pump(); + expect(painterOf(tester).cursorX, ms('2026-10-22 12:00')); + expect( + tester.getSemantics(find.bySemanticsLabel('Tide chart')), + isSemantics(label: 'Tide chart', value: 'Cursor 12:00 PM, 6.0 ft'), + ); + + // Tap to focus, then step 30 minutes with the arrow keys. + await gesture.removePointer(); + await tester.tapAt(const Offset(44 + 342 / 4, 120)); + await tester.pump(); + expect(painterOf(tester).cursorX, ms('2026-10-22 06:00')); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.pump(); + final double cursor = painterOf(tester).cursorX!; + expect(cursor, ms('2026-10-22 06:30')); + expect(painterOf(tester).spec.readout(cursor), ('6:30 AM', '3.3 ft ↑')); + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pump(); + expect(painterOf(tester).cursorX, isNull); + }); + + testWidgets('builds through the passive transformer manager', ( + tester, + ) async { + final node = LineChartNode.empty(); + 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: Builder( + builder: (context) => manager.buildWidgetFromNode( + node, + context, + settings: const WidgetBuildSettings(debugLabel: 'test'), + ), + ), + ), + ), + ); + expect(tester.takeException(), isNull); + expect(find.byType(PassiveLineChartWidget), findsOneWidget); + }); + }); + + testWidgets('the axis title and readout units can be bound', (tester) async { + await pumpChart( + tester, + chart( + variables: { + 'yAxisTitle': r"${= 1 > 0 ? 'M' : 'FT'}", + 'tooltipYScale': r'${= 0.3048}', + 'tooltipYDecimals': r'${= 2}', + 'tooltipYSuffix': r"${= ' m'}", + }, + ), + ); + final spec = painterOf(tester).spec; + expect(spec.yAxisTitle, 'M'); + expect(spec.tooltipYScale, 0.3048); + expect(spec.tooltipYDecimals, 2); + expect(spec.tooltipYSuffix, ' m'); + }); +} diff --git a/test/load_api_order_test.dart b/test/load_api_order_test.dart new file mode 100644 index 00000000..9aa3ce16 --- /dev/null +++ b/test/load_api_order_test.dart @@ -0,0 +1,158 @@ +import 'dart:convert'; + +import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:codelessly_sdk/codelessly_sdk.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; + +class _FakeCache extends Fake implements CacheManager {} + +class _FakeAuthManager extends Fake implements AuthManager { + @override + AuthData? get authData => AuthData( + authToken: 'token', + projectId: 'project', + timestamp: DateTime(2026), + owner: 'owner', + teams: const {}, + users: const {}, + roles: const {}, + ); +} + +class _FakeLocalRepository extends Fake implements LocalDataRepository {} + +class _FakeNetworkRepository extends Fake implements NetworkDataRepository {} + +class _FakeFirestore extends Fake implements FirebaseFirestore {} + +class _FakeLocalDatabase extends Fake implements LocalDatabase {} + +class _TestDataManager extends DataManager { + _TestDataManager(SDKPublishModel model, CodelesslyConfig config) + : super( + 'test', + config: config, + cacheManager: _FakeCache(), + authManager: _FakeAuthManager(), + networkDataRepository: _FakeNetworkRepository(), + localDataRepository: _FakeLocalRepository(), + firebaseFirestore: _FakeFirestore(), + errorLogger: ErrorLogger.instance, + publishModel: model, + ); + + @override + LocalDatabase get localDatabase => _FakeLocalDatabase(); +} + +Reaction _onLoad(ActionModel action) => Reaction( + name: action.type.name, + trigger: TriggerModel(type: TriggerType.load), + action: action, +); + +CallFunctionAction _record(String what) => + CallFunctionAction(name: 'record', params: {'what': what}); + +/// A canvas whose load actions are: record "before", call an API (recording +/// "success" when it answers), record "after". +Map _page() => { + 'canvas': CanvasNode( + id: 'canvas', + name: 'canvas', + basicBoxLocal: NodeBox(0, 0, 390, 844), + children: ['body'], + properties: CanvasProperties( + bodyId: 'body', + safeArea: const SafeAreaModel.all(false), + ), + reactions: [ + _onLoad(_record('before')), + _onLoad(ApiCallAction(apiId: 'sun', onSuccess: [_record('success')])), + _onLoad(_record('after')), + ], + )..parentID = kRootNode, + 'body': AutoPlaceholderNode( + id: 'body', + name: 'body', + basicBoxLocal: NodeBox(0, 0, 390, 844), + children: const [], + )..parentID = 'canvas', +}; + +void main() { + testWidgets('A canvas runs its load-time API call in chain order', ( + tester, + ) async { + final List calls = []; + final model = SDKPublishModel( + projectId: 'project', + owner: 'owner', + teams: const {}, + users: const {}, + roles: const {}, + public: false, + ); + final layout = SDKPublishLayout.private( + id: 'layout', + projectId: 'project', + pageId: 'page', + lastUpdated: DateTime(2026), + canvases: {'canvas': _page()}, + breakpoints: [const Breakpoint(nodeId: 'canvas')], + privacy: model, + ); + model.layouts[layout.id] = SDKPublishLayout.fromJson( + jsonDecode(jsonEncode(layout.toJson())) as Map, + ); + model.apis['sun'] = HttpApiData( + id: 'sun', + name: 'Sun times', + directory: '', + url: 'https://example.com/sun', + owner: 'owner', + teams: const {}, + users: const {}, + roles: const {}, + public: false, + ); + final config = CodelesslyConfig(authToken: 'token', isPreview: true); + final codelessly = Codelessly( + config: config, + client: MockClient((request) async { + calls.add('request'); + return http.Response('{"sunrise": "7:25"}', 200, request: request); + }), + publishDataManager: _TestDataManager(model, config) + ..status = CStatus.loaded(), + previewDataManager: _TestDataManager(model, config) + ..status = CStatus.loaded(), + )..status = CStatus.loaded(); + + await tester.runAsync(() async { + await tester.pumpWidget( + MaterialApp( + home: CodelesslyWidget( + codelessly: codelessly, + layoutID: 'layout', + functions: { + 'record': (context, reference, params) => + calls.add('${params['what']}'), + }, + ), + ), + ); + for (int i = 0; i < 10 && calls.length < 4; i++) { + await tester.pump(); + await Future.delayed(const Duration(milliseconds: 20)); + } + }); + + expect(tester.takeException(), isNull); + expect(calls, ['before', 'request', 'success', 'after']); + }); +} diff --git a/test/responsive_variance_test.dart b/test/responsive_variance_test.dart index a002803b..a82a2ce2 100644 --- a/test/responsive_variance_test.dart +++ b/test/responsive_variance_test.dart @@ -1,5 +1,6 @@ import 'package:codelessly_api/codelessly_api.dart'; import 'package:codelessly_sdk/codelessly_sdk.dart'; +import 'package:codelessly_sdk/src/ui/codelessly_dialog_widget.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:provider/provider.dart'; @@ -139,6 +140,79 @@ Future pumpPage( await tester.pump(); } +/// Shows [nodes]' canvas the way the SDK's show-dialog action does: a +/// [CodelesslyDialogWidget] in a dismissible dialog, on a screen of [screen]. +Future showPageInDialog( + WidgetTester tester, + Map nodes, { + required Size screen, +}) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = screen; + addTearDown(tester.view.reset); + final registry = NodeRegistry()..setNodes(nodes); + final manager = PassiveNodeTransformerManager( + registry.getNodeByID, + (context, bounds, pageID, layoutID, canvasID) => const SizedBox(), + ); + // A fresh app, without a dialog left open by an earlier call. + await tester.pumpWidget(const SizedBox()); + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: CodelesslyContext.empty(), + child: MaterialApp( + home: Scaffold( + body: Builder( + builder: (context) => TextButton( + onPressed: () => showDialog( + context: context, + useRootNavigator: false, + builder: (context) => CodelesslyDialogWidget( + showCloseButton: false, + builder: (context) => manager.buildWidgetFromNode( + nodes['canvas']!, + context, + settings: const WidgetBuildSettings(debugLabel: 'test'), + ), + ), + ), + child: const Text('open'), + ), + ), + ), + ), + ), + ); + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); +} + +/// [responsivePage] as a sheet: full screen on mobile, a centered 560 wide +/// card on tablet and desktop. +Map responsiveSheet() { + final nodes = responsivePage(); + // No background: the barrier shows around the card. + (nodes['canvas']! as CanvasNode).fills = []; + for (final breakpoint in [ + ScreenBreakpoint.tablet, + ScreenBreakpoint.desktop, + ]) { + final root = nodes['${breakpoint.name}-root']! as RowColumnNode; + nodes[root.id] = RowColumnNode( + id: root.id, + name: root.name, + basicBoxLocal: NodeBox(0, 0, 560, 400), + rowColumnType: RowColumnType.column, + mainAxisAlignment: MainAxisAlignmentC.start, + crossAxisAlignment: CrossAxisAlignmentC.stretch, + alignment: AlignmentModel.center, + fills: [PaintModel.whitePaint], + children: root.children, + )..parentID = root.parentID; + } + return nodes; +} + /// A variant's root and its rectangles. Set idsOf(ScreenBreakpoint breakpoint, int count) => { '${breakpoint.name}-root', @@ -286,4 +360,55 @@ void main() { expect(tester.takeException(), isNull); expect(builtRectangles(nodes), idsOf(ScreenBreakpoint.tablet, 3)); }); + + testWidgets('a dialog with a responsive layout follows the screen', ( + tester, + ) async { + for (final screen in screens) { + final nodes = responsiveSheet(); + await showPageInDialog(tester, nodes, screen: screen); + + expect(tester.takeException(), isNull); + final ScreenBreakpoint breakpoint = ScreenBreakpoint.forWidth( + screen.width, + ); + final int count = switch (breakpoint) { + ScreenBreakpoint.mobile => 2, + ScreenBreakpoint.tablet => 3, + ScreenBreakpoint.desktop => 4, + }; + expect(builtRectangles(nodes), idsOf(breakpoint, count)); + // The canvas fills the screen; each variant places itself in it: the + // mobile sheet fills it, the card is centered. + expect(tester.getSize(rectOf(nodes['canvas']!)), screen); + final Rect root = tester.getRect( + rectOf(nodes['${breakpoint.name}-root']!), + ); + if (breakpoint == ScreenBreakpoint.mobile) { + expect(root, Offset.zero & screen); + } else { + expect(root.size, const Size(560, 400)); + expect(root.center, screen.center(Offset.zero)); + } + } + }); + + testWidgets('a dialog with a responsive layout closes on a barrier tap', ( + tester, + ) async { + final nodes = responsiveSheet(); + await showPageInDialog(tester, nodes, screen: const Size(1440, 900)); + expect(builtRectangles(nodes), idsOf(ScreenBreakpoint.desktop, 4)); + + // Inside the card, below its rectangles, the dialog stays open. + await tester.tapAt(const Offset(720, 600)); + await tester.pumpAndSettle(); + expect(builtRectangles(nodes), idsOf(ScreenBreakpoint.desktop, 4)); + + // Outside of it, the empty canvas lets the tap through to the barrier. + await tester.tapAt(const Offset(100, 100)); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + expect(builtRectangles(nodes), isEmpty); + }); } diff --git a/test/semantics_test.dart b/test/semantics_test.dart index e9432117..74ed5c7c 100644 --- a/test/semantics_test.dart +++ b/test/semantics_test.dart @@ -26,13 +26,15 @@ Future pumpNode(WidgetTester tester, BaseNode node) async { 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'), + home: Material( + child: Align( + alignment: Alignment.topLeft, + child: Builder( + builder: (context) => manager.buildWidgetFromNode( + node, + context, + settings: const WidgetBuildSettings(debugLabel: 'test'), + ), ), ), ), @@ -42,6 +44,101 @@ Future pumpNode(WidgetTester tester, BaseNode node) async { } void main() { + testWidgets('a semanticLabel formula names a node and replaces its text', ( + tester, + ) async { + final SemanticsHandle handle = tester.ensureSemantics(); + final node = tappable('day', 'Day') + ..variables['semanticLabel'] = + r'Sat, Oct 24${= 1 < 2 ? ", good pooling" : ""}' + ..variables['semanticSelected'] = r'${= 2 > 1}'; + await pumpNode(tester, node); + expect( + tester.getSemantics(find.bySemanticsLabel('Sat, Oct 24, good pooling')), + matchesSemantics( + label: 'Sat, Oct 24, good pooling', + isButton: true, + hasTapAction: true, + hasSelectedState: true, + isSelected: true, + ), + ); + // On a button with text, the label replaces the text (as the reference + // app's buttons read) and the button keeps its tap. + final withText = TextNode.fromJson({ + 'id': 'label', + 'name': 'Label', + 'type': 'text', + 'basicBoxLocal': [0, 0, 40, 20], + 'characters': '24', + 'textMixedProps': [ + {'start': 0, 'end': 2}, + ], + })..parentID = 'chip'; + final chip = + RowColumnNode( + id: 'chip', + name: 'Chip', + basicBoxLocal: NodeBox(0, 0, 60, 30), + children: ['label'], + rowColumnType: RowColumnType.row, + reactions: [ + Reaction( + action: LinkAction(url: ''), + trigger: TriggerModel(type: TriggerType.click), + ), + ], + ) + ..parentID = kRootNode + ..variables['semanticLabel'] = 'Saturday'; + final registry = NodeRegistry() + ..setNodes({'chip': chip, 'label': withText}); + 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( + chip, + context, + settings: const WidgetBuildSettings(debugLabel: 'test'), + ), + ), + ), + ), + ), + ); + expect( + tester.getSemantics(find.bySemanticsLabel('Saturday')), + matchesSemantics(label: 'Saturday', isButton: true, hasTapAction: true), + ); + expect(find.bySemanticsLabel(RegExp('24')), findsNothing); + handle.dispose(); + }); + + testWidgets('a semanticLabel on a node that does not react adds a label', ( + tester, + ) async { + final SemanticsHandle handle = tester.ensureSemantics(); + final node = + RectangleNode( + id: 'reading', + name: 'Reading', + basicBoxLocal: NodeBox(0, 0, 40, 40), + ) + ..parentID = kRootNode + ..variables['semanticLabel'] = 'Tide now 2.0 ft, falling'; + await pumpNode(tester, node); + expect(find.bySemanticsLabel('Tide now 2.0 ft, falling'), findsOneWidget); + handle.dispose(); + }); + testWidgets('a tappable node without text reads as a named button', ( tester, ) async { @@ -75,4 +172,71 @@ void main() { 'Add to bag', ); }); + + testWidgets('a semanticLabel on a field keeps the field', (tester) async { + final SemanticsHandle handle = tester.ensureSemantics(); + final node = TextFieldNode( + id: 'search', + name: 'Search', + basicBoxLocal: NodeBox(0, 0, 300, 48), + properties: TextFieldProperties(), + )..parentID = kRootNode; + node.variables['semanticLabel'] = 'Search creatures'; + await pumpNode(tester, node); + expect(tester.takeException(), isNull); + final data = tester + .getSemantics(find.bySemanticsLabel(RegExp('^Search creatures'))) + .getSemanticsData(); + expect(data.flagsCollection.isTextField, isTrue); + handle.dispose(); + }); + + testWidgets('a labelled button runs its reactions from its semantics tap', ( + tester, + ) async { + final SemanticsHandle handle = tester.ensureSemantics(); + final List calls = []; + final node = + RectangleNode( + id: 'settings', + name: 'Settings', + basicBoxLocal: NodeBox(0, 0, 40, 40), + reactions: [ + Reaction( + action: CallFunctionAction(name: 'record'), + trigger: TriggerModel(type: TriggerType.click), + ), + ], + ) + ..parentID = kRootNode + ..variables['semanticLabel'] = 'Settings'; + 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() + ..functions['record'] = (context, reference, params) => + calls.add('tap'), + child: MaterialApp( + home: Align( + alignment: Alignment.topLeft, + child: Builder( + builder: (context) => manager.buildWidgetFromNode( + node, + context, + settings: const WidgetBuildSettings(debugLabel: 'test'), + ), + ), + ), + ), + ), + ); + tester.semantics.tap(find.semantics.byLabel('Settings')); + await tester.pump(); + expect(calls, ['tap']); + handle.dispose(); + }); } diff --git a/test/text_color_binding_test.dart b/test/text_color_binding_test.dart new file mode 100644 index 00000000..841c2016 --- /dev/null +++ b/test/text_color_binding_test.dart @@ -0,0 +1,67 @@ +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'; + +TextNode text(Map variables) => TextNode.fromJson({ + 'id': 'text', + 'name': 'Label', + 'type': 'text', + 'basicBoxLocal': [0, 0, 120, 20], + 'characters': 'Mid zone', + 'textMixedProps': [ + { + 'start': 0, + 'end': 8, + 'fills': [ + {'id': 'f', 'type': 'solid', 'color': '#111111'}, + ], + }, + ], +})..variables.addAll(variables); + +Future> pumpText(WidgetTester tester, TextNode node) async { + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: CodelesslyContext.empty(), + child: MaterialApp( + home: Center( + child: PassiveTextWidget( + node: node, + settings: const WidgetBuildSettings(debugLabel: 'test'), + ), + ), + ), + ), + ); + final RichText rich = tester.widget(find.byType(RichText).first); + final List colors = []; + rich.text.visitChildren((span) { + if (span is TextSpan && span.text != null) colors.add(span.style?.color); + return true; + }); + return colors; +} + +void main() { + testWidgets('A textColor formula recolors every span of a text', ( + WidgetTester tester, + ) async { + final colors = await pumpText( + tester, + text({'textColor': r'${= 1 < 2 ? "#F3ECDD" : "#1F2B2E"}'}), + ); + expect(colors, isNotEmpty); + for (final c in colors) { + expect(c?.toARGB32(), 0xFFF3ECDD); + } + }); + + testWidgets('A text without a textColor binding keeps its span colors', ( + WidgetTester tester, + ) async { + final colors = await pumpText(tester, text({})); + expect(colors.single?.toARGB32(), isNot(0xFFF3ECDD)); + }); +} diff --git a/test/toast_test.dart b/test/toast_test.dart index 940850e8..5001d5df 100644 --- a/test/toast_test.dart +++ b/test/toast_test.dart @@ -65,4 +65,227 @@ void main() { await tester.pump(const Duration(milliseconds: 300)); expect(find.text('Second'), findsNothing); }); + + group('a styled toast', () { + const Color ink = Color(0xFF1F2B2E); + const Color paper = Color(0xFFF3ECDD); + const Color seaGlass = Color(0xFF9CC5B5); + const Color coral = Color(0xFFD9573B); + + ShowToastAction styled() => ActionModel.fromJson({ + 'type': 'showToast', + 'message': 'Ochre sea star added', + 'actionLabel': 'View', + 'duration': 4000, + 'backgroundColor': '#1F2B2E', + 'textColor': '#F3ECDD', + 'actionColor': '#9CC5B5', + 'showClose': true, + 'cornerRadius': 4, + 'border': {'color': '#1F2B2E', 'width': 1.5}, + 'shadows': [ + { + 'type': 'dropShadow', + 'radius': 0, + 'spread': 0, + 'color': '#1F2B2E', + 'offset': {'x': 4, 'y': 4}, + }, + ], + 'maxWidth': 480, + 'messageStyle': { + 'fontFamily': 'Work Sans', + 'fontWeight': 'w600', + 'fontSize': 13, + }, + 'actionStyle': {'fontFamily': 'Work Sans', 'fontWeight': 'w600'}, + 'icon': { + 'type': 'icon', + 'show': true, + 'color': '#D9573B', + 'icon': { + 'type': 'CUPERTINO_ICON', + 'name': 'star_fill', + 'codepoint': 0xf4b8, + 'fontFamily': 'CupertinoIcons', + }, + }, + 'iconSize': 34, + 'bottomOffset': 24, + 'bottomOffsets': {'mobile': 84}, + }) as ShowToastAction; + + Future pumpHost(WidgetTester tester, Size size) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = size; + addTearDown(tester.view.reset); + late BuildContext context; + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: CodelesslyContext.empty(), + child: MaterialApp( + home: Builder( + builder: (c) { + context = c; + return const SizedBox(); + }, + ), + ), + ), + ); + return context; + } + + TextStyle styleOf(WidgetTester tester, String text) => tester + .widget( + find.descendant(of: find.text(text), matching: find.byType(RichText)), + ) + .text + .style!; + + testWidgets('takes its colors, fonts, radius, border, shadow and icon', ( + tester, + ) async { + final BuildContext context = await pumpHost(tester, const Size(390, 844)); + AppToast.show(context, styled()); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + await tester.pump(const Duration(milliseconds: 300)); + + final Container box = tester.widget( + find.byKey(const ValueKey('codelessly-toast')), + ); + final BoxDecoration decoration = box.decoration! as BoxDecoration; + expect(decoration.color, ink); + expect(decoration.borderRadius, BorderRadius.circular(4)); + expect( + decoration.border, + const Border.fromBorderSide(BorderSide(color: ink, width: 1.5)), + ); + // The shadow is drawn around the clip that rounds the toast. + final BoxDecoration outer = + tester + .widget( + find + .ancestor( + of: find.byKey(const ValueKey('codelessly-toast')), + matching: find.byType(DecoratedBox), + ) + .first, + ) + .decoration + as BoxDecoration; + expect(outer.boxShadow, const [ + BoxShadow(color: ink, offset: Offset(4, 4)), + ]); + expect(box.constraints!.maxWidth, 480); + + final String? workSans = TextUtils.retrieveTextStyle( + fontName: const FontName( + family: 'Work Sans', + weight: FontWeightNumeric.w600, + ), + ).fontFamily; + final TextStyle message = styleOf(tester, 'Ochre sea star added'); + expect(message.color, paper); + expect(message.fontSize, 13); + expect(message.fontFamily, workSans); + // The button has its own color, not the message's. + final TextStyle action = styleOf(tester, 'View'); + expect(action.color, seaGlass); + expect(action.fontFamily, workSans); + + // The icon takes its own color and the toast's icon size. + final SvgIcon icon = tester.widget( + find.descendant( + of: find.byKey(const ValueKey('codelessly-toast')), + matching: find.byType(SvgIcon), + ), + ); + expect(icon.color, coral); + expect(icon.size, 34); + + // Above a mobile tab bar. + expect( + tester.getRect(find.byKey(const ValueKey('codelessly-toast'))).bottom, + 844 - 84, + ); + + // The close button dismisses it. + final Finder close = find.byKey(const ValueKey('codelessly-toast-close')); + expect(tester.widget(close).color, paper); + await tester.tap(close); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + expect(find.text('Ochre sea star added'), findsNothing); + }); + + testWidgets('sits at the offset of the screen size', (tester) async { + final BuildContext context = await pumpHost( + tester, + const Size(1440, 900), + ); + AppToast.show(context, styled()); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + await tester.pump(const Duration(milliseconds: 300)); + expect( + tester.getRect(find.byKey(const ValueKey('codelessly-toast'))).bottom, + 900 - 24, + ); + AppToast.hide(); + await tester.pumpAndSettle(); + }); + }); + + testWidgets('a toast button can be tapped from the first frame', ( + tester, + ) async { + final SemanticsHandle handle = tester.ensureSemantics(); + late BuildContext context; + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: CodelesslyContext.empty(), + child: MaterialApp( + home: Builder( + builder: (c) { + context = c; + return const SizedBox(); + }, + ), + ), + ), + ); + FunctionsRepository.triggerAction( + context, + TriggerType.click, + reactions: [ + Reaction( + action: ShowToastAction( + message: 'Sighting deleted', + actionLabel: 'Undo', + actions: [ShowToastAction(message: 'Restored')], + ), + trigger: TriggerModel(type: TriggerType.click), + ), + ], + ); + // Its semantics don't change once it shows (the web engine keeps a new + // node's stacking only while its siblings stay the same). + await tester.pump(); + expect( + tester.getSemantics(find.bySemanticsLabel('Undo')), + matchesSemantics( + label: 'Undo', + isButton: true, + hasTapAction: true, + hasEnabledState: true, + isEnabled: true, + isFocusable: true, + hasFocusAction: true, + ), + ); + await tester.pumpAndSettle(const Duration(seconds: 6)); + handle.dispose(); + }); } diff --git a/test/transformers_test.dart b/test/transformers_test.dart index 1f7bf55a..982749c2 100644 --- a/test/transformers_test.dart +++ b/test/transformers_test.dart @@ -23,6 +23,7 @@ List standaloneNodes() => [ ), ), ProgressBarNode.empty(), + LineChartNode.empty(), RadioNode.empty(), SliderNode.empty(), SwitchNode.empty(),