Building a Messaging App in Flutter — Part II: Main Method

Friday, Oct 11, 2019| Tags: flutter

This is part of a series of articles describing the work we did for Timy Messenger, a messaging app for groups to communicate and organize themselves. Build with flutter.

In this article I will explain everything that goes in the MaterialApp top level Widget, including the custom logger, the Redux configuration, localization and Firebase Cloud Messaging configuration.

The app is open source and the code is available for you get here:

janoodleFTW/timy-messenger

In this article we will take a look mainly at circles_app.dart.

Main Method

Originally, the app was code-named “Circles”, and so the CirclesApp Widget remained as the top level Widget.

This is the top Widget in the hierarchy and the one that is called by the main method:

void main() { 
  configureLogger();  
  runApp(CirclesApp());  
}

In this method we configure the logging system of the app even before launching the first Widget. We do this as a way to catch any issues that may even be in it.

Logger

You can find the logger.dart in the util package.

On app start, the app calls configureLogger, which is a top level method that will initalize the Logger class adding a DebugLoggerClient or a CrashlyticsLoggerClient depending if the app is in release mode or not.

The way to check if the app is in release mode, is with the global boolean kReleaseMode.

configureLogger() {  
  if (!kReleaseMode) {  
    Logger.addClient(DebugLoggerClient());  
  } else {  
    FlutterError.onError = Crashlytics.instance.recordFlutterError;  
    Logger.addClient(CrashlyticsLoggerClient());  
  }  
}

The DebugLoggerClient implements a LoggerClient, which is an abstract class with an empty onLog method.

abstract class LoggerClient {  
  onLog({  
    LogLevel level,  
    String message,  
    dynamic e,  
    StackTrace s,  
  });  
}

Depending on the LogLevel (debug, warning or error) it will print different information in the logs using debugPrint.

