Mid last year, I wanted to port an Android app to iOS and web. Flutter was the choice for mobile platforms, and I was thinking about what to choose for the web side.

While I fell in love with Flutter on first sight, I still had some reservations: While propagating state down the widget tree, Flutter’s InheritedWidget or Redux—with all its variations—will do the job, but with a new framework like Flutter, you would expect that the view layer would be a little more reactive, i.e., widgets would be stateless themselves, and change according to the state they’re fed from outside, but they aren’t. Aslo, Flutter only supports Android and iOS, but I wanted to publish to the web. I already have loads of business logic in my app and I wanted to reuse it as much as possible, and the idea of changing the code in at least two places for a single change in business logic was unacceptable.

I started looking around how to get over this and came across BLoC. For a quick intro, I recommend watching Flutter/AngularDart – Code sharing, better together (DartConf 2018) when you have the time.

BLoC Pattern

BLoC is a fancy word invented by Google meaning “business logic components.” The idea of the BLoC pattern is to store as much of your business logic as possible in pure Dart code so it can be reused by other platforms. To achieve this, there are rules you must follow:

Communicate in layers. Views communicate with the BLoC layer, which communicates to the repositories, and the repositories talk to the data layer. Don’t skip layers while communicating.

Views communicate with the BLoC layer, which communicates to the repositories, and the repositories talk to the data layer. Don’t skip layers while communicating. Communicate over interfaces. Interfaces must be written in pure, platform-independent Dart code. For more information, see the documentation on implicit interfaces.

Interfaces must be written in pure, platform-independent Dart code. For more information, see the documentation on implicit interfaces. BLoCs only expose streams and sinks. The I/O of a BLoC will be discussed later.

The I/O of a BLoC will be discussed later. Keep views simple. Keep the business logic out of the views. They should only display data and respond to user interaction.

Keep the business logic out of the views. They should only display data and respond to user interaction. Make BLoCs platform agnostic. BLoCs are pure Dart code, and so they should contain no platform-specific logic or dependencies. Do not branch into platform conditional code. BLoCs are logic implemented in pure Dart and are above dealing with the base platform.

BLoCs are pure Dart code, and so they should contain no platform-specific logic or dependencies. Do not branch into platform conditional code. BLoCs are logic implemented in pure Dart and are above dealing with the base platform. Inject platform-specific dependencies. This may sound contradictory to the rule above, but hear me out. BLoCs themselves are platform agnostic, but what if they need to communicate with a platform-specific repository? Inject it. By ensuring communicating over interfaces and injecting these repositories, we can be sure that regardless of whether your repository is written for Flutter or AngularDart, the BLoC won’t care.

One last thing to keep in mind is that the input for a BLoC should be a sink, while the output is through a stream. These are both part of the StreamController .

If you strictly adhere to these rules while writing your web (or mobile!) app, creating a mobile (or web!) version can be as simple as making the views and platform-specific interfaces. Even if you’ve just begun using AngularDart or Flutter, it’s still easy to make views with basic platform knowledge. You may end up reusing more than half your codebase. The BLoC pattern keeps everything structured and easy to maintain.

Building an AngularDart and Flutter BLoC Todo App

I made a simple todo app in Flutter and AngularDart. The app uses Firecloud as a back-end and a reactive approach to view creation. The app has three parts:

bloc

todo_app_flutter

todoapp_dart_angular

You can choose to have more parts—for example, data interface, localization interface, etc. The thing to remember is that each layer should communicate with the other over an interface.

The BLoC Code

In the bloc/ directory:

lib/src/bloc : The BloC modules are stored here as pure Dart libraries containing the business logic.

: The BloC modules are stored here as pure Dart libraries containing the business logic. lib/src/repository : The interfaces to data are stored in the directory.

: The interfaces to data are stored in the directory. lib/src/repository/firestore : The repository contains the FireCloud interface to data together with its model, and since this is a sample app, we only have one data model todo.dart and one interface to the data todo_repository.dart ; however, in a real-world app, there will be more models and repository interfaces.

: The repository contains the FireCloud interface to data together with its model, and since this is a sample app, we only have one data model and one interface to the data ; however, in a real-world app, there will be more models and repository interfaces. lib/src/repository/preferences contains preferences_interface.dart , a simple interface that stores successfully signed-in usernames to local storage on web or shared preferences on mobile devices.

