diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f4c05d..7aff3e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,29 @@ +## 7.2.0 + +- Require codelessly_api 5.2.0. +- Run the new actions: `auth` (sign up, sign in, sign out, update profile, + password reset), `conditional` (if/else), `forEach` and `cloudFiles` + (image uploads to the project's storage and file deletes). +- New variables: `${auth.uid / email / displayName / initial / isSignedIn}`, + and `${result}` for what an earlier action in the chain produced (such as + `${result.id}` of a new document). +- Cloud writes support field operations: `$increment`, `$arrayUnion`, + `$arrayRemove`, `$delete` and `$now`. Updates keep `createdAt`. +- Variable paths and accessors follow the value's type at every level + (`${item.memberIds.length}`, `${item.title.initial}`); new `initial`, + `timeAgo` and `date` accessors. +- Text with inline `${...}` paths rebuilds when those variables change. +- Canvas load actions run after the first frame, in one chain, and notify, + so fields bound to a variable set on load show its value. +- Nodes hidden by a condition at runtime collapse, including flexing ones. +- Scrollable rows and columns honor center and end alignment. +- "Load from cloud" waits for the cloud database and completes on the first + snapshot; documents with dates no longer stall the listener. +- Slug bundles load from `firebaseOptions.storageBucket`. +- Fixes: data-bound lists and grids in dialogs, navigation bar item clicks, + ink frame sizing, clearing text fields, empty image URLs, and outlined + buttons without a border width. + ## 7.1.1 - A badge with no child no longer fills loose constraints, so a badge set diff --git a/example/macos/Flutter/GeneratedPluginRegistrant.swift b/example/macos/Flutter/GeneratedPluginRegistrant.swift index 6bfb920..ccba0e2 100644 --- a/example/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/example/macos/Flutter/GeneratedPluginRegistrant.swift @@ -6,16 +6,20 @@ import FlutterMacOS import Foundation import cloud_firestore +import file_picker import firebase_auth import firebase_core +import firebase_storage import shared_preferences_foundation import url_launcher_macos import webview_flutter_wkwebview func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FLTFirebaseFirestorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseFirestorePlugin")) + FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) FLTFirebaseAuthPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAuthPlugin")) FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin")) + FLTFirebaseStoragePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseStoragePlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) WebViewFlutterPlugin.register(with: registry.registrar(forPlugin: "WebViewFlutterPlugin")) diff --git a/example/windows/flutter/generated_plugin_registrant.cc b/example/windows/flutter/generated_plugin_registrant.cc index a3c4d16..3419566 100644 --- a/example/windows/flutter/generated_plugin_registrant.cc +++ b/example/windows/flutter/generated_plugin_registrant.cc @@ -9,6 +9,7 @@ #include #include #include +#include #include void RegisterPlugins(flutter::PluginRegistry* registry) { @@ -18,6 +19,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("FirebaseAuthPluginCApi")); FirebaseCorePluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("FirebaseCorePluginCApi")); + FirebaseStoragePluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FirebaseStoragePluginCApi")); UrlLauncherWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("UrlLauncherWindows")); } diff --git a/example/windows/flutter/generated_plugins.cmake b/example/windows/flutter/generated_plugins.cmake index 9cebacb..24ac8df 100644 --- a/example/windows/flutter/generated_plugins.cmake +++ b/example/windows/flutter/generated_plugins.cmake @@ -6,6 +6,7 @@ list(APPEND FLUTTER_PLUGIN_LIST cloud_firestore firebase_auth firebase_core + firebase_storage url_launcher_windows ) diff --git a/lib/src/auth/auth_manager.dart b/lib/src/auth/auth_manager.dart index 38e5076..7545fd4 100644 --- a/lib/src/auth/auth_manager.dart +++ b/lib/src/auth/auth_manager.dart @@ -391,6 +391,18 @@ class AuthManager { ); } + /// Authenticates again for the user now signed in, after an auth action + /// changed it, so that user gets the project's claim before the app reads + /// or writes the project's data as them. + Future reauthenticate() async { + DebugLogger.instance.printFunction('reauthenticate()', name: name); + if (_disposed) return; + final User? user = firebaseAuth.currentUser; + if (user == null) return; + _idTokenResult = await user.getIdTokenResult(true); + await authenticate(); + } + /// Performs a handshake with the server to authenticate the token. /// Makes a network request to verify the auth token and retrieve project access. Future authenticate() async { diff --git a/lib/src/codelessly.dart b/lib/src/codelessly.dart index ca84fe3..cc81ad9 100644 --- a/lib/src/codelessly.dart +++ b/lib/src/codelessly.dart @@ -9,6 +9,7 @@ import 'package:flutter/widgets.dart'; import 'package:http/http.dart'; import '../codelessly_sdk.dart'; +import 'functions/app_auth.dart'; import 'logging/stat_tracker.dart'; import 'utils/codelessly_http_client.dart'; @@ -126,6 +127,28 @@ class Codelessly { /// Whether this Codelessly instance already initialized firebase or not. bool _didInitializeFirebase = false; + /// The app user that auth actions signed in, as the `auth` variable sees + /// it. Layouts that use `${auth...}` rebuild when it changes. + final ValueNotifier> appUser = ValueNotifier( + AppAuth.userToMap(null), + ); + + StreamSubscription? _appUserSubscription; + + /// Re-reads the signed-in user into [appUser], e.g. after its display name + /// changed, which Firebase doesn't announce. + void refreshAppUser() { + appUser.value = AppAuth.userToMap(_firebaseAuth?.currentUser); + } + + void _listenToAppUser() { + _appUserSubscription?.cancel(); + _appUserSubscription = _firebaseAuth?.userChanges().listen( + (User? user) => appUser.value = AppAuth.userToMap(user), + ); + refreshAppUser(); + } + /// A map of data that is passed to loaded layouts for nodes to replace their /// property values with. final Map data = {}; @@ -239,6 +262,7 @@ class Codelessly { _cacheManager?.dispose(); } _authManager?.dispose(); + _appUserSubscription?.cancel(); _publishDataManager?.dispose(); _previewDataManager?.dispose(); _templateDataManager?.dispose(); @@ -426,6 +450,7 @@ class Codelessly { // Initialize Firestore and FirebaseAuth instances. _firebaseFirestore = FirebaseFirestore.instanceFor(app: _firebaseApp!); _firebaseAuth = FirebaseAuth.instanceFor(app: _firebaseApp!); + _listenToAppUser(); _didInitializeFirebase = true; DebugLogger.instance.printInfo( @@ -468,6 +493,7 @@ class Codelessly { // Initialize Firestore and FirebaseAuth instances. _firebaseFirestore = FirebaseFirestore.instanceFor(app: _firebaseApp!); _firebaseAuth = FirebaseAuth.instanceFor(app: _firebaseApp!); + _listenToAppUser(); _didInitializeFirebase = true; diff --git a/lib/src/data/cloud_database.dart b/lib/src/data/cloud_database.dart index 8416ffc..70bbc39 100644 --- a/lib/src/data/cloud_database.dart +++ b/lib/src/data/cloud_database.dart @@ -169,7 +169,9 @@ abstract class CloudDatabase extends ChangeNotifier { /// /// [documentId] is the id of the document. If [documentId] is empty, then /// "default" is used as the document id. - void streamDocumentToVariable( + /// Streams a document into [variable]. Completes once the first snapshot + /// (or error) has been written to it. + Future streamDocumentToVariable( String path, String documentId, Observable variable, @@ -177,7 +179,9 @@ abstract class CloudDatabase extends ChangeNotifier { void reset(); - void streamCollectionToVariable( + /// Streams a query into [variable]. Completes once the first snapshot + /// (or error) has been written to it. + Future streamCollectionToVariable( String path, Observable variable, { required List whereFilters, @@ -343,7 +347,9 @@ class FirestoreCloudDatabase extends CloudDatabase { // Auto generate id if desired. if (autoGenerateId) { - final document = await rootRef.collection(path).add(value); + final document = await rootRef + .collection(path) + .add(resolveFieldOperations(value)); StatTracker.instance.track(StatType.write, 'cloudDatabase/addDocument'); DebugLogger.instance.printInfo( @@ -353,7 +359,7 @@ class FirestoreCloudDatabase extends CloudDatabase { // Sanitize data afterwards because we didn't have the docId before. value = sanitizeCloudDataToSend(value, docId: document.id); - await document.set(value); + await document.set(resolveFieldOperations(value)); return true; } @@ -378,7 +384,7 @@ class FirestoreCloudDatabase extends CloudDatabase { } // Set document. - await docRef.set(value); + await docRef.set(resolveFieldOperations(value)); StatTracker.instance.track(StatType.write, 'cloudDatabase/addDocument'); DebugLogger.instance.printInfo( @@ -406,11 +412,15 @@ class FirestoreCloudDatabase extends CloudDatabase { // await docRef.set(value); // } - // Sanitize data. - value = sanitizeCloudDataToSend(value, docId: documentId); + // Sanitize data. An update keeps the document's createdAt. + value = sanitizeCloudDataToSend( + value, + docId: documentId, + keepCreatedAt: true, + ); // TODO: Should we do update instead of set? - await docRef.set(value, SetOptions(merge: true)); + await docRef.set(resolveFieldOperations(value), SetOptions(merge: true)); StatTracker.instance.track(StatType.write, 'cloudDatabase/updateDocument'); DebugLogger.instance.printInfo( @@ -464,11 +474,12 @@ class FirestoreCloudDatabase extends CloudDatabase { } @override - void streamDocumentToVariable( + Future streamDocumentToVariable( String path, String documentId, Observable variable, ) { + final Completer firstSnapshot = Completer(); DebugLogger.instance.printFunction( 'streamDocumentToVariable(path: $path, documentId: $documentId)', name: name, @@ -505,6 +516,7 @@ class FirestoreCloudDatabase extends CloudDatabase { value: CloudDatabaseVariableUtils.success(data), ), ); + if (!firstSnapshot.isCompleted) firstSnapshot.complete(); }, onError: (error) { DebugLogger.instance.printInfo( @@ -517,10 +529,12 @@ class FirestoreCloudDatabase extends CloudDatabase { value: CloudDatabaseVariableUtils.error(error.toString()), ), ); + if (!firstSnapshot.isCompleted) firstSnapshot.complete(); }, ); _replaceSubscription(variable, subscription); + return firstSnapshot.future; } @override @@ -535,7 +549,7 @@ class FirestoreCloudDatabase extends CloudDatabase { } @override - void streamCollectionToVariable( + Future streamCollectionToVariable( String path, Observable variable, { required List whereFilters, @@ -544,6 +558,8 @@ class FirestoreCloudDatabase extends CloudDatabase { required ScopedValues scopedValues, required NullSubstitutionMode nullSubstitutionMode, }) { + final Completer firstSnapshot = Completer(); + // Get doc reference. final collectionRef = getCollectionPath(path); @@ -583,12 +599,22 @@ class FirestoreCloudDatabase extends CloudDatabase { 'Updating variable ${variable.value.name} with success state.', name: name, ); - // Set the variable with success state. - variable.set( - variable.value.copyWith( - value: CloudDatabaseVariableUtils.success(docs), - ), - ); + // Set the variable with success state. The chain waiting on the + // first snapshot continues even if a listener of the variable throws. + try { + variable.set( + variable.value.copyWith( + value: CloudDatabaseVariableUtils.success(docs), + ), + ); + } catch (error, stackTrace) { + DebugLogger.instance.printInfo( + 'Listener of ${variable.value.name} failed: $error\n$stackTrace', + name: name, + ); + } finally { + if (!firstSnapshot.isCompleted) firstSnapshot.complete(); + } }, onError: (error) { DebugLogger.instance.printInfo( @@ -601,10 +627,12 @@ class FirestoreCloudDatabase extends CloudDatabase { value: CloudDatabaseVariableUtils.error(error.toString()), ), ); + if (!firstSnapshot.isCompleted) firstSnapshot.complete(); }, ); _replaceSubscription(variable, subscription); + return firstSnapshot.future; } } @@ -649,6 +677,7 @@ Map sanitizeCloudDataToSend( bool hidePrivateFields = false, bool allowEmptyData = false, bool autoChangeUpdatedAt = true, + bool keepCreatedAt = false, }) { if (data.isEmpty && !allowEmptyData) return data; @@ -664,9 +693,11 @@ Map sanitizeCloudDataToSend( late final DateTime now = DateTime.now().toUtc(); if (autoChangeUpdatedAt) { - data[SDKConstants.createdAt] = serializedCosmicValue( - data[SDKConstants.createdAt] ?? now, - ); + if (!keepCreatedAt || data.containsKey(SDKConstants.createdAt)) { + data[SDKConstants.createdAt] = serializedCosmicValue( + data[SDKConstants.createdAt] ?? now, + ); + } data[SDKConstants.updatedAt] = serializedCosmicValue(now); } @@ -689,6 +720,49 @@ Map sanitizeCloudDataToSend( return data; } +/// Turns field operation markers in [data] into Firestore field values, so +/// actions can write them from JSON: +/// +/// * `{"$increment": 1}` adds to a number. +/// * `{"$arrayUnion": [...]}` adds items to a list that aren't in it yet. +/// * `{"$arrayRemove": [...]}` removes items from a list. +/// * `{"$delete": true}` removes the field (updates only). +/// * `{"$now": true}` writes the current time as an ISO 8601 string, the +/// format `createdAt` and `updatedAt` use. +Map resolveFieldOperations(Map data) => { + for (final MapEntry(:key, :value) in data.entries) + key: _resolveFieldOperation(value), +}; + +Object? _resolveFieldOperation(Object? value) { + if (value is List) return value.map(_resolveFieldOperation).toList(); + if (value is! Map) return value; + if (value.length == 1) { + final MapEntry entry = value.entries.first; + List items() => + entry.value is List ? List.from(entry.value) : [entry.value]; + switch (entry.key) { + case r'$increment': + final num? amount = entry.value is num + ? entry.value + : num.tryParse(entry.value.toString()); + return FieldValue.increment(amount ?? 0); + case r'$arrayUnion': + return FieldValue.arrayUnion(items()); + case r'$arrayRemove': + return FieldValue.arrayRemove(items()); + case r'$delete': + return FieldValue.delete(); + case r'$now': + return DateTime.now().toUtc().toIso8601String(); + } + } + return { + for (final MapEntry entry in value.entries) + entry.key.toString(): _resolveFieldOperation(entry.value), + }; +} + /// Returns a sanitized date string from the given [value]. /// Converts different representation of date to a string representation. String? serializedCosmicValue(Object? value) { diff --git a/lib/src/data/network_data_repository.dart b/lib/src/data/network_data_repository.dart index c2ea86b..228ae9a 100644 --- a/lib/src/data/network_data_repository.dart +++ b/lib/src/data/network_data_repository.dart @@ -33,8 +33,13 @@ abstract class NetworkDataRepository { ); try { + // Projects created since late 2024 use a `.firebasestorage.app` + // bucket, so take the bucket from the options when they name one. + final String bucket = + config.firebaseOptions.storageBucket ?? + '${config.firebaseOptions.projectId}.appspot.com'; final String url = - 'https://firebasestorage.googleapis.com/v0/b/${config.firebaseOptions.projectId}.appspot.com/o/${Uri.encodeComponent('${source.serverPath}/$slug.json')}?alt=media'; + 'https://firebasestorage.googleapis.com/v0/b/$bucket/o/${Uri.encodeComponent('${source.serverPath}/$slug.json')}?alt=media'; DebugLogger.instance.printInfo('Publish bundle URL: $url', name: name); final http.Response result = await http.get(Uri.parse(url)); diff --git a/lib/src/functions/action_chain.dart b/lib/src/functions/action_chain.dart new file mode 100644 index 0000000..bfe3a8b --- /dev/null +++ b/lib/src/functions/action_chain.dart @@ -0,0 +1,36 @@ +import 'dart:async'; + +/// The state shared by the actions of one chain: the actions a trigger runs +/// in order, or the actions nested in a for-each, an upload or an if/else. +/// +/// Its [result] is what the `${result}` variable resolves to: the value the +/// previous action produced (e.g. the id of a document it added), or the +/// current item while a for-each or an upload runs its nested actions. +class ActionChain { + /// Creates an [ActionChain] that starts with [result]. + ActionChain({this.result}); + + /// What `${result}` resolves to. + Object? result; + + static const Symbol _zoneKey = #codelesslyActionChain; + + /// The chain the running action belongs to, if any. + static ActionChain? get current => Zone.current[_zoneKey] as ActionChain?; + + /// Sets the current chain's result, when an action runs in a chain. Map + /// results merge into a map result, so values an earlier action produced + /// (e.g. an upload's `url`) stay readable after a later one adds its own. + static void setResult(Object? result) { + final ActionChain? chain = current; + if (chain == null) return; + final Object? previous = chain.result; + chain.result = previous is Map && result is Map + ? {...previous, ...result} + : result; + } + + /// Runs [body] in a new chain whose `${result}` starts as [result]. + static R run(R Function() body, {Object? result}) => + runZoned(body, zoneValues: {_zoneKey: ActionChain(result: result)}); +} diff --git a/lib/src/functions/app_auth.dart b/lib/src/functions/app_auth.dart new file mode 100644 index 0000000..11b0c8b --- /dev/null +++ b/lib/src/functions/app_auth.dart @@ -0,0 +1,134 @@ +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:firebase_auth/firebase_auth.dart'; +import 'package:flutter/widgets.dart'; +import 'package:provider/provider.dart'; + +import '../../codelessly_sdk.dart'; +import 'action_chain.dart'; + +/// Signs app users in and out for [AuthAction]s, and describes the signed-in +/// user to the `auth` variable. +abstract final class AppAuth { + static const String name = 'AppAuth'; + + /// The `auth` variable's value for [user]. Anonymous users, which the SDK + /// signs in on its own to read published data, count as signed out. + static Map userToMap(User? user) { + final bool signedIn = user != null && !user.isAnonymous; + final String displayName = signedIn ? user.displayName ?? '' : ''; + final String email = signedIn ? user.email ?? '' : ''; + final String initialSource = displayName.isNotEmpty ? displayName : email; + return { + 'isSignedIn': signedIn, + 'uid': signedIn ? user.uid : '', + 'email': email, + 'displayName': displayName, + 'initial': initialSource.isEmpty + ? '' + : initialSource.characters.first.toUpperCase(), + 'photoUrl': signedIn ? user.photoURL ?? '' : '', + }; + } + + /// A readable message for a Firebase Auth [error]. + static String messageFor(Object error) { + if (error is! FirebaseAuthException) return error.toString(); + return switch (error.code) { + 'invalid-credential' || + 'wrong-password' || + 'user-not-found' || + 'INVALID_LOGIN_CREDENTIALS' => 'Wrong email or password.', + 'email-already-in-use' => 'An account already exists for that email.', + 'weak-password' => 'Password must be at least 6 characters.', + 'invalid-email' => 'That email address is not valid.', + 'missing-password' => 'Enter your password.', + 'user-disabled' => 'This account has been disabled.', + 'too-many-requests' => 'Too many attempts. Try again later.', + 'network-request-failed' => 'Network error. Check your connection.', + _ => error.message ?? error.code, + }; + } + + /// Performs [action]. Throws when it fails, so the actions after it in the + /// chain don't run, after writing the message to its error variable. + static Future perform(BuildContext context, AuthAction action) async { + final ScopedValues scopedValues = ScopedValues.of(context); + final Codelessly codelessly = context.read(); + final CodelesslyContext codelesslyContext = context + .read(); + + String eval(String value) => PropertyValueDelegate.substituteVariables( + value, + nullSubstitutionMode: NullSubstitutionMode.emptyString, + scopedValues: scopedValues, + ).trim(); + + void setError(String message) { + final VariableData? variable = action.errorVariable; + if (variable == null) return; + final notifier = codelesslyContext.findVariableByName(variable.name); + notifier?.set(notifier.value.copyWith(value: message)); + } + + final FirebaseAuth auth = codelessly.firebaseAuth; + final String email = eval(action.email); + final String displayName = eval(action.displayName); + DebugLogger.instance.printInfo( + 'Auth action ${action.operation.name} for [$email]', + name: name, + ); + + try { + switch (action.operation) { + case AuthOperation.signIn: + await auth.signInWithEmailAndPassword( + email: email, + password: eval(action.password), + ); + case AuthOperation.signUp: + final UserCredential credential = await auth + .createUserWithEmailAndPassword( + email: email, + password: eval(action.password), + ); + if (displayName.isNotEmpty) { + await credential.user?.updateDisplayName(displayName); + } + case AuthOperation.signOut: + await auth.signOut(); + // The SDK needs a user to read the project's data. + await auth.signInAnonymously(); + case AuthOperation.updateProfile: + await auth.currentUser?.updateDisplayName(displayName); + case AuthOperation.sendPasswordResetEmail: + await auth.sendPasswordResetEmail(email: email); + } + + if (action.operation != AuthOperation.sendPasswordResetEmail) { + await auth.currentUser?.reload(); + codelessly.refreshAppUser(); + } + + // A different user needs the project's claim before it can read or + // write the project's data. + if (action.operation + case AuthOperation.signIn || + AuthOperation.signUp || + AuthOperation.signOut) { + await codelessly.authManager.reauthenticate(); + } + } catch (error) { + final String message = messageFor(error); + DebugLogger.instance.printInfo( + 'Auth action ${action.operation.name} failed: $message', + name: name, + ); + setError(message); + rethrow; + } + + setError(''); + ActionChain.setResult(userToMap(auth.currentUser)); + return true; + } +} diff --git a/lib/src/functions/cloud_files.dart b/lib/src/functions/cloud_files.dart new file mode 100644 index 0000000..1cfc8d4 --- /dev/null +++ b/lib/src/functions/cloud_files.dart @@ -0,0 +1,184 @@ +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:file_picker/file_picker.dart'; +import 'package:firebase_storage/firebase_storage.dart'; +import 'package:flutter/widgets.dart'; +import 'package:provider/provider.dart'; + +import '../../codelessly_sdk.dart'; +import 'action_chain.dart'; + +/// Uploads and deletes files in the project's cloud storage folder for +/// [CloudFilesAction]s. +abstract final class CloudFiles { + static const String name = 'CloudFiles'; + + static const Map _contentTypes = { + 'jpg': 'image/jpeg', + 'jpeg': 'image/jpeg', + 'png': 'image/png', + 'gif': 'image/gif', + 'webp': 'image/webp', + 'heic': 'image/heic', + 'bmp': 'image/bmp', + 'svg': 'image/svg+xml', + 'pdf': 'application/pdf', + }; + + /// The content type for a file [extension]. + static String contentTypeFor(String extension) => + _contentTypes[extension.toLowerCase()] ?? 'application/octet-stream'; + + /// The storage folder of the project the [codelessly] instance runs, kept + /// apart for published and preview data like the cloud database. + static String rootFolder(Codelessly codelessly) { + final CloudDatabase? database = codelessly.cloudDatabase; + if (database == null) { + throw StateError('Cloud storage is not ready yet.'); + } + final String root = switch (database.publishSource) { + PublishSource.publish => 'data', + PublishSource.preview => 'preview_data', + PublishSource.template => 'template_data', + }; + return '$root/${database.identifier}'; + } + + /// Joins a relative [path] to the project's folder, dropping empty and + /// `..` segments so a path can't leave it. + static String resolve(Codelessly codelessly, String path) { + final List segments = path + .split('/') + .map((segment) => segment.trim()) + .where((segment) => segment.isNotEmpty && segment != '..') + .toList(); + return [rootFolder(codelessly), ...segments].join('/'); + } + + /// Performs [action]. + static Future perform( + BuildContext context, + CloudFilesAction action, { + required Future Function(List actions) runActions, + }) async { + final ScopedValues scopedValues = ScopedValues.of(context); + final Codelessly codelessly = context.read(); + final String path = PropertyValueDelegate.substituteVariables( + action.path, + nullSubstitutionMode: NullSubstitutionMode.emptyString, + scopedValues: scopedValues, + ); + final FirebaseStorage storage = FirebaseStorage.instanceFor( + app: codelessly.firebaseApp, + ); + + switch (action.operation) { + case CloudFilesOperation.delete: + if (path.trim().isEmpty) return true; + final Reference ref = path.startsWith('http') + ? storage.refFromURL(path) + : storage.ref( + path.startsWith(rootFolder(codelessly)) + ? path + : resolve(codelessly, path), + ); + if (!ref.fullPath.startsWith('${rootFolder(codelessly)}/')) { + throw StateError('Files outside the project can\'t be deleted.'); + } + try { + await ref.delete(); + } on FirebaseException catch (error) { + // Already gone is what the action wants. + if (error.code != 'object-not-found') rethrow; + } + return true; + case CloudFilesOperation.upload: + return _upload( + context, + action, + codelessly, + storage, + path, + runActions: runActions, + ); + } + } + + static Future _upload( + BuildContext context, + CloudFilesAction action, + Codelessly codelessly, + FirebaseStorage storage, + String folder, { + required Future Function(List actions) runActions, + }) async { + final CodelesslyContext codelesslyContext = context + .read(); + final FilePickerResult? picked = await FilePicker.platform.pickFiles( + type: action.fileType == CloudFilesType.image + ? FileType.image + : FileType.any, + allowMultiple: action.allowMultiple, + withData: true, + ); + final List files = [ + for (final PlatformFile file in picked?.files ?? const []) + if (file.bytes != null && file.size <= action.maxSizeMB * 1024 * 1024) + file, + ]; + if (files.isEmpty) return true; + + void setProgress(bool uploading, int current) { + final VariableData? variable = action.progressVariable; + if (variable == null) return; + final notifier = codelesslyContext.findVariableByName(variable.name); + notifier?.set( + notifier.value.copyWith( + value: { + 'uploading': uploading, + 'current': current, + 'total': files.length, + }, + ), + ); + } + + final String resolvedFolder = resolve(codelessly, folder); + try { + for (int index = 0; index < files.length; index++) { + final PlatformFile file = files[index]; + setProgress(true, index + 1); + final String extension = (file.extension ?? 'bin').toLowerCase(); + final String id = generateId(); + final Reference ref = storage.ref('$resolvedFolder/$id.$extension'); + await ref.putData( + file.bytes!, + SettableMetadata(contentType: contentTypeFor(extension)), + ); + final String url = await ref.getDownloadURL(); + DebugLogger.instance.printInfo( + 'Uploaded ${file.name} to ${ref.fullPath}', + name: name, + ); + + if (action.actions.isEmpty || !context.mounted) continue; + await ActionChain.run( + () => runActions(action.actions), + result: { + 'url': url, + 'path': ref.fullPath, + 'id': id, + 'name': file.name, + 'extension': extension, + 'contentType': contentTypeFor(extension), + 'size': file.size, + 'index': index, + 'total': files.length, + }, + ); + } + } finally { + setProgress(false, 0); + } + return true; + } +} diff --git a/lib/src/functions/functions_repository.dart b/lib/src/functions/functions_repository.dart index 98d9993..4537e3e 100644 --- a/lib/src/functions/functions_repository.dart +++ b/lib/src/functions/functions_repository.dart @@ -13,6 +13,9 @@ import 'package:url_launcher/url_launcher.dart'; import '../../codelessly_sdk.dart'; import '../logging/stat_tracker.dart'; import '../ui/codelessly_dialog_widget.dart'; +import 'action_chain.dart'; +import 'app_auth.dart'; +import 'cloud_files.dart'; /// Enum representing the types of API requests. enum ApiRequestType { @@ -119,9 +122,92 @@ class FunctionsRepository { context, action as LoadFromCloudStorageAction, ); + case ActionType.auth: + return AppAuth.perform(context, action as AuthAction); + case ActionType.conditional: + return runConditional(context, action as ConditionalAction); + case ActionType.forEach: + return runForEach(context, action as ForEachAction); + case ActionType.cloudFiles: + return CloudFiles.perform( + context, + action as CloudFilesAction, + runActions: (actions) => runActions(context, actions), + ); + } + } + + /// Runs [actions] in order, like a trigger's reactions: blocking actions + /// are awaited and a failure stops the rest by throwing. + static Future runActions( + BuildContext context, + List actions, + ) async { + for (final ActionModel action in actions) { + if (!context.mounted) return; + final FutureOr future = performAction(context, action); + if (action.nonBlocking) { + if (future is Future) unawaited(future.then((_) {})); + } else { + await future; + } } } + /// Runs the branch of [action] its expression picks. + static Future runConditional( + BuildContext context, + ConditionalAction action, + ) async { + final bool matches = + action.expression.accept( + ConditionEvaluator(scopedValues: ScopedValues.of(context)), + ) == + true; + DebugLogger.instance.printInfo( + 'Conditional action took the ${matches ? 'if' : 'else'} branch.', + name: name, + ); + await runActions(context, matches ? action.actions : action.elseActions); + return true; + } + + /// Runs the nested actions of [action] once per item of its list, with + /// `${result.item}` and `${result.index}` set. + static Future runForEach( + BuildContext context, + ForEachAction action, + ) async { + final ScopedValues scopedValues = ScopedValues.of(context); + final String path = action.items.trim(); + final Object? value = path.isEmpty + ? null + : PropertyValueDelegate.getVariableValueFromPath( + path.wrapWithVariableSyntax(), + scopedValues: scopedValues, + ); + // A cloud variable keeps its documents under `data`. + final Object? items = value is Map && value['data'] is List + ? value['data'] + : value; + if (items is! List) { + DebugLogger.instance.printInfo( + 'For each: [${action.items}] is not a list. Skipping.', + name: name, + ); + return true; + } + final List snapshot = List.of(items); + for (int index = 0; index < snapshot.length; index++) { + if (!context.mounted) break; + await ActionChain.run( + () => runActions(context, action.actions), + result: {'item': snapshot[index], 'index': index}, + ); + } + return true; + } + static Future makeApiRequestFromAction( ApiCallAction action, BuildContext context, [ @@ -1044,6 +1130,16 @@ ${response.body.contains('{') ? const JsonEncoder.withIndent(' ').convert(json. if (filteredReactions.isEmpty) return false; + return ActionChain.run( + () => _runReactions(context, filteredReactions, value), + ); + } + + static Future _runReactions( + BuildContext context, + Iterable filteredReactions, + dynamic value, + ) async { for (final reaction in filteredReactions) { if (!context.mounted) continue; final FutureOr future; @@ -1432,6 +1528,8 @@ ${response.body.contains('{') ? const JsonEncoder.withIndent(' ').convert(json. BuildContext context, SetCloudStorageAction action, ) async { + await awaitCloudDatabase(context); + if (!context.mounted) return false; final ScopedValues scopedValues = ScopedValues.of(context); return await switch (action.subAction) { AddDocumentSubAction addDocumentSubAction => addDocumentToCloud( @@ -1472,42 +1570,25 @@ ${response.body.contains('{') ? const JsonEncoder.withIndent(' ').convert(json. Map data = {}; if (subAction.useRawValue) { - // Substitute variables in raw value. - final updatedValue = PropertyValueDelegate.substituteVariables( - subAction.rawValue, - nullSubstitutionMode: NullSubstitutionMode.emptyString, - scopedValues: scopedValues, - ); - // Parse to JSON. - final parsed = tryJsonDecode(updatedValue); - data = switch (parsed) { - null => {}, - Map map => Map.from(map), - _ => {'data': parsed}, - }; + data = _jsonFromTemplate(subAction.rawValue, scopedValues); } else { - // Substitute variables in value. - final updatedValue = PropertyValueDelegate.substituteVariables( - subAction.newValue, - nullSubstitutionMode: NullSubstitutionMode.emptyString, - scopedValues: scopedValues, - ); - // Parse to JSON. - final parsed = tryJsonDecode(updatedValue); - data = switch (parsed) { - null => {}, - Map map => Map.from(map), - _ => {'data': parsed}, - }; + data = _jsonFromTemplate(subAction.newValue, scopedValues); } - return await cloudDatabase.addDocument( + // Pick the id here when generating one, so `${result.id}` can tell the + // following actions which document was added. + final String documentId = subAction.autoGenerateId + ? generateId() + : evaluatedDocumentId; + final bool added = await cloudDatabase.addDocument( evaluatedPath, value: data, - documentId: evaluatedDocumentId, - autoGenerateId: subAction.autoGenerateId, - skipCreationIfDocumentExists: subAction.skipCreationIfDocumentExists, + documentId: documentId, + skipCreationIfDocumentExists: + !subAction.autoGenerateId && subAction.skipCreationIfDocumentExists, ); + ActionChain.setResult({'id': documentId, 'path': evaluatedPath}); + return added; } catch (error, stackTrace) { DebugLogger.instance.printInfo(error.toString(), name: name); DebugLogger.instance.printInfo(stackTrace.toString(), name: name); @@ -1515,6 +1596,32 @@ ${response.body.contains('{') ? const JsonEncoder.withIndent(' ').convert(json. } } + /// The document data a JSON [template] describes once its variables are + /// substituted. See [PropertyValueDelegate.substituteVariablesInJson]. + static Map _jsonFromTemplate( + String template, + ScopedValues scopedValues, + ) { + final Map? data = + PropertyValueDelegate.substituteVariablesInJson( + template, + scopedValues: scopedValues, + ); + if (data != null) return data; + final Object? parsed = tryJsonDecode( + PropertyValueDelegate.substituteVariables( + template, + nullSubstitutionMode: NullSubstitutionMode.emptyString, + scopedValues: scopedValues, + ), + ); + return switch (parsed) { + null => {}, + Map map => Map.from(map), + _ => {'data': parsed}, + }; + } + static Future updateDocumentOnCloud( UpdateDocumentSubAction subAction, ScopedValues scopedValues, @@ -1538,14 +1645,10 @@ ${response.body.contains('{') ? const JsonEncoder.withIndent(' ').convert(json. ); if (subAction.useRawValue) { - // Substitute variables in raw value. - final updatedValue = PropertyValueDelegate.substituteVariables( + final Map data = _jsonFromTemplate( subAction.rawValue, - nullSubstitutionMode: NullSubstitutionMode.emptyString, - scopedValues: scopedValues, + scopedValues, ); - // Parse to JSON. - final Map data = jsonDecode(updatedValue); return await cloudDatabase.updateDocument( evaluatedPath, @@ -1709,6 +1812,23 @@ ${response.body.contains('{') ? const JsonEncoder.withIndent(' ').convert(json. } } + /// The project's cloud database, waiting for it when an action runs + /// before it is ready: on a reload the app user is restored, and a screen + /// can load data, before the SDK has verified the project's data access. + static Future awaitCloudDatabase( + BuildContext context, { + Codelessly? codelessly, + Duration timeout = const Duration(seconds: 20), + }) async { + codelessly ??= context.read(); + final DateTime deadline = DateTime.now().add(timeout); + while (codelessly.cloudDatabase == null && + DateTime.now().isBefore(deadline)) { + await Future.delayed(const Duration(milliseconds: 100)); + } + return codelessly.cloudDatabase; + } + static Future loadFromStorageAction( BuildContext context, LoadFromCloudStorageAction action, @@ -1737,13 +1857,19 @@ ${response.body.contains('{') ? const JsonEncoder.withIndent(' ').convert(json. value: CloudDatabaseVariableUtils.loading(), ); - final CloudDatabase? cloudDatabase = scopedValues.cloudDatabase; + final Codelessly codelessly = context.read(); + final CloudDatabase? cloudDatabase = + scopedValues.cloudDatabase ?? + await awaitCloudDatabase(context, codelessly: codelessly); if (cloudDatabase == null) { DebugLogger.instance.printInfo( - 'Cloud storage is null. Waiting for it to initialize...', + 'Cloud storage did not initialize.', name: name, ); + variable.value = variable.value.copyWith( + value: CloudDatabaseVariableUtils.error('Cloud data is unavailable.'), + ); return; } @@ -1757,7 +1883,7 @@ ${response.body.contains('{') ? const JsonEncoder.withIndent(' ').convert(json. 'Streaming document from cloud storage: $evaluatedPath/$evaluatedDocumentId', name: name, ); - cloudDatabase.streamDocumentToVariable( + await cloudDatabase.streamDocumentToVariable( evaluatedPath, evaluatedDocumentId, variable, @@ -1768,13 +1894,13 @@ ${response.body.contains('{') ? const JsonEncoder.withIndent(' ').convert(json. name: name, ); - cloudDatabase.streamCollectionToVariable( + await cloudDatabase.streamCollectionToVariable( evaluatedPath, variable, whereFilters: action.whereFilters, orderByOperations: action.orderByFilters, limit: (action.limit ?? 0) > 0 ? action.limit : null, - scopedValues: ScopedValues.of(context), + scopedValues: scopedValues, nullSubstitutionMode: NullSubstitutionMode.emptyString, ); } diff --git a/lib/src/transformers/node_transformers/passive_canvas_transformer.dart b/lib/src/transformers/node_transformers/passive_canvas_transformer.dart index f6c1415..73700b4 100644 --- a/lib/src/transformers/node_transformers/passive_canvas_transformer.dart +++ b/lib/src/transformers/node_transformers/passive_canvas_transformer.dart @@ -7,6 +7,7 @@ import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; import '../../../codelessly_sdk.dart'; +import '../../functions/action_chain.dart'; class PassiveCanvasTransformer extends NodeWidgetTransformer { static const String name = 'PassiveCanvasWidget'; @@ -475,18 +476,22 @@ class _PassiveCanvasWidgetState extends State { 'Performing actions on canvas load', name: PassiveCanvasTransformer.name, ); - executeActionAt(0, onLoadActions); + // After the first frame, so actions that navigate or show dialogs (e.g. + // an if/else that redirects signed out users) don't run mid-build. The + // actions share one chain so `${result}` flows between them. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + ActionChain.run(() => executeActionAt(0, onLoadActions)); + }); } void executeActionAt(int index, List actions) { if (index >= actions.length) return; final action = actions[index]; - final future = FunctionsRepository.performAction( - context, - action, - notify: false, - ); + // Notifies: the canvas has built by now, so widgets bound to a variable + // an action sets (a form prefilled on load) must rebuild. + final future = FunctionsRepository.performAction(context, action); if (future is! Future || action.nonBlocking) { executeActionAt(index + 1, actions); diff --git a/lib/src/transformers/node_transformers/passive_grid_view_transformer.dart b/lib/src/transformers/node_transformers/passive_grid_view_transformer.dart index 47b4829..581ae83 100644 --- a/lib/src/transformers/node_transformers/passive_grid_view_transformer.dart +++ b/lib/src/transformers/node_transformers/passive_grid_view_transformer.dart @@ -174,7 +174,7 @@ class PassiveGridViewWidget extends StatelessWidget { primary: node.primary, gridDelegate: node.properties.gridDelegate, shrinkWrap: - node.properties.itemCount != null && + itemCount != null && (node.scrollDirection == AxisC.horizontal ? node.isHorizontalWrap : node.isVerticalWrap), diff --git a/lib/src/transformers/node_transformers/passive_list_view_transformer.dart b/lib/src/transformers/node_transformers/passive_list_view_transformer.dart index 787a78b..054340b 100644 --- a/lib/src/transformers/node_transformers/passive_list_view_transformer.dart +++ b/lib/src/transformers/node_transformers/passive_list_view_transformer.dart @@ -183,9 +183,11 @@ class PassiveListViewWidget extends StatelessWidget { // https://stackoverflow.com/a/54587532/4327834 final ScrollableState? parentScrollable = Scrollable.maybeOf(context); + // A list of a known length that wraps its content on the scroll axis + // sizes itself to its items, including one bound to a data variable. final bool shrinkWrap = parentScrollable != null ? true - : node.properties.itemCount != null && + : itemCount != null && (node.scrollDirection == AxisC.horizontal ? node.isHorizontalWrap : node.isVerticalWrap); diff --git a/lib/src/transformers/node_transformers/passive_navigation_bar_transformer.dart b/lib/src/transformers/node_transformers/passive_navigation_bar_transformer.dart index 8a1090b..0cd0378 100644 --- a/lib/src/transformers/node_transformers/passive_navigation_bar_transformer.dart +++ b/lib/src/transformers/node_transformers/passive_navigation_bar_transformer.dart @@ -30,6 +30,12 @@ class PassiveNavigationBarTransformer TriggerType.changed, reactions: item.reactions, ); + // Picking a destination is a tap on its item, so click reactions run too. + FunctionsRepository.triggerAction( + context, + TriggerType.click, + reactions: item.reactions, + ); } Widget buildFromProps( diff --git a/lib/src/transformers/node_transformers/passive_rectangle_transformer.dart b/lib/src/transformers/node_transformers/passive_rectangle_transformer.dart index cc778d3..425f69d 100644 --- a/lib/src/transformers/node_transformers/passive_rectangle_transformer.dart +++ b/lib/src/transformers/node_transformers/passive_rectangle_transformer.dart @@ -144,6 +144,8 @@ class PassiveRectangleWidget extends StatelessWidget { stackAlignment: stackAlignment, applyPadding: applyPadding, ), + alignment: + stackAlignment.flutterAlignment ?? AlignmentDirectional.topStart, ), ], ); @@ -177,8 +179,9 @@ class PassiveRectangleWidget extends StatelessWidget { List wrapWithInkWell( BuildContext context, BaseNode node, - List children, -) { + List children, { + AlignmentGeometry alignment = AlignmentDirectional.topStart, +}) { if (node is! ReactionMixin) return children; final InkWellModel? inkWell = node is BlendMixin ? node.inkWell : null; @@ -220,10 +223,18 @@ List wrapWithInkWell( highlightColor: inkWell.highlightColor?.toFlutterColor(), hoverColor: inkWell.hoverColor?.toFlutterColor(), focusColor: inkWell.focusColor?.toFlutterColor(), - child: Stack(children: children), + child: Stack(alignment: alignment, children: children), ), ); + // A node with a set size fills it with its ink and children. Otherwise the + // inner stack sizes to its unpositioned children, and when those are + // hidden (a likes badge on a photo tile) everything else in it gets no + // space. + if (node.horizontalFit != SizeFit.shrinkWrap && + node.verticalFit != SizeFit.shrinkWrap) { + return [Positioned.fill(child: widget)]; + } return [widget]; } @@ -720,6 +731,11 @@ Widget buildFill( nullSubstitutionMode: settings.nullSubstitutionMode, scopedValues: scopedValues, ); + // A URL path that resolves to nothing, like a cover that isn't set + // yet, has no image to load. + if (bytes == null && imageURL.trim().isEmpty) { + return const SizedBox.shrink(); + } Widget child; if (imageFillBuilder != null) { 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 affaecc..744e519 100644 --- a/lib/src/transformers/node_transformers/passive_row_column_transformer.dart +++ b/lib/src/transformers/node_transformers/passive_row_column_transformer.dart @@ -79,6 +79,8 @@ class PassiveRowColumnTransformer extends NodeWidgetTransformer { res = wrapWithPaddedScrollable( padding: rowColumnNode.resolvedPadding().flutterEdgeInsets, node: (rowColumnNode as ScrollableMixin), + fillViewport: + rowColumnNode.mainAxisAlignment != MainAxisAlignmentC.start, child: res, ); } @@ -112,6 +114,8 @@ class PassiveRowColumnTransformer extends NodeWidgetTransformer { res = wrapWithPaddedScrollable( padding: rowColumnNode.resolvedPadding().flutterEdgeInsets, node: (rowColumnNode as ScrollableMixin), + fillViewport: + rowColumnNode.mainAxisAlignment != MainAxisAlignmentC.start, child: res, ); } @@ -281,6 +285,45 @@ class PassiveRowColumnWidget extends StatelessWidget { final NodeTransformerManager manager; final WidgetBuildSettings settings; + /// A flexing child hidden at runtime gives up its share of the free space, + /// so a visible sibling (an empty state next to a hidden spinner) can take + /// all of it. The flex drops to zero rather than removing the [Flexible] so + /// the child keeps its state when it shows again. + Widget _collapseWhenHidden( + BuildContext context, + BaseNode child, + Widget sizedWidget, + ) { + final NodeTransformerManager manager = this.manager; + if (sizedWidget is! Flexible || manager is! PassiveNodeTransformerManager) { + return sizedWidget; + } + + Widget buildFlexible(BuildContext context) { + final bool hidden = manager.runtimeVisibility(context, child) == false; + // Without flex the hidden child would be laid out with unbounded + // main-axis constraints, which a scrollable inside it can't take + // (a hidden grid computed NaN offsets), so it gets no space at all. + return Flexible( + flex: hidden ? 0 : sizedWidget.flex, + fit: sizedWidget.fit, + child: SizedBox( + width: hidden ? 0 : null, + height: hidden ? 0 : null, + child: sizedWidget.child, + ), + ); + } + + final List listenables = manager.listenablesFor(context, child); + if (listenables.isEmpty) return buildFlexible(context); + return ManagedListenableBuilder( + key: ValueKey('${child.id}-flex'), + listenables: listenables, + builder: buildFlexible, + ); + } + @override Widget build(BuildContext context) { final List widgetChildren = children.map((child) { @@ -290,12 +333,14 @@ class PassiveRowColumnWidget extends StatelessWidget { settings: settings, ); - return PassiveRowColumnTransformer.wrapChildWithSizeFits( - child, - node, - builtWidget, - child.flex, - ); + final Widget sizedWidget = + PassiveRowColumnTransformer.wrapChildWithSizeFits( + child, + node, + builtWidget, + child.flex, + ); + return _collapseWhenHidden(context, child, sizedWidget); }).toList(); final Widget child = (node is DefaultShapeNode) 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 e82c8ed..fd47c34 100644 --- a/lib/src/transformers/node_transformers/passive_text_field_transformer.dart +++ b/lib/src/transformers/node_transformers/passive_text_field_transformer.dart @@ -138,9 +138,21 @@ class PassiveTextFieldWidget extends StatefulWidget { class _PassiveTextFieldWidgetState extends State { late final TextEditingController controller = TextEditingController( - text: getInitialText(widget.node.initialText), + text: boundValue() ?? getInitialText(widget.node.initialText), ); + /// The current value of the variable this field is bound to, if any. A + /// variable set before the field first builds (by a canvas load action + /// that prefills a form) shows up in the field. + String? boundValue() { + if (widget.node.variables['inputValue'] == null) return null; + return PropertyValueDelegate.getPropertyValue( + widget.node, + 'inputValue', + scopedValues: ScopedValues.of(context), + ); + } + final FocusNode focusNode = FocusNode(); String getInitialText(String text) { @@ -175,12 +187,16 @@ class _PassiveTextFieldWidgetState extends State { // different from the current controller text. // widget.node.variables['inputValue'] only works when a sync variable is specified. If it is not then // we rely on initial text to be able to see if controller text needs to be updated. - String? currentPropertyValue = widget.node.variables['inputValue'] != null + final String? boundPath = widget.node.variables['inputValue']; + String? currentPropertyValue = boundPath != null ? PropertyValueDelegate.getPropertyValue( - widget.node, - 'inputValue', - scopedValues: scopedValues, - ) + widget.node, + 'inputValue', + scopedValues: scopedValues, + ) ?? + // An empty text variable reads as null; a field bound to one + // still clears, e.g. after a comment is sent. + (_isExistingVariable(boundPath, scopedValues) ? '' : null) : findIndexedValueIfApplied(scopedValues) ?? PropertyValueDelegate.getPropertyValueFromNodeValues( widget.node, @@ -201,6 +217,16 @@ class _PassiveTextFieldWidgetState extends State { } } + bool _isExistingVariable(String path, ScopedValues scopedValues) { + final VariableMatch? match = VariableMatch.parse( + path.wrapWithVariableSyntax(), + ); + if (match == null || match.hasPath || match.isPredefinedVariable) { + return false; + } + return scopedValues.variables.values.findByNameOrNull(match.name) != null; + } + /// The purpose of this function is to find the indexed value of the variable /// 'item' if it is applied to the initial text. This is used to determine if /// the controller text needs to be updated when the variable 'inputValue' is diff --git a/lib/src/transformers/passive_transformer_manager.dart b/lib/src/transformers/passive_transformer_manager.dart index 3957dfc..e62cd53 100644 --- a/lib/src/transformers/passive_transformer_manager.dart +++ b/lib/src/transformers/passive_transformer_manager.dart @@ -137,6 +137,34 @@ class PassiveNodeTransformerManager extends WidgetNodeTransformerManager { if (!enabled) return const SizedBox.shrink(); + final CodelesslyContext codelesslyContext = context + .read(); + final List listenables = listenablesFor(context, node); + + return NodeStateProviderWidget( + key: ValueKey(node.id), + node: node, + codelesslyContext: codelesslyContext, + child: Builder( + builder: (context) { + if (listenables.isNotEmpty) { + return ManagedListenableBuilder( + key: ValueKey(node.id), + listenables: listenables, + builder: (context) => builder(context), + ); + } else { + return builder(context); + } + }, + ), + ); + } + + /// Returns what the widget of [node] rebuilds on: the node's local values, + /// the variables its properties use and the variables of the conditions + /// that affect it. + List listenablesFor(BuildContext context, BaseNode node) { final CodelesslyContext codelesslyContext = context .read(); @@ -166,6 +194,9 @@ class PassiveNodeTransformerManager extends WidgetNodeTransformerManager { // handle storage variable final notifier = getStorageListenerFor(match, context); if (notifier != null) listenables.add(notifier); + case 'auth': + final notifier = context.read()?.appUser; + if (notifier != null) listenables.add(notifier); } } @@ -178,8 +209,17 @@ class PassiveNodeTransformerManager extends WidgetNodeTransformerManager { // Traverse through all the multi-variables that the node properties are attached // to. - for (final String variablePath - in node.multipleVariables.values.expand((path) => path).toSet()) { + // Text written with inline `${...}` paths listens to them too, so text + // stays live whether or not its multipleVariables were filled in. + final Set multiplePaths = { + ...node.multipleVariables.values.expand((path) => path), + if (node is TextNode) + ...variablePathRegex + .allMatches(node.characters) + .map((match) => match.namedGroup('value')) + .nonNulls, + }; + for (final String variablePath in multiplePaths) { final match = VariableMatch.parse(variablePath.wrapWithVariableSyntax()); if (match == null) continue; @@ -193,6 +233,9 @@ class PassiveNodeTransformerManager extends WidgetNodeTransformerManager { // handle storage variable final notifier = getStorageListenerFor(match, context); if (notifier != null) listenables.add(notifier); + case 'auth': + final notifier = context.read()?.appUser; + if (notifier != null) listenables.add(notifier); } } @@ -220,6 +263,9 @@ class PassiveNodeTransformerManager extends WidgetNodeTransformerManager { context, ); if (notifier != null) listenables.add(notifier); + case 'auth': + final notifier = context.read()?.appUser; + if (notifier != null) listenables.add(notifier); } continue; } @@ -233,24 +279,7 @@ class PassiveNodeTransformerManager extends WidgetNodeTransformerManager { } } - return NodeStateProviderWidget( - key: ValueKey(node.id), - node: node, - codelesslyContext: codelesslyContext, - child: Builder( - builder: (context) { - if (listenables.isNotEmpty) { - return ManagedListenableBuilder( - key: ValueKey(node.id), - listenables: listenables, - builder: (context) => builder(context), - ); - } else { - return builder(context); - } - }, - ), - ); + return listenables; } Listenable? getStorageListenerFor(VariableMatch match, BuildContext context) { diff --git a/lib/src/transformers/transformer_functions.dart b/lib/src/transformers/transformer_functions.dart index 3270ca0..7acb36d 100644 --- a/lib/src/transformers/transformer_functions.dart +++ b/lib/src/transformers/transformer_functions.dart @@ -310,7 +310,11 @@ OutlinedBorder? getButtonShape(ButtonNode node, ScopedValues scopedValues) { borderColor: node.properties.buttonType == ButtonTypeEnum.outlined ? borderColor ?? buttonColor : borderColor, - borderWidth: node.properties.borderWidth, + // An outlined button without a border width set still draws its outline, + // as Flutter's OutlinedButton does. + borderWidth: node.properties.buttonType == ButtonTypeEnum.outlined + ? node.properties.borderWidth ?? 1 + : node.properties.borderWidth, ); } @@ -637,9 +641,43 @@ Widget wrapWithPaddedScrollable({ required ScrollableMixin node, EdgeInsets? padding, Clip? clipBehavior, + bool fillViewport = false, required Widget child, }) { if (!node.isScrollable) return child; + Widget scrollView(Widget content) => SingleChildScrollView( + scrollDirection: node.scrollDirection.flutterAxis, + reverse: node.reverse, + physics: node.physics.flutterScrollPhysics(node.shouldAlwaysScroll), + primary: node.primary, + padding: padding, + keyboardDismissBehavior: + node.keyboardDismissBehavior.flutterKeyboardDismissBehavior, + clipBehavior: clipBehavior ?? Clip.hardEdge, + child: content, + ); + // Content at least as long as the viewport, so a column that centers or + // spreads its children still does while it fits and scrolls once it + // doesn't. + final Widget scrollable = !fillViewport + ? scrollView(child) + : LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + final bool vertical = node.scrollDirection.isVertical; + final double extent = vertical + ? constraints.maxHeight - (padding?.vertical ?? 0) + : constraints.maxWidth - (padding?.horizontal ?? 0); + if (!extent.isFinite || extent <= 0) return scrollView(child); + return scrollView( + ConstrainedBox( + constraints: vertical + ? BoxConstraints(minHeight: extent) + : BoxConstraints(minWidth: extent), + child: child, + ), + ); + }, + ); return SizedBox( width: (node.horizontalFit == SizeFit.shrinkWrap) ? null @@ -651,17 +689,7 @@ Widget wrapWithPaddedScrollable({ : (node.verticalFit == SizeFit.expanded) ? double.infinity : node.basicBoxLocal.height, - child: SingleChildScrollView( - scrollDirection: node.scrollDirection.flutterAxis, - reverse: node.reverse, - physics: node.physics.flutterScrollPhysics(node.shouldAlwaysScroll), - primary: node.primary, - padding: padding, - keyboardDismissBehavior: - node.keyboardDismissBehavior.flutterKeyboardDismissBehavior, - clipBehavior: clipBehavior ?? Clip.hardEdge, - child: child, - ), + child: scrollable, ); } diff --git a/lib/src/transformers/utils/condition_evaluator.dart b/lib/src/transformers/utils/condition_evaluator.dart index f5bbba4..cd57954 100644 --- a/lib/src/transformers/utils/condition_evaluator.dart +++ b/lib/src/transformers/utils/condition_evaluator.dart @@ -59,6 +59,17 @@ class ConditionEvaluator @override Object? visitVariablePart(VariablePart part) { + // A part that is exactly one path compares by its value as is, so lists + // and maps keep their type for operators like contains and isEmpty. + final String path = part.valueString.trim(); + if (RegExp(r'^\$\{[^{}]+\}$').hasMatch(path)) { + final Object? raw = + PropertyValueDelegate.getVariableValueFromPath( + path, + scopedValues: scopedValues, + ); + if (raw is List || raw is Map) return raw; + } final value = part.valueString.splitMapJoinRegex( variablePathRegex, onMatch: (match) { @@ -260,6 +271,18 @@ class ConditionEvaluator @override R? visitLoadFromCloudStorageAction(LoadFromCloudStorageAction action) => null; + + @override + R? visitAuthAction(AuthAction action) => null; + + @override + R? visitConditionalAction(ConditionalAction action) => null; + + @override + R? visitForEachAction(ForEachAction action) => null; + + @override + R? visitCloudFilesAction(CloudFilesAction action) => null; } class ConditionPrinter @@ -392,4 +415,16 @@ class ConditionPrinter @override void visitLoadFromCloudStorageAction(LoadFromCloudStorageAction action) {} + + @override + void visitAuthAction(AuthAction action) {} + + @override + void visitConditionalAction(ConditionalAction action) {} + + @override + void visitForEachAction(ForEachAction action) {} + + @override + void visitCloudFilesAction(CloudFilesAction action) {} } diff --git a/lib/src/transformers/utils/condition_visitors.dart b/lib/src/transformers/utils/condition_visitors.dart index 88cb9db..1564dd0 100644 --- a/lib/src/transformers/utils/condition_visitors.dart +++ b/lib/src/transformers/utils/condition_visitors.dart @@ -118,6 +118,18 @@ class ConditionVariablesVisitor Set? visitLoadFromCloudStorageAction( LoadFromCloudStorageAction action, ) => {}; + + @override + Set? visitAuthAction(AuthAction action) => {}; + + @override + Set? visitConditionalAction(ConditionalAction action) => {}; + + @override + Set? visitForEachAction(ForEachAction action) => {}; + + @override + Set? visitCloudFilesAction(CloudFilesAction action) => {}; } /// A visitor that returns the list of node IDs used in a condition's actions. @@ -184,4 +196,16 @@ class ConditionNodesVisitor @override String? visitLoadFromCloudStorageAction(LoadFromCloudStorageAction action) => null; + + @override + String? visitAuthAction(AuthAction action) => null; + + @override + String? visitConditionalAction(ConditionalAction action) => null; + + @override + String? visitForEachAction(ForEachAction action) => null; + + @override + String? visitCloudFilesAction(CloudFilesAction action) => null; } diff --git a/lib/src/transformers/utils/property_value_delegate.dart b/lib/src/transformers/utils/property_value_delegate.dart index 31463cc..3be4211 100644 --- a/lib/src/transformers/utils/property_value_delegate.dart +++ b/lib/src/transformers/utils/property_value_delegate.dart @@ -6,6 +6,7 @@ import 'package:meta/meta.dart'; import 'package:rfc_6901/rfc_6901.dart'; import '../../../codelessly_sdk.dart'; +import '../../functions/action_chain.dart'; /// Represents what to do when a variable path evaluates to null when /// substituting variables in a text. @@ -155,6 +156,55 @@ class PropertyValueDelegate { /// evaluates to null. See [NullSubstitutionMode] for more details. /// /// [scopedValues] is required to retrieve the data from various sources. + /// Substitutes variables in a JSON [template] value by value, so the + /// result stays valid JSON whatever the values contain: a string that is + /// exactly one `${path}` becomes the path's value as is (a list, a number, + /// null, ...), and other strings get their paths substituted as text. + /// + /// Returns null when [template] isn't JSON before substitution, e.g. when a + /// path stands unquoted for a number. + static Map? substituteVariablesInJson( + String template, { + required ScopedValues scopedValues, + }) { + final Object? parsed = tryJsonDecode(template.trim()); + if (parsed is! Map) return null; + return Map.from( + _substituteJsonValue(parsed, scopedValues) as Map, + ); + } + + static Object? _substituteJsonValue( + Object? value, + ScopedValues scopedValues, + ) { + return switch (value) { + String() => + RegExp(r'^\$\{[^{}]+\}$').hasMatch(value.trim()) + ? _retrieveVariableValue(value.trim(), scopedValues: scopedValues) + : substituteVariables( + value, + scopedValues: scopedValues, + nullSubstitutionMode: NullSubstitutionMode.emptyString, + ), + List() => [ + for (final item in value) _substituteJsonValue(item, scopedValues), + ], + Map() => { + for (final MapEntry(:key, :value) in value.entries) + substituteVariables( + key.toString(), + scopedValues: scopedValues, + nullSubstitutionMode: NullSubstitutionMode.emptyString, + ): _substituteJsonValue( + value, + scopedValues, + ), + }, + _ => value, + }; + } + static String substituteVariables( String text, { required ScopedValues scopedValues, @@ -214,7 +264,9 @@ class PropertyValueDelegate { if (variable == null) return null; - VariableType accessorType = match.hasAccessor + // Predefined variables like item hold any type, so read accessors for + // the value they hold (an item can be a string, a map, ...). + VariableType accessorType = match.hasAccessor || match.isPredefinedVariable ? VariableType.fromObjectType(value) : variable.type; List accessors = variable.name.startsWith('api') @@ -316,6 +368,8 @@ class PropertyValueDelegate { 'value' => scopedValues.nodeState, 'storage' => scopedValues.localStorage?.getAll(), 'route' => scopedValues.routeParams, + 'auth' => scopedValues.appUser, + 'result' => ActionChain.current?.result, _ => null, }; @@ -428,11 +482,37 @@ class PropertyValueDelegate { final JsonPath jsonPath = JsonPath(path); // Retrieve values from JSON that match the path. final values = jsonPath.read(data); - if (values.isEmpty) return (null, null); + if (values.isEmpty) return _substituteComputedProperty(text, data); // Return the first value. return (values.first.value, values.first.pointer); } + /// Resolves a path that ends in a computed property of a nested value, like + /// `${item.memberIds.length}` or `${item.createdAt.timeAgo}`, which plain + /// JSON paths can't read: the rest of the path is resolved first, then the + /// property is read from [getVariableProperties] of that value. + static (Object?, JsonPointer?) _substituteComputedProperty( + String text, + Map data, + ) { + final String path = text.substring(2, text.length - 1); + final int dot = path.lastIndexOf('.'); + if (dot <= 0) return (null, null); + final String property = path.substring(dot + 1); + if (!RegExp(r'^[a-zA-Z]+$').hasMatch(property)) return (null, null); + final (Object? parent, _) = substituteJsonPath( + path.substring(0, dot), + data, + ); + if (parent == null) return (null, null); + final Map properties = getVariableProperties( + parent, + VariableType.fromObjectType(parent), + ); + if (!properties.containsKey(property)) return (null, null); + return (properties[property], null); + } + static void putValueInJsonPath( String path, Object? value, @@ -498,6 +578,13 @@ class PropertyValueDelegate { 'upcase': value.toUpperCase(), 'downcase': value.toLowerCase(), 'capitalize': value.capitalized, + 'initial': value.trim().isEmpty + ? '' + : value.trim().characters.first.toUpperCase(), + if (DateTime.tryParse(value) case final DateTime date) ...{ + 'timeAgo': formatTimeAgo(date), + 'date': formatDate(date), + }, }, int() => { 'isEven': value.isEven, @@ -518,6 +605,11 @@ class PropertyValueDelegate { 'ceil': value.ceil(), }, bool() => {'toggled': !value}, + // Cloud documents carry createdAt and updatedAt as dates. + DateTime() => { + 'timeAgo': formatTimeAgo(value), + 'date': formatDate(value), + }, Map() => { 'length': value.length, 'isEmpty': value.isEmpty, @@ -573,7 +665,7 @@ List getAccessorsForType(VariableType type) { Accessor( name: 'downcase', type: VariableType.text, - getValue: (value) => value?.toString().toUpperCase(), + getValue: (value) => value?.toString().toLowerCase(), ), Accessor( name: 'capitalize', @@ -595,6 +687,30 @@ List getAccessorsForType(VariableType type) { type: VariableType.boolean, getValue: (value) => value?.toString().trim().isEmpty, ), + Accessor( + name: 'initial', + type: VariableType.text, + getValue: (value) { + final String text = value?.toString().trim() ?? ''; + return text.isEmpty ? '' : text.characters.first.toUpperCase(); + }, + ), + Accessor( + name: 'timeAgo', + type: VariableType.text, + getValue: (value) => switch (DateTime.tryParse('${value ?? ''}')) { + final DateTime date => formatTimeAgo(date), + null => null, + }, + ), + Accessor( + name: 'date', + type: VariableType.text, + getValue: (value) => switch (DateTime.tryParse('${value ?? ''}')) { + final DateTime date => formatDate(date), + null => null, + }, + ), ]; case VariableType.integer: return [ @@ -908,3 +1024,29 @@ List getCloudDataVariableAccessorsForType(VariableType dataType) => [ getValue: (value) => value.toMap()?['error'], ), ]; + +const List _monthNames = [ + 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', // + 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec', +]; + +/// Formats [date] like `Oct 14, 2026`, in local time. +String formatDate(DateTime date) { + final DateTime local = date.toLocal(); + return '${_monthNames[local.month - 1]} ${local.day}, ${local.year}'; +} + +/// Formats [date] relative to now: `Just now`, `5 min ago`, `2 hours ago`, +/// `3 days ago`, then [formatDate] after a week. +String formatTimeAgo(DateTime date, {DateTime? now}) { + final Duration diff = (now ?? DateTime.now()).difference(date); + if (diff.inMinutes < 1) return 'Just now'; + if (diff.inHours < 1) return '${diff.inMinutes} min ago'; + if (diff.inDays < 1) { + return '${diff.inHours} ${diff.inHours == 1 ? 'hour' : 'hours'} ago'; + } + if (diff.inDays < 7) { + return '${diff.inDays} ${diff.inDays == 1 ? 'day' : 'days'} ago'; + } + return formatDate(date); +} diff --git a/lib/src/transformers/utils/scoped_values.dart b/lib/src/transformers/utils/scoped_values.dart index 86cb822..b02af17 100644 --- a/lib/src/transformers/utils/scoped_values.dart +++ b/lib/src/transformers/utils/scoped_values.dart @@ -20,6 +20,9 @@ class ScopedValues { /// The route params for this route/page for given context. late final Map routeParams; + /// The signed-in app user, as the `auth` variable sees it. + late final Map? appUser; + // private fields. late final WeakReference? _codelesslyContextRef; late final WeakReference? _localStorageRef; @@ -62,7 +65,8 @@ class ScopedValues { _dataOverrides = null, indexedItem = null, nodeState = null, - routeParams = {}; + routeParams = {}, + appUser = null; /// Creates a new scoped values instance from a given context. ScopedValues.of( @@ -82,6 +86,7 @@ class ScopedValues { ModalRoute.of(context)?.settings.arguments as Map? ?? {}; final instance = context.read(); + appUser = instance?.appUser.value; if (instance != null) { _localStorageRef = WeakReference(instance.localDatabase); _cloudDatabaseRef = instance.cloudDatabase == null diff --git a/lib/src/transformers/widget_node_transformer_manager.dart b/lib/src/transformers/widget_node_transformer_manager.dart index 268434f..93c0453 100644 --- a/lib/src/transformers/widget_node_transformer_manager.dart +++ b/lib/src/transformers/widget_node_transformer_manager.dart @@ -46,6 +46,15 @@ abstract class WidgetNodeTransformerManager return Opacity(opacity: opacity, child: widget); } + /// Returns the visibility a condition or variable gives [node] at runtime, + /// or null when nothing overrides the node's own visibility. + bool? runtimeVisibility(BuildContext context, BaseNode node) => + PropertyValueDelegate.getPropertyValue( + node, + 'visible', + scopedValues: ScopedValues.of(context), + ); + /// Convenience method to handle widget visibility. Widget applyWidgetVisibility( BuildContext context, @@ -53,23 +62,23 @@ abstract class WidgetNodeTransformerManager Widget widget, { required bool maintainState, }) { - final bool visible = - PropertyValueDelegate.getPropertyValue( - node, - 'visible', - scopedValues: ScopedValues.of(context), - ) ?? - node.visible; + final bool? runtimeVisible = runtimeVisibility(context, node); + final bool visible = runtimeVisible ?? node.visible; if (visible) return widget; + // A node hidden by a condition or variable at runtime gives up its space, + // so alternative content (a sign-in or sign-up title, an error line) can + // share a spot in a row or column. A node hidden in the editor keeps its + // space, matching the canvas. + final bool keepSize = maintainState && runtimeVisible == null; return Visibility( visible: visible, maintainState: maintainState, maintainAnimation: maintainState, - maintainSize: maintainState, - maintainSemantics: maintainState, - maintainInteractivity: maintainState, + maintainSize: keepSize, + maintainSemantics: keepSize, + maintainInteractivity: keepSize, child: widget, ); } diff --git a/lib/src/utils/extensions.dart b/lib/src/utils/extensions.dart index b072c4d..cb4e2d5 100644 --- a/lib/src/utils/extensions.dart +++ b/lib/src/utils/extensions.dart @@ -1621,6 +1621,34 @@ extension ActionModelExt on ActionModel { if (action is SetVariantAction && action.nodeID == oldNodeId) { return action.copyWith(nodeID: newNodeId); } + if (action is ConditionalAction) { + return action.copyWith( + actions: [ + for (final nested in action.actions) + nested.withUpdatedNodeId(oldNodeId, newNodeId), + ], + elseActions: [ + for (final nested in action.elseActions) + nested.withUpdatedNodeId(oldNodeId, newNodeId), + ], + ); + } + if (action is ForEachAction) { + return action.copyWith( + actions: [ + for (final nested in action.actions) + nested.withUpdatedNodeId(oldNodeId, newNodeId), + ], + ); + } + if (action is CloudFilesAction) { + return action.copyWith( + actions: [ + for (final nested in action.actions) + nested.withUpdatedNodeId(oldNodeId, newNodeId), + ], + ); + } return action; } @@ -1730,8 +1758,59 @@ extension ActionModelExt on ActionModel { .map((filter) => filter.getUsedVariableNames()) .expand((e) => e), ); + case ActionType.auth: + final AuthAction action = this as AuthAction; + strings.addAll([action.email, action.password, action.displayName]); + if (action.errorVariable case VariableData variable) { + strings.add(variable.name.wrapWithVariableSyntax()); + } + case ActionType.conditional: + final ConditionalAction action = this as ConditionalAction; + return { + ...?action.expression.accept>( + ConditionVariablesVisitor( + excludedVariableNames: includePredefined + ? const {} + : predefinedVariableNames, + ), + ), + for (final ActionModel nested in [ + ...action.actions, + ...action.elseActions, + ]) + ...nested.getUsedVariableNames( + includePredefined: includePredefined, + ), + }; + case ActionType.forEach: + final ForEachAction action = this as ForEachAction; + strings.add(action.items.wrapWithVariableSyntax()); + return { + ..._namesIn(strings, includePredefined), + for (final ActionModel nested in action.actions) + ...nested.getUsedVariableNames( + includePredefined: includePredefined, + ), + }; + case ActionType.cloudFiles: + final CloudFilesAction action = this as CloudFilesAction; + strings.add(action.path); + if (action.progressVariable case VariableData variable) { + strings.add(variable.name.wrapWithVariableSyntax()); + } + return { + ..._namesIn(strings, includePredefined), + for (final ActionModel nested in action.actions) + ...nested.getUsedVariableNames( + includePredefined: includePredefined, + ), + }; } + return _namesIn(strings, includePredefined); + } + + static Set _namesIn(List strings, bool includePredefined) { return strings .map((text) => VariableMatch.parseAll(text)) .expand((matches) => matches) diff --git a/lib/src/utils/utils.dart b/lib/src/utils/utils.dart index 5d913ec..938141d 100644 --- a/lib/src/utils/utils.dart +++ b/lib/src/utils/utils.dart @@ -14,9 +14,11 @@ const Set predefinedVariableNames = { 'item', 'storage', 'route', + 'auth', + 'result', }; -const Set predefinedListenableVariableNames = {'storage'}; +const Set predefinedListenableVariableNames = {'storage', 'auth'}; final Set predefinedVariables = { PredefinedVariableData(name: 'data', type: VariableType.map), @@ -25,6 +27,8 @@ final Set predefinedVariables = { PredefinedVariableData(name: 'value'), PredefinedVariableData(name: 'storage', type: VariableType.map), PredefinedVariableData(name: 'route', type: VariableType.map), + PredefinedVariableData(name: 'auth', type: VariableType.map), + PredefinedVariableData(name: 'result'), }; /// Returns the greatest common divisor (gcd) of two integers using Euclid's diff --git a/pubspec.yaml b/pubspec.yaml index f2ef59a..219f375 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: 7.1.1 +version: 7.2.0 homepage: https://codelessly.com/ repository: https://github.com/Codelessly/CodelesslySDK issue_tracker: https://github.com/Codelessly/CodelesslySDK/issues @@ -28,13 +28,14 @@ dependencies: flutter: sdk: flutter # Codelessly - codelessly_api: ^5.1.0 + codelessly_api: ^5.2.0 json_annotation: ^4.12.0 # Firebase firebase_core: ^4.15.0 firebase_auth: ^6.7.0 cloud_firestore: ^6.10.0 + firebase_storage: ^13.0.6 firebase_ui_firestore: ^2.1.0 google_fonts: ^9.0.0 @@ -60,6 +61,7 @@ dependencies: # File and Image path: ^1.9.0 + file_picker: ^10.3.8 # Storage and Caching hive_ce: ^2.9.0 diff --git a/test/variable_paths_test.dart b/test/variable_paths_test.dart new file mode 100644 index 0000000..fa55567 --- /dev/null +++ b/test/variable_paths_test.dart @@ -0,0 +1,144 @@ +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:codelessly_sdk/codelessly_sdk.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + final String created = DateTime.now() + .subtract(const Duration(hours: 2)) + .toUtc() + .toIso8601String(); + final Map album = { + 'id': 'a1', + 'title': 'summer', + 'ownerId': 'u1', + 'memberIds': ['u1', 'u2'], + 'createdAt': created, + 'coverUrl': null, + }; + final VariableData albums = VariableData( + id: 'albums', + name: 'albums', + type: VariableType.map, + value: { + 'isSuccess': true, + 'data': [album], + }, + ); + final VariableData email = VariableData( + id: 'email', + name: 'email', + type: VariableType.text, + value: 'Ava@Example.com', + ); + + Future scoped(WidgetTester tester) async { + late ScopedValues values; + await tester.pumpWidget( + IndexedItemProvider( + item: IndexedItem(0, album), + child: Builder( + builder: (context) { + values = ScopedValues.of( + context, + variablesOverrides: [albums, email], + ); + return const SizedBox(); + }, + ), + ), + ); + return values; + } + + String sub(String text, ScopedValues values) => + PropertyValueDelegate.substituteVariables( + text, + scopedValues: values, + nullSubstitutionMode: NullSubstitutionMode.emptyString, + ); + + testWidgets('nested accessors resolve', (tester) async { + final ScopedValues values = await scoped(tester); + expect(sub(r'${albums.data.length} albums', values), '1 albums'); + expect(sub(r'${item.memberIds.length}', values), '2'); + expect(sub(r'${item.title.initial}', values), 'S'); + expect(sub(r'${item.createdAt.timeAgo}', values), '2 hours ago'); + expect(sub(r'${email.downcase}', values), 'ava@example.com'); + expect(sub(r'${albums.data[0].title}', values), 'summer'); + }); + + testWidgets('accessors read string items', (tester) async { + late ScopedValues values; + await tester.pumpWidget( + IndexedItemProvider( + item: IndexedItem(1, 'ben@example.com'), + child: Builder( + builder: (context) { + values = ScopedValues.of(context); + return const SizedBox(); + }, + ), + ), + ); + expect(sub(r'${item.initial}', values), 'B'); + expect(sub(r'${item} #${index}', values), 'ben@example.com #1'); + }); + + testWidgets('JSON templates keep values typed', (tester) async { + final ScopedValues values = await scoped(tester); + final Map? json = + PropertyValueDelegate.substituteVariablesInJson( + r'{"title": "${item.title}", "ids": "${item.memberIds}", ' + r'"cover": "${item.coverUrl}", "label": "Hi \"${item.title}\"", ' + r'"n": {"$increment": 1}}', + scopedValues: values, + ); + expect(json, { + 'title': 'summer', + 'ids': ['u1', 'u2'], + 'cover': null, + 'label': 'Hi "summer"', + 'n': {r'$increment': 1}, + }); + }); + + testWidgets('conditions compare lists by value', (tester) async { + final ScopedValues values = await scoped(tester); + bool eval(BaseExpression expression) => + expression.accept( + ConditionEvaluator(scopedValues: values), + ) == + true; + expect( + eval( + Expression( + leftPart: VariablePart(valueString: r'${item.memberIds}'), + operator: ConditionOperation.contains, + rightPart: RawValuePart('u2'), + ), + ), + isTrue, + ); + expect( + eval( + Expression( + leftPart: VariablePart(valueString: r'${item.memberIds}'), + operator: ConditionOperation.contains, + rightPart: RawValuePart('u'), + ), + ), + isFalse, + ); + expect( + eval( + Expression( + leftPart: VariablePart(valueString: r'${item.memberIds.length}'), + operator: ConditionOperation.greaterThan, + rightPart: RawValuePart('1'), + ), + ), + isTrue, + ); + }); +}