diff --git a/CHANGELOG.md b/CHANGELOG.md index d6490ef2..1ccaf34b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,4 @@ -## Unreleased +## 8.3.0 ### Layout @@ -31,6 +31,39 @@ - A text field's `errorText` template that comes out empty is no error. - List items on the design canvas show their own item's text. +### Widgets + +- Gauges (`PassiveGaugeWidget`, `GaugePainter`) and bar charts + (`PassiveBarChartWidget`) render the new `gauge` and `barChart` nodes; both + take `scopedValues` so an editor can preview their bindings. +- Industrial slider: rectangle fader thumb with grip, fill from the thumb to + the end, notch tick marks, a value marker, a bindable active track color, + a selectable range and the `changeEnd` trigger. +- Text nodes take a bound `textColor`; icons take an `icon` binding naming a + Material icon (`iconWithName`). +- Nodes with a `transition` animate runtime opacity changes. +- `ScopedValues.preview` takes `routeParams`, so a design canvas can show + `${route.…}` with sample params. +- An icon bound to a name loads the SVG of that icon's version + (`materialIconVersions`), so icons added after version 1 (`air`) draw. +- `PassiveTextFieldWidget.scopedValues`: the values a field's bound and + initial text read, so an editor can show variable defaults. +- Nodes with corners take a `cornerRadius` binding: a number or + `[top-left, top-right, bottom-right, bottom-left]`. +- A row or column that hugs its cross axis and stretches its children gives + hugging children the thickest child's size, as the editor lays it out. + +### Actions and variables + +- Interval reactions run on a timer per period while a canvas shows. +- Dialogs open centered, as a bottom sheet, adaptively or anchored under the + trigger; canvas confirms take the same placement. +- Toasts: alignment, side margin, max width, corner radius, action color, + close button and font size. +- Computed variables also come from `VariableData.formula`, coerced to the + variable's type; `setVariable` leaves computed variables alone. +- Formulas: `pad`, `alpha`, `concat` and list `+`; `FormulaEvaluator.parses`. + ## 8.2.0 ### States diff --git a/lib/src/functions/app_confirm_dialog.dart b/lib/src/functions/app_confirm_dialog.dart index 76f330fe..c8941cda 100644 --- a/lib/src/functions/app_confirm_dialog.dart +++ b/lib/src/functions/app_confirm_dialog.dart @@ -45,6 +45,7 @@ class AppConfirmDialog { params: action.params, barrierDismissible: action.barrierDismissible, barrierColor: action.barrierColor?.toFlutterColor(), + placement: action.placement, ); final bool confirmed = ConfirmAction.isConfirmResult(result); return ( diff --git a/lib/src/functions/app_toast.dart b/lib/src/functions/app_toast.dart index 51faed5e..aa9a4a30 100644 --- a/lib/src/functions/app_toast.dart +++ b/lib/src/functions/app_toast.dart @@ -178,23 +178,31 @@ class _ToastViewState extends State<_ToastView> { child: retrieveIconWidget(action.icon!, 18) ?? const SizedBox(), ); final bool hasButton = widget.onAction != null; + final bool hasClose = action.showCloseButton; + final bool stretch = action.alignment == ToastAlignment.stretch; final bool top = action.position == ToastPosition.top; final EdgeInsets safe = MediaQuery.paddingOf(context); final TextStyle messageStyle = theme.bodyStyle.copyWith( color: textColor, - fontSize: 14, + fontSize: action.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), + constraints: BoxConstraints(minHeight: 44, maxWidth: action.maxWidth), + padding: EdgeInsets.fromLTRB( + 18, + hasButton || hasClose ? 6 : 10, + hasButton || hasClose ? 8 : 18, + hasButton || hasClose ? 6 : 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( @@ -205,17 +213,21 @@ class _ToastViewState extends State<_ToastView> { ], ), child: Row( - mainAxisSize: MainAxisSize.min, + mainAxisSize: stretch ? MainAxisSize.max : MainAxisSize.min, children: [ if (icon != null) ...[icon, const SizedBox(width: 8)], - Flexible(child: Text(widget.message, style: messageStyle)), + if (stretch) + Expanded(child: Text(widget.message, style: messageStyle)) + else + Flexible(child: Text(widget.message, style: messageStyle)), if (hasButton) ...[ const SizedBox(width: 8), TextButton( key: const ValueKey('codelessly-toast-action'), onPressed: _onActionPressed, style: TextButton.styleFrom( - foregroundColor: textColor, + foregroundColor: + action.actionColor?.toFlutterColor() ?? textColor, minimumSize: const Size(48, 36), padding: const EdgeInsets.symmetric(horizontal: 12), textStyle: theme.buttonStyle.copyWith( @@ -229,6 +241,16 @@ class _ToastViewState extends State<_ToastView> { child: Text(widget.actionLabel), ), ], + if (hasClose) + IconButton( + key: const ValueKey('codelessly-toast-close'), + onPressed: _close, + tooltip: 'Close message', + iconSize: 16, + visualDensity: VisualDensity.compact, + color: textColor, + icon: const Icon(Icons.close), + ), ], ), ); @@ -243,7 +265,13 @@ class _ToastViewState extends State<_ToastView> { offset: _visible ? Offset.zero : Offset(0, top ? -0.25 : 0.25), duration: _fade, curve: Curves.easeOutCubic, - child: Center( + child: Align( + alignment: switch (action.alignment) { + ToastAlignment.start => Alignment.centerLeft, + ToastAlignment.end => Alignment.centerRight, + ToastAlignment.center || + ToastAlignment.stretch => Alignment.center, + }, child: Material(type: MaterialType.transparency, child: toast), ), ), @@ -251,12 +279,15 @@ class _ToastViewState extends State<_ToastView> { ); return Positioned( - left: 16, - right: 16, + left: action.sideMargin, + right: action.sideMargin, top: top ? safe.top + 16 : null, bottom: top ? null : 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 86de7a90..46b5787f 100644 --- a/lib/src/functions/functions_repository.dart +++ b/lib/src/functions/functions_repository.dart @@ -571,9 +571,21 @@ class FunctionsRepository { barrierDismissible: action.barrierDismissible, barrierColor: action.barrierColor?.toFlutterColor(), showCloseButton: action.showCloseButton, + placement: action.placement, + anchorGap: action.anchorGap, ); } + /// The global rectangle of the widget [context] belongs to, if it is laid + /// out. + static Rect? _globalRectOf(BuildContext context) { + final RenderObject? box = context.mounted + ? context.findRenderObject() + : null; + if (box is! RenderBox || !box.hasSize || !box.attached) return null; + return box.localToGlobal(Offset.zero) & box.size; + } + /// 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 @@ -590,6 +602,8 @@ class FunctionsRepository { bool barrierDismissible = true, Color? barrierColor, bool showCloseButton = false, + DialogPlacement placement = DialogPlacement.center, + double anchorGap = 6, }) async { final ScopedValues scopedValues = ActionChain.scopedValuesOf(context); final parsedParams = substituteVariablesInMap(params, scopedValues); @@ -632,21 +646,94 @@ class FunctionsRepository { // A chain triggered in the dialog can finish on this layout after the // dialog closes (e.g. Navigate back, then a toast). final List openerHosts = ActionChainOpener.hostsFor(context); - final Object? result = await showDialog( - context: context, - useRootNavigator: false, - barrierDismissible: barrierDismissible, - barrierColor: barrierColor, - routeSettings: RouteSettings(arguments: parsedParams), - builder: (context) => ActionChainOpener( - openerHosts: openerHosts, - child: CodelesslyDialogWidget( - showCloseButton: showCloseButton, - builder: (context) => - CodelesslyWidget(codelessly: codelessly, layoutID: layoutId), - ), + Widget dialog(BuildContext context) => ActionChainOpener( + openerHosts: openerHosts, + child: CodelesslyDialogWidget( + showCloseButton: showCloseButton, + builder: (context) => + CodelesslyWidget(codelessly: codelessly, layoutID: layoutId), ), ); + + final Size screen = MediaQuery.sizeOf(context); + final DialogPlacement effective = switch (placement) { + DialogPlacement.adaptive => + screen.width < 600 + ? DialogPlacement.bottomSheet + : DialogPlacement.center, + _ => placement, + }; + final Rect? anchor = effective == DialogPlacement.anchored + ? _globalRectOf(context) + : null; + + final Future shown = switch (effective) { + DialogPlacement.bottomSheet => showGeneralDialog( + context: context, + useRootNavigator: false, + barrierDismissible: barrierDismissible, + barrierLabel: 'Close', + barrierColor: barrierColor ?? Colors.black54, + transitionDuration: const Duration(milliseconds: 250), + routeSettings: RouteSettings(arguments: parsedParams), + pageBuilder: (context, _, _) => CodelesslySheetPlacement( + child: ActionChainOpener( + openerHosts: openerHosts, + child: CanvasSizingScope( + sizing: CanvasSizing.fill, + child: CodelesslyWidget( + codelessly: codelessly, + layoutID: layoutId, + ), + ), + ), + ), + transitionBuilder: (context, animation, _, child) => SlideTransition( + position: Tween(begin: const Offset(0, 1), end: Offset.zero).animate( + CurvedAnimation(parent: animation, curve: Curves.easeOutCubic), + ), + child: child, + ), + ), + DialogPlacement.anchored when anchor != null => + showGeneralDialog( + context: context, + useRootNavigator: false, + barrierDismissible: barrierDismissible, + barrierLabel: 'Close', + barrierColor: barrierColor ?? Colors.transparent, + transitionDuration: const Duration(milliseconds: 120), + routeSettings: RouteSettings(arguments: parsedParams), + pageBuilder: (context, _, _) => CodelesslyAnchoredPlacement( + anchor: anchor, + gap: anchorGap, + child: ActionChainOpener( + openerHosts: openerHosts, + child: CanvasSizingScope( + sizing: CanvasSizing.design, + child: Material( + type: MaterialType.transparency, + child: CodelesslyWidget( + codelessly: codelessly, + layoutID: layoutId, + ), + ), + ), + ), + ), + transitionBuilder: (context, animation, _, child) => + FadeTransition(opacity: animation, child: child), + ), + _ => showDialog( + context: context, + useRootNavigator: false, + barrierDismissible: barrierDismissible, + barrierColor: barrierColor, + routeSettings: RouteSettings(arguments: parsedParams), + builder: dialog, + ), + }; + final Object? result = await shown; if (context.mounted) { codelessly.notifyNavigationListeners( context, @@ -1138,6 +1225,14 @@ ${response.body.contains('{') ? const JsonEncoder.withIndent(' ').convert(json. action.variable.name, ); if (variableNotifier == null) return false; + if (variableNotifier.value.isComputed) { + DebugLogger.instance.printInfo( + 'Variable ${action.variable.name} is computed from a formula and ' + "can't be set.", + name: name, + ); + return false; + } String newValue = substituteNewValue( action.newValue, diff --git a/lib/src/transformers/node_transformers/node_transformers.dart b/lib/src/transformers/node_transformers/node_transformers.dart index 8421a1e2..94c06deb 100644 --- a/lib/src/transformers/node_transformers/node_transformers.dart +++ b/lib/src/transformers/node_transformers/node_transformers.dart @@ -22,6 +22,8 @@ export 'passive_navigation_bar_transformer.dart'; export 'passive_page_view_transformer.dart'; export 'passive_placeholder_transformer.dart'; export 'passive_progressbar_transformer.dart'; +export 'passive_gauge_transformer.dart'; +export 'passive_bar_chart_transformer.dart'; export 'passive_radio_transformer.dart'; export 'passive_rectangle_transformer.dart'; export 'passive_row_column_transformer.dart'; diff --git a/lib/src/transformers/node_transformers/passive_bar_chart_transformer.dart b/lib/src/transformers/node_transformers/passive_bar_chart_transformer.dart new file mode 100644 index 00000000..2e282343 --- /dev/null +++ b/lib/src/transformers/node_transformers/passive_bar_chart_transformer.dart @@ -0,0 +1,264 @@ +import 'dart:convert'; + +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:flutter/material.dart'; + +import '../../../codelessly_sdk.dart'; + +class PassiveBarChartTransformer extends NodeWidgetTransformer { + PassiveBarChartTransformer(super.getNode, super.manager); + + @override + Widget buildWidget( + BarChartNode node, + BuildContext context, + WidgetBuildSettings settings, + ) { + return PassiveBarChartWidget(node: node, settings: settings); + } +} + +/// Renders a [BarChartNode], reading its bindable `values`, `labels`, +/// `highlight` and `color` from conditions and variables. +class PassiveBarChartWidget extends StatelessWidget { + final BarChartNode node; + final WidgetBuildSettings settings; + final List variablesOverrides; + + /// The values bindings read, when the caller has them, e.g. the editor's + /// variable defaults on the design canvas. Defaults to the context's. + final ScopedValues? scopedValues; + + /// Overrides [BarChartProperties.animate], e.g. to hold still in the editor. + final bool? animate; + + const PassiveBarChartWidget({ + super.key, + required this.node, + required this.settings, + this.variablesOverrides = const [], + this.scopedValues, + this.animate, + }); + + /// Reads a bound list: a list, or JSON text of one. + static List? _list(Object? value) { + if (value is List) return value; + if (value is String && value.trim().startsWith('[')) { + try { + final Object? decoded = jsonDecode(value); + if (decoded is List) return decoded; + } catch (_) {} + } + return null; + } + + static double? _number(Object? value) => switch (value) { + num() => value.toDouble(), + String() => double.tryParse(value.trim()), + _ => null, + }; + + @override + Widget build(BuildContext context) { + final ScopedValues scopedValues = + this.scopedValues ?? + ScopedValues.of(context, variablesOverrides: variablesOverrides); + final BarChartProperties props = node.properties; + Object? bound(String property) => + PropertyValueDelegate.getPropertyValue( + node, + property, + scopedValues: scopedValues, + ); + + final List values = + _list(bound('values'))?.map(_number).toList() ?? node.values; + final List labels = + _list(bound('labels'))?.map((e) => e == null ? '' : '$e').toList() ?? + node.labels; + final Object? boundHighlight = bound('highlight'); + final int? highlight = boundHighlight != null + ? _number(boundHighlight)?.toInt() + : (node.variables['highlight'] == null ? node.highlight : null); + final Color color = + PropertyValueDelegate.getPropertyValue( + node, + 'color', + scopedValues: scopedValues, + )?.toFlutterColor() ?? + props.color.toFlutterColor(); + + double max = props.max; + if (max <= 0) { + max = values.whereType().fold(0, (a, b) => b > a ? b : a); + } + final bool animated = animate ?? props.animate; + final bool hasLabels = labels.any((l) => l.isNotEmpty); + final bool gutter = props.gridLabels.isNotEmpty; + final double right = gutter ? props.gridLabelWidth : 0; + final TextStyle styled = TextUtils.retrieveTextStyle( + fontName: FontName( + family: props.labelFontFamily, + weight: FontWeightNumeric.w400, + ), + fontSize: props.labelFontSize, + color: props.labelColor.toFlutterColor(), + ).copyWith(height: 1.2); + + return AdaptiveNodeBox( + node: node, + child: LayoutBuilder( + builder: (context, constraints) { + final double labelHeight = hasLabels + ? props.labelFontSize * 1.2 + props.labelGap + : 0; + final double height = (constraints.maxHeight - labelHeight - 1).clamp( + 0, + double.infinity, + ); + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SizedBox( + height: height, + child: Stack( + clipBehavior: Clip.none, + children: [ + for (int g = 1; g <= props.gridLines; g++) ...[ + Positioned( + left: 0, + right: right, + bottom: height * g / props.gridLines - 0.5, + height: 1, + child: ColoredBox( + color: props.gridColor.toFlutterColor(), + ), + ), + if (gutter && g - 1 < props.gridLabels.length) + Positioned( + right: 0, + width: props.gridLabelWidth - 6, + bottom: + height * g / props.gridLines - + props.labelFontSize / 2, + child: Text( + props.gridLabels[g - 1], + style: styled.copyWith(height: 1), + maxLines: 1, + softWrap: false, + ), + ), + ], + Positioned.fill( + right: right, + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + for (int i = 0; i < values.length; i++) ...[ + if (i > 0) SizedBox(width: props.barGap), + Expanded( + child: _bar( + values[i], + i, + max, + height, + highlight, + color, + animated, + ), + ), + ], + ], + ), + ), + ], + ), + ), + Container( + height: 1, + margin: EdgeInsets.only(right: right), + color: props.baselineColor.toFlutterColor(), + ), + if (hasLabels) ...[ + SizedBox(height: props.labelGap), + Padding( + padding: EdgeInsets.only(right: right), + child: Row( + children: [ + for (int i = 0; i < values.length; i++) ...[ + if (i > 0) SizedBox(width: props.barGap), + Expanded( + child: Text( + i < labels.length ? labels[i] : '', + textAlign: TextAlign.center, + maxLines: 1, + softWrap: false, + overflow: TextOverflow.visible, + style: i == highlight + ? styled.copyWith( + color: props.highlightLabelColor + .toFlutterColor(), + ) + : styled, + ), + ), + ], + ], + ), + ), + ], + ], + ); + }, + ), + ); + } + + Widget _bar( + double? value, + int index, + double max, + double height, + int? highlight, + Color color, + bool animated, + ) { + final BarChartProperties props = node.properties; + if (value == null) { + return Align( + alignment: Alignment.bottomCenter, + child: Container( + height: props.emptyHeight, + color: props.emptyColor.toFlutterColor(), + ), + ); + } + final double h = max <= 0 + ? 0 + : (height * (value / max).clamp(0.0, 1.0)).clamp( + props.emptyHeight, + height, + ); + final Color barColor = + highlight == null || highlight < 0 || index == highlight + ? color + : color.withValues(alpha: color.a * props.dimmedOpacity); + return TweenAnimationBuilder( + tween: Tween(end: h), + duration: animated + ? Duration(milliseconds: props.animationDurationInMillis) + : Duration.zero, + curve: Curves.easeOutCubic, + builder: (context, hh, _) => Container( + height: hh, + decoration: BoxDecoration( + color: barColor, + borderRadius: BorderRadius.vertical( + top: Radius.circular(props.barRadius), + ), + ), + ), + ); + } +} diff --git a/lib/src/transformers/node_transformers/passive_canvas_transformer.dart b/lib/src/transformers/node_transformers/passive_canvas_transformer.dart index 9fe25753..f3ca31fa 100644 --- a/lib/src/transformers/node_transformers/passive_canvas_transformer.dart +++ b/lib/src/transformers/node_transformers/passive_canvas_transformer.dart @@ -666,8 +666,82 @@ class _PassiveCanvasWidgetState extends State { codelessly?.setSystemUIBrightness(widget.node.properties.brightness); } + /// Timers for the canvas's interval reactions, one per period. + final List _intervalTimers = []; + + /// Periods whose actions are still running, so a slow chain skips ticks + /// instead of piling up. + final Set _runningIntervals = {}; + + /// Interval actions grouped by period, in reaction order. + Map> collectIntervalActions() { + final Map> groups = {}; + for (final Reaction reaction in widget.node.reactions) { + if (reaction.trigger.type != TriggerType.interval) continue; + if (!reaction.action.enabled) continue; + groups + .putIfAbsent(reaction.trigger.effectiveIntervalMillis, () => []) + .add(reaction.action); + } + return groups; + } + + /// Starts (or restarts) a periodic timer for each interval period. Each + /// tick runs that period's actions in order on one chain, like the load + /// actions. + void startIntervals() { + stopIntervals(); + for (final MapEntry> group + in collectIntervalActions().entries) { + _intervalTimers.add( + Timer.periodic(Duration(milliseconds: group.key), (_) { + if (!mounted || _runningIntervals.contains(group.key)) return; + _runningIntervals.add(group.key); + ActionChain.run( + () => runActions(group.value), + context: context, + ).whenComplete(() => _runningIntervals.remove(group.key)); + }), + ); + } + } + + void stopIntervals() { + for (final Timer timer in _intervalTimers) { + timer.cancel(); + } + _intervalTimers.clear(); + _runningIntervals.clear(); + } + + /// Runs [actions] one after another, waiting on each blocking action. + Future runActions(List actions) async { + for (final ActionModel action in actions) { + // contextFor checks that the context it returns is mounted. + // ignore: use_build_context_synchronously + final BuildContext? actionContext = ActionChain.contextFor(context); + if (actionContext == null) return; + // ignore: use_build_context_synchronously + final result = FunctionsRepository.performAction(actionContext, action); + if (result is Future && !action.nonBlocking) { + try { + await result; + } catch (e, stackTrace) { + ErrorLogger.instance.captureException( + e, + message: 'Interval action failed', + type: 'interval_action_failed', + stackTrace: stackTrace, + ); + return; + } + } + } + } + @override void dispose() { + stopIntervals(); codelessly?.removeNavigationListener('canvas-${widget.node.id}'); _gateUser?.removeListener(_enforceSignIn); _stopWatchingValues(); @@ -758,6 +832,9 @@ class _PassiveCanvasWidgetState extends State { super.didUpdateWidget(oldWidget); if (widget.node != oldWidget.node) { onLoadActions = collectOnLoadActions(); + if (didPerformOnLoadActions && !widget.settings.isPreview) { + startIntervals(); + } } } @@ -779,6 +856,7 @@ class _PassiveCanvasWidgetState extends State { triggerOnLoadActions(context); _watchValues(context); + startIntervals(); } List collectOnLoadActions() => widget.node.reactions diff --git a/lib/src/transformers/node_transformers/passive_gauge_transformer.dart b/lib/src/transformers/node_transformers/passive_gauge_transformer.dart new file mode 100644 index 00000000..f4ebde5d --- /dev/null +++ b/lib/src/transformers/node_transformers/passive_gauge_transformer.dart @@ -0,0 +1,404 @@ +import 'dart:math' as math; + +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:flutter/material.dart'; + +import '../../../codelessly_sdk.dart'; + +class PassiveGaugeTransformer extends NodeWidgetTransformer { + PassiveGaugeTransformer(super.getNode, super.manager); + + @override + Widget buildWidget( + GaugeNode node, + BuildContext context, + WidgetBuildSettings settings, + ) { + return PassiveGaugeWidget(node: node, settings: settings); + } +} + +/// Renders a [GaugeNode], reading its bindable `value`, `limit`, `color` +/// and `limitColor` from conditions and variables. +class PassiveGaugeWidget extends StatelessWidget { + final GaugeNode node; + final WidgetBuildSettings settings; + final List variablesOverrides; + + /// The values bindings read, when the caller has them, e.g. the editor's + /// variable defaults on the design canvas. Defaults to the context's. + final ScopedValues? scopedValues; + + /// Overrides [GaugeProperties.animate], e.g. to hold still in the editor. + final bool? animate; + + const PassiveGaugeWidget({ + super.key, + required this.node, + required this.settings, + this.variablesOverrides = const [], + this.scopedValues, + this.animate, + }); + + @override + Widget build(BuildContext context) { + final ScopedValues scopedValues = + this.scopedValues ?? + ScopedValues.of(context, variablesOverrides: variablesOverrides); + final GaugeProperties props = node.properties; + final double value = + _number( + PropertyValueDelegate.getPropertyValue( + node, + 'value', + scopedValues: scopedValues, + ), + ) ?? + node.value; + final double? limit = + _number( + PropertyValueDelegate.getPropertyValue( + node, + 'limit', + scopedValues: scopedValues, + ), + ) ?? + (node.variables['limit'] == null ? node.limit : null); + final Color color = + PropertyValueDelegate.getPropertyValue( + node, + 'color', + scopedValues: scopedValues, + )?.toFlutterColor() ?? + props.color.toFlutterColor(); + final Color limitColor = + PropertyValueDelegate.getPropertyValue( + node, + 'limitColor', + scopedValues: scopedValues, + )?.toFlutterColor() ?? + props.limitColor.toFlutterColor(); + + final double range = props.max - props.min; + double fraction(double v) => + range == 0 ? 0 : ((v - props.min) / range).clamp(0.0, 1.0); + + final bool animated = animate ?? props.animate; + return AdaptiveNodeBox( + node: node, + child: TweenAnimationBuilder( + tween: Tween(end: fraction(value)), + duration: animated + ? Duration(milliseconds: props.animationDurationInMillis) + : Duration.zero, + curve: Curves.easeOutCubic, + builder: (context, v, _) => CustomPaint( + size: Size.infinite, + painter: GaugePainter( + props: props, + fraction: v, + limit: limit == null ? null : fraction(limit), + color: color, + limitColor: limitColor, + ), + ), + ), + ); + } +} + +/// Reads a number from a bound value: a number, or text that parses as one. +double? _number(Object? value) => switch (value) { + num() => value.toDouble(), + String() => double.tryParse(value.trim()), + _ => null, +}; + +/// Paints a [GaugeProperties] gauge filled to [fraction] (0 to 1), with an +/// optional limit marker at [limit] (0 to 1). +class GaugePainter extends CustomPainter { + final GaugeProperties props; + final double fraction; + final double? limit; + final Color color; + final Color limitColor; + + GaugePainter({ + required this.props, + required this.fraction, + required this.limit, + required this.color, + required this.limitColor, + }); + + static double _rad(double degrees) => degrees * math.pi / 180; + + @override + void paint(Canvas canvas, Size size) { + if (size.isEmpty) return; + switch (props.shape) { + case GaugeShape.arc: + _paintArc(canvas, size); + case GaugeShape.bar: + _paintBar(canvas, size); + } + } + + void _paintArc(Canvas canvas, Size size) { + final double d = math.min(size.width, size.height * 2); + final double stroke = props.thickness > 0 ? props.thickness : d * 0.075; + final Offset center = Offset(size.width / 2, d / 2); + final double r = d / 2 - stroke / 2 - d * 0.04; + final Rect rect = Rect.fromCircle(center: center, radius: r); + final double start = _rad(props.startAngle); + final double sweep = _rad(props.sweepAngle); + final Color track = props.trackColor.toFlutterColor(); + + if (props.segments > 0) { + final double seg = sweep / props.segments; + final double gap = _rad(props.segmentGap); + final int lit = (fraction * props.segments).round(); + for (int i = 0; i < props.segments; i++) { + canvas.drawArc( + rect, + start + i * seg + gap / 2, + math.max(0, seg - gap), + false, + Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = stroke + ..color = i < lit ? color : track, + ); + } + } else { + final Paint paint = Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = stroke; + canvas.drawArc(rect, start, sweep, false, paint..color = track); + if (fraction > 0) { + canvas.drawArc( + rect, + start, + sweep * fraction, + false, + paint..color = color, + ); + } + } + + if (props.ticks > 0) { + final Paint tick = Paint()..color = props.tickColor.toFlutterColor(); + for (int i = 0; i <= props.ticks; i++) { + final double a = start + sweep * i / props.ticks; + final bool major = + props.majorTickEvery > 0 && i % props.majorTickEvery == 0; + final double r1 = r - stroke / 2 - d * 0.03; + final double r2 = r1 - (major ? d * 0.05 : d * 0.025); + final Offset dir = Offset(math.cos(a), math.sin(a)); + tick.strokeWidth = major ? 2 : 1; + canvas.drawLine(center + dir * r1, center + dir * r2, tick); + } + } + + if (limit != null) { + final double a = start + sweep * limit!; + final Offset dir = Offset(math.cos(a), math.sin(a)); + final double outer = r + stroke / 2 + d * 0.035; + final double inner = r - stroke / 2 - d * 0.005; + canvas.drawLine( + center + dir * inner, + center + dir * outer, + Paint() + ..color = limitColor + ..strokeWidth = props.limitWidth + ..strokeCap = StrokeCap.square, + ); + } + } + + void _paintBar(Canvas canvas, Size size) { + final bool vertical = size.height > size.width; + final Radius radius = Radius.circular(props.cornerRadius); + final RRect well = RRect.fromRectAndRadius(Offset.zero & size, radius); + final Color background = props.backgroundColor.toFlutterColor(); + if (background.a > 0) canvas.drawRRect(well, Paint()..color = background); + + final double inset = props.inset; + final Rect inner = Rect.fromLTRB( + inset, + inset, + size.width - inset, + size.height - inset, + ); + final double length = vertical ? inner.height : inner.width; + final Color track = props.trackColor.toFlutterColor(); + + // The part of [inner] between fractions [from] and [to] along the bar. + Rect span(double from, double to) => vertical + ? Rect.fromLTRB( + inner.left, + inner.bottom - length * to, + inner.right, + inner.bottom - length * from, + ) + : Rect.fromLTRB( + inner.left + length * from, + inner.top, + inner.left + length * to, + inner.bottom, + ); + + Paint fillPaint(Rect rect) { + final Paint paint = Paint()..color = color; + if (props.fillStartOpacity < 1) { + final Color faded = color.withValues( + alpha: color.a * props.fillStartOpacity, + ); + paint.shader = LinearGradient( + begin: vertical ? Alignment.topCenter : Alignment.centerRight, + end: vertical ? Alignment.bottomCenter : Alignment.centerLeft, + colors: [color, faded], + ).createShader(rect); + } + return paint; + } + + if (props.segments > 0) { + final double gap = props.segmentGap; + final double seg = (length - gap * (props.segments - 1)) / props.segments; + final int lit = (fraction * props.segments).round(); + final Radius segRadius = Radius.circular( + math.min(props.cornerRadius, seg / 2), + ); + for (int i = 0; i < props.segments; i++) { + final double from = (i * (seg + gap)) / length; + final double to = (i * (seg + gap) + seg) / length; + final Rect rect = span(from, to); + canvas.drawRRect( + RRect.fromRectAndRadius(rect, segRadius), + Paint()..color = i < lit ? color : track, + ); + } + } else { + if (track.a > 0) { + canvas.drawRRect( + RRect.fromRectAndRadius(inner, radius), + Paint()..color = track, + ); + } + if (fraction > 0) { + final Rect fill = span(0, fraction); + canvas.drawRect(fill, fillPaint(fill)); + if (props.edgeWidth > 0) { + final Rect edge = vertical + ? Rect.fromLTWH(fill.left, fill.top, fill.width, props.edgeWidth) + : Rect.fromLTWH( + fill.right - props.edgeWidth, + fill.top, + props.edgeWidth, + fill.height, + ); + canvas.drawRect( + edge, + Paint()..color = props.edgeColor.toFlutterColor(), + ); + } + } + } + + // Graduations: full width at major ticks, 35% in from each side at minor. + if (props.ticks > 1) { + final Paint tick = Paint() + ..color = props.tickColor.toFlutterColor() + ..strokeWidth = 1; + final double across = vertical ? inner.width : inner.height; + for (int i = 1; i < props.ticks; i++) { + final bool major = + props.majorTickEvery > 0 && i % props.majorTickEvery == 0; + final double w = major ? across : across * 0.35; + final double p = length * i / props.ticks; + if (vertical) { + final double y = inner.bottom - p; + canvas.drawLine( + Offset(inner.left, y), + Offset(inner.left + w, y), + tick, + ); + canvas.drawLine( + Offset(inner.right - w, y), + Offset(inner.right, y), + tick, + ); + } else { + final double x = inner.left + p; + canvas.drawLine(Offset(x, inner.top), Offset(x, inner.top + w), tick); + canvas.drawLine( + Offset(x, inner.bottom - w), + Offset(x, inner.bottom), + tick, + ); + } + } + } + + if (limit != null) { + final Paint paint = Paint() + ..color = limitColor + ..strokeWidth = props.limitWidth; + final double p = length * limit!; + final Offset a = vertical + ? Offset(inner.left, inner.bottom - p) + : Offset(inner.left + p, inner.top); + final Offset b = vertical + ? Offset(inner.right, inner.bottom - p) + : Offset(inner.left + p, inner.bottom); + if (props.limitStyle == GaugeLimitStyle.dashed) { + const double dash = 5; + final double total = (b - a).distance; + final Offset dir = (b - a) / total; + for (double t = 0; t < total; t += dash * 2) { + canvas.drawLine( + a + dir * t, + a + dir * math.min(t + dash, total), + paint, + ); + } + } else { + canvas.drawLine(a, b, paint); + } + if (props.limitPointer) { + final Path tri = vertical + ? (Path() + ..moveTo(0, a.dy - 5) + ..lineTo(5, a.dy) + ..lineTo(0, a.dy + 5) + ..close()) + : (Path() + ..moveTo(a.dx - 5, size.height) + ..lineTo(a.dx, size.height - 5) + ..lineTo(a.dx + 5, size.height) + ..close()); + canvas.drawPath(tri, Paint()..color = limitColor); + } + } + + final Color border = props.borderColor.toFlutterColor(); + if (border.a > 0) { + canvas.drawRRect( + well.deflate(0.5), + Paint() + ..style = PaintingStyle.stroke + ..color = border, + ); + } + } + + @override + bool shouldRepaint(GaugePainter old) => + old.fraction != fraction || + old.limit != limit || + old.color != color || + old.limitColor != limitColor || + old.props != props; +} diff --git a/lib/src/transformers/node_transformers/passive_icon_transformer.dart b/lib/src/transformers/node_transformers/passive_icon_transformer.dart index e1fddacf..69983fe4 100644 --- a/lib/src/transformers/node_transformers/passive_icon_transformer.dart +++ b/lib/src/transformers/node_transformers/passive_icon_transformer.dart @@ -35,15 +35,25 @@ class PassiveIconWidget extends StatelessWidget { @override Widget build(BuildContext context) { + final ScopedValues scopedValues = ScopedValues.of(context); final ColorRGBA? iconColor = PropertyValueDelegate.getPropertyValue( node, 'iconColor', - scopedValues: ScopedValues.of(context), + scopedValues: scopedValues, ); - final MultiSourceIconModel icon = iconColor == null - ? node.properties.icon - : node.properties.icon.copyWith(color: iconColor); + // An `icon` binding names the icon to show, e.g. a list item's. + MultiSourceIconModel icon = iconWithName( + node.properties.icon, + node.variables['icon'] == null + ? null + : PropertyValueDelegate.getPropertyValue( + node, + 'icon', + scopedValues: scopedValues, + ), + ); + if (iconColor != null) icon = icon.copyWith(color: iconColor); return retrieveIconWidget(icon, node.basicBoxLocal.width) ?? SizedBox( width: node.basicBoxLocal.width, 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..6d8df7e7 100644 --- a/lib/src/transformers/node_transformers/passive_row_column_transformer.dart +++ b/lib/src/transformers/node_transformers/passive_row_column_transformer.dart @@ -312,10 +312,19 @@ class PassiveRowColumnTransformer extends NodeWidgetTransformer { // A parent that stretches its children across gives each one the full // cross axis, like the editor's solver, except a child that hugs on that // axis: the solver keeps its own size there and places it at the start. + // In a parent that hugs the cross axis too (measured with an + // IntrinsicHeight/Width), the solver stretches hugging children to the + // thickest one, so cards in a row get equal heights. + final bool parentHugsCross = + (mainAxis == AxisC.horizontal + ? parentRowColumn.verticalFit + : parentRowColumn.horizontalFit) == + SizeFit.shrinkWrap; final AlignmentGeometry? childAlignment = (alignment ?? node.alignment).data?.flutterAlignmentGeometry ?? (parentRowColumn.crossAxisAlignment == CrossAxisAlignmentC.stretch && - crossAxisFit == SizeFit.shrinkWrap + crossAxisFit == SizeFit.shrinkWrap && + !parentHugsCross ? AlignmentDirectional.topStart : null); final bool hasAlignment = childAlignment != null; diff --git a/lib/src/transformers/node_transformers/passive_slider_transformer.dart b/lib/src/transformers/node_transformers/passive_slider_transformer.dart index a7ecc97f..93052bee 100644 --- a/lib/src/transformers/node_transformers/passive_slider_transformer.dart +++ b/lib/src/transformers/node_transformers/passive_slider_transformer.dart @@ -81,6 +81,15 @@ class PassiveSliderWidget extends StatelessWidget { ); } + void onChangeEnd(BuildContext context, double internalValue) { + FunctionsRepository.triggerAction( + context, + node: node, + TriggerType.changeEnd, + value: internalValue, + ); + } + @override Widget build(BuildContext context) { final ScopedValues scopedValues = ScopedValues.of( @@ -94,13 +103,31 @@ class PassiveSliderWidget extends StatelessWidget { scopedValues: scopedValues, ) ?? node.value; + final Object? marker = PropertyValueDelegate.getPropertyValue( + node, + 'markerValue', + scopedValues: scopedValues, + ); + final ColorRGBA? activeTrackColor = + PropertyValueDelegate.getPropertyValue( + node, + 'activeTrackColor', + scopedValues: scopedValues, + ); return TransformerSlider( node: node, settings: settings, variablesOverrides: variablesOverrides, onChanged: (value) => onChanged(context, value), + onChangeEnd: (value) => onChangeEnd(context, value), value: value, + markerValue: switch (marker) { + num() => marker.toDouble(), + String() => double.tryParse(marker), + _ => node.variables['markerValue'] == null ? node.markerValue : null, + }, + activeTrackColor: activeTrackColor?.toFlutterColor(), ); } } @@ -112,6 +139,9 @@ class TransformerSlider extends StatelessWidget { required this.settings, required this.onChanged, required this.value, + this.onChangeEnd, + this.markerValue, + this.activeTrackColor, this.variablesOverrides = const [], }); @@ -119,16 +149,62 @@ class TransformerSlider extends StatelessWidget { final WidgetBuildSettings settings; final List variablesOverrides; final ValueChanged? onChanged; + final ValueChanged? onChangeEnd; final double value; + /// Where to draw [SliderProperties.markerColor]'s marker, if anywhere. + final double? markerValue; + + /// Overrides [SliderProperties.activeTrackColor] (from a binding). + final Color? activeTrackColor; + + /// [v] kept within the selectable part of the track. + double _selectable(double v) { + final SliderProperties p = node.properties; + final double lo = (p.selectableMin ?? p.min).clamp(p.min, p.max); + final double hi = (p.selectableMax ?? p.max).clamp(lo, p.max); + return v.clamp(lo, hi); + } + @override Widget build(BuildContext context) { - final SliderTrackShape trackShape = switch (node.properties.trackShape) { + final SliderProperties p = node.properties; + final double range = p.max - p.min; + final double? markerFraction = markerValue == null || range == 0 + ? null + : ((markerValue! - p.min) / range).clamp(0.0, 1.0); + final SliderTrackShape baseTrackShape = switch (p.trackShape) { SliderTrackShapeEnum.rectangle => const RectangularSliderTrackShape(), SliderTrackShapeEnum.roundedRectangle => const RoundedRectSliderTrackShape(), SliderTrackShapeEnum.gapped => const GappedSliderTrackShape(), }; + // Filling from the end or drawing a marker needs our own track painter; + // otherwise the Material shapes stay as they were. + final SliderTrackShape trackShape = p.fillFromEnd || markerFraction != null + ? IndustrialSliderTrackShape( + fillFromEnd: p.fillFromEnd, + markerFraction: markerFraction, + markerColor: p.markerColor.toFlutterColor(), + markerWidth: p.markerWidth, + markerHeight: p.markerHeight, + radius: p.trackShape == SliderTrackShapeEnum.rectangle + ? 0 + : (p.trackHeight / 2), + ) + : baseTrackShape; + final SliderComponentShape thumbShape = !p.showThumb + ? const RoundSliderThumbShape(enabledThumbRadius: 0) + : p.thumbShape == SliderThumbShapeEnum.rectangle + ? RectSliderThumbShape( + width: p.thumbWidth, + height: p.thumbHeight, + radius: p.thumbCornerRadius, + borderColor: p.thumbBorderColor.toFlutterColor(), + borderWidth: p.thumbBorderWidth, + gripColor: p.thumbGripColor.toFlutterColor(), + ) + : RoundSliderThumbShape(enabledThumbRadius: p.thumbRadius); final SliderComponentShape valueIndicatorShape = switch (node .properties @@ -167,26 +243,30 @@ class TransformerSlider extends StatelessWidget { overlayColor: node.properties.showThumb ? node.properties.overlayColor.toFlutterColor() : Colors.transparent, - activeTrackColor: node.properties.activeTrackColor.toFlutterColor(), + activeTrackColor: + activeTrackColor ?? + node.properties.activeTrackColor.toFlutterColor(), inactiveTrackColor: node.properties.inactiveTrackColor .toFlutterColor(), thumbColor: node.properties.thumbColor.toFlutterColor(), - thumbShape: RoundSliderThumbShape( - enabledThumbRadius: node.properties.showThumb - ? node.properties.thumbRadius - : 0, - ), + thumbShape: thumbShape, trackShape: trackShape, // GappedSliderTrackShape requires a gap; 6 is the Material 3 // (2024) default. trackGap: node.properties.trackShape == SliderTrackShapeEnum.gapped ? 6 : null, - tickMarkShape: node.properties.isDiscrete - ? RoundSliderTickMarkShape( - tickMarkRadius: node.properties.tickMarkRadius, + tickMarkShape: !node.properties.isDiscrete + ? null + : node.properties.tickMarkStyle == SliderTickMarkStyle.notch + ? NotchSliderTickMarkShape( + divisions: node.properties.divisions ?? 0, + every: node.properties.notchEvery, + color: node.properties.inactiveTickMarkColor.toFlutterColor(), ) - : null, + : RoundSliderTickMarkShape( + tickMarkRadius: node.properties.tickMarkRadius, + ), showValueIndicator: showValueIndicator, secondaryActiveTrackColor: node.properties.secondaryActiveTrackColor ?.toFlutterColor(), @@ -208,8 +288,18 @@ class TransformerSlider extends StatelessWidget { ), ), child: Slider( - value: value.clamp(node.properties.min, node.properties.max), - onChanged: onChanged, + value: _selectable( + value.clamp(node.properties.min, node.properties.max), + ), + onChanged: onChanged == null + ? null + : (v) { + final double kept = _selectable(v); + if (kept != value) onChanged!(kept); + }, + onChangeEnd: onChangeEnd == null + ? null + : (v) => onChangeEnd!(_selectable(v)), autofocus: node.properties.autofocus, min: node.properties.min, max: node.properties.max, @@ -227,3 +317,182 @@ class TransformerSlider extends StatelessWidget { ); } } + +/// A slider track that can fill from the thumb to the end and draw a thin +/// marker at [markerFraction] of its length. +class IndustrialSliderTrackShape extends SliderTrackShape + with BaseSliderTrackShape { + const IndustrialSliderTrackShape({ + required this.fillFromEnd, + required this.markerFraction, + required this.markerColor, + required this.markerWidth, + required this.markerHeight, + required this.radius, + }); + + final bool fillFromEnd; + final double? markerFraction; + final Color markerColor; + final double markerWidth; + final double markerHeight; + final double radius; + + @override + void paint( + PaintingContext context, + Offset offset, { + required RenderBox parentBox, + required SliderThemeData sliderTheme, + required Animation enableAnimation, + required TextDirection textDirection, + required Offset thumbCenter, + Offset? secondaryOffset, + bool isDiscrete = false, + bool isEnabled = false, + }) { + final Rect track = getPreferredRect( + parentBox: parentBox, + offset: offset, + sliderTheme: sliderTheme, + isEnabled: isEnabled, + isDiscrete: isDiscrete, + ); + final Canvas canvas = context.canvas; + final Radius r = Radius.circular(radius); + canvas.drawRRect( + RRect.fromRectAndRadius(track, r), + Paint()..color = sliderTheme.inactiveTrackColor ?? Colors.grey, + ); + final Rect active = fillFromEnd + ? Rect.fromLTRB(thumbCenter.dx, track.top, track.right, track.bottom) + : Rect.fromLTRB(track.left, track.top, thumbCenter.dx, track.bottom); + if (active.width > 0) { + canvas.drawRRect( + RRect.fromRectAndRadius(active, r), + Paint()..color = sliderTheme.activeTrackColor ?? Colors.black, + ); + } + if (markerFraction != null) { + final double x = track.left + track.width * markerFraction!; + canvas.drawRect( + Rect.fromCenter( + center: Offset(x, track.center.dy), + width: markerWidth, + height: markerHeight, + ), + Paint()..color = markerColor, + ); + } + } +} + +/// A rounded-rectangle thumb, like a fader knob, with an optional border +/// and a short grip line across it. +class RectSliderThumbShape extends SliderComponentShape { + const RectSliderThumbShape({ + required this.width, + required this.height, + required this.radius, + required this.borderColor, + required this.borderWidth, + required this.gripColor, + }); + + final double width; + final double height; + final double radius; + final Color borderColor; + final double borderWidth; + final Color gripColor; + + @override + Size getPreferredSize(bool isEnabled, bool isDiscrete) => Size(width, height); + + @override + void paint( + PaintingContext context, + Offset center, { + required Animation activationAnimation, + required Animation enableAnimation, + required bool isDiscrete, + required TextPainter labelPainter, + required RenderBox parentBox, + required SliderThemeData sliderTheme, + required TextDirection textDirection, + required double value, + required double textScaleFactor, + required Size sizeWithOverflow, + }) { + final Canvas canvas = context.canvas; + final RRect body = RRect.fromRectAndRadius( + Rect.fromCenter(center: center, width: width, height: height), + Radius.circular(radius), + ); + canvas.drawRRect(body, Paint()..color = sliderTheme.thumbColor!); + if (borderColor.a > 0 && borderWidth > 0) { + canvas.drawRRect( + body.deflate(borderWidth / 2), + Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = borderWidth + ..color = borderColor, + ); + } + if (gripColor.a > 0) { + canvas.drawRect( + Rect.fromCenter(center: center, width: width * 0.43, height: 2), + Paint()..color = gripColor, + ); + } + } +} + +/// Division marks as short lines under the track; every fifth is longer. +class NotchSliderTickMarkShape extends SliderTickMarkShape { + const NotchSliderTickMarkShape({ + required this.divisions, + required this.color, + this.every = 1, + }); + + final int divisions; + final Color color; + + /// Only every this many divisions gets a notch. + final int every; + + @override + Size getPreferredSize({ + required SliderThemeData sliderTheme, + bool isEnabled = false, + }) => const Size(1, 1); + + @override + void paint( + PaintingContext context, + Offset center, { + required RenderBox parentBox, + required SliderThemeData sliderTheme, + required Animation enableAnimation, + required TextDirection textDirection, + required Offset thumbCenter, + bool isEnabled = false, + }) { + final Rect track = + (sliderTheme.trackShape ?? const RectangularSliderTrackShape()) + .getPreferredRect(parentBox: parentBox, sliderTheme: sliderTheme); + final double fraction = track.width == 0 + ? 0 + : ((center.dx - track.left) / track.width).clamp(0.0, 1.0); + final int index = (fraction * divisions).round(); + final int step = every < 1 ? 1 : every; + if (index % step != 0) return; + final bool major = (index ~/ step) % 5 == 0; + final double top = center.dy + (sliderTheme.trackHeight ?? 4) / 2 + 3; + context.canvas.drawRect( + Rect.fromLTWH(center.dx - 0.5, top, 1, major ? 8 : 5), + Paint()..color = color, + ); + } +} 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 7e6f2d71..afebb749 100644 --- a/lib/src/transformers/node_transformers/passive_text_field_transformer.dart +++ b/lib/src/transformers/node_transformers/passive_text_field_transformer.dart @@ -139,6 +139,10 @@ class PassiveTextFieldWidget extends StatefulWidget { final FormFieldValidator? validator; final bool? autofocus; + /// The values the field's bound and initial text read, in place of the + /// running app's: an editor's design canvas passes its variable defaults. + final ScopedValues? scopedValues; + PassiveTextFieldWidget({ super.key, required this.node, @@ -152,6 +156,7 @@ class PassiveTextFieldWidget extends StatefulWidget { this.autovalidateMode, this.validator, this.autofocus, + this.scopedValues, }) : variablesOverrides = variables ?? []; @override @@ -171,7 +176,7 @@ class _PassiveTextFieldWidgetState extends State { return PropertyValueDelegate.getPropertyValue( widget.node, 'inputValue', - scopedValues: ScopedValues.of(context), + scopedValues: widget.scopedValues ?? ScopedValues.of(context), ); } @@ -181,7 +186,7 @@ class _PassiveTextFieldWidgetState extends State { return PropertyValueDelegate.substituteVariables( text, nullSubstitutionMode: widget.settings.nullSubstitutionMode, - scopedValues: ScopedValues.of(context), + scopedValues: widget.scopedValues ?? ScopedValues.of(context), ); } @@ -203,7 +208,8 @@ class _PassiveTextFieldWidgetState extends State { super.didUpdateWidget(oldWidget); // Update controller text if this text field is bound to a variable. - final ScopedValues scopedValues = ScopedValues.of(context); + final ScopedValues scopedValues = + widget.scopedValues ?? ScopedValues.of(context); // Get the value of bound variable and update the controller text if it's // different from the current controller text. diff --git a/lib/src/transformers/passive_transformer_manager.dart b/lib/src/transformers/passive_transformer_manager.dart index b977354e..4bffbc8f 100644 --- a/lib/src/transformers/passive_transformer_manager.dart +++ b/lib/src/transformers/passive_transformer_manager.dart @@ -53,6 +53,8 @@ class PassiveNodeTransformerManager extends WidgetNodeTransformerManager { 'loadingIndicator': PassiveLoadingIndicatorTransformer(getNode, this), 'dropdown': PassiveDropdownTransformer(getNode, this), 'progressBar': PassiveProgressBarTransformer(getNode, this), + 'gauge': PassiveGaugeTransformer(getNode, this), + 'barChart': PassiveBarChartTransformer(getNode, this), 'variance': PassiveVarianceTransformer(getNode, this), 'webView': PassiveWebViewTransformer(getNode, this), 'listView': PassiveListViewTransformer(getNode, this), @@ -175,9 +177,9 @@ class PassiveNodeTransformerManager extends WidgetNodeTransformerManager { /// Returns [node] with the style properties that conditions, bound /// variables or formulas, or set value actions give it at runtime: `fill` - /// (a text's color for text nodes), `stroke`, `opacity`, `width`, - /// `height`, `flex` (its share of an expanded row or column, so bars - /// can be sized proportionally) and `rotation` (in degrees). Returns + /// (a text's color for text nodes), `stroke`, `cornerRadius`, `opacity`, + /// `width`, `height`, `flex` (its share of an expanded row or column, so + /// bars can be sized proportionally) and `rotation` (in degrees). Returns /// [node] itself when nothing changes it. BaseNode applyStyleOverrides(BuildContext context, BaseNode node) { final ScopedValues scopedValues = ScopedValues.of(context); @@ -221,6 +223,15 @@ class PassiveNodeTransformerManager extends WidgetNodeTransformerManager { ]; } } + if (node is CornerMixin) { + // A number, or [top-left, top-right, bottom-right, bottom-left], e.g. + // a dialog squared at the bottom as a mobile sheet: + // `${= screen.isMobile ? [6, 6, 0, 0] : 6}`. + final CornerRadius? radius = value('cornerRadius'); + if (radius != null && radius != node.cornerRadius) { + patch['cornerRadius'] = radius.toJson(); + } + } if (node is BlendMixin) { final double? opacity = value('opacity'); if (opacity != null && opacity != node.opacity) { @@ -332,7 +343,6 @@ class PassiveNodeTransformerManager extends WidgetNodeTransformerManager { // List of value notifiers that the widget listens to. This includes node's // local values and the variables attached to node properties. final List listenables = []; - // Local node values. final ValueNotifier>? listenableNodeValues = codelesslyContext.nodeValues[node.id]; @@ -478,9 +488,10 @@ class PassiveNodeTransformerManager extends WidgetNodeTransformerManager { .findVariableByName(name); if (variable == null) return; if (!listenables.contains(variable)) listenables.add(variable); - final String? expression = FormulaEvaluator.exactExpression( - variable.value.value, - ); + // A computed variable listens to what its formula reads. + final String? expression = variable.value.isComputed + ? variable.value.formula + : FormulaEvaluator.exactExpression(variable.value.value); if (expression == null) return; for (final String path in FormulaEvaluator.referencedPaths(expression)) { final VariableMatch? match = VariableMatch.parse( diff --git a/lib/src/transformers/transformer_functions.dart b/lib/src/transformers/transformer_functions.dart index 5a4302eb..4a74a4a3 100644 --- a/lib/src/transformers/transformer_functions.dart +++ b/lib/src/transformers/transformer_functions.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; import '../utils/extensions.dart'; +import '../utils/material_icon_versions.dart'; import 'transformers.dart'; /// The colour a button of [type] has when its buttonColor is unset (null): @@ -351,6 +352,40 @@ String? getSliderLabel(SliderNode node, double value) { return node.properties.label.replaceAll('\${value}', valueString); } +/// [base] showing the Material icon named [name] instead, for an icon bound +/// to data. [name] is a Flutter `Icons` name such as `kitchen` or +/// `kitchen_outlined`: an `_outlined`, `_rounded` or `_sharp` suffix picks +/// the style. The icon is drawn from its SVG by name, so no codepoint is +/// needed. Returns [base] for an empty or malformed name. +MultiSourceIconModel iconWithName(MultiSourceIconModel base, String? name) { + final String key = (name ?? '').trim().toLowerCase(); + if (!RegExp(r'^[a-z0-9_]+$').hasMatch(key)) return base; + MaterialIconStyle style = MaterialIconStyle.filled; + String iconName = key; + for (final (String suffix, MaterialIconStyle suffixStyle) in const [ + ('_outlined', MaterialIconStyle.outlined), + ('_rounded', MaterialIconStyle.rounded), + ('_sharp', MaterialIconStyle.sharp), + ]) { + if (key.endsWith(suffix)) { + style = suffixStyle; + iconName = key.substring(0, key.length - suffix.length); + break; + } + } + return base.copyWith( + type: IconTypeEnum.icon, + // The icon's SVG is served from the version the icon was added in. + icon: MaterialIcon( + codepoint: 0, + name: iconName, + style: style, + version: materialIconVersions[iconName] ?? 1, + ), + forceIcon: true, + ); +} + Widget? retrieveIconWidget( MultiSourceIconModel icon, [ double? size, diff --git a/lib/src/transformers/utils/property_value_delegate.dart b/lib/src/transformers/utils/property_value_delegate.dart index d955a280..1d25f142 100644 --- a/lib/src/transformers/utils/property_value_delegate.dart +++ b/lib/src/transformers/utils/property_value_delegate.dart @@ -254,33 +254,83 @@ class PropertyValueDelegate { ); } - /// Evaluates a formula [expression] (the part after `${=`), reading its - /// variable paths from [scopedValues]. See [FormulaEvaluator]. - /// Whether [variable] is computed: its value is a `${= ...}` formula, + /// Whether [variable] is computed: it has a formula + /// ([VariableData.isComputed]) or its value is a `${= ...}` formula, /// worked out from other variables whenever it is read. static bool isComputedVariable(VariableData variable) => + variable.isComputed || FormulaEvaluator.exactExpression(variable.value) != null; static int _computedDepth = 0; + /// Names of the computed variables being evaluated, so a formula that + /// (indirectly) reads its own variable gets null instead of looping. + static final Set _computing = {}; + /// The value of [variable]: its formula's result for a computed variable, /// else its stored value. Computed variables can read each other; a cycle /// gives null instead of recursing forever. + /// + /// A [VariableData.formula] result is coerced to the variable's type; a + /// `${= ...}` value keeps whatever its formula returns. static Object? variableValueOf( VariableData variable, ScopedValues scopedValues, ) { - final String? expression = FormulaEvaluator.exactExpression(variable.value); + final bool typed = variable.isComputed; + final String? expression = typed + ? variable.formula + : FormulaEvaluator.exactExpression(variable.value); if (expression == null) return variable.getValue(); - if (_computedDepth >= 16) return null; + if (_computedDepth >= 16 || !_computing.add(variable.name)) return null; _computedDepth++; try { - return evaluateFormula(expression, scopedValues: scopedValues); + final Object? value = evaluateFormula( + expression, + scopedValues: scopedValues, + ); + return typed ? _asType(value, variable.type) : value; } finally { _computedDepth--; + _computing.remove(variable.name); } } + /// [variableValueOf] with a named [scopedValues]. + static Object? variableValue( + VariableData variable, { + required ScopedValues scopedValues, + }) => variableValueOf(variable, scopedValues); + + static Object? _asType(Object? value, VariableType type) => switch (type) { + // A choice holds the id of one of its options. + VariableType.text || + VariableType.choice => FormulaEvaluator.stringify(value), + VariableType.integer => + value is num ? value.round() : num.tryParse('${value ?? ''}')?.round(), + VariableType.decimal => + value is num + ? value.toDouble() + : num.tryParse('${value ?? ''}')?.toDouble(), + VariableType.boolean => + value is bool + ? value + : value is String + ? bool.tryParse(value, caseSensitive: false) + : value != null && value != 0, + VariableType.list => value is List ? value : null, + VariableType.map => value is Map ? value : null, + VariableType.color => + value is ColorRGBA + ? value + : value is String + ? ColorRGBA.fromHex(value) + : null, + VariableType.image => value, + }; + + /// Evaluates a formula [expression] (the part after `${=`), reading its + /// variable paths from [scopedValues]. See [FormulaEvaluator]. static Object? evaluateFormula( String expression, { required ScopedValues scopedValues, diff --git a/lib/src/transformers/utils/scoped_values.dart b/lib/src/transformers/utils/scoped_values.dart index 25d85b63..d3aaf962 100644 --- a/lib/src/transformers/utils/scoped_values.dart +++ b/lib/src/transformers/utils/scoped_values.dart @@ -82,6 +82,8 @@ class ScopedValues { /// Creates scoped values for a design canvas, where [variables] (by name) /// hold their default values and nothing is read from a running app. /// [codelesslyContext] supplies conditions; the caller keeps it alive. + /// [routeParams] are sample params for `${route.…}`, such as the ones a + /// dialog canvas is designed with. ScopedValues.preview({ required Map variables, CodelesslyContext? codelesslyContext, @@ -89,6 +91,7 @@ class ScopedValues { this._appUser, this.screenSize, Map? data, + Map? routeParams, }) : _codelesslyContextRef = codelesslyContext == null ? null : WeakReference(codelesslyContext), @@ -97,7 +100,7 @@ class ScopedValues { _dataOverrides = data, _previewVariables = variables, nodeState = null, - routeParams = {}; + routeParams = routeParams ?? {}; /// Creates a new scoped values instance from a given context. ScopedValues.of( diff --git a/lib/src/transformers/utils/text_utils.dart b/lib/src/transformers/utils/text_utils.dart index 127c6811..d01b9b3c 100644 --- a/lib/src/transformers/utils/text_utils.dart +++ b/lib/src/transformers/utils/text_utils.dart @@ -217,8 +217,22 @@ class TextUtils { hasMissingFont: hasMissingFont, ); + // A `textColor` binding or condition recolors every span. + final Color? textColor = PropertyValueDelegate.getPropertyValue( + textNode, + 'textColor', + scopedValues: ScopedValues.of( + context, + variablesOverrides: variablesOverrides, + ), + )?.toFlutterColor(); + return Text.rich( - TextSpan(children: spans), + TextSpan( + children: textColor == null + ? spans + : [for (final span in spans) recolorSpan(span, textColor)], + ), textAlign: textNode.textAlignHorizontal.toFlutter(), maxLines: textNode.maxLines, overflow: textNode.overflow.flutterOverflow, @@ -279,6 +293,22 @@ class TextUtils { ); } + /// [span] and its children drawn in [color]. + static TextSpan recolorSpan(TextSpan span, Color color) => TextSpan( + text: span.text, + style: (span.style ?? const TextStyle()).copyWith(color: color), + recognizer: span.recognizer, + semanticsLabel: span.semanticsLabel, + mouseCursor: span.mouseCursor, + onEnter: span.onEnter, + onExit: span.onExit, + locale: span.locale, + spellOut: span.spellOut, + children: span.children + ?.map((child) => child is TextSpan ? recolorSpan(child, color) : child) + .toList(), + ); + static Color? retrievePropColor(TextProp prop) => prop.fills .firstWhereOrNull((fill) => fill.color != null) ?.toFlutterColor(); diff --git a/lib/src/transformers/widget_node_transformer_manager.dart b/lib/src/transformers/widget_node_transformer_manager.dart index 87c40bd2..75a95403 100644 --- a/lib/src/transformers/widget_node_transformer_manager.dart +++ b/lib/src/transformers/widget_node_transformer_manager.dart @@ -51,9 +51,18 @@ abstract class WidgetNodeTransformerManager /// Convenience method to handle widget opacity. Widget applyWidgetOpacity(BaseNode node, Widget widget) { - if (node is! BlendMixin || node.opacity > 0.99) { - return widget; + if (node is! BlendMixin) return widget; + // A node with a transition always has the animated wrapper, so a change + // of its opacity (to fully opaque too) tweens instead of jumping. + if (node.transition case final NodeTransition transition?) { + return AnimatedOpacity( + opacity: node.opacity.clamp(0.0, 1.0), + duration: transition.duration, + curve: transition.curve.flutterCurve, + child: widget, + ); } + if (node.opacity > 0.99) return widget; final double opacity = node.opacity < 0.99 ? node.opacity : 1; return Opacity(opacity: opacity, child: widget); diff --git a/lib/src/ui/codelessly_dialog_widget.dart b/lib/src/ui/codelessly_dialog_widget.dart index 86a735bc..71401093 100644 --- a/lib/src/ui/codelessly_dialog_widget.dart +++ b/lib/src/ui/codelessly_dialog_widget.dart @@ -52,3 +52,85 @@ class CodelesslyDialogWidget extends StatelessWidget { return Center(child: builder(context)); } } + +/// Pins a dialog canvas to the bottom of the screen at full width, its +/// height taken from the canvas (its design height, or its content when it +/// shrink-wraps), and no taller than 92% of the screen. +class CodelesslySheetPlacement extends StatelessWidget { + final Widget child; + + const CodelesslySheetPlacement({super.key, required this.child}); + + @override + Widget build(BuildContext context) { + final Size screen = MediaQuery.sizeOf(context); + return Align( + alignment: Alignment.bottomCenter, + child: ConstrainedBox( + constraints: BoxConstraints(maxHeight: screen.height * 0.92), + child: ClipRect( + child: SingleChildScrollView( + child: SizedBox( + width: screen.width, + child: Material(type: MaterialType.transparency, child: child), + ), + ), + ), + ), + ); + } +} + +/// Places a dialog canvas at its design size just below [anchor] (a global +/// rectangle), left-aligned with it, or above it when there is no room +/// below, and always fully on screen. +class CodelesslyAnchoredPlacement extends StatelessWidget { + final Rect anchor; + final double gap; + final Widget child; + + const CodelesslyAnchoredPlacement({ + super.key, + required this.anchor, + required this.gap, + required this.child, + }); + + @override + Widget build(BuildContext context) { + return CustomSingleChildLayout( + delegate: _AnchorDelegate(anchor, gap), + child: child, + ); + } +} + +class _AnchorDelegate extends SingleChildLayoutDelegate { + final Rect anchor; + final double gap; + + _AnchorDelegate(this.anchor, this.gap); + + static const double _margin = 8; + + @override + BoxConstraints getConstraintsForChild(BoxConstraints constraints) => + constraints.loosen().deflate(const EdgeInsets.all(_margin)); + + @override + Offset getPositionForChild(Size size, Size childSize) { + double left = anchor.left; + left = left.clamp(_margin, size.width - childSize.width - _margin); + double top = anchor.bottom + gap; + if (top + childSize.height > size.height - _margin && + anchor.top - gap - childSize.height >= _margin) { + top = anchor.top - gap - childSize.height; + } + top = top.clamp(_margin, size.height - childSize.height - _margin); + return Offset(left, top); + } + + @override + bool shouldRelayout(_AnchorDelegate old) => + old.anchor != anchor || old.gap != gap; +} diff --git a/lib/src/utils/extensions.dart b/lib/src/utils/extensions.dart index 8cc1c817..7a43377c 100644 --- a/lib/src/utils/extensions.dart +++ b/lib/src/utils/extensions.dart @@ -2322,3 +2322,14 @@ extension TextInputActionCExt on TextInputActionC { TextInputActionC.newline => TextInputAction.newline, }; } + +extension TransitionCurveExt on TransitionCurve { + /// The Flutter [Curve] for this easing. + Curve get flutterCurve => switch (this) { + TransitionCurve.linear => Curves.linear, + TransitionCurve.easeIn => Curves.easeIn, + TransitionCurve.easeOut => Curves.easeOut, + TransitionCurve.easeInOut => Curves.easeInOut, + TransitionCurve.easeOutCubic => Curves.easeOutCubic, + }; +} diff --git a/lib/src/utils/formula.dart b/lib/src/utils/formula.dart index 3a7f2582..2d595c01 100644 --- a/lib/src/utils/formula.dart +++ b/lib/src/utils/formula.dart @@ -27,8 +27,14 @@ typedef FormulaPathResolver = Object? Function(String path); /// `condition ? a : b`. `+` joins text when either side is text. /// - functions: money, fixed, round, floor, ceil, abs, min, max, sum, count, /// length, if, plural, join, upper, lower, trim, contains, startsWith, -/// endsWith, where, without, number, text, isEmpty and isNotEmpty. See +/// endsWith, where, without, number, text, isEmpty, isNotEmpty, clamp, +/// sin, cos, sqrt, pow, pad (`pad(5, 2)` is "05") and alpha +/// (`alpha('#FF4D4F', 0.12)` is "#1FFF4D4F"), take, skip and keys (a map's +/// keys as text). See /// [FormulaEvaluator.functions]. +/// - `object(key, value, ...)` builds a map from key/value pairs, since +/// braces can't appear in a formula: a list of records for a list view is +/// `[object('title', 'Fresh', 'level', fresh), ...]`. /// - list functions that take a predicate or key: filter, where, count, /// find, any, all, map, sortBy, unique, sum and join. The predicate is a /// lambda, `item => expression`, or a field name, optionally with a value @@ -79,6 +85,9 @@ class FormulaEvaluator { } } + /// Whether [expression] is a well-formed formula. + static bool parses(String expression) => _parse(expression) != null; + /// The variable paths [expression] reads, e.g. `storage.cart` and `qty`. static Set referencedPaths(String expression) { final _Node? node = _parse(expression); @@ -394,6 +403,17 @@ class FormulaEvaluator { 'thousands(1094) → 1,094', ), FormulaFunctionDoc('number', 'number(value)', 'number("42") → 42'), + FormulaFunctionDoc( + 'concat', + 'concat(list, list or value, ...)', + 'concat(take(history, 23), [soc])', + ), + FormulaFunctionDoc( + 'alpha', + 'alpha(color, opacity)', + "alpha('#FF4D4F', 0.12) → #1FFF4D4F", + ), + FormulaFunctionDoc('pad', 'pad(value, width, fill)', 'pad(5, 2) → 05'), FormulaFunctionDoc('text', 'text(value)', 'text(42) → "42"'), FormulaFunctionDoc( 'isEmpty', @@ -955,6 +975,16 @@ Object? _distance(List a) { return unit.startsWith('mi') ? km / 1.609344 : km; } +/// [color] (`#RRGGBB` or `#AARRGGBB`) at [opacity] (0 to 1), as +/// `#AARRGGBB`. +String? _alpha(Object? color, num opacity) { + final String hex = FormulaEvaluator.stringify(color).replaceAll('#', ''); + if (hex.length != 6 && hex.length != 8) return null; + final String rgb = hex.substring(hex.length - 6).toUpperCase(); + final int a = (opacity.clamp(0, 1) * 255).round(); + return '#${a.toRadixString(16).padLeft(2, '0').toUpperCase()}$rgb'; +} + Object? _arg(List args, int index) => index < args.length ? args[index] : null; @@ -1074,6 +1104,10 @@ final Map _functions = { if (_arg(a, 0) case final Map m) ...m.map((k, v) => MapEntry('$k', v)), FormulaEvaluator.stringify(_arg(a, 1)): _arg(a, 2), }, + 'concat': (a) => [ + for (final Object? item in a) + if (item is Iterable) ...item else item, + ], 'keys': (a) => switch (_arg(a, 0)) { final Map m => m.keys.map((k) => k.toString()).toList(), _ => [], @@ -1130,6 +1164,11 @@ final Map _functions = { return '${n < 0 ? '-' : ''}$whole${parts.length > 1 ? '.${parts[1]}' : ''}'; }, 'number': (a) => _toNum(_arg(a, 0)), + 'alpha': (a) => _alpha(_arg(a, 0), _num(_arg(a, 1))), + 'pad': (a) => FormulaEvaluator.stringify(_arg(a, 0)).padLeft( + _intArg(_arg(a, 1)), + _arg(a, 2) == null ? '0' : FormulaEvaluator.stringify(_arg(a, 2)), + ), 'text': (a) => FormulaEvaluator.stringify(_arg(a, 0)), 'isEmpty': (a) => !_truthy(_functions['length']!(a)), 'isNotEmpty': (a) => _truthy(_functions['length']!(a)), @@ -1310,6 +1349,8 @@ class _Binary extends _Node { final Object? b = right.eval(resolve); switch (op) { case '+': + // Lists join, and a list plus a value appends it. + if (a is Iterable) return [...a, if (b is Iterable) ...b else b]; final num? na = a is String ? null : _toNum(a); final num? nb = b is String ? null : _toNum(b); if (na != null && nb != null) return _tidy(na + nb); diff --git a/lib/src/utils/material_icon_versions.dart b/lib/src/utils/material_icon_versions.dart new file mode 100644 index 00000000..d0cc8f73 --- /dev/null +++ b/lib/src/utils/material_icon_versions.dart @@ -0,0 +1,1903 @@ +// The latest version of each Material Icons icon above version 1, from the +// Google Fonts icon metadata (fonts.google.com/metadata/icons). The SVG of +// an icon is served per version (`/s/i/materialicons/air/v10/24px.svg`) and +// only from the version the icon was added in, so an icon named by data +// ([iconWithName]) needs its version. Icons not listed are at version 1. + +/// Material Icons version by icon name, for icons above version 1. +const Map materialIconVersions = { + '10k': 10, + '10mp': 10, + '11mp': 10, + '12mp': 10, + '13mp': 10, + '14mp': 10, + '15mp': 10, + '16mp': 10, + '17mp': 10, + '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': 17, + '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, + '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': 13, + 'account_circle': 18, + '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_call': 10, + 'add_chart': 10, + 'add_circle': 13, + 'add_circle_outline': 16, + 'add_comment': 11, + '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, + '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_right': 12, + 'arrow_right_alt': 13, + 'arrow_upward': 11, + 'art_track': 13, + 'article': 13, + 'aspect_ratio': 12, + 'assessment': 12, + 'assignment': 13, + 'assignment_ind': 12, + 'assignment_late': 15, + 'assignment_return': 14, + 'assignment_returned': 12, + 'assignment_turned_in': 16, + 'assistant': 12, + 'assistant_direction': 11, + 'assistant_navigation': 10, + 'assistant_photo': 12, + 'atm': 12, + 'attach_email': 16, + 'attach_file': 16, + 'attach_money': 17, + 'attachment': 15, + 'attractions': 10, + 'attribution': 11, + '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_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, + 'balcony': 4, + 'ballot': 14, + 'bar_chart': 12, + 'batch_prediction': 12, + 'bathroom': 10, + 'bathtub': 11, + 'battery_alert': 12, + 'battery_charging_full': 12, + 'battery_full': 12, + 'battery_saver': 9, + 'battery_std': 12, + 'battery_unknown': 13, + 'beach_access': 12, + 'bed': 9, + 'bedroom_baby': 9, + 'bedroom_child': 9, + 'bedroom_parent': 9, + 'bedtime': 14, + 'beenhere': 12, + 'bento': 6, + 'bike_scooter': 11, + 'biotech': 11, + 'blender': 9, + 'blinds': 3, + 'block': 16, + 'block_flipped': 12, + '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': 14, + '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, + '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, + 'broken_image': 11, + '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': 12, + 'cake': 13, + 'calculate': 11, + '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_rental': 10, + 'car_repair': 10, + 'card_giftcard': 14, + 'card_membership': 14, + 'card_travel': 13, + 'carpenter': 6, + 'cases': 11, + 'casino': 13, + 'cast': 18, + 'cast_connected': 13, + 'cast_for_education': 14, + 'castle': 2, + 'catching_pokemon': 4, + 'category': 12, + 'celebration': 11, + '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': 13, + 'chat_bubble_outline': 16, + 'check': 18, + 'check_box': 16, + 'check_box_outline_blank': 16, + 'check_circle': 18, + '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': 7, + '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_upload': 12, + 'cloudy_snowing': 2, + 'co2': 3, + '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, + '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_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, + '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, + 'crop': 12, + 'crop_16_9': 12, + 'crop_3_2': 12, + 'crop_5_4': 12, + 'crop_7_5': 12, + 'crop_din': 12, + 'crop_free': 12, + 'crop_landscape': 12, + 'crop_original': 13, + 'crop_portrait': 12, + 'crop_rotate': 12, + 'crop_square': 12, + 'cruelty_free': 3, + '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_usage': 12, + 'date_range': 12, + 'deck': 11, + 'dehaze': 12, + 'delete': 17, + 'delete_forever': 15, + 'delete_outline': 11, + 'delete_sweep': 12, + 'delivery_dining': 15, + 'departure_board': 11, + 'description': 12, + 'design_services': 12, + 'desktop_access_disabled': 11, + 'desktop_mac': 16, + 'desktop_windows': 12, + 'details': 16, + 'developer_board': 13, + 'developer_board_off': 4, + 'developer_mode': 12, + 'device_hub': 12, + 'device_thermostat': 11, + 'device_unknown': 12, + 'devices': 16, + 'devices_other': 12, + 'dialer_sip': 12, + 'dialpad': 12, + 'diamond': 2, + 'dining': 9, + 'dinner_dining': 10, + 'directions': 12, + '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': 12, + '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, + '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_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_outline': 12, + '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': 12, + 'edit_road': 11, + 'egg': 2, + 'egg_alt': 2, + 'eject': 11, + 'elderly': 7, + 'electric_bike': 14, + 'electric_car': 14, + 'electric_moped': 14, + 'electric_rickshaw': 15, + 'electric_scooter': 14, + 'electrical_services': 12, + 'elevator': 8, + 'email': 18, + 'emergency': 5, + '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, + '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': 19, + 'event_available': 17, + 'event_busy': 20, + 'event_note': 14, + '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_retouching_natural': 16, + 'face_retouching_off': 15, + 'fact_check': 14, + '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_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_b_and_w': 13, + 'filter_center_focus': 12, + 'filter_drama': 12, + 'filter_frames': 13, + 'filter_hdr': 13, + 'filter_list': 12, + 'filter_list_alt': 10, + 'filter_none': 13, + 'filter_tilt_shift': 14, + 'filter_vintage': 13, + 'find_in_page': 12, + 'find_replace': 12, + 'fingerprint': 12, + 'fire_extinguisher': 7, + 'fireplace': 11, + 'first_page': 11, + 'fit_screen': 15, + 'fitbit': 2, + 'fitness_center': 12, + 'flag': 18, + '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, + 'flutter_dash': 5, + 'fmd_bad': 15, + 'fmd_good': 15, + 'foggy': 2, + 'folder': 16, + 'folder_open': 13, + 'folder_shared': 16, + 'folder_special': 11, + 'follow_the_signs': 7, + 'font_download': 11, + 'font_download_off': 4, + 'food_bank': 7, + 'forest': 2, + '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_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, + 'gavel': 14, + 'generating_tokens': 5, + 'gesture': 12, + 'get_app': 12, + 'gif': 14, + 'gif_box': 3, + '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, + 'h_mobiledata': 9, + 'h_plus_mobiledata': 9, + 'hail': 11, + '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, + 'height': 11, + 'help': 13, + 'help_center': 9, + 'help_outline': 12, + 'hevc': 10, + '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, + 'holiday_village': 4, + 'home': 16, + 'home_filled': 14, + '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': 18, + 'hotel_class': 5, + '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, + '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': 20, + 'info_outline': 16, + '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, + '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, + 'kayaking': 4, + 'kebab_dining': 2, + 'key': 2, + 'keyboard': 13, + 'keyboard_alt': 15, + 'keyboard_arrow_down': 16, + 'keyboard_arrow_left': 17, + 'keyboard_arrow_right': 17, + 'keyboard_arrow_up': 16, + 'keyboard_backspace': 13, + 'keyboard_capslock': 13, + 'keyboard_double_arrow_down': 2, + 'keyboard_double_arrow_left': 2, + 'keyboard_double_arrow_right': 2, + 'keyboard_double_arrow_up': 2, + 'keyboard_hide': 12, + 'keyboard_return': 13, + 'keyboard_tab': 13, + 'keyboard_voice': 17, + 'king_bed': 12, + 'kitchen': 11, + 'kitesurfing': 4, + 'label': 17, + 'label_important': 20, + 'label_important_outline': 16, + 'label_off': 13, + 'landscape': 12, + '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_outline': 20, + 'line_axis': 2, + 'line_style': 14, + 'line_weight': 14, + 'linear_scale': 17, + 'link': 21, + 'link_off': 11, + 'linked_camera': 15, + 'liquor': 10, + 'list': 17, + 'list_alt': 13, + 'live_help': 13, + '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': 14, + 'local_florist': 12, + 'local_gas_station': 12, + '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': 19, + '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_pin': 7, + 'location_searching': 12, + 'lock': 18, + 'lock_clock': 11, + 'lock_open': 16, + 'lock_outline': 13, + '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, + 'mail': 16, + 'mail_outline': 12, + 'male': 3, + 'man': 2, + 'manage_accounts': 10, + '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, + 'markunread': 12, + 'markunread_mailbox': 12, + 'masks': 7, + 'maximize': 11, + 'media_bluetooth_off': 10, + 'media_bluetooth_on': 10, + 'mediation': 15, + 'medical_services': 12, + 'medication': 10, + 'medication_liquid': 2, + 'meeting_room': 17, + 'memory': 12, + 'menu': 13, + 'menu_book': 12, + 'menu_open': 13, + '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, + '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_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, + 'network_cell': 16, + 'network_check': 12, + 'network_locked': 17, + 'network_wifi': 16, + 'new_label': 3, + 'new_releases': 12, + 'next_plan': 16, + 'next_week': 14, + 'nfc': 12, + 'night_shelter': 6, + 'nightlife': 10, + 'nightlight': 10, + 'nightlight_round': 10, + 'nights_stay': 11, + 'no_accounts': 10, + 'no_backpack': 6, + 'no_cell': 8, + 'no_drinks': 8, + 'no_encryption': 17, + 'no_encryption_gmailerrorred': 10, + 'no_flash': 8, + 'no_food': 8, + 'no_luggage': 7, + 'no_meals': 7, + 'no_meals_ouline': 6, + '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': 17, + 'not_started': 13, + 'note': 12, + 'note_add': 13, + 'note_alt': 10, + 'notes': 12, + 'notification_add': 5, + 'notification_important': 11, + '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, + '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, + 'outgoing_mail': 11, + 'outlet': 7, + 'outlined_flag': 12, + 'padding': 11, + 'pages': 11, + 'pageview': 12, + 'paid': 9, + 'palette': 16, + 'pan_tool': 14, + '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': 12, + 'pedal_bike': 14, + 'pending': 17, + 'pending_actions': 7, + 'people': 23, + 'people_alt': 21, + 'people_outline': 12, + '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_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': 17, + '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': 13, + 'phone_forwarded': 13, + 'phone_in_talk': 12, + '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, + '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, + 'pie_chart_outlined': 10, + 'pin': 10, + 'pin_drop': 17, + 'pin_end': 3, + 'pin_invoke': 3, + 'pivot_table_chart': 11, + 'pix': 2, + 'place': 17, + '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, + 'plumbing': 12, + 'plus_one': 17, + 'podcasts': 9, + 'point_of_sale': 8, + 'policy': 12, + 'poll': 13, + '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, + 'psychology': 11, + 'public': 12, + '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, + '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, + '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': 11, + 'record_voice_over': 12, + 'recycling': 3, + '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_shopping_cart': 12, + 'reorder': 16, + '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, + '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, + 'rounded_corner': 15, + 'route': 2, + 'router': 13, + 'rowing': 14, + 'rss_feed': 12, + 'rsvp': 10, + 'rtt': 12, + 'rule': 8, + 'rule_folder': 7, + 'run_circle': 11, + 'running_with_errors': 4, + 'rv_hookup': 12, + 'safety_divider': 7, + 'sailing': 4, + 'sanitizer': 6, + 'satellite': 12, + 'save': 12, + 'save_alt': 12, + 'save_as': 2, + 'saved_search': 11, + 'savings': 5, + 'scanner': 12, + 'scatter_plot': 12, + 'schedule': 17, + 'schedule_send': 14, + 'schema': 4, + 'school': 12, + 'science': 17, + 'score': 12, + 'screen_lock_landscape': 16, + 'screen_lock_portrait': 17, + 'screen_lock_rotation': 16, + 'screen_rotation': 12, + 'screen_search_desktop': 10, + 'screen_share': 12, + 'screenshot': 10, + '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_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, + 'share': 18, + 'share_location': 17, + 'shield': 11, + 'shield_moon': 2, + 'shop': 12, + 'shop_2': 9, + 'shop_two': 11, + 'shopping_bag': 8, + 'shopping_basket': 12, + 'shopping_cart': 18, + 'short_text': 15, + 'shortcut': 10, + 'show_chart': 13, + 'shower': 9, + 'shuffle': 14, + 'shuffle_on': 12, + 'shutter_speed': 11, + 'sick': 6, + 'signal_cellular_0_bar': 15, + 'signal_cellular_4_bar': 12, + 'signal_cellular_alt': 12, + '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, + '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, + 'snowing': 2, + 'snowmobile': 4, + 'snowshoeing': 4, + 'soap': 8, + 'social_distance': 7, + 'sort': 13, + 'sort_by_alpha': 11, + 'soup_kitchen': 2, + 'source': 7, + 'south': 7, + 'south_america': 2, + 'south_east': 7, + 'south_west': 7, + 'spa': 12, + 'space_bar': 11, + 'space_dashboard': 3, + '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_handball': 11, + 'sports_hockey': 11, + 'sports_kabaddi': 11, + 'sports_mma': 11, + 'sports_motorsports': 11, + 'sports_rugby': 11, + 'sports_score': 10, + 'sports_soccer': 11, + 'sports_tennis': 11, + 'sports_volleyball': 11, + 'square_foot': 11, + 'stacked_bar_chart': 11, + 'stacked_line_chart': 6, + 'stairs': 8, + 'star': 20, + 'star_border': 19, + 'star_border_purple500': 10, + 'star_half': 22, + 'star_outline': 11, + 'star_purple500': 10, + 'star_rate': 12, + 'stars': 12, + '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, + '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, + 'sunny': 2, + 'sunny_snowing': 2, + '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, + 'switch_access_shortcut': 2, + 'switch_access_shortcut_add': 2, + '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_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': 2, + 'terrain': 12, + 'text_fields': 14, + 'text_format': 12, + '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': 18, + 'thumb_down_alt': 11, + 'thumb_down_off_alt': 13, + 'thumb_up': 18, + 'thumb_up_alt': 11, + 'thumb_up_off_alt': 13, + 'thumbs_up_down': 12, + '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, + 'title': 11, + 'toc': 13, + 'today': 17, + 'toggle_off': 12, + 'toggle_on': 12, + 'token': 2, + 'toll': 12, + 'tonality': 12, + 'topic': 7, + 'touch_app': 13, + 'tour': 12, + 'toys': 14, + 'track_changes': 11, + 'traffic': 12, + 'train': 11, + 'tram': 11, + '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': 9, + 'try': 9, + 'tty': 8, + 'tune': 12, + 'tungsten': 10, + 'turned_in': 11, + 'turned_in_not': 11, + 'tv': 16, + 'tv_off': 11, + 'two_wheeler': 14, + 'umbrella': 8, + 'unarchive': 13, + 'undo': 13, + 'unfold_less': 12, + 'unfold_more': 12, + '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, + 'verified': 11, + 'verified_user': 18, + 'vertical_align_bottom': 12, + 'vertical_align_center': 12, + 'vertical_align_top': 12, + 'vertical_distribute': 6, + 'vertical_split': 15, + 'vibration': 12, + 'video_call': 11, + 'video_camera_back': 10, + 'video_camera_front': 10, + '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': 13, + 'view_compact': 12, + 'view_day': 13, + 'view_headline': 11, + 'view_in_ar': 11, + 'view_list': 14, + 'view_module': 13, + 'view_quilt': 14, + 'view_sidebar': 13, + 'view_stream': 13, + 'view_week': 13, + 'vignette': 12, + 'villa': 4, + 'visibility': 16, + 'visibility_off': 12, + 'voice_chat': 12, + 'voice_over_off': 11, + 'voicemail': 12, + 'volume_down': 13, + 'volume_down_alt': 3, + 'volume_mute': 13, + 'volume_off': 17, + 'volume_up': 13, + 'volunteer_activism': 10, + 'vpn_key': 12, + 'vpn_lock': 17, + 'vrpano': 10, + 'wallpaper': 13, + 'warning': 17, + 'warning_amber': 16, + 'wash': 8, + 'watch': 12, + 'watch_later': 17, + '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_twighlight': 10, + 'wb_twilight': 9, + 'wc': 11, + 'web': 13, + 'web_asset': 12, + 'web_asset_off': 4, + 'web_stories': 4, + 'weekend': 12, + 'west': 7, + 'whatsapp': 2, + 'whatshot': 17, + 'wheelchair_pickup': 8, + 'where_to_vote': 16, + 'widgets': 13, + 'wifi': 12, + 'wifi_calling': 11, + 'wifi_calling_3': 10, + 'wifi_lock': 13, + 'wifi_off': 12, + 'wifi_protected_setup': 12, + 'wifi_tethering': 12, + 'wifi_tethering_error': 3, + 'wifi_tethering_off': 10, + 'window': 9, + 'wine_bar': 6, + 'woman': 2, + 'work': 12, + 'work_off': 12, + 'work_outline': 12, + 'workspace_premium': 3, + 'workspaces': 9, + 'workspaces_filled': 13, + 'workspaces_outline': 13, + 'wrap_text': 13, + 'wrong_location': 17, + 'wysiwyg': 8, + 'yard': 9, + 'youtube_searched_for': 12, + 'zoom_in': 11, + 'zoom_out': 11, + 'zoom_out_map': 17, +}; diff --git a/pubspec.yaml b/pubspec.yaml index ea7839c4..fb46ccb2 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.2.0 +version: 8.3.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.1.0 + codelessly_api: ^6.2.0 json_annotation: ^4.12.0 # Firebase diff --git a/test/computed_variable_test.dart b/test/computed_variable_test.dart new file mode 100644 index 00000000..b089f14d --- /dev/null +++ b/test/computed_variable_test.dart @@ -0,0 +1,109 @@ +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 'feedback_actions_test.dart' + show body, brandRegular, button, canvas, layoutOf, pumpLayouts, text; + +final VariableData level = VariableData( + id: 'level', + name: 'level', + value: 0.87, + type: VariableType.decimal, +); +final VariableData limit = VariableData( + id: 'limit', + name: 'limit', + value: 0.75, + type: VariableType.decimal, +); +final VariableData over = VariableData( + id: 'over', + name: 'over', + type: VariableType.boolean, + formula: 'level > limit', +); +final VariableData label = VariableData( + id: 'label', + name: 'label', + formula: "if(over, 'ALERT ' + round(level * 100) + '%', 'OK')", +); +final VariableData alerts = VariableData( + id: 'alerts', + name: 'alerts', + type: VariableType.list, + formula: "filter([object('id', 'black', 'on', over)], a => a.on)", +); +final VariableData loop = VariableData( + id: 'loop', + name: 'loop', + type: VariableType.integer, + formula: 'loop + 1', +); + +SetVariableAction setLimit(String value) => + SetVariableAction(variable: limit, newValue: value); + +Future pumpScreen(WidgetTester tester) => pumpLayouts( + tester, + [ + layoutOf('layout', [ + canvas('main', 'mainBody'), + body('mainBody', 'main', ['status', 'count', 'raise', 'lower', 'cycle']), + text('status', 'mainBody', r'Status ${label}', brandRegular), + text('count', 'mainBody', r'Alerts ${= length(alerts)}', brandRegular), + button('raise', 'mainBody', 'Raise', setLimit('0.9')), + button('lower', 'mainBody', 'Lower', setLimit('0.5')), + text('cycle', 'mainBody', r'Loop [${loop}]', brandRegular), + ]), + ], + variables: { + 'main': { + for (final v in [level, limit, over, label, alerts, loop]) v.id: v, + }, + }, +); + +void main() { + testWidgets('computed variables follow the variables their formulas read', ( + tester, + ) async { + await pumpScreen(tester); + expect(find.text('Status ALERT 87%'), findsOneWidget); + expect(find.text('Alerts 1'), findsOneWidget); + + // Raising the limit clears the alert everywhere, through two levels of + // computed variables. + await tester.tap(find.text('Raise')); + await tester.pumpAndSettle(); + expect(find.text('Status OK'), findsOneWidget); + expect(find.text('Alerts 0'), findsOneWidget); + + await tester.tap(find.text('Lower')); + await tester.pumpAndSettle(); + expect(find.text('Status ALERT 87%'), findsOneWidget); + }); + + testWidgets('a formula that reads its own variable stops, not hangs', ( + tester, + ) async { + await pumpScreen(tester); + // The inner read of `loop` is null (0 in arithmetic), so loop + 1 is 1. + expect(find.text('Loop [1]'), findsOneWidget); + }); + + testWidgets('setting a computed variable is ignored', (tester) async { + await pumpScreen(tester); + final BuildContext context = tester.element(find.text('Raise')); + expect( + FunctionsRepository.setVariableFromAction( + context, + SetVariableAction(variable: over, newValue: 'false'), + ), + isFalse, + ); + await tester.pump(); + expect(find.text('Status ALERT 87%'), findsOneWidget); + }); +} diff --git a/test/corner_radius_binding_test.dart b/test/corner_radius_binding_test.dart new file mode 100644 index 00000000..97d522b5 --- /dev/null +++ b/test/corner_radius_binding_test.dart @@ -0,0 +1,64 @@ +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() { + // A dialog is a bottom sheet on phones, squared at the bottom, and a card + // rounded on every corner above. + testWidgets('corner radius follows a bound formula', (tester) async { + final RectangleNode node = RectangleNode.fromJson({ + ...RectangleNode( + id: 'dialog', + name: 'Dialog', + basicBoxLocal: NodeBox(0, 0, 40, 40), + ).toJson(), + 'variables': {'cornerRadius': r'${= sheet ? [6, 6, 0, 0] : 6}'}, + }); + node.parentID = kRootNode; + final registry = NodeRegistry()..setNodes({node.id: node}); + final manager = PassiveNodeTransformerManager( + registry.getNodeByID, + (context, bounds, pageID, layoutID, canvasID) => const SizedBox(), + ); + final Observable sheet = Observable( + VariableData( + id: 'sheet', + name: 'sheet', + type: VariableType.boolean, + value: true, + ), + ); + final CodelesslyContext codelesslyContext = CodelesslyContext.empty() + ..variables['sheet'] = sheet; + + late BuildContext context; + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: codelesslyContext, + child: MaterialApp( + home: Builder( + builder: (c) { + context = c; + return const SizedBox(); + }, + ), + ), + ), + ); + + CornerRadius radius() => (manager.applyStyleOverrides( + context, + node, + ) as CornerMixin).cornerRadius; + + expect(radius().tl, const RadiusModel.circular(6)); + expect(radius().tr, const RadiusModel.circular(6)); + expect(radius().br, RadiusModel.zero); + expect(radius().bl, RadiusModel.zero); + + sheet.set(sheet.value.copyWith(value: false)); + expect(radius(), const CornerRadius.all(RadiusModel.circular(6))); + }); +} diff --git a/test/dialog_placement_test.dart b/test/dialog_placement_test.dart new file mode 100644 index 00000000..c0d79348 --- /dev/null +++ b/test/dialog_placement_test.dart @@ -0,0 +1,124 @@ +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 'feedback_actions_test.dart' + show brandRegular, layoutOf, mainScreen, pumpLayouts, text; + +/// A 180 × 120 menu canvas with one line of text. +SDKPublishLayout menu() => layoutOf('menuLayout', [ + CanvasNode( + id: 'menu', + name: 'menu', + basicBoxLocal: NodeBox(0, 0, 180, 120), + scaleMode: ScaleMode.responsive, + children: ['menuBody'], + properties: CanvasProperties( + bodyId: 'menuBody', + safeArea: const SafeAreaModel.all(false), + ), + )..parentID = kRootNode, + AutoPlaceholderNode( + id: 'menuBody', + name: 'menuBody', + basicBoxLocal: NodeBox(0, 0, 180, 120), + children: ['item'], + mainAxisAlignment: MainAxisAlignmentC.start, + horizontalFit: SizeFit.expanded, + verticalFit: SizeFit.expanded, + )..parentID = 'menu', + text('item', 'menuBody', 'Snooze 1 hour', brandRegular), +]); + +Rect menuRect(WidgetTester tester) => tester.getRect( + find + .ancestor( + of: find.text('Snooze 1 hour'), + matching: find.byType(CodelesslyWidget), + ) + .first, +); + +Future open( + WidgetTester tester, + DialogPlacement placement, { + Size size = const Size(390, 844), +}) async { + await pumpLayouts(tester, [ + layoutOf( + 'main', + mainScreen( + ShowDialogAction( + destinationId: 'menu', + placement: placement, + anchorGap: 6, + ), + ), + ), + menu(), + ], size: size); + await tester.tap(find.text('Go')); + await tester.pumpAndSettle(); + expect(find.text('Snooze 1 hour'), findsOneWidget); +} + +void main() { + testWidgets('an anchored dialog opens just below its trigger', ( + tester, + ) async { + await open(tester, DialogPlacement.anchored); + final Rect go = tester.getRect( + find.ancestor(of: find.text('Go'), matching: find.byType(FitBox)).first, + ); + final Rect shown = menuRect(tester); + expect(shown.top, closeTo(go.bottom + 6, 1)); + expect(shown.left, closeTo(go.left.clamp(8, 390 - 180 - 8), 1)); + expect(shown.size, const Size(180, 120)); + }); + + testWidgets('a bottom sheet spans the screen width at the bottom', ( + tester, + ) async { + await open(tester, DialogPlacement.bottomSheet); + final Rect shown = menuRect(tester); + expect(shown.width, 390); + expect(shown.height, 120); + expect(shown.bottom, 844); + }); + + testWidgets('adaptive is a sheet on mobile', (tester) async { + await open(tester, DialogPlacement.adaptive); + expect(menuRect(tester).bottom, 844); + expect(menuRect(tester).width, 390); + }); + + testWidgets('adaptive is centered on desktop', (tester) async { + await open(tester, DialogPlacement.adaptive, size: const Size(1440, 900)); + final Rect shown = menuRect(tester); + expect(shown.size, const Size(180, 120)); + expect(shown.center, const Offset(720, 450)); + }); + + testWidgets('an anchored dialog without room below opens above', ( + tester, + ) async { + await pumpLayouts(tester, [ + layoutOf( + 'main', + mainScreen( + ShowDialogAction( + destinationId: 'menu', + placement: DialogPlacement.anchored, + ), + ), + ), + menu(), + ], size: const Size(390, 200)); + await tester.tap(find.text('Go')); + await tester.pumpAndSettle(); + final Rect shown = menuRect(tester); + expect(shown.top, greaterThanOrEqualTo(8)); + expect(shown.bottom, lessThanOrEqualTo(192)); + }); +} diff --git a/test/feedback_actions_test.dart b/test/feedback_actions_test.dart index 0247d15d..980463a2 100644 --- a/test/feedback_actions_test.dart +++ b/test/feedback_actions_test.dart @@ -161,10 +161,14 @@ class _TestDataManager extends DataManager { } /// Pumps the layout [layouts.first] on a screen of [size]. +/// +/// [variables] are the first layout's variables by canvas ID (or app/page +/// scope) and variable ID. Future pumpLayouts( WidgetTester tester, List layouts, { Size size = const Size(390, 844), + Map> variables = const {}, }) async { tester.view.devicePixelRatio = 1; tester.view.physicalSize = size; @@ -181,6 +185,17 @@ Future pumpLayouts( for (final layout in layouts) { model.layouts[layout.id] = layout; } + if (variables.isNotEmpty) { + model.variables[layouts.first.id] = SDKLayoutVariables( + id: layouts.first.id, + variables: variables, + owner: 'owner', + teams: const {}, + users: const {}, + roles: const {}, + public: false, + ); + } final codelessly = Codelessly( config: CodelesslyConfig(authToken: 'token'), publishDataManager: _TestDataManager(model)..status = CStatus.loaded(), diff --git a/test/formula_math_test.dart b/test/formula_math_test.dart index 0e45bb58..dbfc8cb5 100644 --- a/test/formula_math_test.dart +++ b/test/formula_math_test.dart @@ -40,7 +40,7 @@ void main() { test('distance between list items', () { expect( eval( - "round(distance(first(stops).lat, first(stops).lng, " + 'round(distance(first(stops).lat, first(stops).lng, ' "find(stops, 'id', 'bryce').lat, find(stops, 'id', 'bryce').lng, 'mi'))", ), inInclusiveRange(60, 70), @@ -77,7 +77,7 @@ void main() { expect(eval('flatten([[1, 2], [3], 4])'), [1, 2, 3, 4]); expect(eval("compact([1, null, '', 2])"), [1, 2]); expect( - eval("compact(flatten(map(stops, s => [s.nights > 2 ? s.id : null])))"), + eval('compact(flatten(map(stops, s => [s.nights > 2 ? s.id : null])))'), ['zion', 'moab'], ); }); diff --git a/test/formula_test.dart b/test/formula_test.dart index af8ba4a1..50f5a365 100644 --- a/test/formula_test.dart +++ b/test/formula_test.dart @@ -33,6 +33,63 @@ void main() { FormulaEvaluator.evaluate(expression, resolve); group('FormulaEvaluator', () { + test('object builds records a list can filter and sort', () { + expect(eval("object('title', 'Black', 'level', 0.87)"), { + 'title': 'Black', + 'level': 0.87, + }); + expect( + eval( + "map(sortBy(filter([object('n', 'a', 'v', 3), object('n', 'b', " + "'v', 1), object('n', 'c', 'v', 0)], r => r.v > 0), 'v'), 'n')", + ), + ['b', 'a'], + ); + expect(eval("object('k', qty * 2).k"), 4); + // A dangling key is dropped. + expect(eval("object('a', 1, 'b')"), {'a': 1}); + }); + + test('parses tells well-formed formulas apart', () { + expect(FormulaEvaluator.parses('level > limit'), isTrue); + expect(FormulaEvaluator.parses('null'), isTrue); + expect(FormulaEvaluator.parses('level >'), isFalse); + expect(FormulaEvaluator.parses("if(a, 'x'"), isFalse); + }); + + test('math helpers', () { + expect(eval('clamp(1.4, 0, 1)'), 1); + expect(eval('clamp(-3, 0, 1)'), 0); + expect(eval('round(sin(0), 3)'), 0); + expect(eval('cos(0)'), 1); + expect(eval('sqrt(16)'), 4); + expect(eval('pow(2, 10)'), 1024); + expect(eval('pad(5, 2)'), '05'); + expect(eval("pad(7, 3, ' ')"), ' 7'); + expect(eval('pad(123, 2)'), '123'); + expect(eval("alpha('#FF4D4F', 0.12)"), '#1FFF4D4F'); + expect(eval("alpha('#80ff4d4f', 1)"), '#FFFF4D4F'); + expect(eval("alpha('red', 1)"), null); + expect(eval('take([1, 2, 3, 4], 3)'), [1, 2, 3]); + expect(eval('take([1], 3)'), [1]); + expect(eval('concat([1, 2], [3], 4)'), [1, 2, 3, 4]); + expect(eval('concat(take([1, 2, 3], 2), [9])'), [1, 2, 9]); + expect(eval('[1, 2] + [3]'), [1, 2, 3]); + expect(eval('[1, 2] + 3'), [1, 2, 3]); + expect(eval("'a' + 'b'"), 'ab'); + expect(eval('skip([1, 2, 3], 1)'), [2, 3]); + expect(eval("keys(object('a', 1, 'b', 2))"), ['a', 'b']); + expect(eval('keys(qty)'), isEmpty); + // A map read with a computed key, e.g. a snooze deadline by alert id. + expect( + eval( + "map(filter([object('id', 'a'), object('id', 'b')], r => " + "object('a', 900)[r.id] > 880), 'id')", + ), + ['a'], + ); + }); + test('arithmetic and precedence', () { expect(eval('1 + 2 * 3'), 7); expect(eval('(1 + 2) * 3'), 9); diff --git a/test/gauge_bar_chart_test.dart b/test/gauge_bar_chart_test.dart new file mode 100644 index 00000000..8fb956e7 --- /dev/null +++ b/test/gauge_bar_chart_test.dart @@ -0,0 +1,185 @@ +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'; + +Future pump(WidgetTester tester, Widget child) => tester.pumpWidget( + ChangeNotifierProvider.value( + value: CodelesslyContext.empty(), + child: MaterialApp(home: Center(child: child)), + ), +); + +GaugeNode gauge( + GaugeProperties props, { + Map variables = const {}, +}) => GaugeNode( + id: 'gauge', + name: 'Gauge', + basicBoxLocal: NodeBox(0, 0, 168, 144), + value: 20, + limit: 30, + properties: props, +)..variables.addAll(variables); + +GaugePainter painterOf(WidgetTester tester) => tester + .widgetList(find.byType(CustomPaint)) + .map((p) => p.painter) + .whereType() + .single; + +void main() { + group('Gauge', () { + testWidgets('fills to its static value and limit', (tester) async { + await pump( + tester, + PassiveGaugeWidget( + node: gauge(GaugeProperties(animate: false)), + settings: const WidgetBuildSettings(debugLabel: 'test'), + ), + ); + final GaugePainter painter = painterOf(tester); + expect(painter.fraction, closeTo(0.2, 1e-9)); + expect(painter.limit, closeTo(0.3, 1e-9)); + }); + + testWidgets('reads value, limit and colors from formulas', (tester) async { + await pump( + tester, + PassiveGaugeWidget( + node: gauge( + GaugeProperties(animate: false, min: 0, max: 1), + variables: { + 'value': r'${= 0.8 + 0.07}', + 'limit': r'${= 0.75}', + 'color': r'${= 0.87 > 0.85 ? "#FF4D4F" : "#3DD68C"}', + 'limitColor': '#F2C230', + }, + ), + settings: const WidgetBuildSettings(debugLabel: 'test'), + ), + ); + final GaugePainter painter = painterOf(tester); + expect(painter.fraction, closeTo(0.87, 1e-9)); + expect(painter.limit, closeTo(0.75, 1e-9)); + expect(painter.color.toARGB32(), 0xFFFF4D4F); + }); + + testWidgets('a limit bound to nothing hides the marker', (tester) async { + await pump( + tester, + PassiveGaugeWidget( + node: gauge( + GaugeProperties(animate: false), + variables: {'limit': r'${= rules.missing}'}, + ), + settings: const WidgetBuildSettings(debugLabel: 'test'), + ), + ); + expect(painterOf(tester).limit, isNull); + }); + + for (final shape in GaugeShape.values) { + for (final segments in [0, 24]) { + testWidgets('paints a ${shape.name} with $segments segments', ( + tester, + ) async { + await pump( + tester, + RepaintBoundary( + child: PassiveGaugeWidget( + node: gauge( + GaugeProperties( + shape: shape, + segments: segments, + ticks: 10, + majorTickEvery: 5, + limitStyle: GaugeLimitStyle.dashed, + limitPointer: true, + edgeWidth: 2, + fillStartOpacity: 0.5, + inset: 3, + borderColor: ColorRGBA.white, + backgroundColor: ColorRGBA.black, + animate: false, + ), + ), + settings: const WidgetBuildSettings(debugLabel: 'test'), + ), + ), + ); + expect(tester.takeException(), isNull); + }); + } + } + }); + + group('Bar chart', () { + BarChartNode chart({Map variables = const {}}) => + BarChartNode( + id: 'chart', + name: 'Chart', + basicBoxLocal: NodeBox(0, 0, 300, 140), + values: [10, 20, null], + labels: ['a', '', 'c'], + highlight: 1, + properties: BarChartProperties( + max: 40, + animate: false, + gridLabels: ['1', '2', '3'], + ), + )..variables.addAll(variables); + + double barHeight(WidgetTester tester, int index) { + final bars = tester + .widgetList(find.byType(Container)) + .where((c) => c.decoration is BoxDecoration) + .toList(); + return tester.getSize(find.byWidget(bars[index])).height; + } + + testWidgets('scales bars to max and dims all but the highlight', ( + tester, + ) async { + await pump( + tester, + PassiveBarChartWidget( + node: chart(), + settings: const WidgetBuildSettings(debugLabel: 'test'), + ), + ); + expect(find.text('a'), findsOneWidget); + expect(find.text('3'), findsOneWidget); + expect(barHeight(tester, 1), closeTo(barHeight(tester, 0) * 2, 0.01)); + final bars = tester + .widgetList(find.byType(Container)) + .map((c) => c.decoration) + .whereType() + .toList(); + expect(bars[0].color!.a, lessThan(bars[1].color!.a)); + }); + + testWidgets('reads values, labels and highlight from formulas', ( + tester, + ) async { + await pump( + tester, + PassiveBarChartWidget( + node: chart( + variables: { + 'values': r'${= [40, 10]}', + 'labels': r'${= ["x", "y"]}', + 'highlight': r'${= 0}', + }, + ), + settings: const WidgetBuildSettings(debugLabel: 'test'), + ), + ); + expect(find.text('x'), findsOneWidget); + expect(find.text('y'), findsOneWidget); + expect(find.text('a'), findsNothing); + expect(barHeight(tester, 0), closeTo(barHeight(tester, 1) * 4, 0.01)); + }); + }); +} diff --git a/test/icon_binding_test.dart b/test/icon_binding_test.dart new file mode 100644 index 00000000..89d66d62 --- /dev/null +++ b/test/icon_binding_test.dart @@ -0,0 +1,45 @@ +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:codelessly_sdk/codelessly_sdk.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + const MultiSourceIconModel base = MultiSourceIconModel.icon( + icon: MaterialIcon( + codepoint: 0xe5e0, + name: 'speed', + style: MaterialIconStyle.filled, + ), + ); + + test('a bound name picks the icon and its style', () { + final MaterialIcon icon = + iconWithName(base, 'kitchen_outlined').icon! as MaterialIcon; + expect(icon.name, 'kitchen'); + expect(icon.style, MaterialIconStyle.outlined); + expect( + icon.toSvgUrl(), + 'https://fonts.gstatic.com/s/i/materialiconsoutlined/kitchen/v11/24px.svg', + ); + + final MaterialIcon filled = iconWithName(base, 'Air').icon! as MaterialIcon; + expect(filled.name, 'air'); + expect(filled.style, MaterialIconStyle.filled); + // Its SVG is only served from the version it was added in. + expect( + filled.toSvgUrl(), + 'https://fonts.gstatic.com/s/i/materialicons/air/v10/24px.svg', + ); + // Icons at version 1 aren't listed. + expect((iconWithName(base, 'abc').icon! as MaterialIcon).version, 1); + expect( + (iconWithName(base, 'power_rounded').icon! as MaterialIcon).style, + MaterialIconStyle.rounded, + ); + }); + + test('an empty or malformed name keeps the designed icon', () { + expect(iconWithName(base, null), same(base)); + expect(iconWithName(base, ' '), same(base)); + expect(iconWithName(base, r'${item.icon}'), same(base)); + }); +} diff --git a/test/industrial_slider_test.dart b/test/industrial_slider_test.dart new file mode 100644 index 00000000..45de016e --- /dev/null +++ b/test/industrial_slider_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 'package:provider/provider.dart'; + +SliderNode rule({ + double value = 75, + Map variables = const {}, + bool fillFromEnd = true, +}) => SliderNode( + id: 'slider', + name: 'Black tank limit', + basicBoxLocal: NodeBox(0, 0, 300, 32), + value: value, + markerValue: 87, + properties: SliderProperties( + min: 0, + max: 100, + divisions: 20, + isDiscrete: true, + selectableMin: 5, + selectableMax: 95, + fillFromEnd: fillFromEnd, + thumbShape: SliderThumbShapeEnum.rectangle, + thumbBorderColor: ColorRGBA.black, + thumbGripColor: ColorRGBA.black, + tickMarkStyle: SliderTickMarkStyle.notch, + notchEvery: 2, + trackHeight: 6, + trackShape: SliderTrackShapeEnum.rectangle, + ), +)..variables.addAll(variables); + +Future pump(WidgetTester tester, Widget child) => tester.pumpWidget( + ChangeNotifierProvider.value( + value: CodelesslyContext.empty(), + child: MaterialApp( + home: Scaffold(body: Center(child: child)), + ), + ), +); + +SliderThemeData theme(WidgetTester tester) => + tester.widget(find.byType(SliderTheme).last).data; + +void main() { + testWidgets('draws the fader thumb, notches, end fill and the marker', ( + tester, + ) async { + await pump( + tester, + PassiveSliderWidget( + node: rule(), + settings: const WidgetBuildSettings(debugLabel: 'test'), + ), + ); + expect(tester.takeException(), isNull); + final SliderThemeData data = theme(tester); + expect(data.thumbShape, isA()); + expect(data.tickMarkShape, isA()); + final track = data.trackShape as IndustrialSliderTrackShape; + expect(track.fillFromEnd, isTrue); + expect(track.markerFraction, closeTo(0.87, 1e-9)); + }); + + testWidgets('marker and active color follow their bindings', (tester) async { + await pump( + tester, + PassiveSliderWidget( + node: rule( + variables: { + 'markerValue': r'${= 64}', + 'activeTrackColor': r'${= 64 > 50 ? "#FF4D4F" : "#F2C230"}', + }, + ), + settings: const WidgetBuildSettings(debugLabel: 'test'), + ), + ); + final SliderThemeData data = theme(tester); + expect( + (data.trackShape as IndustrialSliderTrackShape).markerFraction, + closeTo(0.64, 1e-9), + ); + expect(data.activeTrackColor!.toARGB32(), 0xFFFF4D4F); + }); + + testWidgets('the thumb stays in the selectable range and reports the end ' + 'of a drag', (tester) async { + final List changes = []; + final List ends = []; + await pump( + tester, + TransformerSlider( + node: rule(value: 75), + settings: const WidgetBuildSettings(debugLabel: 'test'), + value: 75, + onChanged: changes.add, + onChangeEnd: ends.add, + ), + ); + final Slider slider = tester.widget(find.byType(Slider)); + expect(slider.value, 75); + // Drag far past the left end: the value stops at 5, not 0. + await tester.drag(find.byType(Slider), const Offset(-600, 0)); + await tester.pump(); + expect(changes, isNotEmpty); + expect(changes.last, 5); + expect(ends.single, 5); + }); + + testWidgets('a plain slider keeps the Material shapes', (tester) async { + await pump( + tester, + PassiveSliderWidget( + node: SliderNode( + id: 's', + name: 's', + basicBoxLocal: NodeBox(0, 0, 300, 48), + value: 50, + properties: SliderProperties(), + ), + settings: const WidgetBuildSettings(debugLabel: 'test'), + ), + ); + final SliderThemeData data = theme(tester); + expect(data.thumbShape, isA()); + expect(data.trackShape, isA()); + }); +} diff --git a/test/interval_trigger_test.dart b/test/interval_trigger_test.dart new file mode 100644 index 00000000..d20a843e --- /dev/null +++ b/test/interval_trigger_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 'feedback_actions_test.dart' + show body, brandRegular, canvas, layoutOf, pumpLayouts, text; + +final VariableData tick = VariableData( + id: 'tick', + name: 'tick', + value: 0, + type: VariableType.integer, +); + +Reaction every(int millis, ActionModel action) => Reaction( + trigger: TriggerModel(type: TriggerType.interval, intervalMillis: millis), + action: action, +); + +SetVariableAction addOne() => SetVariableAction( + variable: tick, + newValue: '1', + numberOperation: NumberOperation.add, +); + +/// A screen showing `tick`, whose canvas runs [reactions]. +SDKPublishLayout screen(List reactions) { + final CanvasNode main = canvas('main', 'mainBody') + ..reactions.addAll(reactions); + final SDKPublishLayout layout = layoutOf('layout', [ + main, + body('mainBody', 'main', ['count']), + text('count', 'mainBody', r'Ticks ${tick}', brandRegular), + ]); + return layout; +} + +Future pumpScreen(WidgetTester tester, List reactions) async { + final SDKPublishLayout layout = screen(reactions); + final Codelessly codelessly = await pumpLayouts( + tester, + [layout], + variables: { + 'main': {tick.id: tick}, + }, + ); + expect(codelessly, isNotNull); +} + +void main() { + testWidgets('an interval reaction runs every period while shown', ( + tester, + ) async { + await pumpScreen(tester, [every(1000, addOne())]); + expect(find.text('Ticks 0'), findsOneWidget); + + await tester.pump(const Duration(milliseconds: 1000)); + await tester.pump(); + expect(find.text('Ticks 1'), findsOneWidget); + + await tester.pump(const Duration(milliseconds: 3000)); + await tester.pump(); + expect(find.text('Ticks 4'), findsOneWidget); + + // Leaving the screen stops the timer. + await tester.pumpWidget(const SizedBox()); + await tester.pump(const Duration(seconds: 5)); + }); + + testWidgets('a slow interval chain skips ticks instead of piling up', ( + tester, + ) async { + // Each run waits 2.5 s after counting, so with a 1 s period only one run + // is in flight at a time. + await pumpScreen(tester, [ + every(1000, addOne()), + every(1000, DelayAction(milliseconds: 2500)), + ]); + await tester.pump(const Duration(milliseconds: 1000)); + await tester.pump(); + expect(find.text('Ticks 1'), findsOneWidget); + await tester.pump(const Duration(milliseconds: 2000)); + await tester.pump(); + expect(find.text('Ticks 1'), findsOneWidget); + await tester.pump(const Duration(milliseconds: 1000)); + await tester.pump(); + expect(find.text('Ticks 2'), findsOneWidget); + await tester.pumpWidget(const SizedBox()); + await tester.pump(const Duration(seconds: 5)); + }); + + testWidgets('reactions with different periods run on their own timers', ( + tester, + ) async { + await pumpScreen(tester, [every(1000, addOne()), every(500, addOne())]); + await tester.pump(const Duration(milliseconds: 1000)); + await tester.pump(); + // 2 from the 500 ms timer, 1 from the 1 s timer. + expect(find.text('Ticks 3'), findsOneWidget); + await tester.pumpWidget(const SizedBox()); + await tester.pump(const Duration(seconds: 5)); + }); +} diff --git a/test/node_transition_test.dart b/test/node_transition_test.dart new file mode 100644 index 00000000..1ca3cb02 --- /dev/null +++ b/test/node_transition_test.dart @@ -0,0 +1,85 @@ +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'feedback_actions_test.dart' + show body, brandRegular, button, canvas, layoutOf, pumpLayouts, text; + +final VariableData bright = VariableData( + id: 'bright', + name: 'bright', + value: true, + type: VariableType.boolean, +); + +TextNode pulse({NodeTransition? transition}) => + text('pulse', 'mainBody', 'Loading', brandRegular) + ..variables['opacity'] = r'${= if(bright, 1, 0.45)}' + ..transition = transition; + +Future pumpScreen(WidgetTester tester, {NodeTransition? transition}) => + pumpLayouts( + tester, + [ + layoutOf('layout', [ + canvas('main', 'mainBody'), + body('mainBody', 'main', ['pulse', 'dim']), + pulse(transition: transition), + button( + 'dim', + 'mainBody', + 'Dim', + SetVariableAction(variable: bright, toggled: true), + ), + ]), + ], + variables: { + 'main': {bright.id: bright}, + }, + ); + +double renderedOpacity(WidgetTester tester) { + final Iterable fades = tester.widgetList( + find.ancestor( + of: find.text('Loading'), + matching: find.byType(FadeTransition), + ), + ); + return fades.first.opacity.value; +} + +void main() { + testWidgets('a node with a transition tweens a bound opacity', ( + tester, + ) async { + await pumpScreen( + tester, + transition: const NodeTransition( + durationMillis: 1000, + curve: TransitionCurve.linear, + ), + ); + expect(renderedOpacity(tester), 1); + + await tester.tap(find.text('Dim')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + expect(renderedOpacity(tester), closeTo(0.725, 0.02)); + + await tester.pump(const Duration(milliseconds: 600)); + expect(renderedOpacity(tester), closeTo(0.45, 0.001)); + }); + + testWidgets('without a transition a bound opacity jumps', (tester) async { + await pumpScreen(tester); + expect(find.byType(AnimatedOpacity), findsNothing); + await tester.tap(find.text('Dim')); + await tester.pump(); + final Opacity opacity = tester.widget( + find + .ancestor(of: find.text('Loading'), matching: find.byType(Opacity)) + .first, + ); + expect(opacity.opacity, 0.45); + }); +} diff --git a/test/preview_route_params_test.dart b/test/preview_route_params_test.dart new file mode 100644 index 00000000..766fd4bd --- /dev/null +++ b/test/preview_route_params_test.dart @@ -0,0 +1,32 @@ +import 'package:codelessly_sdk/codelessly_sdk.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('design canvas values read sample route params', () { + final ScopedValues values = ScopedValues.preview( + variables: const {}, + routeParams: const {'title': 'Log black tank dump?', 'danger': true}, + ); + expect( + PropertyValueDelegate.getVariableValueFromPath( + r'${route.title}', + scopedValues: values, + ), + 'Log black tank dump?', + ); + expect( + PropertyValueDelegate.getVariableValueFromPath( + r'${= upper(route.title)}', + scopedValues: values, + ), + 'LOG BLACK TANK DUMP?', + ); + expect( + PropertyValueDelegate.getVariableValueFromPath( + r'${route.danger}', + scopedValues: values, + ), + isTrue, + ); + }); +} diff --git a/test/stretch_row_test.dart b/test/stretch_row_test.dart new file mode 100644 index 00000000..e1b87306 --- /dev/null +++ b/test/stretch_row_test.dart @@ -0,0 +1,56 @@ +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'feedback_actions_test.dart' show body, canvas, layoutOf, pumpLayouts; + +RowColumnNode card(String id, String content) => RowColumnNode( + id: id, + name: id, + rowColumnType: RowColumnType.column, + basicBoxLocal: NodeBox(0, 0, 150, 100), + horizontalFit: SizeFit.expanded, + verticalFit: SizeFit.shrinkWrap, + fills: [PaintModel.solid(id: '$id fill', color: ColorRGB.black)], + children: [content], +)..parentID = 'cards'; + +RectangleNode block(String id, String parent, double height) => + RectangleNode(id: id, name: id, basicBoxLocal: NodeBox(0, 0, 100, height)) + ..parentID = parent; + +void main() { + testWidgets('a row that hugs its height and stretches its children gives ' + "hugging cards the tallest card's height, as the editor lays it out", ( + tester, + ) async { + await pumpLayouts(tester, [ + layoutOf('main', [ + canvas('main', 'mainBody'), + body('mainBody', 'main', ['cards']), + RowColumnNode( + id: 'cards', + name: 'cards', + rowColumnType: RowColumnType.row, + basicBoxLocal: NodeBox(0, 0, 390, 320), + horizontalFit: SizeFit.expanded, + verticalFit: SizeFit.shrinkWrap, + crossAxisAlignment: CrossAxisAlignmentC.stretch, + children: ['short', 'tall'], + )..parentID = 'mainBody', + card('short', 'shortBlock'), + block('shortBlock', 'short', 200), + card('tall', 'tallBlock'), + block('tallBlock', 'tall', 320), + ]), + ]); + expect( + tester.getSize(find.byKey(const ValueKey('short')).first).height, + 320, + ); + expect( + tester.getSize(find.byKey(const ValueKey('tall')).first).height, + 320, + ); + }); +} diff --git a/test/text_color_binding_test.dart b/test/text_color_binding_test.dart new file mode 100644 index 00000000..da545415 --- /dev/null +++ b/test/text_color_binding_test.dart @@ -0,0 +1,66 @@ +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:codelessly_sdk/codelessly_sdk.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; + +TextNode reading(Map variables) => TextNode( + id: 'reading', + name: 'Reading', + basicBoxLocal: NodeBox(0, 0, 120, 30), + characters: '87%', + textMixedProps: [ + StartEndProp( + start: 0, + end: 3, + fontSize: 20, + fills: [PaintModel.solid(id: 'p', color: ColorRGB.fromHex('#E9EBEE'))], + ), + ], +)..variables.addAll(variables); + +Future paintedColor(WidgetTester tester, TextNode node) async { + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: CodelesslyContext.empty(), + child: MaterialApp( + home: Center( + child: Builder( + builder: (context) => TextUtils.buildTextForTextNode( + context, + node, + variablesOverrides: const [], + nullSubstitutionMode: NullSubstitutionMode.emptyString, + replaceVariablesWithSymbol: false, + ), + ), + ), + ), + ), + ); + final RichText rich = tester.widget(find.byType(RichText)); + Color? color; + rich.text.visitChildren((span) { + if (span is TextSpan && span.text != null) { + color = span.style?.color; + return false; + } + return true; + }); + return color; +} + +void main() { + testWidgets('a textColor formula recolors the text', (tester) async { + final Color? color = await paintedColor( + tester, + reading({'textColor': r'${= 0.87 > 0.75 ? "#FF4D4F" : "#E9EBEE"}'}), + ); + expect(color?.toARGB32(), 0xFFFF4D4F); + }); + + testWidgets('without a binding the text keeps its fill', (tester) async { + final Color? color = await paintedColor(tester, reading({})); + expect(color?.toARGB32(), 0xFFE9EBEE); + }); +} diff --git a/test/text_field_scoped_values_test.dart b/test/text_field_scoped_values_test.dart new file mode 100644 index 00000000..7db733b9 --- /dev/null +++ b/test/text_field_scoped_values_test.dart @@ -0,0 +1,45 @@ +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() { + // A design canvas shows a form field bound to a variable with the + // variable's default value. + testWidgets('a text field shows its bound value from given scoped values', ( + tester, + ) async { + final TextFieldNode node = TextFieldNode( + id: 'name', + name: 'Name', + basicBoxLocal: NodeBox(0, 0, 200, 44), + properties: TextFieldProperties(), + )..variables['inputValue'] = r'${rigName}'; + + await tester.pumpWidget( + ChangeNotifierProvider( + create: (_) => CodelesslyContext.empty(), + child: MaterialApp( + home: Material( + child: PassiveTextFieldWidget( + node: node, + settings: const WidgetBuildSettings(debugLabel: 'test'), + scopedValues: ScopedValues.preview( + variables: { + 'rigName': VariableData( + id: 'rigName', + name: 'rigName', + value: 'Juniper', + ), + }, + ), + ), + ), + ), + ), + ); + + expect(find.text('Juniper'), findsOneWidget); + }); +} diff --git a/test/toast_layout_test.dart b/test/toast_layout_test.dart new file mode 100644 index 00000000..88249f2e --- /dev/null +++ b/test/toast_layout_test.dart @@ -0,0 +1,120 @@ +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'; + +Future show( + WidgetTester tester, + ShowToastAction action, + Size size, +) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = size; + addTearDown(tester.view.reset); + late BuildContext context; + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: CodelesslyContext.empty(), + child: MaterialApp( + home: Builder( + builder: (c) { + context = c; + return const SizedBox(); + }, + ), + ), + ), + ); + FunctionsRepository.triggerAction( + context, + TriggerType.click, + reactions: [ + Reaction( + action: action, + trigger: TriggerModel(type: TriggerType.click), + ), + ], + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + await tester.pump(const Duration(milliseconds: 300)); +} + +Rect toastRect(WidgetTester tester) => tester.getRect( + find + .ancestor(of: find.text('Dump logged'), matching: find.byType(Container)) + .first, +); + +void main() { + testWidgets('an end-aligned toast sits against the right margin', ( + tester, + ) async { + await show( + tester, + ShowToastAction( + message: 'Dump logged', + alignment: ToastAlignment.end, + sideMargin: 24, + maxWidth: 420, + bottomOffset: 24, + cornerRadius: 4, + showCloseButton: true, + fontSize: 13, + duration: 5000, + ), + const Size(1440, 900), + ); + final Rect rect = toastRect(tester); + expect(rect.right, closeTo(1440 - 24, 0.5)); + expect(rect.bottom, closeTo(900 - 24, 0.5)); + expect(rect.width, lessThanOrEqualTo(420)); + expect( + find.byKey(const ValueKey('codelessly-toast-close')), + findsOneWidget, + ); + + await tester.tap(find.byKey(const ValueKey('codelessly-toast-close'))); + await tester.pump(const Duration(milliseconds: 300)); + await tester.pump(const Duration(milliseconds: 300)); + expect(find.text('Dump logged'), findsNothing); + }); + + testWidgets('a stretched toast spans the margins', (tester) async { + await show( + tester, + ShowToastAction( + message: 'Dump logged', + alignment: ToastAlignment.stretch, + bottomOffset: 76, + duration: 1000, + ), + const Size(390, 844), + ); + final Rect rect = toastRect(tester); + expect(rect.left, closeTo(16, 0.5)); + expect(rect.right, closeTo(390 - 16, 0.5)); + expect(rect.bottom, closeTo(844 - 76, 0.5)); + await tester.pump(const Duration(seconds: 2)); + }); + + testWidgets('the button label takes the action color', (tester) async { + await show( + tester, + ShowToastAction( + message: 'Dump logged', + actionLabel: 'UNDO', + actionColor: const ColorRGBA(r: 0.76, g: 0.33, b: 0, a: 1), + duration: 1000, + ), + const Size(390, 844), + ); + final TextButton button = tester.widget(find.byType(TextButton)); + expect( + button.style!.foregroundColor!.resolve({})!.toARGB32(), + const ColorRGBA(r: 0.76, g: 0.33, b: 0, a: 1).toFlutterColor().toARGB32(), + ); + await tester.pump(const Duration(seconds: 2)); + }); +}