//BLOC abstract class PreferencesInterface{ //Preferences final DEFAULT_USERNAME = "DEFAULT_USERNAME"; Future initPreferences(); String get defaultUsername; void setDefaultUsername(String username); }

Web and mobile implementations must implement this to the store and get the default username from local storage/preferences. The AngularDart implementation of this looks like:

// ANGULAR DART class PreferencesInterfaceImpl extends PreferencesInterface { SharedPreferences _prefs; @override Future initPreferences() async => _prefs = await SharedPreferences.getInstance(); @override void setDefaultUsername(String username) => _prefs.setString(DEFAULT_USERNAME, username); @override String get defaultUsername => _prefs.getString(DEFAULT_USERNAME); }

Nothing spectacular here—it implements what it needs. You might notice the initPreferences() async method that returns null . This method needs to be implemented on the Flutter side since getting the SharedPreferences instance on mobile is async.

//FLUTTER @override Future initPreferences() async => _prefs = await SharedPreferences.getInstance();

Let’s stick a little bit with the lib/src/bloc dir. Any view that handles some business logic should have its BLoC component. In this dir, you’ll see view BLoCs base_bloc.dart , endpoints.dart , and session.dart . The last one is responsible for signing the user in and out and providing endpoints for repository interfaces. The reason the session interface exists is that the firebase and firecloud packages are not the same for web and mobile and must be implemented based on platform.

// BLOC abstract class Session implements Endpoints { //Collections. @protected final String userCollectionName = "users"; @protected final String todoCollectionName = "todos"; String userId; Session(){ _isSignedIn.stream.listen((signedIn) { if(!signedIn) _logout(); }); } final BehaviorSubject<bool> _isSignedIn = BehaviorSubject<bool>(); Stream<bool> get isSignedIn => _isSignedIn.stream; Sink<bool> get signedIn => _isSignedIn.sink; Future<String> signIn(String username, String password); @protected void logout(); void _logout() { logout(); userId = null; } }

The idea is to keep the session class global (singleton). Based on its _isSignedIn.stream getter, it handles the app switch between login/todo-list view and provides endpoints to repository implementations if the userId exists (i.e., the user is signed in).

base_bloc.dart is the base for all BLoCs. In this example, it handles load indicator and error dialog display as needed.

For the business logic example, we’ll take a look at todo_add_edit_bloc.dart . The file’s long name explains its purpose. It has a private void method _addUpdateTodo(bool addUpdate) .

// BLOC void _addUpdateTodo(bool addUpdate) { if(!addUpdate) return; //Check required. if(_title.value.isEmpty) _todoError.sink.add(0); else if(_description.value.isEmpty) _todoError.sink.add(1); else _todoError.sink.add(-1); if(_todoError.value >= 0) return; final TodoBloc todoBloc = _todo.value == null ? TodoBloc("", false, DateTime.now(), null, null, null) : _todo.value; todoBloc.title = _title.value; todoBloc.description = _description.value; showProgress.add(true); _toDoRepository.addUpdateToDo(todoBloc) .doOnDone( () => showProgress.add(false) ) .listen((_) => _closeDetail.add(true) , onError: (err) => error.add( err.toString()) ); }

The input for this method is bool addUpdate and it’s a listener of final BehaviorSubject<bool> _addUpdate = BehaviorSubject<bool>() . When a user clicks on the save button in the app, the event sends this subject sink true value and triggers this BLoC function. This piece of flutter code does the magic on the view side.

// FLUTTER IconButton(icon: Icon(Icons.done), onPressed: () => _todoAddEditBloc.addUpdateSink.add(true),),

_addUpdateTodo checks that both the title and description are not empty and changes the value of _todoError BehaviorSubject based on this condition. The _todoError error is responsible for triggering the view error display on input fields if no value is supplied. If everything is fine, it checks whether to create or update the TodoBloc and finally _toDoRepository does the write to FireCloud.

The business logic is here but notice:

Only streams and sinks are public in BLoC. _addUpdateTodo is private and can’t be accessed from view.

is private and can’t be accessed from view. _title.value and _description.value are filled by the user entering the value in the text input. Text input on text change event sends its value to the respective sinks. This way, we have a reactive change of values in the BLoC and the display of them in the view.

