From d603e01bef4f943862e6594248e907ebf3c04c1e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 09:50:25 +0000 Subject: [PATCH 1/6] Run auth, conditional, forEach and cloud file actions; add auth and result variables, field operations Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VJ3E2NmBuiBkBnmDDmQBcz --- .../Flutter/GeneratedPluginRegistrant.swift | 4 + .../flutter/generated_plugin_registrant.cc | 3 + .../windows/flutter/generated_plugins.cmake | 1 + lib/src/auth/auth_manager.dart | 12 ++ lib/src/codelessly.dart | 26 +++ lib/src/data/cloud_database.dart | 91 +++++++-- lib/src/data/network_data_repository.dart | 7 +- lib/src/functions/action_chain.dart | 29 +++ lib/src/functions/app_auth.dart | 133 +++++++++++++ lib/src/functions/cloud_files.dart | 184 ++++++++++++++++++ lib/src/functions/functions_repository.dart | 116 ++++++++++- .../passive_transformer_manager.dart | 9 + .../utils/condition_evaluator.dart | 24 +++ .../utils/condition_visitors.dart | 24 +++ .../utils/property_value_delegate.dart | 3 + lib/src/transformers/utils/scoped_values.dart | 7 +- lib/src/utils/extensions.dart | 79 ++++++++ lib/src/utils/utils.dart | 6 +- pubspec.yaml | 2 + 19 files changed, 738 insertions(+), 22 deletions(-) create mode 100644 lib/src/functions/action_chain.dart create mode 100644 lib/src/functions/app_auth.dart create mode 100644 lib/src/functions/cloud_files.dart diff --git a/example/macos/Flutter/GeneratedPluginRegistrant.swift b/example/macos/Flutter/GeneratedPluginRegistrant.swift index 6bfb9205..ccba0e24 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 a3c4d169..3419566a 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 9cebacbc..24ac8dfe 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 38e5076d..7545fd4f 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 ca84fe34..98acab7e 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 8416ffc6..34522044 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); @@ -589,6 +605,7 @@ class FirestoreCloudDatabase extends CloudDatabase { value: CloudDatabaseVariableUtils.success(docs), ), ); + if (!firstSnapshot.isCompleted) firstSnapshot.complete(); }, onError: (error) { DebugLogger.instance.printInfo( @@ -601,10 +618,12 @@ class FirestoreCloudDatabase extends CloudDatabase { value: CloudDatabaseVariableUtils.error(error.toString()), ), ); + if (!firstSnapshot.isCompleted) firstSnapshot.complete(); }, ); _replaceSubscription(variable, subscription); + return firstSnapshot.future; } } @@ -649,6 +668,7 @@ Map sanitizeCloudDataToSend( bool hidePrivateFields = false, bool allowEmptyData = false, bool autoChangeUpdatedAt = true, + bool keepCreatedAt = false, }) { if (data.isEmpty && !allowEmptyData) return data; @@ -664,9 +684,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 +711,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 c2ea86bf..228ae9ae 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 00000000..bd04a170 --- /dev/null +++ b/lib/src/functions/action_chain.dart @@ -0,0 +1,29 @@ +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. + static void setResult(Object? result) => current?.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 00000000..3f7c85fd --- /dev/null +++ b/lib/src/functions/app_auth.dart @@ -0,0 +1,133 @@ +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 00000000..1cfc8d44 --- /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 98d99936..f9d4b33d 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,7 +122,90 @@ 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( @@ -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; @@ -1501,13 +1597,21 @@ ${response.body.contains('{') ? const JsonEncoder.withIndent(' ').convert(json. }; } - 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); @@ -1757,7 +1861,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,7 +1872,7 @@ ${response.body.contains('{') ? const JsonEncoder.withIndent(' ').convert(json. name: name, ); - cloudDatabase.streamCollectionToVariable( + await cloudDatabase.streamCollectionToVariable( evaluatedPath, variable, whereFilters: action.whereFilters, diff --git a/lib/src/transformers/passive_transformer_manager.dart b/lib/src/transformers/passive_transformer_manager.dart index 3957dfc6..a2cb70d0 100644 --- a/lib/src/transformers/passive_transformer_manager.dart +++ b/lib/src/transformers/passive_transformer_manager.dart @@ -166,6 +166,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); } } @@ -193,6 +196,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 +226,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; } diff --git a/lib/src/transformers/utils/condition_evaluator.dart b/lib/src/transformers/utils/condition_evaluator.dart index f5bbba45..e603bf46 100644 --- a/lib/src/transformers/utils/condition_evaluator.dart +++ b/lib/src/transformers/utils/condition_evaluator.dart @@ -260,6 +260,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 +404,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 88cb9db0..1564dd06 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 31463cc2..f64225aa 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. @@ -316,6 +317,8 @@ class PropertyValueDelegate { 'value' => scopedValues.nodeState, 'storage' => scopedValues.localStorage?.getAll(), 'route' => scopedValues.routeParams, + 'auth' => scopedValues.appUser, + 'result' => ActionChain.current?.result, _ => null, }; diff --git a/lib/src/transformers/utils/scoped_values.dart b/lib/src/transformers/utils/scoped_values.dart index 86cb822f..b02af175 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/utils/extensions.dart b/lib/src/utils/extensions.dart index b072c4d1..cb4e2d53 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 5d913ec1..938141db 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 f2ef59a3..0f14e78b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -35,6 +35,7 @@ dependencies: 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 From f17bd814fe64f5c5054c6fd29f34a5bddcd99992 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 13:31:30 +0000 Subject: [PATCH 2/6] Variable paths, JSON templates and on-load timing for data-driven apps Inline text listens to variable paths, string accessors (initial, timeAgo, date) work on predefined variables, downcase is fixed, JSON writes keep typed values, list conditions compare raw lists, on-load actions run after the first frame and setResult merges results. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VJ3E2NmBuiBkBnmDDmQBcz --- lib/src/codelessly.dart | 2 +- lib/src/functions/action_chain.dart | 19 ++- lib/src/functions/app_auth.dart | 7 +- lib/src/functions/functions_repository.dart | 65 ++++---- .../passive_canvas_transformer.dart | 9 +- .../passive_transformer_manager.dart | 13 +- .../utils/condition_evaluator.dart | 11 ++ .../utils/property_value_delegate.dart | 140 ++++++++++++++++- test/variable_paths_test.dart | 144 ++++++++++++++++++ 9 files changed, 360 insertions(+), 50 deletions(-) create mode 100644 test/variable_paths_test.dart diff --git a/lib/src/codelessly.dart b/lib/src/codelessly.dart index 98acab7e..cc81ad98 100644 --- a/lib/src/codelessly.dart +++ b/lib/src/codelessly.dart @@ -493,7 +493,7 @@ class Codelessly { // Initialize Firestore and FirebaseAuth instances. _firebaseFirestore = FirebaseFirestore.instanceFor(app: _firebaseApp!); _firebaseAuth = FirebaseAuth.instanceFor(app: _firebaseApp!); - _listenToAppUser(); + _listenToAppUser(); _didInitializeFirebase = true; diff --git a/lib/src/functions/action_chain.dart b/lib/src/functions/action_chain.dart index bd04a170..bfe3a8b9 100644 --- a/lib/src/functions/action_chain.dart +++ b/lib/src/functions/action_chain.dart @@ -18,12 +18,19 @@ class ActionChain { /// 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. - static void setResult(Object? result) => current?.result = result; + /// 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)}, - ); + 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 index 3f7c85fd..11b0c8b6 100644 --- a/lib/src/functions/app_auth.dart +++ b/lib/src/functions/app_auth.dart @@ -111,9 +111,10 @@ abstract final class AppAuth { // 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) { + if (action.operation + case AuthOperation.signIn || + AuthOperation.signUp || + AuthOperation.signOut) { await codelessly.authManager.reauthenticate(); } } catch (error) { diff --git a/lib/src/functions/functions_repository.dart b/lib/src/functions/functions_repository.dart index f9d4b33d..ba78f7ed 100644 --- a/lib/src/functions/functions_repository.dart +++ b/lib/src/functions/functions_repository.dart @@ -1568,33 +1568,9 @@ ${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); } // Pick the id here when generating one, so `${result.id}` can tell the @@ -1607,8 +1583,7 @@ ${response.body.contains('{') ? const JsonEncoder.withIndent(' ').convert(json. value: data, documentId: documentId, skipCreationIfDocumentExists: - !subAction.autoGenerateId && - subAction.skipCreationIfDocumentExists, + !subAction.autoGenerateId && subAction.skipCreationIfDocumentExists, ); ActionChain.setResult({'id': documentId, 'path': evaluatedPath}); return added; @@ -1619,6 +1594,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, @@ -1642,14 +1643,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, diff --git a/lib/src/transformers/node_transformers/passive_canvas_transformer.dart b/lib/src/transformers/node_transformers/passive_canvas_transformer.dart index f6c1415c..5ba9a6de 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,7 +476,13 @@ 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) { diff --git a/lib/src/transformers/passive_transformer_manager.dart b/lib/src/transformers/passive_transformer_manager.dart index a2cb70d0..2d5202b6 100644 --- a/lib/src/transformers/passive_transformer_manager.dart +++ b/lib/src/transformers/passive_transformer_manager.dart @@ -181,8 +181,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; diff --git a/lib/src/transformers/utils/condition_evaluator.dart b/lib/src/transformers/utils/condition_evaluator.dart index e603bf46..cd579546 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) { diff --git a/lib/src/transformers/utils/property_value_delegate.dart b/lib/src/transformers/utils/property_value_delegate.dart index f64225aa..024504f6 100644 --- a/lib/src/transformers/utils/property_value_delegate.dart +++ b/lib/src/transformers/utils/property_value_delegate.dart @@ -156,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, @@ -215,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') @@ -431,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, @@ -501,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, @@ -576,7 +660,7 @@ List getAccessorsForType(VariableType type) { Accessor( name: 'downcase', type: VariableType.text, - getValue: (value) => value?.toString().toUpperCase(), + getValue: (value) => value?.toString().toLowerCase(), ), Accessor( name: 'capitalize', @@ -598,6 +682,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 [ @@ -911,3 +1019,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/test/variable_paths_test.dart b/test/variable_paths_test.dart new file mode 100644 index 00000000..fa555679 --- /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, + ); + }); +} From 8da8e181af009f59420e9a982743a0f6ce0e0cbe Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 16:13:02 +0000 Subject: [PATCH 3/6] Collapse runtime-hidden nodes, center in scroll views, wait for cloud data A node hidden by a condition gives up its space (and its taps), a scrollable column that centers its children still does while they fit, and cloud loads and writes that run before the project's data access is verified wait for it instead of hanging in the loading state. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VJ3E2NmBuiBkBnmDDmQBcz --- lib/src/functions/functions_repository.dart | 31 +++++++++++-- .../passive_row_column_transformer.dart | 4 ++ .../transformers/transformer_functions.dart | 46 ++++++++++++++----- .../widget_node_transformer_manager.dart | 24 ++++++---- 4 files changed, 81 insertions(+), 24 deletions(-) diff --git a/lib/src/functions/functions_repository.dart b/lib/src/functions/functions_repository.dart index ba78f7ed..4537e3e2 100644 --- a/lib/src/functions/functions_repository.dart +++ b/lib/src/functions/functions_repository.dart @@ -1528,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( @@ -1810,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, @@ -1838,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; } @@ -1875,7 +1900,7 @@ ${response.body.contains('{') ? const JsonEncoder.withIndent(' ').convert(json. 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_row_column_transformer.dart b/lib/src/transformers/node_transformers/passive_row_column_transformer.dart index affaeccd..9f0eea1c 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, ); } diff --git a/lib/src/transformers/transformer_functions.dart b/lib/src/transformers/transformer_functions.dart index 3270ca0d..d8bb7ddc 100644 --- a/lib/src/transformers/transformer_functions.dart +++ b/lib/src/transformers/transformer_functions.dart @@ -637,9 +637,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 +685,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/widget_node_transformer_manager.dart b/lib/src/transformers/widget_node_transformer_manager.dart index 268434f8..e6396dbf 100644 --- a/lib/src/transformers/widget_node_transformer_manager.dart +++ b/lib/src/transformers/widget_node_transformer_manager.dart @@ -53,23 +53,27 @@ abstract class WidgetNodeTransformerManager Widget widget, { required bool maintainState, }) { - final bool visible = - PropertyValueDelegate.getPropertyValue( - node, - 'visible', - scopedValues: ScopedValues.of(context), - ) ?? - node.visible; + final bool? runtimeVisible = PropertyValueDelegate.getPropertyValue( + node, + 'visible', + scopedValues: ScopedValues.of(context), + ); + 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, ); } From d63e7c4cd1743e46be1865455c64b760eed7e931 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 08:59:22 +0000 Subject: [PATCH 4/6] Fix cloud lists, hidden flex children, ink frames and cleared text fields - A snapshot listener that throws no longer stalls the action chain waiting on the first snapshot; dates in documents read through timeAgo and date. - A flexing child hidden at runtime gives up its space with flex 0 and a zero size, so a visible sibling fills it and a hidden scrollable never sees unbounded constraints (a hidden grid computed NaN offsets). - A sized frame with an ink well fills its size with the ink and children instead of shrinking to its unpositioned children, which hid the photo in a grid tile when its likes badge was hidden. - Image fills whose URL path resolves to nothing load no image. - A text field bound to a text variable clears when the variable is set to empty. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VJ3E2NmBuiBkBnmDDmQBcz --- lib/src/data/cloud_database.dart | 23 +++++--- .../passive_rectangle_transformer.dart | 22 ++++++-- .../passive_row_column_transformer.dart | 53 ++++++++++++++++--- .../passive_text_field_transformer.dart | 24 +++++++-- .../passive_transformer_manager.dart | 47 +++++++++------- .../utils/property_value_delegate.dart | 5 ++ .../widget_node_transformer_manager.dart | 15 ++++-- 7 files changed, 145 insertions(+), 44 deletions(-) diff --git a/lib/src/data/cloud_database.dart b/lib/src/data/cloud_database.dart index 34522044..70bbc39a 100644 --- a/lib/src/data/cloud_database.dart +++ b/lib/src/data/cloud_database.dart @@ -599,13 +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), - ), - ); - if (!firstSnapshot.isCompleted) firstSnapshot.complete(); + // 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( diff --git a/lib/src/transformers/node_transformers/passive_rectangle_transformer.dart b/lib/src/transformers/node_transformers/passive_rectangle_transformer.dart index cc778d34..425f69dc 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 9f0eea1c..744e519b 100644 --- a/lib/src/transformers/node_transformers/passive_row_column_transformer.dart +++ b/lib/src/transformers/node_transformers/passive_row_column_transformer.dart @@ -285,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) { @@ -294,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 e82c8ed5..93944dbc 100644 --- a/lib/src/transformers/node_transformers/passive_text_field_transformer.dart +++ b/lib/src/transformers/node_transformers/passive_text_field_transformer.dart @@ -175,12 +175,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 +205,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 2d5202b6..e62cd531 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(); @@ -251,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/utils/property_value_delegate.dart b/lib/src/transformers/utils/property_value_delegate.dart index 024504f6..3be42119 100644 --- a/lib/src/transformers/utils/property_value_delegate.dart +++ b/lib/src/transformers/utils/property_value_delegate.dart @@ -605,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, diff --git a/lib/src/transformers/widget_node_transformer_manager.dart b/lib/src/transformers/widget_node_transformer_manager.dart index e6396dbf..93c04537 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,11 +62,7 @@ abstract class WidgetNodeTransformerManager Widget widget, { required bool maintainState, }) { - final bool? runtimeVisible = PropertyValueDelegate.getPropertyValue( - node, - 'visible', - scopedValues: ScopedValues.of(context), - ); + final bool? runtimeVisible = runtimeVisibility(context, node); final bool visible = runtimeVisible ?? node.visible; if (visible) return widget; From 8bdf8e381cfca76acd7b9de1302f7d53e25029f2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 09:43:29 +0000 Subject: [PATCH 5/6] Fix data-bound lists, nav bar links, prefilled fields and outlined buttons - List and grid views shrink-wrap when their item count comes from data, so a bound list inside a dialog no longer gets unbounded height. - Navigation bar items run their click reactions when selected. - Canvas on-load actions notify listeners, so a field bound to a variable set on load shows the value; a text field also starts from its bound variable. - Outlined buttons draw a 1px outline when no border width is set. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VJ3E2NmBuiBkBnmDDmQBcz --- .../passive_canvas_transformer.dart | 8 +++----- .../passive_grid_view_transformer.dart | 2 +- .../passive_list_view_transformer.dart | 4 +++- .../passive_navigation_bar_transformer.dart | 6 ++++++ .../passive_text_field_transformer.dart | 14 +++++++++++++- lib/src/transformers/transformer_functions.dart | 6 +++++- 6 files changed, 31 insertions(+), 9 deletions(-) diff --git a/lib/src/transformers/node_transformers/passive_canvas_transformer.dart b/lib/src/transformers/node_transformers/passive_canvas_transformer.dart index 5ba9a6de..73700b40 100644 --- a/lib/src/transformers/node_transformers/passive_canvas_transformer.dart +++ b/lib/src/transformers/node_transformers/passive_canvas_transformer.dart @@ -489,11 +489,9 @@ class _PassiveCanvasWidgetState extends State { 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 47b4829c..581ae83e 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 787a78ba..054340bd 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 8a1090bc..0cd03782 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_text_field_transformer.dart b/lib/src/transformers/node_transformers/passive_text_field_transformer.dart index 93944dbc..fd47c345 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) { diff --git a/lib/src/transformers/transformer_functions.dart b/lib/src/transformers/transformer_functions.dart index d8bb7ddc..7acb36d2 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, ); } From 80f4a5c4265f9a67a442051d47fcf419bafe1dbf Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 17:23:43 +0000 Subject: [PATCH 6/6] codelessly_sdk 7.2.0: run data-driven apps Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VJ3E2NmBuiBkBnmDDmQBcz --- CHANGELOG.md | 26 ++++++++++++++++++++++++++ pubspec.yaml | 4 ++-- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f4c05db..7aff3e76 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/pubspec.yaml b/pubspec.yaml index 0f14e78b..219f375a 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,7 +28,7 @@ dependencies: flutter: sdk: flutter # Codelessly - codelessly_api: ^5.1.0 + codelessly_api: ^5.2.0 json_annotation: ^4.12.0 # Firebase