Skip to content

Flutter Todos

advanced

In the following tutorial, we’re going to build a todos app in Flutter using the Bloc library.

demo

Key Topics

  • Bloc and Cubit to manage the various feature states.
  • Layered Architecture for separation of concerns and to facilitate reusability.
  • BlocObserver to observe state changes.
  • BlocProvider, a Flutter widget which provides a bloc to its children.
  • BlocBuilder, a Flutter widget that handles building the widget in response to new states.
  • BlocListener, a Flutter widget that handles performing side effects in response to state changes.
  • RepositoryProvider, a Flutter widget to provide a repository to its children.
  • Equatable to prevent unnecessary rebuilds.
  • 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.

Terminal window
very_good create flutter_app flutter_todos --desc "An example todos app that showcases bloc state management patterns."

Next we’ll create the todos_api, local_storage_todos_api, and todos_repository packages using very_good_cli:

Terminal window
# create package:todos_api under packages/todos_api
very_good create dart_package todos_api --desc "The interface and models for an API providing access to todos." -o packages
# create package:local_storage_todos_api under packages/local_storage_todos_api
very_good create flutter_package local_storage_todos_api --desc "A Flutter implementation of the TodosApi that uses local storage." -o packages
# create package:todos_repository under packages/todos_repository
very_good create dart_package todos_repository --desc "A repository that handles todo related requests." -o packages

We can then replace the contents of pubspec.yaml with:

pubspec.yaml
name: flutter_todos
description: An example todos app that showcases bloc state management patterns.
version: 1.0.0+1
publish_to: none
environment:
sdk: ">=3.0.0 <4.0.0"
dependencies:
bloc: ^8.1.1
equatable: ^2.0.5
flutter:
sdk: flutter
flutter_bloc: ^8.1.1
flutter_localizations:
sdk: flutter
intl: ^0.18.0
local_storage_todos_api:
path: packages/local_storage_todos_api
todos_api:
path: packages/todos_api
todos_repository:
path: packages/todos_repository
dev_dependencies:
bloc_test: ^9.1.0
flutter_test:
sdk: flutter
mockingjay: ^0.5.0
mocktail: ^1.0.0
flutter:
uses-material-design: true
generate: true

Finally, we can install all the dependencies:

Terminal window
very_good packages get --recursive

Project Structure

Our application project structure should look like:

├── lib
├── packages
│ ├── local_storage_todos_api
│ ├── todos_api
│ └── todos_repository
└── test

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

Todos Architecture Diagram

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.

packages/todos_api/pubspec.yaml
name: todos_api
description: The interface and models for an API providing access to todos.
version: 1.0.0+1
publish_to: none
environment:
sdk: ">=3.0.0 <4.0.0"
dependencies:
equatable: ^2.0.3
json_annotation: ^4.6.0
meta: ^1.7.0
uuid: ^3.0.6
dev_dependencies:
build_runner: ^2.2.0
json_serializable: ^6.3.1
test: ^1.21.4
packages/todos_api/lib/src/todos_api.dart
import 'package:todos_api/todos_api.dart';
/// {@template todos_api}
/// The interface for an API that provides access to a list of todos.
/// {@endtemplate}
abstract class TodosApi {
/// {@macro todos_api}
const TodosApi();
/// Provides a [Stream] of all todos.
Stream<List<Todo>> getTodos();
/// Saves a [todo].
///
/// If a [todo] with the same id already exists, it will be replaced.
Future<void> saveTodo(Todo todo);
/// Deletes the `todo` with the given id.
///
/// If no `todo` with the given id exists, a [TodoNotFoundException] error is
/// thrown.
Future<void> deleteTodo(String id);
/// Deletes all completed todos.
///
/// Returns the number of deleted todos.
Future<int> clearCompleted();
/// Sets the `isCompleted` state of all todos to the given value.
///
/// Returns the number of updated todos.
Future<int> completeAll({required bool isCompleted});
/// Closes the client and frees up any resources.
Future<void> close();
}
/// Error thrown when a [Todo] with a given id is not found.
class TodoNotFoundException implements Exception {}

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.

packages/todos_api/lib/src/models/todo.dart
import 'package:equatable/equatable.dart';
import 'package:json_annotation/json_annotation.dart';
import 'package:meta/meta.dart';
import 'package:todos_api/todos_api.dart';
import 'package:uuid/uuid.dart';
part 'todo.g.dart';
/// {@template todo_item}
/// A single `todo` item.
///
/// Contains a [title], [description] and [id], in addition to a [isCompleted]
/// flag.
///
/// If an [id] is provided, it cannot be empty. If no [id] is provided, one
/// will be generated.
///
/// [Todo]s are immutable and can be copied using [copyWith], in addition to
/// being serialized and deserialized using [toJson] and [fromJson]
/// respectively.
/// {@endtemplate}
@immutable
@JsonSerializable()
class Todo extends Equatable {
/// {@macro todo_item}
Todo({
required this.title,
String? id,
this.description = '',
this.isCompleted = false,
}) : assert(
id == null || id.isNotEmpty,
'id must either be null or not empty',
),
id = id ?? const Uuid().v4();
/// The unique identifier of the `todo`.
///
/// Cannot be empty.
final String id;
/// The title of the `todo`.
///
/// Note that the title may be empty.
final String title;
/// The description of the `todo`.
///
/// Defaults to an empty string.
final String description;
/// Whether the `todo` is completed.
///
/// Defaults to `false`.
final bool isCompleted;
/// Returns a copy of this `todo` with the given values updated.
///
/// {@macro todo_item}
Todo copyWith({
String? id,
String? title,
String? description,
bool? isCompleted,
}) {
return Todo(
id: id ?? this.id,
title: title ?? this.title,
description: description ?? this.description,
isCompleted: isCompleted ?? this.isCompleted,
);
}
/// Deserializes the given [JsonMap] into a [Todo].
static Todo fromJson(JsonMap json) => _$TodoFromJson(json);
/// Converts this [Todo] into a [JsonMap].
JsonMap toJson() => _$TodoToJson(this);
@override
List<Object> get props => [id, title, description, isCompleted];
}

