MultiBlocListener, a Flutter widget that reduces nesting when using multiple BlocListeners.
Setup
We’ll start off by creating a brand new Flutter project using the very_good_cli.
Next we’ll create the todos_api, local_storage_todos_api, and todos_repository packages using very_good_cli:
We can then replace the contents of pubspec.yaml with:
Finally, we can install all the dependencies:
Project Structure
Our application project structure should look like:
We split the project into multiple packages in order to maintain explicit dependencies for each package with clear boundaries that enforce the single responsibility principle.
Modularizing our project like this has many benefits including but not limited to:
easy to reuse packages across multiple projects
CI/CD improvements in terms of efficiency (run checks on only the code that has changed)
easy to maintain the packages in isolation with their dedicated test suites, semantic versioning, and release cycle/cadence
Architecture
Layering our code is incredibly important and helps us iterate quickly and with confidence. Each layer has a single responsibility and can be used and tested in isolation. This allows us to keep changes contained to a specific layer in order to minimize the impact on the entire application. In addition, layering our application allows us to easily reuse libraries across multiple projects (especially with respect to the data layer).
Our application consists of three main layers:
data layer
domain layer
feature layer
presentation/UI (widgets)
business logic (blocs/cubits)
Data Layer
This layer is the lowest layer and is responsible for retrieving raw data from external sources such as a databases, APIs, and more. Packages in the data layer generally should not depend on any UI and can be reused and even published on pub.dev as a standalone package. In this example, our data layer consists of the todos_api and local_storage_todos_api packages.
Domain Layer
This layer combines one or more data providers and applies “business rules” to the data. Each component in this layer is called a repository and each repository generally manages a single domain. Packages in the repository layer should generally only interact with the data layer. In this example, our repository layer consists of the todos_repository package.
Feature Layer
This layer contains all of the application-specific features and use cases. Each feature generally consists of some UI and business logic. Features should generally be independent of other features so that they can easily be added/removed without impacting the rest of the codebase. Within each feature, the state of the feature along with any business logic is managed by blocs. Blocs interact with zero or more repositories. Blocs react to events and emit states which trigger changes in the UI. Widgets within each feature should generally only depend on the corresponding bloc and render UI based on the current state. The UI can notify the bloc of user input via events. In this example, our application will consist of the home, todos_overview, stats, and edit_todos features.
Now that we’ve gone over the layers at a high level, let’s start building our application starting with the data layer!
Data Layer
The data layer is the lowest layer in our application and consists of raw data providers. Packages in this layer are primarily concerned with where/how data is coming from. In this case our data layer will consist of the TodosApi, which is an interface, and the LocalStorageTodosApi, which is an implementation of the TodosApi backed by shared_preferences.
TodosApi
The todos_api package will export a generic interface for interacting/managing todos. Later we’ll implement the TodosApi using shared_preferences. Having an abstraction will make it easy to support other implementations without having to change any other part of our application. For example, we can later add a FirestoreTodosApi, which uses cloud_firestore instead of shared_preferences, with minimal code changes to the rest of the application.
Todo model
Next we’ll define our Todo model.
The first thing of note is that the Todo model doesn’t live in our app — it’s part of the todos_api package. This is because the TodosApi defines APIs that return/accept Todo objects. The model is a Dart representation of the raw Todo object that will be stored/retrieved.
The Todo model uses json_serializable to handle the json (de)serialization. If you are following along, you will have to run the code generation step to resolve the compiler errors.
json_map.dart provides a typedef for code checking and linting.
The model of the Todo is defined in todos_api/models/todo.dart and is exported by package:todos_api/todos_api.dart.
Streams vs Futures
In a previous version of this tutorial, the TodosApi was Future-based rather than Stream-based.
A Future-based implementation could consist of two methods: loadTodos and saveTodos (note the plural). This means, a full list of todos must be provided to the method each time.
One limitation of this approach is that the standard CRUD (Create, Read, Update, and Delete) operation requires sending the full list of todos with each call. For example, on an Add Todo screen, one cannot just send the added todo item. Instead, we must keep track of the entire list and provide the entire new list of todos when persisting the updated list.
A second limitation is that loadTodos is a one-time delivery of data. The app must contain logic to ask for updates periodically.
In the current implementation, the TodosApi exposes a Stream<List<Todo>> via getTodos() which will report real-time updates to all subscribers when the list of todos has changed.
In addition, todos can be created, deleted, or updated individually. For example, both deleting and saving a todo are done with only the todo as the argument. It’s not necessary to provide the newly updated list of todos each time.
LocalStorageTodosApi
This package implements the todos_api using the shared_preferences package.
Repository Layer
A repository is part of the business layer. A repository depends on one or more data providers that have no business value, and combines their public API into APIs that provide business value. In addition, having a repository layer helps abstract data acquisition from the rest of the application, allowing us to change where/how data is being stored without affecting other parts of the app.
TodosRepository
Instantiating the repository requires specifying a TodosApi, which we discussed earlier in this tutorial, so we added it as a dependency in our pubspec.yaml:
Library Exports
In addition to exporting the TodosRepository class, we also export the Todo model from the todos_api package. This step prevents tight coupling between the application and the data providers.
We decided to re-export the same Todo model from the todos_api, rather than redefining a separate model in the todos_repository, because in this case we are in complete control of the data model. In many cases, the data provider will not be something that you can control. In those cases, it becomes increasingly important to maintain your own model definitions in the repository layer to maintain full control of the interface and API contract.
Feature Layer
Entrypoint
Our app’s entrypoint is main.dart. In this case, there are three versions:
The most notable thing is the concrete implementation of the local_storage_todos_api is instantiated within each entrypoint.
Bootstrapping
bootstrap.dart loads our BlocObserver and creates the instance of TodosRepository.
App
App wraps a RepositoryProvider widget that provides the repository to all children. Since both the EditTodoPage and HomePage subtrees are descendents, all the blocs and cubits can access the repository.
AppView creates the MaterialApp and configures the theme and localizations.
Theme
This provides theme definition for light and dark mode.
Home
The home feature is responsible for managing the state of the currently-selected tab and displays the correct subtree.
HomeState
There are only two states associated with the two screens: todos and stats.
HomeCubit
A cubit is appropriate in this case due to the simplicity of the business logic. We have one method setTab to change the tab.
HomeView
view.dart is a barrel file that exports all relevant UI components for the home feature.
home_page.dart contains the UI for the root page that the user will see when the app is launched.
A simplified representation of the widget tree for the HomePage is:
The HomePage provides an instance of HomeCubit to HomeView. HomeView uses context.select to selectively rebuild whenever the tab changes.
This allows us to easily widget test HomeView by providing a mock HomeCubit and stubbing the state.
The BottomAppBar contains HomeTabButton widgets which call setTab on the HomeCubit. The instance of the cubit is looked up via context.read and the appropriate method is invoked on the cubit instance.
TodosOverview
The todos overview feature allows users to manage their todos by creating, editing, deleting, and filtering todos.
TodosOverviewEvent
Let’s create todos_overview/bloc/todos_overview_event.dart and define the events.
TodosOverviewSubscriptionRequested: This is the startup event. In response, the bloc subscribes to the stream of todos from the TodosRepository.
TodosOverviewTodoDeleted: This deletes a Todo.
TodosOverviewTodoCompletionToggled: This toggles a todo’s completed status.
TodosOverviewToggleAllRequested: This toggles completion for all todos.
TodosOverviewClearCompletedRequested: This deletes all completed todos.
TodosOverviewUndoDeletionRequested: This undoes a todo deletion, e.g. an accidental deletion.
TodosOverviewFilterChanged: This takes a TodosViewFilter as an argument and changes the view by applying a filter.
TodosOverviewState
Let’s create todos_overview/bloc/todos_overview_state.dart and define the state.
TodosOverviewState will keep track of a list of todos, the active filter, the lastDeletedTodo, and the status.
When TodosOverviewSubscriptionRequested is added, the bloc starts by emitting a loading state. In response, the UI can then render a loading indicator.
Next, we use emit.forEach<List<Todo>>( ... ) which creates a subscription on the todos stream from the TodosRepository.
Now that the subscription is handled, we will handle the other events, like adding, modifying, and deleting todos.
The undo feature allows users to restore the last deleted item.
_onTodoDeleted does two things. First, it emits a new state with the Todo to be deleted. Then, it deletes the Todo via a call to the repository.
_onUndoDeletionRequested runs when the undo deletion request event comes from the UI.
_onUndoDeletionRequested does the following:
Temporarily saves a copy of the last deleted todo.
Updates the state by removing the lastDeletedTodo.
Reverts the deletion.
Filtering
_onFilterChanged emits a new state with the new event filter.
Models
There is one model file that deals with the view filtering.
todos_view_filter.dart is an enum that represents the three view filters and the methods to apply the filter.
models.dart is the barrel file for exports.
Next, let’s take a look at the TodosOverviewPage.
TodosOverviewPage
A simplified representation of the widget tree for the TodosOverviewPage is:
Just as with the Home feature, the TodosOverviewPage provides an instance of the TodosOverviewBloc to the subtree via BlocProvider<TodosOverviewBloc>. This scopes the TodosOverviewBloc to just the widgets below TodosOverviewPage.
There are three widgets that are listening for changes in the TodosOverviewBloc.
The first is a BlocListener that listens for errors. The listener will only be called when listenWhen returns true. If the status is TodosOverviewStatus.failure, a SnackBar is displayed.
We created a second BlocListener that listens for deletions. When a todo has been deleted, a SnackBar is displayed with an undo button. If the user taps undo, the TodosOverviewUndoDeletionRequested event will be added to the bloc.
Finally, we use a BlocBuilder to builds the ListView that displays the todos.
The AppBarcontains two actions which are dropdowns for filtering and manipulating the todos.
view.dart is the barrel file that exports todos_overview_page.dart.
Widgets
widgets.dart is another barrel file that exports all the components used within the todos_overview feature.
todo_list_tile.dart is the ListTile for each todo item.
todos_overview_options_button.dart exposes two options for manipulating todos:
toggleAll
clearCompleted
todos_overview_filter_button.dart exposes three filter options:
all
activeOnly
completedOnly
Stats
The stats feature displays statistics about the active and completed todos.
StatsState
StatsState keeps track of summary information and the current StatsStatus.
StatsEvent
StatsEvent has only one event called StatsSubscriptionRequested:
StatsBloc
StatsBloc depends on the TodosRepository just like TodosOverviewBloc. It subscribes to the todos stream via _todosRepository.getTodos.
Stats View
view.dart is the barrel file for the stats_page.
stats_page.dart contains the UI for the page that displays the todos statistics.
A simplified representation of the widget tree for the StatsPage is:
EditTodo
The EditTodo feature allows users to edit an existing todo item and save the changes.
EditTodoState
EditTodoState keeps track of the information needed when editing a todo.
EditTodoEvent
The different events the bloc will react to are:
EditTodoTitleChanged
EditTodoDescriptionChanged
EditTodoSubmitted
EditTodoBloc
EditTodoBloc depends on the TodosRepository, just like TodosOverviewBloc and StatsBloc.
Data Flow
Even though there are many features that depend on the same list of todos, there is no bloc-to-bloc communication. Instead, all features are independent of each other and rely on the TodosRepository to listen for changes in the list of todos, as well as perform updates to the list.
For example, the EditTodos doesn’t know anything about the TodosOverview or Stats features.
When the UI submits a EditTodoSubmitted event:
EditTodoBloc handles the business logic to update the TodosRepository.
TodosRepository notifies TodosOverviewBloc and StatsBloc.
TodosOverviewBloc and StatsBloc notify the UI which update with the new state.
EditTodoPage
Just like with the previous features, the EditTodosPage provides an instance of the EditTodosBloc via BlocProvider. Unlike the other features, the EditTodosPage is a separate route which is why it exposes a staticroute method. This makes it easy to push the EditTodosPage onto the navigation stack via Navigator.of(context).push(...).
A simplified representation of the widget tree for the EditTodosPage is:
Summary
That’s it, we have completed the tutorial! 🎉
The full source code for this example, including unit and widget tests, can be found here.