Master Riverpod in Flutter - Part 2: Building a Todo App [2025]

Ayaan Haaris
AUTHOR
Ayaan Haaris

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 package
  • riverpod_annotation: For code generation support
  • build_runner and riverpod_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.

lib/data/models/todo.dart
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:

  1. Immutability: Notice how all fields are final. This is intentional:

    • Prevents accidental modifications
    • Makes state changes explicit
    • Helps with debugging
  2. 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:

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: Uses DateTime.now().toIso8601String() to generate a unique identifier based on the current timestamp
  • title: 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.

lib/features/todos/todos_screen.dart
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 of Consumer 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:

lib/main.dart
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.

Phone Screenshot

Toggle Todo

Right now, we only display the todos, let's add the functionality to toggle them.

Update your TodosScreen with these changes:

todos_screen.dart
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.

Phone Screenshot

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.

todos_screen.dart
  
  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.

Phone Screenshot

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:

add_todo_dialog.dart
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:

  1. Check if the text is not empty
  2. Add the todo using the provider
  3. Close the dialog

Now, let's wire up the dialog to our floating action button in the TodosScreen:

todos_screen.dart
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.

Phone Screenshot

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:

todos_provider.dart
/// 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:

todos_provider.dart

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:

todos_provider.dart
/// 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:

todos_provider.dart
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:

  1. The new filter is stored for future use
  2. 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:

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),
];

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

todos_screen.dart
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 filter PopupMenuItem.
  • We added a PopupMenuButton in the AppBar 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.

Phone Screenshot
Phone Screenshot

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 and hive_flutter: The core Hive packages for local storage
  • path_provider: Required by Hive to access the device's local storage directories securely
  • hive_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.

lib/data/models/todo.dart
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:

main.dart
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:

  1. Better user experience: Users see clear, understandable error messages
  2. Easier error handling: Our UI code can focus on handling just one type of exception
  3. Consistent error messages: We maintain a consistent style of error messages throughout the app
  4. 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:

todos_repository.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)' : ''}';
}

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:

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

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

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

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

todos_repository.dart
/// 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.

todos_repository.dart
/// 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:

  1. We define a constant _boxName = 'todos' - think of a Hive "box" like a table in a database. This is where we'll store all our todos.

  2. 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 it
  • addTodo(): Opens the box and stores a new todo using its ID as the key
  • updateTodo(): First checks if the todo exists, then updates it
  • deleteTodo(): 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

  1. Error Handling: By converting all errors to TodoException, we make error handling consistent throughout our app.
  2. 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.
  3. 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.

todos_repository.dart
(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:

todos_repository.dart
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 in build()
  • Change the return type of build() from List<Todo> to FutureOr<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:

todos_provider.dart
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 todos
  • AsyncLoading: During operations
  • AsyncError: 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.

todos_screen.dart
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),
      ),
    );
  }
}

Handling Async States with .when()

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:

  1. The app feels faster and more responsive
  2. Users don't have to wait to see their actions take effect
  3. 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:

todos_provider.dart
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:

todos_provider.dart
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.

  1. Add a refresh method to the TodosProvider
todos_provider.dart
/// 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();
  });
}
  1. Wrap the entire todos.when() with a RefreshIndicator
todos_screen.dart
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 uses AsyncLoading().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() with RefreshIndicator 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.

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

features/todos/views/widgets/todos_list_error_widget.dart
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'),
          ),
        ],
      ),
    );
  }
}
  1. Use the Error Widget in the todos.when()
todos_screen.dart
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!