json_map.dart provides a typedef for code checking and linting.

packages/todos_api/lib/src/models/json_map.dart
/// The type definition for a JSON-serializable [Map].
typedef JsonMap = Map<String, dynamic>;

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.

For an example of a Future-based API see Brian Egan’s implementation in his Architecture Samples.

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.

packages/local_storage_todos_api/pubspec.yaml
name: local_storage_todos_api
description: A Flutter implementation of the TodosApi that uses local storage.
version: 1.0.0+1
publish_to: none
environment:
sdk: ">=3.0.0 <4.0.0"
dependencies:
flutter:
sdk: flutter
meta: ^1.7.0
rxdart: ^0.27.5
shared_preferences: ^2.0.15
todos_api:
path: ../todos_api
dev_dependencies:
flutter_test:
sdk: flutter
mocktail: ^1.0.0
packages/local_storage_todos_api/lib/src/local_storage_todos_api.dart
import 'dart:async';
import 'dart:convert';
import 'package:meta/meta.dart';
import 'package:rxdart/subjects.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:todos_api/todos_api.dart';
/// {@template local_storage_todos_api}
/// A Flutter implementation of the [TodosApi] that uses local storage.
/// {@endtemplate}
class LocalStorageTodosApi extends TodosApi {
/// {@macro local_storage_todos_api}
LocalStorageTodosApi({
required SharedPreferences plugin,
}) : _plugin = plugin {
_init();
}
final SharedPreferences _plugin;
late final _todoStreamController = BehaviorSubject<List<Todo>>.seeded(
const [],
);
/// The key used for storing the todos locally.
///
/// This is only exposed for testing and shouldn't be used by consumers of
/// this library.
@visibleForTesting
static const kTodosCollectionKey = '__todos_collection_key__';
String? _getValue(String key) => _plugin.getString(key);
Future<void> _setValue(String key, String value) =>
_plugin.setString(key, value);
void _init() {
final todosJson = _getValue(kTodosCollectionKey);
if (todosJson != null) {
final todos = List<Map<dynamic, dynamic>>.from(
json.decode(todosJson) as List,
)
.map((jsonMap) => Todo.fromJson(Map<String, dynamic>.from(jsonMap)))
.toList();
_todoStreamController.add(todos);
} else {
_todoStreamController.add(const []);
}
}
@override
Stream<List<Todo>> getTodos() => _todoStreamController.asBroadcastStream();
@override
Future<void> saveTodo(Todo todo) {
final todos = [..._todoStreamController.value];
final todoIndex = todos.indexWhere((t) => t.id == todo.id);
if (todoIndex >= 0) {
todos[todoIndex] = todo;
} else {
todos.add(todo);
}
_todoStreamController.add(todos);
return _setValue(kTodosCollectionKey, json.encode(todos));
}
@override
Future<void> deleteTodo(String id) async {
final todos = [..._todoStreamController.value];
final todoIndex = todos.indexWhere((t) => t.id == id);
if (todoIndex == -1) {
throw TodoNotFoundException();
} else {
todos.removeAt(todoIndex);
_todoStreamController.add(todos);
return _setValue(kTodosCollectionKey, json.encode(todos));
}
}
@override
Future<int> clearCompleted() async {
final todos = [..._todoStreamController.value];
final completedTodosAmount = todos.where((t) => t.isCompleted).length;
todos.removeWhere((t) => t.isCompleted);
_todoStreamController.add(todos);
await _setValue(kTodosCollectionKey, json.encode(todos));
return completedTodosAmount;
}
@override
Future<int> completeAll({required bool isCompleted}) async {
final todos = [..._todoStreamController.value];
final changedTodosAmount =
todos.where((t) => t.isCompleted != isCompleted).length;
final newTodos = [
for (final todo in todos) todo.copyWith(isCompleted: isCompleted),
];
_todoStreamController.add(newTodos);
await _setValue(kTodosCollectionKey, json.encode(newTodos));
return changedTodosAmount;
}
@override
Future<void> close() {
return _todoStreamController.close();
}
}

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

packages/todos_repository/lib/src/todos_repository.dart
import 'package:todos_api/todos_api.dart';
/// {@template todos_repository}
/// A repository that handles `todo` related requests.
/// {@endtemplate}
class TodosRepository {
/// {@macro todos_repository}
const TodosRepository({
required TodosApi todosApi,
}) : _todosApi = todosApi;
final TodosApi _todosApi;
/// Provides a [Stream] of all todos.
Stream<List<Todo>> getTodos() => _todosApi.getTodos();
/// Saves a [todo].
///
/// If a [todo] with the same id already exists, it will be replaced.
Future<void> saveTodo(Todo todo) => _todosApi.saveTodo(todo);
/// Deletes the `todo` with the given id.
///
/// If no `todo` with the given id exists, a [TodoNotFoundException] error is
/// thrown.
Future<void> deleteTodo(String id) => _todosApi.deleteTodo(id);
/// Deletes all completed todos.
///
/// Returns the number of deleted todos.
Future<int> clearCompleted() => _todosApi.clearCompleted();
/// Sets the `isCompleted` state of all todos to the given value.
///
/// Returns the number of updated todos.
Future<int> completeAll({required bool isCompleted}) =>
_todosApi.completeAll(isCompleted: isCompleted);
}

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:

