Flutter application templates and bottom navigation using provider and MobX

UPDATE 5/6/2019: the code snippets have been updated to make use of const where possible with some notes added

UPDATE 19/6/2019: this post has been updated since it was originally written. The DestinationsStore class was previously called AppStore. The renaming is to better align with domain vocabulary

UPDATE 30/6/2019: I've updated some of the code snippets and screenshots here to match the latest updates

Application developers are accustomed to having using an IDE to create a brand new application using one of the provide templates as a starting point. For example, when creating an Android application, developers can choose from options like a bottom navigation activity, master/detail flow etc. Some ecosystems also provide the ability create an application using templates created by the community. These are generally done so that developers get to use their preferred library of choice for structuring their application. For example, if you're developing a Xamarin.Forms application in Visual Studio and prefer to use Prism to implement the MVVM pattern, then one can install the Prism Template Pack that would add project templates to their Visual Studio installation. This would allow developers to pick the template that would create a Xamarin.Forms applications with Prism installed and view models are then implemented using Prism's APIs. Currently, Flutter doesn't provide similar functionality though it is possible to use the command to create an app from one the samples in the docs. In this post, I'll be introducing my attempt to create an application template that uses material design and combines using the provider and MobX for state management. The application will have a bottom navigation bar that allows users to change destinations (note: destinations are what they're referred to material design specification but I will use this interchangeably refer to them as pages as well)

The following are screenshots from the application

The home page
The dashboard page
The notifications page

The application is similar to the one created by Android Studio when creating an Android application with a bottom navigation activity. The difference here is that each page has its own counter that should be familiar to Flutter developers as the tooling will default to creating a counter application out of the box.

Each page is associated with a data store to keep track of the counter and an action to increment the counter. A data store for the destinations is used to track the available destinations that the user can select and which one is currently selected

import 'package:mobx/mobx.dart';
import '../constants/enums.dart';
part 'destinations_store.g.dart';

class DestinationsStore = DestinationsStoreBase with _$DestinationsStore;

abstract class DestinationsStoreBase with Store {
  static const List<Destination> destinations = Destination.values;

  @observable
  int selectedDestinationIndex = destinations.indexOf(Destination.Home);

  @computed
  Destination get selectedDestination => destinations[selectedDestinationIndex];

  @action
  void selectDestination(int index) {
    selectedDestinationIndex = index;
  }
}

Within the data store, the selected destination defaults to the home page. The data store provides an action that updates the currently selected destination and is invoked when the user picks one of the items that are part of bottom navigation bar. The code for the application that relates to this is as follows

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'constants/enums.dart';
import 'constants/keys.dart';
import 'pages/dashboard_page.dart';
import 'pages/home_page.dart';
import 'pages/notifications_page.dart';
import 'pages/settings_page.dart';
import 'services/preferences_service.dart';
import 'stores/dashboard_store.dart';
import 'stores/destinations_store.dart';
import 'stores/home_store.dart';
import 'stores/notifications_store.dart';
import 'stores/settings_store.dart';

void main() async {
  final sharedPreferences = await SharedPreferences.getInstance();
  runApp(App(sharedPreferences));
}

class App extends StatelessWidget {
  const App(this.sharedPreferences, {Key key}) : super(key: key);

  final SharedPreferences sharedPreferences;

  @override
  Widget build(BuildContext context) {
    return MultiProvider(
      providers: [
        Provider<DestinationsStore>(
          builder: (_) => DestinationsStore(),
        ),
        Provider<HomeStore>(
          builder: (_) => HomeStore(),
        ),
        Provider<DashboardStore>(
          builder: (_) => DashboardStore(),
        ),
        Provider<NotificationsStore>(
          builder: (_) => NotificationsStore(),
        ),
        Provider<PreferencesService>(
          builder: (_) => PreferencesService(sharedPreferences),
        ),
        ProxyProvider<PreferencesService, SettingsStore>(
          builder: (_, preferencesService, __) =>
              SettingsStore(preferencesService),
        ),
      ],
      child: Consumer<SettingsStore>(
        builder: (context, store, _) {
          return Observer(
            builder: (_) {
              return MaterialApp(
                title: 'App title',
                theme: store.useDarkMode ? ThemeData.dark() : ThemeData.light(),
                home: Consumer<DestinationsStore>(
                  builder: (context, store, _) {
                    return Observer(
                      builder: (_) {
                        return Scaffold(
                          appBar: AppBar(
                            title: AppBarTitle(store.selectedDestination),
                          ),
                          body: SafeArea(
                            child: PageContainer(
                              store.selectedDestination,
                            ),
                          ),
                          bottomNavigationBar: AppBottomNavigationBar(store),
                          floatingActionButton: store.selectedDestination ==
                                  Destination.Settings
                              ? null
                              : FloatingActionButton(
                                  key: Keys.incrementButtonKey,
                                  onPressed: () {
                                    switch (store.selectedDestination) {
                                      case Destination.Home:
                                        Provider.of<HomeStore>(context)
                                            .increment();
                                        break;
                                      case Destination.Dashboard:
                                        Provider.of<DashboardStore>(context)
                                            .increment();
                                        break;
                                      case Destination.Notifications:
                                        Provider.of<NotificationsStore>(context)
                                            .increment();
                                        break;
                                      case Destination.Settings:
                                        break;
                                    }
                                  },
                                  child: const Icon(Icons.add),
                                ),
                        );
                      },
                    );
                  },
                ),
              );
            },
          );
        },
      ),
    );
  }
}

class AppBarTitle extends StatelessWidget {
  const AppBarTitle(this.destination, {Key key}) : super(key: key);

  final Destination destination;

  @override
  Widget build(BuildContext context) {
    switch (destination) {
      case Destination.Dashboard:
        return const Text('Dashboard', key: Keys.dashboardPageTitleKey);
      case Destination.Notifications:
        return const Text('Notifications', key: Keys.notificationsPageTitleKey);
      case Destination.Settings:
        return const Text('Settings', key: Keys.settingsPageTitleKey);
      default:
        return const Text('Home', key: Keys.homePageTitleKey);
    }
  }
}

class AppBottomNavigationBar extends StatelessWidget {
  const AppBottomNavigationBar(
    this.store, {
    Key key,
  }) : super(key: key);

  final DestinationsStore store;

  @override
  Widget build(BuildContext context) {
    return BottomNavigationBar(
      key: const Key('bottomNavigationBar'),
      selectedItemColor: Colors.blue,
      unselectedItemColor: Colors.grey,
      currentIndex: store.selectedDestinationIndex,
      items: DestinationsStoreBase.destinations.map(
        (option) {
          switch (option) {
            case Destination.Home:
              return const BottomNavigationBarItem(
                icon: Icon(Icons.home),
                title: Text('Home'),
              );
            case Destination.Dashboard:
              return const BottomNavigationBarItem(
                icon: Icon(Icons.dashboard),
                title: Text('Dashboard'),
              );
            case Destination.Notifications:
              return const BottomNavigationBarItem(
                icon: Icon(Icons.notifications),
                title: Text('Notifications'),
              );
            case Destination.Settings:
              return const BottomNavigationBarItem(
                icon: Icon(Icons.settings),
                title: Text('Settings'),
              );
          }
        },
      ).toList(),
      onTap: (index) => store.selectDestination(index),
    );
  }
}

class PageContainer extends StatelessWidget {
  const PageContainer(this.destination, {Key key}) : super(key: key);

  final Destination destination;

  @override
  Widget build(BuildContext context) {
    switch (destination) {
      case Destination.Dashboard:
        return const DashboardPage(key: Keys.dashboardPageKey);
      case Destination.Notifications:
        return const NotificationsPage(key: Keys.notificationsPageKey);
      case Destination.Settings:
        return const SettingsPage(key: Keys.settingsPageKey);
      default:
        return const HomePage(key: Keys.homePageKey);
    }
  }
}

The child of the Scaffold widget is what will display the current page via the PageContainer widget. The Scaffold widget has a BottomNavigationBar widget specified and this widget handles when the user taps on an menu item. When this happens, the action to update the selected destination is invoked. If the destination changes, the PageContainer widget is redrawn at which point since there is an Observer widget that will react to when the selected destination changes (the selectedDestination property in the DestinationsStore class). When the destination is observed to have changed, the selected destination is checked to determine the page that should be rendered. Some approaches like to define all of the pages for the tabs and store them in a list beforehand compared to what's shown above. I prefer the approach I've shown as it keeps widget construction in the build method, avoids initialising widgets that might not actually be displayed and in my opinion, makes the code is easier to follow.

Now that I've explained the structure behind the application, if this template is of interest to you, check out the GitHub repository here. There's a readme that explains the steps required to use the template to create an application. Updates may be made to the template in the future so if you find it useful, I'd suggest watching the repository for updates.