diff --git a/CHANGELOG.md b/CHANGELOG.md index 555bdd98..a5673fc6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,46 @@ +## 8.4.0 + +### Layout + +- Charts: `ChartNode` renders as a line/area chart; tapping or dragging + snaps the selection, writes the bound variable and fires `changed`. +- A list that doesn't scroll, hugs its items and has a known item count + lays them out without a viewport, so a stretched row of cards keeps + equal heights with a list in one of them; with `wrap` its items flow onto + runs. +- A flexible row or column that scrolls sizes to its content, up to the + space left. +- Text span ranges are clamped to the text. + +### Bindings + +- `cornerRadius`, `padding` and `strokeWeight` can be bound to data like + fill, opacity and size. +- Bind an icon's glyph by name (`icon`); `materialIconVersions` gives the + SVG version of each Material icon. +- Variable overrides are looked up by name. + +### Overlays and actions + +- Side sheets (`DialogPresentation.sideSheet`), dialogs kept `insets` from + the screen edges, and a maximum height as a share of the screen + (`maxHeightFactor`) for dialogs and bottom sheets; a canvas that hugs its + content gives a content-height sheet or dialog. +- The rest of an action chain runs after a replace navigation. +- Batch cloud writes: `removeDocuments` and `setDocuments` commit in + Firestore batches of up to 500. +- Toasts: action color, close button, corner radius, font size, family and weight and a bound + offset; without their own font they use the app's text style. +- Text fields use their decoration's `contentPadding`. + +### Publishing and design canvas + +- Navigation, dialogs and links resolve a canvas on any published page + (`layoutCanvases` index); publishing one page keeps the other pages' + fonts and APIs. +- List items on the design canvas read their own element of the list's + data (`VariablePreview.scopeFor`). + ## 8.3.0 Requires codelessly_api 6.2.0. diff --git a/lib/codelessly_sdk.dart b/lib/codelessly_sdk.dart index ffaef123..116758b7 100644 --- a/lib/codelessly_sdk.dart +++ b/lib/codelessly_sdk.dart @@ -46,6 +46,7 @@ export 'src/ui/zoomable_box.dart'; export 'src/ui/skeleton.dart'; export 'src/ui/stripe_painter.dart'; export 'src/utils/cupertino_icons_by_codepoint.dart'; +export 'src/utils/material_icon_versions.dart'; export 'src/utils/extensions.dart'; export 'src/utils/flutter_icons_data_map.dart'; export 'src/utils/flutter_icons_names_map.dart'; diff --git a/lib/src/data/cloud_database.dart b/lib/src/data/cloud_database.dart index b4d109e9..c961b051 100644 --- a/lib/src/data/cloud_database.dart +++ b/lib/src/data/cloud_database.dart @@ -132,6 +132,69 @@ abstract class CloudDatabase extends ChangeNotifier { /// it will return true. Future removeDocument(String path, String documentId); + /// The most writes Firestore takes in one batch. + static const int maxBatchWrites = 500; + + /// Removes many documents of the collection at [path] at once and returns + /// how many were removed. + /// + /// Removes the documents with [documentIds] when given; otherwise those + /// matching [filters], or every document of the collection when there are + /// none. Implementations remove them in as few writes as they can (see + /// [FirestoreCloudDatabase.removeDocuments]); this default removes the + /// [documentIds] one by one and can't query. + Future removeDocuments( + String path, { + Iterable? documentIds, + List filters = const [], + }) async { + if (documentIds == null) { + throw UnsupportedError('$runtimeType can only remove documents by id.'); + } + int removed = 0; + for (final String id in documentIds) { + if (id.trim().isEmpty) continue; + if (await removeDocument(path, id.trim())) removed++; + } + return removed; + } + + /// Writes [documents] to the collection at [path] at once and returns how + /// many were written. + /// + /// Each document's id is its [idField]; a document without one gets a + /// generated id. A document replaces the stored one unless [merge] is + /// true. With [replaceCollection], the collection's other documents are + /// removed, so it holds exactly [documents] (restoring seed data). + /// + /// This default writes them one by one and can't [replaceCollection]; + /// see [FirestoreCloudDatabase.setDocuments]. + Future setDocuments( + String path, + List> documents, { + String idField = 'id', + bool merge = false, + bool replaceCollection = false, + }) async { + if (replaceCollection) { + throw UnsupportedError('$runtimeType cannot replace a collection.'); + } + int written = 0; + for (final Map document in documents) { + final String id = document[idField]?.toString().trim() ?? ''; + final bool ok = merge && id.isNotEmpty + ? await updateDocument(path, documentId: id, value: document) + : await addDocument( + path, + documentId: id.isEmpty ? generateId() : id, + skipCreationIfDocumentExists: false, + value: document, + ); + if (ok) written++; + } + return written; + } + /// Retrieves the document data from the cloud storage at the given [path] /// with the given [documentId]. /// @@ -483,6 +546,135 @@ class FirestoreCloudDatabase extends CloudDatabase { return true; } + /// Removes the documents in as few batched writes as Firestore allows + /// ([CloudDatabase.maxBatchWrites] deletes each). Without [documentIds], + /// the documents to remove are read first: those matching [filters] (run + /// in the app past their `==` filters, so no index is needed), and in an + /// owner-only collection only the signed-in user's. + @override + Future removeDocuments( + String path, { + Iterable? documentIds, + List filters = const [], + }) async { + DebugLogger.instance.printFunction( + 'removeDocuments(path: $path, ids: $documentIds, filters: $filters)', + name: name, + ); + final CollectionReference> collection = + getCollectionPath(path); + final List>> refs = + documentIds != null + ? [ + for (final String id in { + for (final String id in documentIds) + if (id.trim().isNotEmpty) id.trim(), + }) + collection.doc(id), + ] + : [ + for (final doc in await _matchingDocuments(path, filters)) + doc.reference, + ]; + await _commitInBatches([ + for (final ref in refs) (WriteBatch batch) => batch.delete(ref), + ], 'cloudDatabase/removeDocuments'); + return refs.length; + } + + /// Writes the documents in as few batched writes as Firestore allows + /// ([CloudDatabase.maxBatchWrites] each), applying the collection's rules + /// to each like [addDocument] does. + @override + Future setDocuments( + String path, + List> documents, { + String idField = 'id', + bool merge = false, + bool replaceCollection = false, + }) async { + DebugLogger.instance.printFunction( + 'setDocuments(path: $path, count: ${documents.length}, ' + 'merge: $merge, replaceCollection: $replaceCollection)', + name: name, + ); + final CollectionReference> collection = + getCollectionPath(path); + final String? uid = currentUserId(); + final Set written = {}; + final List writes = []; + for (final Map document in documents) { + final String id = document[idField]?.toString().trim() ?? ''; + final DocumentReference> ref = id.isEmpty + ? collection.doc() + : collection.doc(id); + written.add(ref.id); + Map data = rules.prepareWrite(path, document, uid); + data = sanitizeCloudDataToSend(data, docId: ref.id, keepCreatedAt: merge); + final Map resolved = resolveFieldOperations(data); + writes.add( + (WriteBatch batch) => + batch.set(ref, resolved, SetOptions(merge: merge)), + ); + } + if (replaceCollection) { + for (final doc in await _matchingDocuments(path, const [])) { + if (written.contains(doc.id)) continue; + writes.add((WriteBatch batch) => batch.delete(doc.reference)); + } + } + await _commitInBatches(writes, 'cloudDatabase/setDocuments'); + return documents.length; + } + + /// The documents of the collection at [path] that pass [filters] and the + /// collection's read rules for the signed-in user. + Future>>> _matchingDocuments( + String path, + List filters, + ) async { + final List? readFilters = rules.readFilters( + path, + currentUserId(), + ); + if (readFilters == null) return []; + final CloudQueryPlan plan = CloudQuery( + filters: [...readFilters, ...filters], + ).plan(forceClient: true); + final QuerySnapshot> snapshot = await plan + .applyTo(getCollectionPath(path)) + .get(); + StatTracker.instance.track(StatType.read, 'cloudDatabase/batchQuery'); + return plan.finish( + snapshot.docs, + data: (doc) => doc.data(), + id: (doc) => doc.id, + ); + } + + /// Commits [writes] in batches of at most [CloudDatabase.maxBatchWrites]. + Future _commitInBatches( + List writes, + String stat, + ) async { + for ( + int start = 0; + start < writes.length; + start += CloudDatabase.maxBatchWrites + ) { + final WriteBatch batch = firestore.batch(); + final int end = (start + CloudDatabase.maxBatchWrites).clamp( + 0, + writes.length, + ); + for (final write in writes.sublist(start, end)) { + write(batch); + } + await batch.commit(); + StatTracker.instance.track(StatType.write, stat); + } + } + @override Future> getDocumentData( String path, diff --git a/lib/src/functions/action_chain.dart b/lib/src/functions/action_chain.dart index 748a6c95..56d6381a 100644 --- a/lib/src/functions/action_chain.dart +++ b/lib/src/functions/action_chain.dart @@ -25,6 +25,10 @@ import '../transformers/utils/scoped_values.dart'; /// removed its document, the remaining actions run with the first host /// still mounted, so toasts, dialogs and navigation still happen. See /// [contextFor]. +/// * the screen a `replace` navigation of the chain put in place of the +/// trigger's: its [ActionChainHost] joins the chain ([ActionChainAdopter]), +/// so the actions after the navigation run there once the old screen is +/// gone. /// /// Something the chain registers to run later, such as a toast's Undo /// button, runs on the same chain through [defer]. @@ -39,7 +43,8 @@ class ActionChain { this.scopedValues, this._context, this._hosts = const [], - }); + List? adopted, + }) : _adopted = adopted ?? []; /// Creates a chain for a trigger that fired in [context], capturing its /// scope and hosts. @@ -62,6 +67,17 @@ class ActionChain { final BuildContext? _context; final List _hosts; + /// Hosts of screens a navigation of this chain replaced the trigger's + /// with, oldest first. Shared with nested chains. + final List _adopted; + + /// Makes [host] a context the chain's remaining actions can run with, + /// ahead of the trigger's and its hosts: the screen a `replace` + /// navigation put in their place. See [ActionChainAdopter]. + void adoptHost(BuildContext host) { + if (!_adopted.contains(host)) _adopted.add(host); + } + static const Symbol _zoneKey = #codelesslyActionChain; /// The chain the running action belongs to, if any. @@ -85,6 +101,7 @@ class ActionChain { scopedValues: scopedValues, context: _context, hosts: _hosts, + adopted: _adopted, ); /// Runs [body] in a new chain whose `${result}` starts as [result]. @@ -149,14 +166,19 @@ class ActionChain { current?.scopedValues ?? ScopedValues.of(context); /// A mounted context to run the next action with: [context] while it is - /// mounted, else the chain's trigger context, else the first of its hosts - /// still mounted. Null when all of them have unmounted, e.g. the app - /// replaced the whole screen. + /// mounted, else the host of a screen a `replace` navigation of the chain + /// opened (see [adoptHost]), else the chain's trigger context, else the + /// first of its hosts still mounted. Null when all of them have unmounted, + /// e.g. the app replaced the whole screen. static BuildContext? contextFor(BuildContext context) { if (context.mounted) return context; final ActionChain? chain = current; if (chain == null) return null; - for (final BuildContext? candidate in [chain._context, ...chain._hosts]) { + for (final BuildContext? candidate in [ + ...chain._adopted, + chain._context, + ...chain._hosts, + ]) { if (candidate != null && candidate.mounted) return candidate; } return null; @@ -176,6 +198,9 @@ class ActionChainHost extends InheritedWidget { static BuildContext? maybeOf(BuildContext context) => context.getElementForInheritedWidgetOfExactType(); + @override + InheritedElement createElement() => _ActionChainHostElement(this); + /// The contexts of the hosts a chain triggered in [context] can fall back /// to, nearest first: every [ActionChainHost] above [context] (a layout /// embedded in another has its own), then, past an [ActionChainOpener], @@ -225,3 +250,35 @@ class ActionChainOpener extends InheritedWidget { @override bool updateShouldNotify(ActionChainOpener oldWidget) => false; } + +class _ActionChainHostElement extends InheritedElement { + _ActionChainHostElement(super.widget); + + @override + void mount(Element? parent, Object? newSlot) { + super.mount(parent, newSlot); + final Widget? adopter = + getElementForInheritedWidgetOfExactType()?.widget; + if (adopter is ActionChainAdopter) adopter.chain.adoptHost(this); + } +} + +/// Wraps a screen that an action of [chain] put in place of the screen the +/// chain was triggered on (a `replace` navigation): the first +/// [ActionChainHost] that mounts below it joins [chain], so the chain's +/// remaining actions (e.g. a toast after "pop the dialog, then replace the +/// page") run with the new screen once the old one unmounts. +class ActionChainAdopter extends InheritedWidget { + /// Creates an [ActionChainAdopter] above [child]. + const ActionChainAdopter({ + super.key, + required this.chain, + required super.child, + }); + + /// The chain the new screen's hosts join. + final ActionChain chain; + + @override + bool updateShouldNotify(ActionChainAdopter oldWidget) => false; +} diff --git a/lib/src/functions/app_toast.dart b/lib/src/functions/app_toast.dart index 51faed5e..8a70096c 100644 --- a/lib/src/functions/app_toast.dart +++ b/lib/src/functions/app_toast.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:codelessly_api/codelessly_api.dart'; import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; import '../../codelessly_sdk.dart'; import 'action_chain.dart'; @@ -35,6 +36,22 @@ class AppToast { final String message = text(action.message); final String actionLabel = action.hasAction ? text(action.actionLabel) : ''; final ResolvedFeedbackTheme theme = FeedbackTheme.of(context); + // Without a font of its own, the toast takes the app's text style at + // the trigger, not Material's default. + final TextStyle appText = DefaultTextStyle.of(context).style; + final String? themeFamily = Theme.of(context) + .textTheme + .bodyMedium + ?.fontFamily; + final TextStyle appFont = + theme.bodyStyle.fontFamily == themeFamily && appText.fontFamily != null + ? theme.bodyStyle.copyWith( + fontFamily: appText.fontFamily, + fontFamilyFallback: appText.fontFamilyFallback, + ) + : theme.bodyStyle; + final TextStyle font = fontOf(action, appFont); + final double? offset = offsetOf(action, scopedValues); // The button's actions run on the chain that showed the toast, whenever // it is pressed: with its captured scope (the `${item}` of a row that has // since left the list) and its `${result}` then (e.g. the id of the item @@ -57,6 +74,8 @@ class AppToast { message: message, actionLabel: actionLabel, theme: theme, + font: font, + offset: offset, onAction: onAction, onDone: () { if (_current == entry) _current = null; @@ -69,6 +88,45 @@ class AppToast { return true; } + /// The message's text style: [base] in [ShowToastAction.fontFamily] (a + /// Google font or one the project loaded) and [ShowToastAction.fontWeight] + /// when set. Without a weight it is semibold. + static TextStyle fontOf(ShowToastAction action, TextStyle base) { + final FontWeight weight = + action.fontWeight?.flutterFontWeight ?? FontWeight.w600; + final String family = action.fontFamily?.trim() ?? ''; + if (family.isEmpty) return base.copyWith(fontWeight: weight); + if (GoogleFonts.asMap().containsKey(family)) { + return GoogleFonts.getFont(family, textStyle: base, fontWeight: weight); + } + return base.copyWith( + fontFamily: family, + fontFamilyFallback: const [], + fontWeight: weight, + ); + } + + /// The distance from the screen edge [action]'s bound + /// [ShowToastAction.offset] gives, read with [scopedValues], or null when + /// it is empty or not a number. + static double? offsetOf(ShowToastAction action, ScopedValues scopedValues) { + final String template = action.offset.trim(); + if (template.isEmpty) return null; + final Object? value = + FormulaEvaluator.exactExpression(template) != null || + RegExp(r'^\$\{[^{}]+\}$').hasMatch(template) + ? PropertyValueDelegate.getVariableValueFromPath( + template, + scopedValues: scopedValues, + ) + : PropertyValueDelegate.substituteVariables( + template, + scopedValues: scopedValues, + nullSubstitutionMode: NullSubstitutionMode.emptyString, + ); + return value.toDouble(); + } + /// Removes the toast on screen, if any. static void hide() { final OverlayEntry? entry = _current; @@ -108,6 +166,8 @@ class _ToastView extends StatefulWidget { required this.message, required this.actionLabel, required this.theme, + required this.font, + required this.offset, required this.onAction, required this.onDone, }); @@ -116,6 +176,8 @@ class _ToastView extends StatefulWidget { final String message; final String actionLabel; final ResolvedFeedbackTheme theme; + final TextStyle font; + final double? offset; final VoidCallback? onAction; final VoidCallback onDone; @@ -178,23 +240,26 @@ class _ToastViewState extends State<_ToastView> { child: retrieveIconWidget(action.icon!, 18) ?? const SizedBox(), ); 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 TextStyle messageStyle = theme.bodyStyle.copyWith( + final double fontSize = action.fontSize ?? 14; + final Color actionColor = action.actionColor?.toFlutterColor() ?? textColor; + final TextStyle messageStyle = widget.font.copyWith( color: textColor, - fontSize: 14, + fontSize: fontSize, height: 1.35, - fontWeight: FontWeight.w600, decoration: TextDecoration.none, ); Widget toast = Container( constraints: const BoxConstraints(minHeight: 44, maxWidth: 560), - padding: EdgeInsets.fromLTRB(18, 10, hasButton ? 8 : 18, 10), + padding: EdgeInsets.fromLTRB(18, 10, hasButton || hasClose ? 4 : 18, 10), decoration: BoxDecoration( color: background, borderRadius: BorderRadius.circular( - hasButton ? theme.buttonRadius.clamp(8, 999) : 999, + action.cornerRadius ?? + (hasButton ? theme.buttonRadius.clamp(8, 999) : 999), ), boxShadow: const [ BoxShadow( @@ -215,11 +280,11 @@ 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.copyWith( - fontSize: 14, + fontSize: fontSize, fontWeight: FontWeight.w700, ), shape: RoundedRectangleBorder( @@ -229,6 +294,19 @@ class _ToastViewState extends State<_ToastView> { child: Text(widget.actionLabel), ), ], + if (hasClose) ...[ + if (!hasButton) const SizedBox(width: 4), + IconButton( + key: const ValueKey('codelessly-toast-close'), + onPressed: _close, + tooltip: 'Close', + color: textColor, + iconSize: 18, + constraints: const BoxConstraints(minWidth: 36, minHeight: 36), + padding: EdgeInsets.zero, + icon: const Icon(Icons.close), + ), + ], ], ), ); @@ -253,10 +331,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 ? safe.top + (widget.offset ?? 16) : null, + bottom: top ? null : widget.offset ?? action.bottomOffset, // 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) || !_visible, + child: toast, + ), ); } } diff --git a/lib/src/functions/functions_repository.dart b/lib/src/functions/functions_repository.dart index 0c1bc3fa..378c2305 100644 --- a/lib/src/functions/functions_repository.dart +++ b/lib/src/functions/functions_repository.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:math' as math; import 'dart:convert'; import 'package:codelessly_api/codelessly_api.dart'; @@ -490,17 +491,10 @@ class FunctionsRepository { await Navigator.maybePop(context, parsedParams); } else { // Check if a layout exists for the action's [destinationId]. - final String? layoutId = - effectiveCodelessly - .dataManager - .publishModel - ?.layouts[action.destinationId] - ?.id ?? - effectiveCodelessly.dataManager.publishModel?.layouts.values - .firstWhereOrNull( - (layout) => layout.canvasIds.contains(action.destinationId), - ) - ?.id; + final String? layoutId = resolveLayoutIdForCanvas( + effectiveCodelessly.dataManager.publishModel, + action.destinationId, + ); DebugLogger.instance.printInfo( 'looking for layout with canvas id: [${action.destinationId}]', @@ -559,25 +553,27 @@ class FunctionsRepository { ); } } else if (action.navigationType == NavigationType.replace) { - await Navigator.pushReplacement( - context, - MaterialPageRoute( - settings: RouteSettings(arguments: parsedParams), - builder: (context) => - CodelesslyWidget(controller: effectiveController), + // The new screen takes over the chain: the actions after this one + // (e.g. a toast) run with it once this screen unmounts. + final ActionChain? chain = ActionChain.current; + // Don't wait for the route's result: it only comes when the new + // screen pops, long after this screen is gone, and the rest of the + // chain would never run. + unawaited( + Navigator.pushReplacement( + context, + MaterialPageRoute( + settings: RouteSettings(arguments: parsedParams), + builder: (context) { + final Widget page = CodelesslyWidget( + controller: effectiveController, + ); + if (chain == null) return page; + return ActionChainAdopter(chain: chain, child: page); + }, + ), ), ); - - // Notify navigation listeners that this screen has been navigated back - // to, because the Navigator.push future has completed and came - // back to this screen. - if (context.mounted) { - effectiveCodelessly.notifyNavigationListeners( - context, - layoutId: myLayoutId, - canvasId: myCanvasId, - ); - } } } } @@ -595,18 +591,53 @@ class FunctionsRepository { showCloseButton: action.showCloseButton, presentation: action.presentation, alignment: action.alignment.flutterAlignment ?? Alignment.center, + insets: action.insets.flutterEdgeInsets, + maxHeightFactor: action.maxHeightFactor, ); } + /// The size canvas [canvasId] was designed at, if it is published. + static Size? canvasDesignSize(Codelessly codelessly, String canvasId) { + final BaseNode? canvas = publishedCanvas(codelessly, canvasId); + if (canvas == null) return null; + return Size(canvas.basicBoxLocal.width, canvas.basicBoxLocal.height); + } + + /// The canvas node [canvasId], if it is published and downloaded. + static CanvasNode? publishedCanvas(Codelessly codelessly, String canvasId) { + for (final layout + in codelessly.dataManager.publishModel?.layouts.values ?? + const []) { + final BaseNode? canvas = layout.canvases[canvasId]?[canvasId]; + if (canvas is CanvasNode) return canvas; + } + return null; + } + /// The id of the published layout that holds the canvas [canvasId], if any. static String? layoutIdForCanvas(Codelessly codelessly, String canvasId) => - codelessly.dataManager.publishModel?.layouts.values - .firstWhereOrNull((layout) => layout.canvasIds.contains(canvasId)) - ?.id; + resolveLayoutIdForCanvas(codelessly.dataManager.publishModel, canvasId); - /// Shows the canvas [canvasId] as a dialog and completes with what it was - /// closed with: the params of the "Navigate back" action that closed it, or - /// null when it was dismissed. + /// The id of the layout of [model] that holds canvas [canvasId] (or whose + /// id is [canvasId]), if any. + /// + /// Layouts already downloaded are checked first. Otherwise the publish + /// model's index of layout canvases names it, so a destination whose + /// layout is not downloaded yet (a screen of another page of the project, + /// in an app that loads layouts on demand) still resolves: the + /// [CodelesslyWidget] shown for it downloads the layout. + static String? resolveLayoutIdForCanvas( + SDKPublishModel? model, + String canvasId, + ) { + if (model == null || canvasId.isEmpty) return null; + return model.resolveLayoutId(canvasId); + } + + /// Shows the canvas [canvasId] as a dialog, a bottom sheet or a side sheet + /// (see [presentCanvas]) and completes with what it was closed with: the + /// params of the "Navigate back" action that closed it, or null when it + /// was dismissed. static Future showLayoutDialog( BuildContext context, { required String canvasId, @@ -616,6 +647,8 @@ class FunctionsRepository { bool showCloseButton = false, DialogPresentation presentation = DialogPresentation.dialog, AlignmentGeometry alignment = Alignment.center, + EdgeInsets insets = EdgeInsets.zero, + double? maxHeightFactor, }) async { final ScopedValues scopedValues = ActionChain.scopedValuesOf(context); final parsedParams = substituteVariablesInMap(params, scopedValues); @@ -662,9 +695,16 @@ class FunctionsRepository { context, presentation: presentation, alignment: alignment, + insets: insets, barrierDismissible: barrierDismissible, barrierColor: barrierColor, showCloseButton: showCloseButton, + maxHeightFactor: maxHeightFactor, + designSize: canvasDesignSize(codelessly, canvasId), + hugsContent: switch (publishedCanvas(codelessly, canvasId)) { + final CanvasNode canvas => CanvasViewport.sizesToContent(canvas), + null => false, + }, routeSettings: RouteSettings(arguments: parsedParams), builder: (context) => ActionChainOpener( openerHosts: openerHosts, @@ -681,41 +721,115 @@ class FunctionsRepository { return result; } - /// Opens what [builder] builds as a dialog at [alignment] or as a bottom - /// sheet, per [presentation] (adaptive: a sheet below 600 px wide), and - /// completes with the value it is closed with. + /// Opens what [builder] builds per [presentation] and completes with the + /// value it is closed with: + /// + /// * a dialog at [alignment], kept [insets] from the screen edges, at most + /// [maxHeightFactor] of the screen high; + /// * a bottom sheet at the screen's width and the canvas's [designSize] + /// height, at most [maxHeightFactor] (else 88%) of the screen (adaptive: + /// a sheet below 600 px wide, a dialog otherwise); + /// * a side sheet sliding in from the right at the canvas's width, as tall + /// as the screen less [insets]. + /// + /// With [hugsContent] (the canvas hugs its height) a sheet is as tall as + /// its content, up to its cap; a side sheet is then centered vertically. + /// The canvas draws the sheet's surface (fill, corners, grab handle). static Future presentCanvas( BuildContext context, { required WidgetBuilder builder, DialogPresentation presentation = DialogPresentation.dialog, AlignmentGeometry alignment = Alignment.center, + EdgeInsets insets = EdgeInsets.zero, bool barrierDismissible = true, Color? barrierColor, bool showCloseButton = false, + double? maxHeightFactor, + Size? designSize, + bool hugsContent = false, RouteSettings? routeSettings, }) { final bool asSheet = switch (presentation) { - DialogPresentation.dialog => false, + DialogPresentation.dialog || DialogPresentation.sideSheet => false, DialogPresentation.bottomSheet => true, DialogPresentation.adaptive => (MediaQuery.maybeSizeOf(context)?.width ?? 1024) < 600, }; - Widget content(BuildContext context) => CodelesslyDialogWidget( - showCloseButton: showCloseButton, - alignment: asSheet ? Alignment.bottomCenter : alignment, - builder: builder, - ); + if (presentation == DialogPresentation.sideSheet) { + return showGeneralDialog( + context: context, + useRootNavigator: false, + barrierDismissible: barrierDismissible, + barrierLabel: MaterialLocalizations.of(context) + .modalBarrierDismissLabel, + barrierColor: barrierColor ?? Colors.black54, + routeSettings: routeSettings, + transitionDuration: const Duration(milliseconds: 260), + transitionBuilder: (context, animation, _, child) => SlideTransition( + position: Tween(begin: const Offset(1, 0), end: Offset.zero) + .animate( + CurvedAnimation(parent: animation, curve: Curves.easeOutCubic), + ), + child: child, + ), + pageBuilder: (context, _, _) => SafeArea( + child: Align( + alignment: Alignment.centerRight, + child: Padding( + padding: insets, + child: CanvasSizingScope( + sizing: hugsContent ? CanvasSizing.design : CanvasSizing.fill, + child: SizedBox( + width: designSize?.width ?? 420, + child: Builder(builder: builder), + ), + ), + ), + ), + ), + ); + } if (asSheet) { return showModalBottomSheet( context: context, useRootNavigator: false, isScrollControlled: true, isDismissible: barrierDismissible, + enableDrag: barrierDismissible, barrierColor: barrierColor, + // The canvas draws the sheet: its fill, corners and grab handle. backgroundColor: Colors.transparent, elevation: 0, + constraints: const BoxConstraints(), routeSettings: routeSettings, - builder: content, + builder: (context) { + final Size screen = MediaQuery.sizeOf(context); + final double cap = + CodelesslyDialogWidget.maxHeightFor( + maxHeightFactor, + screen.height, + ) ?? + screen.height * 0.88; + if (hugsContent) { + return CanvasSizingScope( + sizing: CanvasSizing.design, + child: ConstrainedBox( + constraints: BoxConstraints( + minWidth: screen.width, + maxHeight: cap, + ), + child: Builder(builder: builder), + ), + ); + } + return CanvasSizingScope( + sizing: CanvasSizing.fill, + child: SizedBox( + height: math.min(designSize?.height ?? double.infinity, cap), + child: Builder(builder: builder), + ), + ); + }, ); } return showDialog( @@ -724,7 +838,13 @@ class FunctionsRepository { barrierDismissible: barrierDismissible, barrierColor: barrierColor, routeSettings: routeSettings, - builder: content, + builder: (context) => CodelesslyDialogWidget( + showCloseButton: showCloseButton, + alignment: alignment, + insets: insets, + maxHeightFactor: maxHeightFactor, + builder: builder, + ), ); } @@ -1840,9 +1960,172 @@ ${response.body.contains('{') ? const JsonEncoder.withIndent(' ').convert(json. ), RemoveDocumentSubAction removeDocumentSubAction => removeDocumentFromCloud(removeDocumentSubAction, scopedValues), + RemoveDocumentsSubAction subAction => removeDocumentsFromCloud( + subAction, + scopedValues, + ), + SetDocumentsSubAction subAction => setDocumentsOnCloud( + subAction, + scopedValues, + ), }; } + /// The value [template] gives: a value as is when it is exactly one + /// `${...}` path or formula, else its text with variables substituted, + /// decoded when it is JSON (whose strings may hold paths too). + static Object? _valueFromTemplate( + String template, + ScopedValues scopedValues, + ) { + final String trimmed = template.trim(); + if (trimmed.isEmpty) return null; + if (FormulaEvaluator.exactExpression(trimmed) != null || + RegExp(r'^\$\{[^{}]+\}$').hasMatch(trimmed)) { + return PropertyValueDelegate.getVariableValueFromPath( + trimmed, + scopedValues: scopedValues, + ); + } + final Object? json = tryJsonDecode(trimmed); + if (json is List || json is Map) { + return PropertyValueDelegate.substituteJsonValue(json, scopedValues); + } + return PropertyValueDelegate.substituteVariables( + trimmed, + scopedValues: scopedValues, + nullSubstitutionMode: NullSubstitutionMode.emptyString, + ); + } + + /// Document ids from [value]: a list of ids or of documents (their `id`), + /// JSON text of one, or ids separated by commas. + @visibleForTesting + static List documentIdsOf(Object? value) { + if (value is String) { + final Object? json = tryJsonDecode(value.trim()); + if (json is List) return documentIdsOf(json); + return [ + for (final String id in value.split(',')) + if (id.trim().isNotEmpty) id.trim(), + ]; + } + if (value is Iterable) { + return [ + for (final Object? item in value) + if (item is Map + ? item['id'] != null + : item != null && item.toString().trim().isNotEmpty) + (item is Map ? item['id'] : item).toString().trim(), + ]; + } + if (value is Map && value['id'] != null) return [value['id'].toString()]; + return []; + } + + /// Removes many documents in one batched write: those [documentIds] gives, + /// or those matching the where filters (all of the collection when there + /// are none). `${result.count}` is how many were removed. + /// + /// [database] stands in for the app's cloud database, in tests. + static Future removeDocumentsFromCloud( + RemoveDocumentsSubAction subAction, + ScopedValues scopedValues, { + @visibleForTesting CloudDatabase? database, + }) async { + try { + final CloudDatabase? cloudDatabase = + database ?? scopedValues.cloudDatabase; + if (cloudDatabase == null) { + DebugLogger.instance.printInfo('Cloud storage is null.', name: name); + return false; + } + final String path = PropertyValueDelegate.substituteVariables( + subAction.path, + nullSubstitutionMode: NullSubstitutionMode.emptyString, + scopedValues: scopedValues, + ); + final List? ids = subAction.documentIds.trim().isEmpty + ? null + : documentIdsOf( + _valueFromTemplate(subAction.documentIds, scopedValues), + ); + final List filters = ids != null + ? const [] + : resolveCloudQuery( + whereFilters: subAction.whereFilters, + orderByOperations: const [], + scopedValues: scopedValues, + nullSubstitutionMode: NullSubstitutionMode.emptyString, + ).filters; + final int removed = await cloudDatabase.removeDocuments( + path, + documentIds: ids, + filters: filters, + ); + ActionChain.setResult({'count': removed, 'path': path}); + return true; + } catch (error, stackTrace) { + DebugLogger.instance.printInfo(error.toString(), name: name); + DebugLogger.instance.printInfo(stackTrace.toString(), name: name); + return false; + } + } + + /// Writes a list of documents in one batched write, e.g. seed data from a + /// list variable. `${result.count}` is how many were written. + /// + /// [database] stands in for the app's cloud database, in tests. + static Future setDocumentsOnCloud( + SetDocumentsSubAction subAction, + ScopedValues scopedValues, { + @visibleForTesting CloudDatabase? database, + }) async { + try { + final CloudDatabase? cloudDatabase = + database ?? scopedValues.cloudDatabase; + if (cloudDatabase == null) { + DebugLogger.instance.printInfo('Cloud storage is null.', name: name); + return false; + } + final String path = PropertyValueDelegate.substituteVariables( + subAction.path, + nullSubstitutionMode: NullSubstitutionMode.emptyString, + scopedValues: scopedValues, + ); + Object? value = _valueFromTemplate(subAction.documents, scopedValues); + if (value is String) value = tryJsonDecode(value); + if (value is Map) value = [value]; + if (value is! List) { + DebugLogger.instance.printInfo( + 'Set Documents needs a list of documents, got $value.', + name: name, + ); + return false; + } + final List> documents = [ + for (final Object? item in value) + if (item is Map) Map.from(item), + ]; + final String idField = subAction.idField.trim().isEmpty + ? 'id' + : subAction.idField.trim(); + final int written = await cloudDatabase.setDocuments( + path, + documents, + idField: idField, + merge: subAction.merge, + replaceCollection: subAction.replaceCollection, + ); + ActionChain.setResult({'count': written, 'path': path}); + return true; + } catch (error, stackTrace) { + DebugLogger.instance.printInfo(error.toString(), name: name); + DebugLogger.instance.printInfo(stackTrace.toString(), name: name); + return false; + } + } + static Future addDocumentToCloud( AddDocumentSubAction subAction, ScopedValues scopedValues, diff --git a/lib/src/model/sdk_publish_model.dart b/lib/src/model/sdk_publish_model.dart index ba04998e..a6df0279 100644 --- a/lib/src/model/sdk_publish_model.dart +++ b/lib/src/model/sdk_publish_model.dart @@ -695,6 +695,13 @@ class SDKPublishUpdates with Equatable { /// apis of a given project. final Map> layoutApis; + /// A map that holds a mapping of layout ids -> the ids of the canvases the + /// layout holds. + /// + /// This lets the SDK find the layout of a canvas (the destination of a + /// navigation or dialog action) before the layout itself is downloaded. + final Map> layoutCanvases; + /// Creates a new instance of [SDKPublishUpdates]. SDKPublishUpdates({ this.fonts = const {}, @@ -704,6 +711,7 @@ class SDKPublishUpdates with Equatable { this.conditions = const {}, this.layoutFonts = const {}, this.layoutApis = const {}, + this.layoutCanvases = const {}, }); /// Creates a copy of this instance with the provided parameters. @@ -715,6 +723,7 @@ class SDKPublishUpdates with Equatable { Map? conditions, Map>? layoutFonts, Map>? layoutApis, + Map>? layoutCanvases, }) { return SDKPublishUpdates( fonts: fonts ?? this.fonts, @@ -724,9 +733,52 @@ class SDKPublishUpdates with Equatable { conditions: conditions ?? this.conditions, layoutFonts: layoutFonts ?? this.layoutFonts, layoutApis: layoutApis ?? this.layoutApis, + layoutCanvases: layoutCanvases ?? this.layoutCanvases, + ); + } + + /// These updates with [newer] applied on top: what the publish model holds + /// after a publish that changed only some of the layouts (for instance the + /// layouts of one page of a project). + /// + /// Timestamps are combined, and the per-layout maps ([layoutFonts], + /// [layoutApis], [layoutCanvases]) keep the entries of layouts [newer] does + /// not mention while taking [newer]'s entries for the layouts it does. + /// A canvas that [newer] places in a layout is dropped from any other + /// layout that listed it, since a canvas lives in one layout. + SDKPublishUpdates merge(SDKPublishUpdates newer) { + final Set movedCanvases = { + for (final Set canvases in newer.layoutCanvases.values) + ...canvases, + }; + return SDKPublishUpdates( + fonts: {...fonts, ...newer.fonts}, + layouts: {...layouts, ...newer.layouts}, + apis: {...apis, ...newer.apis}, + variables: {...variables, ...newer.variables}, + conditions: {...conditions, ...newer.conditions}, + layoutFonts: {...layoutFonts, ...newer.layoutFonts}, + layoutApis: {...layoutApis, ...newer.layoutApis}, + layoutCanvases: { + for (final MapEntry(key: String layoutId, value: Set canvases) + in layoutCanvases.entries) + if (!newer.layoutCanvases.containsKey(layoutId)) + layoutId: canvases.difference(movedCanvases), + ...newer.layoutCanvases, + }..removeWhere((_, Set canvases) => canvases.isEmpty), ); } + /// The id of the layout that holds canvas [canvasId] according to + /// [layoutCanvases], if any. + String? layoutIdForCanvas(String canvasId) { + for (final MapEntry(key: String layoutId, value: Set canvases) + in layoutCanvases.entries) { + if (canvases.contains(canvasId)) return layoutId; + } + return null; + } + @override List get props => [ fonts, @@ -736,6 +788,7 @@ class SDKPublishUpdates with Equatable { conditions, layoutFonts, layoutApis, + layoutCanvases, ]; /// Creates a new instance of [SDKPublishUpdates] from a JSON map. @@ -870,15 +923,17 @@ class SDKLayoutConditions extends PrivacyBase { /// Finding layouts in a published model. extension SDKPublishModelLayouts on SDKPublishModel { /// The layout [id] names: a layout ID, or the ID of a canvas in a layout, - /// so a link can open a screen by its canvas. Null when [id] is null or - /// names neither. + /// so a link can open a screen by its canvas. Layouts not loaded yet (on + /// another page, or loaded on demand) are found through + /// [SDKPublishUpdates.layoutCanvases]. Null when [id] is null or names + /// neither. String? resolveLayoutId(String? id) { if (id == null || id.isEmpty) return null; if (layouts.containsKey(id)) return id; for (final SDKPublishLayout layout in layouts.values) { if (layout.canvasIds.contains(id)) return layout.id; } - return null; + return updates.layoutIdForCanvas(id); } /// The parameters [link] gives a screen of layout [layoutId] (the entry diff --git a/lib/src/model/sdk_publish_model.g.dart b/lib/src/model/sdk_publish_model.g.dart index 7b30d926..8024bb51 100644 --- a/lib/src/model/sdk_publish_model.g.dart +++ b/lib/src/model/sdk_publish_model.g.dart @@ -201,6 +201,14 @@ SDKPublishUpdates _$SDKPublishUpdatesFromJson(Map json) => SDKPublishUpdates( ), ) ?? const {}, + layoutCanvases: + (json['layoutCanvases'] as Map?)?.map( + (k, e) => MapEntry( + k as String, + (e as List).map((e) => e as String).toSet(), + ), + ) ?? + const {}, ); Map _$SDKPublishUpdatesToJson( @@ -213,6 +221,9 @@ Map _$SDKPublishUpdatesToJson( 'conditions': const DateTimeMapConverter().toJson(instance.conditions), 'layoutFonts': instance.layoutFonts.map((k, e) => MapEntry(k, e.toList())), 'layoutApis': instance.layoutApis.map((k, e) => MapEntry(k, e.toList())), + 'layoutCanvases': instance.layoutCanvases.map( + (k, e) => MapEntry(k, e.toList()), + ), }; SDKLayoutVariables _$SDKLayoutVariablesFromJson(Map json) => SDKLayoutVariables( diff --git a/lib/src/transformers/defaults/default_properties.dart b/lib/src/transformers/defaults/default_properties.dart index 0e688793..ecc2fd0b 100644 --- a/lib/src/transformers/defaults/default_properties.dart +++ b/lib/src/transformers/defaults/default_properties.dart @@ -262,6 +262,9 @@ ProgressBarProperties get defaultProgressBarProperties => ProgressBarProperties( cornerRadius: ProgressBarDefaults.cornerRadius, ); +/// A new chart's properties: a sample curve, so it looks like a chart. +ChartProperties get defaultChartProperties => ChartProperties.sample(); + TabBarProperties get defaultTabBarProperties => TabBarProperties( dividerColor: TabBarDefaults.dividerColor.toColorRGBA(), indicatorColor: TabBarDefaults.indicatorColor.toColorRGBA(), diff --git a/lib/src/transformers/node_transformers/node_transformers.dart b/lib/src/transformers/node_transformers/node_transformers.dart index 8421a1e2..682441f2 100644 --- a/lib/src/transformers/node_transformers/node_transformers.dart +++ b/lib/src/transformers/node_transformers/node_transformers.dart @@ -5,6 +5,7 @@ export 'passive_app_bar_transformer.dart'; export 'passive_badge_transformer.dart'; export 'passive_button_transformer.dart'; export 'passive_canvas_transformer.dart'; +export 'passive_chart_transformer.dart'; export 'passive_checkbox_transformer.dart'; export 'passive_chip_transformer.dart'; export 'passive_divider_transformer.dart'; diff --git a/lib/src/transformers/node_transformers/passive_canvas_transformer.dart b/lib/src/transformers/node_transformers/passive_canvas_transformer.dart index 9fe25753..2695c00a 100644 --- a/lib/src/transformers/node_transformers/passive_canvas_transformer.dart +++ b/lib/src/transformers/node_transformers/passive_canvas_transformer.dart @@ -499,6 +499,14 @@ class CanvasViewport extends StatelessWidget { const CanvasViewport({super.key, required this.node, required this.child}); + /// Whether [node] is a canvas whose height is its content's: it hugs + /// vertically. A scaffold needs a bounded height, so a canvas with an app + /// bar, navigation bar or FAB takes its design height instead. + static bool sizesToContent(CanvasNode node) => + node.verticalFit == SizeFit.shrinkWrap && + node.scaleMode == ScaleMode.responsive && + !node.isAScaffold; + /// The canvas box for [constraints] under [sizing], per axis. A null /// height leaves the height to the canvas content (see /// [CanvasContentHeight]), within the incoming maximum. @@ -507,13 +515,7 @@ class CanvasViewport extends StatelessWidget { BoxConstraints constraints, { CanvasSizing sizing = CanvasSizing.fill, }) { - // A canvas whose height is its content's. A scaffold needs a bounded - // height, so a canvas with an app bar, navigation bar or FAB takes its - // design height instead. - final bool contentHeight = - node.verticalFit == SizeFit.shrinkWrap && - node.scaleMode == ScaleMode.responsive && - !node.isAScaffold; + final bool contentHeight = sizesToContent(node); if (sizing == CanvasSizing.design) { final double width = constraints.constrainWidth(node.basicBoxLocal.width); if (contentHeight) return (width: width, height: null); diff --git a/lib/src/transformers/node_transformers/passive_chart_transformer.dart b/lib/src/transformers/node_transformers/passive_chart_transformer.dart new file mode 100644 index 00000000..ccacfcb1 --- /dev/null +++ b/lib/src/transformers/node_transformers/passive_chart_transformer.dart @@ -0,0 +1,502 @@ +import 'dart:math' as math; + +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import '../../../codelessly_sdk.dart'; +import '../utils/node_state_provider.dart'; + +class PassiveChartTransformer extends NodeWidgetTransformer { + PassiveChartTransformer(super.getNode, super.manager); + + @override + Widget buildWidget( + ChartNode node, + BuildContext context, + WidgetBuildSettings settings, + ) { + return PassiveChartWidget(node: node, settings: settings); + } +} + +/// Paints a [ChartNode] and lets the user select an x value on it. +/// +/// The points, markers and selection come from the variables bound to +/// `points`, `markers` and `selectedX` when there are any, and from the +/// node otherwise. Selecting writes `selectedX` and fires +/// [TriggerType.changed] with the selected value, which actions read as +/// `${value}`. +class PassiveChartWidget extends StatefulWidget { + final ChartNode node; + final WidgetBuildSettings settings; + final List variablesOverrides; + + /// Whether tapping or dragging selects. False paints the chart only, as + /// on the editor's canvas. + final bool interactive; + + const PassiveChartWidget({ + super.key, + required this.node, + required this.settings, + this.variablesOverrides = const [], + this.interactive = true, + }); + + @override + State createState() => _PassiveChartWidgetState(); +} + +class _PassiveChartWidgetState extends State { + /// The selection made on this chart while no variable or node value holds + /// it. + double? localSelection; + + /// The selection painted last, so a drag fires only when it changes. + double? shownSelection; + + /// Bumped when a font finishes loading, so labels paint with it. + int fontsGeneration = 0; + + @override + void initState() { + super.initState(); + PaintingBinding.instance.systemFonts.addListener(onFontsChanged); + } + + @override + void didUpdateWidget(covariant PassiveChartWidget oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.node.selectedX != widget.node.selectedX) { + localSelection = null; + } + } + + @override + void dispose() { + PaintingBinding.instance.systemFonts.removeListener(onFontsChanged); + super.dispose(); + } + + void onFontsChanged() { + if (mounted) setState(() => fontsGeneration++); + } + + ChartNode get node => widget.node; + + ChartProperties get props => widget.node.properties; + + void select(BuildContext context, Offset position) { + final Size? size = context.size; + if (size == null || size.width <= 0 || props.xMax == props.xMin) return; + final double x = + props.xMin + position.dx / size.width * (props.xMax - props.xMin); + final double selected = props.snapSelection(x); + if (selected == shownSelection) return; + shownSelection = selected; + setState(() => localSelection = selected); + + // Whole values read as `7`, not `7.0`, in variables and actions. + final num value = selected == selected.roundToDouble() + ? selected.toInt() + : selected; + NodeStateProvider.setState(context, value); + if (!FunctionsRepository.setPropertyVariable( + context, + node: node, + property: 'selectedX', + value: value.toString(), + )) { + FunctionsRepository.setNodeValue( + context, + node: node, + property: 'selectedX', + value: selected, + ); + } + FunctionsRepository.triggerAction( + context, + node: node, + TriggerType.changed, + value: value, + ); + } + + @override + Widget build(BuildContext context) { + final ScopedValues scopedValues = ScopedValues.of( + context, + variablesOverrides: widget.variablesOverrides, + ); + final List points = chartPoints(node, scopedValues); + final List markers = chartMarkers(node, scopedValues); + final double? selectedX = + PropertyValueDelegate.getPropertyValue( + node, + 'selectedX', + scopedValues: scopedValues, + ) ?? + localSelection ?? + node.selectedX; + shownSelection = selectedX; + + final Widget paint = CustomPaint( + size: node.basicBoxLocal.size.flutterSize, + painter: ChartPainter( + properties: props, + points: points, + markers: markers, + selectedX: selectedX, + textScaler: + MediaQuery.maybeTextScalerOf(context) ?? TextScaler.noScaling, + fontsGeneration: fontsGeneration, + ), + ); + + if (!widget.interactive || !props.selectable) { + return AdaptiveNodeBox(node: node, child: paint); + } + + return AdaptiveNodeBox( + node: node, + child: MouseRegion( + cursor: SystemMouseCursors.resizeLeftRight, + child: Builder( + builder: (context) => GestureDetector( + behavior: HitTestBehavior.opaque, + onTapDown: (details) => select(context, details.localPosition), + onHorizontalDragStart: (details) => + select(context, details.localPosition), + onHorizontalDragUpdate: (details) => + select(context, details.localPosition), + child: paint, + ), + ), + ), + ); + } +} + +/// The points of [node]'s chart: the list bound to `points`, or the node's. +List chartPoints(ChartNode node, ScopedValues scopedValues) { + final List? bound = node.variables['points'] == null + ? null + : PropertyValueDelegate.getPropertyValue( + node, + 'points', + scopedValues: scopedValues, + ); + if (bound == null) return node.properties.points; + return [ + for (final Object? value in bound) + if (value.typedValue() case final double number) number, + ]; +} + +/// The markers of [node]'s chart: the list bound to `markers`, or the +/// node's. Bound markers are maps shaped like [ChartMarker]'s JSON. +List chartMarkers(ChartNode node, ScopedValues scopedValues) { + final List? bound = node.variables['markers'] == null + ? null + : PropertyValueDelegate.getPropertyValue( + node, + 'markers', + scopedValues: scopedValues, + ); + if (bound == null) return node.properties.markers; + final List markers = []; + for (final Object? value in bound) { + if (value is! Map) continue; + try { + markers.add(ChartMarker.fromJson(value)); + } catch (_) { + // Skip markers that aren't shaped like one. + } + } + return markers; +} + +/// Paints a line/area chart as described by [properties]. +class ChartPainter extends CustomPainter { + final ChartProperties properties; + final List points; + final List markers; + final double? selectedX; + final TextScaler textScaler; + final int fontsGeneration; + + ChartPainter({ + required this.properties, + required this.points, + required this.markers, + required this.selectedX, + this.textScaler = TextScaler.noScaling, + this.fontsGeneration = 0, + }); + + ChartProperties get p => properties; + + @override + void paint(Canvas canvas, Size size) { + if (size.isEmpty) return; + canvas.save(); + canvas.clipRect(Offset.zero & size); + + final double labelHeight = math.max(0, p.labelHeight); + final double chartHeight = math.max(0, size.height - labelHeight); + final double span = p.xMax - p.xMin; + double x(double value) => + span == 0 ? 0 : (value - p.xMin) / span * size.width; + + double low = p.yMin ?? (points.isEmpty ? 0 : points.reduce(math.min)); + double high = p.yMax ?? (points.isEmpty ? 1 : points.reduce(math.max)); + if (high == low) { + low -= 1; + high += 1; + } + final double inset = p.plotInset; + double y(double value) => + inset + (1 - (value - low) / (high - low)) * (chartHeight - inset * 2); + + // Grid and its labels. + final double? gridStep = p.gridStepX; + if (gridStep != null && gridStep > 0 && span > 0) { + final Paint grid = Paint() + ..color = p.gridColor.toFlutterColor() + ..strokeWidth = 1; + final int lines = (span / gridStep + 1e-9).floor(); + for (int i = 0; i <= lines; i++) { + final double value = p.xMin + i * gridStep; + final double dx = x(value); + canvas.drawLine(Offset(dx, 0), Offset(dx, chartHeight), grid); + if (p.xLabels.isEmpty && i > 0 && value < p.xMax - 1e-9) { + final String label = i - 1 < p.gridLabels.length + ? p.gridLabels[i - 1] + : formatChartNumber(value); + paintLabel(canvas, label, dx, chartHeight, labelHeight); + } + } + } + for (final ChartLabel label in p.xLabels) { + paintLabel(canvas, label.label, x(label.x), chartHeight, labelHeight); + } + if (p.axisColor case final ColorRGBA axisColor) { + canvas.drawLine( + Offset(0, chartHeight), + Offset(size.width, chartHeight), + Paint() + ..color = axisColor.toFlutterColor() + ..strokeWidth = 1, + ); + } + + for (final ChartRange range in p.ranges) { + canvas.drawRect( + Rect.fromLTRB(x(range.from), 0, x(range.to), chartHeight), + Paint()..color = range.color.toFlutterColor(), + ); + } + + // Curve and the area under it. + if (points.isNotEmpty) { + final List offsets = [ + for (int i = 0; i < points.length; i++) + Offset( + points.length == 1 ? 0 : x(p.xMin + span * i / (points.length - 1)), + y(points[i]), + ), + ]; + final Path line = Path()..moveTo(offsets.first.dx, offsets.first.dy); + for (int i = 1; i < offsets.length; i++) { + if (p.smooth) { + final Offset previous = offsets[i - 1]; + final Offset current = offsets[i]; + final double midX = (previous.dx + current.dx) / 2; + line.cubicTo( + midX, + previous.dy, + midX, + current.dy, + current.dx, + current.dy, + ); + } else { + line.lineTo(offsets[i].dx, offsets[i].dy); + } + } + if (p.fillColor case final ColorRGBA fillColor) { + final Path area = Path.from(line) + ..lineTo(offsets.last.dx, chartHeight) + ..lineTo(offsets.first.dx, chartHeight) + ..close(); + canvas.drawPath(area, Paint()..color = fillColor.toFlutterColor()); + } + if (p.lineWidth > 0) { + canvas.drawPath( + line, + Paint() + ..color = p.lineColor.toFlutterColor() + ..style = PaintingStyle.stroke + ..strokeWidth = p.lineWidth + ..strokeJoin = StrokeJoin.round, + ); + } + } + + for (final ChartMarker marker in markers) { + final double? value = marker.y ?? p.valueAt(marker.x, points); + if (value == null) continue; + final Offset dot = Offset(x(marker.x), y(value)); + final Color color = marker.color.toFlutterColor(); + canvas.drawCircle(dot, p.markerRadius, Paint()..color = color); + if (marker.label.isEmpty) continue; + final TextPainter text = layoutText( + marker.label, + p.markerLabelStyle, + color: color, + ); + final double dx = (dot.dx - text.width / 2).clamp( + 0.0, + math.max(0.0, size.width - text.width), + ); + final double dy = switch (marker.position) { + ChartMarkerPosition.above => + dot.dy - p.markerRadius - p.markerLabelStyle.fontSize * 1.3, + ChartMarkerPosition.below => dot.dy + p.markerRadius + 1.5, + }; + text.paint(canvas, Offset(dx, dy)); + text.dispose(); + } + + if (p.referenceX case final double referenceX) { + final double dx = x(referenceX); + final Paint paint = Paint() + ..color = p.referenceColor.toFlutterColor() + ..strokeWidth = p.referenceWidth; + if (p.referenceDashed) { + for (double dy = 0; dy < chartHeight; dy += 6) { + canvas.drawLine( + Offset(dx, dy), + Offset(dx, math.min(chartHeight, dy + 3)), + paint, + ); + } + } else { + canvas.drawLine(Offset(dx, 0), Offset(dx, chartHeight), paint); + } + } + + if (selectedX case final double selected) { + final double step = math.max(0, p.selectionStep); + if (step > 0) { + canvas.drawRect( + Rect.fromLTRB(x(selected), 0, x(selected + step), chartHeight), + Paint()..color = p.selectionColor.toFlutterColor(), + ); + } + final double center = selected + step / 2; + final double dx = x(center); + if (p.selectionLineWidth > 0) { + canvas.drawLine( + Offset(dx, 0), + Offset(dx, chartHeight), + Paint() + ..color = p.selectionLineColor.toFlutterColor() + ..strokeWidth = p.selectionLineWidth, + ); + } + final double? value = p.valueAt(center, points); + if (value != null && p.selectionDotRadius > 0) { + final Offset dot = Offset(dx, y(value)); + canvas.drawCircle( + dot, + p.selectionDotRadius, + Paint()..color = p.selectionDotColor.toFlutterColor(), + ); + if (p.selectionDotStrokeWidth > 0) { + canvas.drawCircle( + dot, + p.selectionDotRadius, + Paint() + ..color = p.selectionDotStrokeColor.toFlutterColor() + ..style = PaintingStyle.stroke + ..strokeWidth = p.selectionDotStrokeWidth, + ); + } + } + } + + canvas.restore(); + } + + void paintLabel( + Canvas canvas, + String label, + double dx, + double chartHeight, + double labelHeight, + ) { + if (labelHeight <= 0 || label.isEmpty) return; + final TextPainter text = layoutText(label, p.labelStyle); + text.paint(canvas, Offset(dx - text.width / 2, chartHeight + 3)); + text.dispose(); + } + + TextPainter layoutText(String text, ChartTextStyle style, {Color? color}) { + return TextPainter( + text: TextSpan( + text: text, + style: chartTextStyle(style, color: color), + ), + textDirection: TextDirection.ltr, + textScaler: textScaler, + )..layout(); + } + + @override + bool shouldRepaint(covariant ChartPainter oldDelegate) => + oldDelegate.properties != properties || + !_listEquals(oldDelegate.points, points) || + !_listEquals(oldDelegate.markers, markers) || + oldDelegate.selectedX != selectedX || + oldDelegate.textScaler != textScaler || + oldDelegate.fontsGeneration != fontsGeneration; + + static bool _listEquals(List a, List b) { + if (identical(a, b)) return true; + if (a.length != b.length) return false; + for (int i = 0; i < a.length; i++) { + if (a[i] != b[i]) return false; + } + return true; + } +} + +/// The Flutter text style of a chart's [style]. +TextStyle chartTextStyle(ChartTextStyle style, {Color? color}) { + final FontWeight weight = + FontWeight.values[((style.fontWeight / 100).round() - 1).clamp(0, 8)]; + final String? family = style.fontFamily; + if (family != null && GoogleFonts.asMap().containsKey(family)) { + return GoogleFonts.getFont( + family, + fontSize: style.fontSize, + fontWeight: weight, + color: color ?? style.color.toFlutterColor(), + height: style.height, + ); + } + return TextStyle( + fontSize: style.fontSize, + fontWeight: weight, + fontFamily: family, + color: color ?? style.color.toFlutterColor(), + height: style.height, + ); +} + +/// Formats an axis value without a trailing `.0`. +String formatChartNumber(double value) => value == value.roundToDouble() + ? value.toInt().toString() + : double.parse(value.toStringAsFixed(2)).toString(); diff --git a/lib/src/transformers/node_transformers/passive_icon_transformer.dart b/lib/src/transformers/node_transformers/passive_icon_transformer.dart index e1fddacf..64ef013c 100644 --- a/lib/src/transformers/node_transformers/passive_icon_transformer.dart +++ b/lib/src/transformers/node_transformers/passive_icon_transformer.dart @@ -27,24 +27,50 @@ class PassiveIconWidget extends StatelessWidget { final IconNode node; final bool useFonts; + /// The values the icon's bindings read; by default the ones in scope + /// where it builds. A design canvas passes its variable defaults, which + /// its build context doesn't hold. + final ScopedValues? scopedValues; + const PassiveIconWidget({ super.key, required this.node, this.useFonts = false, + this.scopedValues, }); - @override - Widget build(BuildContext context) { + /// [node]'s icon with its bound color and glyph applied: a bound or + /// conditional `icon` names the glyph (e.g. star or star_border by a + /// favorite flag), `iconColor` colors it. + static MultiSourceIconModel resolvedIcon( + IconNode node, + ScopedValues scopedValues, + ) { final ColorRGBA? iconColor = PropertyValueDelegate.getPropertyValue( node, 'iconColor', - scopedValues: ScopedValues.of(context), + scopedValues: scopedValues, ); - final MultiSourceIconModel icon = iconColor == null - ? node.properties.icon - : node.properties.icon.copyWith(color: iconColor); - return retrieveIconWidget(icon, node.basicBoxLocal.width) ?? + final String? glyph = PropertyValueDelegate.getPropertyValue( + node, + 'icon', + scopedValues: scopedValues, + ); + MultiSourceIconModel icon = node.properties.icon; + if (glyph != null && icon.icon != null) { + icon = icon.copyWith(icon: icon.icon!.named(glyph)); + } + if (iconColor != null) icon = icon.copyWith(color: iconColor); + return icon; + } + + @override + Widget build(BuildContext context) { + return retrieveIconWidget( + resolvedIcon(node, scopedValues ?? ScopedValues.of(context)), + node.basicBoxLocal.width, + ) ?? SizedBox( width: node.basicBoxLocal.width, height: node.basicBoxLocal.height, diff --git a/lib/src/transformers/node_transformers/passive_list_view_transformer.dart b/lib/src/transformers/node_transformers/passive_list_view_transformer.dart index 84c03ae9..67d8fb98 100644 --- a/lib/src/transformers/node_transformers/passive_list_view_transformer.dart +++ b/lib/src/transformers/node_transformers/passive_list_view_transformer.dart @@ -1,4 +1,5 @@ import 'dart:core'; +import 'dart:math' as math; import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:codelessly_api/codelessly_api.dart'; @@ -83,11 +84,97 @@ class PassiveListViewWidget extends StatelessWidget { builder: builder, ); + /// Lays out all [itemCount] items in a [Flex] instead of a viewport, for a + /// list that [listBuildsAllItems]. It looks the same, and a Row or Column + /// around it can measure it (a viewport can't report intrinsic sizes), so + /// a stretched row of cards keeps equal heights with a list in one of them. + Widget staticList( + BuildContext context, + int itemCount, + IndexedWidgetBuilder itemBuilder, + ) { + if (listWraps(node)) return wrappedList(context, itemCount, itemBuilder); + final bool vertical = node.scrollDirection == AxisC.vertical; + final List items = [ + for (int index = 0; index < itemCount; index++) ...[ + if (index > 0 && node.properties.hasSeparator) + ListViewItemSeparator( + scrollDirection: node.scrollDirection, + properties: node.properties, + ), + itemBuilder(context, index), + ], + ]; + final Widget flex = Padding( + padding: node.padding.flutterEdgeInsets, + child: Flex( + direction: node.scrollDirection.flutterAxis, + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: node.reverse ? items.reversed.toList() : items, + ), + ); + // The cross axis follows [ViewportLayout]: fill when expanded and + // bounded, else the design size. The scroll axis hugs the items. + final SizeFit crossFit = vertical ? node.horizontalFit : node.verticalFit; + final double designCross = vertical + ? node.basicBoxLocal.width + : node.basicBoxLocal.height; + final bool fill = crossFit == SizeFit.expanded; + return FitBox( + width: vertical ? designCross : null, + height: vertical ? null : designCross, + fillWidth: vertical && fill, + fillHeight: !vertical && fill, + child: node.clipsContent ? ClipRect(child: flex) : flex, + ); + } + + /// Lays out all [itemCount] items of a wrapping list (see [listWraps]) in + /// a [Wrap] along the scroll direction: items flow onto a new run when + /// they don't fit, with the separator's spacing between items and + /// [ListViewProperties.runSpacing] between runs, all aligned to the start. + /// + /// The list is sized by its own fits: fixed takes its design size, expanded + /// fills a bounded axis, and shrink-wrap hugs (along the scroll direction, + /// the widest run; across it, the runs). + Widget wrappedList( + BuildContext context, + int itemCount, + IndexedWidgetBuilder itemBuilder, + ) { + final List items = [ + for (int index = 0; index < itemCount; index++) + itemBuilder(context, index), + ]; + final Widget wrap = Padding( + padding: node.padding.flutterEdgeInsets, + child: Wrap( + direction: node.scrollDirection.flutterAxis, + spacing: listWrapSpacing(node), + runSpacing: math.max(0, node.properties.runSpacing), + children: node.reverse ? items.reversed.toList() : items, + ), + ); + return FitBox( + width: node.horizontalFit == SizeFit.shrinkWrap + ? null + : node.basicBoxLocal.width, + height: node.verticalFit == SizeFit.shrinkWrap + ? null + : node.basicBoxLocal.height, + fillWidth: node.horizontalFit == SizeFit.expanded, + fillHeight: node.verticalFit == SizeFit.expanded, + child: node.clipsContent ? ClipRect(child: wrap) : wrap, + ); + } + /// Builds one item, placed as the editor lays it out; see [ListItemBox]. Widget buildItem(BaseNode item, BuildContext context) => ListItemBox( item: item, scrollDirection: node.scrollDirection, itemExtent: node.properties.itemExtent, + inWrap: listWraps(node), child: manager.buildWidgetByID(item.id, context, settings: settings), ); @@ -302,6 +389,10 @@ class PassiveListViewWidget extends StatelessWidget { itemBuilder: builder, ); + if (itemCount != null && listBuildsAllItems(node)) { + return staticList(context, itemCount, itemBuilder); + } + // An endless list with items of free extent ends after a viewport's // worth of items that take no space, so they cannot be built without // limit; see [EndlessList]. A fixed item extent is bounded by Flutter. diff --git a/lib/src/transformers/node_transformers/passive_rectangle_transformer.dart b/lib/src/transformers/node_transformers/passive_rectangle_transformer.dart index 4ec5efb7..c7048cd6 100644 --- a/lib/src/transformers/node_transformers/passive_rectangle_transformer.dart +++ b/lib/src/transformers/node_transformers/passive_rectangle_transformer.dart @@ -79,13 +79,20 @@ class PassiveRectangleWidget extends StatelessWidget { bool isPlaceholder = parent is CanvasNode && parent.properties.bodyId == node.id; - // The canvas body hugs; the canvas sizes the screen. Every other node - // follows its fits (see FitBox): an expanded axis fills when bounded and - // keeps its design size when not. - final SizeFit horizontalFit = isPlaceholder + // The canvas body hugs; the canvas sizes the screen. A flexible + // Row/Column that scrolls along its main axis hugs that axis: its content + // sizes it, capped by its Flexible parent. Every other node follows its + // fits (see FitBox): an expanded axis fills when bounded and keeps its + // design size when not. + final bool contentSized = + PassiveRowColumnTransformer.scrollsFlexiblyOnMainAxis(node); + final bool isRow = + node is RowColumnMixin && + (node as RowColumnMixin).rowColumnType == RowColumnType.row; + final SizeFit horizontalFit = isPlaceholder || (contentSized && isRow) ? SizeFit.shrinkWrap : node.horizontalFit; - final SizeFit verticalFit = isPlaceholder + final SizeFit verticalFit = isPlaceholder || (contentSized && !isRow) ? SizeFit.shrinkWrap : node.verticalFit; diff --git a/lib/src/transformers/node_transformers/passive_row_column_transformer.dart b/lib/src/transformers/node_transformers/passive_row_column_transformer.dart index b7e72510..6ce88997 100644 --- a/lib/src/transformers/node_transformers/passive_row_column_transformer.dart +++ b/lib/src/transformers/node_transformers/passive_row_column_transformer.dart @@ -29,18 +29,28 @@ class PassiveRowColumnTransformer extends NodeWidgetTransformer { final bool isPlaceholder = parent is CanvasNode && parent.properties.bodyId == rowColumnNode.id; + // A flexible Row/Column that scrolls along its main axis is a Flutter + // Flexible (loose) around its scroll view: as long as its content, at + // most the space its parent gives it, and the content scrolls past that. + final bool contentSized = + withScroll && scrollsFlexiblyOnMainAxis(rowColumnNode); + final FitBox outerBox = FitBox( - width: _outerExtent( - rowColumnNode.horizontalFit, - design: rowColumnNode.outerBoxLocal.width, - min: rowColumnNode.constraints.minWidth, - max: rowColumnNode.constraints.maxWidth, - ), + width: contentSized && isRow + ? null + : _outerExtent( + rowColumnNode.horizontalFit, + design: rowColumnNode.outerBoxLocal.width, + min: rowColumnNode.constraints.minWidth, + max: rowColumnNode.constraints.maxWidth, + ), fillWidth: _fillsAxis( rowColumnNode.horizontalFit, max: rowColumnNode.constraints.maxWidth, ), - height: isPlaceholder && rowColumnNode.verticalFit.isFlex + height: contentSized && !isRow + ? null + : isPlaceholder && rowColumnNode.verticalFit.isFlex ? rowColumnNode.constraints.minHeight : _outerExtent( rowColumnNode.verticalFit, @@ -121,9 +131,12 @@ class PassiveRowColumnTransformer extends NodeWidgetTransformer { res = wrapWithPaddedScrollable( padding: rowColumnNode.resolvedPadding().flutterEdgeInsets, node: (rowColumnNode as ScrollableMixin), + // Sized by its content, there is no free space to align it in. fillViewport: + !contentSized && rowColumnNode.mainAxisAlignment != MainAxisAlignmentC.start, flexContent: hasMainAxisFlexChild(rowColumnNode, childrenNodes), + contentSizedOnScrollAxis: contentSized, child: res, ); } @@ -205,6 +218,27 @@ class PassiveRowColumnTransformer extends NodeWidgetTransformer { return res; } + /// Whether [node] is a Row/Column that scrolls along its main axis and is + /// flexible on it: it is as long as its content, capped by its [Flexible] + /// parent, like the editor's `min(flex share, content)`. + static bool scrollsFlexiblyOnMainAxis(BaseNode node) { + if (node is! RowColumnMixin || node is! ScrollableMixin) return false; + if (rowColumnWraps(node)) return false; + final ScrollableMixin scrollable = node as ScrollableMixin; + final bool isRow = node.rowColumnType == RowColumnType.row; + if (!scrollable.isScrollable) return false; + if (scrollable.scrollDirection != + (isRow ? AxisC.horizontal : AxisC.vertical)) { + return false; + } + return (isRow ? node.horizontalFit : node.verticalFit) == SizeFit.flexible; + } + + /// Whether [node] is a row/column whose children flow onto new runs when + /// they don't fit ([RowColumnMixin.wrap]), built as a [CodelesslyWrap]. + static bool rowColumnWraps(BaseNode node) => + node is RowColumnMixin && node.wrap; + /// The [Wrap.alignment] of a wrapping row/column with [alignment] as its /// main-axis alignment. static WrapAlignment wrapAlignment(MainAxisAlignmentC alignment) => diff --git a/lib/src/transformers/node_transformers/passive_text_field_transformer.dart b/lib/src/transformers/node_transformers/passive_text_field_transformer.dart index 7260acc1..eb09aeac 100644 --- a/lib/src/transformers/node_transformers/passive_text_field_transformer.dart +++ b/lib/src/transformers/node_transformers/passive_text_field_transformer.dart @@ -551,7 +551,10 @@ class _PassiveTextFieldWidgetState extends State { floatingLabelBehavior: decoration.floatingLabelBehavior.toFlutter(), isCollapsed: isCollapsed, isDense: isDense, - contentPadding: node.padding.flutterEdgeInsets, + // The decoration's own content padding wins over the node's padding, + // which defaults to 12 x 16 for text fields: zero makes a bare input. + contentPadding: + (decoration.contentPadding ?? node.padding).flutterEdgeInsets, prefixIcon: !decoration.prefixIcon.icon.show || decoration.prefixIcon.icon.isEmpty ? null diff --git a/lib/src/transformers/passive_transformer_manager.dart b/lib/src/transformers/passive_transformer_manager.dart index b977354e..572ac5ae 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), + 'chart': PassiveChartTransformer(getNode, this), 'variance': PassiveVarianceTransformer(getNode, this), 'webView': PassiveWebViewTransformer(getNode, this), 'listView': PassiveListViewTransformer(getNode, this), @@ -175,10 +176,10 @@ class PassiveNodeTransformerManager extends WidgetNodeTransformerManager { /// Returns [node] with the style properties that conditions, bound /// variables or formulas, or set value actions give it at runtime: `fill` - /// (a text's color for text nodes), `stroke`, `opacity`, `width`, - /// `height`, `flex` (its share of an expanded row or column, so bars - /// can be sized proportionally) and `rotation` (in degrees). Returns - /// [node] itself when nothing changes it. + /// (a text's color for text nodes), `stroke`, `strokeWeight`, `opacity`, + /// `cornerRadius`, `padding`, `width`, `height`, `flex` (its share of an + /// expanded row or column, so bars can be sized proportionally) and + /// `rotation` (in degrees). Returns [node] itself when nothing changes it. BaseNode applyStyleOverrides(BuildContext context, BaseNode node) { final ScopedValues scopedValues = ScopedValues.of(context); T? value(String property) => @@ -205,6 +206,11 @@ class PassiveNodeTransformerManager extends WidgetNodeTransformerManager { for (final paint in geometry.strokes.skip(1)) paint.toJson(), ]; } + // A selected row's border can thicken, e.g. 1.5 to 2 px. + final double? strokeWeight = value('strokeWeight'); + if (strokeWeight != null && strokeWeight != geometry.strokeWeight) { + patch['strokeWeight'] = strokeWeight < 0 ? 0.0 : strokeWeight; + } } if (node is TextNode) { // A text's fill is its color: it replaces the color of every span. @@ -227,6 +233,21 @@ class PassiveNodeTransformerManager extends WidgetNodeTransformerManager { patch['opacity'] = opacity.clamp(0, 1).toDouble(); } } + if (node is CornerMixin) { + // A number rounds every corner; [tl, tr, br, bl] rounds each (a bottom + // sheet's top corners). See [ConversionExt.toCornerRadius]. + final CornerRadius? radius = value('cornerRadius') + ?.toCornerRadius(); + if (radius != null && radius != node.cornerRadius) { + patch['cornerRadius'] = radius.toJson(); + } + } + // A number pads every side; [h, v] or [l, t, r, b] pad each. + final EdgeInsetsModel? padding = value('padding') + ?.toEdgeInsetsModel(); + if (padding != null && padding != node.padding) { + patch['padding'] = padding.toJson(); + } final NodeBox box = node.basicBoxLocal; final double? width = value('width'); final double? height = value('height'); diff --git a/lib/src/transformers/transformer_functions.dart b/lib/src/transformers/transformer_functions.dart index 5a4302eb..e4f69524 100644 --- a/lib/src/transformers/transformer_functions.dart +++ b/lib/src/transformers/transformer_functions.dart @@ -662,18 +662,28 @@ bool isCustomPositionRequiredForImage(PaintModel paint, SizeC nodeSize) { /// Wraps [child] in [node]'s scroll view, sized by [node]'s fits (see /// [FitBox]). See [buildScrollView] for [fillViewport] and [flexContent]. +/// +/// With [contentSizedOnScrollAxis] the scroll view hugs its content along +/// the scroll axis (at most the incoming maximum) instead of taking its +/// design size: a flexible Row/Column that scrolls. Widget wrapWithPaddedScrollable({ required ScrollableMixin node, EdgeInsets? padding, Clip? clipBehavior, bool fillViewport = false, bool flexContent = false, + bool contentSizedOnScrollAxis = false, required Widget child, }) { if (!node.isScrollable) return child; + final bool vertical = node.scrollDirection.isVertical; return FitBox.fromFits( - horizontalFit: node.horizontalFit, - verticalFit: node.verticalFit, + horizontalFit: contentSizedOnScrollAxis && !vertical + ? SizeFit.shrinkWrap + : node.horizontalFit, + verticalFit: contentSizedOnScrollAxis && vertical + ? SizeFit.shrinkWrap + : node.verticalFit, size: node.basicBoxLocal.size, child: buildScrollView( node: node, diff --git a/lib/src/transformers/utils/scoped_values.dart b/lib/src/transformers/utils/scoped_values.dart index 25d85b63..9664bbda 100644 --- a/lib/src/transformers/utils/scoped_values.dart +++ b/lib/src/transformers/utils/scoped_values.dart @@ -53,8 +53,10 @@ class ScopedValues { Map get variables => { ...?_previewVariables, ...codelesslyContext?.variableNamesMap() ?? const {}, + // By name, as everything reading this map looks variables up (a + // formula's `formStart`); keyed by id an override was never found. for (final variable in _variablesOverrides ?? const []) - variable.id: variable, + variable.name: variable, }; /// A map of data that is passed to loaded layouts for nodes to replace their diff --git a/lib/src/transformers/utils/text_utils.dart b/lib/src/transformers/utils/text_utils.dart index 127c6811..eeee3395 100644 --- a/lib/src/transformers/utils/text_utils.dart +++ b/lib/src/transformers/utils/text_utils.dart @@ -31,8 +31,11 @@ class TextUtils { String characters = rawText; // Get substring from the raw text for the given start and end positions. + // A range past the text (left by an edit that shortened it) is clamped, + // so one bad range can't throw on every layout and paint. if (prop is StartEndProp) { - characters = rawText.substring(prop.start, prop.end); + final int end = prop.end.clamp(0, rawText.length); + characters = rawText.substring(prop.start.clamp(0, end), end); } return buildTextSpan( diff --git a/lib/src/transformers/utils/variable_span.dart b/lib/src/transformers/utils/variable_span.dart index 868654be..2edda925 100644 --- a/lib/src/transformers/utils/variable_span.dart +++ b/lib/src/transformers/utils/variable_span.dart @@ -15,6 +15,21 @@ abstract final class VariablePreview { /// fall back to placeholders. static ScopedValues? Function(BaseNode? node)? scopeResolver; + /// The values [node] sees on the design canvas, as [scopeResolver] gives + /// them, with [item] as `item` / `index` when it carries data: a list on + /// the canvas builds each item with its own element of the list's data, + /// while [scopeResolver] only knows the node (so it gives the first item). + static ScopedValues? scopeFor(BaseNode? node, {IndexedItem? item}) { + final ScopedValues? values = scopeResolver?.call(node); + if (values == null || item == null || item.item == null) return values; + return ScopedValues.preview( + variables: values.variables, + codelesslyContext: values.codelesslyContext, + screenSize: values.screenSize, + indexedItem: item, + ); + } + /// Accessors that read a property of a value rather than name it, so the /// placeholder for `${item.title.initial}` is named after `title`. static const Set _accessorNames = { @@ -47,15 +62,7 @@ abstract final class VariablePreview { TextStyle? style, IndexedItem? item, }) { - ScopedValues? scopedValues = scopeResolver?.call(node); - if (scopedValues != null && item != null) { - scopedValues = ScopedValues.preview( - variables: scopedValues.variables, - codelesslyContext: scopedValues.codelesslyContext, - screenSize: scopedValues.screenSize, - indexedItem: item, - ); - } + final ScopedValues? scopedValues = scopeFor(node, item: item); if (scopedValues != null) { final String value = PropertyValueDelegate.substituteVariables( expression, @@ -148,7 +155,8 @@ class VariableSpan extends TextSpan { /// The variable expression, such as `${user.name}`. final String variable; - /// Creates a [VariableSpan] for the [variable] expression of [node]. + /// Creates a [VariableSpan] for the [variable] expression of [node], in + /// list [item] when the node is inside a list. VariableSpan({ required this.variable, BaseNode? node, diff --git a/lib/src/transformers/utils/viewport_sizing.dart b/lib/src/transformers/utils/viewport_sizing.dart index 74083b90..86538f27 100644 --- a/lib/src/transformers/utils/viewport_sizing.dart +++ b/lib/src/transformers/utils/viewport_sizing.dart @@ -177,6 +177,9 @@ class ViewportNodeBox extends StatelessWidget { /// * Scroll axis: with [itemExtent] the item fills the extent, as in the /// editor. Otherwise an expanded or flexible item, which has nothing to /// fill along an endless axis, takes its design extent. +/// +/// In a wrapping list ([inWrap]) the item is not aligned across the list: +/// it sits in its run at its own size, as [Wrap] places it. class ListItemBox extends StatelessWidget { /// The item node. final BaseNode item; @@ -190,6 +193,9 @@ class ListItemBox extends StatelessWidget { /// The built item. final Widget child; + /// Whether the item is a child of a wrapping list's [Wrap]. + final bool inWrap; + /// Creates a [ListItemBox]. const ListItemBox({ super.key, @@ -197,6 +203,7 @@ class ListItemBox extends StatelessWidget { required this.scrollDirection, required this.itemExtent, required this.child, + this.inWrap = false, }); @override @@ -217,6 +224,7 @@ class ListItemBox extends StatelessWidget { child: result, ); } + if (inWrap) return result; return Align( alignment: vertical ? Alignment.topCenter : Alignment.centerLeft, child: result, @@ -300,6 +308,41 @@ class DesignCellGridDelegate extends SliverGridDelegate { oldDelegate.mainAxisExtent != mainAxisExtent; } +/// Whether list [node] has a known item count: bound data or a set count, +/// and no cloud query (whose length is only known once it loads, page by +/// page). +bool listHasKnownItemCount(ListViewNode node) => + !node.useCloudDatabase && + (node.variables['data'] != null || node.properties.itemCount != null); + +/// Whether list [node] lays its items out in a [Wrap]: it wraps +/// ([ListViewProperties.wrap]) and has a known item count. Such a list builds +/// all its items and never scrolls, whatever its scroll settings. A list +/// with an unknown count ignores [ListViewProperties.wrap] and scrolls. +bool listWraps(ListViewNode node) => + node.properties.wrap && listHasKnownItemCount(node); + +/// Whether list [node] builds all its items up front, as a plain [Flex] or a +/// [Wrap] rather than a viewport: it wraps (see [listWraps]), or it doesn't +/// scroll, hugs its items along its axis and has a known item count (bound +/// data or a set count). Such a list builds every item either way, so it +/// needs no viewport. +bool listBuildsAllItems(ListViewNode node) { + if (listWraps(node)) return true; + final SizeFit mainFit = node.scrollDirection == AxisC.vertical + ? node.verticalFit + : node.horizontalFit; + return !node.isScrollable && + mainFit == SizeFit.shrinkWrap && + listHasKnownItemCount(node); +} + +/// The gap between consecutive items of a wrapping list: the separator's +/// spacing when it has one. A wrapping list draws no dividers. +double listWrapSpacing(ListViewNode node) => node.properties.hasSeparator + ? math.max(0, node.properties.separatorSpacing) + : 0; + /// Whether [node] or any node below it builds a widget that cannot report /// intrinsic sizes: a list, grid or page view (a viewport behind a /// [LayoutBuilder]), a scrolling row, column or frame (a [LayoutBuilder] @@ -310,7 +353,7 @@ class DesignCellGridDelegate extends SliverGridDelegate { /// "RenderViewport does not support returning intrinsic dimensions"), so a /// caller that adds an intrinsic wrapper must skip it when this is true. bool subtreeBlocksIntrinsics(BaseNode node, GetNode getNode) { - if (node is ListViewNode || + if ((node is ListViewNode && !listBuildsAllItems(node)) || node is GridViewNode || node is PageViewNode || node is WebViewNode) { diff --git a/lib/src/ui/codelessly_dialog_widget.dart b/lib/src/ui/codelessly_dialog_widget.dart index e9af3071..0256baf5 100644 --- a/lib/src/ui/codelessly_dialog_widget.dart +++ b/lib/src/ui/codelessly_dialog_widget.dart @@ -11,13 +11,30 @@ class CodelesslyDialogWidget extends StatelessWidget { /// or anywhere else, e.g. a menu at the top right. final AlignmentGeometry alignment; + /// Space kept between the dialog and the screen edges. + final EdgeInsets insets; + + /// The most of the screen's height the dialog may take, as a share of it + /// (see [ShowDialogAction.maxHeightFactor]). Null keeps the canvas's + /// design height. + final double? maxHeightFactor; + const CodelesslyDialogWidget({ super.key, required this.builder, required this.showCloseButton, this.alignment = Alignment.center, + this.insets = EdgeInsets.zero, + this.maxHeightFactor, }); + /// The height cap of a canvas shown on a screen [screenHeight] tall with + /// [factor] (see [ShowDialogAction.maxHeightFactor]), or null for none. + static double? maxHeightFor(double? factor, double screenHeight) { + if (factor == null || !factor.isFinite || factor <= 0) return null; + return screenHeight * factor.clamp(0, 1); + } + @override Widget build(BuildContext context) { // A dialog is centered and sized by its layout, so its canvas keeps its @@ -29,9 +46,30 @@ class CodelesslyDialogWidget extends StatelessWidget { } Widget _buildDialog(BuildContext context) { + return Padding( + padding: insets, + child: Align(alignment: alignment, child: _buildContent(context)), + ); + } + + Widget _buildContent(BuildContext context) { + // The canvas keeps its design height up to the cap; a taller design is + // laid out at the cap and its content (a flexible or expanded scrolling + // body) adapts. A canvas sized by its content stops at the cap too. + final double? maxHeight = maxHeightFor( + maxHeightFactor, + MediaQuery.sizeOf(context).height, + ); + if (maxHeight == null) return _buildCanvas(context); + return ConstrainedBox( + constraints: BoxConstraints(maxHeight: maxHeight), + child: _buildCanvas(context), + ); + } + + Widget _buildCanvas(BuildContext context) { if (showCloseButton) { - return Align( - alignment: alignment, + return Center( child: Material( type: MaterialType.transparency, child: Stack( @@ -55,6 +93,6 @@ class CodelesslyDialogWidget extends StatelessWidget { ); } - return Align(alignment: alignment, child: builder(context)); + return builder(context); } } diff --git a/lib/src/utils/extensions.dart b/lib/src/utils/extensions.dart index 8cc1c817..6e5b8898 100644 --- a/lib/src/utils/extensions.dart +++ b/lib/src/utils/extensions.dart @@ -1128,6 +1128,23 @@ extension BoxWidthStyleEnumExtensions on BoxWidthStyleEnum { } extension IconModelExt on IconModel { + /// This icon's glyph swapped for the one called [name] (e.g. `star_border`) + /// in the same set and style, or this icon when [name] is empty or the set + /// can't be looked up by name (Cupertino icons are found by codepoint). + IconModel named(String name) { + final String glyph = name.trim().toLowerCase(); + if (glyph.isEmpty || glyph == this.name) return this; + final IconModel icon = this; + return switch (icon) { + MaterialIcon() => icon.copyWith( + name: glyph, + version: materialIconVersions[glyph] ?? icon.version, + ), + MaterialSymbol() => icon.copyWith(name: glyph), + _ => icon, + }; + } + String get label => name.split('_').map((part) => part.capitalized).join(' '); // IconData toFontIconData() { @@ -1763,6 +1780,17 @@ extension ActionModelExt on ActionModel { ]); case RemoveDocumentSubAction action: strings.addAll([action.path, action.documentId]); + case RemoveDocumentsSubAction action: + strings.addAll([ + action.path, + action.documentIds, + for (final filter in action.whereFilters) ...[ + filter.field, + filter.value, + ], + ]); + case SetDocumentsSubAction action: + strings.addAll([action.path, action.documents]); case UpdateDocumentSubAction action: strings.addAll([ action.path, diff --git a/lib/src/utils/material_icon_versions.dart b/lib/src/utils/material_icon_versions.dart new file mode 100644 index 00000000..c9dddf42 --- /dev/null +++ b/lib/src/utils/material_icon_versions.dart @@ -0,0 +1,2129 @@ +// Generated from the Material Icons metadata (fonts.google.com); do not edit. + +/// The latest version of each Material Icon by name, as its SVG URL on +/// fonts.gstatic.com needs it. Used to show an icon chosen by name at +/// runtime (an icon bound to a variable or formula). +const Map materialIconVersions = { + '10k': 10, + '10mp': 10, + '11mp': 10, + '123': 1, + '12mp': 10, + '13mp': 10, + '14mp': 10, + '15mp': 10, + '16mp': 10, + '17mp': 10, + '18_up_rating': 1, + '18mp': 10, + '19mp': 10, + '1k': 10, + '1k_plus': 10, + '1x_mobiledata': 9, + '20mp': 10, + '21mp': 10, + '22mp': 10, + '23mp': 10, + '24mp': 10, + '2k': 10, + '2k_plus': 10, + '2mp': 10, + '30fps': 10, + '30fps_select': 10, + '360': 14, + '3d_rotation': 18, + '3g_mobiledata': 9, + '3k': 10, + '3k_plus': 10, + '3mp': 10, + '3p': 11, + '4g_mobiledata': 9, + '4g_plus_mobiledata': 9, + '4k': 11, + '4k_plus': 10, + '4mp': 10, + '5g': 13, + '5k': 10, + '5k_plus': 10, + '5mp': 10, + '60fps': 10, + '60fps_select': 10, + '6_ft_apart': 9, + '6k': 10, + '6k_plus': 10, + '6mp': 10, + '7k': 10, + '7k_plus': 10, + '7mp': 10, + '8k': 11, + '8k_plus': 11, + '8mp': 10, + '9k': 10, + '9k_plus': 10, + '9mp': 10, + 'abc': 1, + 'ac_unit': 12, + 'access_alarm': 12, + 'access_alarms': 12, + 'access_time': 12, + 'access_time_filled': 10, + 'accessibility': 13, + 'accessibility_new': 13, + 'accessible': 13, + 'accessible_forward': 13, + 'account_balance': 19, + 'account_balance_wallet': 12, + 'account_box': 14, + 'account_circle': 20, + 'account_tree': 12, + 'ad_units': 13, + 'adb': 12, + 'add': 21, + 'add_a_photo': 14, + 'add_alarm': 13, + 'add_alert': 16, + 'add_box': 13, + 'add_business': 17, + 'add_card': 1, + 'add_chart': 11, + 'add_circle': 13, + 'add_circle_outline': 16, + 'add_comment': 11, + 'add_home': 1, + 'add_home_work': 1, + 'add_ic_call': 12, + 'add_link': 11, + 'add_location': 16, + 'add_location_alt': 13, + 'add_moderator': 12, + 'add_photo_alternate': 12, + 'add_reaction': 9, + 'add_road': 12, + 'add_shopping_cart': 15, + 'add_task': 6, + 'add_to_drive': 18, + 'add_to_home_screen': 12, + 'add_to_photos': 12, + 'add_to_queue': 11, + 'addchart': 13, + 'adf_scanner': 2, + 'adjust': 12, + 'admin_panel_settings': 13, + 'ads_click': 3, + 'agriculture': 11, + 'air': 10, + 'airline_seat_flat': 12, + 'airline_seat_flat_angled': 12, + 'airline_seat_individual_suite': 11, + 'airline_seat_legroom_extra': 12, + 'airline_seat_legroom_normal': 12, + 'airline_seat_legroom_reduced': 12, + 'airline_seat_recline_extra': 12, + 'airline_seat_recline_normal': 12, + 'airline_stops': 2, + 'airlines': 2, + 'airplane_ticket': 11, + 'airplanemode_active': 16, + 'airplanemode_inactive': 17, + 'airplay': 13, + 'airport_shuttle': 12, + 'alarm': 12, + 'alarm_add': 12, + 'alarm_off': 12, + 'alarm_on': 12, + 'album': 12, + 'align_horizontal_center': 6, + 'align_horizontal_left': 7, + 'align_horizontal_right': 7, + 'align_vertical_bottom': 6, + 'align_vertical_center': 6, + 'align_vertical_top': 6, + 'all_inbox': 11, + 'all_inclusive': 11, + 'all_out': 12, + 'alt_route': 9, + 'alternate_email': 13, + 'analytics': 12, + 'anchor': 7, + 'android': 15, + 'animation': 14, + 'announcement': 18, + 'aod': 10, + 'apartment': 11, + 'api': 7, + 'app_blocking': 13, + 'app_registration': 11, + 'app_settings_alt': 17, + 'app_shortcut': 2, + 'approval': 11, + 'apps': 12, + 'apps_outage': 2, + 'architecture': 11, + 'archive': 16, + 'area_chart': 3, + 'arrow_back': 17, + 'arrow_back_ios': 14, + 'arrow_back_ios_new': 6, + 'arrow_circle_down': 8, + 'arrow_circle_left': 2, + 'arrow_circle_right': 2, + 'arrow_circle_up': 8, + 'arrow_downward': 17, + 'arrow_drop_down': 16, + 'arrow_drop_down_circle': 12, + 'arrow_drop_up': 12, + 'arrow_forward': 13, + 'arrow_forward_ios': 16, + 'arrow_left': 12, + 'arrow_outward': 1, + 'arrow_right': 12, + 'arrow_right_alt': 13, + 'arrow_upward': 11, + 'art_track': 13, + 'article': 13, + 'aspect_ratio': 12, + 'assessment': 12, + 'assignment': 15, + 'assignment_ind': 12, + 'assignment_late': 15, + 'assignment_return': 14, + 'assignment_returned': 12, + 'assignment_turned_in': 16, + 'assist_walker': 1, + 'assistant': 12, + 'assistant_direction': 14, + 'assistant_photo': 12, + 'assured_workload': 1, + 'atm': 12, + 'attach_email': 16, + 'attach_file': 16, + 'attach_money': 17, + 'attachment': 15, + 'attractions': 10, + 'attribution': 11, + 'audio_file': 1, + 'audiotrack': 12, + 'auto_awesome': 14, + 'auto_awesome_mosaic': 14, + 'auto_awesome_motion': 14, + 'auto_delete': 11, + 'auto_fix_high': 14, + 'auto_fix_normal': 14, + 'auto_fix_off': 13, + 'auto_graph': 4, + 'auto_mode': 1, + 'auto_stories': 13, + 'autofps_select': 10, + 'autorenew': 12, + 'av_timer': 12, + 'baby_changing_station': 8, + 'back_hand': 3, + 'backpack': 8, + 'backspace': 13, + 'backup': 15, + 'backup_table': 11, + 'badge': 11, + 'bakery_dining': 11, + 'balance': 1, + 'balcony': 4, + 'ballot': 14, + 'bar_chart': 13, + 'batch_prediction': 12, + 'bathroom': 10, + 'bathtub': 11, + 'battery_0_bar': 1, + 'battery_1_bar': 1, + 'battery_2_bar': 1, + 'battery_3_bar': 1, + 'battery_4_bar': 1, + 'battery_5_bar': 1, + 'battery_6_bar': 1, + 'battery_alert': 12, + 'battery_charging_full': 12, + 'battery_full': 12, + 'battery_saver': 9, + 'battery_std': 12, + 'battery_unknown': 13, + 'beach_access': 12, + 'bed': 10, + 'bedroom_baby': 9, + 'bedroom_child': 9, + 'bedroom_parent': 9, + 'bedtime': 15, + 'bedtime_off': 2, + 'beenhere': 12, + 'bento': 6, + 'bike_scooter': 11, + 'biotech': 11, + 'blender': 9, + 'blind': 1, + 'blinds': 3, + 'blinds_closed': 1, + 'block': 17, + 'bloodtype': 10, + 'bluetooth': 11, + 'bluetooth_audio': 12, + 'bluetooth_connected': 12, + 'bluetooth_disabled': 17, + 'bluetooth_drive': 9, + 'bluetooth_searching': 13, + 'blur_circular': 12, + 'blur_linear': 12, + 'blur_off': 12, + 'blur_on': 12, + 'bolt': 10, + 'book': 16, + 'book_online': 10, + 'bookmark': 17, + 'bookmark_add': 3, + 'bookmark_added': 3, + 'bookmark_border': 12, + 'bookmark_remove': 3, + 'bookmarks': 12, + 'border_all': 12, + 'border_bottom': 12, + 'border_clear': 12, + 'border_color': 15, + 'border_horizontal': 12, + 'border_inner': 12, + 'border_left': 12, + 'border_outer': 12, + 'border_right': 12, + 'border_style': 12, + 'border_top': 12, + 'border_vertical': 12, + 'boy': 1, + 'branding_watermark': 13, + 'breakfast_dining': 11, + 'brightness_1': 19, + 'brightness_2': 12, + 'brightness_3': 16, + 'brightness_4': 16, + 'brightness_5': 12, + 'brightness_6': 12, + 'brightness_7': 13, + 'brightness_auto': 12, + 'brightness_high': 13, + 'brightness_low': 12, + 'brightness_medium': 12, + 'broadcast_on_home': 1, + 'broadcast_on_personal': 1, + 'broken_image': 11, + 'browse_gallery': 1, + 'browser_not_supported': 12, + 'browser_updated': 2, + 'brunch_dining': 11, + 'brush': 13, + 'bubble_chart': 12, + 'bug_report': 12, + 'build': 12, + 'build_circle': 14, + 'bungalow': 4, + 'burst_mode': 12, + 'bus_alert': 15, + 'business': 12, + 'business_center': 15, + 'cabin': 4, + 'cable': 10, + 'cached': 13, + 'cake': 13, + 'calculate': 11, + 'calendar_month': 1, + 'calendar_today': 12, + 'calendar_view_day': 15, + 'calendar_view_month': 10, + 'calendar_view_week': 10, + 'call': 17, + 'call_end': 16, + 'call_made': 13, + 'call_merge': 13, + 'call_missed': 13, + 'call_missed_outgoing': 15, + 'call_received': 13, + 'call_split': 13, + 'call_to_action': 11, + 'camera': 12, + 'camera_alt': 12, + 'camera_enhance': 12, + 'camera_front': 12, + 'camera_indoor': 9, + 'camera_outdoor': 9, + 'camera_rear': 13, + 'camera_roll': 12, + 'cameraswitch': 10, + 'campaign': 12, + 'cancel': 16, + 'cancel_presentation': 14, + 'cancel_schedule_send': 12, + 'candlestick_chart': 2, + 'car_crash': 1, + 'car_rental': 10, + 'car_repair': 10, + 'card_giftcard': 14, + 'card_membership': 14, + 'card_travel': 13, + 'carpenter': 6, + 'cases': 12, + 'casino': 13, + 'cast': 18, + 'cast_connected': 13, + 'cast_for_education': 14, + 'castle': 2, + 'catching_pokemon': 4, + 'category': 12, + 'celebration': 11, + 'cell_tower': 1, + 'cell_wifi': 14, + 'center_focus_strong': 12, + 'center_focus_weak': 12, + 'chair': 10, + 'chair_alt': 9, + 'chalet': 4, + 'change_circle': 8, + 'change_history': 11, + 'charging_station': 8, + 'chat': 19, + 'chat_bubble': 15, + 'chat_bubble_outline': 18, + 'check': 18, + 'check_box': 16, + 'check_box_outline_blank': 16, + 'check_circle': 19, + 'check_circle_outline': 12, + 'checklist': 3, + 'checklist_rtl': 3, + 'checkroom': 8, + 'chevron_left': 16, + 'chevron_right': 16, + 'child_care': 12, + 'child_friendly': 12, + 'chrome_reader_mode': 12, + 'church': 2, + 'circle': 11, + 'circle_notifications': 11, + 'class': 11, + 'clean_hands': 8, + 'cleaning_services': 12, + 'clear': 12, + 'clear_all': 12, + 'close': 19, + 'close_fullscreen': 7, + 'closed_caption': 12, + 'closed_caption_disabled': 8, + 'closed_caption_off': 11, + 'cloud': 12, + 'cloud_circle': 12, + 'cloud_done': 12, + 'cloud_download': 12, + 'cloud_off': 16, + 'cloud_queue': 12, + 'cloud_sync': 1, + 'cloud_upload': 12, + 'co2': 3, + 'co_present': 1, + 'code': 11, + 'code_off': 4, + 'coffee': 9, + 'coffee_maker': 9, + 'collections': 12, + 'collections_bookmark': 12, + 'color_lens': 16, + 'colorize': 12, + 'comment': 19, + 'comment_bank': 15, + 'comments_disabled': 3, + 'commit': 1, + 'commute': 12, + 'compare': 12, + 'compare_arrows': 14, + 'compass_calibration': 12, + 'compost': 3, + 'compress': 10, + 'computer': 12, + 'confirmation_number': 15, + 'connect_without_contact': 7, + 'connected_tv': 11, + 'connecting_airports': 2, + 'construction': 11, + 'contact_emergency': 1, + 'contact_mail': 13, + 'contact_page': 6, + 'contact_phone': 13, + 'contact_support': 13, + 'contactless': 11, + 'contacts': 11, + 'content_copy': 17, + 'content_cut': 17, + 'content_paste': 14, + 'content_paste_go': 2, + 'content_paste_off': 4, + 'content_paste_search': 2, + 'contrast': 1, + 'control_camera': 12, + 'control_point': 13, + 'control_point_duplicate': 12, + 'cookie': 2, + 'copy_all': 8, + 'copyright': 17, + 'coronavirus': 7, + 'corporate_fare': 7, + 'cottage': 4, + 'countertops': 6, + 'create': 16, + 'create_new_folder': 11, + 'credit_card': 12, + 'credit_card_off': 4, + 'credit_score': 10, + 'crib': 4, + 'crisis_alert': 1, + 'crop': 12, + 'crop_16_9': 13, + 'crop_3_2': 13, + 'crop_5_4': 13, + 'crop_7_5': 13, + 'crop_din': 12, + 'crop_free': 12, + 'crop_landscape': 12, + 'crop_original': 13, + 'crop_portrait': 12, + 'crop_rotate': 12, + 'crop_square': 12, + 'cruelty_free': 3, + 'css': 1, + 'currency_bitcoin': 1, + 'currency_exchange': 1, + 'currency_franc': 1, + 'currency_lira': 1, + 'currency_pound': 1, + 'currency_ruble': 1, + 'currency_rupee': 1, + 'currency_yen': 1, + 'currency_yuan': 1, + 'curtains': 1, + 'curtains_closed': 1, + 'cyclone': 1, + 'dangerous': 12, + 'dark_mode': 4, + 'dashboard': 13, + 'dashboard_customize': 11, + 'data_array': 2, + 'data_exploration': 3, + 'data_object': 2, + 'data_saver_off': 10, + 'data_saver_on': 10, + 'data_thresholding': 1, + 'data_usage': 12, + 'dataset': 1, + 'dataset_linked': 1, + 'date_range': 12, + 'deblur': 1, + 'deck': 11, + 'dehaze': 12, + 'delete': 17, + 'delete_forever': 15, + 'delete_outline': 11, + 'delete_sweep': 12, + 'delivery_dining': 15, + 'density_large': 1, + 'density_medium': 1, + 'density_small': 1, + 'departure_board': 11, + 'description': 12, + 'deselect': 1, + 'design_services': 12, + 'desk': 1, + 'desktop_access_disabled': 11, + 'desktop_mac': 17, + 'desktop_windows': 13, + 'details': 16, + 'developer_board': 13, + 'developer_board_off': 4, + 'developer_mode': 12, + 'device_hub': 12, + 'device_thermostat': 11, + 'device_unknown': 12, + 'devices': 17, + 'devices_fold': 1, + 'devices_other': 12, + 'dialer_sip': 12, + 'dialpad': 12, + 'diamond': 2, + 'difference': 1, + 'dining': 9, + 'dinner_dining': 10, + 'directions': 14, + 'directions_bike': 13, + 'directions_boat': 17, + 'directions_boat_filled': 16, + 'directions_bus': 12, + 'directions_bus_filled': 10, + 'directions_car': 18, + 'directions_car_filled': 10, + 'directions_off': 14, + 'directions_railway': 13, + 'directions_railway_filled': 10, + 'directions_run': 13, + 'directions_subway': 12, + 'directions_subway_filled': 10, + 'directions_transit': 12, + 'directions_transit_filled': 10, + 'directions_walk': 13, + 'dirty_lens': 15, + 'disabled_by_default': 6, + 'disabled_visible': 3, + 'disc_full': 12, + 'discount': 1, + 'display_settings': 1, + 'diversity_1': 1, + 'diversity_2': 1, + 'diversity_3': 1, + 'dns': 12, + 'do_disturb': 10, + 'do_disturb_alt': 11, + 'do_disturb_off': 10, + 'do_disturb_on': 10, + 'do_not_disturb': 11, + 'do_not_disturb_alt': 12, + 'do_not_disturb_off': 11, + 'do_not_disturb_on': 11, + 'do_not_disturb_on_total_silence': 15, + 'do_not_step': 8, + 'do_not_touch': 8, + 'dock': 12, + 'document_scanner': 3, + 'domain': 16, + 'domain_add': 1, + 'domain_disabled': 12, + 'domain_verification': 11, + 'done': 19, + 'done_all': 12, + 'done_outline': 12, + 'donut_large': 13, + 'donut_small': 12, + 'door_back': 10, + 'door_front': 10, + 'door_sliding': 10, + 'doorbell': 10, + 'double_arrow': 11, + 'downhill_skiing': 4, + 'download': 10, + 'download_done': 10, + 'download_for_offline': 9, + 'downloading': 9, + 'drafts': 17, + 'drag_handle': 14, + 'drag_indicator': 15, + 'draw': 6, + 'drive_eta': 12, + 'drive_file_move': 19, + 'drive_file_move_rtl': 3, + 'drive_file_rename_outline': 12, + 'drive_folder_upload': 11, + 'dry': 8, + 'dry_cleaning': 10, + 'duo': 12, + 'dvr': 13, + 'dynamic_feed': 15, + 'dynamic_form': 7, + 'e_mobiledata': 9, + 'earbuds': 9, + 'earbuds_battery': 9, + 'east': 7, + 'edgesensor_high': 10, + 'edgesensor_low': 10, + 'edit': 12, + 'edit_attributes': 12, + 'edit_calendar': 5, + 'edit_location': 16, + 'edit_location_alt': 8, + 'edit_note': 5, + 'edit_notifications': 4, + 'edit_off': 15, + 'edit_road': 11, + 'egg': 2, + 'egg_alt': 2, + 'eject': 11, + 'elderly': 7, + 'elderly_woman': 1, + 'electric_bike': 14, + 'electric_bolt': 1, + 'electric_car': 14, + 'electric_meter': 1, + 'electric_moped': 14, + 'electric_rickshaw': 15, + 'electric_scooter': 14, + 'electrical_services': 12, + 'elevator': 8, + 'email': 18, + 'emergency': 5, + 'emergency_recording': 1, + 'emergency_share': 1, + 'emoji_emotions': 11, + 'emoji_events': 13, + 'emoji_food_beverage': 11, + 'emoji_nature': 11, + 'emoji_objects': 11, + 'emoji_people': 11, + 'emoji_symbols': 11, + 'emoji_transportation': 11, + 'energy_savings_leaf': 1, + 'engineering': 11, + 'enhanced_encryption': 19, + 'equalizer': 12, + 'error': 20, + 'error_outline': 16, + 'escalator': 8, + 'escalator_warning': 8, + 'euro': 12, + 'euro_symbol': 13, + 'ev_station': 11, + 'event': 21, + 'event_available': 17, + 'event_busy': 20, + 'event_note': 14, + 'event_repeat': 1, + 'event_seat': 19, + 'exit_to_app': 13, + 'expand': 11, + 'expand_circle_down': 2, + 'expand_less': 12, + 'expand_more': 14, + 'explicit': 12, + 'explore': 12, + 'explore_off': 12, + 'exposure': 12, + 'exposure_neg_1': 13, + 'exposure_neg_2': 13, + 'exposure_plus_1': 12, + 'exposure_plus_2': 12, + 'exposure_zero': 12, + 'extension': 12, + 'extension_off': 4, + 'face': 15, + 'face_2': 1, + 'face_3': 1, + 'face_4': 1, + 'face_5': 1, + 'face_6': 1, + 'face_retouching_natural': 16, + 'face_retouching_off': 15, + 'fact_check': 14, + 'factory': 1, + 'family_restroom': 9, + 'fast_forward': 18, + 'fast_rewind': 12, + 'fastfood': 12, + 'favorite': 17, + 'favorite_border': 12, + 'fax': 2, + 'featured_play_list': 12, + 'featured_video': 12, + 'feed': 10, + 'feedback': 12, + 'female': 3, + 'fence': 6, + 'festival': 10, + 'fiber_dvr': 13, + 'fiber_manual_record': 16, + 'fiber_new': 13, + 'fiber_pin': 12, + 'fiber_smart_record': 12, + 'file_copy': 12, + 'file_download': 11, + 'file_download_done': 11, + 'file_download_off': 4, + 'file_open': 1, + 'file_present': 10, + 'file_upload': 11, + 'filter': 12, + 'filter_1': 12, + 'filter_2': 12, + 'filter_3': 12, + 'filter_4': 12, + 'filter_5': 12, + 'filter_6': 12, + 'filter_7': 12, + 'filter_8': 12, + 'filter_9': 12, + 'filter_9_plus': 13, + 'filter_alt': 11, + 'filter_alt_off': 1, + 'filter_b_and_w': 13, + 'filter_center_focus': 12, + 'filter_drama': 12, + 'filter_frames': 13, + 'filter_hdr': 13, + 'filter_list': 13, + 'filter_list_off': 1, + 'filter_none': 13, + 'filter_tilt_shift': 14, + 'filter_vintage': 13, + 'find_in_page': 12, + 'find_replace': 12, + 'fingerprint': 12, + 'fire_extinguisher': 7, + 'fire_hydrant_alt': 1, + 'fire_truck': 1, + 'fireplace': 11, + 'first_page': 11, + 'fit_screen': 15, + 'fitbit': 2, + 'fitness_center': 12, + 'flag': 18, + 'flag_circle': 1, + 'flaky': 13, + 'flare': 12, + 'flash_auto': 12, + 'flash_off': 12, + 'flash_on': 12, + 'flashlight_off': 10, + 'flashlight_on': 10, + 'flatware': 9, + 'flight': 18, + 'flight_class': 2, + 'flight_land': 13, + 'flight_takeoff': 13, + 'flip': 12, + 'flip_camera_android': 12, + 'flip_camera_ios': 12, + 'flip_to_back': 12, + 'flip_to_front': 12, + 'flood': 1, + 'fluorescent': 1, + 'flutter_dash': 5, + 'fmd_bad': 15, + 'fmd_good': 15, + 'folder': 16, + 'folder_copy': 1, + 'folder_delete': 1, + 'folder_off': 1, + 'folder_open': 13, + 'folder_shared': 16, + 'folder_special': 11, + 'folder_zip': 1, + 'follow_the_signs': 7, + 'font_download': 11, + 'font_download_off': 4, + 'food_bank': 7, + 'forest': 2, + 'fork_left': 1, + 'fork_right': 1, + 'format_align_center': 12, + 'format_align_justify': 12, + 'format_align_left': 13, + 'format_align_right': 13, + 'format_bold': 12, + 'format_clear': 12, + 'format_color_fill': 14, + 'format_color_reset': 11, + 'format_color_text': 17, + 'format_indent_decrease': 13, + 'format_indent_increase': 13, + 'format_italic': 12, + 'format_line_spacing': 12, + 'format_list_bulleted': 13, + 'format_list_numbered': 12, + 'format_list_numbered_rtl': 12, + 'format_overline': 1, + 'format_paint': 12, + 'format_quote': 12, + 'format_shapes': 12, + 'format_size': 12, + 'format_strikethrough': 12, + 'format_textdirection_l_to_r': 13, + 'format_textdirection_r_to_l': 13, + 'format_underlined': 13, + 'fort': 2, + 'forum': 19, + 'forward': 17, + 'forward_10': 13, + 'forward_30': 13, + 'forward_5': 13, + 'forward_to_inbox': 9, + 'foundation': 6, + 'free_breakfast': 11, + 'free_cancellation': 5, + 'front_hand': 3, + 'fullscreen': 12, + 'fullscreen_exit': 12, + 'functions': 12, + 'g_mobiledata': 9, + 'g_translate': 15, + 'gamepad': 12, + 'games': 12, + 'garage': 10, + 'gas_meter': 1, + 'gavel': 14, + 'generating_tokens': 5, + 'gesture': 12, + 'get_app': 12, + 'gif': 14, + 'gif_box': 3, + 'girl': 1, + 'gite': 4, + 'golf_course': 11, + 'gpp_bad': 10, + 'gpp_good': 9, + 'gpp_maybe': 10, + 'gps_fixed': 12, + 'gps_not_fixed': 12, + 'gps_off': 12, + 'grade': 16, + 'gradient': 12, + 'grading': 12, + 'grain': 12, + 'graphic_eq': 13, + 'grass': 7, + 'grid_3x3': 10, + 'grid_4x4': 10, + 'grid_goldenratio': 10, + 'grid_off': 12, + 'grid_on': 12, + 'grid_view': 12, + 'group': 19, + 'group_add': 18, + 'group_off': 5, + 'group_remove': 3, + 'group_work': 12, + 'groups': 6, + 'groups_2': 1, + 'groups_3': 1, + 'h_mobiledata': 9, + 'h_plus_mobiledata': 9, + 'hail': 11, + 'handshake': 1, + 'handyman': 12, + 'hardware': 10, + 'hd': 12, + 'hdr_auto': 10, + 'hdr_auto_select': 10, + 'hdr_enhanced_select': 11, + 'hdr_off': 11, + 'hdr_off_select': 10, + 'hdr_on': 11, + 'hdr_on_select': 10, + 'hdr_plus': 10, + 'hdr_strong': 12, + 'hdr_weak': 12, + 'headphones': 9, + 'headphones_battery': 9, + 'headset': 12, + 'headset_mic': 12, + 'headset_off': 12, + 'healing': 12, + 'health_and_safety': 7, + 'hearing': 12, + 'hearing_disabled': 14, + 'heart_broken': 2, + 'heat_pump': 1, + 'height': 11, + 'help': 13, + 'help_center': 9, + 'help_outline': 12, + 'hevc': 10, + 'hexagon': 1, + 'hide_image': 10, + 'hide_source': 9, + 'high_quality': 12, + 'highlight': 14, + 'highlight_alt': 12, + 'highlight_off': 17, + 'hiking': 4, + 'history': 12, + 'history_edu': 11, + 'history_toggle_off': 9, + 'hive': 2, + 'hls': 1, + 'hls_off': 1, + 'holiday_village': 4, + 'home': 16, + 'home_max': 10, + 'home_mini': 10, + 'home_repair_service': 12, + 'home_work': 13, + 'horizontal_distribute': 6, + 'horizontal_rule': 13, + 'horizontal_split': 15, + 'hot_tub': 12, + 'hotel': 19, + 'hotel_class': 6, + 'hourglass_bottom': 11, + 'hourglass_disabled': 12, + 'hourglass_empty': 15, + 'hourglass_full': 15, + 'hourglass_top': 11, + 'house': 11, + 'house_siding': 6, + 'houseboat': 4, + 'how_to_reg': 14, + 'how_to_vote': 15, + 'html': 1, + 'http': 11, + 'https': 16, + 'hub': 2, + 'hvac': 12, + 'ice_skating': 4, + 'icecream': 11, + 'image': 16, + 'image_aspect_ratio': 12, + 'image_not_supported': 13, + 'image_search': 12, + 'imagesearch_roller': 11, + 'import_contacts': 14, + 'import_export': 12, + 'important_devices': 11, + 'inbox': 18, + 'incomplete_circle': 3, + 'indeterminate_check_box': 18, + 'info': 21, + 'input': 13, + 'insert_chart': 12, + 'insert_chart_outlined': 12, + 'insert_comment': 13, + 'insert_drive_file': 17, + 'insert_emoticon': 15, + 'insert_invitation': 12, + 'insert_link': 12, + 'insert_page_break': 2, + 'insert_photo': 12, + 'insights': 12, + 'install_desktop': 1, + 'install_mobile': 1, + 'integration_instructions': 11, + 'interests': 2, + 'interpreter_mode': 2, + 'inventory': 13, + 'inventory_2': 9, + 'invert_colors': 13, + 'invert_colors_off': 12, + 'ios_share': 13, + 'iron': 4, + 'iso': 11, + 'javascript': 1, + 'join_full': 1, + 'join_inner': 1, + 'join_left': 1, + 'join_right': 1, + 'kayaking': 4, + 'kebab_dining': 2, + 'key': 2, + 'key_off': 1, + 'keyboard': 13, + 'keyboard_alt': 15, + 'keyboard_arrow_down': 18, + 'keyboard_arrow_left': 17, + 'keyboard_arrow_right': 17, + 'keyboard_arrow_up': 19, + 'keyboard_backspace': 13, + 'keyboard_capslock': 13, + 'keyboard_command_key': 1, + 'keyboard_control_key': 1, + 'keyboard_double_arrow_down': 2, + 'keyboard_double_arrow_left': 2, + 'keyboard_double_arrow_right': 2, + 'keyboard_double_arrow_up': 2, + 'keyboard_hide': 13, + 'keyboard_option_key': 1, + 'keyboard_return': 13, + 'keyboard_tab': 13, + 'keyboard_voice': 17, + 'king_bed': 12, + 'kitchen': 12, + 'kitesurfing': 4, + 'label': 17, + 'label_important': 20, + 'label_off': 13, + 'lan': 1, + 'landscape': 12, + 'landslide': 1, + 'language': 12, + 'laptop': 13, + 'laptop_chromebook': 12, + 'laptop_mac': 12, + 'laptop_windows': 12, + 'last_page': 13, + 'launch': 17, + 'layers': 12, + 'layers_clear': 12, + 'leaderboard': 6, + 'leak_add': 12, + 'leak_remove': 12, + 'legend_toggle': 8, + 'lens': 12, + 'lens_blur': 10, + 'library_add': 13, + 'library_add_check': 17, + 'library_books': 14, + 'library_music': 13, + 'light': 9, + 'light_mode': 4, + 'lightbulb': 18, + 'lightbulb_circle': 1, + 'line_axis': 2, + 'line_style': 14, + 'line_weight': 14, + 'linear_scale': 17, + 'link': 21, + 'link_off': 11, + 'linked_camera': 15, + 'liquor': 10, + 'list': 18, + 'list_alt': 13, + 'live_help': 15, + 'live_tv': 11, + 'living': 9, + 'local_activity': 19, + 'local_airport': 14, + 'local_atm': 12, + 'local_bar': 12, + 'local_cafe': 11, + 'local_car_wash': 12, + 'local_convenience_store': 16, + 'local_dining': 21, + 'local_drink': 12, + 'local_fire_department': 15, + 'local_florist': 12, + 'local_gas_station': 13, + 'local_grocery_store': 12, + 'local_hospital': 12, + 'local_hotel': 13, + 'local_laundry_service': 16, + 'local_library': 12, + 'local_mall': 12, + 'local_movies': 12, + 'local_offer': 20, + 'local_parking': 12, + 'local_pharmacy': 12, + 'local_phone': 17, + 'local_pizza': 12, + 'local_play': 12, + 'local_police': 13, + 'local_post_office': 12, + 'local_printshop': 17, + 'local_see': 16, + 'local_shipping': 12, + 'local_taxi': 12, + 'location_city': 12, + 'location_disabled': 12, + 'location_off': 11, + 'location_on': 15, + 'location_searching': 12, + 'lock': 18, + 'lock_clock': 11, + 'lock_open': 16, + 'lock_person': 1, + 'lock_reset': 1, + 'login': 12, + 'logo_dev': 2, + 'logout': 12, + 'looks': 13, + 'looks_3': 12, + 'looks_4': 12, + 'looks_5': 12, + 'looks_6': 13, + 'looks_one': 12, + 'looks_two': 12, + 'loop': 12, + 'loupe': 12, + 'low_priority': 18, + 'loyalty': 12, + 'lte_mobiledata': 9, + 'lte_plus_mobiledata': 9, + 'luggage': 7, + 'lunch_dining': 11, + 'lyrics': 3, + 'macro_off': 1, + 'mail': 16, + 'mail_lock': 1, + 'mail_outline': 12, + 'male': 3, + 'man': 2, + 'man_2': 1, + 'man_3': 1, + 'man_4': 1, + 'manage_accounts': 10, + 'manage_history': 1, + 'manage_search': 10, + 'map': 16, + 'maps_home_work': 10, + 'maps_ugc': 13, + 'margin': 11, + 'mark_as_unread': 10, + 'mark_chat_read': 8, + 'mark_chat_unread': 8, + 'mark_email_read': 8, + 'mark_email_unread': 8, + 'mark_unread_chat_alt': 1, + 'markunread': 12, + 'markunread_mailbox': 12, + 'masks': 7, + 'maximize': 11, + 'media_bluetooth_off': 10, + 'media_bluetooth_on': 10, + 'mediation': 15, + 'medical_information': 1, + 'medical_services': 12, + 'medication': 10, + 'medication_liquid': 2, + 'meeting_room': 17, + 'memory': 12, + 'menu': 13, + 'menu_book': 12, + 'menu_open': 13, + 'merge': 1, + 'merge_type': 13, + 'message': 13, + 'mic': 13, + 'mic_external_off': 11, + 'mic_external_on': 11, + 'mic_none': 12, + 'mic_off': 14, + 'microwave': 6, + 'military_tech': 11, + 'minimize': 11, + 'minor_crash': 1, + 'miscellaneous_services': 12, + 'missed_video_call': 13, + 'mms': 12, + 'mobile_friendly': 12, + 'mobile_off': 12, + 'mobile_screen_share': 12, + 'mobiledata_off': 10, + 'mode': 10, + 'mode_comment': 12, + 'mode_edit': 11, + 'mode_edit_outline': 10, + 'mode_fan_off': 1, + 'mode_night': 10, + 'mode_of_travel': 2, + 'mode_standby': 9, + 'model_training': 13, + 'monetization_on': 12, + 'money': 11, + 'money_off': 12, + 'money_off_csred': 11, + 'monitor': 11, + 'monitor_heart': 2, + 'monitor_weight': 10, + 'monochrome_photos': 11, + 'mood': 16, + 'mood_bad': 12, + 'moped': 14, + 'more': 13, + 'more_horiz': 13, + 'more_time': 11, + 'more_vert': 19, + 'mosque': 2, + 'motion_photos_auto': 15, + 'motion_photos_off': 14, + 'motion_photos_on': 18, + 'motion_photos_pause': 8, + 'motion_photos_paused': 17, + 'mouse': 12, + 'move_down': 2, + 'move_to_inbox': 15, + 'move_up': 2, + 'movie': 12, + 'movie_creation': 12, + 'movie_filter': 13, + 'moving': 4, + 'mp': 15, + 'multiline_chart': 13, + 'multiple_stop': 7, + 'museum': 11, + 'music_note': 12, + 'music_off': 12, + 'music_video': 15, + 'my_location': 12, + 'nat': 11, + 'nature': 12, + 'nature_people': 12, + 'navigate_before': 13, + 'navigate_next': 13, + 'navigation': 12, + 'near_me': 12, + 'near_me_disabled': 6, + 'nearby_error': 10, + 'nearby_off': 10, + 'nest_cam_wired_stand': 1, + 'network_cell': 16, + 'network_check': 12, + 'network_locked': 17, + 'network_ping': 1, + 'network_wifi': 16, + 'network_wifi_1_bar': 1, + 'network_wifi_2_bar': 1, + 'network_wifi_3_bar': 1, + 'new_label': 3, + 'new_releases': 13, + 'newspaper': 1, + 'next_plan': 16, + 'next_week': 14, + 'nfc': 12, + 'night_shelter': 6, + 'nightlife': 10, + 'nightlight': 10, + 'nightlight_round': 10, + 'nights_stay': 12, + 'no_accounts': 10, + 'no_adult_content': 1, + 'no_backpack': 6, + 'no_cell': 8, + 'no_crash': 1, + 'no_drinks': 8, + 'no_encryption': 17, + 'no_encryption_gmailerrorred': 10, + 'no_flash': 8, + 'no_food': 8, + 'no_luggage': 7, + 'no_meals': 7, + 'no_meeting_room': 11, + 'no_photography': 8, + 'no_sim': 12, + 'no_stroller': 9, + 'no_transfer': 7, + 'noise_aware': 2, + 'noise_control_off': 2, + 'nordic_walking': 4, + 'north': 7, + 'north_east': 7, + 'north_west': 7, + 'not_accessible': 13, + 'not_interested': 12, + 'not_listed_location': 18, + 'not_started': 13, + 'note': 12, + 'note_add': 13, + 'note_alt': 10, + 'notes': 12, + 'notification_add': 5, + 'notification_important': 12, + 'notifications': 18, + 'notifications_active': 13, + 'notifications_none': 12, + 'notifications_off': 12, + 'notifications_paused': 12, + 'numbers': 2, + 'offline_bolt': 18, + 'offline_pin': 18, + 'offline_share': 12, + 'oil_barrel': 1, + 'on_device_training': 1, + 'ondemand_video': 11, + 'online_prediction': 12, + 'opacity': 15, + 'open_in_browser': 12, + 'open_in_full': 7, + 'open_in_new': 21, + 'open_in_new_off': 4, + 'open_with': 12, + 'other_houses': 4, + 'outbound': 8, + 'outbox': 10, + 'outdoor_grill': 11, + 'outlet': 7, + 'outlined_flag': 12, + 'output': 1, + 'padding': 11, + 'pages': 11, + 'pageview': 12, + 'paid': 9, + 'palette': 16, + 'pan_tool': 14, + 'pan_tool_alt': 1, + 'panorama': 17, + 'panorama_fish_eye': 13, + 'panorama_horizontal': 12, + 'panorama_horizontal_select': 11, + 'panorama_photosphere': 11, + 'panorama_photosphere_select': 12, + 'panorama_vertical': 13, + 'panorama_vertical_select': 12, + 'panorama_wide_angle': 14, + 'panorama_wide_angle_select': 12, + 'paragliding': 4, + 'park': 10, + 'party_mode': 12, + 'password': 10, + 'pattern': 11, + 'pause': 16, + 'pause_circle': 10, + 'pause_circle_filled': 15, + 'pause_circle_outline': 19, + 'pause_presentation': 12, + 'payment': 12, + 'payments': 13, + 'pedal_bike': 14, + 'pending': 17, + 'pending_actions': 7, + 'pentagon': 1, + 'people': 23, + 'people_alt': 21, + 'people_outline': 12, + 'percent': 1, + 'perm_camera_mic': 12, + 'perm_contact_calendar': 13, + 'perm_data_setting': 12, + 'perm_device_information': 13, + 'perm_identity': 12, + 'perm_media': 15, + 'perm_phone_msg': 12, + 'perm_scan_wifi': 12, + 'person': 17, + 'person_2': 1, + 'person_3': 1, + 'person_4': 1, + 'person_add': 16, + 'person_add_alt': 11, + 'person_add_alt_1': 13, + 'person_add_disabled': 11, + 'person_off': 4, + 'person_outline': 19, + 'person_pin': 13, + 'person_pin_circle': 18, + 'person_remove': 12, + 'person_remove_alt_1': 13, + 'person_search': 12, + 'personal_injury': 3, + 'personal_video': 13, + 'pest_control': 12, + 'pest_control_rodent': 12, + 'pets': 12, + 'phishing': 2, + 'phone': 12, + 'phone_android': 12, + 'phone_bluetooth_speaker': 12, + 'phone_callback': 13, + 'phone_disabled': 12, + 'phone_enabled': 16, + 'phone_forwarded': 13, + 'phone_iphone': 12, + 'phone_locked': 19, + 'phone_missed': 13, + 'phone_paused': 13, + 'phonelink': 12, + 'phonelink_erase': 12, + 'phonelink_lock': 15, + 'phonelink_off': 12, + 'phonelink_ring': 12, + 'phonelink_setup': 13, + 'photo': 12, + 'photo_album': 14, + 'photo_camera': 18, + 'photo_camera_back': 11, + 'photo_camera_front': 11, + 'photo_filter': 12, + 'photo_library': 12, + 'photo_size_select_actual': 12, + 'photo_size_select_large': 12, + 'photo_size_select_small': 12, + 'php': 1, + 'piano': 4, + 'piano_off': 4, + 'picture_as_pdf': 12, + 'picture_in_picture': 12, + 'picture_in_picture_alt': 12, + 'pie_chart': 11, + 'pie_chart_outline': 10, + 'pin': 10, + 'pin_drop': 18, + 'pin_end': 3, + 'pin_invoke': 3, + 'pinch': 1, + 'pivot_table_chart': 11, + 'pix': 2, + 'place': 19, + 'plagiarism': 11, + 'play_arrow': 16, + 'play_circle': 9, + 'play_circle_filled': 14, + 'play_circle_outline': 19, + 'play_disabled': 10, + 'play_for_work': 12, + 'play_lesson': 10, + 'playlist_add': 16, + 'playlist_add_check': 17, + 'playlist_add_check_circle': 2, + 'playlist_add_circle': 2, + 'playlist_play': 14, + 'playlist_remove': 1, + 'plumbing': 12, + 'plus_one': 17, + 'podcasts': 9, + 'point_of_sale': 8, + 'policy': 12, + 'poll': 13, + 'polyline': 1, + 'polymer': 12, + 'pool': 12, + 'portable_wifi_off': 12, + 'portrait': 12, + 'post_add': 11, + 'power': 12, + 'power_input': 12, + 'power_off': 12, + 'power_settings_new': 12, + 'precision_manufacturing': 10, + 'pregnant_woman': 14, + 'present_to_all': 11, + 'preview': 7, + 'price_change': 10, + 'price_check': 10, + 'print': 16, + 'print_disabled': 12, + 'priority_high': 19, + 'privacy_tip': 12, + 'private_connectivity': 5, + 'production_quantity_limits': 7, + 'propane': 1, + 'propane_tank': 1, + 'psychology': 11, + 'psychology_alt': 1, + 'public': 13, + 'public_off': 8, + 'publish': 12, + 'published_with_changes': 6, + 'punch_clock': 2, + 'push_pin': 13, + 'qr_code': 12, + 'qr_code_2': 6, + 'qr_code_scanner': 7, + 'query_builder': 13, + 'query_stats': 4, + 'question_answer': 12, + 'question_mark': 1, + 'queue': 12, + 'queue_music': 18, + 'queue_play_next': 14, + 'quickreply': 12, + 'quiz': 10, + 'r_mobiledata': 9, + 'radar': 10, + 'radio': 12, + 'radio_button_checked': 13, + 'radio_button_unchecked': 13, + 'railway_alert': 10, + 'ramen_dining': 10, + 'ramp_left': 1, + 'ramp_right': 1, + 'rate_review': 11, + 'raw_off': 10, + 'raw_on': 10, + 'read_more': 13, + 'real_estate_agent': 3, + 'receipt': 12, + 'receipt_long': 12, + 'recent_actors': 12, + 'recommend': 14, + 'record_voice_over': 12, + 'rectangle': 1, + 'recycling': 4, + 'redeem': 12, + 'redo': 13, + 'reduce_capacity': 6, + 'refresh': 16, + 'remember_me': 10, + 'remove': 16, + 'remove_circle': 19, + 'remove_circle_outline': 12, + 'remove_done': 10, + 'remove_from_queue': 13, + 'remove_moderator': 11, + 'remove_red_eye': 12, + 'remove_road': 1, + 'remove_shopping_cart': 12, + 'reorder': 16, + 'repartition': 1, + 'repeat': 12, + 'repeat_on': 12, + 'repeat_one': 12, + 'repeat_one_on': 12, + 'replay': 13, + 'replay_10': 14, + 'replay_30': 14, + 'replay_5': 14, + 'replay_circle_filled': 12, + 'reply': 20, + 'reply_all': 17, + 'report': 18, + 'report_gmailerrorred': 11, + 'report_off': 12, + 'report_problem': 18, + 'request_page': 7, + 'request_quote': 7, + 'reset_tv': 10, + 'restart_alt': 10, + 'restaurant': 16, + 'restaurant_menu': 12, + 'restore': 12, + 'restore_from_trash': 12, + 'restore_page': 12, + 'reviews': 10, + 'rice_bowl': 6, + 'ring_volume': 13, + 'rocket': 1, + 'rocket_launch': 1, + 'roller_shades': 1, + 'roller_shades_closed': 1, + 'roller_skating': 1, + 'roofing': 6, + 'room': 19, + 'room_preferences': 7, + 'room_service': 11, + 'rotate_90_degrees_ccw': 12, + 'rotate_90_degrees_cw': 2, + 'rotate_left': 13, + 'rotate_right': 13, + 'roundabout_left': 1, + 'roundabout_right': 1, + 'rounded_corner': 15, + 'route': 2, + 'router': 13, + 'rowing': 14, + 'rss_feed': 12, + 'rsvp': 10, + 'rtt': 15, + 'rule': 8, + 'rule_folder': 7, + 'run_circle': 11, + 'running_with_errors': 4, + 'rv_hookup': 12, + 'safety_check': 1, + 'safety_divider': 7, + 'sailing': 4, + 'sanitizer': 6, + 'satellite': 12, + 'satellite_alt': 1, + 'save': 12, + 'save_alt': 12, + 'save_as': 2, + 'saved_search': 11, + 'savings': 5, + 'scale': 1, + 'scanner': 12, + 'scatter_plot': 12, + 'schedule': 17, + 'schedule_send': 14, + 'schema': 4, + 'school': 13, + 'science': 17, + 'score': 12, + 'scoreboard': 1, + 'screen_lock_landscape': 16, + 'screen_lock_portrait': 17, + 'screen_lock_rotation': 16, + 'screen_rotation': 12, + 'screen_rotation_alt': 1, + 'screen_search_desktop': 10, + 'screen_share': 12, + 'screenshot': 10, + 'screenshot_monitor': 1, + 'scuba_diving': 1, + 'sd': 10, + 'sd_card': 12, + 'sd_card_alert': 11, + 'sd_storage': 12, + 'search': 17, + 'search_off': 12, + 'security': 12, + 'security_update': 11, + 'security_update_good': 10, + 'security_update_warning': 10, + 'segment': 11, + 'select_all': 12, + 'self_improvement': 11, + 'sell': 10, + 'send': 20, + 'send_and_archive': 11, + 'send_time_extension': 2, + 'send_to_mobile': 11, + 'sensor_door': 8, + 'sensor_occupied': 1, + 'sensor_window': 8, + 'sensors': 4, + 'sensors_off': 5, + 'sentiment_dissatisfied': 11, + 'sentiment_neutral': 11, + 'sentiment_satisfied': 11, + 'sentiment_satisfied_alt': 11, + 'sentiment_very_dissatisfied': 11, + 'sentiment_very_satisfied': 11, + 'set_meal': 6, + 'settings': 19, + 'settings_accessibility': 10, + 'settings_applications': 11, + 'settings_backup_restore': 11, + 'settings_bluetooth': 11, + 'settings_brightness': 12, + 'settings_cell': 11, + 'settings_ethernet': 11, + 'settings_input_antenna': 11, + 'settings_input_component': 11, + 'settings_input_composite': 11, + 'settings_input_hdmi': 11, + 'settings_input_svideo': 11, + 'settings_overscan': 11, + 'settings_phone': 15, + 'settings_power': 11, + 'settings_remote': 11, + 'settings_suggest': 20, + 'settings_system_daydream': 12, + 'settings_voice': 11, + 'severe_cold': 1, + 'shape_line': 1, + 'share': 18, + 'share_location': 17, + 'shield': 11, + 'shield_moon': 2, + 'shop': 12, + 'shop_2': 9, + 'shop_two': 11, + 'shopping_bag': 9, + 'shopping_basket': 12, + 'shopping_cart': 18, + 'shopping_cart_checkout': 1, + 'short_text': 15, + 'shortcut': 10, + 'show_chart': 13, + 'shower': 9, + 'shuffle': 14, + 'shuffle_on': 12, + 'shutter_speed': 11, + 'sick': 6, + 'sign_language': 1, + 'signal_cellular_0_bar': 15, + 'signal_cellular_4_bar': 12, + 'signal_cellular_alt': 12, + 'signal_cellular_alt_1_bar': 1, + 'signal_cellular_alt_2_bar': 1, + 'signal_cellular_connected_no_internet_0_bar': 13, + 'signal_cellular_connected_no_internet_4_bar': 13, + 'signal_cellular_no_sim': 11, + 'signal_cellular_nodata': 10, + 'signal_cellular_null': 12, + 'signal_cellular_off': 12, + 'signal_wifi_0_bar': 15, + 'signal_wifi_4_bar': 12, + 'signal_wifi_4_bar_lock': 13, + 'signal_wifi_bad': 10, + 'signal_wifi_connected_no_internet_4': 10, + 'signal_wifi_off': 17, + 'signal_wifi_statusbar_4_bar': 9, + 'signal_wifi_statusbar_connected_no_internet_4': 11, + 'signal_wifi_statusbar_null': 9, + 'signpost': 1, + 'sim_card': 12, + 'sim_card_alert': 11, + 'sim_card_download': 9, + 'single_bed': 11, + 'sip': 10, + 'skateboarding': 4, + 'skip_next': 16, + 'skip_previous': 16, + 'sledding': 5, + 'slideshow': 12, + 'slow_motion_video': 11, + 'smart_button': 7, + 'smart_display': 9, + 'smart_screen': 9, + 'smart_toy': 9, + 'smartphone': 16, + 'smoke_free': 12, + 'smoking_rooms': 12, + 'sms': 12, + 'sms_failed': 13, + 'snippet_folder': 7, + 'snooze': 12, + 'snowboarding': 4, + 'snowmobile': 4, + 'snowshoeing': 4, + 'soap': 8, + 'social_distance': 7, + 'solar_power': 1, + 'sort': 13, + 'sort_by_alpha': 11, + 'sos': 1, + 'soup_kitchen': 2, + 'source': 7, + 'south': 7, + 'south_america': 2, + 'south_east': 7, + 'south_west': 7, + 'spa': 12, + 'space_bar': 11, + 'space_dashboard': 3, + 'spatial_audio': 1, + 'spatial_audio_off': 1, + 'spatial_tracking': 1, + 'speaker': 16, + 'speaker_group': 12, + 'speaker_notes': 13, + 'speaker_notes_off': 13, + 'speaker_phone': 15, + 'speed': 12, + 'spellcheck': 12, + 'splitscreen': 11, + 'spoke': 2, + 'sports': 11, + 'sports_bar': 7, + 'sports_baseball': 11, + 'sports_basketball': 11, + 'sports_cricket': 11, + 'sports_esports': 11, + 'sports_football': 12, + 'sports_golf': 11, + 'sports_gymnastics': 1, + 'sports_handball': 11, + 'sports_hockey': 11, + 'sports_kabaddi': 11, + 'sports_martial_arts': 1, + 'sports_mma': 11, + 'sports_motorsports': 11, + 'sports_rugby': 11, + 'sports_score': 10, + 'sports_soccer': 11, + 'sports_tennis': 11, + 'sports_volleyball': 11, + 'square': 1, + 'square_foot': 11, + 'ssid_chart': 1, + 'stacked_bar_chart': 12, + 'stacked_line_chart': 6, + 'stadium': 1, + 'stairs': 8, + 'star': 22, + 'star_border': 19, + 'star_border_purple500': 10, + 'star_half': 22, + 'star_outline': 11, + 'star_purple500': 10, + 'star_rate': 12, + 'stars': 12, + 'start': 1, + 'stay_current_landscape': 12, + 'stay_current_portrait': 12, + 'stay_primary_landscape': 12, + 'stay_primary_portrait': 12, + 'sticky_note_2': 7, + 'stop': 12, + 'stop_circle': 15, + 'stop_screen_share': 13, + 'storage': 13, + 'store': 11, + 'store_mall_directory': 12, + 'storefront': 17, + 'storm': 10, + 'straight': 1, + 'straighten': 12, + 'stream': 11, + 'streetview': 12, + 'strikethrough_s': 14, + 'stroller': 8, + 'style': 12, + 'subdirectory_arrow_left': 11, + 'subdirectory_arrow_right': 11, + 'subject': 13, + 'subscript': 13, + 'subscriptions': 11, + 'subtitles': 12, + 'subtitles_off': 11, + 'subway': 11, + 'summarize': 10, + 'superscript': 12, + 'supervised_user_circle': 12, + 'supervisor_account': 13, + 'support': 11, + 'support_agent': 12, + 'surfing': 4, + 'surround_sound': 13, + 'swap_calls': 12, + 'swap_horiz': 12, + 'swap_horizontal_circle': 12, + 'swap_vert': 12, + 'swap_vertical_circle': 13, + 'swipe': 12, + 'swipe_down': 1, + 'swipe_down_alt': 1, + 'swipe_left': 1, + 'swipe_left_alt': 1, + 'swipe_right': 1, + 'swipe_right_alt': 1, + 'swipe_up': 1, + 'swipe_up_alt': 1, + 'swipe_vertical': 1, + 'switch_access_shortcut': 3, + 'switch_access_shortcut_add': 3, + 'switch_account': 11, + 'switch_camera': 12, + 'switch_left': 8, + 'switch_right': 9, + 'switch_video': 12, + 'synagogue': 2, + 'sync': 17, + 'sync_alt': 12, + 'sync_disabled': 16, + 'sync_lock': 1, + 'sync_problem': 21, + 'system_security_update': 10, + 'system_security_update_good': 10, + 'system_security_update_warning': 10, + 'system_update': 12, + 'system_update_alt': 13, + 'tab': 12, + 'tab_unselected': 12, + 'table_bar': 2, + 'table_chart': 12, + 'table_restaurant': 2, + 'table_rows': 17, + 'table_view': 8, + 'tablet': 12, + 'tablet_android': 14, + 'tablet_mac': 12, + 'tag': 10, + 'tag_faces': 19, + 'takeout_dining': 16, + 'tap_and_play': 12, + 'tapas': 6, + 'task': 10, + 'task_alt': 6, + 'taxi_alert': 11, + 'temple_buddhist': 2, + 'temple_hindu': 3, + 'terminal': 1, + 'terrain': 12, + 'text_decrease': 1, + 'text_fields': 14, + 'text_format': 12, + 'text_increase': 1, + 'text_rotate_up': 12, + 'text_rotate_vertical': 12, + 'text_rotation_angledown': 12, + 'text_rotation_angleup': 12, + 'text_rotation_down': 12, + 'text_rotation_none': 12, + 'text_snippet': 8, + 'textsms': 12, + 'texture': 12, + 'theater_comedy': 11, + 'theaters': 12, + 'thermostat': 11, + 'thermostat_auto': 10, + 'thumb_down': 19, + 'thumb_down_alt': 11, + 'thumb_down_off_alt': 13, + 'thumb_up': 19, + 'thumb_up_alt': 11, + 'thumb_up_off_alt': 13, + 'thumbs_up_down': 12, + 'thunderstorm': 1, + 'time_to_leave': 12, + 'timelapse': 12, + 'timeline': 13, + 'timer': 13, + 'timer_10': 12, + 'timer_10_select': 15, + 'timer_3': 12, + 'timer_3_select': 15, + 'timer_off': 13, + 'tips_and_updates': 3, + 'tire_repair': 1, + 'title': 11, + 'toc': 13, + 'today': 17, + 'toggle_off': 12, + 'toggle_on': 12, + 'token': 2, + 'toll': 12, + 'tonality': 12, + 'topic': 7, + 'tornado': 1, + 'touch_app': 13, + 'tour': 12, + 'toys': 14, + 'track_changes': 11, + 'traffic': 12, + 'train': 11, + 'tram': 11, + 'transcribe': 1, + 'transfer_within_a_station': 12, + 'transform': 12, + 'transgender': 3, + 'transit_enterexit': 12, + 'translate': 16, + 'travel_explore': 8, + 'trending_down': 18, + 'trending_flat': 19, + 'trending_up': 19, + 'trip_origin': 12, + 'troubleshoot': 10, + 'try': 9, + 'tsunami': 1, + 'tty': 8, + 'tune': 12, + 'tungsten': 10, + 'turn_left': 1, + 'turn_right': 1, + 'turn_sharp_left': 1, + 'turn_sharp_right': 1, + 'turn_slight_left': 1, + 'turn_slight_right': 1, + 'turned_in': 11, + 'turned_in_not': 11, + 'tv': 16, + 'tv_off': 11, + 'two_wheeler': 14, + 'type_specimen': 1, + 'u_turn_left': 1, + 'u_turn_right': 1, + 'umbrella': 8, + 'unarchive': 13, + 'undo': 13, + 'unfold_less': 12, + 'unfold_less_double': 1, + 'unfold_more': 12, + 'unfold_more_double': 1, + 'unpublished': 7, + 'unsubscribe': 12, + 'upcoming': 9, + 'update': 13, + 'update_disabled': 8, + 'upgrade': 12, + 'upload': 10, + 'upload_file': 10, + 'usb': 12, + 'usb_off': 4, + 'vaccines': 2, + 'vape_free': 1, + 'vaping_rooms': 1, + 'verified': 11, + 'verified_user': 18, + 'vertical_align_bottom': 12, + 'vertical_align_center': 12, + 'vertical_align_top': 12, + 'vertical_distribute': 6, + 'vertical_shades': 1, + 'vertical_shades_closed': 1, + 'vertical_split': 15, + 'vibration': 12, + 'video_call': 11, + 'video_camera_back': 10, + 'video_camera_front': 10, + 'video_chat': 1, + 'video_file': 1, + 'video_label': 11, + 'video_library': 13, + 'video_settings': 12, + 'video_stable': 10, + 'videocam': 16, + 'videocam_off': 12, + 'videogame_asset': 11, + 'videogame_asset_off': 4, + 'view_agenda': 14, + 'view_array': 13, + 'view_carousel': 13, + 'view_column': 13, + 'view_comfy': 14, + 'view_comfy_alt': 1, + 'view_compact': 13, + 'view_compact_alt': 1, + 'view_cozy': 1, + 'view_day': 13, + 'view_headline': 11, + 'view_in_ar': 11, + 'view_kanban': 1, + 'view_list': 14, + 'view_module': 13, + 'view_quilt': 14, + 'view_sidebar': 13, + 'view_stream': 13, + 'view_timeline': 1, + 'view_week': 13, + 'vignette': 12, + 'villa': 4, + 'visibility': 16, + 'visibility_off': 12, + 'voice_chat': 13, + 'voice_over_off': 11, + 'voicemail': 12, + 'volcano': 1, + 'volume_down': 13, + 'volume_mute': 13, + 'volume_off': 17, + 'volume_up': 14, + 'volunteer_activism': 10, + 'vpn_key': 12, + 'vpn_key_off': 1, + 'vpn_lock': 17, + 'vrpano': 10, + 'wallet': 1, + 'wallpaper': 13, + 'warehouse': 1, + 'warning': 17, + 'warning_amber': 17, + 'wash': 8, + 'watch': 12, + 'watch_later': 17, + 'watch_off': 1, + 'water': 11, + 'water_damage': 6, + 'water_drop': 3, + 'waterfall_chart': 11, + 'waves': 11, + 'waving_hand': 3, + 'wb_auto': 12, + 'wb_cloudy': 12, + 'wb_incandescent': 12, + 'wb_iridescent': 11, + 'wb_shade': 10, + 'wb_sunny': 12, + 'wb_twilight': 9, + 'wc': 11, + 'web': 13, + 'web_asset': 12, + 'web_asset_off': 4, + 'web_stories': 4, + 'webhook': 1, + 'weekend': 12, + 'west': 7, + 'whatshot': 17, + 'wheelchair_pickup': 8, + 'where_to_vote': 16, + 'widgets': 13, + 'width_full': 1, + 'width_normal': 1, + 'width_wide': 1, + 'wifi': 12, + 'wifi_1_bar': 1, + 'wifi_2_bar': 1, + 'wifi_calling': 11, + 'wifi_calling_3': 10, + 'wifi_channel': 1, + 'wifi_find': 1, + 'wifi_lock': 13, + 'wifi_off': 12, + 'wifi_password': 1, + 'wifi_protected_setup': 12, + 'wifi_tethering': 12, + 'wifi_tethering_error': 3, + 'wifi_tethering_off': 10, + 'wind_power': 1, + 'window': 10, + 'wine_bar': 6, + 'woman': 2, + 'woman_2': 1, + 'work': 12, + 'work_history': 1, + 'work_off': 12, + 'work_outline': 12, + 'workspace_premium': 3, + 'workspaces': 9, + 'wrap_text': 13, + 'wrong_location': 17, + 'wysiwyg': 8, + 'yard': 9, + 'youtube_searched_for': 12, + 'zoom_in': 11, + 'zoom_in_map': 1, + 'zoom_out': 11, + 'zoom_out_map': 17, +}; diff --git a/pubspec.yaml b/pubspec.yaml index fb46ccb2..52c576e7 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: codelessly_sdk description: Codelessly CloudUIâ„¢ - Server-driven UI for Flutter. Supercharge your Flutter apps with dynamic UI and real-time updates. Build and publish UI without code! -version: 8.3.0 +version: 8.4.0 homepage: https://codelessly.com/ repository: https://github.com/Codelessly/CodelesslySDK issue_tracker: https://github.com/Codelessly/CodelesslySDK/issues @@ -28,7 +28,7 @@ dependencies: flutter: sdk: flutter # Codelessly - codelessly_api: ^6.2.0 + codelessly_api: ^6.3.0 json_annotation: ^4.12.0 # Firebase diff --git a/test/chart_test.dart b/test/chart_test.dart new file mode 100644 index 00000000..f7568eaa --- /dev/null +++ b/test/chart_test.dart @@ -0,0 +1,229 @@ +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:codelessly_sdk/codelessly_sdk.dart'; +import 'package:codelessly_sdk/src/functions/action_chain.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; + +/// A 240 x 100 tide chart: 10 pixels per hour, selection clamped to 5..20. +ChartNode tideChart({ + Map variables = const {}, + List reactions = const [], + double? selectedX, + bool selectable = true, +}) => ChartNode( + id: 'tide', + name: 'Tide', + basicBoxLocal: NodeBox(0, 0, 240, 100), + selectedX: selectedX, + reactions: reactions, + properties: ChartProperties( + points: [for (int i = 0; i <= 24; i++) 1], + xMax: 24, + fillColor: ColorRGBA.grey10, + gridStepX: 3, + gridLabels: const ['3a', '6a', '9a', '12p', '3p', '6p', '9p'], + ranges: const [ChartRange(from: 0, to: 5), ChartRange(from: 21, to: 24)], + markers: const [ChartMarker(x: 4.2, label: '4:12am')], + referenceX: 7.67, + selectionStep: 1, + selectionMin: 5, + selectionMax: 20, + selectable: selectable, + ), +)..variables.addAll(variables); + +Observable variable( + String name, + Object? value, [ + VariableType type = VariableType.text, +]) => Observable(VariableData(id: name, name: name, value: value, type: type)); + +Reaction onChanged(String variableName) => Reaction( + trigger: TriggerModel(type: TriggerType.changed), + action: SetVariableAction( + variable: VariableData(id: variableName, name: variableName), + newValue: r'${value}', + ), +); + +Future pumpChart( + WidgetTester tester, + ChartNode node, + CodelesslyContext codelesslyContext, +) async { + final registry = NodeRegistry()..setNodes({node.id: node}); + final manager = PassiveNodeTransformerManager( + registry.getNodeByID, + (context, bounds, pageID, layoutID, canvasID) => const SizedBox(), + ); + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: codelesslyContext, + child: MaterialApp( + home: ActionChainHost( + child: Scaffold( + body: Align( + alignment: Alignment.topLeft, + child: Builder( + builder: (context) => manager.buildWidgetFromNode( + node, + context, + settings: const WidgetBuildSettings(debugLabel: 'test'), + ), + ), + ), + ), + ), + ), + ), + ); +} + +final Finder chartPaint = find.byWidgetPredicate( + (widget) => widget is CustomPaint && widget.painter is ChartPainter, +); + +ChartPainter painter(WidgetTester tester) => + tester.widget(chartPaint).painter! as ChartPainter; + +/// Taps the chart [hours] from its left edge. +Future tapAt(WidgetTester tester, double hours) async { + await tester.tapAt(tester.getTopLeft(chartPaint) + Offset(hours * 10, 50)); + await tester.pumpAndSettle(); +} + +void main() { + testWidgets('paints the bound points and selection', (tester) async { + final codelesslyContext = CodelesslyContext.empty() + ..variables['curve'] = variable('curve', [ + 0, + 1.5, + '3', + 2, + ], VariableType.list) + ..variables['hour'] = variable('hour', 7, VariableType.integer); + await pumpChart( + tester, + tideChart(variables: {'points': r'${curve}', 'selectedX': r'${hour}'}), + codelesslyContext, + ); + + expect(chartPaint, findsOneWidget); + expect(tester.getSize(chartPaint), const Size(240, 100)); + expect(painter(tester).points, [0, 1.5, 3, 2]); + expect(painter(tester).selectedX, 7); + expect(painter(tester).markers.single.label, '4:12am'); + expect(tester.takeException(), isNull); + }); + + testWidgets('points and markers can come from a formula', (tester) async { + await pumpChart( + tester, + tideChart( + variables: {'points': r'${= [1, 2, 3]}', 'markers': r'${markers}'}, + ), + CodelesslyContext.empty() + ..variables['markers'] = variable('markers', [ + {'x': 1, 'label': 'high', 'position': 'below'}, + 'not a marker', + ], VariableType.list), + ); + + expect(painter(tester).points, [1, 2, 3]); + final ChartMarker marker = painter(tester).markers.single; + expect(marker.label, 'high'); + expect(marker.position, ChartMarkerPosition.below); + }); + + testWidgets('tapping selects the snapped hour, writes the bound variable ' + 'and fires changed', (tester) async { + final hour = variable('hour', 7, VariableType.integer); + final picked = variable('picked', ''); + final codelesslyContext = CodelesslyContext.empty() + ..variables['hour'] = hour + ..variables['picked'] = picked; + await pumpChart( + tester, + tideChart( + variables: {'selectedX': r'${hour}'}, + reactions: [onChanged('picked')], + ), + codelesslyContext, + ); + + await tapAt(tester, 12.4); + expect(hour.value.value.typedValue(), 12); + expect(picked.value.value, '12'); + expect(painter(tester).selectedX, 12); + + // Clamped to selectionMin and selectionMax. + await tapAt(tester, 1.5); + expect(hour.value.value.typedValue(), 5); + expect(picked.value.value, '5'); + await tapAt(tester, 23.5); + expect(hour.value.value.typedValue(), 20); + expect(painter(tester).selectedX, 20); + }); + + testWidgets('dragging scrubs the selection', (tester) async { + final List fired = []; + final picked = variable('picked', ''); + final codelesslyContext = CodelesslyContext.empty() + ..variables['picked'] = picked; + picked.addListener(() => fired.add(picked.value.value.toString())); + await pumpChart( + tester, + tideChart(selectedX: 7, reactions: [onChanged('picked')]), + codelesslyContext, + ); + expect(painter(tester).selectedX, 7); + + final TestGesture gesture = await tester.startGesture( + tester.getTopLeft(chartPaint) + const Offset(75, 50), + ); + for (double dx = 75; dx <= 115; dx += 5) { + await gesture.moveTo(tester.getTopLeft(chartPaint) + Offset(dx, 50)); + await tester.pump(); + } + await gesture.up(); + await tester.pumpAndSettle(); + + // Without a bound variable the chart keeps the selection itself, and + // fires once per hour it moves to (the drag starts past the touch slop). + expect(painter(tester).selectedX, 11); + expect(fired, ['9', '10', '11']); + }); + + testWidgets('a chart that is not selectable ignores taps', (tester) async { + final picked = variable('picked', ''); + await pumpChart( + tester, + tideChart( + selectedX: 7, + selectable: false, + reactions: [onChanged('picked')], + ), + CodelesslyContext.empty()..variables['picked'] = picked, + ); + await tapAt(tester, 12.4); + expect(painter(tester).selectedX, 7); + expect(picked.value.value, ''); + }); + + testWidgets('paints without points, grid or labels', (tester) async { + await pumpChart( + tester, + ChartNode( + id: 'empty', + name: 'Empty', + basicBoxLocal: NodeBox(0, 0, 100, 40), + selectedX: 0, + properties: ChartProperties(smooth: true, labelHeight: 0), + ), + CodelesslyContext.empty(), + ); + expect(chartPaint, findsOneWidget); + expect(tester.takeException(), isNull); + }); +} diff --git a/test/cloud_batch_test.dart b/test/cloud_batch_test.dart new file mode 100644 index 00000000..08793c96 --- /dev/null +++ b/test/cloud_batch_test.dart @@ -0,0 +1,320 @@ +// Firestore marks its references @sealed; these test fakes stand in for them. +// ignore_for_file: subtype_of_sealed_class + +import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:codelessly_sdk/codelessly_sdk.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Collection path -> document id -> data. +final Map>> store = {}; + +/// The batches committed, each as its number of writes. +final List commits = []; + +int _autoId = 0; + +class _MemQuery extends Fake implements Query> { + _MemQuery(this._collection, [this.tests = const []]); + + final _MemCollection? _collection; + _MemCollection get collection => _collection!; + final List)> tests; + + @override + Query> where( + Object field, { + Object? isEqualTo, + Object? isNotEqualTo, + Object? isLessThan, + Object? isLessThanOrEqualTo, + Object? isGreaterThan, + Object? isGreaterThanOrEqualTo, + Object? arrayContains, + Iterable? arrayContainsAny, + Iterable? whereIn, + Iterable? whereNotIn, + bool? isNull, + }) => _MemQuery(collection, [...tests, (data) => data[field] == isEqualTo]); + + @override + Future>> get([GetOptions? options]) async { + final docs = store[collection.path] ?? {}; + return _MemSnapshot([ + for (final entry in docs.entries) + if (tests.every((test) => test(entry.value))) + _MemDocSnapshot(collection.doc(entry.key) as _MemDoc, entry.value), + ]); + } +} + +class _MemCollection extends _MemQuery + implements CollectionReference> { + _MemCollection(this.path) : super(null); + + @override + _MemCollection get collection => this; + + @override + final String path; + + @override + DocumentReference> doc([String? id]) => + _MemDoc(this, id ?? 'auto${_autoId++}'); +} + +class _MemDoc extends Fake implements DocumentReference> { + _MemDoc(this.parentCollection, this.id); + + final _MemCollection parentCollection; + + @override + final String id; + + @override + String get path => '${parentCollection.path}/$id'; + + @override + CollectionReference> collection(String id) => + _MemCollection('$path/$id'); + + @override + bool operator ==(Object other) => other is _MemDoc && other.path == path; + + @override + int get hashCode => path.hashCode; +} + +class _MemDocSnapshot extends Fake + implements QueryDocumentSnapshot> { + _MemDocSnapshot(this.reference, this._data); + + @override + final _MemDoc reference; + final Map _data; + + @override + String get id => reference.id; + + @override + Map data() => _data; +} + +class _MemSnapshot extends Fake implements QuerySnapshot> { + _MemSnapshot(this.docs); + + @override + final List>> docs; +} + +class _MemBatch extends Fake implements WriteBatch { + final List _ops = []; + + @override + void delete(DocumentReference document) { + final doc = document as _MemDoc; + _ops.add(() => store[doc.parentCollection.path]?.remove(doc.id)); + } + + @override + void set(DocumentReference document, T data, [SetOptions? options]) { + final doc = document as _MemDoc; + final Map value = Map.of(data as Map); + _ops.add(() { + final docs = store.putIfAbsent(doc.parentCollection.path, () => {}); + docs[doc.id] = options?.merge == true + ? {...?docs[doc.id], ...value} + : value; + }); + } + + @override + Future commit() async { + expect(_ops.length, lessThanOrEqualTo(500)); + commits.add(_ops.length); + for (final op in _ops) { + op(); + } + } +} + +class _MemFirestore extends Fake implements FirebaseFirestore { + @override + CollectionReference> collection(String path) => + _MemCollection(path); + + @override + WriteBatch batch() => _MemBatch(); +} + +const String sessions = 'data/project/sessions'; + +void main() { + late FirestoreCloudDatabase database; + + void seed(int count, {String Function(int i)? spot}) { + store[sessions] = { + for (int i = 0; i < count; i++) + 's$i': {'id': 's$i', 'spot': spot?.call(i) ?? 'Pipe'}, + }; + } + + setUp(() { + store.clear(); + commits.clear(); + database = FirestoreCloudDatabase( + 'project', + PublishSource.publish, + firestore: _MemFirestore(), + ); + }); + + test('removeDocuments clears a collection in one batch', () async { + seed(14); + expect(await database.removeDocuments('sessions'), 14); + expect(store[sessions], isEmpty); + expect(commits, [14]); + }); + + test('removeDocuments splits more than 500 deletes into batches', () async { + seed(1203); + expect(await database.removeDocuments('sessions'), 1203); + expect(store[sessions], isEmpty); + expect(commits, [500, 500, 203]); + }); + + test('removeDocuments by where filter or ids', () async { + seed(6, spot: (i) => i.isEven ? 'Pipe' : 'Malibu'); + expect( + await database.removeDocuments( + 'sessions', + filters: const [ + CloudWhereFilter('spot', WhereQueryOperator.equal, 'Malibu'), + ], + ), + 3, + ); + expect(store[sessions]!.keys, ['s0', 's2', 's4']); + + expect( + await database.removeDocuments('sessions', documentIds: ['s0', ' s4 ']), + 2, + ); + expect(store[sessions]!.keys, ['s2']); + expect(commits, [3, 2]); + }); + + test('setDocuments writes a list in one batch and can replace the ' + 'collection', () async { + seed(3); + final int written = await database.setDocuments('sessions', [ + {'id': 's1', 'spot': 'Rincon'}, + {'id': 'n1', 'spot': 'Trestles'}, + {'spot': 'No id'}, + ], replaceCollection: true); + expect(written, 3); + expect(commits, [5]); // 3 sets + 2 deletes (s0, s2) + final docs = store[sessions]!; + expect(docs.keys, containsAll(['s1', 'n1'])); + expect(docs.length, 3); + expect(docs['s1']!['spot'], 'Rincon'); + // A document without an id gets one, stored in its id field. + final generated = docs.entries.firstWhere( + (e) => e.value['spot'] == 'No id', + ); + expect(generated.value['id'], generated.key); + }); + + test('setDocuments merges into stored documents when asked', () async { + seed(1); + await database.setDocuments('sessions', [ + {'id': 's0', 'rating': 4}, + ], merge: true); + expect(store[sessions]!['s0']!['spot'], 'Pipe'); + expect(store[sessions]!['s0']!['rating'], 4); + }); + + test('the actions read ids, filters and documents from variables', () async { + seed(5, spot: (i) => i < 2 ? 'Malibu' : 'Pipe'); + final ScopedValues scope = ScopedValues.preview( + variables: { + 'selected': VariableData( + id: 'selected', + name: 'selected', + type: VariableType.list, + value: [ + {'id': 's3'}, + {'id': 's4'}, + ], + ), + 'seed': VariableData( + id: 'seed', + name: 'seed', + type: VariableType.list, + value: [ + {'id': 'a', 'spot': 'Pipe'}, + {'id': 'b', 'spot': 'Rincon'}, + ], + ), + }, + ); + + expect( + await FunctionsRepository.removeDocumentsFromCloud( + const RemoveDocumentsSubAction( + path: 'sessions', + documentIds: r'${selected}', + ), + scope, + database: database, + ), + isTrue, + ); + expect(store[sessions]!.keys, ['s0', 's1', 's2']); + + await FunctionsRepository.removeDocumentsFromCloud( + const RemoveDocumentsSubAction( + path: 'sessions', + whereFilters: [ + WhereQueryFilter( + field: 'spot', + operator: WhereQueryOperator.equal, + value: 'Malibu', + ), + ], + ), + scope, + database: database, + ); + expect(store[sessions]!.keys, ['s2']); + + expect( + await FunctionsRepository.setDocumentsOnCloud( + const SetDocumentsSubAction( + path: 'sessions', + documents: r'${seed}', + replaceCollection: true, + ), + scope, + database: database, + ), + isTrue, + ); + expect(store[sessions]!.keys, unorderedEquals(['a', 'b'])); + expect(commits, [2, 2, 3]); + }); + + test('documentIdsOf reads lists, documents, JSON and commas', () { + expect(FunctionsRepository.documentIdsOf(['a', 'b']), ['a', 'b']); + expect( + FunctionsRepository.documentIdsOf([ + {'id': 'a'}, + {'name': 'x'}, + ]), + ['a'], + ); + expect(FunctionsRepository.documentIdsOf('["a","b"]'), ['a', 'b']); + expect(FunctionsRepository.documentIdsOf('a, b,,c'), ['a', 'b', 'c']); + expect(FunctionsRepository.documentIdsOf(null), isEmpty); + }); +} diff --git a/test/feedback_actions_test.dart b/test/feedback_actions_test.dart index 0247d15d..466a4441 100644 --- a/test/feedback_actions_test.dart +++ b/test/feedback_actions_test.dart @@ -512,4 +512,76 @@ void main() { await tester.pumpAndSettle(); }); }); + + group('navigation', () { + testWidgets('a chain in a dialog that pops it and replaces the page ' + 'still shows its toast and runs on the new page', (tester) async { + final ButtonNode save = button( + 'save', + 'dialogBody', + 'Save', + NavigationAction(navigationType: NavigationType.pop, destinationId: ''), + ); + save.reactions.addAll([ + Reaction( + action: NavigationAction( + navigationType: NavigationType.replace, + destinationId: 'other', + ), + trigger: TriggerModel(type: TriggerType.click), + ), + Reaction( + action: ShowToastAction(message: 'Session saved', duration: 1500), + trigger: TriggerModel(type: TriggerType.click), + ), + Reaction( + action: DelayAction(milliseconds: 2000), + trigger: TriggerModel(type: TriggerType.click), + ), + Reaction( + action: ShowToastAction(message: 'Still here'), + trigger: TriggerModel(type: TriggerType.click), + ), + ]); + await pumpLayouts(tester, [ + layoutOf( + 'main', + mainScreen(ShowDialogAction(destinationId: 'dialogCanvas')), + ), + layoutOf('dialog', [ + canvas('dialogCanvas', 'dialogBody', fill: ColorRGBA.white), + body('dialogBody', 'dialogCanvas', ['save']), + save, + ]), + layoutOf('other', [ + canvas('other', 'otherBody', fill: ColorRGBA.white), + body('otherBody', 'other', ['otherTitle']), + text('otherTitle', 'otherBody', 'Settings', brandRegular), + ]), + ]); + + await tester.tap(find.text('Go')); + await tester.pumpAndSettle(); + expect(find.text('Save'), findsOneWidget); + + await tester.tap(find.text('Save')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + expect(find.text('Session saved'), findsOneWidget); + await tester.pumpAndSettle(); + // The page was replaced: the dialog and the main screen are gone. + expect(find.text('Save'), findsNothing); + expect(find.text('Go'), findsNothing); + expect(find.text('Settings'), findsOneWidget); + + // The rest of the chain runs on the new page after the old one has + // unmounted. + await tester.pump(const Duration(seconds: 2)); + await tester.pump(const Duration(milliseconds: 300)); + expect(find.text('Still here'), findsOneWidget); + await tester.pump(const Duration(seconds: 3)); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + }); + }); } diff --git a/test/flexible_scroll_body_test.dart b/test/flexible_scroll_body_test.dart new file mode 100644 index 00000000..d44e0fde --- /dev/null +++ b/test/flexible_scroll_body_test.dart @@ -0,0 +1,77 @@ +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'viewport_layout_test.dart' as vp; + +/// A 400x600 form column: a 60 tall header, a body column that scrolls with +/// 22 padding and [fields] 100 tall fields, and a 50 tall footer. The body's +/// design height is [bodyHeight] (what the editor solved). +Future _pumpForm( + WidgetTester tester, { + required SizeFit bodyFit, + required int fields, + double bodyHeight = 44, +}) async { + final RowColumnNode outer = RowColumnNode( + id: 'outer', + name: 'outer', + basicBoxLocal: NodeBox(0, 0, 400, 600), + rowColumnType: RowColumnType.column, + mainAxisAlignment: MainAxisAlignmentC.start, + crossAxisAlignment: CrossAxisAlignmentC.start, + children: ['header', 'body', 'footer'], + )..parentID = kRootNode; + final RowColumnNode body = RowColumnNode( + id: 'body', + name: 'body', + basicBoxLocal: NodeBox(0, 0, 400, bodyHeight), + rowColumnType: RowColumnType.column, + mainAxisAlignment: MainAxisAlignmentC.start, + crossAxisAlignment: CrossAxisAlignmentC.start, + verticalFit: bodyFit, + padding: const EdgeInsetsModel.all(22), + isScrollable: true, + children: [for (int i = 0; i < fields; i++) 'field$i'], + )..parentID = 'outer'; + await vp.pump(tester, const Size(800, 800), vp.Host.loose, outer, [ + vp.rect('header', 'outer', width: 400, height: 60), + body, + for (int i = 0; i < fields; i++) + vp.rect('field$i', 'body', width: 300, height: 100), + vp.rect('footer', 'outer', width: 400, height: 50), + ]); +} + +double _bodyHeight(WidgetTester tester) => + tester.getRect(vp.rectOf('footer').first).top - + tester.getRect(vp.rectOf('header').first).bottom; + +void main() { + testWidgets('a flexible scrolling body sizes to its content', (tester) async { + await _pumpForm(tester, bodyFit: SizeFit.flexible, fields: 2); + expect(_bodyHeight(tester), 244); + expect( + tester.getRect(vp.rectOf('field0').first).top - + tester.getRect(vp.rectOf('header').first).bottom, + 22, + ); + }); + + testWidgets('a flexible scrolling body takes at most the remaining space ' + 'and scrolls', (tester) async { + await _pumpForm(tester, bodyFit: SizeFit.flexible, fields: 8); + expect(_bodyHeight(tester), 490); + final ScrollableState scrollable = tester.state( + find.byType(Scrollable).first, + ); + expect(scrollable.position.maxScrollExtent, 844 - 490); + }); + + testWidgets('an expanded scrolling body fills the remaining space', ( + tester, + ) async { + await _pumpForm(tester, bodyFit: SizeFit.expanded, fields: 2); + expect(_bodyHeight(tester), 490); + }); +} diff --git a/test/icon_binding_test.dart b/test/icon_binding_test.dart new file mode 100644 index 00000000..a7f0c73c --- /dev/null +++ b/test/icon_binding_test.dart @@ -0,0 +1,84 @@ +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:codelessly_sdk/codelessly_sdk.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'feedback_actions_test.dart' as harness; + +/// A star icon whose glyph is bound to [glyph]. +IconNode star(String? glyph) => IconNode( + id: 'star', + name: 'star', + basicBoxLocal: NodeBox(0, 0, 24, 24), + icon: const MaterialIcon( + codepoint: 0xe838, + name: 'star', + style: MaterialIconStyle.filled, + version: 22, + ), +)..variables.addAll({'icon': ?glyph}); + +/// The scoped values of a pumped screen. (The icon itself isn't pumped: its +/// SVG would be fetched, and tests have no network.) +Future scope(WidgetTester tester) async { + await harness.pumpLayouts(tester, [ + harness.layoutOf('main', [ + harness.canvas('main', 'mainBody'), + harness.body('mainBody', 'main', ['box']), + RectangleNode( + id: 'box', + name: 'box', + basicBoxLocal: NodeBox(0, 0, 10, 10), + )..parentID = 'mainBody', + ]), + ]); + await tester.pumpAndSettle(); + return ScopedValues.of( + tester.element( + find.byKey(const ValueKey('Rectangle Transformer of box [box]')), + ), + ); +} + +void main() { + testWidgets('a bound glyph name picks the icon and its version', ( + tester, + ) async { + final ScopedValues values = await scope(tester); + final IconModel icon = PassiveIconWidget.resolvedIcon( + star(r"${= 1 > 2 ? 'star' : 'star_border'}"), + values, + ).icon!; + expect(icon.name, 'star_border'); + expect((icon as MaterialIcon).version, materialIconVersions['star_border']); + expect(icon.style, MaterialIconStyle.filled); + expect(icon.toSvgUrl(), contains('/materialicons/star_border/v')); + }); + + testWidgets('without a binding the icon keeps its glyph', (tester) async { + final ScopedValues values = await scope(tester); + expect( + PassiveIconWidget.resolvedIcon(star(null), values).icon!.name, + 'star', + ); + }); + + test('named() keeps the icon for an empty name or a Cupertino icon', () { + const material = MaterialIcon( + codepoint: 0xe838, + name: 'star', + style: MaterialIconStyle.outlined, + version: 22, + ); + expect(material.named(''), same(material)); + expect(material.named(' Home ').name, 'home'); + expect( + (material.named('home') as MaterialIcon).style, + MaterialIconStyle.outlined, + ); + // An unknown name keeps the icon's own version. + expect((material.named('not_an_icon') as MaterialIcon).version, 22); + final cupertino = CupertinoIcon(name: 'battery_full', codepoint: 0xf113); + expect(cupertino.named('star'), same(cupertino)); + }); +} diff --git a/test/list_view_wrap_test.dart b/test/list_view_wrap_test.dart new file mode 100644 index 00000000..2c3d8455 --- /dev/null +++ b/test/list_view_wrap_test.dart @@ -0,0 +1,130 @@ +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 'viewport_layout_test.dart' as vp; + +/// A horizontal list of [itemCount] 100x50 items that wraps, 14 apart with +/// 6 between runs, and hugs its height. +ListViewNode wrappingList({ + double width = 250, + int? itemCount = 4, + SizeFit horizontalFit = SizeFit.fixed, + bool hasSeparator = true, +}) { + final ListViewNode list = vp.list( + width: width, + height: 50, + itemCount: itemCount, + horizontalFit: horizontalFit, + verticalFit: SizeFit.shrinkWrap, + scrollDirection: AxisC.horizontal, + ); + list.properties + ..wrap = true + ..hasSeparator = hasSeparator + ..separatorSpacing = 14 + ..runSpacing = 6; + return list; +} + +List itemRects(WidgetTester tester) => [ + for (final Element element in vp.rectOf('item').evaluate()) + tester.getRect(find.byWidget(element.widget).first), +]; + +void main() { + test('a wrapping list with a known count builds all its items', () { + final ListViewNode list = wrappingList(); + expect(list.isScrollable, isTrue); + expect(listWraps(list), isTrue); + expect(listBuildsAllItems(list), isTrue); + expect(listWrapSpacing(list), 14); + + list.properties.itemCount = null; + expect(listWraps(list), isFalse, reason: 'endless list'); + expect(listBuildsAllItems(list), isFalse); + + list + ..properties.itemCount = 3 + ..useCloudDatabase = true; + expect(listWraps(list), isFalse, reason: 'cloud query'); + }); + + testWidgets('a wrapping list keeps its items on one run when they fit', ( + tester, + ) async { + await vp.pump( + tester, + const Size(800, 800), + vp.Host.loose, + wrappingList(width: 500), + [vp.rect('item', 'list')], + ); + expect(find.byType(Wrap), findsOneWidget); + expect(find.byType(ViewportNodeBox), findsNothing); + final List items = itemRects(tester); + expect(items, hasLength(4)); + for (int i = 1; i < 4; i++) { + expect(items[i].top, items[0].top); + expect(items[i].left - items[i - 1].right, 14); + } + expect(tester.getSize(find.byType(Wrap)).height, 50); + }); + + testWidgets('a wrapping list moves items that do not fit to a new run', ( + tester, + ) async { + await vp.pump(tester, const Size(800, 800), vp.Host.loose, wrappingList(), [ + vp.rect('item', 'list'), + ]); + final List items = itemRects(tester); + // 100 + 14 + 100 = 214 fits in 250, a third item does not. + expect(items[1].top, items[0].top); + expect(items[2].left, items[0].left); + expect(items[2].top - items[0].bottom, 6); + expect(items[3].top, items[2].top); + expect(tester.getSize(find.byType(Wrap)), const Size(250, 106)); + }); + + testWidgets('an expanded wrapping list wraps at the width it gets', ( + tester, + ) async { + await vp.pump( + tester, + const Size(250, 800), + vp.Host.tight, + wrappingList(width: 800, horizontalFit: SizeFit.expanded), + [vp.rect('item', 'list')], + ); + final List items = itemRects(tester); + expect(items[2].top - items[0].bottom, 6); + }); + + testWidgets('without a separator the items touch', (tester) async { + await vp.pump( + tester, + const Size(800, 800), + vp.Host.loose, + wrappingList(width: 500, hasSeparator: false), + [vp.rect('item', 'list')], + ); + final List items = itemRects(tester); + expect(items[1].left, items[0].right); + }); + + testWidgets('a wrapping list with no count scrolls as before', ( + tester, + ) async { + await vp.pump( + tester, + const Size(800, 800), + vp.Host.loose, + wrappingList(itemCount: null)..verticalFit = SizeFit.fixed, + [vp.rect('item', 'list')], + ); + expect(find.byType(Wrap), findsNothing); + expect(find.byType(ViewportNodeBox), findsOneWidget); + }); +} diff --git a/test/multi_page_publish_test.dart b/test/multi_page_publish_test.dart new file mode 100644 index 00000000..65ebc393 --- /dev/null +++ b/test/multi_page_publish_test.dart @@ -0,0 +1,231 @@ +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:codelessly_sdk/codelessly_sdk.dart'; +import 'package:flutter_test/flutter_test.dart'; + +// A project published one page at a time: page A's layouts must survive a +// publish of page B, and a screen of one page must be able to open a screen +// of the other. + +SDKPublishLayout layout(String id, String pageId, List canvasIds) => + SDKPublishLayout( + id: id, + pageId: pageId, + projectId: 'project', + canvases: { + for (final String canvasId in canvasIds) + canvasId: { + canvasId: CanvasNode( + id: canvasId, + name: canvasId, + basicBoxLocal: NodeBox(0, 0, 390, 844), + properties: CanvasProperties(bodyId: canvasId), + children: const [], + ), + }, + }, + lastUpdated: DateTime(2026), + owner: 'owner', + teams: const {}, + users: const {}, + roles: const {}, + public: false, + ); + +SDKPublishModel model({ + Map layouts = const {}, + SDKPublishUpdates? updates, +}) { + final SDKPublishModel published = SDKPublishModel( + projectId: 'project', + updates: updates, + owner: 'owner', + teams: const {}, + users: const {}, + roles: const {}, + public: false, + ); + layouts.forEach((String id, SDKPublishLayout layout) { + published.layouts[id] = layout; + }); + return published; +} + +void main() { + group('SDKPublishUpdates.merge', () { + final DateTime t1 = DateTime(2026, 1, 1); + final DateTime t2 = DateTime(2026, 2, 1); + + // Page A was published first: layout a1 (fonts f1, api p1). + final SDKPublishUpdates pageA = SDKPublishUpdates( + layouts: {'a1': t1}, + fonts: {'f1': t1}, + apis: {'p1': t1}, + variables: {'a1': t1}, + conditions: {'a1': t1}, + layoutFonts: { + 'a1': {'f1'}, + }, + layoutApis: { + 'a1': {'p1'}, + }, + layoutCanvases: { + 'a1': {'canvasA'}, + }, + ); + + test('a publish of another page keeps the first page\'s layouts', () { + final SDKPublishUpdates pageB = SDKPublishUpdates( + layouts: {'b1': t2}, + fonts: {'f2': t2}, + variables: {'b1': t2}, + layoutFonts: { + 'b1': {'f2'}, + }, + layoutApis: {'b1': {}}, + layoutCanvases: { + 'b1': {'canvasB'}, + }, + ); + + final SDKPublishUpdates merged = pageA.merge(pageB); + + expect(merged.layouts, {'a1': t1, 'b1': t2}); + expect(merged.fonts, {'f1': t1, 'f2': t2}); + expect(merged.apis, {'p1': t1}); + expect(merged.variables, {'a1': t1, 'b1': t2}); + expect(merged.conditions, {'a1': t1}); + // These were replaced by the newer publish's maps, which dropped the + // fonts and apis of page A's layouts. + expect(merged.layoutFonts, { + 'a1': {'f1'}, + 'b1': {'f2'}, + }); + expect(merged.layoutApis, { + 'a1': {'p1'}, + 'b1': {}, + }); + expect(merged.layoutCanvases, { + 'a1': {'canvasA'}, + 'b1': {'canvasB'}, + }); + }); + + test('a republished layout takes the newer entries', () { + final SDKPublishUpdates again = SDKPublishUpdates( + layouts: {'a1': t2}, + layoutFonts: {'a1': {}}, + layoutCanvases: { + 'a1': {'canvasA', 'canvasA2'}, + }, + ); + + final SDKPublishUpdates merged = pageA.merge(again); + + expect(merged.layouts, {'a1': t2}); + expect(merged.layoutFonts, {'a1': {}}); + expect(merged.layoutApis, { + 'a1': {'p1'}, + }); + expect(merged.layoutCanvases['a1'], {'canvasA', 'canvasA2'}); + }); + + test('a canvas listed by a newer layout leaves its old layout', () { + final SDKPublishUpdates old = SDKPublishUpdates( + layoutCanvases: { + 'a1': {'canvasA'}, + 'a2': {'canvasMoved', 'canvasStays'}, + }, + ); + final SDKPublishUpdates merged = old.merge( + SDKPublishUpdates( + layoutCanvases: { + 'b1': {'canvasMoved'}, + }, + ), + ); + + expect(merged.layoutCanvases, { + 'a1': {'canvasA'}, + 'a2': {'canvasStays'}, + 'b1': {'canvasMoved'}, + }); + expect(merged.layoutIdForCanvas('canvasMoved'), 'b1'); + }); + + test('layoutCanvases round-trips through JSON', () { + final SDKPublishUpdates decoded = SDKPublishUpdates.fromJson( + pageA.toJson(), + ); + expect(decoded.layoutCanvases, pageA.layoutCanvases); + // Models published before the index existed still parse. + expect( + SDKPublishUpdates.fromJson({}).layoutCanvases, + isEmpty, + ); + }); + }); + + group('resolveLayoutIdForCanvas', () { + test('finds a downloaded layout of any page by its canvas', () { + final SDKPublishModel published = model( + layouts: { + 'a1': layout('a1', 'pageA', ['canvasA']), + 'b1': layout('b1', 'pageB', ['canvasB1', 'canvasB2']), + }, + ); + + expect( + FunctionsRepository.resolveLayoutIdForCanvas(published, 'canvasA'), + 'a1', + ); + expect( + FunctionsRepository.resolveLayoutIdForCanvas(published, 'canvasB2'), + 'b1', + ); + // A layout id works as a destination too. + expect( + FunctionsRepository.resolveLayoutIdForCanvas(published, 'b1'), + 'b1', + ); + }); + + test('finds a layout that is not downloaded yet from the index', () { + final SDKPublishModel published = model( + layouts: { + 'a1': layout('a1', 'pageA', ['canvasA']), + }, + updates: SDKPublishUpdates( + layoutCanvases: { + 'a1': {'canvasA'}, + 'b1': {'canvasB'}, + }, + ), + ); + + expect( + FunctionsRepository.resolveLayoutIdForCanvas(published, 'canvasB'), + 'b1', + ); + }); + + test('is null for an unknown canvas', () { + final SDKPublishModel published = model( + layouts: { + 'a1': layout('a1', 'pageA', ['canvasA']), + }, + ); + expect( + FunctionsRepository.resolveLayoutIdForCanvas(published, 'nope'), + isNull, + ); + expect( + FunctionsRepository.resolveLayoutIdForCanvas(published, ''), + isNull, + ); + expect( + FunctionsRepository.resolveLayoutIdForCanvas(null, 'canvasA'), + isNull, + ); + }); + }); +} diff --git a/test/radius_padding_binding_test.dart b/test/radius_padding_binding_test.dart new file mode 100644 index 00000000..f57cb940 --- /dev/null +++ b/test/radius_padding_binding_test.dart @@ -0,0 +1,149 @@ +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( + 'corner radius, padding and stroke weight follow bound formulas', + (tester) async { + final RectangleNode node = RectangleNode.fromJson({ + ...RectangleNode( + id: 'sheet', + name: 'Sheet', + basicBoxLocal: NodeBox(0, 0, 200, 100), + strokes: [PaintModel.blackPaint], + strokeWeight: 1.5, + fills: [ + PaintModel.solid(id: 'fill', color: ColorRGB.fromHex('#FFFFFF')), + ], + ).toJson(), + 'variables': { + 'cornerRadius': r'${= isMobile ? [18, 18, 0, 0] : 12}', + 'padding': r'${= isMobile ? [16, 8] : 24}', + 'strokeWeight': r'${= isMobile ? 1.5 : 2}', + }, + }); + node.parentID = kRootNode; + final registry = NodeRegistry()..setNodes({node.id: node}); + final manager = PassiveNodeTransformerManager( + registry.getNodeByID, + (context, bounds, pageID, layoutID, canvasID) => const SizedBox(), + ); + final Observable isMobile = Observable( + VariableData( + id: 'isMobile', + name: 'isMobile', + type: VariableType.boolean, + value: true, + ), + ); + final CodelesslyContext codelesslyContext = CodelesslyContext.empty() + ..variables['isMobile'] = isMobile; + + late BuildContext buildContext; + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: codelesslyContext, + child: MaterialApp( + home: Align( + alignment: Alignment.topLeft, + child: Builder( + builder: (context) { + buildContext = context; + return manager.buildWidgetFromNode( + node, + context, + settings: const WidgetBuildSettings(debugLabel: 'test'), + ); + }, + ), + ), + ), + ), + ); + + BorderRadiusGeometry? radius() => tester.allWidgets + .map( + (w) => switch (w) { + final Ink ink => ink.decoration, + final DecoratedBox box => box.decoration, + final Container c => c.decoration, + _ => null, + }, + ) + .whereType() + .map((decoration) => decoration.borderRadius) + .nonNulls + .firstOrNull; + + expect( + radius(), + const BorderRadius.only( + topLeft: Radius.circular(18), + topRight: Radius.circular(18), + ), + ); + BaseNode styled = manager.applyStyleOverrides(buildContext, node); + expect( + styled.padding, + const EdgeInsetsModel.symmetric(horizontal: 16, vertical: 8), + ); + expect(styled.innerBoxLocal.edgeLeft, 16); + expect((styled as GeometryMixin).strokeWeight, 1.5); + + isMobile.set(isMobile.value.copyWith(value: false)); + await tester.pump(); + + expect(radius(), BorderRadius.circular(12)); + styled = manager.applyStyleOverrides(buildContext, node); + expect(styled.padding, const EdgeInsetsModel.all(24)); + expect((styled as GeometryMixin).strokeWeight, 2); + expect(node.strokeWeight, 1.5); + expect( + (styled as CornerMixin).cornerRadius, + const CornerRadius.all(RadiusModel.circular(12)), + ); + // The node itself is unchanged. + expect(node.cornerRadius, CornerRadius.zero); + expect(node.padding, EdgeInsetsModel.zero); + }, + ); + + testWidgets('an unparseable binding keeps the node\'s own values', ( + tester, + ) async { + final RectangleNode node = RectangleNode.fromJson({ + ...RectangleNode( + id: 'box', + name: 'Box', + basicBoxLocal: NodeBox(0, 0, 50, 50), + cornerRadius: const CornerRadius.all(RadiusModel.circular(6)), + padding: const EdgeInsetsModel.all(4), + ).toJson(), + 'variables': {'cornerRadius': r'${= "round"}', 'padding': r'${= "x"}'}, + }); + node.parentID = kRootNode; + final registry = NodeRegistry()..setNodes({node.id: node}); + final manager = PassiveNodeTransformerManager( + registry.getNodeByID, + (context, bounds, pageID, layoutID, canvasID) => const SizedBox(), + ); + late BuildContext buildContext; + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: CodelesslyContext.empty(), + child: MaterialApp( + home: Builder( + builder: (context) { + buildContext = context; + return const SizedBox(); + }, + ), + ), + ), + ); + expect(manager.applyStyleOverrides(buildContext, node), same(node)); + }); +} diff --git a/test/scoped_values_overrides_test.dart b/test/scoped_values_overrides_test.dart new file mode 100644 index 00000000..2181cb39 --- /dev/null +++ b/test/scoped_values_overrides_test.dart @@ -0,0 +1,48 @@ +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('variable overrides are found by name, as formulas read them', ( + tester, + ) async { + late ScopedValues scope; + await tester.pumpWidget( + ChangeNotifierProvider( + create: (_) => CodelesslyContext.empty(), + child: Builder( + builder: (context) { + scope = ScopedValues.of( + context, + variablesOverrides: [ + VariableData( + id: '0SfOJhCuRyCYVLBBiyZE', + name: 'formStart', + type: VariableType.integer, + value: '360', + ), + ], + ); + return const SizedBox(); + }, + ), + ), + ); + expect(scope.variables.keys, ['formStart']); + final IconNode icon = IconNode( + id: 'minus', + name: 'minus', + basicBoxLocal: NodeBox(0, 0, 24, 24), + )..variables['iconColor'] = r"${= formStart <= 0 ? '#D8D2C8' : '#2B2420'}"; + expect( + PropertyValueDelegate.getPropertyValue( + icon, + 'iconColor', + scopedValues: scope, + )?.toFlutterColor().toARGB32(), + 0xFF2B2420, + ); + }); +} diff --git a/test/show_dialog_presentation_test.dart b/test/show_dialog_presentation_test.dart new file mode 100644 index 00000000..9dbe7d69 --- /dev/null +++ b/test/show_dialog_presentation_test.dart @@ -0,0 +1,311 @@ +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 'feedback_actions_test.dart' as harness; + +/// A canvas of [size] holding the text 'Overlay'. +List overlayScreen(Size size) => [ + CanvasNode( + id: 'overlay', + name: 'overlay', + basicBoxLocal: NodeBox(0, 0, size.width, size.height), + children: ['overlayBody'], + properties: CanvasProperties( + bodyId: 'overlayBody', + safeArea: const SafeAreaModel.all(false), + ), + )..parentID = kRootNode, + harness.body('overlayBody', 'overlay', ['label']), + harness.text('label', 'overlayBody', 'Overlay', harness.brandRegular), +]; + +/// A canvas of [width] that hugs its content vertically: the 'Overlay' text +/// over a box [contentHeight] tall, in a body that hugs too. +List huggingScreen(double width, double contentHeight) => [ + CanvasNode( + id: 'overlay', + name: 'overlay', + basicBoxLocal: NodeBox(0, 0, width, 600), + verticalFit: SizeFit.shrinkWrap, + scaleMode: ScaleMode.responsive, + children: ['overlayBody'], + properties: CanvasProperties( + bodyId: 'overlayBody', + safeArea: const SafeAreaModel.all(false), + ), + )..parentID = kRootNode, + AutoPlaceholderNode( + id: 'overlayBody', + name: 'overlayBody', + basicBoxLocal: NodeBox(0, 0, width, contentHeight + 19), + children: ['label', 'box'], + mainAxisAlignment: MainAxisAlignmentC.start, + crossAxisAlignment: CrossAxisAlignmentC.start, + horizontalFit: SizeFit.expanded, + verticalFit: SizeFit.shrinkWrap, + )..parentID = 'overlay', + harness.text('label', 'overlayBody', 'Overlay', harness.brandRegular), + RectangleNode( + id: 'box', + name: 'box', + basicBoxLocal: NodeBox(0, 0, 100, contentHeight), + )..parentID = 'overlayBody', +]; + +Future showOverlay( + WidgetTester tester, + Map extra, { + Size design = const Size(390, 400), + Size screen = const Size(834, 1112), + List? overlay, +}) async { + final ActionModel action = ActionModel.fromJson({ + 'type': 'showDialog', + 'destinationId': 'overlay', + ...extra, + }); + await harness.pumpLayouts(tester, [ + harness.layoutOf('main', harness.mainScreen(action)), + harness.layoutOf('sheet', overlay ?? overlayScreen(design)), + ], size: screen); + await tester.tap(find.text('Go')); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + expect(find.text('Overlay'), findsOneWidget); + // The overlay's canvas: the layout widget the overlay route built. + return tester.getRect(find.byType(CodelesslyWidget).last); +} + +void main() { + testWidgets('a dialog keeps its design size, centered', (tester) async { + final Rect box = await showOverlay(tester, {}); + expect(box.size, const Size(390, 400)); + expect(box.center, const Offset(417, 556)); + }); + + testWidgets('an aligned dialog sits in its corner, inset from the edges', ( + tester, + ) async { + final Rect box = await showOverlay(tester, { + 'alignment': { + 'data': [-1, 1], + }, + 'insets': [96, 0, 0, 16], + }, design: const Size(340, 300)); + expect(box.left, 96); + expect(box.bottom, 1112 - 16); + expect(box.size, const Size(340, 300)); + }); + + testWidgets('a bottom sheet spans the screen width at its design height', ( + tester, + ) async { + final Rect box = await showOverlay(tester, { + 'presentation': 'bottomSheet', + }, screen: const Size(390, 844)); + expect(box.width, 390); + expect(box.height, 400); + expect(box.bottom, 844); + }); + + testWidgets('a tall bottom sheet stops at 88% of the screen', (tester) async { + final Rect box = await showOverlay( + tester, + {'presentation': 'bottomSheet'}, + design: const Size(390, 2000), + screen: const Size(390, 844), + ); + expect(box.height, closeTo(844 * 0.88, 0.5)); + expect(box.bottom, 844); + }); + + testWidgets('a side sheet hugs the right edge at its design width', ( + tester, + ) async { + final Rect box = await showOverlay(tester, { + 'presentation': 'sideSheet', + 'insets': 10, + }, design: const Size(420, 700)); + expect(box.width, 420); + expect(box.right, 834 - 10); + expect(box.top, 10); + expect(box.bottom, 1112 - 10); + }); + + group('maxHeightFactor', () { + testWidgets('a dialog that fits keeps its design height', (tester) async { + final Rect box = await showOverlay( + tester, + {'maxHeightFactor': 0.9}, + design: const Size(600, 1000), + screen: const Size(834, 1112), + ); + expect(box.size, const Size(600, 1000)); + }); + + testWidgets('a taller dialog stops at the share of the screen', ( + tester, + ) async { + final Rect box = await showOverlay( + tester, + {'maxHeightFactor': 0.9}, + design: const Size(600, 1000), + screen: const Size(1440, 900), + ); + expect(box.width, 600); + expect(box.height, closeTo(810, 0.5)); + expect(box.center, const Offset(720, 450)); + }); + + testWidgets('without it a dialog keeps its design height', (tester) async { + final Rect box = await showOverlay( + tester, + {}, + design: const Size(600, 800), + screen: const Size(1440, 900), + ); + expect(box.height, 800); + }); + + testWidgets('a bottom sheet uses it instead of 88%', (tester) async { + final Rect box = await showOverlay( + tester, + {'presentation': 'bottomSheet', 'maxHeightFactor': 0.5}, + design: const Size(390, 2000), + screen: const Size(390, 844), + ); + expect(box.height, closeTo(422, 0.5)); + expect(box.bottom, 844); + }); + + test('maxHeightFor caps by the share, ignoring unusable factors', () { + expect(CodelesslyDialogWidget.maxHeightFor(0.9, 1000), 900); + expect(CodelesslyDialogWidget.maxHeightFor(2, 1000), 1000); + expect(CodelesslyDialogWidget.maxHeightFor(null, 1000), isNull); + expect(CodelesslyDialogWidget.maxHeightFor(0, 1000), isNull); + }); + }); + + group('a canvas that hugs its content', () { + // The box plus the label's line, far below the canvas's 600 design + // height. + void expectContentHeight(Rect box, double boxHeight) => expect( + box.height, + allOf(greaterThan(boxHeight + 10), lessThan(boxHeight + 30)), + ); + + testWidgets('makes a bottom sheet as tall as its content', (tester) async { + final Rect box = await showOverlay( + tester, + {'presentation': 'bottomSheet'}, + screen: const Size(390, 844), + overlay: huggingScreen(390, 200), + ); + expect(box.width, 390); + expectContentHeight(box, 200); + expect(box.bottom, 844); + }); + + testWidgets('stops a bottom sheet at its cap', (tester) async { + final Rect box = await showOverlay( + tester, + {'presentation': 'bottomSheet', 'maxHeightFactor': 0.25}, + screen: const Size(390, 844), + overlay: huggingScreen(390, 400), + ); + expect(box.height, closeTo(211, 0.5)); + expect(box.bottom, 844); + }); + + testWidgets('makes a side sheet as tall as its content, centered', ( + tester, + ) async { + final Rect box = await showOverlay(tester, { + 'presentation': 'sideSheet', + 'insets': 10, + }, overlay: huggingScreen(420, 300)); + expect(box.width, 420); + expect(box.right, 834 - 10); + expectContentHeight(box, 300); + expect(box.center.dy, closeTo(1112 / 2, 0.5)); + }); + + testWidgets('makes a dialog as tall as its content', (tester) async { + final Rect box = await showOverlay( + tester, + {}, + overlay: huggingScreen(400, 150), + ); + expect(box.size.width, 400); + expectContentHeight(box, 150); + }); + }); + + group('the actions after it run when the overlay closes', () { + /// Shows the overlay from a button whose next action toasts 'Closed'. + Future open(WidgetTester tester, Map extra) async { + final ActionModel show = ActionModel.fromJson({ + 'type': 'showDialog', + 'destinationId': 'overlay', + ...extra, + }); + final List main = harness.mainScreen(show); + final ButtonNode go = main.whereType().single; + go.reactions.add( + Reaction( + action: ActionModel.fromJson({ + 'type': 'showToast', + 'message': 'Closed', + }), + trigger: TriggerModel(type: TriggerType.click), + ), + ); + await harness.pumpLayouts(tester, [ + harness.layoutOf('main', main), + harness.layoutOf('sheet', overlayScreen(const Size(390, 400))), + ], size: const Size(834, 1112)); + await tester.tap(find.text('Go')); + await tester.pumpAndSettle(); + expect(find.text('Overlay'), findsOneWidget); + expect(find.text('Closed'), findsNothing); + } + + Future expectClosedOnce(WidgetTester tester) async { + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); + expect(find.text('Overlay'), findsNothing); + expect(find.text('Closed'), findsOneWidget); + await tester.pumpAndSettle(const Duration(seconds: 5)); + } + + testWidgets('a dialog dismissed by its barrier', (tester) async { + await open(tester, {}); + await tester.tapAt(const Offset(10, 10)); + await expectClosedOnce(tester); + }); + + testWidgets('a bottom sheet dismissed by its barrier', (tester) async { + await open(tester, {'presentation': 'bottomSheet'}); + await tester.tapAt(const Offset(10, 10)); + await expectClosedOnce(tester); + }); + + testWidgets('a side sheet dismissed by its barrier', (tester) async { + await open(tester, {'presentation': 'sideSheet'}); + await tester.tapAt(const Offset(10, 10)); + await expectClosedOnce(tester); + }); + + testWidgets('a dialog closed by the system back', (tester) async { + await open(tester, {}); + final NavigatorState navigator = tester.state( + find.byType(Navigator).last, + ); + navigator.pop(); + await expectClosedOnce(tester); + }); + }); +} diff --git a/test/static_list_test.dart b/test/static_list_test.dart new file mode 100644 index 00000000..bd2211f9 --- /dev/null +++ b/test/static_list_test.dart @@ -0,0 +1,100 @@ +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 'viewport_layout_test.dart' as vp; + +/// A list that doesn't scroll and hugs its three items. +ListViewNode staticList({String parentID = kRootNode}) => + vp.list(parentID: parentID, verticalFit: SizeFit.shrinkWrap) + ..isScrollable = false; + +RowColumnNode flex( + String id, + String parentID, + RowColumnType type, + List children, { + SizeFit horizontalFit = SizeFit.shrinkWrap, + SizeFit verticalFit = SizeFit.shrinkWrap, + CrossAxisAlignmentC cross = CrossAxisAlignmentC.start, +}) => RowColumnNode( + id: id, + name: id, + basicBoxLocal: NodeBox(0, 0, 300, 300), + rowColumnType: type, + horizontalFit: horizontalFit, + verticalFit: verticalFit, + crossAxisAlignment: cross, + children: children, +)..parentID = parentID; + +void main() { + test('a list that neither scrolls nor has a fixed extent lets intrinsics ' + 'through', () { + final list = staticList(); + final item = vp.rect('item', 'list'); + BaseNode getNode(String id) => {'list': list, 'item': item}[id]!; + + expect(listBuildsAllItems(list), isTrue); + expect(subtreeBlocksIntrinsics(list, getNode), isFalse); + + list.isScrollable = true; + expect(listBuildsAllItems(list), isFalse); + expect(subtreeBlocksIntrinsics(list, getNode), isTrue); + + list + ..isScrollable = false + ..verticalFit = SizeFit.fixed; + expect(subtreeBlocksIntrinsics(list, getNode), isTrue); + + list + ..verticalFit = SizeFit.shrinkWrap + ..properties = ListViewProperties(); + expect(listBuildsAllItems(list), isFalse, reason: 'endless list'); + }); + + vp.forEachLayout('a static list lays its items out without a viewport', ( + tester, + screen, + host, + ) async { + await vp.pump(tester, screen, host, staticList(), [ + vp.rect('item', 'list'), + ]); + expect(find.byType(ViewportNodeBox), findsNothing); + expect(vp.rectOf('item'), findsNWidgets(3)); + final Rect first = tester.getRect(vp.rectOf('item').at(0)); + final Rect last = tester.getRect(vp.rectOf('item').at(2)); + expect(last.bottom - first.top, 150); + expect(first.size, const Size(100, 50)); + }); + + vp.forEachLayout('a stretched row keeps cards equal with a list in one', ( + tester, + screen, + host, + ) async { + // row(stretch) > [left column > list of 3 x 50, right column > 300 rect] + final row = flex('row', kRootNode, RowColumnType.row, [ + 'left', + 'right', + ], cross: CrossAxisAlignmentC.stretch); + await vp.pump(tester, screen, host, row, [ + flex('left', 'row', RowColumnType.column, [ + 'list', + ], verticalFit: SizeFit.expanded), + staticList(parentID: 'left'), + vp.rect('item', 'list'), + flex('right', 'row', RowColumnType.column, ['tall']), + vp.rect('tall', 'right', height: 300), + ]); + final Finder left = vp.rectOf('left'); + final Finder right = vp.rectOf('right'); + // Without intrinsics the stretch fell back to start and the left card + // kept its own (list) height. A tight host stretches both to the screen. + if (host != vp.Host.tight) expect(tester.getSize(right).height, 300); + expect(tester.getSize(left).height, tester.getSize(right).height); + expect(vp.rectOf('item'), findsNWidgets(3)); + }); +} diff --git a/test/text_field_content_padding_test.dart b/test/text_field_content_padding_test.dart new file mode 100644 index 00000000..a1107866 --- /dev/null +++ b/test/text_field_content_padding_test.dart @@ -0,0 +1,104 @@ +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 InputBorderModel _none = InputBorderModel( + borderType: BorderTypeEnum.none, +); + +TextFieldNode _field({EdgeInsetsModel? contentPadding}) => TextFieldNode( + id: 'field', + name: 'Field', + basicBoxLocal: NodeBox(0, 0, 300, 40), + properties: TextFieldProperties( + decoration: InputDecorationModel( + isCollapsed: true, + hintText: 'Notes', + border: _none, + enabledBorder: _none, + focusedBorder: _none, + disabledBorder: _none, + errorBorder: _none, + focusedErrorBorder: _none, + contentPadding: contentPadding, + ), + ), +); + +Future _pump(WidgetTester tester, TextFieldNode node) async { + final registry = NodeRegistry()..setNodes({node.id: node}); + final manager = PassiveNodeTransformerManager( + registry.getNodeByID, + (context, bounds, pageID, layoutID, canvasID) => const SizedBox(), + ); + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: CodelesslyContext.empty(), + child: MaterialApp( + home: Scaffold( + body: Align( + alignment: Alignment.topLeft, + child: Builder( + builder: (context) => manager.buildWidgetFromNode( + node, + context, + settings: const WidgetBuildSettings(debugLabel: 'test'), + ), + ), + ), + ), + ), + ), + ); + await tester.pump(); +} + +void main() { + testWidgets('a zero content padding puts the text flush with the field', ( + tester, + ) async { + await _pump(tester, _field(contentPadding: EdgeInsetsModel.zero)); + final InputDecoration decoration = tester + .widget(find.byType(TextField)) + .decoration!; + expect(decoration.contentPadding, EdgeInsets.zero); + expect( + tester.getTopLeft(find.byType(EditableText)).dx, + tester.getTopLeft(find.byType(TextField)).dx, + ); + }); + + testWidgets('without one, the node padding (12 x 16) pads the text', ( + tester, + ) async { + await _pump(tester, _field()); + final InputDecoration decoration = tester + .widget(find.byType(TextField)) + .decoration!; + expect( + decoration.contentPadding, + const EdgeInsets.symmetric(horizontal: 12, vertical: 16), + ); + expect( + tester.getTopLeft(find.byType(EditableText)).dx - + tester.getTopLeft(find.byType(TextField)).dx, + 12, + ); + }); + + testWidgets('a custom content padding applies as given', (tester) async { + await _pump( + tester, + _field(contentPadding: const EdgeInsetsModel.only(left: 4, top: 2)), + ); + expect( + tester + .widget(find.byType(TextField)) + .decoration! + .contentPadding, + const EdgeInsets.only(left: 4, top: 2), + ); + }); +} diff --git a/test/text_span_range_test.dart b/test/text_span_range_test.dart new file mode 100644 index 00000000..d62d65b4 --- /dev/null +++ b/test/text_span_range_test.dart @@ -0,0 +1,27 @@ +import 'package:flutter/widgets.dart'; +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:codelessly_sdk/codelessly_sdk.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + TextSpan span(String text, int start, int end) => + TextUtils.buildTextSpanForProp( + null, + text, + node: null, + prop: StartEndProp(start: start, end: end), + tapGestureRecognizer: null, + variablesOverrides: const [], + nullSubstitutionMode: NullSubstitutionMode.noChange, + replaceVariableWithSymbol: false, + ); + + test('a range inside the text takes its characters', () { + expect(span('Swell report', 0, 5).toPlainText(), 'Swell'); + }); + + test('a range past the end of the text is clamped instead of throwing', () { + expect(span('Swell', 0, 9).toPlainText(), 'Swell'); + expect(span('Swell', 7, 9).toPlainText(), ''); + }); +} diff --git a/test/toast_test.dart b/test/toast_test.dart index 940850e8..fa942566 100644 --- a/test/toast_test.dart +++ b/test/toast_test.dart @@ -65,4 +65,160 @@ void main() { await tester.pump(const Duration(milliseconds: 300)); expect(find.text('Second'), findsNothing); }); + + testWidgets('a toast can have an orange Undo, a close button, a radius, ' + 'a font size, a bound offset and the app font', (tester) async { + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + late BuildContext context; + final ctx = CodelesslyContext.empty() + ..variables['isMobile'] = Observable( + VariableData( + id: 'isMobile', + name: 'isMobile', + type: VariableType.boolean, + value: true, + ), + ); + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: ctx, + child: MaterialApp( + home: DefaultTextStyle( + style: const TextStyle(fontFamily: 'AppBody'), + child: Builder( + builder: (c) { + context = c; + return const SizedBox(); + }, + ), + ), + ), + ), + ); + + FunctionsRepository.triggerAction( + context, + TriggerType.click, + reactions: [ + Reaction( + action: ShowToastAction( + message: 'Session deleted', + duration: 10000, + backgroundColor: ColorRGBA.fromHex('#0E2233'), + textColor: ColorRGBA.white, + actionLabel: 'UNDO', + actionColor: ColorRGBA.fromHex('#F2542D'), + showClose: true, + cornerRadius: 8, + fontSize: 13, + offset: r'${= isMobile ? 72 : 16}', + ), + trigger: TriggerModel(type: TriggerType.click), + ), + ], + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + + final Text message = tester.widget(find.text('Session deleted')); + expect(message.style!.fontSize, 13); + expect(message.style!.fontFamily, 'AppBody'); + expect(message.style!.color, Colors.white); + + final ButtonStyle undo = tester + .widget( + find.byKey(const ValueKey('codelessly-toast-action')), + ) + .style!; + expect( + undo.foregroundColor!.resolve({}), + ColorRGBA.fromHex('#F2542D')!.toFlutterColor(), + ); + + final BoxDecoration decoration = tester + .widgetList( + find.ancestor( + of: find.text('Session deleted'), + matching: find.byType(Container), + ), + ) + .map((c) => c.decoration) + .whereType() + .first; + expect(decoration.borderRadius, BorderRadius.circular(8)); + expect(decoration.color, ColorRGBA.fromHex('#0E2233')!.toFlutterColor()); + + // 72 px above the bottom, from the bound offset. + final Positioned positioned = tester.widget( + find + .ancestor( + of: find.text('Session deleted'), + matching: find.byType(Positioned), + ) + .first, + ); + expect(positioned.bottom, 72); + + // The close button hides it. + await tester.tap(find.byKey(const ValueKey('codelessly-toast-close'))); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + expect(find.text('Session deleted'), findsNothing); + }); + + testWidgets('an existing toast keeps its look without the new fields', ( + tester, + ) async { + late BuildContext context; + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: CodelesslyContext.empty(), + child: MaterialApp( + home: Builder( + builder: (c) { + context = c; + return const SizedBox(); + }, + ), + ), + ), + ); + AppToast.show(context, ShowToastAction(message: 'Saved')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + expect(tester.widget(find.text('Saved')).style!.fontSize, 14); + expect(find.byKey(const ValueKey('codelessly-toast-close')), findsNothing); + final Positioned positioned = tester.widget( + find + .ancestor(of: find.text('Saved'), matching: find.byType(Positioned)) + .first, + ); + expect(positioned.bottom, 104); + await tester.pump(const Duration(seconds: 3)); + await tester.pumpAndSettle(); + }); + + test('a toast can set its font family and weight', () { + const TextStyle app = TextStyle(fontFamily: 'Mono', fontSize: 14); + final TextStyle plain = AppToast.fontOf( + ShowToastAction(message: 'Saved'), + app, + ); + expect(plain.fontFamily, 'Mono'); + expect(plain.fontWeight, FontWeight.w600); + + final TextStyle custom = AppToast.fontOf( + ShowToastAction( + message: 'Saved', + fontFamily: 'Brand Sans', + fontWeight: FontWeightNumeric.w500, + ), + app, + ); + expect(custom.fontFamily, 'Brand Sans'); + expect(custom.fontWeight, FontWeight.w500); + expect(custom.fontSize, 14); + }); } diff --git a/test/transformers_test.dart b/test/transformers_test.dart index 3b21e502..8fd03117 100644 --- a/test/transformers_test.dart +++ b/test/transformers_test.dart @@ -23,6 +23,7 @@ List standaloneNodes() => [ ), ), ProgressBarNode.empty(), + ChartNode.empty(), RadioNode.empty(), SliderNode.empty(), SwitchNode.empty(), diff --git a/test/variable_preview_test.dart b/test/variable_preview_test.dart index f1ce98ff..bdddd414 100644 --- a/test/variable_preview_test.dart +++ b/test/variable_preview_test.dart @@ -104,4 +104,44 @@ void main() { 'Shots', ); }); + + group('list items', () { + setUp(() { + // As the editor resolves a node in a bound list: the first item. + VariablePreview.scopeResolver = (node) => ScopedValues.preview( + variables: { + 'region': VariableData( + id: 'v1', + name: 'region', + value: 'All', + type: VariableType.text, + ), + }, + indexedItem: IndexedItem(0, {'label': 'All'}), + ); + }); + + test('an item with data shows its own element', () { + final IndexedItem third = IndexedItem(2, {'label': 'Orange County'}); + expect( + VariablePreview.textFor(r'${item.label}', item: third), + 'Orange County', + ); + expect(VariablePreview.textFor(r'${= index + 1}', item: third), '3'); + expect( + VariableSpan(variable: r'${item.label}', item: third).text, + 'Orange County', + ); + // Variables still read the canvas's defaults. + expect(VariablePreview.textFor(r'${region}', item: third), 'All'); + }); + + test('without item data the item from the resolver stands', () { + expect(VariablePreview.textFor(r'${item.label}'), 'All'); + expect( + VariablePreview.textFor(r'${item.label}', item: IndexedItem(3, null)), + 'All', + ); + }); + }); }