packages/todos_repository/pubspec.yaml
name: todos_repository
description: A repository that handles todo related requests.
version: 1.0.0+1
publish_to: none
environment:
sdk: ">=3.0.0 <4.0.0"
dependencies:
todos_api:
path: ../todos_api
dev_dependencies:
mocktail: ^1.0.0
test: ^1.21.4

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.

packages/todos_repository/lib/todos_repository.dart
/// A repository that handles `todo` related requests.
library todos_repository;
export 'package:todos_api/todos_api.dart' show Todo;
export 'src/todos_repository.dart';

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:

lib/main_development.dart
import 'package:flutter/widgets.dart';
import 'package:flutter_todos/bootstrap.dart';
import 'package:local_storage_todos_api/local_storage_todos_api.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
final todosApi = LocalStorageTodosApi(
plugin: await SharedPreferences.getInstance(),
);
bootstrap(todosApi: todosApi);
}
lib/main_staging.dart
import 'package:flutter/widgets.dart';
import 'package:flutter_todos/bootstrap.dart';
import 'package:local_storage_todos_api/local_storage_todos_api.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
final todosApi = LocalStorageTodosApi(
plugin: await SharedPreferences.getInstance(),
);
bootstrap(todosApi: todosApi);
}
lib/main_production.dart
import 'package:flutter/widgets.dart';
import 'package:flutter_todos/bootstrap.dart';
import 'package:local_storage_todos_api/local_storage_todos_api.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
final todosApi = LocalStorageTodosApi(
plugin: await SharedPreferences.getInstance(),
);
bootstrap(todosApi: todosApi);
}

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.

lib/bootstrap.dart
import 'dart:async';
import 'dart:developer';
import 'package:bloc/bloc.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_todos/app/app.dart';
import 'package:flutter_todos/app/app_bloc_observer.dart';
import 'package:todos_api/todos_api.dart';
import 'package:todos_repository/todos_repository.dart';
void bootstrap({required TodosApi todosApi}) {
FlutterError.onError = (details) {
log(details.exceptionAsString(), stackTrace: details.stack);
};
Bloc.observer = const AppBlocObserver();
final todosRepository = TodosRepository(todosApi: todosApi);
runZonedGuarded(
() => runApp(App(todosRepository: todosRepository)),
(error, stackTrace) => log(error.toString(), stackTrace: stackTrace),
);
}

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.

lib/app/app.dart
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_todos/home/home.dart';
import 'package:flutter_todos/l10n/l10n.dart';
import 'package:flutter_todos/theme/theme.dart';
import 'package:todos_repository/todos_repository.dart';
class App extends StatelessWidget {
const App({required this.todosRepository, super.key});
final TodosRepository todosRepository;
@override
Widget build(BuildContext context) {
return RepositoryProvider.value(
value: todosRepository,
child: const AppView(),
);
}
}
class AppView extends StatelessWidget {
const AppView({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
theme: FlutterTodosTheme.light,
darkTheme: FlutterTodosTheme.dark,
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: const HomePage(),
);
}
}

Theme

This provides theme definition for light and dark mode.

lib/theme/theme.dart
import 'package:flutter/material.dart';
class FlutterTodosTheme {
static ThemeData get light {
return ThemeData(
appBarTheme: const AppBarTheme(color: Color.fromARGB(255, 117, 208, 247)),
colorScheme: ColorScheme.fromSeed(
seedColor: const Color(0xFF13B9FF),
),
snackBarTheme: const SnackBarThemeData(
behavior: SnackBarBehavior.floating,
),
);
}
static ThemeData get dark {
return ThemeData(
appBarTheme: const AppBarTheme(
color: Color.fromARGB(255, 16, 46, 59),
),
colorScheme: ColorScheme.fromSeed(
brightness: Brightness.dark,
seedColor: const Color(0xFF13B9FF),
),
snackBarTheme: const SnackBarThemeData(
behavior: SnackBarBehavior.floating,
),
);
}
}

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.

lib/home/cubit/home_state.dart
part of 'home_cubit.dart';
enum HomeTab { todos, stats }
final class HomeState extends Equatable {
const HomeState({
this.tab = HomeTab.todos,
});
final HomeTab tab;
@override
List<Object> get props => [tab];
}

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.

lib/home/cubit/home_cubit.dart
import 'package:bloc/bloc.dart';
import 'package:equatable/equatable.dart';
part 'home_state.dart';
class HomeCubit extends Cubit<HomeState> {
HomeCubit() : super(const HomeState());
void setTab(HomeTab tab) => emit(HomeState(tab: tab));
}

HomeView

view.dart is a barrel file that exports all relevant UI components for the home feature.

lib/home/view/view.dart
export 'home_page.dart';

home_page.dart contains the UI for the root page that the user will see when the app is launched.

lib/home/view/home_page.dart
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_todos/edit_todo/edit_todo.dart';
import 'package:flutter_todos/home/home.dart';
import 'package:flutter_todos/stats/stats.dart';
import 'package:flutter_todos/todos_overview/todos_overview.dart';
class HomePage extends StatelessWidget {
const HomePage({super.key});
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (_) => HomeCubit(),
child: const HomeView(),
);
}
}
class HomeView extends StatelessWidget {
const HomeView({super.key});
@override
Widget build(BuildContext context) {
final selectedTab = context.select((HomeCubit cubit) => cubit.state.tab);
return Scaffold(
body: IndexedStack(
index: selectedTab.index,
children: const [TodosOverviewPage(), StatsPage()],
),
floatingActionButtonLocation: FloatingActionButtonLocation.centerDocked,
floatingActionButton: FloatingActionButton(
shape: const CircleBorder(),
key: const Key('homeView_addTodo_floatingActionButton'),
onPressed: () => Navigator.of(context).push(EditTodoPage.route()),
child: const Icon(Icons.add),
),
bottomNavigationBar: BottomAppBar(
shape: const CircularNotchedRectangle(),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_HomeTabButton(
groupValue: selectedTab,
value: HomeTab.todos,
icon: const Icon(Icons.list_rounded),
),
_HomeTabButton(
groupValue: selectedTab,
value: HomeTab.stats,
icon: const Icon(Icons.show_chart_rounded),
),
],
),
),
);
}
}
class _HomeTabButton extends StatelessWidget {
const _HomeTabButton({
required this.groupValue,
required this.value,
required this.icon,
});
final HomeTab groupValue;
final HomeTab value;
final Widget icon;
@override
Widget build(BuildContext context) {
return IconButton(
onPressed: () => context.read<HomeCubit>().setTab(value),
iconSize: 32,
color:
groupValue != value ? null : Theme.of(context).colorScheme.secondary,
icon: icon,
);
}
}