and are filled by the user entering the value in the text input. Text input on text change event sends its value to the respective sinks. This way, we have a reactive change of values in the BLoC and the display of them in the view. _toDoRepository is platform dependent and is provided by injection.

Check out the code of the todo_list.dart BLoC _getTodos() method. It listens for a snapshot of the todo collection and streams the collection data to list in its view. The view list is redrawn based on collection stream change.

// BLOC void _getTodos(){ showProgress.add(true); _toDoRepository.getToDos() .listen((todosList) { todosSink.add(todosList); showProgress.add(false); }, onError: (err) { showProgress.add(false); error.add(err.toString()); }); }

The important thing to be aware of when using streams or rx equivalent is that streams must be closed. We do that in the dispose() method of each BLoC. Dispose the BLoC of each view in its dispose/destroy method.

// FLUTTER @override void dispose() { widget.baseBloc.dispose(); super.dispose(); }

Or in an AngularDart project:

// ANGULAR DART @override void ngOnDestroy() { todoListBloc.dispose(); }

Injecting Platform-specific Repositories

We said before that everything that comes in a BLoC must be simple Dart and nothing platform-dependent. TodoAddEditBloc needs ToDoRepository to write to Firestore. Firebase has platform-dependent packages, and we must have separate implementations of the ToDoRepository interface. These implementations are injected in apps. For Flutter, I used the flutter_simple_dependency_injection package and it looks like this:

// FLUTTER class Injection { static Firestore _firestore = Firestore.instance; static FirebaseAuth _auth = FirebaseAuth.instance; static PreferencesInterface _preferencesInterface = PreferencesInterfaceImpl(); static Injector injector; static Future initInjection() async { await _preferencesInterface.initPreferences(); injector = Injector.getInjector(); //Session injector.map<Session>((i) => SessionImpl(_auth, _firestore), isSingleton: true); //Repository injector.map<ToDoRepository>((i) => ToDoRepositoryImpl(injector.get<Session>()), isSingleton: false); //Bloc injector.map<LoginBloc>((i) => LoginBloc(_preferencesInterface, injector.get<Session>()), isSingleton: false); injector.map<TodoListBloc>((i) => TodoListBloc(injector.get<ToDoRepository>(), injector.get<Session>()), isSingleton: false); injector.map<TodoAddEditBloc>((i) => TodoAddEditBloc(injector.get<ToDoRepository>()), isSingleton: false); } }

Use this in a widget like this:

// FLUTTER TodoAddEditBloc _todoAddEditBloc = Injection.injector.get<TodoAddEditBloc>();

AngularDart has injection built in via providers.

// ANGULAR DART @GenerateInjector([ ClassProvider(PreferencesInterface, useClass: PreferencesInterfaceImpl), ClassProvider(Session, useClass: SessionImpl), ExistingProvider(Endpoints, Session) ])

And in a component:

// ANGULAR DART providers: [ overlayBindings, ClassProvider(ToDoRepository, useClass: ToDoRepositoryImpl), ClassProvider(TodoAddEditBloc), ExistingProvider(BaseBloc, TodoAddEditBloc) ],

We can see that Session is global. It provides the sign in/out functionality and endpoints used in ToDoRepository and BLoCs. ToDoRepository needs an endpoints interface which is implemented in SessionImpl and so on. The view should see only its BLoC and nothing more.

Views

Views should be as simple as possible. They only display what comes from the BLoC and sends the user’s input to the BLoC. We’ll go over it with TodoAddEdit widget from Flutter and its web equivalent TodoDetailComponent . They display the selected todo title and description and user can add or update a todo.

Flutter:

// FLUTTER _todoAddEditBloc.todoStream.first.then((todo) { _titleController.text = todo.title; _descriptionController.text = todo.description; });

And later in code…

// FLUTTER StreamBuilder<int>( stream: _todoAddEditBloc.todoErrorStream, builder: (BuildContext context, AsyncSnapshot errorSnapshot) { return TextField( onChanged: (text) => _todoAddEditBloc.titleSink.add(text), decoration: InputDecoration(hintText: Localization.of(context).title, labelText: Localization.of(context).title, errorText: errorSnapshot.data == 0 ? Localization.of(context).titleEmpty : null), controller: _titleController, ); }, ),

