Reduxing Flutter Firebase App – WeightTracker 4

Reduxing Flutter Firebase App – WeightTracker 4

In this post I’m going to go through how I added flutter_redux library to my existing Firebase-connected app. The presented development is part of my Weight Tracker app (simple app for tracking weight) and will be focused only on that.

Note: This is only my approach to this problem. There are probably better ones, if you have any idea how it could be improved, leave a comment. 😉

Introduction

Some time ago I came across Redux library which provides a way to have one state object (not in the meaning of Flutter state) in application and pass it to widgets in build functions. I recommend going to example to make sure you understand the concept. I didn’t think that I need this kind of tool until I started playing with Firebase Auth and user switching. For me having Database reference in a view class and making sure that it corresponds to the right user and has attached right listeners didn’t work out. Especially having in mind that soon I want to add more Widgets. So I decided to make use of Redux and see if it solves my problems.

Note: I mentioned user switching but it won’t be in content of this post since it is still in development.

Getting started

Dependencies

First of all, I added Redux dependencies in your pubspec.yaml (as on 05-10-2017):

dependencies:
  flutter:
    sdk: flutter
  ...
  redux: ">=2.0.0 <3.0.0"
  flutter_redux: 0.3.1

State and reducer

Next step is to create state class which will represent whole state in my application and stateReducer method for manipulating state. At this point I will have only list of weights. So created redux_core.dart file looks like this:

import 'package:weight_tracker/model/weight_entry.dart';

@immutable
class ReduxState {
  final List<WeightEntry> entries;

  ReduxState({this.entries});
  
  ReduxState copyWith({List<WeightEntry> entries}) {
    return new ReduxState(
      entries: entries ?? this.entries,
      );
  }
}

ReduxState stateReducer(ReduxState state, action) {
  //TODO: add actions
  return state;
}

Interesting thing here is that action is dynamic. Since there will be different types of actions that will provide various types of data, Brian Egan suggested to have a class for every action and don’t play with generic Action class. And that’s what I did 🙂

What is also worth mentioning is that ReduxState class is immutable. That means that in order to “change” state, we have to create new one. That’s why I created copyWith method, so it will be easy to copy state with only changed fields.

Store and StoreProvider

In main.dart I create Store object and provide it in StoreProvider. From now on every StoreConnector will have access to the store and therefore to the state.

import 'package:flutter/material.dart';
import 'package:flutter_redux/flutter_redux.dart';
import 'package:redux/redux.dart';
import 'package:weight_tracker/home_page.dart';
import 'package:weight_tracker/logic/redux_core.dart';

void main() {
  runApp(new MyApp());
}

class MyApp extends StatelessWidget {
  final Store store = new Store(stateReducer, initialState: new ReduxState(entries: new List()));

  @override
  Widget build(BuildContext context) {
    return new MaterialApp(
      title: 'Weight Tracker',
      theme: new ThemeData(
        primarySwatch: Colors.green,
      ),
      home: new StoreProvider(
        store: store,
        child: new HomePage(title: 'Weight Tracker'),
      ),
    );
  }
}

StoreConnector

Next step is to create ViewModel class in my screen widget. At this moment we don’t really need it but it will be helpful in the future.

@immutable
class HomePageViewModel {
  final List<WeightEntry> entries;

  HomePageViewModel({this.entries});
}

Now I will wrap my screen Widget in StoreConnector. Let’s see how it looks like:

@override
Widget build(BuildContext context) {
  return new StoreConnector<ReduxState, HomePageViewModel>(
    converter: (store) {
      return new HomePageViewModel(
        entries: store.state.entries);
    },
    builder: (context, viewModel) {
      return new Scaffold(
        appBar: new AppBar(
          title: new Text(widget.title),
        ),
        body: new ListView.builder(
          shrinkWrap: true,
          controller: _listViewScrollController,
          itemCount: viewModel.entries.length,
          itemBuilder: (buildContext, index) {
            //calculating difference
            double difference = index == viewModel.entries.length - 1
                ? 0.0
                : viewModel.entries[index].weight -
                    viewModel.entries[index + 1].weight;
            return new InkWell(
                onTap: () => _openEditEntryDialog(
                    viewModel.entries[index]),
                child:
                    new WeightListItem(viewModel.entries[index], difference));
          },
        ),
        floatingActionButton: new FloatingActionButton(
          onPressed: () => _openAddEntryDialog(),
          tooltip: 'Add new weight entry',
          child: new Icon(Icons.add),
        ),
      );
    },
  );
}

We can see that the main Widget is now StoreConnector. It contains of two components: converter and builder. Converter is a method that accepts Store and returns ViewModel specified in StoreConnector. At this point theoreatically I could use List as ViewModel since I am not using anything else but it will change soon. Other component of StoreConnector is builder, which is also a method. It is very similar to overriden build method except it has also earlier defined ViewModel as a parameter. We can use that ViewModel to populate data into our view.

