Flutter Todos
Este conteúdo não está disponível em sua língua ainda.
In the following tutorial, we’re going to build a todos app in Flutter using the Bloc library.
- 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.0.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_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
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
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!
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
.
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 {}
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
.
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';
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.
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 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(); }}
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.
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
:
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
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.
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.
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();
final todosRepository = TodosRepository(todosApi: todosApi);
runApp(App(todosRepository: todosRepository));}
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.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(), ); }}
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.
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];}
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));}
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.
The todos overview feature allows users to manage their todos by creating, editing, deleting, and filtering todos.
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.
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.
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(); }}
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
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.
_onFilterChanged
emits a new state with the new event filter.
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
.
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.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
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
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
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), ); }}
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
The EditTodo
feature allows users to edit an existing todo item and save the changes.
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];}
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
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)); } }}
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.
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
That’s it, we have completed the tutorial! 🎉
The full source code for this example, including unit and widget tests, can be found here.