The StreamBuilder widget rebuilds itself if there is an error (nothing inserted). This happens by listening to _todoAddEditBloc.todoErrorStream . _todoAddEditBloc.titleSink , which is a sink in the BLoC that holds the title and is updated on the user entering text in the text field.

The initial value of this input field (if one todo is selected) is filled by listening to _todoAddEditBloc.todoStream which holds the selected todo or an empty one if we add a new todo.

Assigning value to a text field is done by its controller _titleController.text = todo.title; .

When the user decides to save the todo, it presses the check icon in app bar and it triggers _todoAddEditBloc.addUpdateSink.add(true) . That invokes the _addUpdateTodo(bool addUpdate) we talked about in previous BLoC section and does all the business logic of adding, updating, or displaying the error back to the user.

Everything is reactive and there is no need to handle the widget state.

AngularDart code is even more simple. After providing the component its BLoC, using providers, the todo_detail.html file code does the part of displaying the data and sending the user interaction back to the BLoC.

// AngularDart <material-input #title label="{{titleStr}}" ngModel="{{(todoAddEditBloc.titleStream | async) == null ? '' : (todoAddEditBloc.titleStream | async)}}" (inputKeyPress)="todoAddEditBloc.titleSink.add($event)" [error]="(todoAddEditBloc.todoErrorStream | async) == 0 ? titleErrString : ''" autoFocus floatingLabel style="width:100%" type="text" useNativeValidation="false" autocomplete="off"> </material-input> <material-input #description label="{{descriptionStr}}" ngModel="{{(todoAddEditBloc.descriptionStream | async) == null ? '' : (todoAddEditBloc.descriptionStream | async)}}" (inputKeyPress)="todoAddEditBloc.descriptionSink.add($event)" [error]="(todoAddEditBloc.todoErrorStream | async) == 1 ? descriptionErrString : ''" autoFocus floatingLabel style="width:100%" type="text" useNativeValidation="false" autocomplete="off"> </material-input> <material-button animated raised role="button" class="blue" (trigger)="todoAddEditBloc.addUpdateSink.add(true)"> {{saveStr}} </material-button> <base-bloc></base-bloc>

Similar to Flutter, we are assigning ngModel= the value from the title stream, which is its initial value.

// AngularDart (inputKeyPress)="todoAddEditBloc.descriptionSink.add($event)"

The inputKeyPress output event sends the characters the user types in the text input back to the BLoC’s description. The material button (trigger)="todoAddEditBloc.addUpdateSink.add(true)" event sends the BLoC add/update event which again triggers the same _addUpdateTodo(bool addUpdate) function in the BLoC. If you take a look at the todo_detail.dart code of the component, you will see that there is almost nothing except the strings that are displayed on the view. I placed them there and not in the HTML because of possible localization that can be done here.

The same goes for every other component—the components and widgets have zero business logic.

One more scenario is worth mentioning. Imagine you have a view with complex data presentation logic or something like a table with values that must be formatted (dates, currencies, etc.). Someone could be tempted to get the values from BLoC and format them in a view. That’s wrong! The values displayed in the view should come to the view already formatted (strings). The reason for that is that the formatting itself is also business logic. One more example is when the formatting of display value depends on some app parameter that can be changed in runtime. By providing that parameter to BLoC and using a reactive approach to view display, the business logic will format the value and redraw only the parts needed. The BLoC model we have in this example, TodoBloc , is very simple. Conversion from a FireCloud model to the BLoC model is done in repository, but if needed, it can be done in BLoC so that the model values are ready for display.

Wrapping Up

This brief article covers the BLoC pattern implementation main concepts. It’s working proof that code sharing between Flutter and AngularDart is possible, allowing for native, and cross-platform development.

Exploring the example, you will see that, when implemented correctly, BLoC shortens the time to create mobile/web apps significantly. An example is ToDoRepository and its implementation. The implementation code is almost identical and even the view composing logic is similar. After a couple of widgets/components, you can quickly start mass production.

I hope this article will give you a glance at the fun and enthusiasm I have making web/mobile apps using Flutter/AngularDart and the BLoC pattern. If you’re looking to build cross-platform desktop applications in JavaScript, read Electron: Cross-platform Desktop Apps Made Easy by fellow Toptaler Stéphane P. Péricat.