After changing ListView.Builder to use data from viewModel we have perfectly working ListView with data coming from our state. The only problem is we have no entries there. Let’s add some!

Adding action

So far our reducer method, which is our way to mutate state, is empty. In order to make it work, we need to define Actions, which we can consider as contract of how the state can be changed. So let’s introduce adding action:

class AddEntryAction {
  final WeightEntry weightEntry;
  AddEntryAction(this.weightEntry);
}
ReduxState stateReducer(ReduxState state, action) {
  if (action is AddEntryAction) {
    return new state.copyWith(
      entries: <WeightEntry>[]
        ..addAll(state.entries)
        ..add(action.weightEntry));
  }
  return state;
}

Simple like that! Now we only need to call it. First, I will add new field in ViewModel class:

@immutable
class HomePageViewModel {
  final List<WeightEntry> entries;
  final Function(WeightEntry) addEntryCallback;

  HomePageViewModel({this.entries,
      this.addEntryCallback});
}

Then we need to update build method:

@override
Widget build(BuildContext context) {
  return new StoreConnector<ReduxState, HomePageViewModel>(
    converter: (store) {
      return new HomePageViewModel(
        entries: store.state.entries,
        addEntryCallback: ((entry) => store.dispatch(new AddEntryAction(entry))));
    },
    builder: (context, viewModel) {
      return new Scaffold(
        ...
        floatingActionButton: new FloatingActionButton(
          onPressed: () => _openAddEntryDialog(viewModel.addEntryCallback),
          tooltip: 'Add new weight entry',
          child: new Icon(Icons.add),
        ),
      );
    },
  );
}
_openAddEntryDialog(Function(WeightEntry) onSubmittedCallback) async {
  WeightEntry entry = ...create entry...
  if (entry != null) {
    onSubmittedCallback(entry);
  }
}

There are few things worth mentioning here:

  • To perform action we call dispatch method of Store object with Action we would like to use. This will cause the reducer method to be invoked and do what we want to do.
  • We can pass our method accepting entry further and call it exactly when we want it.
  • This solution shows us very clearly what data our screen widget uses and what actions it can perform.

Now let’s introduce Firebase Database to the equation.

Reduxing Firebase Database

Signing in

In my scenario, I want user to be anonymously logged in as soon as app launches. To do that we will need to do following changes:

Add new actions

class InitAction {}
class UserLoadedAction {
  final FirebaseUser firebaseUser;
  UserLoadedAction(this.firebaseUser);
}

Update ReduxState

class ReduxState {
  final FirebaseUser firebaseUser;
  final List<WeightEntry> entries;
  ...
}

Since the reducer is not supposed to do any API calls, we will introduce Middleware. Middleware is a function that is invoked before reducer in order to call async functions, so that reducer will perform only pure functions without any side effects. To understand this concept better, I recommend reading this article.

firebaseMiddleware(Store<ReduxState> store, action, NextDispatcher next) {
  if (action is InitAction) {
    if (store.state.firebaseUser == null) {
      FirebaseAuth.instance.currentUser().then((user) {
        if (user != null) {
          store.dispatch(new UserLoadedAction(user));
        } else {
          FirebaseAuth.instance
              .signInAnonymously()
              .then((user) => store.dispatch(new UserLoadedAction(user)));
        }
      });
    }
  } 
  next(action);
}

What happens here is if we try to dispatch InitAction, then middleware function will try to get current user and if there is none it will try to signInAnonymously. What’s important is that this is done async, so the reducer will be invoked anyway (thanks to calling next method). When the user is provided, we dispatch new UserLoadedAction with obtained FirebaseUser.

Then let’s update stateReducer method

ReduxState stateReducer(ReduxState state, action) {
  if (action is UserLoadedAction) {
    return state.copyWith(firebaseUser: action.firebaseUser);
  } else if (action is AddEntryAction) {
    ...
  }
  return state;
}

And call it from widget:

class MyApp extends StatelessWidget {
  final Store store = new Store(stateReducer,
    initialState: new ReduxState(
      firebaseUser: null,
      entries: new List()),
    middleware: [firebaseMiddleware].toList());

  @override
  Widget build(BuildContext context) {
    store.dispatch(new InitAction());
    return new MaterialApp(
      title: 'Weight Tracker',
      theme: new ThemeData(
        primarySwatch: Colors.green,
      ),
      home: new StoreProvider(
        store: store,
        child: new HomePage(title: 'Weight Tracker'),
      ),
    );
  }
}

Notice that we added middleware to the Store initialization. From now on, on every launch user will be signed in anonymously. It’s not much unless we will also connect to database.

