Flutter Todos
In the following tutorial, we’re going to build a todos app in Flutter using the Bloc library.
Key Topics
Section titled “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.
We’ll start off by creating a brand new Flutter project using the very_good_cli.
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
:
# create package:todos_api under packages/todos_apivery_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_apivery_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_repositoryvery_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:
name: flutter_todosdescription: An example todos app that showcases bloc state management patterns.version: 1.0.0+1publish_to: none
environment: sdk: ">=3.6.0 <4.0.0"
dependencies: bloc: ^9.0.0 equatable: ^2.0.0 flutter: sdk: flutter flutter_bloc: ^9.1.0 flutter_localizations: sdk: flutter intl: ^0.19.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_lint: ^0.2.0-dev.0 bloc_test: ^10.0.0 flutter_test: sdk: flutter mockingjay: ^0.6.0 mocktail: ^1.0.0
flutter: uses-material-design: true generate: true
Finally, we can install all the dependencies:
very_good packages get --recursive
Project Structure
Section titled “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
Section titled “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
Section titled “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
Section titled “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.
name: todos_apidescription: The interface and models for an API providing access to todos.version: 1.0.0+1publish_to: none
environment: sdk: ">=3.6.0 <4.0.0"
dependencies: equatable: ^2.0.0 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
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
Section titled “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.
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.
/// 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
.
Update Exports
Section titled “Update Exports”Our Todo
model and the TodosApi
are exported via barrel files. Notice how we
don’t import the model directly, but we import it in lib/src/todos_api.dart
with a reference to the package barrel file:
import 'package:todos_api/todos_api.dart';
. Update the barrel files to resolve
any remaining import errors:
export 'json_map.dart';export 'todo.dart';
/// The interface and models for an API providing access to todos.library todos_api;
export 'src/models/models.dart';export 'src/todos_api.dart';
Streams vs Futures
Section titled “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
Section titled “LocalStorageTodosApi”This package implements the todos_api
using the
shared_preferences
package.
name: local_storage_todos_apidescription: A Flutter implementation of the TodosApi that uses local storage.version: 1.0.0+1publish_to: none
environment: sdk: ">=3.6.0 <4.0.0"
dependencies: flutter: sdk: flutter meta: ^1.8.0 rxdart: ^0.28.0 shared_preferences: ^2.0.0 todos_api: path: ../todos_api
dev_dependencies: flutter_test: sdk: flutter mocktail: ^1.0.0
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 initialLength = todos.length;
todos.removeWhere((t) => t.isCompleted); final completedTodosAmount = initialLength - todos.length;
_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
Section titled “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
Section titled “TodosRepository”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);
/// Disposes any resources managed by the repository. void dispose() => _todosApi.close();}
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
:
name: todos_repositorydescription: A repository that handles todo related requests.version: 1.0.0+1publish_to: none
environment: sdk: ">=3.6.0 <4.0.0"
dependencies: todos_api: path: ../todos_api
dev_dependencies: mocktail: ^1.0.0 test: ^1.21.4
Library Exports
Section titled “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.
/// 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
Section titled “Feature Layer”Entrypoint
Section titled “Entrypoint”Our app’s entrypoint is main.dart
. In this case, there are three versions:
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);}
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);}
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
Section titled “Bootstrapping”bootstrap.dart
loads our BlocObserver
and creates the instance of
TodosRepository
.
import 'dart:developer';
import 'package:bloc/bloc.dart';import 'package:flutter/foundation.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); };
PlatformDispatcher.instance.onError = (error, stack) { log(error.toString(), stackTrace: stack); return true; };
Bloc.observer = const AppBlocObserver();
runApp(App(createTodosRepository: () => TodosRepository(todosApi: todosApi)));}
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.
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.createTodosRepository, super.key});
final TodosRepository Function() createTodosRepository;
@override Widget build(BuildContext context) { return RepositoryProvider<TodosRepository>( create: (_) => createTodosRepository(), dispose: (repository) => repository.dispose(), 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(), ); }}
This provides theme definition for light and dark mode.
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, ), ); }}
The home feature is responsible for managing the state of the currently-selected tab and displays the correct subtree.
HomeState
Section titled “HomeState”There are only two states associated with the two screens: todos
and stats
.
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
Section titled “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.
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
Section titled “HomeView”view.dart
is a barrel file that exports all relevant UI components for the
home feature.
export 'home_page.dart';
home_page.dart
contains the UI for the root page that the user will see when
the app is launched.
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
Section titled “TodosOverview”The todos overview feature allows users to manage their todos by creating, editing, deleting, and filtering todos.
TodosOverviewEvent
Section titled “TodosOverviewEvent”Let’s create todos_overview/bloc/todos_overview_event.dart
and define the
events.
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 theTodosRepository
.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 aTodosViewFilter
as an argument and changes the view by applying a filter.
TodosOverviewState
Section titled “TodosOverviewState”Let’s create todos_overview/bloc/todos_overview_state.dart
and define the
state.
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
Section titled “TodosOverviewBloc”Let’s create 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
Section titled “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
Section titled “onTodoSaved”_onTodoSaved
simply calls _todosRepository.saveTodo(event.todo)
.
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
Section titled “Filtering”_onFilterChanged
emits a new state with the new event filter.
Models
Section titled “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.
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.
export 'todos_view_filter.dart';
Next, let’s take a look at the TodosOverviewPage
.
TodosOverviewPage
Section titled “TodosOverviewPage”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.builder( itemCount: state.filteredTodos.length, itemBuilder: (_, index) { final todo = state.filteredTodos.elementAt(index); return 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
.
-
The first is a
BlocListener
that listens for errors. Thelistener
will only be called whenlistenWhen
returnstrue
. If the status isTodosOverviewStatus.failure
, aSnackBar
is displayed. -
We created a second
BlocListener
that listens for deletions. When a todo has been deleted, aSnackBar
is displayed with an undo button. If the user taps undo, theTodosOverviewUndoDeletionRequested
event will be added to the bloc. -
Finally, we use a
BlocBuilder
to builds the ListView that displays the todos.
The AppBar
contains two actions which are dropdowns for filtering and
manipulating the todos.
view.dart
is the barrel file that exports todos_overview_page.dart
.
export 'todos_overview_page.dart';
Widgets
Section titled “Widgets”widgets.dart
is another barrel file that exports all the components used
within the todos_overview
feature.
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.
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
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';
@visibleForTestingenum 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
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), ); }}
The stats feature displays statistics about the active and completed todos.
StatsState
Section titled “StatsState”StatsState
keeps track of summary information and the current StatsStatus
.
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
Section titled “StatsEvent”StatsEvent
has only one event called StatsSubscriptionRequested
:
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
Section titled “StatsBloc”StatsBloc
depends on the TodosRepository
just like TodosOverviewBloc
. It
subscribes to the todos stream via _todosRepository.getTodos
.
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
Section titled “Stats View”view.dart
is the barrel file for the stats_page
.
export 'stats_page.dart';
stats_page.dart
contains the UI for the page that displays the todos
statistics.
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
Section titled “EditTodo”The EditTodo
feature allows users to edit an existing todo item and save the
changes.
EditTodoState
Section titled “EditTodoState”EditTodoState
keeps track of the information needed when editing a todo.
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
Section titled “EditTodoEvent”The different events the bloc will react to are:
EditTodoTitleChanged
EditTodoDescriptionChanged
EditTodoSubmitted
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
Section titled “EditTodoBloc”EditTodoBloc
depends on the TodosRepository
, just like TodosOverviewBloc
and StatsBloc
.
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
Section titled “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 theTodosRepository
.TodosRepository
notifiesTodosOverviewBloc
andStatsBloc
.TodosOverviewBloc
andStatsBloc
notify the UI which update with the new state.
EditTodoPage
Section titled “EditTodoPage”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
Section titled “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.