A simplified representation of the widget tree for the HomePage is:

├── HomePage
│ └── BlocProvider<HomeCubit>
│ └── HomeView
│ ├── context.select<HomeCubit, HomeTab>
│ └── BottomAppBar
│ └── HomeTabButton(s)
│ └── context.read<HomeCubit>

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.

lib/todos_overview/bloc/todos_overview_event.dart
part of 'todos_overview_bloc.dart';
sealed class TodosOverviewEvent extends Equatable {
const TodosOverviewEvent();
@override
List<Object> get props => [];
}
final class TodosOverviewSubscriptionRequested extends TodosOverviewEvent {
const TodosOverviewSubscriptionRequested();
}
final class TodosOverviewTodoCompletionToggled extends TodosOverviewEvent {
const TodosOverviewTodoCompletionToggled({
required this.todo,
required this.isCompleted,
});
final Todo todo;
final bool isCompleted;
@override
List<Object> get props => [todo, isCompleted];
}
final class TodosOverviewTodoDeleted extends TodosOverviewEvent {
const TodosOverviewTodoDeleted(this.todo);
final Todo todo;
@override
List<Object> get props => [todo];
}
final class TodosOverviewUndoDeletionRequested extends TodosOverviewEvent {
const TodosOverviewUndoDeletionRequested();
}
class TodosOverviewFilterChanged extends TodosOverviewEvent {
const TodosOverviewFilterChanged(this.filter);
final TodosViewFilter filter;
@override
List<Object> get props => [filter];
}
class TodosOverviewToggleAllRequested extends TodosOverviewEvent {
const TodosOverviewToggleAllRequested();
}
class TodosOverviewClearCompletedRequested extends TodosOverviewEvent {
const TodosOverviewClearCompletedRequested();
}
  • 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.

lib/todos_overview/bloc/todos_overview_state.dart
part of 'todos_overview_bloc.dart';
enum TodosOverviewStatus { initial, loading, success, failure }
final class TodosOverviewState extends Equatable {
const TodosOverviewState({
this.status = TodosOverviewStatus.initial,
this.todos = const [],
this.filter = TodosViewFilter.all,
this.lastDeletedTodo,
});
final TodosOverviewStatus status;
final List<Todo> todos;
final TodosViewFilter filter;
final Todo? lastDeletedTodo;
Iterable<Todo> get filteredTodos => filter.applyAll(todos);
TodosOverviewState copyWith({
TodosOverviewStatus Function()? status,
List<Todo> Function()? todos,
TodosViewFilter Function()? filter,
Todo? Function()? lastDeletedTodo,
}) {
return TodosOverviewState(
status: status != null ? status() : this.status,
todos: todos != null ? todos() : this.todos,
filter: filter != null ? filter() : this.filter,
lastDeletedTodo:
lastDeletedTodo != null ? lastDeletedTodo() : this.lastDeletedTodo,
);
}
@override
List<Object?> get props => [
status,
todos,
filter,
lastDeletedTodo,
];
}

TodosOverviewState will keep track of a list of todos, the active filter, the lastDeletedTodo, and the status.

TodosOverviewBloc

Let’s create todos_overview/bloc/todos_overview_bloc.dart.