switch (level) {  
  case LogLevel.debug:  
    debugPrint("${_timestamp()} [DEBUG]  $message");  
    if (e != null) {  
      debugPrint(e.toString());  
      debugPrint(s.toString() ?? StackTrace.current);  
    }  
    break;

Debug and warning levels can optionally accept an Exception to be displayed and a StackTrace. While the error log level always requires the current StackTrace. For example here’s the log error method signature:

/// Error level logs  
/// Requires a current StackTrace to report correctly on Crashlytics  
/// Always reports as non-fatal to Crashlytics  
static e(  
  String message, {  
  dynamic e,  
  @required StackTrace s,  
})

From code, we call Logger.e(“message”, e: error, s: StackTrace.current) to log an error message.

We use debugPrint, instead of Dart’s print, because: “debugPrint throttles messages and avoid dropping messages that rate-limit their logging” (from print.dart)

The other LoggerClient is the CrashlyticsLoggerClient, this one is similar to the Debug one, except it uses a Crashlytics.instance to send logs to, and in case of error reported, call to recordError (which logs a non-fatal in Firebase Crashlytics).

There’s a final step that also happens in the configureLogger, which is this one:

// Pass all uncaught errors from the framework to Crashlytics.  
FlutterError.onError = Crashlytics.instance.recordFlutterError;

This makes sure that the Flutter errors (rendering issues, null pointers, etc.) are passed to the Crashlytics error handler and so reported to Crashlytics. Again, this will be recorded as a non-fatal.

CirclesApp

Let’s move into the CirclesApp Widget.

This is the top level Widget that hosts a MaterialApp Widget.

The MaterialApp is wrapped by a StoreProvider. This is a Widget from flutter_redux with the goal to provide a Redux Store to all descendants. You will be able to access the Store using StoreConnector or StoreBuilder.

StoreProvider(  
  store: store,  
  child: MaterialApp(

Store

The Redux Store is the central piece of the Redux State Management Architecture. It holds the application state, as well as the different Reducer and Middleware functions.

A short explanation of what they are:

  • The Reducer functions are a set of functions that will update your application state. For example, if your app state is holding a counter = 1, you can use a Reducer function to change the counter value to 2.
  • The Middleware functions are a set of functions that will perform operations (in parallel) to the application state, and may or may not trigger other actions later. For example, you can use a Middleware function to save the counter value in a database.

The Store needs to be initialised before using the application, and this happens inside the CirclesApp Widget.

The Store is created in the initState method of the _CirclesAppState class, and it takes three parameters: The Reducer, the initial app state and the Middleware.

The appReducer is created by combining multiple reducers from different parts of the app:

final appReducer = combineReducers<AppState>([  
  TypedReducer<AppState, OnGroupsLoaded>(_onGroupsLoaded),  
  TypedReducer<AppState, SelectGroup>(_onSelectGroup),  
  ...authReducers,  
  ...userReducers,  
  ...calendarReducer,  
  ...channelReducers,  
  ...messageReducers,  
  ...pushReducers,  
  ...uiReducers,  
]);

This allows us to split Reducers by feature, rather than having all Reducer functions in the same place. For example, authReducers is located in the authentication package and contains two Reducer functions:

final authReducers = <AppState Function(AppState, dynamic)>[  
  TypedReducer<AppState, OnAuthenticated>(_onAuthenticated),  
  TypedReducer<AppState, OnLogoutSuccess>(_onLogout),  
];

In a similar way, the Middleware is the combination of multiple Middleware functions:

middleware: createStoreMiddleware(  
    groupRepository,  
  )  
  ..addAll(createAuthenticationMiddleware(  
    userRepo,  
    _navigatorKey,  
  ))  
  ..addAll(createCalendarMiddleware(calendarRepository))  
  ..addAll(createUserMiddleware(userRepo))

This also allows us to have all the Middleware functions where they belong and not all grouped in the same place.

As you can notice, the createMiddleware methods require a series of dependencies to be passed to them. In this case, the different data repositories.

Repositories

The instances for the different data repositories are located in the _CircleAppState. They require in all cases an instance of Firestore.

These repositories are passed then to the createMiddleware methods.

Having an abstraction layer between Firebase Firestore and the Middleware functions is very important to be able to effectively test the Middleware. Without the repositories, you would be using Firestore directly from the Middleware making it very hard to test, and as well, makes code harder to reuse and navigate.

The Firestore API is very difficult to mock which makes the repositories hard to test as well.

Before even finishing loading, the application already launches an action to verify the authentication state:

store.dispatch(VerifyAuthenticationState());

This action takes care of checking the current auth state in Firebase Auth and updates the app state correctly. It load all the user data from Firestore once the User object is retrieved, and as well the User’s Groups.

AppState

We have the Reducers, the Middleware, our data repositories and now we need an initial AppState. In this case, the AppState is always empty when the app starts.

If you are an experienced Android developer, you will already see how this may cause problems when the app is killed in the background and the state is lost. The app does not store and restore the AppState on the application lifecycle, and this is a limitation that we decided to accept.

However, the AppState is recreated based on the data read from Firestore. The User data, Channels, Groups, etc. will be restored as well.

FirebaseMessaging

Firebase Cloud Messaging initialisation also happens here. It is performed in four steps:

  • It configures the callbacks for when Push Notifications are received.
  • Requests notifications permissions for iOS.
  • Registers iOS settings.
  • Obtain a Firebase Token asynchronously.

The callbacks will trigger a Redux action and handled in the Middleware:

_firebaseMessaging.configure(  
  onMessage: (Map<String, dynamic> message) async {  
    store.dispatch(OnPushNotificationReceivedAction(message));  
  },  
  onLaunch: (Map<String, dynamic> message) async {  
    store.dispatch(OnPushNotificationOpenAction(message));  
  },  
  onResume: (Map<String, dynamic> message) async {  
    store.dispatch(OnPushNotificationOpenAction(message));  
  },  
);

onMessage is called when the app is in foreground and a Push is received.

onLaunch and onResume are called when the user opens the app from a Push Notification, with some differences between Android and iOS. Check https://pub.dev/packages/firebase_messaging for more details on this.

_firebaseMessaging.getToken().then((String token) {  
  assert(token != null);  
  Logger.d("Push Messaging token: $token");  
  store.dispatch(UpdateUserTokenAction(token));  
});

Another interesting part is obtaining the Token and handling it, which is also handled launching a Redux action.

Localization

The last part of this article is about how the app handles localization.

This is done by passing to parameters to the MaterialApp:

MaterialApp(  
  localizationsDelegates: localizationsDelegates,  
  supportedLocales: [  
    const Locale("de", "DE"),  
    const Locale("en", "EN"),  
  ],
  • localizationsDelegates a list of classes that will handle localization.
  • supportedLocales to list the supported locales, in this case English and German.

Localization is implemented in the circles_localization.dart file, which I will go in more detail in future articles.

One thing to note is that the app calls to _updateUserLocale**(**context) when navigating to the Home screen, this helps storing the User locale on Firestore, to later be used to localize things from Cloud Functions like Push Notifications.

I hope you enjoyed the second part of this series and it helped you understand the different things that can go in the main Widget of a Flutter app.

In the next article I want to give a deeper look at the Redux implementation of the project.

Thanks for reading!

Want to learn more Android and Flutter? Check my courses here.

INTERESTED IN WORKING TOGETHER?

Contact with me