This is part 1 of a multi-part series on full-stack flutter architecture, from backend, to web admin, to native IOS and Android apps, to automated deployment, utilising a small sample application.

In 2017 I discovered flutter, the new hotness for building mobile apps that seemingly achieves the holy grail of a singular code-base shared between Android and IOS. In the latter part of 2017, I started work on a new startup, utilising flutter.

Fast-forward to 2019, I'm still working on this startup, and despite numerous setbacks with the still young platform, I've managed to achieve not only a near 100% code-share between IOS and Android, but utilising AngularDart, I've been able to share the business logic between web and mobile also.

Note that flutter support for web and desktop is available in tech preview at the time of writing. This makes the "write once, run anywhere" dream closer to reality than it already was with flutter and AngularDart. Not covered in this part, but may in a  future part.

The App

The app will be a basic "Time Tracking App". Initially it'll feature a timer that you can start, stop or reset. In future articles we'll add a project selection, the ability to edit / change recorded time, a timesheet history, login, and a remote backend.

Time Tracking App

Initial Architecture

Architecture is one area where I initially struggled to settle on something that I liked the feel of. The vanilla "set state" way mixes logic with UI, is difficult to maintain, makes sharing state between different parts of the widget hierarchy problematic, and is more difficult to tune for rendering performance. Many of the alternatives such as BLoC and redux seem to require a lot of boilerplate and in my opinion, add complexity that is unnecessary in a native app environment.

Over time, I've streamlined my flutter architectural practices. Our goal should be to produce a "reactive" UI, proper separation of logic and UI, minimise boilerplate, keep code to a minimum,  be efficient in terms of widget tree rebuilds and hence, produce a performant app.

Hooks, Functional Widget and RxDart

Flutter Hooks is an implementation of React Hooks. In all practical sense, it serves as a drop-in replacement for existing Flutter state management techniques. Hooks manage the widget lifecycle separately to the widgets themselves, and to the state data that they are bound to. HookWidget is a drop-in replacement for StatefulWidget, minus the heavy boilerplate.

Widgets are classes. UIs can be composed of multiple custom widgets, particularly where different parts of the UI are to be rebuilt independently of each other. Even though HookWidget already saves us a lot of ceremony, we can make our code even cleaner by adopting Functional Widgets.

Lastly, we use RxDart as the glue between our state objects, and the UI; more specifically, we will use BehaviorSubject, which is a kind of Broadcast StreamController that automatically sends the last item added to any new listeners. Functionally this works like a BLoC stream/sink combo, but without all the extra ceremony and boilerplate. Combined with Hooks, it makes complex Reactive UIs easy-mode.

Okay, let's get started

I'm assuming you've got the following already:

  1. Flutter (this code is built against stable 1.2.1)
  2. Visual Studio Code
  3. The following Visual Studio Code extensions: Flutter, Dart
  4. Access to a device to test on, be it a physical iPhone or Android Phone, or an emulator. (Note: this code will function on both IOS and Android without modification)

Setup Project

In a terminal, create a new folder "time_tracking", and type the following (feel free to use your own org).

flutter create --org org.paddo --project-name time_tracking app

We do it this way because we will add new folders for backend, web, etc later on.

Run Project

Open Visual Studio Code in the time_tracking folder. Hit F5 to run the app in your emulator. You may need to tweak your launch.json like so:

{
    "version": "0.2.0",
    "configurations": [
        {
            "name": "Dart",
            "program": "app/lib/main.dart",
            "request": "launch",
            "type": "dart"
        }
    ]
}

From here, you should see the default flutter demo app on your device, great. Now we can trash all that starter code and start again!

Add some dependencies

Open Pubspec.yaml, and make it look like this:

name: time_tracking
description: A new Flutter project.
version: 1.0.0+1
environment:
  sdk: ">=2.1.0 <3.0.0"
dependencies:
  flutter:
    sdk: flutter
  cupertino_icons: ^0.1.2
  flutter_hooks: ^0.3.0
  rxdart: ^0.22.0
  functional_widget_annotation: ^0.5.0
dev_dependencies:
  flutter_test:
    sdk: flutter
  functional_widget: ^0.6.0
  build_runner: ^1.3.1
flutter:
  uses-material-design: true

You'll note that we've added rxdart, functional_widget_annotations and flutter_hooks. Additionally, we've added the code-generation bits for functional widgets under dev_dependencies.

Code-gen stuff

Before we go too far, let's update our .gitignore to ignore generated code. Add this to the end of the file: *.g.dart

