Home feature
The home feature is responsible for managing the state of the currently-selected tab and displays the correct subtree.
HomeState
There are only two states associated with the two screens: todos and stats.
EditTodo is a separate route therefore it isn’t part of the HomeState.
part of 'home_cubit.dart';enum HomeTab { todos, stats }final classHomeStateextendsEquatable{ const HomeState({this.tab = HomeTab.todos, }); final HomeTab tab; @override List<Object> get props => [tab];}HomeCubit
A cubit is appropriate in this case due to the simplicity of the business logic. We have one method setTab to change the tab.
part'home_state.dart';classHomeCubitextendsCubit<HomeState> { HomeCubit() : super(const HomeState());void setTab(HomeTab tab) => emit(HomeState(tab: tab));}HomeView
view.dart is a barrel file that exports all relevant UI components for the home feature.
export'home_page.dart';home_page.dart contains the UI for the root page that the user will see when the app is launched.
A simplified representation of the widget tree for the HomePage is:
├── 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.
context.read doesn’t listen for changes, it is just used to access to HomeCubit and call setTab.
Todos Feature
The todos overview feature allows users to manage their todos by creating, editing, deleting, and filtering todos.
TodosOverviewEvent
Let's create todos_overview/bloc/todos_overview_event.dart and defines the events.
part of 'todos_overview_bloc.dart';sealed classTodosOverviewEventextendsEquatable{ const TodosOverviewEvent(); @override List<Object?> get props => [];}final classTodosOverviewSubscriptionRequestedextendsTodosOverviewEvent{ const TodosOverviewSubscriptionRequested();}final classTodosOverviewTodoCompletionToggledextendsTodosOverviewEvent{ const TodosOverviewTodoCompletionToggled({requiredthis.todo,requiredthis.isCompleted, }); final Todo todo; final bool isCompleted; @override List<Object> get props => [todo, isCompleted];}final classTodosOverviewTodoDeletedextendsTodosOverviewEvent{ const TodosOverviewTodoDeleted(this.todo); final Todo todo; @override List<Object> get props => [todo];}final classTodosOverviewUndoDeletionRequestedextendsTodosOverviewEvent{ const TodosOverviewUndoDeletionRequested();}classTodosOverviewFilterChangedextendsTodosOverviewEvent{ const TodosOverviewFilterChanged(this.filter); final TodosViewFilter filter; @override List<Object> get props => [filter];}classTodosOverviewToggleAllRequestedextendsTodosOverviewEvent{ const TodosOverviewToggleAllRequested();}classTodosOverviewClearCompletedRequestedextendsTodosOverviewEvent{ const TodosOverviewClearCompletedRequested();}• TodosOverviewSubscriptionRequested: This is the startup event. In response, the bloc subscribes to the stream of todos from theTodosRepository.• TodosOverviewTodoDeleted: This deletes aTodo.• 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 aTodosViewFilteras an argument and changes the view by applying a filter.
TodosOverviewState
TodosOverviewState will keep track of a list of todos, the active filter, the lastDeletedTodo, and the status.
part of 'todos_overview_bloc.dart';enum TodosOverviewStatus { initial, loading, success, failure }final classTodosOverviewStateextendsEquatable{ 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, ];}In addition to the default getters and setters, we have a custom getter called filteredTodos. The UI uses BlocBuilder to access either state.filteredTodos or state.todos.
TodosOverviewBloc
The bloc does not create an instance of the TodosRepository internally. Instead, it relies on an instance of the repository to be injected via constructor.
part'todos_overview_event.dart';part 'todos_overview_state.dart';classTodosOverviewBlocextendsBloc<TodosOverviewEvent, TodosOverviewState> { TodosOverviewBloc({required TodosRepository todosRepository, }) : _todosRepository = todosRepository,super(const TodosOverviewState()) {on<TodosOverviewSubscriptionRequested>(_onSubscriptionRequested);on<TodosOverviewTodoCompletionToggled>(_onTodoCompletionToggled);on<TodosOverviewTodoDeleted>(_onTodoDeleted);on<TodosOverviewUndoDeletionRequested>(_onUndoDeletionRequested);on<TodosOverviewFilterChanged>(_onFilterChanged);on<TodosOverviewToggleAllRequested>(_onToggleAllRequested);on<TodosOverviewClearCompletedRequested>(_onClearCompletedRequested); } final TodosRepository _todosRepository; Future<void> _onSubscriptionRequested( TodosOverviewSubscriptionRequested event, Emitter<TodosOverviewState> emit, ) async { emit(state.copyWith(status: () => TodosOverviewStatus.loading));await emit.forEach<List<Todo>>( _todosRepository.getTodos(), onData: (todos) => state.copyWith( status: () => TodosOverviewStatus.success, todos: () => todos, ), onError: (_, _) => state.copyWith( status: () => TodosOverviewStatus.failure, ), ); } Future<void> _onTodoCompletionToggled( TodosOverviewTodoCompletionToggled event, Emitter<TodosOverviewState> emit, ) async {final newTodo = event.todo.copyWith(isCompleted: event.isCompleted);await _todosRepository.saveTodo(newTodo); } Future<void> _onTodoDeleted( TodosOverviewTodoDeleted event, Emitter<TodosOverviewState> emit, ) async { emit(state.copyWith(lastDeletedTodo: () => event.todo));await _todosRepository.deleteTodo(event.todo.id); } Future<void> _onUndoDeletionRequested( TodosOverviewUndoDeletionRequested event, Emitter<TodosOverviewState> emit, ) async {assert( state.lastDeletedTodo != null,'Last deleted todo can not be null.', );final todo = state.lastDeletedTodo!; emit(state.copyWith(lastDeletedTodo: () => null));await _todosRepository.saveTodo(todo); } void _onFilterChanged( TodosOverviewFilterChanged event, Emitter<TodosOverviewState> emit, ) { emit(state.copyWith(filter: () => event.filter)); } Future<void> _onToggleAllRequested( TodosOverviewToggleAllRequested event, Emitter<TodosOverviewState> emit, ) async {final areAllCompleted = state.todos.every((todo) => todo.isCompleted);await _todosRepository.completeAll(isCompleted: !areAllCompleted); } Future<void> _onClearCompletedRequested( TodosOverviewClearCompletedRequested event, Emitter<TodosOverviewState> emit, ) async {await _todosRepository.clearCompleted(); }}onSubscriptionRequested
When TodosOverviewSubscriptionRequested is added, the bloc starts by emitting a loading state. In response, the UI can then render a loading indicator.
We use emit.forEach<List<Todo>>( ... ) which creates a subscription on the todos stream from the TodosRepository.
emit.forEach() is not the same forEach() used by lists. This forEach enables the bloc to subscribe to a Stream and emit a new state for each update from the stream.
Using await emit.forEach() is a newer pattern for subscribing to a stream which allows the bloc to manage the subscription internally.
After we handled the subscription, we will handle the other events, like adding, modifying, and deleting todos.
onTodoSaved
_onTodoSaved simply calls _todosRepository.saveTodo(event.todo).
emit is never called from within onTodoSaved and many other event handlers. Instead, they notify the repository which emits an updated list via the todos stream.
Undo
The undo feature allows users to restore the last deleted item.
_onTodoDeleted does two things. First, it emits a new state with the Todo to be deleted. Then, it deletes the Todo via a call to the repository.
_onUndoDeletionRequested runs when the undo deletion request event comes from the UI.
_onUndoDeletionRequested does the following:
• Temporarily saves a copy of the last deleted todo. • Updates the state by removing the lastDeletedTodo.• Reverts the deletion.
Filtering
_onFilterChanged emits a new state with the new event filter.
Models
There is one model file that deals with the view filtering.
todos_view_filter.dart is an enum that represents the three view filters and the methods to apply the filter.
enum TodosViewFilter { all, activeOnly, completedOnly }extension TodosViewFilterX on TodosViewFilter { bool apply(Todo todo) {switch (this) {case TodosViewFilter.all:returntrue;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';TodosOverviewPage
A simplified representation of the widget tree for the TodosOverviewPage is:
├── TodosOverviewPage│ └── BlocProvider<TodosOverviewBloc>│ └── TodosOverviewView│ ├── BlocListener<TodosOverviewBloc>│ └── BlocListener<TodosOverviewBloc>│ └── BlocBuilder<TodosOverviewBloc>│ └── ListViewThe TodosOverviewPage provides an instance of the TodosOverviewBloc to the subtree via BlocProvider<TodosOverviewBloc>. This scopes the TodosOverviewBloc to just the widgets below TodosOverviewPage.
view.dart is the barrel file that exports todos_overview_page.dart.
export'todos_overview_page.dart';There are three widgets that are listening for changes in the TodosOverviewBloc.
1. The first is a BlocListenerthat listens for errors. The listener will only be called whenlistenWhenreturnstrue. If the status isTodosOverviewStatus.failure, aSnackBaris displayed.2. We created a second BlocListenerthat listens for deletions. When a todo has been deleted, aSnackBaris displayed with anundobutton. If the user tapsundo, theTodosOverviewUndoDeletionRequestedevent will be added to the bloc.3. Finally, we use a BlocBuilderto builds theListViewthat displays the todos.
The AppBar contains two actions which are dropdowns for filtering and manipulating the todos.
• TodosOverviewTodoCompletionToggledandTodosOverviewTodoDeletedare added to the bloc viacontext.read.
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.
classTodoListTileextendsStatelessWidget{ 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
@visibleForTestingenum TodosOverviewOption { toggleAll, clearCompleted }classTodosOverviewOptionsButtonextendsStatelessWidget{ 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
classTodosOverviewFilterButtonextendsStatelessWidget{ 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), ); }}
夜雨聆风