Skip to content

Commit 86d3cc8

Browse files
codelessly_sdk 7.2.0: run data-driven apps (#64)
* Run auth, conditional, forEach and cloud file actions; add auth and result variables, field operations Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VJ3E2NmBuiBkBnmDDmQBcz * 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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VJ3E2NmBuiBkBnmDDmQBcz * 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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VJ3E2NmBuiBkBnmDDmQBcz * 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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VJ3E2NmBuiBkBnmDDmQBcz * 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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VJ3E2NmBuiBkBnmDDmQBcz * codelessly_sdk 7.2.0: run data-driven apps Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VJ3E2NmBuiBkBnmDDmQBcz --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 07cd9e5 commit 86d3cc8

30 files changed

Lines changed: 1365 additions & 133 deletions

‎CHANGELOG.md‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,29 @@
1+
## 7.2.0
2+
3+
- Require codelessly_api 5.2.0.
4+
- Run the new actions: `auth` (sign up, sign in, sign out, update profile,
5+
password reset), `conditional` (if/else), `forEach` and `cloudFiles`
6+
(image uploads to the project's storage and file deletes).
7+
- New variables: `${auth.uid / email / displayName / initial / isSignedIn}`,
8+
and `${result}` for what an earlier action in the chain produced (such as
9+
`${result.id}` of a new document).
10+
- Cloud writes support field operations: `$increment`, `$arrayUnion`,
11+
`$arrayRemove`, `$delete` and `$now`. Updates keep `createdAt`.
12+
- Variable paths and accessors follow the value's type at every level
13+
(`${item.memberIds.length}`, `${item.title.initial}`); new `initial`,
14+
`timeAgo` and `date` accessors.
15+
- Text with inline `${...}` paths rebuilds when those variables change.
16+
- Canvas load actions run after the first frame, in one chain, and notify,
17+
so fields bound to a variable set on load show its value.
18+
- Nodes hidden by a condition at runtime collapse, including flexing ones.
19+
- Scrollable rows and columns honor center and end alignment.
20+
- "Load from cloud" waits for the cloud database and completes on the first
21+
snapshot; documents with dates no longer stall the listener.
22+
- Slug bundles load from `firebaseOptions.storageBucket`.
23+
- Fixes: data-bound lists and grids in dialogs, navigation bar item clicks,
24+
ink frame sizing, clearing text fields, empty image URLs, and outlined
25+
buttons without a border width.
26+
127
## 7.1.1
228

329
- A badge with no child no longer fills loose constraints, so a badge set

‎example/macos/Flutter/GeneratedPluginRegistrant.swift‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,20 @@ import FlutterMacOS
66
import Foundation
77

88
import cloud_firestore
9+
import file_picker
910
import firebase_auth
1011
import firebase_core
12+
import firebase_storage
1113
import shared_preferences_foundation
1214
import url_launcher_macos
1315
import webview_flutter_wkwebview
1416

1517
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
1618
FLTFirebaseFirestorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseFirestorePlugin"))
19+
FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin"))
1720
FLTFirebaseAuthPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAuthPlugin"))
1821
FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin"))
22+
FLTFirebaseStoragePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseStoragePlugin"))
1923
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
2024
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
2125
WebViewFlutterPlugin.register(with: registry.registrar(forPlugin: "WebViewFlutterPlugin"))

‎example/windows/flutter/generated_plugin_registrant.cc‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
#include <cloud_firestore/cloud_firestore_plugin_c_api.h>
1010
#include <firebase_auth/firebase_auth_plugin_c_api.h>
1111
#include <firebase_core/firebase_core_plugin_c_api.h>
12+
#include <firebase_storage/firebase_storage_plugin_c_api.h>
1213
#include <url_launcher_windows/url_launcher_windows.h>
1314

1415
void RegisterPlugins(flutter::PluginRegistry* registry) {
@@ -18,6 +19,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
1819
registry->GetRegistrarForPlugin("FirebaseAuthPluginCApi"));
1920
FirebaseCorePluginCApiRegisterWithRegistrar(
2021
registry->GetRegistrarForPlugin("FirebaseCorePluginCApi"));
22+
FirebaseStoragePluginCApiRegisterWithRegistrar(
23+
registry->GetRegistrarForPlugin("FirebaseStoragePluginCApi"));
2124
UrlLauncherWindowsRegisterWithRegistrar(
2225
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
2326
}

‎example/windows/flutter/generated_plugins.cmake‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
66
cloud_firestore
77
firebase_auth
88
firebase_core
9+
firebase_storage
910
url_launcher_windows
1011
)
1112

‎lib/src/auth/auth_manager.dart‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -391,6 +391,18 @@ class AuthManager {
391391
);
392392
}
393393

