Skip to main content

A month of Flutter: user registration refactor with reactive scoped model


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 to AuthService
  • 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 got routeName added
In MyApp's buildMaterialApp 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(),
    },
  ),
)
ScopedModel takes a model, which in this case is CurrentUserModel (I'm not 100% in on that name yet) and acts similar to an InheritedWidget. Any children can find and interact with the CurrentuserModelinstance or be conditionally rendered based on its state.
Coming from a background in web development, I thinkf of Models 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 or state machine and can manage the state of several things.
I have set the CurrentStatusModel to have one of three statues:
enum Status {
  Unauthenticated,
  Unregistered,
  Authenticated,
}
  • Unauthenticated is the initial default
  • Unregistered the user has authenticated through Firebase
  • Authenticated is when the user has authenticated, agreed to the Terms of Service, and a User document has been saved to Firestore
Here is the new CurrentUserModel definition:
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);
  }
}
I have created a named constructor 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 firebaseAuth.onAuthStateChanged.listen(_onAuthStateChanged). This adds a listener to FirebaseAuth and anytime the user signs in or signs out the callback will be called.
There is a static of method for the convenience of being able to call CurrentUserModel.of(context) for easy access to the state.
static CurrentUserModel of(BuildContext context) =>
    ScopedModel.of<CurrentUserModel>(context);
The signInsignOut, and register methods perform as they are named. They do consolidate a number of service dependencies into CurrentUserModel 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 User document. Lastly it will notify children that the state has changed and will start listening to changes to the User document.
_listenToUserChanges 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 CurrentUserModel is _onUserDocumentChange. If the document exists the user is authenticated and registered.
I also added a User 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.

Another change in HomePage is to wrap the SignInFabwidget in a ScopedModelDescendant<CurrentUserModel>. This will cause it to get rebuilt when CurrentUserModelnotifies its children of state changes. The buildercallback has to return a Widget so I just return an empty Container if the user is authenticated.
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.
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 CurrentUserModel so I added a simple appMock to makes this easier.
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(),
      },
    ),
  );
}

Code changes

Comments

Popular posts from this blog

Installing Storytlr the lifestreaming platform

" Storytlr  is an open source lifestreaming and micro blogging platform. You can use it for a single user or it can act as a host for many people all from the same installation." I've been looking for something like Storytlr for a few months now or at least trying to do it with Drupal . While I love Drupal and FeedAPI  I did not want to spend all that time building a lifestream website. So I've been playing around with Storytlr instead and found it very easy. Here is how I got it up and running on a Ubuntu EC2 server. You can also check out the official Storytlr install instructions . Assumptions: LAMP stack installed and running. Domain setup for a directory. MySQL database and user ready to go. Lets get started! Get the code : wget http://storytlr.googlecode.com/files/storytlr-0.9.2.tgz tar -xvzf storytlr-0.9.2.tgz You can find out the  latest stable release  on Storytlr's downloads page. Import the database : Within protected/install is database.sq

Sync is currently experiencing problems

Update : I now recommend you install Google Chrome  and  disable  the built in Browser as it supports encrypting all synced data. After picking up a gorgeous  Galaxy Nexus yesterday I was running into an issue where my browser data wasn't syncing to the phone. After a little Googling I found this is commonly caused by having all of my synced Chrome data encrypted instead of the default of only encrypting the passwords. These are the steps I went through to get my dat syncing again without losing any of it. The exact error I was getting was "Sync is currently experiencing problems. It will be back shortly." In Google Chrome open the personal stuff settings page by clicking this link or by opening the wrench menu, and click on "signed in with example@gmail.com".  Hit "disconnect your Google Account" to temporarily disable syncing from your browser. Visit the Google Dashboard and "Stop sync and delete data from Google". I waite

A month of Flutter: a look back

Originally published on bendyworks.com . This is it. 31 blog posts in 31 days. Writing  a month of flutter  has been a ton of work but also lots of fun and a good learning experience. I really appreciate how supportive and and positive everyone as been. Publishing experience For the series I've been posting on  bendyworks.com ,  DEV ,  my personal blog , and  Medium . After publishing to these sites, I would put the Bendyworks link on  Twitter ,  Reddit , and the  Flutter Study Group Slack . Posting to DEV was easy as they use Markdown just like the Bendworks blog. DEV also has built in support for a  series of posts  so it's easy to read the entire series. I did have to manually upload any embedded images. DEV also has a number of  liquid tags  for embedding things like GitHub issues that I didn't make as much use of as I should have. Blogger is rich text so it was easy to copy/paste the rendered posts. This would hotlink all the images though so I had to rem