Since this app is designed for a group of people to work together in real-time, it is important that when someone enters a bill or payment record into the database, that record is immediately visible to anyone else who is using the app. Unfortunately, if we just use Futures to get all of the data from our database when we think we need it (like when the app starts or when that user submits some new information) those methods will only be called when they are explicitly told to by that instance of the app. We could have a refresh button that triggered all of our data to be re-fetched in case anything has changed, or just rely on our users to eventually close and re-open the app if they want the most updated information. This may work for a hobby project but today’s consumers expect more out of the software they use – especially software they pay for.
So the challenge is to ensure that the critical information is always being listened to in case it changes. Fortunately, Flutter and Firestore provide all of the tools we will need to do just this. The general strategy will be to listen to a function we write in our database layer that returns a Stream<QuerySnapshot> or Stream<DocumentSnapshot>, and in the event something changes in that Query/Document, a new snapshot will be piped into a method in our repository that will convert that DocumentSnapshot or QuerySnapshot into a Map, or whatever data object we need, and then send it directly to the Bloc as a Stream. The Bloc will be listening to that stream and will be ready to react to any changes that come down the pipe.
For example, we have the list of accounts and connection requests for a particular user on that user’s document, which is in the ‘Users’ collection at the root of the database. When a connection request is approved by the administrator of the account you are trying to connect to, that will delete the ‘connection request’ entry on the user document and write the account id to the user’s ‘accounts’ list. That change is propagated by the administrator of the account, which is a separate instance of the app from the user who is trying to connect to the account. So the connecting user will want to be listening to their own user document, and when a change is made, update the data in the Bloc and rebuild the UI.
Since before we were doing something like, “make the change in the database, then rebuild the UI” and we are adding, “when a change is made in the database, rebuild the UI”, we will want to delete part of what we were doing before so we do not unnecessarily rebuild the UI, or rebuild it before we receive the most current info from the database. Essentially, we will have two parallel processes – one reacting to changes in the app’s interface and sending information up to the database, and a separate process that is listening to the database and sending information down to the UI.
Another issue this brings up is that we will sometimes want to modify multiple fields on a single document but treat it as a single ‘event,’ so it doesn’t trigger the UI to rebuild with each line of the update, but just at once when it is all updated. It is easy to do this by adding the individual changes to a WriteBatch object and then committing it all at once instead of dealing with individual Futures.
Database Layer
Among other things, it will be important to listen to the particular user document of the user who is signed in, and the collection of users who belong to the currently selected account. In the database layer, these methods look like this:
Stream<DocumentSnapshot> currentUserStream(String userId){
return _user(userId).snapshots();
}
Stream<QuerySnapshot> userStream(String accountId) {
return _usersCollection.where(ACCOUNTS, arrayContains: accountId).snapshots();
}
In the first method, I am just streaming the particular document that is identified by the userId. When this stream is listened to or when that user document is modified, a DocumentSnapshot will be piped through the stream to any methods which are listening to it. In the second method, I am actually searching through all of the user documents in the collection for those who have a particular accountId in their ‘accounts’ array. Whenever a new user has that id added to their accounts array, a new QuerySnapshot that contains a list of all of those users will be piped through the stream to be listened to.
If we want to modify multiple fields on the user document at once, we will need to use a WriteBatch object to avoid triggering the methods listening for changes to that document more than once:
Future<void> addAccountToUser(String userId, String accountId, String permission) async {
WriteBatch batch =_firestore.batch();
DocumentReference user =_user(userId);
DocumentSnapshot userSnapshot = await user.get();
batch.updateData(user, {ACCOUNTS: userSnapshot[ACCOUNTS] + [accountId]});
Map thisAccount = {PERMISSIONS:[permission]};
Map userAccountsInfo = userSnapshot.data[ACCOUNT_INFO];
userAccountsInfo[accountId] = thisAccount;
batch.updateData(user, {ACCOUNT_INFO:userAccountsInfo});
return batch.commit();
}
Repository Layer
These streams are piped through our repository layer where they are adjusted to be the data type that our Bloc can easily work with. We can do this by using the map() function which we can call directly on the stream:
Stream<Map<String, dynamic>> currentUserStream(String userId){
return _db.currentUserStream(userId).map((document) {
document.data[ID] = document.documentID;
return document.data;
});
}
Stream<List<Map<String, dynamic>>> userStream(String accountId){
return _db.userStream(accountId).map(
(snapshot) => snapshot.documents.map(
(document) => document.data
).toList()
);
}
In the first Stream, we are retrieving a single user but we will need to save the documentId in the map we pass through. So, we can modify the data before returning it by adding an ‘Id’ field (ID is a string constant, ‘Id’). In the second Stream, we are converting a QuerySnapshot, which potentially contains many individual DocumentSnapshots, into a List of Maps. So, we call map first to convert the QuerySnapshot into a list, and map on each item in the list to convert it into a Map<String, dynamic> instead of a DocumentSnapshot.
One advantage of cleansing our streams of the DocumentSnapshot and QuerySnapshot types is that we don’t have to import the firestore library into the Bloc class to parse those types there. This is what we want because the Bloc layer should only be dependent on the Repo layer, and the repository only dependent on the data layer.
Bloc Layer
Here is where the magic happens. The key will be to keep separate (1) the listening and responding to changes in the database from (2) responding to UI events. For example, to request a connection to an account, first the user enters the account name and clicks the ‘submit’ button, which sends a RequestConnectionEvent with the request account name to the Bloc. From there, it looks like this:
void _requestConnection(String accountName) async {
_accountStateSink.add(AccountStateLoading());
dynamic accountIdOrNull = await repo.getAccountByName(accountName);
if(accountIdOrNull != null){
bool newAccount = !currentUser.accounts.contains(accountIdOrNull);
if(newAccount){
//update the user document
repo.createAccountConnectionRequest(
accountIdOrNull,
currentUser.userId
);
} else {
_accountStateSink.add(
AccountStateSelect(
error: 'You are already connected to $accountName'
)
);
}
} else {
_accountStateSink.add(
AccountStateSelect(
error: '$accountName does not exist'
)
);
}
}
So, the first thing this does is set the state to ‘AccountStateLoading()’, which sets the whole screen to a loading screen. Ideally, we might just want to display a small loading icon in the corner of the screen but otherwise keep the account page visible. The important thing is that in response to the UI event, the loading state is set, we check if the account is already connected to, and if not, we just call the method in the repository which sets the information on the user document. If there is no error, we never actually do anything to get out of the loading screen in this process. If we never heard back from the database, we would stay in the loading screen forever (which is another issue that will eventually have to be fixed, but in most cases, will not matter).
Because the user that is being modified is the currentUser, though, once the database is updated it will trigger the method that is listening to the current user stream:
// in the Bloc's constructor, we define the StreamSubscription
// _userSubscription:
_userSubscription = repo.currentUserStream(authBloc.currentUserId).listen(
_updateCurrentUserAndAccountNames
);
...
void _updateCurrentUserAndAccountNames(Map<String, dynamic> user) async{
User userFromSink = User.fromJson(user);
//check to see if we need to update accountNames
if(currentUser != null){
if(currentUser.accounts != userFromSink.accounts){
accountNames =
await repo.getAccountNames(userFromSink.accounts);
} //otherwise they are still accurate
} else { //no current user, so we need names from this one
accountNames =
await repo.getAccountNames(userFromSink.accounts);
}
currentUser = userFromSink;
_goToAccountsOrSelect();
}
void _goToAccountsOrSelect() {
if(currentUser.accounts.length == 1){
accountEvent.add(AccountEventGoHome(accountIndex: 0));
} else {
accountEvent.add(AccountEventGoToSelect());
}
}
Once the new user with a new ‘connection request’ comes down the pipe, it will check out the new user and eventually trigger a new Account Event that will cause the UI to rebuild with the new currentUser information. The bloc checks stuff like if a new account id is in the list of accounts (which would be the case if a connection request was just approved), in which case a database call will need to be made to retrieve the name of that account in order to display the name and not just the documentId. Once the new event is added to the Event Stream, that will lead to the original pipeline in the bloc, _mapEventToState, and the UI will rebuild with the new State object.
Costs and Benefits
We will want to use this technique in combination with explicit database calls to balance performance, accuracy, reactivity, and bandwidth.
For information like the current user, we will want to constantly be listening to that because the normal use case of the app involves a separate instance, the account administrator, modifying the user document while the user is potentially logged in. We pay a price by defining this StreamSubscription, though, and if we did this everywhere for every piece of data in the entire app, we might create a suboptimal user experience or worse, make the app impossibly frustrating and slow.
There are other instances, though, when we would not expect the information we retrieve to change in another instance, like the name of the account a particular user is connected to. There may be times when that information does change, like, if a user requests a connection to an account, then the account name changes, but the list of connection requests still displays the old account name. I just don’t foresee that leading to any particular confusion or frustration, so I don’t think it is worth it to stream the names of the accounts for which there are connection requests.
Summary
When we created our first Blocs, the basic components were Event objects, State objects, Streams of the events and States pulled from a StreamController we instantiated when creating the bloc and disposed of when the bloc is disposed, and the _mapEventToState method that we invoked by listening to the Event stream. Methods in the repository that modified the database were called in this Event -> State pipeline. Now this basic recipe is getting a little more complicated by adding another listener, this time to a stream we define in the repository layer. This listener triggers a method like _mapEventToState, except it maps the data that is being listened to to an event, which is then mapped to the new state via the original pipeline. This allows us to simplify some of the paths through the _mapEventToState, because the modifications to the database will now trigger the event via the bloc’s new listener.