394+
/// Authenticates again for the user now signed in, after an auth action
395+
/// changed it, so that user gets the project's claim before the app reads
396+
/// or writes the project's data as them.
397+
Future<void> reauthenticate() async {
398+
DebugLogger.instance.printFunction('reauthenticate()', name: name);
399+
if (_disposed) return;
400+
final User? user = firebaseAuth.currentUser;
401+
if (user == null) return;
402+
_idTokenResult = await user.getIdTokenResult(true);
403+
await authenticate();
404+
}
405+
394406
/// Performs a handshake with the server to authenticate the token.
395407
/// Makes a network request to verify the auth token and retrieve project access.
396408
Future<void> authenticate() async {

‎lib/src/codelessly.dart‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import 'package:flutter/widgets.dart';
99
import 'package:http/http.dart';
1010

1111
import '../codelessly_sdk.dart';
12+
import 'functions/app_auth.dart';
1213
import 'logging/stat_tracker.dart';
1314
import 'utils/codelessly_http_client.dart';
1415

@@ -126,6 +127,28 @@ class Codelessly {
126127
/// Whether this Codelessly instance already initialized firebase or not.
127128
bool _didInitializeFirebase = false;
128129

130+
/// The app user that auth actions signed in, as the `auth` variable sees
131+
/// it. Layouts that use `${auth...}` rebuild when it changes.
132+
final ValueNotifier<Map<String, dynamic>> appUser = ValueNotifier(
133+
AppAuth.userToMap(null),
134+
);
135+
136+
StreamSubscription<User?>? _appUserSubscription;
137+
138+
/// Re-reads the signed-in user into [appUser], e.g. after its display name
139+
/// changed, which Firebase doesn't announce.
140+
void refreshAppUser() {
141+
appUser.value = AppAuth.userToMap(_firebaseAuth?.currentUser);
142+
}
143+
144+
void _listenToAppUser() {
145+
_appUserSubscription?.cancel();
146+
_appUserSubscription = _firebaseAuth?.userChanges().listen(
147+
(User? user) => appUser.value = AppAuth.userToMap(user),
148+
);
149+
refreshAppUser();
150+
}
151+
129152
/// A map of data that is passed to loaded layouts for nodes to replace their
130153
/// property values with.
131154
final Map<String, dynamic> data = {};
@@ -239,6 +262,7 @@ class Codelessly {
239262
_cacheManager?.dispose();
240263
}
241264
_authManager?.dispose();
265+
_appUserSubscription?.cancel();
242266
_publishDataManager?.dispose();
243267
_previewDataManager?.dispose();
244268
_templateDataManager?.dispose();
@@ -426,6 +450,7 @@ class Codelessly {
426450
// Initialize Firestore and FirebaseAuth instances.
427451
_firebaseFirestore = FirebaseFirestore.instanceFor(app: _firebaseApp!);
428452
_firebaseAuth = FirebaseAuth.instanceFor(app: _firebaseApp!);
453+
_listenToAppUser();
429454
_didInitializeFirebase = true;
430455

431456
DebugLogger.instance.printInfo(
@@ -468,6 +493,7 @@ class Codelessly {
468493
// Initialize Firestore and FirebaseAuth instances.
469494
_firebaseFirestore = FirebaseFirestore.instanceFor(app: _firebaseApp!);
470495
_firebaseAuth = FirebaseAuth.instanceFor(app: _firebaseApp!);
496+
_listenToAppUser();
471497

472498
_didInitializeFirebase = true;
473499

‎lib/src/data/cloud_database.dart‎

Lines changed: 93 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -169,15 +169,19 @@ abstract class CloudDatabase extends ChangeNotifier {
169169
///
170170
/// [documentId] is the id of the document. If [documentId] is empty, then
171171
/// "default" is used as the document id.
172-
void streamDocumentToVariable(
172+
/// Streams a document into [variable]. Completes once the first snapshot
173+
/// (or error) has been written to it.
174+
Future<void> streamDocumentToVariable(
173175
String path,
174176
String documentId,
175177
Observable<VariableData> variable,
176178
);
177179

178180
void reset();
179181

180-
void streamCollectionToVariable(
182+
/// Streams a query into [variable]. Completes once the first snapshot
183+
/// (or error) has been written to it.
184+
Future<void> streamCollectionToVariable(
181185
String path,
182186
Observable<VariableData> variable, {
183187
required List<WhereQueryFilter> whereFilters,
@@ -343,7 +347,9 @@ class FirestoreCloudDatabase extends CloudDatabase {
343347

344348
// Auto generate id if desired.
345349
if (autoGenerateId) {
346-
final document = await rootRef.collection(path).add(value);
350+
final document = await rootRef
351+
.collection(path)
352+
.add(resolveFieldOperations(value));
347353
StatTracker.instance.track(StatType.write, 'cloudDatabase/addDocument');
348354

349355
DebugLogger.instance.printInfo(
@@ -353,7 +359,7 @@ class FirestoreCloudDatabase extends CloudDatabase {
353359

354360
// Sanitize data afterwards because we didn't have the docId before.
355361
value = sanitizeCloudDataToSend(value, docId: document.id);
356-
await document.set(value);
362+
await document.set(resolveFieldOperations(value));
357363

358364
return true;
359365
}
@@ -378,7 +384,7 @@ class FirestoreCloudDatabase extends CloudDatabase {
378384
}
379385

380386
// Set document.
381-
await docRef.set(value);
387+
await docRef.set(resolveFieldOperations(value));
382388
StatTracker.instance.track(StatType.write, 'cloudDatabase/addDocument');
383389

384390
DebugLogger.instance.printInfo(
@@ -406,11 +412,15 @@ class FirestoreCloudDatabase extends CloudDatabase {
406412
// await docRef.set(value);
407413
// }
408414

409-
// Sanitize data.
410-
value = sanitizeCloudDataToSend(value, docId: documentId);
415+
// Sanitize data. An update keeps the document's createdAt.
416+
value = sanitizeCloudDataToSend(
417+
value,
418+
docId: documentId,
419+
keepCreatedAt: true,
420+
);
411421

412422
// TODO: Should we do update instead of set?
413-
await docRef.set(value, SetOptions(merge: true));
423+
await docRef.set(resolveFieldOperations(value), SetOptions(merge: true));
414424
StatTracker.instance.track(StatType.write, 'cloudDatabase/updateDocument');
415425

416426
DebugLogger.instance.printInfo(
@@ -464,11 +474,12 @@ class FirestoreCloudDatabase extends CloudDatabase {
464474
}
465475

466476
@override
467-
void streamDocumentToVariable(
477+
Future<void> streamDocumentToVariable(
468478
String path,
469479
String documentId,
470480
Observable<VariableData> variable,
471481
) {
482+
final Completer<void> firstSnapshot = Completer<void>();
472483
DebugLogger.instance.printFunction(
473484
'streamDocumentToVariable(path: $path, documentId: $documentId)',
474485
name: name,
@@ -505,6 +516,7 @@ class FirestoreCloudDatabase extends CloudDatabase {
505516
value: CloudDatabaseVariableUtils.success(data),
506517
),
507518
);
519+
if (!firstSnapshot.isCompleted) firstSnapshot.complete();
508520
},
509521
onError: (error) {
510522
DebugLogger.instance.printInfo(
@@ -517,10 +529,12 @@ class FirestoreCloudDatabase extends CloudDatabase {
517529
value: CloudDatabaseVariableUtils.error(error.toString()),
518530
),
519531
);
532+
if (!firstSnapshot.isCompleted) firstSnapshot.complete();
520533
},
521534
);
522535

523536
_replaceSubscription(variable, subscription);
537+
return firstSnapshot.future;
524538
}
525539

526540
@override
@@ -535,7 +549,7 @@ class FirestoreCloudDatabase extends CloudDatabase {
535549
}
536550

537551
@override
538-
void streamCollectionToVariable(
552+
Future<void> streamCollectionToVariable(
539553
String path,
540554
Observable<VariableData> variable, {
541555
required List<WhereQueryFilter> whereFilters,
@@ -544,6 +558,8 @@ class FirestoreCloudDatabase extends CloudDatabase {
544558
required ScopedValues scopedValues,
545559
required NullSubstitutionMode nullSubstitutionMode,
546560
}) {
561+
final Completer<void> firstSnapshot = Completer<void>();
562+
547563
// Get doc reference.
548564
final collectionRef = getCollectionPath(path);
549565

@@ -583,12 +599,22 @@ class FirestoreCloudDatabase extends CloudDatabase {
583599
'Updating variable ${variable.value.name} with success state.',
584600
name: name,
585601
);
586-
// Set the variable with success state.
587-
variable.set(
588-
variable.value.copyWith(
589-
value: CloudDatabaseVariableUtils.success(docs),
590-
),
591-
);
602+
// Set the variable with success state. The chain waiting on the
603+
// first snapshot continues even if a listener of the variable throws.
604+
try {
605+
variable.set(
606+
variable.value.copyWith(
607+
value: CloudDatabaseVariableUtils.success(docs),
608+
),
609+
);
610+
} catch (error, stackTrace) {
611+
DebugLogger.instance.printInfo(
612+
'Listener of ${variable.value.name} failed: $error\n$stackTrace',
613+
name: name,
614+
);
615+
} finally {
616+
if (!firstSnapshot.isCompleted) firstSnapshot.complete();
617+
}
592618
},
593619
onError: (error) {
594620
DebugLogger.instance.printInfo(
@@ -601,10 +627,12 @@ class FirestoreCloudDatabase extends CloudDatabase {
601627
value: CloudDatabaseVariableUtils.error(error.toString()),
602628
),
603629
);
630+
if (!firstSnapshot.isCompleted) firstSnapshot.complete();
604631
},
605632
);
606633

607634
_replaceSubscription(variable, subscription);
635+
return firstSnapshot.future;
608636
}
609637
}
610638

@@ -649,6 +677,7 @@ Map<String, dynamic> sanitizeCloudDataToSend(
649677
bool hidePrivateFields = false,
650678
bool allowEmptyData = false,
651679
bool autoChangeUpdatedAt = true,
680+
bool keepCreatedAt = false,
652681
}) {
653682
if (data.isEmpty && !allowEmptyData) return data;
654683

@@ -664,9 +693,11 @@ Map<String, dynamic> sanitizeCloudDataToSend(
664693
late final DateTime now = DateTime.now().toUtc();
665694

666695
if (autoChangeUpdatedAt) {
667-
data[SDKConstants.createdAt] = serializedCosmicValue(
668-
data[SDKConstants.createdAt] ?? now,
669-
);
696+
if (!keepCreatedAt || data.containsKey(SDKConstants.createdAt)) {
697+
data[SDKConstants.createdAt] = serializedCosmicValue(
698+
data[SDKConstants.createdAt] ?? now,
699+
);
700+
}
670701
data[SDKConstants.updatedAt] = serializedCosmicValue(now);
671702
}
672703

@@ -689,6 +720,49 @@ Map<String, dynamic> sanitizeCloudDataToSend(
689720
return data;
690721
}
691722

723+
/// Turns field operation markers in [data] into Firestore field values, so
724+
/// actions can write them from JSON:
725+
///
726+
/// * `{"$increment": 1}` adds to a number.
727+
/// * `{"$arrayUnion": [...]}` adds items to a list that aren't in it yet.
728+
/// * `{"$arrayRemove": [...]}` removes items from a list.
729+
/// * `{"$delete": true}` removes the field (updates only).
730+
/// * `{"$now": true}` writes the current time as an ISO 8601 string, the
731+
/// format `createdAt` and `updatedAt` use.
732+
Map<String, dynamic> resolveFieldOperations(Map<String, dynamic> data) => {
733+
for (final MapEntry(:key, :value) in data.entries)
734+
key: _resolveFieldOperation(value),
735+
};
736+
737+
Object? _resolveFieldOperation(Object? value) {
738+
if (value is List) return value.map(_resolveFieldOperation).toList();
739+
if (value is! Map) return value;
740+
if (value.length == 1) {
741+
final MapEntry entry = value.entries.first;
742+
List<Object?> items() =>
743+
entry.value is List ? List<Object?>.from(entry.value) : [entry.value];
744+
switch (entry.key) {
745+
case r'$increment':
746+
final num? amount = entry.value is num
747+
? entry.value
748+
: num.tryParse(entry.value.toString());
749+
return FieldValue.increment(amount ?? 0);
750+
case r'$arrayUnion':
751+
return FieldValue.arrayUnion(items());
752+
case r'$arrayRemove':
753+
return FieldValue.arrayRemove(items());
754+
case r'$delete':
755+
return FieldValue.delete();
756+
case r'$now':
757+
return DateTime.now().toUtc().toIso8601String();
758+
}
759+
}
760+
return {
761+
for (final MapEntry entry in value.entries)
762+
entry.key.toString(): _resolveFieldOperation(entry.value),
763+
};
764+
}
765+
692766
/// Returns a sanitized date string from the given [value].
693767
/// Converts different representation of date to a string representation.
694768
String? serializedCosmicValue(Object? value) {

0 commit comments

Comments
 (0)