Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
4 changes: 4 additions & 0 deletions example/macos/Flutter/GeneratedPluginRegistrant.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand Down
3 changes: 3 additions & 0 deletions example/windows/flutter/generated_plugin_registrant.cc
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
#include <cloud_firestore/cloud_firestore_plugin_c_api.h>
#include <firebase_auth/firebase_auth_plugin_c_api.h>
#include <firebase_core/firebase_core_plugin_c_api.h>
#include <firebase_storage/firebase_storage_plugin_c_api.h>
#include <url_launcher_windows/url_launcher_windows.h>

void RegisterPlugins(flutter::PluginRegistry* registry) {
Expand All @@ -18,6 +19,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
registry->GetRegistrarForPlugin("FirebaseAuthPluginCApi"));
FirebaseCorePluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FirebaseCorePluginCApi"));
FirebaseStoragePluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FirebaseStoragePluginCApi"));
UrlLauncherWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
}
1 change: 1 addition & 0 deletions example/windows/flutter/generated_plugins.cmake
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
cloud_firestore
firebase_auth
firebase_core
firebase_storage
url_launcher_windows
)

Expand Down
12 changes: 12 additions & 0 deletions lib/src/auth/auth_manager.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> 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<void> authenticate() async {
Expand Down
26 changes: 26 additions & 0 deletions lib/src/codelessly.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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<Map<String, dynamic>> appUser = ValueNotifier(
AppAuth.userToMap(null),
);

StreamSubscription<User?>? _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<String, dynamic> data = {};
Expand Down Expand Up @@ -239,6 +262,7 @@ class Codelessly {
_cacheManager?.dispose();
}
_authManager?.dispose();
_appUserSubscription?.cancel();
_publishDataManager?.dispose();
_previewDataManager?.dispose();
_templateDataManager?.dispose();
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -468,6 +493,7 @@ class Codelessly {
// Initialize Firestore and FirebaseAuth instances.
_firebaseFirestore = FirebaseFirestore.instanceFor(app: _firebaseApp!);
_firebaseAuth = FirebaseAuth.instanceFor(app: _firebaseApp!);
_listenToAppUser();

_didInitializeFirebase = true;

Expand Down
112 changes: 93 additions & 19 deletions lib/src/data/cloud_database.dart
Original file line number Diff line number Diff line change
Expand Up @@ -169,15 +169,19 @@ 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<void> streamDocumentToVariable(
String path,
String documentId,
Observable<VariableData> variable,
);

void reset();

void streamCollectionToVariable(
/// Streams a query into [variable]. Completes once the first snapshot
/// (or error) has been written to it.
Future<void> streamCollectionToVariable(
String path,
Observable<VariableData> variable, {
required List<WhereQueryFilter> whereFilters,
Expand Down Expand Up @@ -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(
Expand All @@ -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;
}
Expand All @@ -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(
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -464,11 +474,12 @@ class FirestoreCloudDatabase extends CloudDatabase {
}

@override
void streamDocumentToVariable(
Future<void> streamDocumentToVariable(
String path,
String documentId,
Observable<VariableData> variable,
) {
final Completer<void> firstSnapshot = Completer<void>();
DebugLogger.instance.printFunction(
'streamDocumentToVariable(path: $path, documentId: $documentId)',
name: name,
Expand Down Expand Up @@ -505,6 +516,7 @@ class FirestoreCloudDatabase extends CloudDatabase {
value: CloudDatabaseVariableUtils.success(data),
),
);
if (!firstSnapshot.isCompleted) firstSnapshot.complete();
},
onError: (error) {
DebugLogger.instance.printInfo(
Expand All @@ -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
Expand All @@ -535,7 +549,7 @@ class FirestoreCloudDatabase extends CloudDatabase {
}

@override
void streamCollectionToVariable(
Future<void> streamCollectionToVariable(
String path,
Observable<VariableData> variable, {
required List<WhereQueryFilter> whereFilters,
Expand All @@ -544,6 +558,8 @@ class FirestoreCloudDatabase extends CloudDatabase {
required ScopedValues scopedValues,
required NullSubstitutionMode nullSubstitutionMode,
}) {
final Completer<void> firstSnapshot = Completer<void>();

// Get doc reference.
final collectionRef = getCollectionPath(path);

Expand Down Expand Up @@ -583,12 +599,22 @@ class FirestoreCloudDatabase extends CloudDatabase {
'Updating variable ${variable.value.name} with success state.',
name: name,
);
// Set the variable with success state.
variable.set(
variable.value.copyWith(
value: CloudDatabaseVariableUtils.success(docs),
),
);
// Set the variable with success state. The chain waiting on the
// first snapshot continues even if a listener of the variable throws.
try {
variable.set(
variable.value.copyWith(
value: CloudDatabaseVariableUtils.success(docs),
),
);
} catch (error, stackTrace) {
DebugLogger.instance.printInfo(
'Listener of ${variable.value.name} failed: $error\n$stackTrace',
name: name,
);
} finally {
if (!firstSnapshot.isCompleted) firstSnapshot.complete();
}
},
onError: (error) {
DebugLogger.instance.printInfo(
Expand All @@ -601,10 +627,12 @@ class FirestoreCloudDatabase extends CloudDatabase {
value: CloudDatabaseVariableUtils.error(error.toString()),
),
);
if (!firstSnapshot.isCompleted) firstSnapshot.complete();
},
);

_replaceSubscription(variable, subscription);
return firstSnapshot.future;
}
}

Expand Down Expand Up @@ -649,6 +677,7 @@ Map<String, dynamic> sanitizeCloudDataToSend(
bool hidePrivateFields = false,
bool allowEmptyData = false,
bool autoChangeUpdatedAt = true,
bool keepCreatedAt = false,
}) {
if (data.isEmpty && !allowEmptyData) return data;

Expand All @@ -664,9 +693,11 @@ Map<String, dynamic> 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);
}

Expand All @@ -689,6 +720,49 @@ Map<String, dynamic> 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<String, dynamic> resolveFieldOperations(Map<String, dynamic> 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<Object?> items() =>
entry.value is List ? List<Object?>.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) {
Expand Down
Loading
Loading