lib/todos_overview/bloc/todos_overview_bloc.dart
import 'package:bloc/bloc.dart';
import 'package:equatable/equatable.dart';
import 'package:flutter_todos/todos_overview/todos_overview.dart';
import 'package:todos_repository/todos_repository.dart';
part 'todos_overview_event.dart';
part 'todos_overview_state.dart';
class TodosOverviewBloc extends Bloc<TodosOverviewEvent, TodosOverviewState> {
TodosOverviewBloc({
required TodosRepository todosRepository,
}) : _todosRepository = todosRepository,
super(const TodosOverviewState()) {
on<TodosOverviewSubscriptionRequested>(_onSubscriptionRequested);
on<TodosOverviewTodoCompletionToggled>(_onTodoCompletionToggled);
on<TodosOverviewTodoDeleted>(_onTodoDeleted);
on<TodosOverviewUndoDeletionRequested>(_onUndoDeletionRequested);
on<TodosOverviewFilterChanged>(_onFilterChanged);
on<TodosOverviewToggleAllRequested>(_onToggleAllRequested);
on<TodosOverviewClearCompletedRequested>(_onClearCompletedRequested);
}
final TodosRepository _todosRepository;
Future<void> _onSubscriptionRequested(
TodosOverviewSubscriptionRequested event,
Emitter<TodosOverviewState> emit,
) async {
emit(state.copyWith(status: () => TodosOverviewStatus.loading));
await emit.forEach<List<Todo>>(
_todosRepository.getTodos(),
onData: (todos) => state.copyWith(
status: () => TodosOverviewStatus.success,
todos: () => todos,
),
onError: (_, __) => state.copyWith(
status: () => TodosOverviewStatus.failure,
),
);
}
Future<void> _onTodoCompletionToggled(
TodosOverviewTodoCompletionToggled event,
Emitter<TodosOverviewState> emit,
) async {
final newTodo = event.todo.copyWith(isCompleted: event.isCompleted);
await _todosRepository.saveTodo(newTodo);
}
Future<void> _onTodoDeleted(
TodosOverviewTodoDeleted event,
Emitter<TodosOverviewState> emit,
) async {
emit(state.copyWith(lastDeletedTodo: () => event.todo));
await _todosRepository.deleteTodo(event.todo.id);
}
Future<void> _onUndoDeletionRequested(
TodosOverviewUndoDeletionRequested event,
Emitter<TodosOverviewState> emit,
) async {
assert(
state.lastDeletedTodo != null,
'Last deleted todo can not be null.',
);
final todo = state.lastDeletedTodo!;
emit(state.copyWith(lastDeletedTodo: () => null));
await _todosRepository.saveTodo(todo);
}
void _onFilterChanged(
TodosOverviewFilterChanged event,
Emitter<TodosOverviewState> emit,
) {
emit(state.copyWith(filter: () => event.filter));
}
Future<void> _onToggleAllRequested(
TodosOverviewToggleAllRequested event,
Emitter<TodosOverviewState> emit,
) async {
final areAllCompleted = state.todos.every((todo) => todo.isCompleted);
await _todosRepository.completeAll(isCompleted: !areAllCompleted);
}
Future<void> _onClearCompletedRequested(
TodosOverviewClearCompletedRequested event,
Emitter<TodosOverviewState> emit,
) async {
await _todosRepository.clearCompleted();
}
}
onSubscriptionRequested

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.

onTodoSaved

_onTodoSaved simply calls _todosRepository.saveTodo(event.todo).

Undo

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.

lib/todos_overview/models/todos_view_filter.dart
import 'package:todos_repository/todos_repository.dart';
enum TodosViewFilter { all, activeOnly, completedOnly }
extension TodosViewFilterX on TodosViewFilter {
bool apply(Todo todo) {
switch (this) {
case TodosViewFilter.all:
return true;
case TodosViewFilter.activeOnly:
return !todo.isCompleted;
case TodosViewFilter.completedOnly:
return todo.isCompleted;
}
}
Iterable<Todo> applyAll(Iterable<Todo> todos) {
return todos.where(apply);
}
}

models.dart is the barrel file for exports.

lib/todos_overview/models/models.dart
export 'todos_view_filter.dart';

Next, let’s take a look at the TodosOverviewPage.

TodosOverviewPage

lib/todos_overview/view/todos_overview_page.dart
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_todos/edit_todo/view/edit_todo_page.dart';
import 'package:flutter_todos/l10n/l10n.dart';
import 'package:flutter_todos/todos_overview/todos_overview.dart';
import 'package:todos_repository/todos_repository.dart';
class TodosOverviewPage extends StatelessWidget {
const TodosOverviewPage({super.key});
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (context) => TodosOverviewBloc(
todosRepository: context.read<TodosRepository>(),
)..add(const TodosOverviewSubscriptionRequested()),
child: const TodosOverviewView(),
);
}
}
class TodosOverviewView extends StatelessWidget {
const TodosOverviewView({super.key});
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
return Scaffold(
appBar: AppBar(
title: Text(l10n.todosOverviewAppBarTitle),
actions: const [
TodosOverviewFilterButton(),
TodosOverviewOptionsButton(),
],
),
body: MultiBlocListener(
listeners: [
BlocListener<TodosOverviewBloc, TodosOverviewState>(
listenWhen: (previous, current) =>
previous.status != current.status,
listener: (context, state) {
if (state.status == TodosOverviewStatus.failure) {
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(
SnackBar(
content: Text(l10n.todosOverviewErrorSnackbarText),
),
);
}
},
),
BlocListener<TodosOverviewBloc, TodosOverviewState>(
listenWhen: (previous, current) =>
previous.lastDeletedTodo != current.lastDeletedTodo &&
current.lastDeletedTodo != null,
listener: (context, state) {
final deletedTodo = state.lastDeletedTodo!;
final messenger = ScaffoldMessenger.of(context);
messenger
..hideCurrentSnackBar()
..showSnackBar(
SnackBar(
content: Text(
l10n.todosOverviewTodoDeletedSnackbarText(
deletedTodo.title,
),
),
action: SnackBarAction(
label: l10n.todosOverviewUndoDeletionButtonText,
onPressed: () {
messenger.hideCurrentSnackBar();
context
.read<TodosOverviewBloc>()
.add(const TodosOverviewUndoDeletionRequested());
},
),
),
);
},
),
],
child: BlocBuilder<TodosOverviewBloc, TodosOverviewState>(
builder: (context, state) {
if (state.todos.isEmpty) {
if (state.status == TodosOverviewStatus.loading) {
return const Center(child: CupertinoActivityIndicator());
} else if (state.status != TodosOverviewStatus.success) {
return const SizedBox();
} else {
return Center(
child: Text(
l10n.todosOverviewEmptyText,
style: Theme.of(context).textTheme.bodySmall,
),
);
}
}
return CupertinoScrollbar(
child: ListView(
children: [
for (final todo in state.filteredTodos)
TodoListTile(
todo: todo,
onToggleCompleted: (isCompleted) {
context.read<TodosOverviewBloc>().add(
TodosOverviewTodoCompletionToggled(
todo: todo,
isCompleted: isCompleted,
),
);
},
onDismissed: (_) {
context
.read<TodosOverviewBloc>()
.add(TodosOverviewTodoDeleted(todo));
},
onTap: () {
Navigator.of(context).push(
EditTodoPage.route(initialTodo: todo),
);
},
),
],
),
);
},
),
),
);
}
}

