Originally published on bendyworks.com.
Yesterday I implemented saving new users to Firestore but I wasn't happy with the implementation. So today I refactored everything, well not everything but a lot. There are still areas for improvement but I like the general pattern being used now.
Now there is a top level ScopedModel
that tracks the current authentication status. This sets up a listener on FirebaseAuth
and Firestore
and will get pushed changes when either have changed. Children widgets that need to change functionality based on authentication status will get rerendered as needed.
A couple of the less important changes that I'll get out of the way now.
-
Auth
was renamed toAuthService
- The Firestore rules were updated to allow a user to read their own user document
- Registration/sign in success
SnackBar
was replaced with an unstyled welcome view -
HomePage
gotrouteName
added
In MyApp
's build
, MaterialApp
has been wrapped in ScopedModel<CurrentUserModel>
. I put ScopedModel
at the top because authentication changing will touch almost all of the app.
ScopedModel<CurrentUserModel>(
model: CurrentUserModel.instance(),
child: MaterialApp(
debugShowCheckedModeBanner: false,
title: 'Birb',
theme: buildThemeData(),
home: const HomePage(title: 'Birb'),
routes: <String, WidgetBuilder>{
RegisterPage.routeName: (BuildContext context) =>
const RegisterPage(),
},
),
)
~~~{% endraw %}
{% raw %}`ScopedModel`{% endraw %} takes a {% raw %}`model`{% endraw %}, which in this case is {% raw %}`CurrentUserModel`{% endraw %} (I'm not 100% in on that name yet) and acts similar to an {% raw %}`InheritedWidget`. Any children can find and interact with the `CurrentuserModel` instance or be conditionally rendered based on its state.
Coming from a background in web development, I thinkf of `Model`s differently than `ScopedModel` does. Typically I think of models as single data objects like a blog post or tweet. In `ScopedModel` land, it's more like a [store](https://redux.js.org/basics/store) or [state machine](https://en.wikipedia.org/wiki/Finite-state_machine) and can manage the state of several things.
I have set the {% raw %}`CurrentStatusModel`{% endraw %} to have one of three statues:{% raw %}
~~~dart
enum Status {
Unauthenticated,
Unregistered,
Authenticated,
}
~~~{% endraw %}
- {% raw %}`Unauthenticated`{% endraw %} is the initial default
- {% raw %}`Unregistered`{% endraw %} the user has authenticated through Firebase
- {% raw %}`Authenticated`{% endraw %} is when the user has authenticated, agreed to the Terms of Service, and a {% raw %}`User`{% endraw %} document has been saved to Firestore
Here is the new {% raw %}`CurrentUserModel`{% endraw %} definition:{% raw %}
~~~dart
class CurrentUserModel extends Model {
CurrentUserModel({
@required this.firestore,
@required this.firebaseAuth,
@required this.userService,
});
CurrentUserModel.instance()
: firestore = Firestore.instance,
firebaseAuth = FirebaseAuth.instance,
userService = UserService.instance(),
authService = AuthService.instance() {
firebaseAuth.onAuthStateChanged.listen(_onAuthStateChanged);
}
Status _status = Status.Unauthenticated;
Firestore firestore;
FirebaseAuth firebaseAuth;
UserService userService;
User _user;
FirebaseUser _firebaseUser;
AuthService authService;
static CurrentUserModel of(BuildContext context) =>
ScopedModel.of<CurrentUserModel>(context);
User get user => _user;
Status get status => _status;
FirebaseUser get firebaseUser => _firebaseUser;
Future<void> signIn() {
return authService.signInWithGoogle();
}
Future<void> signOut() {
return firebaseAuth.signOut();
}
Future<void> register(Map<String, String> formData) async {
await userService.createUser(_firebaseUser.uid, formData);
}
Future<void> _onAuthStateChanged(FirebaseUser firebaseUser) async {
if (firebaseUser == null) {
_firebaseUser = null;
_user = null;
_status = Status.Unauthenticated;
} else {
if (firebaseUser.uid != _firebaseUser?.uid) {
_firebaseUser = firebaseUser;
}
_status = Status.Unregistered;
if (firebaseUser.uid != _user?.id) {
_user = await userService.getById(_firebaseUser.uid);
}
if (_user != null) {
_status = Status.Authenticated;
}
}
notifyListeners();
_listenToUserChanges();
}
void _onUserDocumentChange(DocumentSnapshot snapshot) {
if (snapshot.exists) {
_user = User.fromDocumentSnapshot(snapshot.documentID, snapshot.data);
_status = Status.Authenticated;
} else {
_user = null;
_status = Status.Unregistered;
}
notifyListeners();
}
void _listenToUserChanges() {
if (_firebaseUser == null) {
return;
}
// TODO(abraham): Does this need any cleanup if uid changes?
firestore
.collection('users')
.document(_firebaseUser.uid)
.snapshots()
.listen(_onUserDocumentChange);
}
}
~~~{% endraw %}
I have created a [named constructor](https://www.dartlang.org/guides/language/language-tour#named-constructors) so that I can call `CurrentUserModel.instance()` and it will use the default services. The number of services this relies on is large and I think can be cleaned up in the future.
The first neat bit is {% raw %}`firebaseAuth.onAuthStateChanged.listen(_onAuthStateChanged)`{% endraw %}. This adds a [listener](https://pub.dartlang.org/documentation/firebase_auth/latest/firebase_auth/FirebaseAuth/onAuthStateChanged.html) to `FirebaseAuth` and anytime the user signs in or signs out the callback will be called.
There is a static {% raw %}`of`{% endraw %} method for the convenience of being able to call {% raw %}`CurrentUserModel.of(context)`{% endraw %} for easy access to the state.{% raw %}
~~~dart
static CurrentUserModel of(BuildContext context) =>
ScopedModel.of<CurrentUserModel>(context);
~~~{% endraw %}
The {% raw %}`signIn`{% endraw %}, {% raw %}`signOut`{% endraw %}, and {% raw %}`register`{% endraw %} methods perform as they are named. They do consolidate a number of service dependencies into {% raw %}`CurrentUserModel`{% endraw %} but I'm not sure this is the best place to have them.
`_onAuthStateChanged` is the work horse of this class. Anytime `FirebaseAuth` changes, this gets called and has to figure out what's going on. In essence if there is no user, it clears all the state, if the user is new or different it tries to get the {% raw %}`User`{% endraw %} document. Lastly it will notify children that the state has changed and will start listening to changes to the {% raw %}`User`{% endraw %} document.
{% raw %}`_listenToUserChanges`{% endraw %} will listen to Firestore for changes to the authenticated user's document. One neat aspect is it can start listening before the document even exists and will get notified when it's created (from registering).
Lastly in {% raw %}`CurrentUserModel`{% endraw %} is {% raw %}`_onUserDocumentChange`{% endraw %}. If the document exists the user is authenticated and registered.
I also added a {% raw %}`User`{% endraw %} model. It doesn't do much yet but handles taking a Firestore document and and turning it into a more manageable class instance.
To make implementation easier, I added a `SignOutAction` widget to the `AppBar` in `HomePage`. This simply renders an `Icon` and calls `CurrentUserModel.of(context).signOut()` on tap.
![screenshot of home page with logout action](https://thepracticaldev.s3.amazonaws.com/i/oh29e4ekgz7xkdtdysln.png)
Another change in `HomePage` is to wrap the `SignInFab` widget in a `ScopedModelDescendant<CurrentUserModel>`. This will cause it to get rebuilt when `CurrentUserModel` notifies its children of state changes. The `builder` callback has to return a `Widget` so I just return an empty `Container` if the user is authenticated.
~~~dart
Widget _floatingActionButton() {
return ScopedModelDescendant<CurrentUserModel>(
builder: (
BuildContext context,
Widget child,
CurrentUserModel model,
) =>
model.user == null ? const SignInFab() : Container(),
);
}
~~~
`RegisterPage` similarly gets updated with a `ScopedModelDescendant<CurrentUserModel>` wrapper. This use is being a little smarter and will show the form if the user is authenticated but not registered and a welcome message once the user finishes registering. This message will need to be [improved](https://github.com/abraham/birb/issues/67).
~~~dart
ScopedModelDescendant<CurrentUserModel>(
builder: (
BuildContext context,
Widget child,
CurrentUserModel model,
) {
if (model.status == Status.Unregistered) {
return const RegisterForm();
} else if (model.status == Status.Authenticated) {
return const Center(
child: Text('Welcome'),
);
} else {
return const CircularProgressIndicator();
}
},
)
~~~
I haven't fleshed out all the tests yet but a lot of the code now has dependencies on {% raw %}`CurrentUserModel`{% endraw %} so I added a simple {% raw %}`appMock`{% endraw %} to makes this easier.{% raw %}
~~~dart
ScopedModel<CurrentUserModel> appMock({
@required Widget child,
@required CurrentUserModel mock,
}) {
return ScopedModel<CurrentUserModel>(
model: mock,
child: MaterialApp(
home: Scaffold(
body: child,
),
routes: <String, WidgetBuilder>{
RegisterPage.routeName: (BuildContext context) => const RegisterPage(),
},
),
);
}
~~~{% endraw %}
## Code changes
- [#66 Create user in Firestore](https://github.com/abraham/birb/pull/66)