Syncing with Firebase Database

Don’t get discouraged if you don’t understand everything while reading. At the end I will explain it and all should be clear. 😉

At first, let’s add DatabaseReference to the state.

class ReduxState {
  final FirebaseUser firebaseUser;
  final DatabaseReference mainReference;
  final List<WeightEntry> entries;
  ...
}

Then we will add two new actions:

class AddDatabaseReferenceAction {
 final DatabaseReference databaseReference;

 AddDatabaseReferenceAction(this.databaseReference);
}

class OnAddedAction {
  final Event event;

  OnAddedAction(this.event);
}

Next we need to update middleware

firebaseMiddleware(Store<ReduxState> store, action, NextDispatcher next) {
  if (action is InitAction) {
    ...
  } else if (action is AddEntryAction) {
    store.state.mainReference.push().set(action.weightEntry.toJson());
  }
  next(action);
  if (action is UserLoadedAction) {
    store.dispatch(new AddDatabaseReferenceAction(FirebaseDatabase.instance
        .reference()
        .child(store.state.firebaseUser.uid)
        .child("entries")
      ..onChildAdded
          .listen((event) => store.dispatch(new OnAddedAction(event)))));
  }
}

And update reducer

ReduxState stateReducer(ReduxState state, action) {
  if (action is AddDatabaseReferenceAction) {
    return state.copyWith(mainReference: action.databaseReference);
  } else if (action is OnAddedAction) {
    return _onEntryAdded(state, action.event);
  }
  ...
}

ReduxState _onEntryAdded(Event event) {
  return new state.copyWith(
     entries: <WeightEntry>[]
       ..addAll(state.entries)
       ..add(new WeightEntry.fromSnapshot(event.snapshot)));
}

How it works?

  1. First we invoke InitAction, in the middleware we obtain a FirebaseUser and invoke OnUserLoadedAction.
  2. After user is updated, we create DatabaseReference and pass it in AddDatabaseReferenceAction.
  3. This reference has also added OnChildAdded listener which, when called, will invoke OnAddedAction.
  4. When that one gets passed, it adds an Entry to the state’s list (exactly how AddEntryAction worked before).

And when it comes to AddEntryAction, instead of adding an Entry to list, we simply call Database’s push method. Since now this action won’t even be handled in reducer, because it doesn’t affect state at all.

If the process is still not clear for you, following UML sequence diagram may be helpful to fully understand the flow (click for better quality):

For better understanding I also recommend checking out whole source code (linked at the bottom).

Wrapping up

Now we have reduxed firebase app. I think that having screen Widgets with clearly defined data and actions they will use (in viewModel) and not having any logic in those widgets are very beneficial for code readability. I encourage you to try Reduxing your Flutter app, it really provides great control over whole application. 🙂

That’s it! 🙂

You can find full code here.

Special thanks to Brain Egan (@brianegan) for support 🙂

13 thoughts on “Reduxing Flutter Firebase App – WeightTracker 4

  1. Hi Marcin

    Thanks for sharing your adventure, really practical and point to point, it helped me a lot in adopting Flutter, if you get a chance can you also show some love how to best implement Rx dart in similar example?

    Kind regards

    1. Hey, I’m glad you like it 🙂

      To be honest I had no plans for working with Rx dart but if I don’t have idea what to do I might try and share it 🙂

  2. Hey, thanks the great article. I’m also using firebase, and would like to test middleware, if you have time, could you please write some examples of how this can be done?
    Thanks again 🙂

  3. You never cancel your subscriptions that dispatch OnAddedAction, OnChangedAction etc. Isn’t that bad practice in a real app?

    Thanks for the article!

    1. Hi Tudor!
      In my particular scenario I think it is good, because I want to listen to those streams all the time.
      In other cases you are right. If I stop needing a subscription I should cancel it.
      Now I did a bit of research and I couldn’t find any way to cancel subscription except setting cancelOnError flag while subscribing. Do you know how to do that? 🙂

        1. I just finished a working prototype with plain redux.dart. I’ll try and write a blog-post about it sometime next week.

          The gist of it:
          – in widget’s onInit / onDispose dispatch RequestDataEventsAction / CancelDataEventsAction
          – in middleware you subscribe to firebase, dispatch DataEventsRequestedAction with the StreamSubscription obj to be saved in your store and OnDataAction each time there’s a new value.
          – in your reducer you save the subscription from DataEventsRequestedAction and ?.cancel() + delete it when CancelDataEventsAction.

  4. I would use a more OO approach. The if statement inside a function could get a weird spaghetti code chunk. I would change action to follow GoF command in action and this method will be specialized for each action. Simpler, no ifs and organized.

Leave a Reply

Your email address will not be published.