A simplified representation of the widget tree for the TodosOverviewPage is:

├── TodosOverviewPage
│ └── BlocProvider<TodosOverviewBloc>
│ └── TodosOverviewView
│ ├── BlocListener<TodosOverviewBloc>
│ └── BlocListener<TodosOverviewBloc>
│ └── BlocBuilder<TodosOverviewBloc>
│ └── ListView

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.

  1. 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.

  2. 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.

  3. 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.

lib/todos_overview/view/view.dart
export 'todos_overview_page.dart';

Widgets

widgets.dart is another barrel file that exports all the components used within the todos_overview feature.

lib/todos_overview/widgets/widgets.dart
export 'todo_list_tile.dart';
export 'todos_overview_filter_button.dart';
export 'todos_overview_options_button.dart';

todo_list_tile.dart is the ListTile for each todo item.

lib/todos_overview/widgets/todo_list_tile.dart
import 'package:flutter/material.dart';
import 'package:todos_repository/todos_repository.dart';
class TodoListTile extends StatelessWidget {
const TodoListTile({
required this.todo,
super.key,
this.onToggleCompleted,
this.onDismissed,
this.onTap,
});
final Todo todo;
final ValueChanged<bool>? onToggleCompleted;
final DismissDirectionCallback? onDismissed;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final captionColor = theme.textTheme.bodySmall?.color;
return Dismissible(
key: Key('todoListTile_dismissible_${todo.id}'),
onDismissed: onDismissed,
direction: DismissDirection.endToStart,
background: Container(
alignment: Alignment.centerRight,
color: theme.colorScheme.error,
padding: const EdgeInsets.symmetric(horizontal: 16),
child: const Icon(
Icons.delete,
color: Color(0xAAFFFFFF),
),
),
child: ListTile(
onTap: onTap,
title: Text(
todo.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: !todo.isCompleted
? null
: TextStyle(
color: captionColor,
decoration: TextDecoration.lineThrough,
),
),
subtitle: Text(
todo.description,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
leading: Checkbox(
shape: const ContinuousRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(8)),
),
value: todo.isCompleted,
onChanged: onToggleCompleted == null
? null
: (value) => onToggleCompleted!(value!),
),
trailing: onTap == null ? null : const Icon(Icons.chevron_right),
),
);
}
}

todos_overview_options_button.dart exposes two options for manipulating todos:

  • toggleAll
  • clearCompleted
lib/todos_overview/widgets/todos_overview_options_button.dart
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_todos/l10n/l10n.dart';
import 'package:flutter_todos/todos_overview/todos_overview.dart';
@visibleForTesting
enum TodosOverviewOption { toggleAll, clearCompleted }
class TodosOverviewOptionsButton extends StatelessWidget {
const TodosOverviewOptionsButton({super.key});
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
final todos = context.select((TodosOverviewBloc bloc) => bloc.state.todos);
final hasTodos = todos.isNotEmpty;
final completedTodosAmount = todos.where((todo) => todo.isCompleted).length;
return PopupMenuButton<TodosOverviewOption>(
shape: const ContinuousRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(16)),
),
tooltip: l10n.todosOverviewOptionsTooltip,
onSelected: (options) {
switch (options) {
case TodosOverviewOption.toggleAll:
context
.read<TodosOverviewBloc>()
.add(const TodosOverviewToggleAllRequested());
case TodosOverviewOption.clearCompleted:
context
.read<TodosOverviewBloc>()
.add(const TodosOverviewClearCompletedRequested());
}
},
itemBuilder: (context) {
return [
PopupMenuItem(
value: TodosOverviewOption.toggleAll,
enabled: hasTodos,
child: Text(
completedTodosAmount == todos.length
? l10n.todosOverviewOptionsMarkAllIncomplete
: l10n.todosOverviewOptionsMarkAllComplete,
),
),
PopupMenuItem(
value: TodosOverviewOption.clearCompleted,
enabled: hasTodos && completedTodosAmount > 0,
child: Text(l10n.todosOverviewOptionsClearCompleted),
),
];
},
icon: const Icon(Icons.more_vert_rounded),
);
}
}

todos_overview_filter_button.dart exposes three filter options:

  • all
  • activeOnly
  • completedOnly
lib/todos_overview/widgets/todos_overview_filter_button.dart
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_todos/l10n/l10n.dart';
import 'package:flutter_todos/todos_overview/todos_overview.dart';
class TodosOverviewFilterButton extends StatelessWidget {
const TodosOverviewFilterButton({super.key});
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
final activeFilter =
context.select((TodosOverviewBloc bloc) => bloc.state.filter);
return PopupMenuButton<TodosViewFilter>(
shape: const ContinuousRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(16)),
),
initialValue: activeFilter,
tooltip: l10n.todosOverviewFilterTooltip,
onSelected: (filter) {
context
.read<TodosOverviewBloc>()
.add(TodosOverviewFilterChanged(filter));
},
itemBuilder: (context) {
return [
PopupMenuItem(
value: TodosViewFilter.all,
child: Text(l10n.todosOverviewFilterAll),
),
PopupMenuItem(
value: TodosViewFilter.activeOnly,
child: Text(l10n.todosOverviewFilterActiveOnly),
),
PopupMenuItem(
value: TodosViewFilter.completedOnly,
child: Text(l10n.todosOverviewFilterCompletedOnly),
),
];
},
icon: const Icon(Icons.filter_list_rounded),
);
}
}

Stats

The stats feature displays statistics about the active and completed todos.

StatsState

StatsState keeps track of summary information and the current StatsStatus.

