Master Riverpod in Flutter - Part 2: Building a Todo App [2025]
- AUTHOR
- Ayaan Haaris
- READING TIME
- ~ 38 min read
- PUBLISHED
- Jan 3, 2025
Riverpod Todo App Source Code
GitHub👋 Welcome to Part 2 of our Riverpod series!
If you're joining us from Part 1: Master Riverpod, you're ready to put those core concepts into practice. If you've landed here directly, I'd strongly recommend starting with Part 1 first.
In this tutorial, we'll build a production-ready Todo application that demonstrates real-world Riverpod usage. You'll learn how to:
- Structure a Flutter project with Riverpod
- Generate type-safe providers using code generation
- Implement state management patterns with
NotifierProvider
- Handle async operations using
AsyncNotifierProvider
- Build clean architecture with the Repository pattern
- Manage loading and error states effectively
- Persist data locally using Hive
What We'll Build
We'll develop this application in two distinct phases to showcase different aspects of Riverpod:
Phase 1: In-Memory Implementation
First, we'll build a foundation with a simple in-memory implementation:
- State management using
NotifierProvider
for reactive updates - Complete CRUD operations (Create, Read, Update, Delete)
- Filter todos by status (All, Active, Completed)
Phase 2: Persistent Storage
Once we have our core functionality working, we'll enhance the app with persistence:
- Implementation of the Repository pattern for data abstraction
- Local storage integration using Hive for offline capability
- Migration to
AsyncNotifierProvider
for handling asynchronous operations - Proper loading and error states management
This two-phase approach will help you understand how Riverpod adapts to evolving requirements while maintaining clean, maintainable code.
Phase 1
Project Setup
Let's start by creating a new Flutter project and setting up the required dependencies.
flutter create riverpod_todo
cd riverpod_todo
For dependencies, we'll need:
flutter_riverpod
: The core Riverpod packageriverpod_annotation
: For code generation supportbuild_runner
andriverpod_generator
: For generating boilerplate code
Execute these commands in your terminal:
flutter pub add flutter_riverpod
flutter pub add riverpod_annotation
flutter pub add dev:riverpod_generator
flutter pub add dev:build_runner
Now, your pubspec.yaml
file should contain the following dependencies (ensure you're using the latest versions):
dependencies:
flutter:
sdk: flutter
flutter_riverpod: ^2.6.1
riverpod_annotation: ^2.6.1
dev_dependencies:
build_runner: ^2.4.13
riverpod_generator: ^2.6.3
NOTE
If you're following along, make sure to specify exact versions in your pubspec.yaml
to ensure reproducible builds.
Project Structure
There are multiple ways to structure your project depending on your project size and complexity.
For this tutorial, I have used a simple folder structure. A data
folder for models and repositories, and a features
folder for providers and views:
lib/
├── data/
│ ├── model/
│ │ └── todo.dart
│ └── repositories
├── features/
│ └── todos/
│ ├── providers/
│ │ └── todos_provider.dart
│ └── view/
│ └── todos_screen.dart
└── main.dart
You can use any folder structure you want, but this is a good starting point.
Todo Model
Before diving into state management, let's design our Todo model. This is crucial because it defines the shape of our data and the operations we can perform on it.
class Todo {
final String id;
final String title;
final bool completed;
Todo({
required this.id,
required this.title,
this.completed = false,
});
Todo copyWith({
String? id,
String? title,
bool? completed,
}) {
return Todo(
id: id ?? this.id,
title: title ?? this.title,
completed: completed ?? this.completed,
);
}
}
Let's break down the important concepts here:
Immutability: Notice how all fields are
final
. This is intentional:- Prevents accidental modifications
- Makes state changes explicit
- Helps with debugging
using
copyWith
method:// Instead of modifying properties directly: todo.completed = true; // ❌ Wrong: Can't modify final fields // Create a new instance with updated values: todo.copyWith(completed: true); // ✅ Correct
WARNING
Always make your models immutable when working with Riverpod. Mutable models can lead to hard-to-debug issues.
Todos Provider
Now that we have our model, let's implement our state management using NotifierProvider
- Riverpod's equivalent to Cubit
or ChangeNotifier
that we discussed in previous parts.
We'll use NotifierProvider
because:
- It's perfect for centralized state management
- Provides built-in state immutability
- Offers great developer experience with code generation
Create lib/features/todos/todos_provider.dart
:
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:riverpod_todo/data/models/todo.dart';
// Generated code will be in todos_provider.g.dart
part 'todos_provider.g.dart';
// Initial todos for testing
List<Todo> initialTodos = [
Todo(id: '1', title: 'Learn Flutter', completed: true),
Todo(id: '2', title: 'Build Todo App', completed: false),
Todo(id: '3', title: 'Master Riverpod', completed: false),
];
class Todos extends _$Todos {
// Initialize the state with initialTodos
List<Todo> build() => initialTodos;
// We simply return the current state
List<Todo> get todos => state;
// Creates a new todo with the given title and adds it to the state
void addTodo(String title) {
final todo = Todo(
id: DateTime.now().toIso8601String(), // Use timestamp as unique ID
title: title,
);
// Create new list with existing todos + new todo
state = [...state, todo];
}
// Toggles the completed status of a todo with the given ID
void toggleTodo(String id) {
state = state.map((todo) {
if (todo.id == id) {
return todo.copyWith(completed: !todo.completed);
}
return todo;
}).toList();
}
// Removes the todo with the given ID from the state
void removeTodo(String id) {
state = state.where((todo) => todo.id != id).toList();
}
}
Run code generation:
dart run build_runner build
Or for continuous generation during development:
dart run build_runner watch
Let's break down the important concepts:
Code Generation Setup
part 'todos_provider.g.dart';
class Todos extends _$Todos
We annotate the class with @riverpod
and use part
to enable code generation.
Initial State
List<Todo> build() => initialTodos;
The build
method defines the initial state. To make it easier to test, we'll start with a list of dummy todos.
List<Todo> initialTodos = [
Todo(id: '1', title: 'Learn Flutter', completed: true),
Todo(id: '2', title: 'Build Todo App', completed: false),
Todo(id: '3', title: 'Master Riverpod', completed: false),
];
You can return an empty list if you want to start with an empty state.
List<Todo> build() => [];
TIP
Try to always start with a valid state, instead of null
.
CRUD Operations
void addTodo(String title) {
final todo = Todo(
id: DateTime.now().toIso8601String(),
title: title,
);
// Update the state
state = [...state, todo];
}
We create a new Todo
instance with two properties:
id
: UsesDateTime.now().toIso8601String()
to generate a unique identifier based on the current timestamptitle
: The user-provided title for the todo item
Then, we update the state using the spread(...
) operator to create a new list containing all existing todos plus our new todo.
This approach to updating state in Riverpod is crucial for several reasons:
First, by creating a new list reference -
state = [...state, todo]
, we ensure Riverpod can detect the state change.Riverpod compares references to determine if the state has changed, so creating a new list guarantees that the comparison will detect our update.
Second, this pattern maintains immutability, which is a core principle in Riverpod.
This immutability helps prevent bugs and makes our state changes more predictable.
NOTE
In case you did not know, the spread operator (...
) is used to create a new list by "spreading" the elements of an existing list into it. For example:
final existing = [1, 2, 3];
final newList = [...existing, 4]; // [1, 2, 3, 4]
Todos Screen
Our Todo model and provider are now ready. Let's build the UI for our Todos screen.
For now, we'll just display the todos.
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:riverpod_todo/features/todos/providers/todos_provider.dart';
class TodosScreen extends ConsumerWidget {
const TodosScreen({super.key});
Widget build(BuildContext context, WidgetRef ref) {
// Watch the todos provider
final todos = ref.watch(todosProvider);
return Scaffold(
appBar: AppBar(
title: const Text('Todos'),
),
body: ListView.builder(
itemCount: todos.length,
itemBuilder: (context, index) {
final todo = todos[index];
return ListTile(
leading: Checkbox(
value: todo.completed,
onChanged: (value) {},
),
title: Text(
todo.title,
),
);
},
),
floatingActionButton: FloatingActionButton(
onPressed: () {
// TODO: Implement add todo functionality
},
child: const Icon(Icons.add),
),
);
}
}
- We're using
ref.watch()
to observe our todos state. Any changes to the todos list will automatically trigger a rebuild of this widget. - The screen uses
ConsumerWidget
instead ofConsumer
builder pattern since the entire screen needs to react to state changes
Finally, we need to update our main.dart
to use our new TodosScreen
:
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:riverpod_todo/features/todos/views/todos_screen.dart';
void main() {
runApp(
const ProviderScope(child: TodosApp()),
);
}
class TodosApp extends StatelessWidget {
const TodosApp({super.key});
Widget build(BuildContext context) {
return MaterialApp(
title: 'Todos App',
debugShowCheckedModeBanner: false,
theme: ThemeData(
brightness: Brightness.light,
scaffoldBackgroundColor: Colors.purple[50],
),
darkTheme: ThemeData(
brightness: Brightness.dark,
scaffoldBackgroundColor: Colors.grey[900],
),
home: const TodosScreen(),
);
}
}
We have setup the Todo provider with initial todos and connected it to the UI.
If you run the app, you should see the initial list of todos.
Toggle Todo
Right now, we only display the todos, let's add the functionality to toggle them.
Update your TodosScreen
with these changes:
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'todos_provider.dart';
class TodosScreen extends ConsumerWidget {
const TodosScreen({super.key});
Widget build(BuildContext context, WidgetRef ref) {
final todos = ref.watch(todosProvider);
return Scaffold(
appBar: AppBar(
title: const Text('Todos'),
),
body: ListView.builder(
itemCount: todos.length,
itemBuilder: (context, index) {
final todo = todos[index];
return ListTile(
leading: Checkbox(
value: todo.completed,
- onChanged: (value) {}
+ onChanged: (_) {
+ ref.read(todosProvider.notifier).toggleTodo(todo.id);
+ },
),
title: Text(
todo.title,
+ style: TextStyle(
+ decoration: todo.completed ? TextDecoration.lineThrough : null,
+ ),
),
);
},
),
floatingActionButton: FloatingActionButton(
onPressed: () {
// TODO: Implement add todo functionality
},
child: const Icon(Icons.add),
),
);
}
}
In the onChanged
callback, we're calling ref.read(todosProvider.notifier).toggleTodo(todo.id);
to toggle the todo.
Remember that we need to use ref.read()
in a callback function like onChanged
instead of ref.watch()
because we're not observing the state, but rather triggering an action.
And for visual feedback, we're using a TextStyle
to add a line through the text if the todo is completed.
Restart the app and you should be able to toggle the todos by clicking the checkbox.
NOTE
For some reason the line-through on completed todo is not properly visible on the screenshot image above but it is there.
Delete Todo
To enable todo deletion, we'll add a delete button as the trailing widget of each ListTile
. When pressed, it will trigger the removeTodo
method from our provider.
Widget build(BuildContext context, WidgetRef ref) {
final todos = ref.watch(todosProvider);
return Scaffold(
appBar: AppBar(
title: const Text('Todos'),
),
body: ListView.builder(
itemCount: todos.length,
itemBuilder: (context, index) {
final todo = todos[index];
return ListTile(
leading: Checkbox(
value: todo.completed,
onChanged: (_) {
ref.read(todosProvider.notifier).toggleTodo(todo.id);
},
),
title: Text(
todo.title,
style: TextStyle(
decoration: todo.completed ? TextDecoration.lineThrough : null,
),
),
+ trailing: IconButton(
+ onPressed: () {
+ ref.read(todosProvider.notifier).removeTodo(todo.id);
+ },
+ icon: const Icon(Icons.delete_outline),
+ ),
);
},
),
floatingActionButton: FloatingActionButton(
onPressed: () {
// TODO: Implement add todo functionality
},
child: const Icon(Icons.add),
),
);
}
NOTE
In real-world applications, you should always show a confirmation dialog before deleting a todo.
Restart the app and you should be able to delete a todo by clicking the delete button.
Add Todo
We'll create a simple dialog that pops up when the user presses the floating action button.
The dialog will contain a text field where the user can enter the todo title - once they hit "Add", the todo is created and the dialog closes automatically.
Organizing Widget Files
As our app grows, it's important to keep our code organized and maintainable. Let's follow some best practices for structuring our widget files:
Creating a Widgets Directory
First, let's create a dedicated folder for our todos_screen
specific widgets and place add_todo_dialog.dart
file:
lib/
└── features/
└── todos/
└── views/
└── widgets/
└── add_todo_dialog.dart // Create this file
This structure helps us:
- Keep related widgets close to their parent screen
- Make it easy to find screen-specific components
- Maintain a clean, organized codebase
TIP
For widgets used across multiple screens, consider creating a lib/global_widgets/
directory. For example:
lib/
└── global_widgets/
├── app_loader.dart
└── error_view.dart
Here is the add_todo_dialog.dart
code:
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:riverpod_todo/features/todos/providers/todos_provider.dart';
class AddTodoDialog extends ConsumerStatefulWidget {
const AddTodoDialog({super.key});
ConsumerState<AddTodoDialog> createState() => _AddTodoDialogState();
}
class _AddTodoDialogState extends ConsumerState<AddTodoDialog> {
final _controller = TextEditingController();
void dispose() {
_controller.dispose();
super.dispose();
}
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Add Todo'),
content: TextField(
controller: _controller,
autofocus: true,
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
TextButton(
onPressed: () {
if (_controller.text.isNotEmpty) {
// Add the todo and close the dialog
ref.read(todosProvider.notifier).addTodo(_controller.text);
Navigator.of(context).pop();
}
},
child: const Text('Add'),
),
],
);
}
}
The code above shows the implementation of an AddTodoDialog
widget that:
- Uses
ConsumerStatefulWidget
to access Riverpod providers - Has a text field for entering the todo title
- Properly disposes the
TextEditingController
to prevent memory leaks - Calls
ref.read(todosProvider.notifier).addTodo()
to add the new todo when the "Add" button is pressed - Closes the dialog after adding the todo
The key part is in onPressed
of "Add" TextButton
where we:
- Check if the text is not empty
- Add the todo using the provider
- Close the dialog
Now, let's wire up the dialog to our floating action button in the TodosScreen
:
floatingActionButton: FloatingActionButton(
onPressed: () {
showDialog(
context: context,
builder: (context) => const AddTodoDialog(),
);
},
child: const Icon(Icons.add),
),
Run or restart the app and press the floating action button to see the dialog.
Filtering Todos
Let's wrap up Phase 1 of our Todo app by adding the ability to filter the todos.
Filtering todos is a common feature in todo apps that lets users view all todos, only completed ones, or just the active ones.
We'll implement this by adding a filter menu in the app bar.
Adding Filter States
First, let's define our filter options using an enum:
/// Defines the available filtering options for todos
enum TodosFilter { all, completed, active }
To manage filtering in our TodosProvider
, we need to track both the complete list of todos and the current filter. We'll add these as private variables:
class Todos extends _$Todos {
TodosFilter _filter = TodosFilter.all;
List<Todo> _allTodos = initialTodos;
TodosFilter get currentFilter => _filter;
// ...existing code...
}
Implementing the Filter Logic
Now, let's add a helper method to TodosProvider
that returns the filtered list based on the current filter:
/// Returns the filtered list of todos based on the current filter
List<Todo> _getFilteredTodos() {
switch (_filter) {
case TodosFilter.completed:
return _allTodos.where((todo) => todo.completed).toList();
case TodosFilter.active:
return _allTodos.where((todo) => !todo.completed).toList();
case TodosFilter.all:
return _allTodos;
}
}
Updating the Filter
To allow users to change the current filter, we'll add a setFilter
method:
void setFilter(TodosFilter filter) {
_filter = filter;
// update the state with the filtered todos
state = _getFilteredTodos();
}
IMPORTANT
Notice how we update both the _filter
and the state
. This ensures that:
- The new filter is stored for future use
- The UI is updated with the filtered list immediately
Finally we need to update build
, addTodo
and removeTodo
methods to use the _getFilteredTodos
method to update the state with the filtered todos.
Here is the updated TodosProvider
code:
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:riverpod_todo/data/models/todo.dart';
// Generated code will be in todos_provider.g.dart
part 'todos_provider.g.dart';
// Initial todos for testing
List<Todo> initialTodos = [
Todo(id: '1', title: 'Learn Flutter', completed: true),
Todo(id: '2', title: 'Build Todo App', completed: false),
Todo(id: '3', title: 'Master Riverpod', completed: false),
];
/// Filter for todos
enum TodosFilter { all, completed, active }
/// Provider for the list of todos
class Todos extends _$Todos {
TodosFilter _filter = TodosFilter.all;
List<Todo> _allTodos = initialTodos;
TodosFilter get currentFilter => _filter;
List<Todo> build() => _getFilteredTodos();
/// Returns the filtered list of todos
List<Todo> _getFilteredTodos() {
switch (_filter) {
case TodosFilter.completed:
return _allTodos.where((todo) => todo.completed).toList();
case TodosFilter.active:
return _allTodos.where((todo) => !todo.completed).toList();
case TodosFilter.all:
return _allTodos;
}
}
void setFilter(TodosFilter filter) {
_filter = filter;
state = _getFilteredTodos();
}
void addTodo(String title) {
final todo = Todo(
id: DateTime.now().toIso8601String(),
title: title,
);
_allTodos = [..._allTodos, todo];
state = _getFilteredTodos();
}
void toggleTodo(String id) {
_allTodos = _allTodos.map((todo) {
if (todo.id == id) {
return todo.copyWith(completed: !todo.completed);
}
return todo;
}).toList();
state = _getFilteredTodos();
}
void removeTodo(String id) {
_allTodos = _allTodos.where((todo) => todo.id != id).toList();
state = _getFilteredTodos();
}
}
Updating the UI
We need to update our TodosScreen
to use the filter functionality of the todosProvider
for getting the todos.
And we need to add a PopupMenuButton
in the AppBar
actions to allow the user to filter the todos.
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:riverpod_ultimate_guide/features/todos/providers/filtered_todos_provider.dart';
import 'package:riverpod_ultimate_guide/features/todos/providers/todos_provider.dart';
import 'package:riverpod_ultimate_guide/features/todos/view/add_todo_dialog.dart';
class TodosScreen extends ConsumerWidget {
const TodosScreen({super.key});
Widget build(BuildContext context, WidgetRef ref) {
final todos = ref.watch(todosProvider);
+ final currentFilter = ref.watch(todosProvider.notifier).currentFilter;
return Scaffold(
appBar: AppBar(
title: const Text('Todos'),
+ actions: [
+ PopupMenuButton<TodosFilter>(
+ icon: const Icon(Icons.filter_list),
+
+ // We are mapping over the TodosFilter enum and creating
+ // a PopupMenuItem for each filter
+ itemBuilder: (context) => TodosFilter.values.map((filter) {
+ return PopupMenuItem(
+ value: filter,
+ child: Text(
+ filter.name.toUpperCase(),
+ style: TextStyle(
+ color: currentFilter == filter
+ ? Theme.of(context).colorScheme.primary
+ : Theme.of(context).colorScheme.onSurface,
+ ),
+ ),
+ );
+ }).toList(),
+ onSelected: (filter) {
+ // Set the current filter to the selected filter
+ ref.read(todosProvider.notifier).setFilter(filter);
+ },
+ ),
+ ],
),
body: ListView.builder(
itemCount: todos.length,
itemBuilder: (context, index) {
final todo = todos[index];
return ListTile(
leading: Checkbox(
value: todo.completed,
onChanged: (_) {
ref.read(todosProvider.notifier).toggleTodo(todo.id);
},
),
title: Text(
todo.title,
style: TextStyle(
decoration: todo.completed ? TextDecoration.lineThrough : null,
),
),
trailing: IconButton(
onPressed: () {
ref.read(todosProvider.notifier).removeTodo(todo.id);
},
icon: const Icon(Icons.delete_outline),
),
);
},
),
floatingActionButton: FloatingActionButton(
onPressed: () {
showDialog(
context: context,
builder: (context) => const AddTodoDialog(),
);
},
child: const Icon(Icons.add),
),
);
}
}
Here are the changes we made:
- We use
ref.watch(todosProvider.notifier).currentFilter
to set active styling for the filterPopupMenuItem
. - We added a
PopupMenuButton
in theAppBar
actions to allow the user to filter the todos.
Now whenever the filter or the todos change, the TodosScreen
will automatically rebuild and update the UI with the filtered todos.
Run the app and you should be able to filter the todos by clicking the filter button in the AppBar
.
With this, Phase 1 of the Riverpod Todo app is complete.
The todo app now implements core functionalities including todo creation, completion status updates, filtering, and deletion.
However, this implementation is currently limited to in-memory storage, meaning all todos are lost when the application restarts.
In Phase 2, we'll focus on persistence and architecture improvements:
- Implementing local storage using
Hive
- Introducing the Repository pattern for data abstraction
- Managing asynchronous operations with
AsyncNotifierProvider
- Handling loading states and error scenarios
These additions will demonstrate how Riverpod can be used to handle more complex application requirements while maintaining clean, maintainable code.
Phase 2
Setting Up Hive
Let's start by setting up local storage with Hive
. Execute these commands in your terminal:
flutter pub add hive
flutter pub add hive_flutter
flutter pub add path_provider
flutter pub add dev:hive_generator
We're adding several packages here:
hive
andhive_flutter
: The core Hive packages for local storagepath_provider
: Required by Hive to access the device's local storage directories securelyhive_generator
: For generating TypeAdapters (we'll use this later for our Todo model)
NOTE
We don't need to add build_runner
again since we already added it earlier for Riverpod's code generation.
Todo Type Adapter
We need to create a type adapter for our Todo
model so that Hive
knows how to store and retrieve it.
See the Hive
docs for more information on generating type adapters here.
import 'package:hive/hive.dart';
part 'todo.g.dart';
(typeId: 0)
class Todo extends HiveObject {
(0)
final String id;
(1)
final String title;
(2)
bool completed;
Todo({
required this.id,
required this.title,
this.completed = false,
});
}
Run code generation:
dart run build_runner build
Initializing Hive in main.dart
In main.dart
, initialize Hive
and register the Todo
adapter:
import 'package:flutter/material.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'data/models/todo.dart';
// Make the main function async
void main() async {
// Ensure WidgetsFlutterBinding is initialized before running the app
WidgetsFlutterBinding.ensureInitialized();
// Initialize Hive
await Hive.initFlutter();
// Register the Todo adapter
Hive.registerAdapter(TodoAdapter());
// Run the app
runApp(const ProviderScope(child: TodosApp()));
}
Todos Repository
Create a new file todos_repository.dart
in lib/data/repositories/
:
lib/
└── data/
└── repositories
└── todos_repository.dart // Create this file
The TodosRepository
will be responsible for interacting with the Hive
database and providing a clean interface for the rest of the application to use.
- It will have methods for adding, updating, and deleting todos
- It will also have a method for getting all todos
- It will handle errors using a custom
Exception
.
TodoException
Before implementing the repository, let's create a custom exception class called TodoException
.
This class will help us handle errors in a more organized and user-friendly way.
When working with data (like Hive
in our case), many things can go wrong:
- There maybe a problem loading the data
- A file might be corrupted
- The data format might be invalid
Instead of letting these errors bubble up through our app with technical messages that users won't understand, we can catch them and convert them into more meaningful messages using our TodoException
.
For example, if Hive
fails to save a todo because the storage is full, instead of showing a complex error message like "HiveError: Some random error jargon", we can catch this error in our repository and throw a TodoException
with a friendly message like "Unable to save todo: Device storage is full".
This approach has several benefits:
- Better user experience: Users see clear, understandable error messages
- Easier error handling: Our UI code can focus on handling just one type of exception
- Consistent error messages: We maintain a consistent style of error messages throughout the app
- Separation of concerns: The repository handles the technical details of errors, while the UI just needs to display the message
Let's create the TodoException
class in todos_repository
file:
/// Custom exception for Todo-related errors
class TodoException implements Exception {
final String message;
final dynamic error;
TodoException(this.message, [this.error]);
String toString() => 'TodoException: $message${error != null ? ' ($error)' : ''}';
}
Todos Repository Interface
Before implementing our actual repository that works with Hive
, let's first create an interface (abstract class) that defines what our repository can do.
Think of this interface as a contract or a blueprint that lists all the actions our todo app needs to perform with the data.
This might seem like an extra step, but it brings several important benefits:
Clear Contract: The interface clearly shows what operations our todo app can perform. Anyone looking at the interface can quickly understand what the repository does without diving into implementation details.
Easier Testing: With an interface, we can create "mock" (fake) repositories for testing. This lets us test our app's logic without needing a real database.
Flexibility to Change: If we later decide to switch from
Hive
to another storage solution (like SQLite or Firebase), we only need to create a new class that follows this interface. The rest of our app won't need any changes because it only knows about the interface, not the specific implementation.Better Code Organization: The interface serves as documentation and keeps our code organized by clearly separating what the repository can do (the interface) from how it does it (the implementation).
For example, imagine you're building a house. Before construction begins, you have a blueprint that shows what the house will look like.
The blueprint doesn't specify whether the walls will be made of brick or concrete - it just shows that there will be walls.
Similarly, our repository interface shows what operations are available without specifying how they'll be implemented.
Let's look at our interface below. It defines four main operations:
- Getting all todos
- Adding a new todo
- Updating an existing todo
- Deleting a todo
Each method is marked as Future
and we've documented that they might throw our custom TodoException
if something goes wrong.
/// Repository interface for Todo operations
abstract class TodosRepository {
/// Retrieves all todos from storage
///
/// Throws [TodoException] if the operation fails
Future<List<Todo>> getTodos();
/// Adds a new todo to storage
///
/// Throws [TodoException] if the operation fails
Future<void> addTodo(Todo todo);
/// Updates an existing todo in storage
///
/// Throws [TodoException] if the todo doesn't exist or the operation fails
Future<void> updateTodo(Todo todo);
/// Deletes a todo from storage
///
/// Throws [TodoException] if the todo doesn't exist or the operation fails
Future<void> deleteTodo(Todo todo);
}
Todos Repository Implementation
Now that we have our interface defined, let's create the actual implementation that will store and manage our todos
using Hive
.
/// Implementation of [TodosRepository] using Hive
class LocalTodosRepository implements TodosRepository {
static const String _boxName = 'todos';
Future<Box<Todo>> _openBox() async {
try {
if (Hive.isBoxOpen(_boxName)) {
return Hive.box<Todo>(_boxName);
}
return await Hive.openBox<Todo>(_boxName);
} catch (e) {
throw TodoException('Failed to open todos box', e);
}
}
Future<List<Todo>> getTodos() async {
try {
final box = await _openBox();
return box.values.toList();
} catch (e) {
throw TodoException('Failed to get todos', e);
}
}
Future<void> addTodo(Todo todo) async {
try {
final box = await _openBox();
await box.put(todo.id, todo);
} catch (e) {
throw TodoException('Failed to add todo', e);
}
}
Future<void> updateTodo(Todo todo) async {
try {
final box = await _openBox();
if (!box.containsKey(todo.id)) {
throw TodoException('Todo not found');
}
await box.put(todo.id, todo);
} catch (e) {
throw TodoException('Failed to update todo', e);
}
}
Future<void> deleteTodo(Todo todo) async {
try {
final box = await _openBox();
if (!box.containsKey(todo.id)) {
throw TodoException('Todo not found');
}
await box.delete(todo.id);
} catch (e) {
throw TodoException('Failed to delete todo', e);
}
}
}
Our LocalTodosRepository
class implements the TodosRepository
interface we created earlier. This means it must provide concrete implementations for all the methods we defined in the interface.
Let's break down the important parts:
We define a constant
_boxName = 'todos'
- think of aHive
"box" like a table in a database. This is where we'll store all our todos.The
_openBox()
helper method does something important: it checks if our todos box is already open before trying to open it again. This helps prevent errors and makes our code more efficient.
TIP
Always check if a Hive
box is already open before trying to open it. Opening an already open box can cause errors!
The implementation of our main methods is straightforward:
getTodos()
: Opens the box and returns all todos stored in itaddTodo()
: Opens the box and stores a new todo using its ID as the keyupdateTodo()
: First checks if the todo exists, then updates itdeleteTodo()
: Similar to update, but removes the todo instead
Each method is wrapped in a try-catch block that converts any Hive-related errors into our custom TodoException
.
WARNING
Remember to always handle errors in your repository implementations. Never let database-specific errors leak into your application code!
Why This Approach Works Well
- Error Handling: By converting all errors to
TodoException
, we make error handling consistent throughout our app. - Encapsulation: All the Hive-specific code is contained within this class. If we need to change how we store todos later, we only need to change this file.
- Efficiency: We reuse the same box connection when possible instead of repeatedly opening and closing it.
Providing the Repository
Now, to make our repository available throughout our app, we'll use Provider
.
(keepAlive: true)
LocalTodosRepository todosRepository(Ref ref) => LocalTodosRepository();
While you can place the provider declaration wherever you want, a common convention is to position it just above the repository implementation for better code organization.
Here is the full implementation of the TodosRepository
:
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:hive/hive.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:riverpod_todo/data/models/todo.dart';
part 'todos_repository.g.dart';
/// Custom exception for Todo-related errors
class TodoException implements Exception {
final String message;
final dynamic error;
TodoException(this.message, [this.error]);
String toString() => 'TodoException: $message${error != null ? ' ($error)' : ''}';
}
/// Repository interface for Todo operations
abstract class TodosRepository {
/// Retrieves all todos from storage
///
/// Throws [TodoException] if the operation fails
Future<List<Todo>> getTodos();
/// Adds a new todo to storage
///
/// Throws [TodoException] if the operation fails
Future<void> addTodo(Todo todo);
/// Updates an existing todo in storage
///
/// Throws [TodoException] if the todo doesn't exist or the operation fails
Future<void> updateTodo(Todo todo);
/// Deletes a todo from storage
///
/// Throws [TodoException] if the todo doesn't exist or the operation fails
Future<void> deleteTodo(Todo todo);
}
(keepAlive: true)
TodosRepository todosRepository(Ref ref) => LocalTodosRepository();
/// Implementation of [TodosRepository] using Hive
class LocalTodosRepository implements TodosRepository {
static const String _boxName = 'todos';
Future<Box<Todo>> _openBox() async {
try {
if (Hive.isBoxOpen(_boxName)) {
return Hive.box<Todo>(_boxName);
}
return await Hive.openBox<Todo>(_boxName);
} catch (e) {
throw TodoException('Failed to open todos box', e);
}
}
Future<List<Todo>> getTodos() async {
try {
final box = await _openBox();
return box.values.toList();
} catch (e) {
throw TodoException('Failed to get todos', e);
}
}
Future<void> addTodo(Todo todo) async {
try {
final box = await _openBox();
await box.put(todo.id, todo);
} catch (e) {
throw TodoException('Failed to add todo', e);
}
}
Future<void> updateTodo(Todo todo) async {
try {
final box = await _openBox();
if (!box.containsKey(todo.id)) {
throw TodoException('Todo not found');
}
await box.put(todo.id, todo);
} catch (e) {
throw TodoException('Failed to update todo', e);
}
}
Future<void> deleteTodo(Todo todo) async {
try {
final box = await _openBox();
if (!box.containsKey(todo.id)) {
throw TodoException('Todo not found');
}
await box.delete(todo.id);
} catch (e) {
throw TodoException('Failed to delete todo', e);
}
}
}
TIP
In a real-world application, you might want to move the TodoException
class and TodosRepository
interface into separate files to keep your repository clean and focused on data operations.
As this is just for demonstration purposes, I'll keep it in the same file for now.
AsyncNotifierProvider
Since now we are dealing with async operations in TodosRepository
, we need to convert our TodosProvider
from NotifierProvider
to use AsyncNotifierProvider
.
Here are the key changes we need to make:
- Replace static
initialTodos
with repository data fetching inbuild()
- Change the return type of
build()
fromList<Todo>
toFutureOr<List<Todo>>
. - Update the state type to handle async operations (
AsyncValue<List<Todo>>
) - Add error handling using
AsyncValue.guard()
- Set loading states before async operations
- Update the UI to handle loading and error states
Let's implement these changes:
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:riverpod_todo/data/models/todo.dart';
import 'package:riverpod_todo/data/repositories/todos_repository.dart';
part 'todos_provider.g.dart';
/// Filter for todos
enum TodosFilter { all, completed, active }
class Todos extends _$Todos {
late final TodosRepository repository;
TodosFilter _filter = TodosFilter.all;
List<Todo> _allTodos = [];
TodosFilter get currentFilter => _filter;
FutureOr<List<Todo>> build() async {
repository = ref.watch(todosRepositoryProvider);
// Store the full list
_allTodos = await repository.getTodos();
// Return filtered list
return _getFilteredTodos();
}
/// Returns the filtered list of todos
List<Todo> _getFilteredTodos() {
switch (_filter) {
case TodosFilter.completed:
return _allTodos.where((todo) => todo.completed).toList();
case TodosFilter.active:
return _allTodos.where((todo) => !todo.completed).toList();
case TodosFilter.all:
return _allTodos;
}
}
void setFilter(TodosFilter filter) {
_filter = filter;
// Just update the state with newly filtered list
state = AsyncValue.data(_getFilteredTodos());
}
Future<void> addTodo(String title) async {
final todo = Todo(
id: DateTime.now().toIso8601String(),
title: title,
);
state = const AsyncLoading();
state = await AsyncValue.guard(() async {
await repository.addTodo(todo);
_allTodos = await repository.getTodos();
return _getFilteredTodos();
});
}
Future<void> toggleTodo(String id) async {
state = const AsyncLoading();
state = await AsyncValue.guard(() async {
final Todo todo = _allTodos.firstWhere((t) => t.id == id);
final updatedTodo = todo.copyWith(completed: !todo.completed);
await repository.updateTodo(updatedTodo);
_allTodos = await repository.getTodos();
return _getFilteredTodos();
});
}
Future<void> removeTodo(String id) async {
state = const AsyncLoading();
state = await AsyncValue.guard(() async {
final Todo todo = _allTodos.firstWhere((t) => t.id == id);
await repository.deleteTodo(todo);
_allTodos = await repository.getTodos();
return _getFilteredTodos();
});
}
}
Let's walk through the key changes and concepts in converting our TodosProvider
to handle asynchronous operations.
State Type Change
FutureOr<List<Todo>> build() async {
repository = ref.watch(todosRepositoryProvider);
return repository.getTodos();
}
The most significant change is in our state type. Instead of returning List<Todo>
directly, we now return FutureOr<List<Todo>>
.
This tells Riverpod that our state will be wrapped in an AsyncValue
, giving us three possible states:
AsyncData
: When we have successfully loaded todosAsyncLoading
: During operationsAsyncError
: When something goes wrong
TIP
Using FutureOr
instead of just Future
gives us flexibility to return either synchronous or asynchronous values, which can be helpful during testing or when implementing caching later.
AsyncValue.guard Pattern
state = await AsyncValue.guard(() async {
await repository.addTodo(todo);
return repository.getTodos();
});
This is one of my favorite Riverpod patterns. As we've discussed in previous article of this series, AsyncValue.guard
automatically:
- Catches any errors during the operation
- Wraps the result in the appropriate
AsyncValue
state - Handles error reporting consistently
IMPORTANT
Always set state to AsyncLoading
before starting an operation. This ensures your UI can show loading indicators appropriately:
state = const AsyncLoading();
Optimistic Updates
You might have noticed that after each operation, we're reloading the entire todo list:
await repository.addTodo(todo);
return repository.getTodos(); // Reload everything
I've intentionally chosen this approach for now to keep our focus on understanding AsyncNotifierProvider
and its core concepts simple.
In real-world applications, reloading the entire list after each operation can lead to:
- Unnecessary network calls (if using a remote API)
- Brief UI flickers during reloads
- Less responsive user experience
In the Enhancements section below, we'll implement optimistic updates - a pattern where we update the UI immediately while the operation happens in the background.
This creates a much smoother user experience while maintaining data integrity.
For now, let's continue with this simpler approach to solidify our understanding of async state management in Riverpod.
Updating the UI
Both our TodosProvider
and TodosRepository
are now ready, let's update our UI to handle all the async states.
The only thing we need to change in our UI is body
to handle the AsyncValue
states.
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:riverpod_ultimate_guide/features/todos/providers/todos_provider.dart';
import 'package:riverpod_ultimate_guide/features/todos/view/add_todo_dialog.dart';
class TodosScreen extends ConsumerWidget {
const TodosScreen({super.key});
Widget build(BuildContext context, WidgetRef ref) {
final todos = ref.watch(todosProvider);
final currentFilter = ref.watch(todosProvider.notifier).currentFilter;
return Scaffold(
appBar: AppBar(
title: const Text('Todos'),
actions: [
PopupMenuButton<TodosFilter>(
icon: const Icon(Icons.filter_list),
// We are mapping over the TodosFilter enum and creating a PopupMenuItem for each filter
itemBuilder: (context) => TodosFilter.values.map((filter) {
return PopupMenuItem(
value: filter,
child: Text(
filter.name.toUpperCase(),
style: TextStyle(
color: currentFilter == filter
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.onSurface,
),
),
);
}).toList(),
onSelected: (filter) {
// We are setting the current filter to the filter we are currently iterating over
ref.read(todosProvider.notifier).setFilter(filter);
},
),
],
),
- body: ListView.builder(
- itemCount: todos.length,
- itemBuilder: (context, index) {
- final todo = todos[index];
- return ListTile(
- leading: Checkbox(
- value: todo.completed,
- onChanged: (_) {
- ref.read(todosProvider.notifier).toggleTodo(todo.id);
- },
- ),
- title: Text(
- todo.title,
- style: TextStyle(
- decoration: todo.completed ? TextDecoration.lineThrough : null,
- ),
- ),
- trailing: IconButton(
- onPressed: () {
- ref.read(todosProvider.notifier).removeTodo(todo.id);
- },
- icon: const Icon(Icons.delete_outline),
- ),
- );
- },
- ),
+ body: todos.when(
+ loading: () => const Center(child: CircularProgressIndicator()),
+ error: (error, stackTrace) => Center(
+ child: Text('Error: ${error.toString()}'),
+ ),
+ data: (filteredTodos) => ListView.builder(
+ itemCount: filteredTodos.length,
+ itemBuilder: (context, index) {
+ final todo = filteredTodos[index];
+ return ListTile(
+ leading: Checkbox(
+ value: todo.completed,
+ onChanged: (_) {
+ ref.read(todosProvider.notifier).toggleTodo(todo.id);
+ },
+ ),
+ title: Text(
+ todo.title,
+ style: TextStyle(
+ decoration: todo.completed ? TextDecoration.lineThrough : null,
+ ),
+ ),
+ trailing: IconButton(
+ onPressed: () {
+ ref.read(todosProvider.notifier).removeTodo(todo.id);
+ },
+ icon: const Icon(Icons.delete_outline),
+ ),
+ );
+ },
+ ),
+ ),
floatingActionButton: FloatingActionButton(
onPressed: () {
showDialog(
context: context,
builder: (context) => const AddTodoDialog(),
);
},
child: const Icon(Icons.add),
),
);
}
}
.when()
Handling Async States with body: todos.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, stackTrace) => Center(
child: Text('Error: ${error.toString()}'),
),
data: (filteredTodos) => ListView.builder(...)
),
The biggest change is how we handle different states of our todos data. Instead of directly using the todos list, we now use the .when()
method to handle three possible situations:
- Loading: Shows a
CircularProgressIndicator
while we're waiting for the todos to load - Error: Shows an error message if something goes wrong
- Data: Shows the actual list of todos when we have the data
This approach makes our app more robust and user-friendly by:
- Always showing something to the user (no blank screens)
- Clearly communicating what's happening
- Handling errors gracefully
Throughout this tutorial, we've seen Riverpod's elegant approach to state management in Flutter.
Starting with a simple in-memory implementation and evolving to a full-featured app with persistence, we've demonstrated how Riverpod scales with your needs.
The transition from basic state to async operations required minimal changes - exactly what we want from a state management solution.
Simple things stay simple, complex things become possible.
While our Todo app is fully functional, let's explore some advanced patterns to level it up.
Enhancements
Let's explore some common patterns you'll encounter in real-world applications and see how we can implement them in our Todo app:
- Optimistic Updates: Update the UI immediately without waiting for the backend operation to complete
- Pull-to-Refresh: Allow users to manually refresh the todos list
- Error Retry Logic: Handle errors gracefully and provide retry options
Let's dive into each enhancement and see how Riverpod helps us implement them cleanly.
Optimistic Updates
Let me explain optimistic updates in simple terms first:
When you "like" a post on social media, the like count increases immediately.
The app doesn't make you wait for the server to confirm - it updates the UI right away while sending the request in the background.
Benefits of this approach:
- The app feels faster and more responsive
- Users don't have to wait to see their actions take effect
- In most cases, operations succeed anyway, so why make users wait?
In our current implementation of TodosProvider
, we don't have any optimistic updates.
For example, when the user adds a todo, we call the addTodo
method of TodosRepository
, wait for the whole operation to complete and then reload the entire todo list to update the UI. This is simple but definitely not efficient.
The correct approach would be to update the UI immediately while the async operation happens in the background.
This creates a much smoother user experience while maintaining data integrity.
Let's see an implementation of "optimistic updates" with the addTodo
method, as it's the simplest to understand:
Future<void> addTodo(String title) async {
// Create the new todo
final todo = Todo(
id: DateTime.now().toIso8601String(),
title: title,
);
// Immediately update the local state (optimistically)
_allTodos = [..._allTodos, todo];
state = AsyncValue.data(_getFilteredTodos());
// Perform the actual server operation
state = await AsyncValue.guard(() async {
await repository.addTodo(todo);
// No need to refresh the entire list if success
return _getFilteredTodos();
});
// If the operation failed, revert the optimistic update
if (state.hasError) {
_allTodos.removeLast();
state = AsyncValue.data(_getFilteredTodos());
}
}
What changed?
- We update the local state immediately (optimistically)
- Then we try to perform the actual server operation
- If it fails, we revert the change, the UI will be reverted to the previous state automatically and the error message from the
TodosRepository
- "Failed to add todo" will be shown. - We removed the unnecessary
getTodos()
call since we already have the updated data
Now the user sees their new todo item added immediately, instead of seeing a loading state and waiting for the async operation to complete.
This makes the app feel much more responsive and provides a better user experience.
Here is the complete implementation of TodosProvider
with optimistic updates:
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:riverpod_todo/data/models/todo.dart';
import 'package:riverpod_todo/data/repositories/todos_repository.dart';
part 'todos_provider.g.dart';
/// Filter for todos
enum TodosFilter { all, completed, active }
class Todos extends _$Todos {
late final TodosRepository repository;
TodosFilter _filter = TodosFilter.all;
List<Todo> _allTodos = [];
TodosFilter get currentFilter => _filter;
FutureOr<List<Todo>> build() async {
repository = ref.watch(todosRepositoryProvider);
// Store the full list
_allTodos = await repository.getTodos();
// Return filtered list
return _getFilteredTodos();
}
/// Returns the filtered list of todos
List<Todo> _getFilteredTodos() {
switch (_filter) {
case TodosFilter.completed:
return _allTodos.where((todo) => todo.completed).toList();
case TodosFilter.active:
return _allTodos.where((todo) => !todo.completed).toList();
case TodosFilter.all:
return _allTodos;
}
}
void setFilter(TodosFilter filter) {
_filter = filter;
// Just update the state with newly filtered list
state = AsyncValue.data(_getFilteredTodos());
}
Future<void> addTodo(String title) async {
final todo = Todo(
id: DateTime.now().toIso8601String(),
title: title,
);
// Optimistically update the local state
_allTodos = [..._allTodos, todo];
state = AsyncValue.data(_getFilteredTodos());
state = await AsyncValue.guard(() async {
await repository.addTodo(todo);
return _getFilteredTodos();
});
if (state.hasError) {
_allTodos.removeLast();
state = AsyncValue.data(_getFilteredTodos());
}
}
Future<void> toggleTodo(String id) async {
// Find and update the todo in local state
final todoIndex = _allTodos.indexWhere((t) => t.id == id);
final todo = _allTodos[todoIndex];
final updatedTodo = todo.copyWith(completed: !todo.completed);
// Optimistically update the local state
_allTodos[todoIndex] = updatedTodo;
state = AsyncValue.data(_getFilteredTodos());
state = await AsyncValue.guard(() async {
await repository.updateTodo(updatedTodo);
return _getFilteredTodos();
});
if (state.hasError) {
// Revert to original state if operation failed
_allTodos[todoIndex] = todo;
state = AsyncValue.data(_getFilteredTodos());
}
}
Future<void> removeTodo(String id) async {
// Find the todo and its index
final todoIndex = _allTodos.indexWhere((t) => t.id == id);
final todo = _allTodos[todoIndex];
// Optimistically update the local state
_allTodos.removeAt(todoIndex);
state = AsyncValue.data(_getFilteredTodos());
state = await AsyncValue.guard(() async {
await repository.deleteTodo(todo);
return _getFilteredTodos();
});
if (state.hasError) {
// Revert to original state if operation failed
_allTodos.insert(todoIndex, todo);
state = AsyncValue.data(_getFilteredTodos());
}
}
}
Pull-to-Refresh
Pull-to-refresh is a common mobile UI pattern where users can drag down from the top of a scrollable list to refresh its contents.
This gesture-based interaction provides several UX benefits:
- Data Freshness: Users can manually sync the app with the latest data
- User Control: Gives users explicit control over when to refresh
- Intuitive: The gesture has become a standard pattern that users naturally expect
- Visual Feedback: The refresh indicator provides clear visual feedback during the update
Let's see how we can implement pull-to-refresh in our app using Riverpod.
- Add a
refresh
method to theTodosProvider
/// Refreshes the todos list from the repository
Future<void> refresh() async {
// Keep previous state(data or error) while loading
state = const AsyncLoading<List<Todo>>().copyWithPrevious(state);
state = await AsyncValue.guard(() async {
_allTodos = await repository.getTodos();
return _getFilteredTodos();
});
}
- Wrap the entire
todos.when()
with aRefreshIndicator
body: RefreshIndicator(
onRefresh: () => ref.read(todosProvider.notifier).refresh(),
child: todos.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, stackTrace) => Center(child: Text('Error: ${error.toString()}')),
data: (filteredTodos) => ListView.builder(
// ... existing ListView.builder code ...
),
),
),
Key Implementation Details:
- The
refresh()
method usesAsyncLoading().copyWithPrevious()
to keep showing the previous state(data or error) while refreshing - During refresh, previous state remains visible while the refresh indicator shows progress
- We wrap the complete
todos.when()
withRefreshIndicator
as it allows pull-to-refresh even when in an error state, giving users a way to recover from errors. - And it maintains a consistent refresh behavior across all states
Adding Error Retry Logic
Error retry logic allows users to attempt failed operations again without losing context or having to restart the app.
Let's see how we can implement error retry logic in our app using Riverpod.
In this example, I will show you how to implement error retry logic for the todos list.
As an exercise, you can try to implement it for other methods like addTodo
, toggleTodo
and removeTodo
.
- Create an Error Widget with a Retry Button
We can create a simple error widget with a retry button that will call the refresh()
method when pressed.
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:riverpod_todo/features/todos/providers/todos_provider.dart';
class TodoListErrorWidget extends ConsumerWidget {
const TodoListErrorWidget({super.key});
Widget build(BuildContext context, WidgetRef ref) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text('Failed to load todos'),
const SizedBox(height: 16),
ElevatedButton.icon(
// we can use the refresh method for retry
onPressed: () => ref.read(todosProvider.notifier).refresh(),
icon: const Icon(Icons.refresh),
label: const Text('Retry'),
),
],
),
);
}
}
- Use the Error Widget in the
todos.when()
todos.when(
loading: () => const Center(child: CircularProgressIndicator()),
- error: (error, stackTrace) => Center(child: Text('Error: ${error.toString()}')),
+ error: (error, stackTrace) => const TodoListErrorWidget(),
data: (filteredTodos) => ListView.builder(
// ... existing ListView.builder code ...
),
),
Thats it guys! Hope you enjoyed this Riverpod series and are confident to use Riverpod in your next project.
Happy coding!