Secondly, we want to configure our code generator to produce "Hook Widgets". We can do this by telling it in a "build.yaml" file in the app folder.

targets:
  $default:
    builders:
      functional_widget:
        options:
          widgetType: hook

Lastly, we need to have the functional widget package generate code for us automatically on file save. In a terminal, make sure you are in the "app" folder, and type the following: flutter pub pub run build_runner watch

Folder structure

I like the idea of keeping code organised by feature / page as opposed to into views/models folders. I'll be using a folder structure like so:

+-- app
|   +-- pubspec.yaml
|   +-- lib
|       +-- main.dart
|       +-- pages
|           +-- timer
|               +-- timer_page.dart
|               +-- timer_page_model.dart
|               +-- widgets
|                   +-- some_timer_page_widget.dart
|           +-- some_other
|               +-- some_other_page.dart
|               +-- some_other_page_model.dart

app/lib/main.dart

Replace main.dart with this... We'll change "home" to the the timer page shortly.

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:functional_widget_annotation/functional_widget_annotation.dart';

part 'main.g.dart';

void main() async {
  await SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp, DeviceOrientation.portraitDown]);
  SystemChrome.setSystemUIOverlayStyle(SystemUiOverlayStyle.light);
  runApp(MyApp());
}

@widget
Widget myApp() => MaterialApp(
      debugShowCheckedModeBanner: false,
      theme: ThemeData.dark(),
      home: Container(),
    );

What's going on here? Of note, we are including the functional widget annotations, which allows us to mark functions with @widget. This  generates a class from that function, complete with HookWidget boilerplate. The "part" line tell us that there will be a generated file at main.g.dart.

Once this file is saved, the code generator should create the widget boilerplate for us at main.g.dart.

app/lib/pages/timer/timer_page_model.dart

import 'dart:async';

import 'package:rxdart/rxdart.dart';

class TimerPageModel {
  final Stopwatch _stopWatch;
  final BehaviorSubject<double> seconds;
  final BehaviorSubject<double> minutes;
  final BehaviorSubject<double> hours;
  final BehaviorSubject<Duration> duration;
  final int speedMultiplier;
  Timer _timer;

  TimerPageModel({this.speedMultiplier = 1})
      : _stopWatch = Stopwatch(),
        seconds = BehaviorSubject.seeded(0.0),
        minutes = BehaviorSubject.seeded(0.0),
        hours = BehaviorSubject.seeded(0.0),
        duration = BehaviorSubject.seeded(Duration.zero);

  _timerElapsed(Timer timer) {
    final elapsedTime = _stopWatch.elapsed * speedMultiplier;
    if(elapsedTime.inSeconds != duration.value.inSeconds) seconds.add((elapsedTime.inSeconds % 60) / 60.0);
    if(elapsedTime.inMinutes != duration.value.inMinutes) minutes.add((elapsedTime.inMinutes % 60) / 60.0);
    if(elapsedTime.inHours != duration.value.inHours) hours.add(elapsedTime.inHours / 24.0);
    duration.add(elapsedTime);
  }

  start() {
    stop();
    _stopWatch.start();
    _timer = Timer.periodic(Duration(milliseconds: 1000 ~/ speedMultiplier), _timerElapsed);
  }

  stop() {
    _stopWatch.stop();
    if (_timer != null) _timer.cancel();
  }

  reset() {
    _stopWatch.reset();
    if (_timer != null) _timer.cancel();
    duration.add(Duration.zero);
    seconds.add(0.0);
    minutes.add(0.0);
    hours.add(0.0);
  }
}

This is the first part of the 2 part puzzle re state management. Essentially, this file is a ViewModel. It contains a private stopwatch and timer for tracking internal state, and a series of public BehaviorSubjects which various parts of the UI will subscribe to.

We've added a "speed multiplier" so we can test it for hours of elapsed time in minutes.

app/lib/pages/timer/timer_page.dart

import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:rxdart/rxdart.dart';
import 'package:time_tracking/pages/timer/timer_page_model.dart';
import 'package:functional_widget_annotation/functional_widget_annotation.dart';

part 'timer_page.g.dart';