lib/stats/bloc/stats_state.dart
part of 'stats_bloc.dart';
enum StatsStatus { initial, loading, success, failure }
final class StatsState extends Equatable {
const StatsState({
this.status = StatsStatus.initial,
this.completedTodos = 0,
this.activeTodos = 0,
});
final StatsStatus status;
final int completedTodos;
final int activeTodos;
@override
List<Object> get props => [status, completedTodos, activeTodos];
StatsState copyWith({
StatsStatus? status,
int? completedTodos,
int? activeTodos,
}) {
return StatsState(
status: status ?? this.status,
completedTodos: completedTodos ?? this.completedTodos,
activeTodos: activeTodos ?? this.activeTodos,
);
}
}

StatsEvent

StatsEvent has only one event called StatsSubscriptionRequested:

lib/stats/bloc/stats_event.dart
part of 'stats_bloc.dart';
sealed class StatsEvent extends Equatable {
const StatsEvent();
@override
List<Object> get props => [];
}
final class StatsSubscriptionRequested extends StatsEvent {
const StatsSubscriptionRequested();
}

StatsBloc

StatsBloc depends on the TodosRepository just like TodosOverviewBloc. It subscribes to the todos stream via _todosRepository.getTodos.

lib/stats/bloc/stats_bloc.dart
import 'package:bloc/bloc.dart';
import 'package:equatable/equatable.dart';
import 'package:todos_repository/todos_repository.dart';
part 'stats_event.dart';
part 'stats_state.dart';
class StatsBloc extends Bloc<StatsEvent, StatsState> {
StatsBloc({
required TodosRepository todosRepository,
}) : _todosRepository = todosRepository,
super(const StatsState()) {
on<StatsSubscriptionRequested>(_onSubscriptionRequested);
}
final TodosRepository _todosRepository;
Future<void> _onSubscriptionRequested(
StatsSubscriptionRequested event,
Emitter<StatsState> emit,
) async {
emit(state.copyWith(status: StatsStatus.loading));
await emit.forEach<List<Todo>>(
_todosRepository.getTodos(),
onData: (todos) => state.copyWith(
status: StatsStatus.success,
completedTodos: todos.where((todo) => todo.isCompleted).length,
activeTodos: todos.where((todo) => !todo.isCompleted).length,
),
onError: (_, __) => state.copyWith(status: StatsStatus.failure),
);
}
}

Stats View

view.dart is the barrel file for the stats_page.

lib/stats/view/view.dart
export 'stats_page.dart';

stats_page.dart contains the UI for the page that displays the todos statistics.

lib/stats/view/stats_page.dart
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_todos/l10n/l10n.dart';
import 'package:flutter_todos/stats/stats.dart';
import 'package:todos_repository/todos_repository.dart';
class StatsPage extends StatelessWidget {
const StatsPage({super.key});
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (context) => StatsBloc(
todosRepository: context.read<TodosRepository>(),
)..add(const StatsSubscriptionRequested()),
child: const StatsView(),
);
}
}
class StatsView extends StatelessWidget {
const StatsView({super.key});
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
final state = context.watch<StatsBloc>().state;
final textTheme = Theme.of(context).textTheme;
return Scaffold(
appBar: AppBar(
title: Text(l10n.statsAppBarTitle),
),
body: Column(
children: [
ListTile(
key: const Key('statsView_completedTodos_listTile'),
leading: const Icon(Icons.check_rounded),
title: Text(l10n.statsCompletedTodoCountLabel),
trailing: Text(
'${state.completedTodos}',
style: textTheme.headlineSmall,
),
),
ListTile(
key: const Key('statsView_activeTodos_listTile'),
leading: const Icon(Icons.radio_button_unchecked_rounded),
title: Text(l10n.statsActiveTodoCountLabel),
trailing: Text(
'${state.activeTodos}',
style: textTheme.headlineSmall,
),
),
],
),
);
}
}

A simplified representation of the widget tree for the StatsPage is:

├── StatsPage
│ └── BlocProvider<StatsBloc>
│ └── StatsView
│ ├── context.watch<StatsBloc>
│ └── Column

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.

lib/edit_todo/bloc/edit_todo_state.dart
part of 'edit_todo_bloc.dart';
enum EditTodoStatus { initial, loading, success, failure }
extension EditTodoStatusX on EditTodoStatus {
bool get isLoadingOrSuccess => [
EditTodoStatus.loading,
EditTodoStatus.success,
].contains(this);
}
final class EditTodoState extends Equatable {
const EditTodoState({
this.status = EditTodoStatus.initial,
this.initialTodo,
this.title = '',
this.description = '',
});
final EditTodoStatus status;
final Todo? initialTodo;
final String title;
final String description;
bool get isNewTodo => initialTodo == null;
EditTodoState copyWith({
EditTodoStatus? status,
Todo? initialTodo,
String? title,
String? description,
}) {
return EditTodoState(
status: status ?? this.status,
initialTodo: initialTodo ?? this.initialTodo,
title: title ?? this.title,
description: description ?? this.description,
);
}
@override
List<Object?> get props => [status, initialTodo, title, description];
}

EditTodoEvent

The different events the bloc will react to are:

  • EditTodoTitleChanged
  • EditTodoDescriptionChanged
  • EditTodoSubmitted
lib/edit_todo/bloc/edit_todo_event.dart
part of 'edit_todo_bloc.dart';
sealed class EditTodoEvent extends Equatable {
const EditTodoEvent();
@override
List<Object> get props => [];
}
final class EditTodoTitleChanged extends EditTodoEvent {
const EditTodoTitleChanged(this.title);
final String title;
@override
List<Object> get props => [title];
}
final class EditTodoDescriptionChanged extends EditTodoEvent {
const EditTodoDescriptionChanged(this.description);
final String description;
@override
List<Object> get props => [description];
}
final class EditTodoSubmitted extends EditTodoEvent {
const EditTodoSubmitted();
}

