Apologies for the lengthy post, alot of context and code is provided. Scroll down to TL;DR section for crux of the question.
Context:
My application needs to listen to any profile changes of a user, whose data is stored in Firebase's Firestore.
As described in multiple questions/posts found here, here, here, here and here (and many more). One should use the Flutter Way by making use of streams, etc.
Since I am interested in determining if the user is authenticated (if not goto Login, if yes, goto Home), I make use of 2 streams
This is used to determine the current logged in state of the user were a StreamBuilder is used to determine if we should goto Login or goto Home
FirebaseFirestore.instance.collection('users').doc(uid).snapshots().map((event) => ...)
This is used when a valid authentication state is established either by registering, logging in or opening the app.
This second stream is problematic
Problem:
When the first stream is triggered and returns a successful authentication i.e. FirebaseAuth.instance.currentUser != null
, the 2nd stream is triggered with:
class Firestore {
Stream<UserModel?> getCurrentUser(String uid) {
return FirebaseFirestore.instance
.collection('users')
.doc(uid)
.snapshots()
.map((event) => UserModel.fromJson(event.data() ?? {}));
}
}
If I make any changes in the firestore emulator this doesn't trigger. If I login successfully, this does trigger but stops at the .snapshots()
with no error message.
If I manually call this from a button click:
var currentUser = Firestore().getCurrentUser(FirebaseAuth.instance.currentUser!.uid);
var first = await currentUser.last;
only the Stream.first
successfully returns a value, calling Stream.toList
or Stream.last
or Stream.single
, the execution just stops, no error, no crash, just stops but the app remains running w/o any changes.
According to the StreamBuilder documentation:
Creates a new StreamBuilder that builds itself based on the latest snapshot of interaction with the specified stream and whose build strategy is given by builder.
TL;DR
This indicates to me that the Stream.last
execution may be problematic
For Example:
Manually invoking the following 2 set of code has different results. Both acquire the getCurrentUser(Uid)
stream (see above class Firestore
for impl). The stream has a Map<String, dynamic>
to UserModel
mapping function attached (see above). The mapping function prints out the content when it is mapped to the UserModel
This prints out the UserModel mapping AND then prints "Got user model" followed by the model.
currentUser.first.then((value) {
print("Got user model");
print(value!.toJson());
});
This prints out the UserModel mapping BUT does not execute the .then()
, or .catchError()
or .whenComplete()
.
currentUser.last.then((value) {
print("Got user model");
print(value!.toJson());
}).catchError((error) {
print(error);
}).whenComplete(() {
print("complete");
});
Thus, after calling currentUser.last
(and mapping to UserModel), the execution stops, not even returning the model - just stops dead.
Am I doing something wrong?
Code & Implementation Details below:
Firestore Class retrieves a stream of the current user's document and should listen for changes:
class Firestore {
Stream<UserModel?> getCurrentUser(String uid) {
return FirebaseFirestore.instance
.collection('users')
.doc(uid)
.snapshots()
.map((event) => UserModel.fromJson(event.data() ?? {}));
}
}
UiRoot uses the authStateChanges()
method to check for updated user auth states, then if they are auth'ed, builds the LoggedIn()
widget
class UiRoot extends StatefulWidget {
@override
_UiRootState createState() => new _UiRootState();
}
class _UiRootState extends State<UiRoot> {
Widget build(BuildContext context) {
Widget retVal;
switch (_authStatus) {
case AuthStatus.notLoggedIn:
...
break;
case AuthStatus.loggedIn:
print("checking logged in");
retVal = StreamProvider<UserModel?>.value(
value: Firestore().getCurrentUser(currentUid),
initialData: null,
child: LoggedIn(),
);
break;
case AuthStatus.unknown:
default:
...
break;
}
return retVal;
}
}
The LoggedIn
widget is the problematic widget. The Stream consistently returns null. (See below for message output)
class LoggedIn extends StatelessWidget{
@override
Widget build(BuildContext context) {
// TODO: implement build
return StreamBuilder<UserModel?>(
builder: (context, snapshot) {
print("Checking UserModel from stream provider");
if (snapshot.data == null) {
print("UserModel is null");
return UiLogin();
}
print("UserModel is NOT null");
return UiHome();
},
);
}
}
UserModel class
part 'user_model.g.dart';
@JsonSerializable(explicitToJson: true)
class UserModel extends Object {
final String uid;
final String name;
final String email;
final String bio;
final String fcmToken;
@JsonKey(fromJson: _dateTimeFromMilliseconds, toJson: _dateTimeToMilliseconds)
final DateTime createdDate;
UserModel({required this.uid, required this.name, required this.email, required this.createdDate, required this.bio, required this.fcmToken});
factory UserModel.fromJson(Map<String, dynamic> json) =>
_$UserModelFromJson(json);
Map<String, dynamic> toJson() => _$UserModelToJson(this);
factory UserModel.fromDocumentSnapshot(
{required DocumentSnapshot<Map<String, dynamic>> doc}) =>
UserModel.fromJson(doc.data() ?? {});
static DateTime _dateTimeFromMilliseconds(String? timeformat) => timeformat == null ? DateTime.now() : DateTime.parse(timeformat);
static String _dateTimeToMilliseconds(DateTime? dateTime) => dateTime == null ? DateTime.now().toIso8601String() : dateTime.toIso8601String();
static fromFirebaseUser({required User user}) {
return Firestore().getCurrentUser(user.uid);
}
UserModel.g helper
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'user_model.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
UserModel _$UserModelFromJson(Map<String, dynamic> json) {
print("Mapping json to object");
if (json == null || json.length == 0) {
return UserModel(uid: "", name: "", email: "", createdDate: DateTime.now(), bio: "", fcmToken: "");
}
print(json.entries.map((e) => e.key + e.value).fold("", (previousValue, element) => "$previousValue,\n$element"));
return UserModel(
uid: json['uid'] as String,
name: json['name'] as String,
email: json['email'] as String,
createdDate:
UserModel._dateTimeFromMilliseconds(json['createdDate'] as String?),
bio: json['bio'] as String,
fcmToken: json['fcmToken'] as String,
);
}
Map<String, dynamic> _$UserModelToJson(UserModel instance) => <String, dynamic>{
'uid': instance.uid,
'name': instance.name,
'email': instance.email,
'bio': instance.bio,
'fcmToken': instance.fcmToken,
'createdDate': UserModel._dateTimeToMilliseconds(instance.createdDate),
};
Log output (custom messages)
I/flutter (17988): AuthModel state changed
I/flutter (17988): AuthModel logged in state
I/flutter (17988): checking logged in
I/flutter (17988): Checking UserModel from stream provider
I/flutter (17988): UserModel is null
I/flutter (17988): registered successfully with uid [82e825c1-4437-4939-8a37-e6610e84b3a1]
I am expecting to see (from LoggedIn()
widget):
I/flutter (17988): UserModel is NOT null
in place of:
I/flutter (17988): UserModel is null
Update:
Link to github project.
To get started:
- Throw in your own
gooogle-services.json
file intoandroid/app
folder - change all references for
com.flutterprojects.myapp
to${your google-services.json package name}