@widget
Widget timerPage() {
  final model = useMemoized(() => TimerPageModel(speedMultiplier: 50));
  final shortestSide = MediaQuery.of(useContext()).size.shortestSide;
  return Container(
    child: Column(
      mainAxisSize: MainAxisSize.max,
      mainAxisAlignment: MainAxisAlignment.spaceEvenly,
      children: <Widget>[
        Stack(
          alignment: Alignment.center,
          children: <Widget>[
            Container(
              width: shortestSide * 0.55,
              height: shortestSide * 0.55,
              decoration: BoxDecoration(
                borderRadius: BorderRadius.circular(shortestSide * 0.4),
                color: Color(0xFF363E66),
              ),
            ),
            ProgressIndicator(model.seconds, shortestSide * 0.6, shortestSide * 0.05, Color(0xFF859DC1)),
            ProgressIndicator(model.minutes, shortestSide * 0.7, shortestSide * 0.05, Color(0xFF82BDBF)),
            ProgressIndicator(model.hours, shortestSide * 0.8, shortestSide * 0.05, Color(0xFFB8D6E1)),
            TimerText(model.duration),
          ],
        ),
        Row(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            RaisedButton(child: Text('Start'), color: Colors.green, onPressed: () => model.start()),
            RaisedButton(child: Text('Stop'), color: Colors.redAccent, onPressed: () => model.stop()),
            RaisedButton(child: Text('Reset'), color: Colors.blueAccent, onPressed: () => model.reset()),
          ],
        ),
      ],
    ),
  );
}

@widget
Widget progressIndicator(BehaviorSubject<double> subject, double diameter, double strokeWidth, Color color) {
  return Container(
    height: diameter,
    width: diameter,
    child: CircularProgressIndicator(
      value: useStream(subject).data ?? 0.0,
      strokeWidth: strokeWidth,
      valueColor: AlwaysStoppedAnimation(color),
    ),
  );
}

@widget
Widget timerText(BehaviorSubject<Duration> subject) {
  final duration = useStream(subject).data ?? Duration.zero;
  final hours = duration.inHours.toString().padLeft(2, '0');
  final minutes = (duration.inMinutes % 60).toString().padLeft(2, '0');
  final seconds = (duration.inSeconds % 60).toString().padLeft(2, '0');

  return Text(
    '$hours:$minutes:$seconds',
    style: Theme.of(useContext()).textTheme.headline,
  );
}

Let's break this down... there's a lot going on.

  1. This file actually contains 3 separate classes that inherit from HookWidget. That's a lot of excess code and excess files avoided. You can find the plumbing in the timer_page.g.dart autogenerated file.
  2. You'll note judicious use of hooks. For our ViewModel, we are using "useMemoized" which basically ensures that it only gets created the first time it's built and is otherwise served from cache. Another hook, useContext gets us access to the BuildContext from anywhere within a HookWidget.
  3. The useStream hook above will rebuild the entire widget (and its widget sub-tree) when the underlying BehaviorSubject has data added to it. This is why it is imperative that you only make use of this hook at the deepest appropriate level within the widget hierarchy. This is achieved by encapsulating that part of the widget tree with an @widget annotated function, and by invoking the generated class instead of the function. This is the most important rule about utilising this architecture.
A couple of notes on hooks:  there are some usage rules. The main usage rule is that all hooks need to be called all the time on each build, there shouldn't be conditional paths that result in more or less hooks being used. Hooks take care of their own lifecycle (dispose, etc), and do not require additional lifecycle management.

One last thing, update app/lib/main.dart

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:functional_widget_annotation/functional_widget_annotation.dart';
import 'package:time_tracking/pages/timer/timer_page.dart';

part 'main.g.dart';

void main() async {
  await SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp, DeviceOrientation.portraitDown]);
  SystemChrome.setSystemUIOverlayStyle(SystemUiOverlayStyle.light);
  runApp(MyApp());
}

@widget
Widget myApp() => MaterialApp(
      debugShowCheckedModeBanner: false,
      theme: ThemeData.dark(),
      home: TimerPage(),
    );

Change our "Container" to a "TimerPage".

All done, what's next?

This should run just fine on whatever emulator or device you have with a press of F5.  Accompanying source code can be found here: https://github.com/paddo/flutter_architecture_part1

I hope that you'll agree that this approach to architecture and state management is certainly one of the easiest to pickup, least "boilerplatey", and quickest to implement. There are of course still many more things to consider, which will be addressed in the coming weeks.

In the next article, we'll add some routing and navigation, a history page, dependency injection via a service locator, and start to add in some managers and services. We're still keeping it local to the device, but we're setting up for part 3, which will add the remote backend, anonymous authentication and app "flavors" so that we may deploy and target dev, test and release versions of our backend.