EditTodoBloc

EditTodoBloc depends on the TodosRepository, just like TodosOverviewBloc and StatsBloc.

lib/edit_todo/bloc/edit_todo_bloc.dart
import 'package:bloc/bloc.dart';
import 'package:equatable/equatable.dart';
import 'package:todos_repository/todos_repository.dart';
part 'edit_todo_event.dart';
part 'edit_todo_state.dart';
class EditTodoBloc extends Bloc<EditTodoEvent, EditTodoState> {
EditTodoBloc({
required TodosRepository todosRepository,
required Todo? initialTodo,
}) : _todosRepository = todosRepository,
super(
EditTodoState(
initialTodo: initialTodo,
title: initialTodo?.title ?? '',
description: initialTodo?.description ?? '',
),
) {
on<EditTodoTitleChanged>(_onTitleChanged);
on<EditTodoDescriptionChanged>(_onDescriptionChanged);
on<EditTodoSubmitted>(_onSubmitted);
}
final TodosRepository _todosRepository;
void _onTitleChanged(
EditTodoTitleChanged event,
Emitter<EditTodoState> emit,
) {
emit(state.copyWith(title: event.title));
}
void _onDescriptionChanged(
EditTodoDescriptionChanged event,
Emitter<EditTodoState> emit,
) {
emit(state.copyWith(description: event.description));
}
Future<void> _onSubmitted(
EditTodoSubmitted event,
Emitter<EditTodoState> emit,
) async {
emit(state.copyWith(status: EditTodoStatus.loading));
final todo = (state.initialTodo ?? Todo(title: '')).copyWith(
title: state.title,
description: state.description,
);
try {
await _todosRepository.saveTodo(todo);
emit(state.copyWith(status: EditTodoStatus.success));
} catch (e) {
emit(state.copyWith(status: EditTodoStatus.failure));
}
}
}
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

lib/edit_todo/view/edit_todo_page.dart
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_todos/edit_todo/edit_todo.dart';
import 'package:flutter_todos/l10n/l10n.dart';
import 'package:todos_repository/todos_repository.dart';
class EditTodoPage extends StatelessWidget {
const EditTodoPage({super.key});
static Route<void> route({Todo? initialTodo}) {
return MaterialPageRoute(
fullscreenDialog: true,
builder: (context) => BlocProvider(
create: (context) => EditTodoBloc(
todosRepository: context.read<TodosRepository>(),
initialTodo: initialTodo,
),
child: const EditTodoPage(),
),
);
}
@override
Widget build(BuildContext context) {
return BlocListener<EditTodoBloc, EditTodoState>(
listenWhen: (previous, current) =>
previous.status != current.status &&
current.status == EditTodoStatus.success,
listener: (context, state) => Navigator.of(context).pop(),
child: const EditTodoView(),
);
}
}
class EditTodoView extends StatelessWidget {
const EditTodoView({super.key});
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
final status = context.select((EditTodoBloc bloc) => bloc.state.status);
final isNewTodo = context.select(
(EditTodoBloc bloc) => bloc.state.isNewTodo,
);
return Scaffold(
appBar: AppBar(
title: Text(
isNewTodo
? l10n.editTodoAddAppBarTitle
: l10n.editTodoEditAppBarTitle,
),
),
floatingActionButton: FloatingActionButton(
tooltip: l10n.editTodoSaveButtonTooltip,
shape: const ContinuousRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(32)),
),
onPressed: status.isLoadingOrSuccess
? null
: () => context.read<EditTodoBloc>().add(const EditTodoSubmitted()),
child: status.isLoadingOrSuccess
? const CupertinoActivityIndicator()
: const Icon(Icons.check_rounded),
),
body: const CupertinoScrollbar(
child: SingleChildScrollView(
child: Padding(
padding: EdgeInsets.all(16),
child: Column(
children: [_TitleField(), _DescriptionField()],
),
),
),
),
);
}
}
class _TitleField extends StatelessWidget {
const _TitleField();
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
final state = context.watch<EditTodoBloc>().state;
final hintText = state.initialTodo?.title ?? '';
return TextFormField(
key: const Key('editTodoView_title_textFormField'),
initialValue: state.title,
decoration: InputDecoration(
enabled: !state.status.isLoadingOrSuccess,
labelText: l10n.editTodoTitleLabel,
hintText: hintText,
),
maxLength: 50,
inputFormatters: [
LengthLimitingTextInputFormatter(50),
FilteringTextInputFormatter.allow(RegExp(r'[a-zA-Z0-9\s]')),
],
onChanged: (value) {
context.read<EditTodoBloc>().add(EditTodoTitleChanged(value));
},
);
}
}
class _DescriptionField extends StatelessWidget {
const _DescriptionField();
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
final state = context.watch<EditTodoBloc>().state;
final hintText = state.initialTodo?.description ?? '';
return TextFormField(
key: const Key('editTodoView_description_textFormField'),
initialValue: state.description,
decoration: InputDecoration(
enabled: !state.status.isLoadingOrSuccess,
labelText: l10n.editTodoDescriptionLabel,
hintText: hintText,
),
maxLength: 300,
maxLines: 7,
inputFormatters: [
LengthLimitingTextInputFormatter(300),
],
onChanged: (value) {
context.read<EditTodoBloc>().add(EditTodoDescriptionChanged(value));
},
);
}
}

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 static route 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:

├── BlocProvider<EditTodosBloc>
│ └── EditTodosPage
│ └── BlocListener<EditTodosBloc>
│ └── EditTodosView
│ ├── TitleField
│ ├── DescriptionField
│ └── Floating Action Button

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.