In this comprehensive guide, we’ll build a fully-functional Todo application using three different state management approaches. By the end, you’ll have a clear understanding of how each solution structures a real-world project and when to choose which one.
Project Overview
We’re building a Todo app with the following features:
- Add new todos
- Mark todos as complete/incomplete
- Filter todos (All, Active, Completed)
- Dark/light theme toggle
- Persistence (mock API/service)
- Error handling
Let’s start with the shared foundation, then explore each implementation.
Project Structure
lib/ │ ├── core/ # Shared across all versions │ ├── models/ │ │ └── todo.dart │ ├── services/ │ │ └── todo_service.dart │ ├── utils/ │ │ └── constants.dart │ └── widgets/ │ ├── todo_item.dart │ └── loading_indicator.dart │ ├── provider_version/ # Provider implementation │ ├── providers/ │ │ ├── todo_provider.dart │ │ └── theme_provider.dart │ ├── screens/ │ │ ├── home_screen.dart │ │ └── add_todo_screen.dart │ └── provider_app.dart │ ├── riverpod_version/ # Riverpod implementation │ ├── providers/ │ │ ├── todo_provider.dart │ │ ├── theme_provider.dart │ │ └── filter_provider.dart │ ├── screens/ │ │ ├── home_screen.dart │ │ └── add_todo_screen.dart │ └── riverpod_app.dart │ ├── bloc_version/ # Bloc implementation │ ├── bloc/ │ │ ├── todo/ │ │ │ ├── todo_bloc.dart │ │ │ ├── todo_event.dart │ │ │ └── todo_state.dart │ │ └── theme/ │ │ ├── theme_bloc.dart │ │ ├── theme_event.dart │ │ └── theme_state.dart │ ├── screens/ │ │ ├── home_screen.dart │ │ └── add_todo_screen.dart │ └── bloc_app.dart │ └── main.dart # Entry point to choose version
Shared Core Files
First, let’s create the shared foundation that all three versions will use.
1. lib/core/models/todo.dart
class Todo {
final String id;
final String title;
final String description;
final bool isCompleted;
final DateTime createdAt;
Todo({
required this.id,
required this.title,
required this.description,
this.isCompleted = false,
required this.createdAt,
});
Todo copyWith({
String? id,
String? title,
String? description,
bool? isCompleted,
DateTime? createdAt,
}) {
return Todo(
id: id ?? this.id,
title: title ?? this.title,
description: description ?? this.description,
isCompleted: isCompleted ?? this.isCompleted,
createdAt: createdAt ?? this.createdAt,
);
}
Map<String, dynamic> toMap() {
return {
'id': id,
'title': title,
'description': description,
'isCompleted': isCompleted,
'createdAt': createdAt.toIso8601String(),
};
}
factory Todo.fromMap(Map<String, dynamic> map) {
return Todo(
id: map['id'],
title: map['title'],
description: map['description'],
isCompleted: map['isCompleted'],
createdAt: DateTime.parse(map['createdAt']),
);
}
}
2. lib/core/services/todo_service.dart
import 'package:flutter/foundation.dart';
import '../models/todo.dart';
class TodoService {
// Simulate network delay
Future<void> _simulateDelay() async {
await Future.delayed(const Duration(milliseconds: 500));
}
// Mock API call to fetch todos
Future<List<Todo>> fetchTodos() async {
await _simulateDelay();
// Simulate random errors (10% chance)
if (DateTime.now().millisecond % 10 == 0) {
throw Exception('Failed to fetch todos');
}
// Return mock data
return [
Todo(
id: '1',
title: 'Learn Flutter',
description: 'Complete state management tutorial',
isCompleted: true,
createdAt: DateTime.now().subtract(const Duration(days: 2)),
),
Todo(
id: '2',
title: 'Write blog post',
description: 'Compare Provider, Riverpod, and Bloc',
isCompleted: false,
createdAt: DateTime.now().subtract(const Duration(days: 1)),
),
];
}
// Mock API call to add a todo
Future<Todo> addTodo(String title, String description) async {
await _simulateDelay();
return Todo(
id: DateTime.now().millisecondsSinceEpoch.toString(),
title: title,
description: description,
isCompleted: false,
createdAt: DateTime.now(),
);
}
// Mock API call to update a todo
Future<Todo> updateTodo(Todo todo) async {
await _simulateDelay();
return todo;
}
// Mock API call to delete a todo
Future<void> deleteTodo(String id) async {
await _simulateDelay();
}
}
3. lib/core/widgets/todo_item.dart
import 'package:flutter/material.dart';
import '../models/todo.dart';
class TodoItem extends StatelessWidget {
final Todo todo;
final VoidCallback onToggle;
final VoidCallback onDelete;
final VoidCallback onTap;
const TodoItem({
Key? key,
required this.todo,
required this.onToggle,
required this.onDelete,
required this.onTap,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Card(
elevation: 2,
margin: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
child: ListTile(
leading: IconButton(
icon: Icon(
todo.isCompleted ? Icons.check_circle : Icons.radio_button_unchecked,
color: todo.isCompleted ? Colors.green : Colors.grey,
),
onPressed: onToggle,
),
title: Text(
todo.title,
style: TextStyle(
decoration: todo.isCompleted ? TextDecoration.lineThrough : null,
fontWeight: FontWeight.bold,
),
),
subtitle: Text(
todo.description,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
trailing: IconButton(
icon: const Icon(Icons.delete, color: Colors.red),
onPressed: onDelete,
),
onTap: onTap,
),
);
}
}
Implementation 1: Provider Version
Provider is the simplest approach, perfect for those who want straightforward state management.
1. lib/provider_version/providers/todo_provider.dart
import 'package:flutter/foundation.dart';
import '../../core/models/todo.dart';
import '../../core/services/todo_service.dart';
class TodoProvider with ChangeNotifier {
final TodoService _todoService = TodoService();
List<Todo> _todos = [];
bool _isLoading = false;
String? _error;
List<Todo> get todos => _todos;
bool get isLoading => _isLoading;
String? get error => _error;
List<Todo> get activeTodos => _todos.where((todo) => !todo.isCompleted).toList();
List<Todo> get completedTodos => _todos.where((todo) => todo.isCompleted).toList();
Future<void> loadTodos() async {
_isLoading = true;
_error = null;
notifyListeners();
try {
_todos = await _todoService.fetchTodos();
_error = null;
} catch (e) {
_error = 'Failed to load todos: $e';
_todos = [];
} finally {
_isLoading = false;
notifyListeners();
}
}
Future<void> addTodo(String title, String description) async {
_isLoading = true;
notifyListeners();
try {
final newTodo = await _todoService.addTodo(title, description);
_todos = [newTodo, ..._todos];
_error = null;
} catch (e) {
_error = 'Failed to add todo: $e';
} finally {
_isLoading = false;
notifyListeners();
}
}
Future<void> toggleTodo(String id) async {
final index = _todos.indexWhere((todo) => todo.id == id);
if (index != -1) {
final updatedTodo = _todos[index].copyWith(
isCompleted: !_todos[index].isCompleted,
);
_isLoading = true;
notifyListeners();
try {
final savedTodo = await _todoService.updateTodo(updatedTodo);
_todos[index] = savedTodo;
_error = null;
} catch (e) {
_error = 'Failed to update todo: $e';
} finally {
_isLoading = false;
notifyListeners();
}
}
}
Future<void> deleteTodo(String id) async {
_isLoading = true;
notifyListeners();
try {
await _todoService.deleteTodo(id);
_todos.removeWhere((todo) => todo.id == id);
_error = null;
} catch (e) {
_error = 'Failed to delete todo: $e';
} finally {
_isLoading = false;
notifyListeners();
}
}
void clearError() {
_error = null;
notifyListeners();
}
}
2. lib/provider_version/providers/theme_provider.dart
import 'package:flutter/material.dart';
class ThemeProvider with ChangeNotifier {
ThemeMode _themeMode = ThemeMode.light;
ThemeMode get themeMode => _themeMode;
void toggleTheme() {
_themeMode = _themeMode == ThemeMode.light
? ThemeMode.dark
: ThemeMode.light;
notifyListeners();
}
void setTheme(ThemeMode mode) {
_themeMode = mode;
notifyListeners();
}
}
3. lib/provider_version/screens/home_screen.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/todo_provider.dart';
import '../providers/theme_provider.dart';
import '../../../core/widgets/todo_item.dart';
class HomeScreen extends StatefulWidget {
const HomeScreen({Key? key}) : super(key: key);
@override
State<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen> {
String _filter = 'all';
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
context.read<TodoProvider>().loadTodos();
});
}
List<Todo> _getFilteredTodos(TodoProvider provider) {
switch (_filter) {
case 'active':
return provider.activeTodos;
case 'completed':
return provider.completedTodos;
default:
return provider.todos;
}
}
@override
Widget build(BuildContext context) {
final todoProvider = context.watch<TodoProvider>();
final themeProvider = context.watch<ThemeProvider>();
final filteredTodos = _getFilteredTodos(todoProvider);
return Scaffold(
appBar: AppBar(
title: const Text('Todo App (Provider)'),
actions: [
IconButton(
icon: Icon(themeProvider.themeMode == ThemeMode.dark
? Icons.light_mode
: Icons.dark_mode),
onPressed: () => themeProvider.toggleTheme(),
),
],
),
body: todoProvider.isLoading && todoProvider.todos.isEmpty
? const Center(child: CircularProgressIndicator())
: Column(
children: [
Padding(
padding: const EdgeInsets.all(16.0),
child: SegmentedButton<String>(
segments: const [
ButtonSegment(value: 'all', label: Text('All')),
ButtonSegment(value: 'active', label: Text('Active')),
ButtonSegment(value: 'completed', label: Text('Completed')),
],
selected: {_filter},
onSelectionChanged: (Set<String> newSelection) {
setState(() {
_filter = newSelection.first;
});
},
),
),
Expanded(
child: ListView.builder(
itemCount: filteredTodos.length,
itemBuilder: (context, index) {
final todo = filteredTodos[index];
return TodoItem(
todo: todo,
onToggle: () => todoProvider.toggleTodo(todo.id),
onDelete: () => todoProvider.deleteTodo(todo.id),
onTap: () {
// Navigate to detail/edit screen
},
);
},
),
),
if (todoProvider.error != null)
Padding(
padding: const EdgeInsets.all(8.0),
child: Card(
color: Colors.red.shade50,
child: ListTile(
leading: const Icon(Icons.error, color: Colors.red),
title: Text(todoProvider.error!),
trailing: IconButton(
icon: const Icon(Icons.close),
onPressed: todoProvider.clearError,
),
),
),
),
],
),
floatingActionButton: FloatingActionButton(
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => AddTodoScreen(),
),
);
},
child: const Icon(Icons.add),
),
);
}
}
4. lib/provider_version/screens/add_todo_screen.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/todo_provider.dart';
class AddTodoScreen extends StatefulWidget {
const AddTodoScreen({Key? key}) : super(key: key);
@override
State<AddTodoScreen> createState() => _AddTodoScreenState();
}
class _AddTodoScreenState extends State<AddTodoScreen> {
final _formKey = GlobalKey<FormState>();
final _titleController = TextEditingController();
final _descriptionController = TextEditingController();
@override
void dispose() {
_titleController.dispose();
_descriptionController.dispose();
super.dispose();
}
Future<void> _submit() async {
if (_formKey.currentState!.validate()) {
final provider = context.read<TodoProvider>();
await provider.addTodo(
_titleController.text,
_descriptionController.text,
);
if (provider.error == null) {
Navigator.pop(context);
}
}
}
@override
Widget build(BuildContext context) {
final todoProvider = context.watch<TodoProvider>();
return Scaffold(
appBar: AppBar(
title: const Text('Add Todo'),
),
body: Padding(
padding: const EdgeInsets.all(16.0),
child: Form(
key: _formKey,
child: Column(
children: [
TextFormField(
controller: _titleController,
decoration: const InputDecoration(
labelText: 'Title',
border: OutlineInputBorder(),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter a title';
}
return null;
},
),
const SizedBox(height: 16),
TextFormField(
controller: _descriptionController,
decoration: const InputDecoration(
labelText: 'Description',
border: OutlineInputBorder(),
),
maxLines: 3,
),
const SizedBox(height: 32),
if (todoProvider.isLoading)
const CircularProgressIndicator()
else
ElevatedButton(
onPressed: _submit,
child: const Text('Add Todo'),
),
if (todoProvider.error != null)
Padding(
padding: const EdgeInsets.only(top: 16.0),
child: Text(
todoProvider.error!,
style: const TextStyle(color: Colors.red),
),
),
],
),
),
),
);
}
}
5. lib/provider_version/provider_app.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import './providers/todo_provider.dart';
import './providers/theme_provider.dart';
import './screens/home_screen.dart';
class ProviderApp extends StatelessWidget {
const ProviderApp({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return MultiProvider(
providers: [
ChangeNotifierProvider(create: (_) => TodoProvider()),
ChangeNotifierProvider(create: (_) => ThemeProvider()),
],
child: Consumer<ThemeProvider>(
builder: (context, themeProvider, child) {
return MaterialApp(
title: 'Todo App (Provider)',
theme: ThemeData.light(),
darkTheme: ThemeData.dark(),
themeMode: themeProvider.themeMode,
home: const HomeScreen(),
debugShowCheckedModeBanner: false,
);
},
),
);
}
}
Implementation 2: Riverpod Version
Riverpod offers compile-time safety and better testability while keeping a similar mental model to Provider.
1. lib/riverpod_version/providers/todo_provider.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/models/todo.dart';
import '../../core/services/todo_service.dart';
class TodoNotifier extends StateNotifier<AsyncValue<List<Todo>>> {
final TodoService _todoService;
TodoNotifier(this._todoService) : super(const AsyncValue.loading()) {
loadTodos();
}
Future<void> loadTodos() async {
state = const AsyncValue.loading();
state = await AsyncValue.guard(() => _todoService.fetchTodos());
}
Future<void> addTodo(String title, String description) async {
final previousState = state;
try {
// Optimistic update
if (previousState.hasValue) {
final newTodo = await _todoService.addTodo(title, description);
state = AsyncValue.data([newTodo, ...previousState.value!]);
}
} catch (e, stackTrace) {
state = AsyncValue.error(e, stackTrace);
}
}
Future<void> toggleTodo(String id) async {
final previousState = state;
if (!previousState.hasValue) return;
try {
final todos = previousState.value!;
final index = todos.indexWhere((todo) => todo.id == id);
if (index != -1) {
final updatedTodo = todos[index].copyWith(
isCompleted: !todos[index].isCompleted,
);
final savedTodo = await _todoService.updateTodo(updatedTodo);
final newTodos = List<Todo>.from(todos);
newTodos[index] = savedTodo;
state = AsyncValue.data(newTodos);
}
} catch (e, stackTrace) {
state = AsyncValue.error(e, stackTrace);
}
}
Future<void> deleteTodo(String id) async {
final previousState = state;
if (!previousState.hasValue) return;
try {
await _todoService.deleteTodo(id);
final newTodos = previousState.value!
.where((todo) => todo.id != id)
.toList();
state = AsyncValue.data(newTodos);
} catch (e, stackTrace) {
state = AsyncValue.error(e, stackTrace);
}
}
}
// Providers
final todoServiceProvider = Provider<TodoService>((ref) => TodoService());
final todoProvider = StateNotifierProvider<TodoNotifier, AsyncValue<List<Todo>>>(
(ref) => TodoNotifier(ref.watch(todoServiceProvider)),
);
final filteredTodoProvider = Provider.family<List<Todo>, String>((ref, filter) {
final todosAsync = ref.watch(todoProvider);
return todosAsync.when(
data: (todos) {
switch (filter) {
case 'active':
return todos.where((todo) => !todo.isCompleted).toList();
case 'completed':
return todos.where((todo) => todo.isCompleted).toList();
default:
return todos;
}
},
loading: () => [],
error: (_, __) => [],
);
});
final activeTodoCountProvider = Provider<int>((ref) {
final todosAsync = ref.watch(todoProvider);
return todosAsync.when(
data: (todos) => todos.where((todo) => !todo.isCompleted).length,
loading: () => 0,
error: (_, __) => 0,
);
});
2. lib/riverpod_version/providers/theme_provider.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
final themeProvider = StateProvider<ThemeMode>((ref) => ThemeMode.light);
final themeNotifierProvider = NotifierProvider<ThemeNotifier, ThemeMode>(
ThemeNotifier.new,
);
class ThemeNotifier extends Notifier<ThemeMode> {
@override
ThemeMode build() {
return ThemeMode.light;
}
void toggleTheme() {
state = state == ThemeMode.light ? ThemeMode.dark : ThemeMode.light;
}
void setTheme(ThemeMode mode) {
state = mode;
}
}
3. lib/riverpod_version/providers/filter_provider.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
enum TodoFilter { all, active, completed }
final filterProvider = StateProvider<TodoFilter>((ref) => TodoFilter.all);
4. lib/riverpod_version/screens/home_screen.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../providers/todo_provider.dart';
import '../providers/theme_provider.dart';
import '../providers/filter_provider.dart';
import '../../../core/widgets/todo_item.dart';
class HomeScreen extends ConsumerWidget {
const HomeScreen({Key? key}) : super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {
final filter = ref.watch(filterProvider);
final todosAsync = ref.watch(todoProvider);
final activeCount = ref.watch(activeTodoCountProvider);
return Scaffold(
appBar: AppBar(
title: const Text('Todo App (Riverpod)'),
actions: [
Consumer(
builder: (context, ref, child) {
final themeMode = ref.watch(themeNotifierProvider);
return IconButton(
icon: Icon(themeMode == ThemeMode.dark
? Icons.light_mode
: Icons.dark_mode),
onPressed: () => ref.read(themeNotifierProvider.notifier).toggleTheme(),
);
},
),
],
),
body: Column(
children: [
Padding(
padding: const EdgeInsets.all(16.0),
child: SegmentedButton<TodoFilter>(
segments: const [
ButtonSegment(value: TodoFilter.all, label: Text('All')),
ButtonSegment(value: TodoFilter.active, label: Text('Active')),
ButtonSegment(value: TodoFilter.completed, label: Text('Completed')),
],
selected: {filter},
onSelectionChanged: (Set<TodoFilter> newSelection) {
ref.read(filterProvider.notifier).state = newSelection.first;
},
),
),
Padding(
padding: const EdgeInsets.all(16.0),
child: Text('$activeCount items left', style: Theme.of(context).textTheme.titleMedium),
),
Expanded(
child: todosAsync.when(
data: (todos) {
final filteredTodos = switch (filter) {
TodoFilter.active => todos.where((todo) => !todo.isCompleted).toList(),
TodoFilter.completed => todos.where((todo) => todo.isCompleted).toList(),
TodoFilter.all => todos,
};
return ListView.builder(
itemCount: filteredTodos.length,
itemBuilder: (context, index) {
final todo = filteredTodos[index];
return TodoItem(
todo: todo,
onToggle: () => ref.read(todoProvider.notifier).toggleTodo(todo.id),
onDelete: () => ref.read(todoProvider.notifier).deleteTodo(todo.id),
onTap: () {
// Navigate to detail/edit screen
},
);
},
);
},
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, stackTrace) => Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.error, color: Colors.red, size: 48),
const SizedBox(height: 16),
Text('Error: $error'),
TextButton(
onPressed: () => ref.read(todoProvider.notifier).loadTodos(),
child: const Text('Retry'),
),
],
),
),
),
),
],
),
floatingActionButton: FloatingActionButton(
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const AddTodoScreen(),
),
);
},
child: const Icon(Icons.add),
),
);
}
}
5. lib/riverpod_version/screens/add_todo_screen.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../providers/todo_provider.dart';
class AddTodoScreen extends ConsumerStatefulWidget {
const AddTodoScreen({Key? key}) : super(key: key);
@override
ConsumerState<AddTodoScreen> createState() => _AddTodoScreenState();
}
class _AddTodoScreenState extends ConsumerState<AddTodoScreen> {
final _formKey = GlobalKey<FormState>();
final _titleController = TextEditingController();
final _descriptionController = TextEditingController();
@override
void dispose() {
_titleController.dispose();
_descriptionController.dispose();
super.dispose();
}
Future<void> _submit() async {
if (_formKey.currentState!.validate()) {
await ref.read(todoProvider.notifier).addTodo(
_titleController.text,
_descriptionController.text,
);
final todosAsync = ref.read(todoProvider);
if (!todosAsync.hasError) {
Navigator.pop(context);
}
}
}
@override
Widget build(BuildContext context) {
final todosAsync = ref.watch(todoProvider);
return Scaffold(
appBar: AppBar(
title: const Text('Add Todo'),
),
body: Padding(
padding: const EdgeInsets.all(16.0),
child: Form(
key: _formKey,
child: Column(
children: [
TextFormField(
controller: _titleController,
decoration: const InputDecoration(
labelText: 'Title',
border: OutlineInputBorder(),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter a title';
}
return null;
},
),
const SizedBox(height: 16),
TextFormField(
controller: _descriptionController,
decoration: const InputDecoration(
labelText: 'Description',
border: OutlineInputBorder(),
),
maxLines: 3,
),
const SizedBox(height: 32),
todosAsync.isLoading
? const CircularProgressIndicator()
: ElevatedButton(
onPressed: _submit,
child: const Text('Add Todo'),
),
if (todosAsync.hasError)
Padding(
padding: const EdgeInsets.only(top: 16.0),
child: Text(
todosAsync.error.toString(),
style: const TextStyle(color: Colors.red),
),
),
],
),
),
),
);
}
}
6. lib/riverpod_version/riverpod_app.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import './providers/theme_provider.dart';
import './screens/home_screen.dart';
class RiverpodApp extends ConsumerWidget {
const RiverpodApp({Key? key}) : super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {
final themeMode = ref.watch(themeNotifierProvider);
return MaterialApp(
title: 'Todo App (Riverpod)',
theme: ThemeData.light(),
darkTheme: ThemeData.dark(),
themeMode: themeMode,
home: const HomeScreen(),
debugShowCheckedModeBanner: false,
);
}
}
Implementation 3: Bloc Version
Bloc provides a strict separation of business logic, perfect for complex applications.
1. lib/bloc_version/bloc/todo/todo_event.dart
import 'package:equatable/equatable.dart';
abstract class TodoEvent extends Equatable {
const TodoEvent();
@override
List<Object> get props => [];
}
class LoadTodosEvent extends TodoEvent {}
class AddTodoEvent extends TodoEvent {
final String title;
final String description;
const AddTodoEvent({required this.title, required this.description});
@override
List<Object> get props => [title, description];
}
class ToggleTodoEvent extends TodoEvent {
final String id;
const ToggleTodoEvent({required this.id});
@override
List<Object> get props => [id];
}
class DeleteTodoEvent extends TodoEvent {
final String id;
const DeleteTodoEvent({required this.id});
@override
List<Object> get props => [id];
}
class FilterTodosEvent extends TodoEvent {
final TodoFilter filter;
const FilterTodosEvent({required this.filter});
@override
List<Object> get props => [filter];
}
class ClearErrorEvent extends TodoEvent {}
enum TodoFilter { all, active, completed }
2. lib/bloc_version/bloc/todo/todo_state.dart
import 'package:equatable/equatable.dart';
import '../../../../core/models/todo.dart';
class TodoState extends Equatable {
final List<Todo> todos;
final TodoFilter filter;
final bool isLoading;
final String? error;
const TodoState({
this.todos = const [],
this.filter = TodoFilter.all,
this.isLoading = false,
this.error,
});
List<Todo> get filteredTodos {
switch (filter) {
case TodoFilter.active:
return todos.where((todo) => !todo.isCompleted).toList();
case TodoFilter.completed:
return todos.where((todo) => todo.isCompleted).toList();
case TodoFilter.all:
default:
return todos;
}
}
int get activeCount => todos.where((todo) => !todo.isCompleted).length;
TodoState copyWith({
List<Todo>? todos,
TodoFilter? filter,
bool? isLoading,
String? error,
}) {
return TodoState(
todos: todos ?? this.todos,
filter: filter ?? this.filter,
isLoading: isLoading ?? this.isLoading,
error: error,
);
}
@override
List<Object?> get props => [todos, filter, isLoading, error];
}
3. lib/bloc_version/bloc/todo/todo_bloc.dart
import 'package:bloc/bloc.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../core/services/todo_service.dart';
import 'todo_event.dart';
import 'todo_state.dart';
class TodoBloc extends Bloc<TodoEvent, TodoState> {
final TodoService _todoService;
TodoBloc(this._todoService) : super(const TodoState()) {
on<LoadTodosEvent>(_onLoadTodos);
on<AddTodoEvent>(_onAddTodo);
on<ToggleTodoEvent>(_onToggleTodo);
on<DeleteTodoEvent>(_onDeleteTodo);
on<FilterTodosEvent>(_onFilterTodos);
on<ClearErrorEvent>(_onClearError);
}
Future<void> _onLoadTodos(
LoadTodosEvent event,
Emitter<TodoState> emit,
) async {
emit(state.copyWith(isLoading: true, error: null));
try {
final todos = await _todoService.fetchTodos();
emit(state.copyWith(
todos: todos,
isLoading: false,
error: null,
));
} catch (e) {
emit(state.copyWith(
isLoading: false,
error: 'Failed to load todos: $e',
));
}
}
Future<void> _onAddTodo(
AddTodoEvent event,
Emitter<TodoState> emit,
) async {
emit(state.copyWith(isLoading: true, error: null));
try {
final newTodo = await _todoService.addTodo(
event.title,
event.description,
);
final updatedTodos = [newTodo, ...state.todos];
emit(state.copyWith(
todos: updatedTodos,
isLoading: false,
error: null,
));
} catch (e) {
emit(state.copyWith(
isLoading: false,
error: 'Failed to add todo: $e',
));
}
}
Future<void> _onToggleTodo(
ToggleTodoEvent event,
Emitter<TodoState> emit,
) async {
final index = state.todos.indexWhere((todo) => todo.id == event.id);
if (index == -1) return;
emit(state.copyWith(isLoading: true, error: null));
try {
final updatedTodo = state.todos[index].copyWith(
isCompleted: !state.todos[index].isCompleted,
);
final savedTodo = await _todoService.updateTodo(updatedTodo);
final updatedTodos = List<Todo>.from(state.todos);
updatedTodos[index] = savedTodo;
emit(state.copyWith(
todos: updatedTodos,
isLoading: false,
error: null,
));
} catch (e) {
emit(state.copyWith(
isLoading: false,
error: 'Failed to update todo: $e',
));
}
}
Future<void> _onDeleteTodo(
DeleteTodoEvent event,
Emitter<TodoState> emit,
) async {
emit(state.copyWith(isLoading: true, error: null));
try {
await _todoService.deleteTodo(event.id);
final updatedTodos = state.todos
.where((todo) => todo.id != event.id)
.toList();
emit(state.copyWith(
todos: updatedTodos,
isLoading: false,
error: null,
));
} catch (e) {
emit(state.copyWith(
isLoading: false,
error: 'Failed to delete todo: $e',
));
}
}
void _onFilterTodos(
FilterTodosEvent event,
Emitter<TodoState> emit,
) {
emit(state.copyWith(filter: event.filter));
}
void _onClearError(
ClearErrorEvent event,
Emitter<TodoState> emit,
) {
emit(state.copyWith(error: null));
}
}
4. lib/bloc_version/bloc/theme/theme_bloc.dart
import 'package:bloc/bloc.dart';
import 'package:equatable/equatable.dart';
import 'package:flutter/material.dart';
// Events
abstract class ThemeEvent extends Equatable {
const ThemeEvent();
@override
List<Object> get props => [];
}
class ToggleThemeEvent extends ThemeEvent {}
class SetThemeEvent extends ThemeEvent {
final ThemeMode themeMode;
const SetThemeEvent({required this.themeMode});
@override
List<Object> get props => [themeMode];
}
// State
class ThemeState extends Equatable {
final ThemeMode themeMode;
const ThemeState({this.themeMode = ThemeMode.light});
ThemeState copyWith({ThemeMode? themeMode}) {
return ThemeState(themeMode: themeMode ?? this.themeMode);
}
@override
List<Object> get props => [themeMode];
}
// Bloc
class ThemeBloc extends Bloc<ThemeEvent, ThemeState> {
ThemeBloc() : super(const ThemeState()) {
on<ToggleThemeEvent>(_onToggleTheme);
on<SetThemeEvent>(_onSetTheme);
}
void _onToggleTheme(
ToggleThemeEvent event,
Emitter<ThemeState> emit,
) {
final newTheme = state.themeMode == ThemeMode.light
? ThemeMode.dark
: ThemeMode.light;
emit(state.copyWith(themeMode: newTheme));
}
void _onSetTheme(
SetThemeEvent event,
Emitter<ThemeState> emit,
) {
emit(state.copyWith(themeMode: event.themeMode));
}
}
5. lib/bloc_version/screens/home_screen.dart
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../bloc/todo/todo_bloc.dart';
import '../bloc/todo/todo_event.dart';
import '../bloc/todo/todo_state.dart';
import '../bloc/theme/theme_bloc.dart';
import '../bloc/theme/theme_event.dart';
import '../../../core/widgets/todo_item.dart';
class HomeScreen extends StatelessWidget {
const HomeScreen({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Todo App (Bloc)'),
actions: [
BlocBuilder<ThemeBloc, ThemeState>(
builder: (context, themeState) {
return IconButton(
icon: Icon(themeState.themeMode == ThemeMode.dark
? Icons.light_mode
: Icons.dark_mode),
onPressed: () => context.read<ThemeBloc>().add(ToggleThemeEvent()),
);
},
),
],
),
body: BlocBuilder<TodoBloc, TodoState>(
builder: (context, state) {
if (state.isLoading && state.todos.isEmpty) {
return const Center(child: CircularProgressIndicator());
}
return Column(
children: [
Padding(
padding: const EdgeInsets.all(16.0),
child: SegmentedButton<TodoFilter>(
segments: const [
ButtonSegment(value: TodoFilter.all, label: Text('All')),
ButtonSegment(value: TodoFilter.active, label: Text('Active')),
ButtonSegment(value: TodoFilter.completed, label: Text('Completed')),
],
selected: {state.filter},
onSelectionChanged: (Set<TodoFilter> newSelection) {
context.read<TodoBloc>().add(
FilterTodosEvent(filter: newSelection.first),
);
},
),
),
Padding(
padding: const EdgeInsets.all(16.0),
child: Text(
'${state.activeCount} items left',
style: Theme.of(context).textTheme.titleMedium,
),
),
Expanded(
child: ListView.builder(
itemCount: state.filteredTodos.length,
itemBuilder: (context, index) {
final todo = state.filteredTodos[index];
return TodoItem(
todo: todo,
onToggle: () => context.read<TodoBloc>().add(
ToggleTodoEvent(id: todo.id),
),
onDelete: () => context.read<TodoBloc>().add(
DeleteTodoEvent(id: todo.id),
),
onTap: () {
// Navigate to detail/edit screen
},
);
},
),
),
if (state.error != null)
Padding(
padding: const EdgeInsets.all(8.0),
child: Card(
color: Colors.red.shade50,
child: ListTile(
leading: const Icon(Icons.error, color: Colors.red),
title: Text(state.error!),
trailing: IconButton(
icon: const Icon(Icons.close),
onPressed: () => context.read<TodoBloc>().add(ClearErrorEvent()),
),
),
),
),
],
);
},
),
floatingActionButton: FloatingActionButton(
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const AddTodoScreen(),
),
);
},
child: const Icon(Icons.add),
),
);
}
}
6. lib/bloc_version/screens/add_todo_screen.dart
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../bloc/todo/todo_bloc.dart';
import '../bloc/todo/todo_event.dart';
class AddTodoScreen extends StatefulWidget {
const AddTodoScreen({Key? key}) : super(key: key);
@override
State<AddTodoScreen> createState() => _AddTodoScreenState();
}
class _AddTodoScreenState extends State<AddTodoScreen> {
final _formKey = GlobalKey<FormState>();
final _titleController = TextEditingController();
final _descriptionController = TextEditingController();
@override
void dispose() {
_titleController.dispose();
_descriptionController.dispose();
super.dispose();
}
void _submit() {
if (_formKey.currentState!.validate()) {
context.read<TodoBloc>().add(
AddTodoEvent(
title: _titleController.text,
description: _descriptionController.text,
),
);
Navigator.pop(context);
}
}
@override
Widget build(BuildContext context) {
return BlocListener<TodoBloc, TodoState>(
listener: (context, state) {
if (state.error != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(state.error!),
backgroundColor: Colors.red,
),
);
}
},
child: Scaffold(
appBar: AppBar(
title: const Text('Add Todo'),
),
body: BlocBuilder<TodoBloc, TodoState>(
builder: (context, state) {
return Padding(
padding: const EdgeInsets.all(16.0),
child: Form(
key: _formKey,
child: Column(
children: [
TextFormField(
controller: _titleController,
decoration: const InputDecoration(
labelText: 'Title',
border: OutlineInputBorder(),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter a title';
}
return null;
},
),
const SizedBox(height: 16),
TextFormField(
controller: _descriptionController,
decoration: const InputDecoration(
labelText: 'Description',
border: OutlineInputBorder(),
),
maxLines: 3,
),
const SizedBox(height: 32),
state.isLoading
? const CircularProgressIndicator()
: ElevatedButton(
onPressed: _submit,
child: const Text('Add Todo'),
),
],
),
),
);
},
),
),
);
}
}
7. lib/bloc_version/bloc_app.dart
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../core/services/todo_service.dart';
import './bloc/todo/todo_bloc.dart';
import './bloc/theme/theme_bloc.dart';
import './screens/home_screen.dart';
class BlocApp extends StatelessWidget {
const BlocApp({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return MultiBlocProvider(
providers: [
BlocProvider(
create: (context) => TodoBloc(TodoService())..add(LoadTodosEvent()),
),
BlocProvider(
create: (context) => ThemeBloc(),
),
],
child: BlocBuilder<ThemeBloc, ThemeState>(
builder: (context, themeState) {
return MaterialApp(
title: 'Todo App (Bloc)',
theme: ThemeData.light(),
darkTheme: ThemeData.dark(),
themeMode: themeState.themeMode,
home: const HomeScreen(),
debugShowCheckedModeBanner: false,
);
},
),
);
}
}
Entry Point: lib/main.dart
import 'package:flutter/material.dart';
import 'provider_version/provider_app.dart';
import 'riverpod_version/riverpod_app.dart';
import 'bloc_version/bloc_app.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'State Management Comparison',
theme: ThemeData.light(),
darkTheme: ThemeData.dark(),
home: const HomePage(),
debugShowCheckedModeBanner: false,
);
}
}
class HomePage extends StatelessWidget {
const HomePage({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Choose State Management')),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
ElevatedButton(
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const ProviderApp(),
),
);
},
child: const Text('Provider Version'),
),
const SizedBox(height: 20),
ElevatedButton(
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const RiverpodApp(),
),
);
},
child: const Text('Riverpod Version'),
),
const SizedBox(height: 20),
ElevatedButton(
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const BlocApp(),
),
);
},
child: const Text('Bloc Version'),
),
],
),
),
);
}
}
Comparison Analysis
Now that we’ve built the same app with all three approaches, let’s analyze the differences:
1. Code Organization
Provider:
- Simple folder structure
- Providers live in a
providers/folder - Business logic mixed with UI logic in providers
Riverpod:
- Clean separation with providers
- Multiple provider types (StateNotifierProvider, Provider, etc.)
- Easy to compose and override providers
Bloc:
- Most structured approach
- Strict separation of events, states, and blocs
- Each feature has its own folder with event/state/bloc files
2. Boilerplate Comparison
| File Type | Provider | Riverpod | Bloc |
|---|---|---|---|
| State Management | 1 file | 1-2 files | 3 files (event, state, bloc) |
| UI Components | Similar | Similar | Similar |
| Total Lines (approx) | 300 | 350 | 450 |
3. Error Handling
Provider: Manual error state in provider, must call notifyListeners()
Riverpod: Built-in AsyncValue handles loading/error/data states automatically
Bloc: Explicit error state in state class, requires manual management
4. Testing
Provider: Easy to test but requires mocking BuildContext
Riverpod: Excellent testability with provider overrides, no BuildContext needed
Bloc: Excellent testability with clear event/state flow, but more setup required
5. Learning Curve
Provider: Easiest – just ChangeNotifier and notifyListeners()
Riverpod: Moderate – need to understand WidgetRef and provider types
Bloc: Steepest – must understand events, states, and bloc patterns
When to Choose Which?
Choose Provider when:
- You’re new to Flutter state management
- Building a small to medium-sized app
- Need a quick prototype
- Team is familiar with basic Flutter concepts
- Don’t need complex async operations
Choose Riverpod when:
- Starting a new production app
- Need compile-time safety
- Want excellent testability
- Dealing with complex async operations
- Need provider composition/overrides
- Building for the long term
Choose Bloc when:
- Working on enterprise applications
- Have complex business logic flows
- Need strict separation of concerns
- Multiple developers working on same codebase
- Require advanced debugging tools (Bloc DevTools)
- Building event-driven features
Performance Considerations
All three solutions are performant for most use cases. However:
- Provider can have unnecessary rebuilds if not careful with
Consumer - Riverpod has optimized rebuilds with
ref.watchscoping - Bloc is very efficient but has higher initial setup cost
Migration Path
If you’re starting with Provider and growing:
- Provider → Keep it simple initially
- Add Riverpod for new features while keeping Provider
- Gradually migrate to Riverpod as needed
- Consider Bloc only for specific complex features
Final Verdict
After building the same app with all three approaches:
- For most apps: Start with Riverpod. It gives you the safety and scalability without overwhelming complexity.
- For learning: Start with Provider, then graduate to Riverpod.
- For large teams/enterprise: Bloc provides the structure and predictability needed for complex projects.
Remember, the “best” solution depends on your specific needs. All three are excellent choices supported by strong communities.
Pro tip: You can mix approaches! Use Provider/Riverpod for simple UI state and Bloc for complex business logic within the same app.
Which state management solution are you using in your projects? Share your experiences in the comments below!

























Leave a Comment
Your email address will not be published. Required fields are marked with *