diff --git a/CHANGELOG.md b/CHANGELOG.md index 07d5136d..1531a3a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,32 @@ +## 6.3.0 + +### Nodes and layout + +- `ChartNode` (`chart`): a line/area chart with bindable `points`, + `markers` and `selectedX`, grid, labels, ranges, a reference line and a + selection band. +- `ListViewProperties.wrap` and `runSpacing`: a list with a known item + count flows its items onto runs; the gap between items is the separator + spacing. +- `InputDecorationModel.contentPadding`. + +### Bindings + +- `TypedValue` reads `CornerRadius` (a number, `[tl, tr, br, bl]` or its + JSON) and reads `EdgeInsetsModel` from text and one-item lists, for + `cornerRadius` and `padding` bindings. + +### Actions + +- `DialogPresentation.sideSheet`, and `ShowDialogAction.insets` and + `maxHeightFactor`. +- `NavigationAction.destinationId` defaults to `''`, so a pop needs none. +- `SetCloudStorageAction`: `removeDocuments` (ids, where filters or the + whole collection) and `setDocuments` (a list of documents, id field, + merge, replace the collection). +- `ShowToastAction`: `cornerRadius`, `fontSize`, `fontFamily`, `fontWeight`, + `actionColor`, `showClose` and a bindable `offset`. + ## 6.2.0 ### Layout diff --git a/lib/src/api/models/action/navigation_action.dart b/lib/src/api/models/action/navigation_action.dart index b74cd1ba..ff0dd4e6 100644 --- a/lib/src/api/models/action/navigation_action.dart +++ b/lib/src/api/models/action/navigation_action.dart @@ -38,7 +38,8 @@ class NavigationAction extends ActionModel with Equatable, SerializableMixin { /// Defines how the navigation should be performed. final NavigationType navigationType; - /// Destination canvas node's ID. + /// Destination canvas node's ID. Empty for a pop, which has no + /// destination. final String destinationId; /// Parameters to pass to the destination page. @@ -47,7 +48,7 @@ class NavigationAction extends ActionModel with Equatable, SerializableMixin { /// Creates a new [NavigationAction]. NavigationAction({ this.navigationType = NavigationType.push, - required this.destinationId, + this.destinationId = '', Map? params, super.nonBlocking, super.enabled, diff --git a/lib/src/api/models/action/navigation_action.g.dart b/lib/src/api/models/action/navigation_action.g.dart index d486f57f..8eab9575 100644 --- a/lib/src/api/models/action/navigation_action.g.dart +++ b/lib/src/api/models/action/navigation_action.g.dart @@ -12,6 +12,7 @@ Map _$NavigationActionOmitDefaults( ) { if (isJsonDefault(instance.navigationType, NavigationType.push)) json.remove('navigationType'); + if (isJsonDefault(instance.destinationId, '')) json.remove('destinationId'); if (isJsonDefault(instance.nonBlocking, false)) json.remove('nonBlocking'); if (isJsonDefault(instance.enabled, true)) json.remove('enabled'); return json; @@ -25,7 +26,7 @@ NavigationAction _$NavigationActionFromJson(Map json) => NavigationAction( navigationType: $enumDecodeNullable(_$NavigationTypeEnumMap, json['navigationType']) ?? NavigationType.push, - destinationId: json['destinationId'] as String, + destinationId: json['destinationId'] as String? ?? '', params: (json['params'] as Map?)?.map((k, e) => MapEntry(k as String, e)), nonBlocking: json['nonBlocking'] as bool? ?? false, enabled: json['enabled'] as bool? ?? true, diff --git a/lib/src/api/models/action/set_cloud_storage_action.dart b/lib/src/api/models/action/set_cloud_storage_action.dart index aecfbebc..d0748f3d 100644 --- a/lib/src/api/models/action/set_cloud_storage_action.dart +++ b/lib/src/api/models/action/set_cloud_storage_action.dart @@ -15,13 +15,23 @@ enum SetCloudStorageOperation { updateDocument, /// Removes an existing document from the collection. - removeDocument; + removeDocument, + + /// Removes many documents of a collection in one write: all of them, those + /// matching where filters, or those with the given ids. + removeDocuments, + + /// Writes a list of documents to a collection in one write, e.g. restoring + /// seed data from a list variable. + setDocuments; /// Displayable string representation of the [StorageOperation]. String get label => switch (this) { SetCloudStorageOperation.addDocument => 'Add Document', SetCloudStorageOperation.updateDocument => 'Update Document', SetCloudStorageOperation.removeDocument => 'Remove Document', + SetCloudStorageOperation.removeDocuments => 'Remove Documents', + SetCloudStorageOperation.setDocuments => 'Set Documents', }; /// Whether this [StorageOperation] is [addDocument]. @@ -32,6 +42,15 @@ enum SetCloudStorageOperation { /// Whether this [StorageOperation] is [removeDocument]. bool get isRemoveDocument => this == removeDocument; + + /// Whether this [StorageOperation] is [removeDocuments]. + bool get isRemoveDocuments => this == removeDocuments; + + /// Whether this [StorageOperation] is [setDocuments]. + bool get isSetDocuments => this == setDocuments; + + /// Whether this operation writes many documents at once. + bool get isBatch => this == removeDocuments || this == setDocuments; } /// An action that changes data in cloud storage. @@ -39,7 +58,8 @@ enum SetCloudStorageOperation { class SetCloudStorageAction extends ActionModel with Equatable, SerializableMixin { /// The sub action to perform. This can be one of [AddDocumentSubAction], - /// [UpdateDocumentSubAction] or [RemoveDocumentSubAction]. + /// [UpdateDocumentSubAction], [RemoveDocumentSubAction], + /// [RemoveDocumentsSubAction] or [SetDocumentsSubAction]. final SetCloudStorageSubAction subAction; /// Creates a new [SetCloudStorageAction]. @@ -91,6 +111,11 @@ sealed class SetCloudStorageSubAction with Equatable, SerializableMixin { UpdateDocumentSubAction.fromJson(json), SetCloudStorageOperation.removeDocument => RemoveDocumentSubAction.fromJson(json), + SetCloudStorageOperation.removeDocuments => + RemoveDocumentsSubAction.fromJson(json), + SetCloudStorageOperation.setDocuments => SetDocumentsSubAction.fromJson( + json, + ), }; } } @@ -380,3 +405,125 @@ class UpdateDocumentSubAction extends SetCloudStorageSubAction numberOperation, ]; } + +/// Represents a sub action that removes many documents of a collection in +/// cloud storage in one write (Firestore batches of up to 500 deletes). +/// +/// Removes the documents whose ids [documentIds] gives, or, when it is +/// empty, the documents matching [whereFilters]: every document of the +/// collection when there are none. In a collection only the owner can read, +/// only the signed-in user's documents are removed. +@JsonSerializable() +class RemoveDocumentsSubAction extends SetCloudStorageSubAction { + /// Path to the collection, as in [RemoveDocumentSubAction.path]. + final String path; + + /// The ids of the documents to remove: a `${...}` path or formula giving a + /// list of ids (or of documents, whose `id` is used), a JSON list, or ids + /// separated by commas. Empty to remove by [whereFilters] instead. + final String documentIds; + + /// The filters documents must pass to be removed when [documentIds] is + /// empty. No filters removes every document of the collection. + @JsonKey(defaultValue: []) + final List whereFilters; + + /// Creates a new [RemoveDocumentsSubAction]. + const RemoveDocumentsSubAction({ + this.path = '', + this.documentIds = '', + this.whereFilters = const [], + }) : super(operation: SetCloudStorageOperation.removeDocuments); + + /// Duplicates this [RemoveDocumentsSubAction] with given data overrides. + RemoveDocumentsSubAction copyWith({ + String? path, + String? documentIds, + List? whereFilters, + }) => RemoveDocumentsSubAction( + path: path ?? this.path, + documentIds: documentIds ?? this.documentIds, + whereFilters: whereFilters ?? this.whereFilters, + ); + + /// Creates a new [RemoveDocumentsSubAction] instance from a JSON data. + factory RemoveDocumentsSubAction.fromJson(Map json) => + _$RemoveDocumentsSubActionFromJson(json); + + @override + Map toJson() => _$RemoveDocumentsSubActionOmitDefaults( + this, + _$RemoveDocumentsSubActionToJson(this), + )..['operation'] = operation.name; + + @override + List get props => [path, documentIds, whereFilters]; +} + +/// Represents a sub action that writes a list of documents to a collection +/// in cloud storage in one write (Firestore batches of up to 500 writes). +@JsonSerializable() +class SetDocumentsSubAction extends SetCloudStorageSubAction { + /// Path to the collection, as in [AddDocumentSubAction.path]. + final String path; + + /// The documents to write: a `${...}` path or formula giving a list of + /// maps, or a JSON list of objects (which may hold `${...}` paths). + final String documents; + + /// The field of each document that holds its id. A document without one + /// gets a generated id. + final String idField; + + /// Whether to merge each document into the stored one instead of + /// replacing it. + final bool merge; + + /// Whether to also remove the collection's documents that are not in the + /// list, so the collection holds exactly [documents] (restoring seed + /// data). + final bool replaceCollection; + + /// Creates a new [SetDocumentsSubAction]. + const SetDocumentsSubAction({ + this.path = '', + this.documents = '', + this.idField = 'id', + this.merge = false, + this.replaceCollection = false, + }) : super(operation: SetCloudStorageOperation.setDocuments); + + /// Duplicates this [SetDocumentsSubAction] with given data overrides. + SetDocumentsSubAction copyWith({ + String? path, + String? documents, + String? idField, + bool? merge, + bool? replaceCollection, + }) => SetDocumentsSubAction( + path: path ?? this.path, + documents: documents ?? this.documents, + idField: idField ?? this.idField, + merge: merge ?? this.merge, + replaceCollection: replaceCollection ?? this.replaceCollection, + ); + + /// Creates a new [SetDocumentsSubAction] instance from a JSON data. + factory SetDocumentsSubAction.fromJson(Map json) => + _$SetDocumentsSubActionFromJson(json); + + @override + Map toJson() => _$SetDocumentsSubActionOmitDefaults( + this, + _$SetDocumentsSubActionToJson(this), + )..['operation'] = operation.name; + + @override + List get props => [ + path, + documents, + idField, + merge, + replaceCollection, + ]; +} diff --git a/lib/src/api/models/action/set_cloud_storage_action.g.dart b/lib/src/api/models/action/set_cloud_storage_action.g.dart index a07f4a8d..8dded25b 100644 --- a/lib/src/api/models/action/set_cloud_storage_action.g.dart +++ b/lib/src/api/models/action/set_cloud_storage_action.g.dart @@ -64,6 +64,29 @@ Map _$UpdateDocumentSubActionOmitDefaults( return json; } +Map _$RemoveDocumentsSubActionOmitDefaults( + RemoveDocumentsSubAction instance, + Map json, +) { + if (isJsonDefault(instance.path, '')) json.remove('path'); + if (isJsonDefault(instance.documentIds, '')) json.remove('documentIds'); + if (isJsonDefault(instance.whereFilters, [])) json.remove('whereFilters'); + return json; +} + +Map _$SetDocumentsSubActionOmitDefaults( + SetDocumentsSubAction instance, + Map json, +) { + if (isJsonDefault(instance.path, '')) json.remove('path'); + if (isJsonDefault(instance.documents, '')) json.remove('documents'); + if (isJsonDefault(instance.idField, 'id')) json.remove('idField'); + if (isJsonDefault(instance.merge, false)) json.remove('merge'); + if (isJsonDefault(instance.replaceCollection, false)) + json.remove('replaceCollection'); + return json; +} + // ************************************************************************** // JsonSerializableGenerator // ************************************************************************** @@ -227,3 +250,45 @@ const _$NumberOperationEnumMap = { NumberOperation.add: 'add', NumberOperation.subtract: 'subtract', }; + +RemoveDocumentsSubAction _$RemoveDocumentsSubActionFromJson(Map json) => + RemoveDocumentsSubAction( + path: json['path'] as String? ?? '', + documentIds: json['documentIds'] as String? ?? '', + whereFilters: + (json['whereFilters'] as List?) + ?.map( + (e) => WhereQueryFilter.fromJson( + Map.from(e as Map), + ), + ) + .toList() ?? + [], + ); + +Map _$RemoveDocumentsSubActionToJson( + RemoveDocumentsSubAction instance, +) => { + 'path': instance.path, + 'documentIds': instance.documentIds, + 'whereFilters': instance.whereFilters.map((e) => e.toJson()).toList(), +}; + +SetDocumentsSubAction _$SetDocumentsSubActionFromJson(Map json) => + SetDocumentsSubAction( + path: json['path'] as String? ?? '', + documents: json['documents'] as String? ?? '', + idField: json['idField'] as String? ?? 'id', + merge: json['merge'] as bool? ?? false, + replaceCollection: json['replaceCollection'] as bool? ?? false, + ); + +Map _$SetDocumentsSubActionToJson( + SetDocumentsSubAction instance, +) => { + 'path': instance.path, + 'documents': instance.documents, + 'idField': instance.idField, + 'merge': instance.merge, + 'replaceCollection': instance.replaceCollection, +}; diff --git a/lib/src/api/models/action/show_dialog_action.dart b/lib/src/api/models/action/show_dialog_action.dart index e4cfed84..9e5bcbce 100644 --- a/lib/src/api/models/action/show_dialog_action.dart +++ b/lib/src/api/models/action/show_dialog_action.dart @@ -30,9 +30,19 @@ class ShowDialogAction extends ActionModel with Equatable, SerializableMixin { final DialogPresentation presentation; /// Where a dialog sits on the screen, e.g. top right for a menu. Center by - /// default. Bottom sheets always sit at the bottom. + /// default. Bottom sheets always sit at the bottom and side sheets at the + /// right edge. final AlignmentModel alignment; + /// Space kept between the overlay and the screen edges: a dialog placed + /// in a corner stays this far from it, a side sheet is inset by it. + final EdgeInsetsModel insets; + + /// The most of the screen height a dialog or bottom sheet may take, as a + /// share of it (e.g. 0.9); its content scrolls beyond. Null keeps the + /// canvas's design height (a bottom sheet's own cap applies). + final double? maxHeightFactor; + /// Creates a new [ShowDialogAction]. ShowDialogAction({ required this.destinationId, @@ -42,6 +52,8 @@ class ShowDialogAction extends ActionModel with Equatable, SerializableMixin { this.showCloseButton = false, this.presentation = DialogPresentation.dialog, this.alignment = AlignmentModel.center, + this.insets = EdgeInsetsModel.zero, + this.maxHeightFactor, super.nonBlocking, super.enabled, }) : params = {...params ?? {}}, @@ -56,6 +68,8 @@ class ShowDialogAction extends ActionModel with Equatable, SerializableMixin { showCloseButton, presentation, alignment, + insets, + maxHeightFactor, ]; /// Duplicates this [ShowDialogAction] with given data overrides. @@ -67,6 +81,9 @@ class ShowDialogAction extends ActionModel with Equatable, SerializableMixin { bool? showCloseButton, DialogPresentation? presentation, AlignmentModel? alignment, + EdgeInsetsModel? insets, + double? maxHeightFactor, + bool clearMaxHeightFactor = false, }) => ShowDialogAction( destinationId: destinationId ?? this.destinationId, params: {...this.params, ...params ?? {}}, @@ -75,6 +92,12 @@ class ShowDialogAction extends ActionModel with Equatable, SerializableMixin { showCloseButton: showCloseButton ?? this.showCloseButton, presentation: presentation ?? this.presentation, alignment: alignment ?? this.alignment, + insets: insets ?? this.insets, + maxHeightFactor: clearMaxHeightFactor + ? null + : maxHeightFactor ?? this.maxHeightFactor, + nonBlocking: nonBlocking, + enabled: enabled, ); /// Creates a new [ShowDialogAction] instance from a JSON data. @@ -99,4 +122,8 @@ enum DialogPresentation { /// A bottom sheet on phones (narrower than 600 px), a dialog otherwise. adaptive, + + /// A sheet that slides in from the right edge, as tall as the screen less + /// the insets (or as its content when the canvas hugs its height). + sideSheet, } diff --git a/lib/src/api/models/action/show_dialog_action.g.dart b/lib/src/api/models/action/show_dialog_action.g.dart index 45efe467..c579f232 100644 --- a/lib/src/api/models/action/show_dialog_action.g.dart +++ b/lib/src/api/models/action/show_dialog_action.g.dart @@ -20,6 +20,8 @@ Map _$ShowDialogActionOmitDefaults( json.remove('presentation'); if (isJsonDefault(instance.alignment, AlignmentModel.center)) json.remove('alignment'); + if (isJsonDefault(instance.insets, EdgeInsetsModel.zero)) + json.remove('insets'); if (isJsonDefault(instance.nonBlocking, false)) json.remove('nonBlocking'); if (isJsonDefault(instance.enabled, true)) json.remove('enabled'); return json; @@ -49,6 +51,10 @@ ShowDialogAction _$ShowDialogActionFromJson(Map json) => ShowDialogAction( alignment: json['alignment'] == null ? AlignmentModel.center : AlignmentModel.fromJson(json['alignment'] as Map), + insets: json['insets'] == null + ? EdgeInsetsModel.zero + : EdgeInsetsModel.fromJson(json['insets']), + maxHeightFactor: (json['maxHeightFactor'] as num?)?.toDouble(), nonBlocking: json['nonBlocking'] as bool? ?? false, enabled: json['enabled'] as bool? ?? true, )..type = $enumDecode(_$ActionTypeEnumMap, json['type']); @@ -65,12 +71,15 @@ Map _$ShowDialogActionToJson(ShowDialogAction instance) => 'showCloseButton': instance.showCloseButton, 'presentation': _$DialogPresentationEnumMap[instance.presentation]!, 'alignment': instance.alignment.toJson(), + 'insets': instance.insets.toJson(), + 'maxHeightFactor': ?instance.maxHeightFactor, }; const _$DialogPresentationEnumMap = { DialogPresentation.dialog: 'dialog', DialogPresentation.bottomSheet: 'bottomSheet', DialogPresentation.adaptive: 'adaptive', + DialogPresentation.sideSheet: 'sideSheet', }; const _$ActionTypeEnumMap = { diff --git a/lib/src/api/models/action/show_toast_action.dart b/lib/src/api/models/action/show_toast_action.dart index a7deaf5a..3c60de4b 100644 --- a/lib/src/api/models/action/show_toast_action.dart +++ b/lib/src/api/models/action/show_toast_action.dart @@ -53,7 +53,9 @@ enum ToastPosition { /// [actions]. /// /// Its colors come from [style] and the app's theme unless [backgroundColor] -/// or [textColor] are set. Its font is the app's. +/// or [textColor] are set. Its font is the app's unless [fontFamily] or +/// [fontWeight] are set. It can show a close (✕) +/// button ([showClose]). @JsonSerializable() class ShowToastAction extends ActionModel with Equatable, SerializableMixin { /// The message. Supports variables and formulas, e.g. @@ -91,6 +93,34 @@ class ShowToastAction extends ActionModel with Equatable, SerializableMixin { /// [ToastPosition.bottom]. final double bottomOffset; + /// Distance from the screen edge the toast shows at, bound to a variable + /// or formula, e.g. `${= screen.isMobile ? 96 : 24}` to sit above a + /// bottom bar on phones. Overrides [bottomOffset] (and the top inset when + /// [position] is [ToastPosition.top]) when it gives a number. Empty to use + /// [bottomOffset]. + final String offset; + + /// Color of the button's label, e.g. an orange Undo. Null takes the + /// message color. + final ColorRGBA? actionColor; + + /// Whether the toast shows a close (✕) button that hides it. + final bool showClose; + + /// Radius of the toast's corners. Null makes a pill, or follows the app's + /// button radius when the toast has a button. + final double? cornerRadius; + + /// Size of the message's text. Null is 14. + final double? fontSize; + + /// Font family of the message, e.g. a Google or project font. Null is + /// the app's. + final String? fontFamily; + + /// Weight of the message's text. Null is the app's. + final FontWeightNumeric? fontWeight; + /// Creates a new [ShowToastAction]. ShowToastAction({ required this.message, @@ -103,6 +133,13 @@ class ShowToastAction extends ActionModel with Equatable, SerializableMixin { List? actions, this.icon, this.bottomOffset = 104, + this.offset = '', + this.actionColor, + this.showClose = false, + this.cornerRadius, + this.fontSize, + this.fontFamily, + this.fontWeight, super.nonBlocking, super.enabled, }) : actions = actions ?? [], @@ -123,6 +160,13 @@ class ShowToastAction extends ActionModel with Equatable, SerializableMixin { actions, icon, bottomOffset, + offset, + actionColor, + showClose, + cornerRadius, + fontSize, + fontFamily, + fontWeight, ]; /// Duplicates this [ShowToastAction] with given data overrides. @@ -140,6 +184,18 @@ class ShowToastAction extends ActionModel with Equatable, SerializableMixin { MultiSourceIconModel? icon, bool clearIcon = false, double? bottomOffset, + String? offset, + ColorRGBA? actionColor, + bool clearActionColor = false, + bool? showClose, + double? cornerRadius, + bool clearCornerRadius = false, + double? fontSize, + bool clearFontSize = false, + String? fontFamily, + bool clearFontFamily = false, + FontWeightNumeric? fontWeight, + bool clearFontWeight = false, }) => ShowToastAction( message: message ?? this.message, duration: duration ?? this.duration, @@ -153,6 +209,13 @@ class ShowToastAction extends ActionModel with Equatable, SerializableMixin { actions: actions ?? this.actions, icon: clearIcon ? null : icon ?? this.icon, bottomOffset: bottomOffset ?? this.bottomOffset, + offset: offset ?? this.offset, + actionColor: clearActionColor ? null : actionColor ?? this.actionColor, + showClose: showClose ?? this.showClose, + cornerRadius: clearCornerRadius ? null : cornerRadius ?? this.cornerRadius, + fontSize: clearFontSize ? null : fontSize ?? this.fontSize, + fontFamily: clearFontFamily ? null : fontFamily ?? this.fontFamily, + fontWeight: clearFontWeight ? null : fontWeight ?? this.fontWeight, nonBlocking: nonBlocking, enabled: enabled, ); diff --git a/lib/src/api/models/action/show_toast_action.g.dart b/lib/src/api/models/action/show_toast_action.g.dart index 684de2c2..8db7f5e9 100644 --- a/lib/src/api/models/action/show_toast_action.g.dart +++ b/lib/src/api/models/action/show_toast_action.g.dart @@ -17,6 +17,8 @@ Map _$ShowToastActionOmitDefaults( if (isJsonDefault(instance.actionLabel, '')) json.remove('actionLabel'); if (isJsonDefault(instance.actions, [])) json.remove('actions'); if (isJsonDefault(instance.bottomOffset, 104)) json.remove('bottomOffset'); + if (isJsonDefault(instance.offset, '')) json.remove('offset'); + if (isJsonDefault(instance.showClose, false)) json.remove('showClose'); if (isJsonDefault(instance.nonBlocking, false)) json.remove('nonBlocking'); if (isJsonDefault(instance.enabled, true)) json.remove('enabled'); return json; @@ -57,6 +59,18 @@ ShowToastAction _$ShowToastActionFromJson(Map json) => ShowToastAction( ? null : MultiSourceIconModel.fromJson(json['icon'] as Map), bottomOffset: (json['bottomOffset'] as num?)?.toDouble() ?? 104, + offset: json['offset'] as String? ?? '', + actionColor: json['actionColor'] == null + ? null + : ColorRGBA.fromJson(json['actionColor']), + showClose: json['showClose'] as bool? ?? false, + cornerRadius: (json['cornerRadius'] as num?)?.toDouble(), + fontSize: (json['fontSize'] as num?)?.toDouble(), + fontFamily: json['fontFamily'] as String?, + fontWeight: $enumDecodeNullable( + _$FontWeightNumericEnumMap, + json['fontWeight'], + ), nonBlocking: json['nonBlocking'] as bool? ?? false, enabled: json['enabled'] as bool? ?? true, )..type = $enumDecode(_$ActionTypeEnumMap, json['type']); @@ -76,6 +90,13 @@ Map _$ShowToastActionToJson(ShowToastAction instance) => 'actions': instance.actions.map((e) => e.toJson()).toList(), 'icon': ?instance.icon?.toJson(), 'bottomOffset': instance.bottomOffset, + 'offset': instance.offset, + 'actionColor': ?instance.actionColor?.toJson(), + 'showClose': instance.showClose, + 'cornerRadius': ?instance.cornerRadius, + 'fontSize': ?instance.fontSize, + 'fontFamily': ?instance.fontFamily, + 'fontWeight': ?_$FontWeightNumericEnumMap[instance.fontWeight], }; const _$ToastStyleEnumMap = { @@ -91,6 +112,18 @@ const _$ToastPositionEnumMap = { ToastPosition.top: 'top', }; +const _$FontWeightNumericEnumMap = { + FontWeightNumeric.w100: 'w100', + FontWeightNumeric.w200: 'w200', + FontWeightNumeric.w300: 'w300', + FontWeightNumeric.w400: 'w400', + FontWeightNumeric.w500: 'w500', + FontWeightNumeric.w600: 'w600', + FontWeightNumeric.w700: 'w700', + FontWeightNumeric.w800: 'w800', + FontWeightNumeric.w900: 'w900', +}; + const _$ActionTypeEnumMap = { ActionType.navigation: 'navigation', ActionType.showDialog: 'showDialog', diff --git a/lib/src/api/models/input_decoration.dart b/lib/src/api/models/input_decoration.dart index 6a3f66f2..0460e8aa 100644 --- a/lib/src/api/models/input_decoration.dart +++ b/lib/src/api/models/input_decoration.dart @@ -174,6 +174,12 @@ class InputDecorationModel with Equatable, SerializableMixin { /// Whether to show counter text. final bool showCounter; + /// The padding around the text inside the field, between it and the + /// field's border or edge. Null uses the node's padding, as before; zero + /// puts the text flush with the field's edges (with [isCollapsed] and no + /// border, a bare text input). + final EdgeInsetsModel? contentPadding; + /// Creates [InputDecorationModel] with given data. InputDecorationModel({ this.icon = const MultiSourceIconModel(), @@ -227,6 +233,7 @@ class InputDecorationModel with Equatable, SerializableMixin { this.semanticCounterText, this.alignLabelWithHint = true, this.constraints = const BoxConstraintsModel(), + this.contentPadding, }) : labelStyle = labelStyle ?? TextProp(fontSize: 14), floatingLabelStyle = floatingLabelStyle ?? @@ -298,6 +305,8 @@ class InputDecorationModel with Equatable, SerializableMixin { bool? alignLabelWithHint, BoxConstraintsModel? constraints, bool? showCounter, + EdgeInsetsModel? contentPadding, + bool clearContentPadding = false, bool forceCounterText = false, }) => InputDecorationModel( icon: icon ?? this.icon, @@ -343,6 +352,9 @@ class InputDecorationModel with Equatable, SerializableMixin { alignLabelWithHint: alignLabelWithHint ?? this.alignLabelWithHint, constraints: constraints ?? this.constraints, showCounter: showCounter ?? this.showCounter, + contentPadding: clearContentPadding + ? null + : contentPadding ?? this.contentPadding, ); @override @@ -388,6 +400,7 @@ class InputDecorationModel with Equatable, SerializableMixin { alignLabelWithHint, constraints, showCounter, + contentPadding, ]; /// Factory constructor for creating a new [InputDecorationModel] instance diff --git a/lib/src/api/models/input_decoration.g.dart b/lib/src/api/models/input_decoration.g.dart index 4239d61f..b75d90e9 100644 --- a/lib/src/api/models/input_decoration.g.dart +++ b/lib/src/api/models/input_decoration.g.dart @@ -185,6 +185,9 @@ InputDecorationModel _$InputDecorationModelFromJson(Map json) => constraints: json['constraints'] == null ? const BoxConstraintsModel() : BoxConstraintsModel.fromJson(json['constraints'] as Map), + contentPadding: json['contentPadding'] == null + ? null + : EdgeInsetsModel.fromJson(json['contentPadding']), ); Map _$InputDecorationModelToJson( @@ -232,6 +235,7 @@ Map _$InputDecorationModelToJson( 'alignLabelWithHint': instance.alignLabelWithHint, 'constraints': instance.constraints.toJson(), 'showCounter': instance.showCounter, + 'contentPadding': ?instance.contentPadding?.toJson(), }; const _$FloatingLabelBehaviorEnumEnumMap = { diff --git a/lib/src/api/node_json_converter.dart b/lib/src/api/node_json_converter.dart index 311acd7c..a91a4e29 100644 --- a/lib/src/api/node_json_converter.dart +++ b/lib/src/api/node_json_converter.dart @@ -66,6 +66,7 @@ class NodeJsonConverter implements JsonConverter { 'webView': WebViewNode.fromJson, 'dropdown': DropdownNode.fromJson, 'progressBar': ProgressBarNode.fromJson, + 'chart': ChartNode.fromJson, 'variance': VarianceNode.fromJson, 'listView': ListViewNode.fromJson, 'gridView': GridViewNode.fromJson, diff --git a/lib/src/api/nodes/chart_node.dart b/lib/src/api/nodes/chart_node.dart new file mode 100644 index 00000000..006299e8 --- /dev/null +++ b/lib/src/api/nodes/chart_node.dart @@ -0,0 +1,671 @@ +import 'package:equatable/equatable.dart'; +import 'package:json_annotation/json_annotation.dart'; + +import '../field_access.dart'; +import '../mixins.dart'; +import '../models/models.dart'; +import 'nodes.dart'; + +part 'chart_node.g.dart'; + +/// Default width of a chart. +const double kChartDefaultWidth = 320; + +/// Default height of a chart. +const double kChartDefaultHeight = 140; + +/// Default color of a chart's line. +const ColorRGBA kChartDefaultLineColor = ColorRGBA( + r: 0x1F / 255, + g: 0x6F / 255, + b: 0x78 / 255, + a: 1, +); + +/// Fill color of the sample chart. +const ColorRGBA kChartSampleFillColor = ColorRGBA( + r: 0x1F / 255, + g: 0x6F / 255, + b: 0x78 / 255, + a: 0x26 / 255, +); + +/// Default color of a chart's reference line. +const ColorRGBA kChartDefaultReferenceColor = ColorRGBA( + r: 0xF2 / 255, + g: 0x54 / 255, + b: 0x2D / 255, + a: 1, +); + +/// A line/area chart that plots [ChartProperties.points] evenly across an x +/// domain and lets the user select an x value by tapping or dragging on it. +/// +/// `variables['points']`, `variables['markers']` and `variables['selectedX']` +/// bind the data, the markers and the selection to variables. Selecting an +/// x value writes it to the bound `selectedX` variable and fires +/// [TriggerType.changed] with it. +@JsonSerializable() +class ChartNode extends SceneNode with CustomPropertiesMixin { + @override + final String type = 'chart'; + + /// Holds configurable properties of the chart. + @override + covariant ChartProperties properties; + + /// The selected x value: the start of the selection band, which is + /// [ChartProperties.selectionStep] wide. Null shows no selection. + double? selectedX; + + /// Creates a chart with sample data, for previews. + ChartNode.empty() + : properties = ChartProperties.sample(), + selectedX = null, + super( + id: 'chart', + name: 'Chart', + basicBoxLocal: NodeBox(0, 0, kChartDefaultWidth, kChartDefaultHeight), + ); + + /// Creates a chart from the given properties, for previews. + ChartNode.fromProperties(this.properties) + : selectedX = null, + super( + id: 'chart', + name: 'Chart', + basicBoxLocal: NodeBox(0, 0, kChartDefaultWidth, kChartDefaultHeight), + ); + + /// Creates a [ChartNode] with the given data. + ChartNode({ + required super.id, + required super.name, + required super.basicBoxLocal, + super.outerBoxLocal, + super.retainedOuterBoxLocal, + super.visible, + super.enabled, + super.alignment, + super.padding, + super.margin, + super.rotationDegrees, + super.reactions, + super.horizontalFit, + super.verticalFit, + super.flex, + super.constraints, + super.aspectRatioLock, + super.edgePins, + super.parentID, + super.positioningMode, + // Custom properties. + required this.properties, + this.selectedX, + super.variables, + super.multipleVariables, + }); + + @override + bool get supportsPadding => false; + + @override + FieldsMap generateFields() => { + ...super.generateFields(), + 'properties': ObjectFieldAccess( + 'Properties', + 'Properties of the chart.', + () => properties, + (value) => properties = value, + ), + }; + + @override + List get triggerTypes => [TriggerType.changed]; + + @override + late final List propertyVariables = [ + ...super.propertyVariables, + if (selectedX case final double selectedX) + DoubleValue(name: 'selectedX', value: selectedX), + ]; + + @override + List get props => [ + ...super.props, + properties, + selectedX, + variables, + multipleVariables, + ]; + + /// Creates a [ChartNode] from JSON data. + factory ChartNode.fromJson(Map json) => _$ChartNodeFromJson(json); + + @override + Map toJson() => _$ChartNodeOmitDefaults(this, _$ChartNodeToJson(this)); +} + +/// Where a marker's label sits relative to its dot. +enum ChartMarkerPosition { + /// Above the dot. + above, + + /// Below the dot. + below; + + /// Displayable string representation. + String get prettify => switch (this) { + ChartMarkerPosition.above => 'Above', + ChartMarkerPosition.below => 'Below', + }; +} + +/// The style of a chart's text. +@JsonSerializable() +class ChartTextStyle extends Equatable with SerializableMixin { + /// Font size. + final double fontSize; + + /// Text color. Markers paint their label in their own color instead. + final ColorRGBA color; + + /// Font family. Null uses the app's default font. + final String? fontFamily; + + /// Font weight, from 100 to 900. + final int fontWeight; + + /// Line height as a multiple of [fontSize]. Null uses the font's own. + final double? height; + + /// Creates a [ChartTextStyle] with the given data. + const ChartTextStyle({ + this.fontSize = 10, + this.color = ColorRGBA.black54, + this.fontFamily, + this.fontWeight = 400, + this.height, + }); + + /// Duplicates this [ChartTextStyle] with the given data overrides. + ChartTextStyle copyWith({ + double? fontSize, + ColorRGBA? color, + String? fontFamily, + int? fontWeight, + double? height, + }) => ChartTextStyle( + fontSize: fontSize ?? this.fontSize, + color: color ?? this.color, + fontFamily: fontFamily ?? this.fontFamily, + fontWeight: fontWeight ?? this.fontWeight, + height: height ?? this.height, + ); + + @override + List get props => [fontSize, color, fontFamily, fontWeight, height]; + + /// Creates a [ChartTextStyle] from JSON data. + factory ChartTextStyle.fromJson(Map json) => _$ChartTextStyleFromJson(json); + + @override + Map toJson() => + _$ChartTextStyleOmitDefaults(this, _$ChartTextStyleToJson(this)); +} + +/// A label on a chart's x axis. +@JsonSerializable() +class ChartLabel extends Equatable with SerializableMixin { + /// The x value the label is centered on. + final double x; + + /// The text of the label. + final String label; + + /// Creates a [ChartLabel] with the given data. + const ChartLabel({required this.x, required this.label}); + + @override + List get props => [x, label]; + + /// Creates a [ChartLabel] from JSON data. + factory ChartLabel.fromJson(Map json) => _$ChartLabelFromJson(json); + + @override + Map toJson() => _$ChartLabelOmitDefaults(this, _$ChartLabelToJson(this)); +} + +/// A shaded x range of a chart, painted behind the curve. +@JsonSerializable() +class ChartRange extends Equatable with SerializableMixin { + /// Where the range starts on the x axis. + final double from; + + /// Where the range ends on the x axis. + final double to; + + /// Color of the range. + final ColorRGBA color; + + /// Creates a [ChartRange] with the given data. + const ChartRange({ + required this.from, + required this.to, + this.color = ColorRGBA.black10, + }); + + @override + List get props => [from, to, color]; + + /// Creates a [ChartRange] from JSON data. + factory ChartRange.fromJson(Map json) => _$ChartRangeFromJson(json); + + @override + Map toJson() => _$ChartRangeOmitDefaults(this, _$ChartRangeToJson(this)); +} + +/// A labeled dot on a chart. +@JsonSerializable() +class ChartMarker extends Equatable with SerializableMixin { + /// The x value of the dot. + final double x; + + /// The y value of the dot. Null puts the dot on the curve. + final double? y; + + /// Text shown next to the dot. + final String label; + + /// Color of the dot and its label. + final ColorRGBA color; + + /// Where the label sits relative to the dot. + final ChartMarkerPosition position; + + /// Creates a [ChartMarker] with the given data. + const ChartMarker({ + required this.x, + this.y, + this.label = '', + this.color = ColorRGBA.black, + this.position = ChartMarkerPosition.above, + }); + + @override + List get props => [x, y, label, color, position]; + + /// Creates a [ChartMarker] from JSON data. + factory ChartMarker.fromJson(Map json) => _$ChartMarkerFromJson(json); + + @override + Map toJson() => _$ChartMarkerOmitDefaults(this, _$ChartMarkerToJson(this)); +} + +/// Holds configurable properties of a [ChartNode]. +@JsonSerializable() +class ChartProperties extends CustomProperties with FieldsHolder { + /// The y values, spread evenly from [xMin] to [xMax]. + List points; + + /// The x value of the first point. + double xMin; + + /// The x value of the last point. + double xMax; + + /// The y value at the bottom of the plot. Null uses the smallest point. + double? yMin; + + /// The y value at the top of the plot. Null uses the largest point. + double? yMax; + + /// Space kept above [yMax] and below [yMin] inside the plot, in pixels. + double plotInset; + + /// Color of the line. + ColorRGBA lineColor; + + /// Width of the line. Zero draws no line. + double lineWidth; + + /// Color of the area under the line. Null draws no area. + ColorRGBA? fillColor; + + /// Whether to draw the line as a smooth curve instead of straight segments. + bool smooth; + + /// Draws a vertical gridline every [gridStepX] x units, starting at [xMin]. + /// Null draws no grid. + double? gridStepX; + + /// Color of the gridlines. + ColorRGBA gridColor; + + /// Color of the line along the bottom of the plot. Null draws none. + ColorRGBA? axisColor; + + /// Labels along the bottom of the chart. When empty, the gridlines + /// between [xMin] and [xMax] are labeled with [gridLabels] in order, or + /// with their x value. + List xLabels; + + /// Labels for the gridlines between [xMin] and [xMax], in order, used when + /// [xLabels] is empty. + List gridLabels; + + /// Style of the axis labels. + ChartTextStyle labelStyle; + + /// Height of the strip under the plot that holds the axis labels. Zero + /// shows no labels. + double labelHeight; + + /// Shaded x ranges painted behind the curve. + List ranges; + + /// Labeled dots on the curve. + List markers; + + /// Radius of the marker dots. + double markerRadius; + + /// Style of the marker labels. Each label takes its marker's color. + ChartTextStyle markerLabelStyle; + + /// The x value of a vertical reference line, e.g. now. Null draws none. + double? referenceX; + + /// Color of the reference line. + ColorRGBA referenceColor; + + /// Width of the reference line. + double referenceWidth; + + /// Whether the reference line is dashed. + bool referenceDashed; + + /// Whether tapping or dragging on the chart selects an x value. + bool selectable; + + /// Width of the selection band, and the step selections snap to from + /// [xMin]. Zero selects any x value. + double selectionStep; + + /// The smallest x value that can be selected. Null allows [xMin]. + double? selectionMin; + + /// The largest x value that can be selected. Null allows the last step + /// before [xMax]. + double? selectionMax; + + /// Color of the selection band. + ColorRGBA selectionColor; + + /// Color of the vertical line through the middle of the selection. + ColorRGBA selectionLineColor; + + /// Width of the selection line. Zero draws no line. + double selectionLineWidth; + + /// Radius of the dot on the curve in the middle of the selection. Zero + /// draws no dot. + double selectionDotRadius; + + /// Fill color of the selection dot. + ColorRGBA selectionDotColor; + + /// Outline color of the selection dot. + ColorRGBA selectionDotStrokeColor; + + /// Outline width of the selection dot. + double selectionDotStrokeWidth; + + /// Creates a [ChartProperties] with the given data. + ChartProperties({ + this.points = const [], + this.xMin = 0, + this.xMax = 1, + this.yMin, + this.yMax, + this.plotInset = 8, + this.lineColor = kChartDefaultLineColor, + this.lineWidth = 2, + this.fillColor, + this.smooth = false, + this.gridStepX, + this.gridColor = ColorRGBA.black10, + this.axisColor, + this.xLabels = const [], + this.gridLabels = const [], + this.labelStyle = const ChartTextStyle(), + this.labelHeight = 16, + this.ranges = const [], + this.markers = const [], + this.markerRadius = 3.5, + this.markerLabelStyle = const ChartTextStyle(fontWeight: 700), + this.referenceX, + this.referenceColor = kChartDefaultReferenceColor, + this.referenceWidth = 1.5, + this.referenceDashed = true, + this.selectable = true, + this.selectionStep = 1, + this.selectionMin, + this.selectionMax, + this.selectionColor = ColorRGBA.black10, + this.selectionLineColor = ColorRGBA.black, + this.selectionLineWidth = 1.5, + this.selectionDotRadius = 6.5, + this.selectionDotColor = ColorRGBA.white, + this.selectionDotStrokeColor = ColorRGBA.black, + this.selectionDotStrokeWidth = 2.5, + }); + + /// A chart with a sample wave, so a new chart looks like one. + factory ChartProperties.sample() => ChartProperties( + points: const [ + 2, 2.6, 3.4, 4.2, 4.7, 4.8, 4.4, 3.7, 2.8, 2.0, 1.4, 1.1, 1.2, // + 1.7, 2.5, 3.3, 4.0, 4.3, 4.2, 3.7, 3.0, 2.3, 1.8, 1.6, 1.8, + ], + xMin: 0, + xMax: 24, + yMin: 0, + yMax: 5.5, + fillColor: kChartSampleFillColor, + gridStepX: 6, + gridLabels: const ['6a', '12p', '6p'], + selectionStep: 1, + ); + + /// Duplicates this [ChartProperties] with the given data overrides. + ChartProperties copyWith({ + List? points, + double? xMin, + double? xMax, + double? yMin, + double? yMax, + double? plotInset, + ColorRGBA? lineColor, + double? lineWidth, + ColorRGBA? fillColor, + bool? smooth, + double? gridStepX, + ColorRGBA? gridColor, + ColorRGBA? axisColor, + List? xLabels, + List? gridLabels, + ChartTextStyle? labelStyle, + double? labelHeight, + List? ranges, + List? markers, + double? markerRadius, + ChartTextStyle? markerLabelStyle, + double? referenceX, + ColorRGBA? referenceColor, + double? referenceWidth, + bool? referenceDashed, + bool? selectable, + double? selectionStep, + double? selectionMin, + double? selectionMax, + ColorRGBA? selectionColor, + ColorRGBA? selectionLineColor, + double? selectionLineWidth, + double? selectionDotRadius, + ColorRGBA? selectionDotColor, + ColorRGBA? selectionDotStrokeColor, + double? selectionDotStrokeWidth, + }) => ChartProperties( + points: points ?? this.points, + xMin: xMin ?? this.xMin, + xMax: xMax ?? this.xMax, + yMin: yMin ?? this.yMin, + yMax: yMax ?? this.yMax, + plotInset: plotInset ?? this.plotInset, + lineColor: lineColor ?? this.lineColor, + lineWidth: lineWidth ?? this.lineWidth, + fillColor: fillColor ?? this.fillColor, + smooth: smooth ?? this.smooth, + gridStepX: gridStepX ?? this.gridStepX, + gridColor: gridColor ?? this.gridColor, + axisColor: axisColor ?? this.axisColor, + xLabels: xLabels ?? this.xLabels, + gridLabels: gridLabels ?? this.gridLabels, + labelStyle: labelStyle ?? this.labelStyle, + labelHeight: labelHeight ?? this.labelHeight, + ranges: ranges ?? this.ranges, + markers: markers ?? this.markers, + markerRadius: markerRadius ?? this.markerRadius, + markerLabelStyle: markerLabelStyle ?? this.markerLabelStyle, + referenceX: referenceX ?? this.referenceX, + referenceColor: referenceColor ?? this.referenceColor, + referenceWidth: referenceWidth ?? this.referenceWidth, + referenceDashed: referenceDashed ?? this.referenceDashed, + selectable: selectable ?? this.selectable, + selectionStep: selectionStep ?? this.selectionStep, + selectionMin: selectionMin ?? this.selectionMin, + selectionMax: selectionMax ?? this.selectionMax, + selectionColor: selectionColor ?? this.selectionColor, + selectionLineColor: selectionLineColor ?? this.selectionLineColor, + selectionLineWidth: selectionLineWidth ?? this.selectionLineWidth, + selectionDotRadius: selectionDotRadius ?? this.selectionDotRadius, + selectionDotColor: selectionDotColor ?? this.selectionDotColor, + selectionDotStrokeColor: + selectionDotStrokeColor ?? this.selectionDotStrokeColor, + selectionDotStrokeWidth: + selectionDotStrokeWidth ?? this.selectionDotStrokeWidth, + ); + + /// Snaps [x] to the selection step and clamps it to the selectable range. + double snapSelection(double x) { + final double step = selectionStep; + double snapped = step > 0 ? xMin + ((x - xMin) / step).floor() * step : x; + final double lowest = selectionMin ?? xMin; + final double highest = + selectionMax ?? (step > 0 ? xMax - step : xMax).clamp(lowest, xMax); + if (snapped < lowest) snapped = lowest; + if (snapped > highest) snapped = highest; + // Steps like 0.1 accumulate float error; keep 10 decimals at most. + return double.parse(snapped.toStringAsFixed(10)); + } + + /// The y value of the curve at [x], interpolated between points. + double? valueAt(double x, [List? points]) { + final List values = points ?? this.points; + if (values.isEmpty) return null; + if (values.length == 1 || xMax == xMin) return values.first; + final double position = + ((x - xMin) / (xMax - xMin)).clamp(0.0, 1.0) * (values.length - 1); + final int index = position.floor().clamp(0, values.length - 2); + final double t = position - index; + return values[index] + (values[index + 1] - values[index]) * t; + } + + @override + FieldsMap generateFields() => { + ...super.generateFields(), + 'lineColor': ColorFieldAccess( + 'Line Color', + 'Color of the line', + () => lineColor, + (value) => lineColor = value, + ), + 'lineWidth': NumFieldAccess( + 'Line Width', + 'Width of the line', + () => lineWidth, + (value) => lineWidth = value, + ), + 'smooth': BoolFieldAccess( + 'Smooth', + 'Whether to draw the line as a smooth curve', + () => smooth, + (value) => smooth = value, + ), + 'gridColor': ColorFieldAccess( + 'Grid Color', + 'Color of the gridlines', + () => gridColor, + (value) => gridColor = value, + ), + 'selectionStep': NumFieldAccess( + 'Selection Step', + 'Width of the selection band', + () => selectionStep, + (value) => selectionStep = value, + ), + 'selectable': BoolFieldAccess( + 'Selectable', + 'Whether tapping or dragging on the chart selects an x value', + () => selectable, + (value) => selectable = value, + ), + }; + + @override + List get props => [ + points, + xMin, + xMax, + yMin, + yMax, + plotInset, + lineColor, + lineWidth, + fillColor, + smooth, + gridStepX, + gridColor, + axisColor, + xLabels, + gridLabels, + labelStyle, + labelHeight, + ranges, + markers, + markerRadius, + markerLabelStyle, + referenceX, + referenceColor, + referenceWidth, + referenceDashed, + selectable, + selectionStep, + selectionMin, + selectionMax, + selectionColor, + selectionLineColor, + selectionLineWidth, + selectionDotRadius, + selectionDotColor, + selectionDotStrokeColor, + selectionDotStrokeWidth, + ]; + + /// Creates a [ChartProperties] from JSON data. + factory ChartProperties.fromJson(Map json) => _$ChartPropertiesFromJson(json); + + @override + Map toJson() => + _$ChartPropertiesOmitDefaults(this, _$ChartPropertiesToJson(this)); +} diff --git a/lib/src/api/nodes/chart_node.g.dart b/lib/src/api/nodes/chart_node.g.dart new file mode 100644 index 00000000..15b3acbb --- /dev/null +++ b/lib/src/api/nodes/chart_node.g.dart @@ -0,0 +1,483 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'chart_node.dart'; + +// ************************************************************************** +// JsonDefaultsGenerator +// ************************************************************************** + +Map _$ChartNodeOmitDefaults( + ChartNode instance, + Map json, +) { + if (isJsonDefault(instance.visible, true)) json.remove('visible'); + if (excludeConstraintsIf(instance)) json.remove('constraints'); + if (isJsonDefault(instance.constraints, const BoxConstraintsModel())) + json.remove('constraints'); + if (isJsonDefault(instance.edgePins, EdgePinsModel.standard)) + json.remove('edgePins'); + if (isJsonDefault(instance.positioningMode, PositioningMode.align)) + json.remove('positioningMode'); + if (isJsonDefault(instance.horizontalFit, SizeFit.fixed)) + json.remove('horizontalFit'); + if (isJsonDefault(instance.verticalFit, SizeFit.fixed)) + json.remove('verticalFit'); + if (isJsonDefault(instance.flex, 1)) json.remove('flex'); + if (isJsonDefault(instance.aspectRatioLock, false)) + json.remove('aspectRatioLock'); + if (isJsonDefault(instance.alignment, AlignmentModel.none)) + json.remove('alignment'); + if (isJsonDefault(instance.margin, EdgeInsetsModel.zero)) + json.remove('margin'); + if (isJsonDefault(instance.padding, EdgeInsetsModel.zero)) + json.remove('padding'); + if (isJsonDefault(instance.rotationDegrees, 0)) json.remove('rotation'); + if (isJsonDefault(instance.interactionStates, {})) + json.remove('interactionStates'); + if (isJsonDefault(instance.tooltip, '')) json.remove('tooltip'); + if (isJsonDefault(instance.zoomable, false)) json.remove('zoomable'); + if (isJsonDefault(instance.enabled, true)) json.remove('enabled'); + if (isJsonDefault(instance.variables, {})) json.remove('variables'); + if (isJsonDefault(instance.multipleVariables, {})) + json.remove('multipleVariables'); + if (isJsonDefault(instance.componentVersion, 1)) + json.remove('componentVersion'); + if (isJsonDefault(instance.componentSchema, {})) + json.remove('componentSchema'); + if (isJsonDefault(instance.reactions, const [])) json.remove('reactions'); + return json; +} + +Map _$ChartTextStyleOmitDefaults( + ChartTextStyle instance, + Map json, +) { + if (isJsonDefault(instance.fontSize, 10)) json.remove('fontSize'); + if (isJsonDefault(instance.color, ColorRGBA.black54)) json.remove('color'); + if (isJsonDefault(instance.fontWeight, 400)) json.remove('fontWeight'); + return json; +} + +Map _$ChartLabelOmitDefaults( + ChartLabel instance, + Map json, +) { + return json; +} + +Map _$ChartRangeOmitDefaults( + ChartRange instance, + Map json, +) { + if (isJsonDefault(instance.color, ColorRGBA.black10)) json.remove('color'); + return json; +} + +Map _$ChartMarkerOmitDefaults( + ChartMarker instance, + Map json, +) { + if (isJsonDefault(instance.label, '')) json.remove('label'); + if (isJsonDefault(instance.color, ColorRGBA.black)) json.remove('color'); + if (isJsonDefault(instance.position, ChartMarkerPosition.above)) + json.remove('position'); + return json; +} + +Map _$ChartPropertiesOmitDefaults( + ChartProperties instance, + Map json, +) { + if (isJsonDefault(instance.points, const [])) json.remove('points'); + if (isJsonDefault(instance.xMin, 0)) json.remove('xMin'); + if (isJsonDefault(instance.xMax, 1)) json.remove('xMax'); + if (isJsonDefault(instance.plotInset, 8)) json.remove('plotInset'); + if (isJsonDefault(instance.lineColor, kChartDefaultLineColor)) + json.remove('lineColor'); + if (isJsonDefault(instance.lineWidth, 2)) json.remove('lineWidth'); + if (isJsonDefault(instance.smooth, false)) json.remove('smooth'); + if (isJsonDefault(instance.gridColor, ColorRGBA.black10)) + json.remove('gridColor'); + if (isJsonDefault(instance.xLabels, const [])) json.remove('xLabels'); + if (isJsonDefault(instance.gridLabels, const [])) json.remove('gridLabels'); + if (isJsonDefault(instance.labelStyle, const ChartTextStyle())) + json.remove('labelStyle'); + if (isJsonDefault(instance.labelHeight, 16)) json.remove('labelHeight'); + if (isJsonDefault(instance.ranges, const [])) json.remove('ranges'); + if (isJsonDefault(instance.markers, const [])) json.remove('markers'); + if (isJsonDefault(instance.markerRadius, 3.5)) json.remove('markerRadius'); + if (isJsonDefault( + instance.markerLabelStyle, + const ChartTextStyle(fontWeight: 700), + )) + json.remove('markerLabelStyle'); + if (isJsonDefault(instance.referenceColor, kChartDefaultReferenceColor)) + json.remove('referenceColor'); + if (isJsonDefault(instance.referenceWidth, 1.5)) + json.remove('referenceWidth'); + if (isJsonDefault(instance.referenceDashed, true)) + json.remove('referenceDashed'); + if (isJsonDefault(instance.selectable, true)) json.remove('selectable'); + if (isJsonDefault(instance.selectionStep, 1)) json.remove('selectionStep'); + if (isJsonDefault(instance.selectionColor, ColorRGBA.black10)) + json.remove('selectionColor'); + if (isJsonDefault(instance.selectionLineColor, ColorRGBA.black)) + json.remove('selectionLineColor'); + if (isJsonDefault(instance.selectionLineWidth, 1.5)) + json.remove('selectionLineWidth'); + if (isJsonDefault(instance.selectionDotRadius, 6.5)) + json.remove('selectionDotRadius'); + if (isJsonDefault(instance.selectionDotColor, ColorRGBA.white)) + json.remove('selectionDotColor'); + if (isJsonDefault(instance.selectionDotStrokeColor, ColorRGBA.black)) + json.remove('selectionDotStrokeColor'); + if (isJsonDefault(instance.selectionDotStrokeWidth, 2.5)) + json.remove('selectionDotStrokeWidth'); + return json; +} + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +ChartNode _$ChartNodeFromJson(Map json) => + ChartNode( + id: json['id'] as String, + name: json['name'] as String, + basicBoxLocal: NodeBox.fromJson(json['basicBoxLocal']), + visible: json['visible'] as bool? ?? true, + enabled: json['enabled'] as bool? ?? true, + alignment: json['alignment'] == null + ? AlignmentModel.none + : AlignmentModel.fromJson(json['alignment'] as Map), + padding: json['padding'] == null + ? EdgeInsetsModel.zero + : EdgeInsetsModel.fromJson(json['padding']), + margin: json['margin'] == null + ? EdgeInsetsModel.zero + : EdgeInsetsModel.fromJson(json['margin']), + rotationDegrees: json['rotation'] == null + ? 0 + : castRotation(json['rotation']), + reactions: + (json['reactions'] as List?) + ?.map((e) => Reaction.fromJson(e as Map)) + .toList() ?? + const [], + horizontalFit: + $enumDecodeNullable(_$SizeFitEnumMap, json['horizontalFit']) ?? + SizeFit.fixed, + verticalFit: + $enumDecodeNullable(_$SizeFitEnumMap, json['verticalFit']) ?? + SizeFit.fixed, + flex: (json['flex'] as num?)?.toInt() ?? 1, + constraints: json['constraints'] == null + ? const BoxConstraintsModel() + : BoxConstraintsModel.fromJson(json['constraints'] as Map), + aspectRatioLock: json['aspectRatioLock'] as bool? ?? false, + edgePins: json['edgePins'] == null + ? EdgePinsModel.standard + : EdgePinsModel.fromJson( + Map.from(json['edgePins'] as Map), + ), + positioningMode: + $enumDecodeNullable( + _$PositioningModeEnumMap, + json['positioningMode'], + ) ?? + PositioningMode.align, + properties: ChartProperties.fromJson(json['properties'] as Map), + selectedX: (json['selectedX'] as num?)?.toDouble(), + variables: + (json['variables'] as Map?)?.map( + (k, e) => MapEntry(k as String, e as String), + ) ?? + {}, + multipleVariables: + (json['multipleVariables'] as Map?)?.map( + (k, e) => MapEntry( + k as String, + (e as List).map((e) => e as String).toList(), + ), + ) ?? + {}, + ) + ..componentId = json['componentId'] as String? + ..markerType = $enumDecodeNullable( + _$ComponentMarkerTypeEnumMap, + json['markerType'], + ) + ..componentVersion = (json['componentVersion'] as num?)?.toInt() ?? 1 + ..componentSchema = + (json['componentSchema'] as Map?)?.map( + (k, e) => MapEntry(k as String, e), + ) ?? + {} + ..widthFactor = (json['widthFactor'] as num?)?.toDouble() + ..heightFactor = (json['heightFactor'] as num?)?.toDouble() + ..interactionStates = + (json['interactionStates'] as Map?)?.map( + (k, e) => MapEntry( + $enumDecode(_$InteractionStateEnumMap, k), + (e as Map).map( + (k, e) => + MapEntry(k as String, Map.from(e as Map)), + ), + ), + ) ?? + {} + ..tooltip = json['tooltip'] as String? ?? '' + ..zoomable = json['zoomable'] as bool? ?? false + ..type = json['type'] as String; + +Map _$ChartNodeToJson(ChartNode instance) => { + 'variables': instance.variables, + 'multipleVariables': instance.multipleVariables, + 'componentId': ?instance.componentId, + 'markerType': ?_$ComponentMarkerTypeEnumMap[instance.markerType], + 'componentVersion': instance.componentVersion, + 'componentSchema': instance.componentSchema, + 'id': instance.id, + 'name': instance.name, + 'visible': instance.visible, + 'constraints': instance.constraints.toJson(), + 'edgePins': instance.edgePins.toJson(), + 'positioningMode': _$PositioningModeEnumMap[instance.positioningMode]!, + 'horizontalFit': _$SizeFitEnumMap[instance.horizontalFit]!, + 'verticalFit': _$SizeFitEnumMap[instance.verticalFit]!, + 'flex': instance.flex, + 'aspectRatioLock': instance.aspectRatioLock, + 'alignment': instance.alignment.toJson(), + 'reactions': instance.reactions.map((e) => e.toJson()).toList(), + 'basicBoxLocal': instance.basicBoxLocal.toJson(), + 'margin': instance.margin.toJson(), + 'padding': instance.padding.toJson(), + 'rotation': instance.rotationDegrees, + 'widthFactor': ?instance.widthFactor, + 'heightFactor': ?instance.heightFactor, + 'interactionStates': instance.interactionStates.map( + (k, e) => MapEntry(_$InteractionStateEnumMap[k]!, e), + ), + 'tooltip': instance.tooltip, + 'zoomable': instance.zoomable, + 'enabled': instance.enabled, + 'type': instance.type, + 'properties': instance.properties.toJson(), + 'selectedX': ?instance.selectedX, +}; + +const _$SizeFitEnumMap = { + SizeFit.locked: 'locked', + SizeFit.fixed: 'fixed', + SizeFit.expanded: 'expanded', + SizeFit.flexible: 'flexible', + SizeFit.shrinkWrap: 'shrinkWrap', +}; + +const _$PositioningModeEnumMap = { + PositioningMode.align: 'align', + PositioningMode.pin: 'pin', +}; + +const _$ComponentMarkerTypeEnumMap = { + ComponentMarkerType.component: 'component', + ComponentMarkerType.instance: 'instance', +}; + +const _$InteractionStateEnumMap = { + InteractionState.hovered: 'hovered', + InteractionState.focused: 'focused', + InteractionState.pressed: 'pressed', + InteractionState.selected: 'selected', + InteractionState.disabled: 'disabled', +}; + +ChartTextStyle _$ChartTextStyleFromJson(Map json) => ChartTextStyle( + fontSize: (json['fontSize'] as num?)?.toDouble() ?? 10, + color: json['color'] == null + ? ColorRGBA.black54 + : ColorRGBA.fromJson(json['color']), + fontFamily: json['fontFamily'] as String?, + fontWeight: (json['fontWeight'] as num?)?.toInt() ?? 400, + height: (json['height'] as num?)?.toDouble(), +); + +Map _$ChartTextStyleToJson(ChartTextStyle instance) => + { + 'fontSize': instance.fontSize, + 'color': instance.color.toJson(), + 'fontFamily': ?instance.fontFamily, + 'fontWeight': instance.fontWeight, + 'height': ?instance.height, + }; + +ChartLabel _$ChartLabelFromJson(Map json) => ChartLabel( + x: (json['x'] as num).toDouble(), + label: json['label'] as String, +); + +Map _$ChartLabelToJson(ChartLabel instance) => + {'x': instance.x, 'label': instance.label}; + +ChartRange _$ChartRangeFromJson(Map json) => ChartRange( + from: (json['from'] as num).toDouble(), + to: (json['to'] as num).toDouble(), + color: json['color'] == null + ? ColorRGBA.black10 + : ColorRGBA.fromJson(json['color']), +); + +Map _$ChartRangeToJson(ChartRange instance) => + { + 'from': instance.from, + 'to': instance.to, + 'color': instance.color.toJson(), + }; + +ChartMarker _$ChartMarkerFromJson(Map json) => ChartMarker( + x: (json['x'] as num).toDouble(), + y: (json['y'] as num?)?.toDouble(), + label: json['label'] as String? ?? '', + color: json['color'] == null + ? ColorRGBA.black + : ColorRGBA.fromJson(json['color']), + position: + $enumDecodeNullable(_$ChartMarkerPositionEnumMap, json['position']) ?? + ChartMarkerPosition.above, +); + +Map _$ChartMarkerToJson(ChartMarker instance) => + { + 'x': instance.x, + 'y': ?instance.y, + 'label': instance.label, + 'color': instance.color.toJson(), + 'position': _$ChartMarkerPositionEnumMap[instance.position]!, + }; + +const _$ChartMarkerPositionEnumMap = { + ChartMarkerPosition.above: 'above', + ChartMarkerPosition.below: 'below', +}; + +ChartProperties _$ChartPropertiesFromJson(Map json) => ChartProperties( + points: + (json['points'] as List?) + ?.map((e) => (e as num).toDouble()) + .toList() ?? + const [], + xMin: (json['xMin'] as num?)?.toDouble() ?? 0, + xMax: (json['xMax'] as num?)?.toDouble() ?? 1, + yMin: (json['yMin'] as num?)?.toDouble(), + yMax: (json['yMax'] as num?)?.toDouble(), + plotInset: (json['plotInset'] as num?)?.toDouble() ?? 8, + lineColor: json['lineColor'] == null + ? kChartDefaultLineColor + : ColorRGBA.fromJson(json['lineColor']), + lineWidth: (json['lineWidth'] as num?)?.toDouble() ?? 2, + fillColor: json['fillColor'] == null + ? null + : ColorRGBA.fromJson(json['fillColor']), + smooth: json['smooth'] as bool? ?? false, + gridStepX: (json['gridStepX'] as num?)?.toDouble(), + gridColor: json['gridColor'] == null + ? ColorRGBA.black10 + : ColorRGBA.fromJson(json['gridColor']), + axisColor: json['axisColor'] == null + ? null + : ColorRGBA.fromJson(json['axisColor']), + xLabels: + (json['xLabels'] as List?) + ?.map((e) => ChartLabel.fromJson(e as Map)) + .toList() ?? + const [], + gridLabels: + (json['gridLabels'] as List?) + ?.map((e) => e as String) + .toList() ?? + const [], + labelStyle: json['labelStyle'] == null + ? const ChartTextStyle() + : ChartTextStyle.fromJson(json['labelStyle'] as Map), + labelHeight: (json['labelHeight'] as num?)?.toDouble() ?? 16, + ranges: + (json['ranges'] as List?) + ?.map((e) => ChartRange.fromJson(e as Map)) + .toList() ?? + const [], + markers: + (json['markers'] as List?) + ?.map((e) => ChartMarker.fromJson(e as Map)) + .toList() ?? + const [], + markerRadius: (json['markerRadius'] as num?)?.toDouble() ?? 3.5, + markerLabelStyle: json['markerLabelStyle'] == null + ? const ChartTextStyle(fontWeight: 700) + : ChartTextStyle.fromJson(json['markerLabelStyle'] as Map), + referenceX: (json['referenceX'] as num?)?.toDouble(), + referenceColor: json['referenceColor'] == null + ? kChartDefaultReferenceColor + : ColorRGBA.fromJson(json['referenceColor']), + referenceWidth: (json['referenceWidth'] as num?)?.toDouble() ?? 1.5, + referenceDashed: json['referenceDashed'] as bool? ?? true, + selectable: json['selectable'] as bool? ?? true, + selectionStep: (json['selectionStep'] as num?)?.toDouble() ?? 1, + selectionMin: (json['selectionMin'] as num?)?.toDouble(), + selectionMax: (json['selectionMax'] as num?)?.toDouble(), + selectionColor: json['selectionColor'] == null + ? ColorRGBA.black10 + : ColorRGBA.fromJson(json['selectionColor']), + selectionLineColor: json['selectionLineColor'] == null + ? ColorRGBA.black + : ColorRGBA.fromJson(json['selectionLineColor']), + selectionLineWidth: (json['selectionLineWidth'] as num?)?.toDouble() ?? 1.5, + selectionDotRadius: (json['selectionDotRadius'] as num?)?.toDouble() ?? 6.5, + selectionDotColor: json['selectionDotColor'] == null + ? ColorRGBA.white + : ColorRGBA.fromJson(json['selectionDotColor']), + selectionDotStrokeColor: json['selectionDotStrokeColor'] == null + ? ColorRGBA.black + : ColorRGBA.fromJson(json['selectionDotStrokeColor']), + selectionDotStrokeWidth: + (json['selectionDotStrokeWidth'] as num?)?.toDouble() ?? 2.5, +); + +Map _$ChartPropertiesToJson(ChartProperties instance) => + { + 'points': instance.points, + 'xMin': instance.xMin, + 'xMax': instance.xMax, + 'yMin': ?instance.yMin, + 'yMax': ?instance.yMax, + 'plotInset': instance.plotInset, + 'lineColor': instance.lineColor.toJson(), + 'lineWidth': instance.lineWidth, + 'fillColor': ?instance.fillColor?.toJson(), + 'smooth': instance.smooth, + 'gridStepX': ?instance.gridStepX, + 'gridColor': instance.gridColor.toJson(), + 'axisColor': ?instance.axisColor?.toJson(), + 'xLabels': instance.xLabels.map((e) => e.toJson()).toList(), + 'gridLabels': instance.gridLabels, + 'labelStyle': instance.labelStyle.toJson(), + 'labelHeight': instance.labelHeight, + 'ranges': instance.ranges.map((e) => e.toJson()).toList(), + 'markers': instance.markers.map((e) => e.toJson()).toList(), + 'markerRadius': instance.markerRadius, + 'markerLabelStyle': instance.markerLabelStyle.toJson(), + 'referenceX': ?instance.referenceX, + 'referenceColor': instance.referenceColor.toJson(), + 'referenceWidth': instance.referenceWidth, + 'referenceDashed': instance.referenceDashed, + 'selectable': instance.selectable, + 'selectionStep': instance.selectionStep, + 'selectionMin': ?instance.selectionMin, + 'selectionMax': ?instance.selectionMax, + 'selectionColor': instance.selectionColor.toJson(), + 'selectionLineColor': instance.selectionLineColor.toJson(), + 'selectionLineWidth': instance.selectionLineWidth, + 'selectionDotRadius': instance.selectionDotRadius, + 'selectionDotColor': instance.selectionDotColor.toJson(), + 'selectionDotStrokeColor': instance.selectionDotStrokeColor.toJson(), + 'selectionDotStrokeWidth': instance.selectionDotStrokeWidth, + }; diff --git a/lib/src/api/nodes/list_view_node.dart b/lib/src/api/nodes/list_view_node.dart index 3dcac284..5acbc5ad 100644 --- a/lib/src/api/nodes/list_view_node.dart +++ b/lib/src/api/nodes/list_view_node.dart @@ -194,6 +194,21 @@ class ListViewProperties extends CustomProperties with FieldsHolder { /// items, if enabled. DividerProperties dividerProperties; + /// Whether the items flow onto new runs when they don't fit along the + /// scroll direction, like Flutter's [Wrap], instead of scrolling. + /// + /// It applies to a list whose item count is known (an [itemCount] or bound + /// `data`) and that doesn't use the cloud database: such a list builds all + /// its items in a [Wrap] and never scrolls. A list with an unknown count + /// (endless, or a cloud query) ignores it and scrolls as usual. + /// + /// The gap between items in a run is the separator's [separatorSpacing] + /// when [hasSeparator] is on; a wrapping list draws no dividers. + bool wrap; + + /// The gap between runs when [wrap] is on, across the scroll direction. + double runSpacing; + /// Creates a new [ListViewProperties] instance. ListViewProperties({ this.itemCount, @@ -203,6 +218,8 @@ class ListViewProperties extends CustomProperties with FieldsHolder { this.separatorSpacing = 0, this.hasSeparator = false, DividerProperties? dividerProperties, + this.wrap = false, + this.runSpacing = 0, }) : dividerProperties = dividerProperties ?? DividerProperties(); /// Creates a copy of this [ListViewProperties] instance with the given value @@ -222,6 +239,8 @@ class ListViewProperties extends CustomProperties with FieldsHolder { double? dividerSpacing, bool? hasSeparator, DividerProperties? dividerProperties, + bool? wrap, + double? runSpacing, }) { return ListViewProperties( itemCount: itemCount ?? this.itemCount, @@ -231,6 +250,8 @@ class ListViewProperties extends CustomProperties with FieldsHolder { separatorSpacing: separatorSpacing ?? this.separatorSpacing, hasSeparator: hasSeparator ?? this.hasSeparator, dividerProperties: dividerProperties ?? this.dividerProperties, + wrap: wrap ?? this.wrap, + runSpacing: runSpacing ?? this.runSpacing, ); } @@ -267,6 +288,22 @@ class ListViewProperties extends CustomProperties with FieldsHolder { (value) => dividerProperties = value, requiresLayout: true, ), + 'wrap': BoolFieldAccess( + 'Wrap', + 'Whether the items flow onto new runs when they don\'t fit.', + () => wrap, + (value) => wrap = value, + defaultValue: () => false, + requiresLayout: true, + ), + 'runSpacing': NumFieldAccess( + 'Run Gap', + 'The gap between runs when the items wrap.', + () => runSpacing, + (value) => runSpacing = value, + defaultValue: () => 0, + requiresLayout: true, + ), }; @override @@ -286,5 +323,7 @@ class ListViewProperties extends CustomProperties with FieldsHolder { separatorSpacing, hasSeparator, dividerProperties, + wrap, + runSpacing, ]; } diff --git a/lib/src/api/nodes/list_view_node.g.dart b/lib/src/api/nodes/list_view_node.g.dart index 2dce408d..bfd7c7eb 100644 --- a/lib/src/api/nodes/list_view_node.g.dart +++ b/lib/src/api/nodes/list_view_node.g.dart @@ -80,6 +80,8 @@ Map _$ListViewPropertiesOmitDefaults( if (isJsonDefault(instance.separator, ListItemSeparator.space)) json.remove('separator'); if (isJsonDefault(instance.hasSeparator, false)) json.remove('hasSeparator'); + if (isJsonDefault(instance.wrap, false)) json.remove('wrap'); + if (isJsonDefault(instance.runSpacing, 0)) json.remove('runSpacing'); return json; } @@ -364,6 +366,8 @@ ListViewProperties _$ListViewPropertiesFromJson(Map json) => ListViewProperties( dividerProperties: json['dividerProperties'] == null ? null : DividerProperties.fromJson(json['dividerProperties'] as Map), + wrap: json['wrap'] as bool? ?? false, + runSpacing: (json['runSpacing'] as num?)?.toDouble() ?? 0, ); Map _$ListViewPropertiesToJson(ListViewProperties instance) => @@ -375,6 +379,8 @@ Map _$ListViewPropertiesToJson(ListViewProperties instance) => 'separator': _$ListItemSeparatorEnumMap[instance.separator]!, 'hasSeparator': instance.hasSeparator, 'dividerProperties': instance.dividerProperties.toJson(), + 'wrap': instance.wrap, + 'runSpacing': instance.runSpacing, }; const _$ListItemSeparatorEnumMap = { diff --git a/lib/src/api/nodes/nodes.dart b/lib/src/api/nodes/nodes.dart index 440e5adc..08725aaf 100644 --- a/lib/src/api/nodes/nodes.dart +++ b/lib/src/api/nodes/nodes.dart @@ -5,6 +5,7 @@ export 'badge_node.dart'; export 'base_node.dart'; export 'button_node.dart'; export 'canvas_node.dart'; +export 'chart_node.dart'; export 'checkbox_node.dart'; export 'chip_node.dart'; export 'divider_node.dart'; diff --git a/lib/src/api/typed_value.dart b/lib/src/api/typed_value.dart index 2f28709a..e49c04a9 100644 --- a/lib/src/api/typed_value.dart +++ b/lib/src/api/typed_value.dart @@ -13,7 +13,7 @@ extension ObjectExt on Object? { return switch (R) { const (CornerRadius) => - CornerRadius.fromJson(value).tryCast() ?? defaultValue, + value.toCornerRadius().tryCast() ?? defaultValue, const (EdgeInsetsModel) => value.toEdgeInsetsModel().tryCast() ?? defaultValue, const (ColorRGB) => @@ -231,14 +231,64 @@ extension ConversionExt on Object? { } /// Converts given object to [EdgeInsetsModel] if possible. Returns null otherwise. + /// + /// Accepts what a binding or formula gives a node's padding: a number for + /// all sides, `[horizontal, vertical]`, `[left, top, right, bottom]`, a + /// one-item list, or any of those as text (`"12"`, `"[16, 8]"`). EdgeInsetsModel? toEdgeInsetsModel() { final value = this; if (value == null) return null; if (value is EdgeInsetsModel) return value; + if (value case [final num all]) return EdgeInsetsModel.all(all.toDouble()); if (value case num() || [num(), num()] || [num(), num(), num(), num()]) { return EdgeInsetsModel.fromJson(value); } + if (value is String) { + final String text = value.trim(); + final Object? parsed = num.tryParse(text) ?? tryJsonDecode(text); + if (parsed is String) return null; + return parsed.toEdgeInsetsModel(); + } + + return null; + } + /// Converts given object to [CornerRadius] if possible. Returns null + /// otherwise. + /// + /// Accepts what a binding or formula gives a node's corner radius: a + /// number for all corners, `[topLeft, topRight, bottomRight, bottomLeft]` + /// (the order of CSS's border-radius), a one-item list, any of those as + /// text (`"12"`, `"[18, 18, 0, 0]"`), or a [CornerRadius]'s JSON. + CornerRadius? toCornerRadius() { + final value = this; + switch (value) { + case null: + return null; + case CornerRadius(): + return value; + case num() || [num()]: + final num all = value is num ? value : (value as List).first as num; + return CornerRadius.all(RadiusModel.circular(all.toDouble())); + case [num tl, num tr, num br, num bl]: + return CornerRadius.only( + tl: RadiusModel.circular(tl.toDouble()), + tr: RadiusModel.circular(tr.toDouble()), + br: RadiusModel.circular(br.toDouble()), + bl: RadiusModel.circular(bl.toDouble()), + ); + case String(): + final String text = value.trim(); + final Object? parsed = num.tryParse(text) ?? tryJsonDecode(text); + if (parsed is String) return null; + return parsed.toCornerRadius(); + case Map() || List(): + try { + return CornerRadius.fromJson(value); + } catch (_) { + return null; + } + } return null; } diff --git a/pubspec.yaml b/pubspec.yaml index 4efa4b73..6f6f51e0 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: codelessly_api description: Flutter's layouts and widgets represented as models and JSON data. This API is used by the Codelessly Editor and CloudUI SDK to render Flutter Widgets. -version: 6.2.0 +version: 6.3.0 homepage: https://codelessly.com/ repository: https://github.com/Codelessly/CodelesslyAPI diff --git a/test/batch_cloud_storage_action_test.dart b/test/batch_cloud_storage_action_test.dart new file mode 100644 index 00000000..cf1aff68 --- /dev/null +++ b/test/batch_cloud_storage_action_test.dart @@ -0,0 +1,124 @@ +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:test/test.dart'; + +void main() { + test('removeDocuments round-trips and omits defaults', () { + final action = SetCloudStorageAction( + subAction: const RemoveDocumentsSubAction(path: 'sessions'), + ); + final Map json = action.toJson(); + expect(json['subAction'], { + 'path': 'sessions', + 'operation': 'removeDocuments', + }); + expect(ActionModel.fromJson(json), action); + + final filtered = SetCloudStorageAction( + subAction: const RemoveDocumentsSubAction( + path: 'sessions', + documentIds: r'${= ids}', + whereFilters: [ + WhereQueryFilter( + field: 'spot', + operator: WhereQueryOperator.equal, + value: 'Pipe', + ), + ], + ), + ); + final restored = + ActionModel.fromJson(filtered.toJson()) as SetCloudStorageAction; + expect(restored, filtered); + expect( + (restored.subAction as RemoveDocumentsSubAction) + .whereFilters + .single + .value, + 'Pipe', + ); + expect(SetCloudStorageOperation.removeDocuments.isBatch, isTrue); + expect(SetCloudStorageOperation.removeDocuments.label, 'Remove Documents'); + }); + + test('setDocuments round-trips and omits defaults', () { + final action = SetCloudStorageAction( + subAction: const SetDocumentsSubAction( + path: 'sessions', + documents: r'${seedSessions}', + ), + ); + expect(action.toJson()['subAction'], { + 'path': 'sessions', + 'documents': r'${seedSessions}', + 'operation': 'setDocuments', + }); + final custom = SetCloudStorageAction( + subAction: const SetDocumentsSubAction( + path: 'sessions', + documents: '[{"id": "a"}]', + idField: 'key', + merge: true, + replaceCollection: true, + ), + ); + expect(ActionModel.fromJson(custom.toJson()), custom); + expect(SetCloudStorageOperation.setDocuments.label, 'Set Documents'); + }); + + test( + 'InputDecorationModel.contentPadding round-trips; omitted by default', + () { + final InputDecorationModel plain = InputDecorationModel(); + expect(plain.toJson().containsKey('contentPadding'), isFalse); + expect(plain.contentPadding, isNull); + + final InputDecorationModel flush = plain.copyWith( + contentPadding: EdgeInsetsModel.zero, + ); + expect(flush.toJson()['contentPadding'], 0); + expect( + InputDecorationModel.fromJson(flush.toJson()).contentPadding, + EdgeInsetsModel.zero, + ); + expect(flush.copyWith(clearContentPadding: true).contentPadding, isNull); + }, + ); + + test( + 'ShowToastAction close button, action color, radius, font and offset', + () { + final ShowToastAction plain = ShowToastAction(message: 'Saved'); + expect(plain.toJson(), {'type': 'showToast', 'message': 'Saved'}); + expect(plain.showClose, isFalse); + + final ShowToastAction custom = plain.copyWith( + actionLabel: 'UNDO', + actionColor: ColorRGBA.fromHex('#F2542D'), + showClose: true, + cornerRadius: 8, + fontSize: 13, + fontFamily: 'Inter', + fontWeight: FontWeightNumeric.w600, + offset: r'${= screen.isMobile ? 96 : 24}', + ); + final Map json = custom.toJson(); + expect(json['showClose'], isTrue); + expect(json['cornerRadius'], 8); + expect(json['fontSize'], 13); + expect(json['fontFamily'], 'Inter'); + expect(json['fontWeight'], 'w600'); + expect(ActionModel.fromJson(json), custom); + expect( + custom.copyWith(clearFontFamily: true, clearFontWeight: true).toJson(), + isNot(contains('fontFamily')), + ); + expect( + custom + .copyWith(clearActionColor: true, clearCornerRadius: true) + .toJson() + .keys, + isNot(contains('actionColor')), + ); + }, + ); +} diff --git a/test/fixtures/chart_tide.json b/test/fixtures/chart_tide.json new file mode 100644 index 00000000..3873148b --- /dev/null +++ b/test/fixtures/chart_tide.json @@ -0,0 +1,228 @@ +{ + "id": "tide", + "name": "Tide chart", + "type": "chart", + "basicBoxLocal": [ + 0, + 0, + 360, + 132 + ], + "horizontalFit": "expanded", + "selectedX": 7, + "variables": { + "points": "${tideCurve}", + "markers": "${tideMarkers}", + "selectedX": "${selectedHour}" + }, + "reactions": [ + { + "id": "r1", + "name": "select", + "trigger": { + "type": "changed" + }, + "action": { + "type": "setVariable", + "variable": { + "id": "selectedHour", + "name": "selectedHour", + "type": "integer" + }, + "newValue": "${value}" + } + } + ], + "properties": { + "points": [ + 1.6, + 1.9, + 2.1, + 2.4, + 2.7, + 3.0, + 3.2, + 3.5, + 3.8, + 4.0, + 4.2, + 4.4, + 4.6, + 4.8, + 4.9, + 4.9, + 5.0, + 5.0, + 5.0, + 4.9, + 4.8, + 4.7, + 4.5, + 4.4, + 4.1, + 3.9, + 3.7, + 3.4, + 3.1, + 2.9, + 2.6, + 2.3, + 2.0, + 1.8, + 1.5, + 1.3, + 1.1, + 1.0, + 0.8, + 0.7, + 0.6, + 0.6, + 0.6, + 0.6, + 0.7, + 0.8, + 0.9, + 1.1, + 1.3, + 1.5, + 1.7, + 2.0, + 2.2, + 2.5, + 2.8, + 3.1, + 3.3, + 3.6, + 3.9, + 4.1, + 4.3, + 4.5, + 4.7, + 4.8, + 4.9, + 5.0, + 5.0, + 5.0, + 5.0, + 4.9, + 4.8, + 4.7, + 4.5, + 4.3, + 4.1, + 3.8, + 3.6, + 3.3, + 3.0, + 2.8, + 2.5, + 2.2, + 2.0, + 1.7, + 1.5, + 1.3, + 1.1, + 0.9, + 0.8, + 0.7, + 0.6, + 0.6, + 0.6, + 0.6, + 0.7, + 0.8, + 1.0 + ], + "xMin": 0, + "xMax": 24, + "yMin": -0.5, + "yMax": 6, + "plotInset": 8, + "lineColor": "#FF1F6F78", + "lineWidth": 2.5, + "fillColor": "#FFD5E6E4", + "gridStepX": 3, + "gridColor": "#FFDCD3C3", + "axisColor": "#FFCFC5B3", + "gridLabels": [ + "3a", + "6a", + "9a", + "12p", + "3p", + "6p", + "9p" + ], + "labelStyle": { + "fontSize": 10, + "color": "#FF7A8690", + "fontFamily": "JetBrains Mono", + "fontWeight": 500, + "height": 1.45 + }, + "labelHeight": 16, + "ranges": [ + { + "from": 0, + "to": 5, + "color": "#0F0E2233" + }, + { + "from": 21, + "to": 24, + "color": "#0F0E2233" + } + ], + "markers": [ + { + "x": 4.2, + "y": 5.0, + "label": "4:12am", + "color": "#FF1F6F78", + "position": "above" + }, + { + "x": 10.42, + "y": 0.6, + "label": "10:25am", + "color": "#FF1F6F78", + "position": "below" + }, + { + "x": 16.62, + "y": 5.0, + "label": "4:37pm", + "color": "#FF1F6F78", + "position": "above" + }, + { + "x": 22.83, + "y": 0.6, + "label": "10:50pm", + "color": "#FF1F6F78", + "position": "below" + } + ], + "markerRadius": 3.5, + "markerLabelStyle": { + "fontSize": 9.5, + "fontFamily": "JetBrains Mono", + "fontWeight": 700, + "height": 1.45 + }, + "referenceX": 7.67, + "referenceColor": "#FFF2542D", + "referenceWidth": 1.5, + "referenceDashed": true, + "selectionStep": 1, + "selectionMin": 5, + "selectionMax": 20, + "selectionColor": "#1A0E2233", + "selectionLineColor": "#FF0E2233", + "selectionLineWidth": 1.5, + "selectionDotRadius": 6.5, + "selectionDotColor": "#FFFBF8F2", + "selectionDotStrokeColor": "#FF0E2233", + "selectionDotStrokeWidth": 2.5 + } +} diff --git a/test/json_defaults_test.dart b/test/json_defaults_test.dart index bb2e1328..55d6537e 100644 --- a/test/json_defaults_test.dart +++ b/test/json_defaults_test.dart @@ -177,6 +177,7 @@ List sampleNodes() { IconNode.empty(), ListTileNode.empty(), ProgressBarNode.empty(), + ChartNode.empty(), RadioNode.empty(), SegmentedButtonNode.empty(), SliderNode.empty(), diff --git a/test/navigation_action_test.dart b/test/navigation_action_test.dart new file mode 100644 index 00000000..3f95d250 --- /dev/null +++ b/test/navigation_action_test.dart @@ -0,0 +1,26 @@ +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:test/test.dart'; + +void main() { + test('a pop navigation parses without a destination', () { + final ActionModel action = ActionModel.fromJson({ + 'type': 'navigation', + 'navigationType': 'pop', + }); + expect(action, isA()); + final nav = action as NavigationAction; + expect(nav.navigationType, NavigationType.pop); + expect(nav.destinationId, ''); + expect(ActionModel.fromJson(nav.toJson()), nav); + }); + + test('a push keeps its destination and params', () { + final nav = NavigationAction( + destinationId: 'canvas1', + params: {'id': r'${item.id}'}, + ); + final json = nav.toJson(); + expect(json['destinationId'], 'canvas1'); + expect(ActionModel.fromJson(json), nav); + }); +} diff --git a/test/nodes/display/chart_node_test.dart b/test/nodes/display/chart_node_test.dart new file mode 100644 index 00000000..82329a4c --- /dev/null +++ b/test/nodes/display/chart_node_test.dart @@ -0,0 +1,131 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:test/test.dart'; + +Object? encodeDecode(Object? json) => jsonDecode(jsonEncode(json)); + +void main() { + // The tide card of qa/surf_log_reference: 97 quarter-hour points over a + // day, gridlines every 3 hours, night ranges, high/low markers, a dashed + // "now" line and an hour selection clamped to 5..20. + final Map tide = jsonDecode( + File('test/fixtures/chart_tide.json').readAsStringSync(), + ) as Map; + + group('ChartNode JSON', () { + test('reads every property of the tide chart', () { + final node = NodeJsonConverter().fromJson(tide)! as ChartNode; + final props = node.properties; + + expect(node.type, 'chart'); + expect(node.selectedX, 7); + expect(node.variables['points'], r'${tideCurve}'); + expect(node.variables['selectedX'], r'${selectedHour}'); + expect(node.reactions.single.trigger.type, TriggerType.changed); + expect(node.triggerTypes, [TriggerType.changed]); + expect(props.points, hasLength(97)); + expect(props.xMax, 24); + expect(props.yMin, -0.5); + expect(props.fillColor, ColorRGBA.fromHex('#D5E6E4')); + expect(props.gridStepX, 3); + expect(props.gridLabels.first, '3a'); + expect(props.labelStyle.fontFamily, 'JetBrains Mono'); + expect(props.labelStyle.height, 1.45); + expect(props.ranges, [ + ChartRange(from: 0, to: 5, color: ColorRGBA.fromHex('#0F0E2233')!), + ChartRange(from: 21, to: 24, color: ColorRGBA.fromHex('#0F0E2233')!), + ]); + expect(props.markers, hasLength(4)); + expect(props.markers[1].position, ChartMarkerPosition.below); + expect(props.referenceX, 7.67); + expect(props.referenceDashed, isTrue); + expect(props.selectionMin, 5); + expect(props.selectionMax, 20); + }); + + test('round-trips the tide chart', () { + final node = NodeJsonConverter().fromJson(tide)! as ChartNode; + final json = encodeDecode(node.toJson()) as Map; + final back = NodeJsonConverter().fromJson(json)! as ChartNode; + + expect(back, node); + expect(back.properties, node.properties); + expect(encodeDecode(back.toJson()), json); + // Values equal to their defaults are left out. + final props = json['properties'] as Map; + expect(props, isNot(contains('plotInset'))); + expect(props, isNot(contains('selectionStep'))); + expect(props['points'], tide['properties']['points']); + }); + + test('a new chart writes only what differs from the defaults', () { + final node = ChartNode( + id: 'c', + name: 'Chart', + basicBoxLocal: NodeBox(0, 0, 100, 50), + properties: ChartProperties(points: [1, 2, 3]), + ); + expect(encodeDecode(node.toJson()), { + 'id': 'c', + 'name': 'Chart', + 'basicBoxLocal': [0, 0, 100, 50], + 'type': 'chart', + 'properties': { + 'points': [1, 2, 3], + }, + }); + final back = ChartNode.fromJson(encodeDecode(node.toJson()) as Map); + expect(back.properties, ChartProperties(points: [1, 2, 3])); + expect(back.selectedX, isNull); + }); + + test('the sample chart round-trips', () { + final node = ChartNode.empty(); + final back = ChartNode.fromJson(encodeDecode(node.toJson()) as Map); + expect(back.properties, node.properties); + expect(back.properties.points, isNotEmpty); + }); + }); + + group('ChartProperties', () { + test('copyWith overrides only what it is given', () { + final props = ChartProperties(points: [1, 2], lineWidth: 3); + final copy = props.copyWith(gridStepX: 2, smooth: true); + expect(copy.points, [1, 2]); + expect(copy.lineWidth, 3); + expect(copy.gridStepX, 2); + expect(copy.smooth, isTrue); + expect(copy, isNot(props)); + expect(props.copyWith(), props); + }); + + test('snaps selections to the step and clamps them', () { + final props = ChartProperties( + xMax: 24, + selectionStep: 1, + selectionMin: 5, + selectionMax: 20, + ); + expect(props.snapSelection(7.9), 7); + expect(props.snapSelection(2), 5); + expect(props.snapSelection(23.5), 20); + + final open = ChartProperties(xMax: 24, selectionStep: 3); + expect(open.snapSelection(-1), 0); + expect(open.snapSelection(23.9), 21); + expect(ChartProperties(selectionStep: 0.1).snapSelection(0.35), 0.3); + expect(ChartProperties(selectionStep: 0).snapSelection(0.37), 0.37); + }); + + test('interpolates the curve between points', () { + final props = ChartProperties(points: [0, 10, 20], xMax: 2); + expect(props.valueAt(0), 0); + expect(props.valueAt(0.5), 5); + expect(props.valueAt(2), 20); + expect(props.valueAt(5), 20); + expect(ChartProperties().valueAt(0), isNull); + }); + }); +} diff --git a/test/nodes/layout/list_view_wrap_test.dart b/test/nodes/layout/list_view_wrap_test.dart new file mode 100644 index 00000000..e121e796 --- /dev/null +++ b/test/nodes/layout/list_view_wrap_test.dart @@ -0,0 +1,70 @@ +import 'dart:convert'; + +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:test/test.dart'; + +Map _roundTrip(Map json) => jsonDecode(jsonEncode(json)) as Map; + +ListViewNode _list(ListViewProperties properties) => ListViewNode( + id: 'list', + name: 'Tides', + basicBoxLocal: NodeBox(0, 0, 300, 100), + children: ['chip'], + properties: properties, +); + +void main() { + group('ListViewProperties wrap', () { + test('defaults are off and left out of the JSON', () { + final ListViewProperties properties = ListViewProperties(); + expect(properties.wrap, isFalse); + expect(properties.runSpacing, 0); + final Map json = properties.toJson(); + expect(json.containsKey('wrap'), isFalse); + expect(json.containsKey('runSpacing'), isFalse); + expect(_list(properties).toJson()['properties'], isNot(contains('wrap'))); + }); + + test('non-default values round-trip through the node', () { + final Map json = _roundTrip( + _list( + ListViewProperties( + itemCount: 4, + hasSeparator: true, + separatorSpacing: 8, + wrap: true, + runSpacing: 6, + ), + ).toJson(), + ); + expect(json['properties']['wrap'], true); + expect(json['properties']['runSpacing'], 6); + final ListViewNode node = ListViewNode.fromJson(json); + expect(node.properties.wrap, isTrue); + expect(node.properties.runSpacing, 6); + expect(node.properties.separatorSpacing, 8); + }); + + test('missing keys decode to defaults; copyWith and equality see them', () { + final ListViewProperties properties = ListViewProperties.fromJson({}); + expect(properties.wrap, isFalse); + expect(properties.runSpacing, 0); + final ListViewProperties wrapped = properties.copyWith( + wrap: true, + runSpacing: 4, + ); + expect(wrapped.wrap, isTrue); + expect(wrapped.runSpacing, 4); + expect(wrapped, isNot(properties)); + }); + + test('field access edits them', () { + final ListViewProperties properties = ListViewProperties(); + final fields = properties.generateFields(); + fields['wrap']!.setValue(true); + fields['runSpacing']!.setValue(8.0); + expect(properties.wrap, isTrue); + expect(properties.runSpacing, 8); + }); + }); +} diff --git a/test/show_dialog_action_test.dart b/test/show_dialog_action_test.dart new file mode 100644 index 00000000..35416435 --- /dev/null +++ b/test/show_dialog_action_test.dart @@ -0,0 +1,96 @@ +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:test/test.dart'; + +void main() { + test('a plain dialog keeps its old JSON', () { + final ActionModel action = ActionModel.fromJson({ + 'type': 'showDialog', + 'destinationId': 'c1', + }); + expect(action, isA()); + final ShowDialogAction dialog = action as ShowDialogAction; + expect(dialog.presentation, DialogPresentation.dialog); + expect(dialog.alignment, AlignmentModel.center); + expect(dialog.insets, EdgeInsetsModel.zero); + expect(dialog.toJson(), { + 'type': 'showDialog', + 'destinationId': 'c1', + 'params': {}, + }); + }); + + test('sheets and placed dialogs round-trip', () { + for (final Map json in >[ + { + 'type': 'showDialog', + 'destinationId': 'session', + 'params': {'id': r'${item.id}'}, + 'presentation': 'bottomSheet', + }, + { + 'type': 'showDialog', + 'destinationId': 'session', + 'params': {}, + 'presentation': 'sideSheet', + 'insets': 10, + }, + { + 'type': 'showDialog', + 'destinationId': 'settings', + 'params': {}, + 'alignment': { + 'data': [-1, 1], + }, + 'insets': [96, 0, 0, 16], + }, + ]) { + final ShowDialogAction action = + ActionModel.fromJson(json) as ShowDialogAction; + expect(ActionModel.fromJson(action.toJson()), action, reason: '$json'); + } + final ShowDialogAction popover = ActionModel.fromJson({ + 'type': 'showDialog', + 'destinationId': 'settings', + 'alignment': { + 'data': [-1, 1], + }, + 'insets': [96, 0, 0, 16], + }) as ShowDialogAction; + expect(popover.alignment, AlignmentModel.bottomLeft); + expect(popover.insets.l, 96); + expect(popover.insets.b, 16); + }); + + test('maxHeightFactor round-trips and is left out when unset', () { + final ShowDialogAction action = ActionModel.fromJson({ + 'type': 'showDialog', + 'destinationId': 'logForm', + 'maxHeightFactor': 0.9, + }) as ShowDialogAction; + expect(action.maxHeightFactor, 0.9); + expect(action.toJson()['maxHeightFactor'], 0.9); + expect(ActionModel.fromJson(action.toJson()), action); + + final ShowDialogAction plain = ShowDialogAction(destinationId: 'c1'); + expect(plain.maxHeightFactor, isNull); + expect(plain.toJson().containsKey('maxHeightFactor'), isFalse); + expect(plain == action.copyWith(destinationId: 'c1'), isFalse); + }); + + test('copyWith sets and clears maxHeightFactor', () { + final ShowDialogAction action = ShowDialogAction( + destinationId: 'logForm', + nonBlocking: true, + ); + final ShowDialogAction capped = action.copyWith(maxHeightFactor: 0.8); + expect(capped.maxHeightFactor, 0.8); + expect(capped.nonBlocking, isTrue); + expect( + capped + .copyWith(presentation: DialogPresentation.bottomSheet) + .maxHeightFactor, + 0.8, + ); + expect(capped.copyWith(clearMaxHeightFactor: true).maxHeightFactor, isNull); + }); +} diff --git a/test/style_binding_values_test.dart b/test/style_binding_values_test.dart new file mode 100644 index 00000000..11801775 --- /dev/null +++ b/test/style_binding_values_test.dart @@ -0,0 +1,72 @@ +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:test/test.dart'; + +void main() { + group('toCornerRadius', () { + test('a number rounds every corner', () { + expect( + 12.toCornerRadius(), + const CornerRadius.all(RadiusModel.circular(12)), + ); + expect( + '12'.toCornerRadius(), + const CornerRadius.all(RadiusModel.circular(12)), + ); + expect( + [8].toCornerRadius(), + const CornerRadius.all(RadiusModel.circular(8)), + ); + }); + + test( + 'a list of four is top-left, top-right, bottom-right, bottom-left', + () { + const expected = CornerRadius.only( + tl: RadiusModel.circular(18), + tr: RadiusModel.circular(18), + br: RadiusModel.circular(0), + bl: RadiusModel.circular(0), + ); + expect([18, 18, 0, 0].toCornerRadius(), expected); + expect('[18, 18, 0, 0]'.toCornerRadius(), expected); + expect([18, 18, 0, 0].typedValue(), expected); + }, + ); + + test("a CornerRadius's JSON still parses", () { + const radius = CornerRadius.only( + tl: RadiusModel.circular(4), + tr: RadiusModel.circular(6), + bl: RadiusModel.circular(8), + br: RadiusModel.circular(10), + ); + expect((radius.toJson() as Object?).toCornerRadius(), radius); + expect(radius.toCornerRadius(), radius); + }); + + test('anything else is null', () { + expect(null.toCornerRadius(), isNull); + expect('big'.toCornerRadius(), isNull); + expect([1, 2, 3].toCornerRadius(), isNull); + expect({'a': 1}.toCornerRadius(), isNull); + }); + }); + + group('toEdgeInsetsModel', () { + test('numbers, lists and text', () { + expect(12.toEdgeInsetsModel(), const EdgeInsetsModel.all(12)); + expect('12'.toEdgeInsetsModel(), const EdgeInsetsModel.all(12)); + expect([12].toEdgeInsetsModel(), const EdgeInsetsModel.all(12)); + expect( + [16, 8].toEdgeInsetsModel(), + const EdgeInsetsModel.symmetric(horizontal: 16, vertical: 8), + ); + expect( + '[1, 2, 3, 4]'.toEdgeInsetsModel(), + const EdgeInsetsModel.only(left: 1, top: 2, right: 3, bottom: 4), + ); + expect('wide'.toEdgeInsetsModel(), isNull); + expect('"wide"'.toEdgeInsetsModel(), isNull); + }); + }); +}