Building a Stateful App with Provider
Capstone: Complete Todo App with Provider
In this capstone lesson, we’ll build a complete, production-quality todo app from scratch using Provider. This app demonstrates every best practice we’ve learned: immutable state, single source of truth, separation of concerns, proper rebuild optimization, and state persistence. By the end, you’ll have a fully functional app with add/edit/delete todos, filtering, search, statistics, theme toggle, and persistent storage.
- Add, edit, and delete todos
- Filter by: All, Active, Completed
- Search todos by title
- Todo count statistics (total, active, completed)
- Dark/light theme toggle with persistence
- State persistence using SharedPreferences
- MultiProvider setup with multiple ChangeNotifiers
- Consumer and Selector widgets for targeted rebuilds
- Proper disposal of resources
Step 1: Data Models
We start with immutable data models. Every model uses copyWith for safe updates and includes JSON serialization for persistence.
lib/models/todo.dart
import 'dart:convert';
enum FilterType { all, active, completed }
class Todo {
final String id;
final String title;
final String description;
final bool isCompleted;
final DateTime createdAt;
final DateTime? completedAt;
const Todo({
required this.id,
required this.title,
this.description = '',
this.isCompleted = false,
required this.createdAt,
this.completedAt,
});
// Immutable update -- returns a NEW Todo, never mutates
Todo copyWith({
String? title,
String? description,
bool? isCompleted,
DateTime? completedAt,
}) {
return Todo(
id: id,
title: title ?? this.title,
description: description ?? this.description,
isCompleted: isCompleted ?? this.isCompleted,
createdAt: createdAt,
completedAt: completedAt ?? this.completedAt,
);
}
// JSON serialization for persistence
Map<String, dynamic> toJson() {
return {
'id': id,
'title': title,
'description': description,
'isCompleted': isCompleted,
'createdAt': createdAt.toIso8601String(),
'completedAt': completedAt?.toIso8601String(),
};
}
factory Todo.fromJson(Map<String, dynamic> json) {
return Todo(
id: json['id'] as String,
title: json['title'] as String,
description: json['description'] as String? ?? '',
isCompleted: json['isCompleted'] as bool? ?? false,
createdAt: DateTime.parse(json['createdAt'] as String),
completedAt: json['completedAt'] != null
? DateTime.parse(json['completedAt'] as String)
: null,
);
}
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is Todo && runtimeType == other.runtimeType && id == other.id;
@override
int get hashCode => id.hashCode;
}
Step 2: Todo State Notifier
The TodoNotifier is the single source of truth for all todo data. It manages the list, filtering, search, and persistence. Notice how every method creates new state instead of mutating existing state.
lib/providers/todo_notifier.dart
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../models/todo.dart';
class TodoNotifier extends ChangeNotifier {
List<Todo> _todos = [];
FilterType _filter = FilterType.all;
String _searchQuery = '';
bool _isLoading = true;
// Public getters -- read-only access to state
List<Todo> get todos => List.unmodifiable(_todos);
FilterType get filter => _filter;
String get searchQuery => _searchQuery;
bool get isLoading => _isLoading;
// Derived state -- computed, never stored
int get totalCount => _todos.length;
int get activeCount => _todos.where((t) => !t.isCompleted).length;
int get completedCount => _todos.where((t) => t.isCompleted).length;
// Filtered + searched todos
List<Todo> get filteredTodos {
List<Todo> result = _todos;
// Apply filter
switch (_filter) {
case FilterType.active:
result = result.where((t) => !t.isCompleted).toList();
break;
case FilterType.completed:
result = result.where((t) => t.isCompleted).toList();
break;
case FilterType.all:
break;
}
// Apply search
if (_searchQuery.isNotEmpty) {
final query = _searchQuery.toLowerCase();
result = result.where((t) =>
t.title.toLowerCase().contains(query) ||
t.description.toLowerCase().contains(query)
).toList();
}
return result;
}
// ====== ACTIONS ======
Future<void> loadTodos() async {
_isLoading = true;
notifyListeners();
try {
final prefs = await SharedPreferences.getInstance();
final todosJson = prefs.getString('todos');
if (todosJson != null) {
final List<dynamic> decoded = jsonDecode(todosJson);
_todos = decoded
.map((item) => Todo.fromJson(item as Map<String, dynamic>))
.toList();
}
} catch (e) {
debugPrint('Error loading todos: \$e');
}
_isLoading = false;
notifyListeners();
}
Future<void> addTodo(String title, {String description = ''}) async {
final todo = Todo(
id: DateTime.now().millisecondsSinceEpoch.toString(),
title: title.trim(),
description: description.trim(),
createdAt: DateTime.now(),
);
// Create NEW list with the new todo
_todos = [..._todos, todo];
notifyListeners();
await _saveTodos();
}
Future<void> updateTodo(String id, {String? title, String? description}) async {
_todos = _todos.map((todo) {
if (todo.id == id) {
return todo.copyWith(
title: title?.trim(),
description: description?.trim(),
);
}
return todo;
}).toList();
notifyListeners();
await _saveTodos();
}
Future<void> toggleTodo(String id) async {
_todos = _todos.map((todo) {
if (todo.id == id) {
return todo.copyWith(
isCompleted: !todo.isCompleted,
completedAt: !todo.isCompleted ? DateTime.now() : null,
);
}
return todo;
}).toList();
notifyListeners();
await _saveTodos();
}
Future<void> deleteTodo(String id) async {
_todos = _todos.where((todo) => todo.id != id).toList();
notifyListeners();
await _saveTodos();
}
Future<void> clearCompleted() async {
_todos = _todos.where((todo) => !todo.isCompleted).toList();
notifyListeners();
await _saveTodos();
}
void setFilter(FilterType newFilter) {
if (_filter != newFilter) {
_filter = newFilter;
notifyListeners();
}
}
void setSearchQuery(String query) {
if (_searchQuery != query) {
_searchQuery = query;
notifyListeners();
}
}
// ====== PERSISTENCE ======
Future<void> _saveTodos() async {
try {
final prefs = await SharedPreferences.getInstance();
final todosJson = jsonEncode(_todos.map((t) => t.toJson()).toList());
await prefs.setString('todos', todosJson);
} catch (e) {
debugPrint('Error saving todos: \$e');
}
}
}
Step 3: Theme Notifier
A separate notifier handles theme state. This follows separation of concerns -- todo logic and theme logic are completely independent.
lib/providers/theme_notifier.dart
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
class ThemeNotifier extends ChangeNotifier {
ThemeMode _themeMode = ThemeMode.light;
ThemeMode get themeMode => _themeMode;
bool get isDarkMode => _themeMode == ThemeMode.dark;
Future<void> loadTheme() async {
try {
final prefs = await SharedPreferences.getInstance();
final isDark = prefs.getBool('isDarkMode') ?? false;
_themeMode = isDark ? ThemeMode.dark : ThemeMode.light;
notifyListeners();
} catch (e) {
debugPrint('Error loading theme: \$e');
}
}
Future<void> toggleTheme() async {
_themeMode = isDarkMode ? ThemeMode.light : ThemeMode.dark;
notifyListeners();
try {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool('isDarkMode', isDarkMode);
} catch (e) {
debugPrint('Error saving theme: \$e');
}
}
}
Step 4: App Entry Point with MultiProvider
We use MultiProvider at the app root to provide both notifiers to the entire widget tree. The create callback also triggers initial data loading.
lib/main.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'providers/todo_notifier.dart';
import 'providers/theme_notifier.dart';
import 'screens/home_screen.dart';
void main() {
runApp(const TodoApp());
}
class TodoApp extends StatelessWidget {
const TodoApp({super.key});
@override
Widget build(BuildContext context) {
return MultiProvider(
providers: [
ChangeNotifierProvider(
create: (_) {
final notifier = TodoNotifier();
notifier.loadTodos(); // Load saved todos on startup
return notifier;
},
),
ChangeNotifierProvider(
create: (_) {
final notifier = ThemeNotifier();
notifier.loadTheme(); // Load saved theme on startup
return notifier;
},
),
],
child: Consumer<ThemeNotifier>(
builder: (context, themeNotifier, child) {
return MaterialApp(
title: 'Todo App',
debugShowCheckedModeBanner: false,
themeMode: themeNotifier.themeMode,
theme: ThemeData(
colorSchemeSeed: Colors.blue,
useMaterial3: true,
brightness: Brightness.light,
),
darkTheme: ThemeData(
colorSchemeSeed: Colors.blue,
useMaterial3: true,
brightness: Brightness.dark,
),
home: const HomeScreen(),
);
},
),
);
}
}
Step 5: Home Screen with Stats and Search
The home screen combines everything: statistics bar, search, filter chips, and the todo list. Notice how we use Selector for the stats to avoid rebuilding the entire screen when only the count changes.
lib/screens/home_screen.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../models/todo.dart';
import '../providers/todo_notifier.dart';
import '../providers/theme_notifier.dart';
import '../widgets/todo_list.dart';
import '../widgets/stats_bar.dart';
import '../widgets/add_todo_sheet.dart';
class HomeScreen extends StatefulWidget {
const HomeScreen({super.key});
@override
State<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen> {
final _searchController = TextEditingController();
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('My Todos'),
actions: [
// Theme toggle -- uses Consumer for targeted rebuild
Consumer<ThemeNotifier>(
builder: (context, theme, _) {
return IconButton(
icon: Icon(
theme.isDarkMode ? Icons.light_mode : Icons.dark_mode,
),
onPressed: theme.toggleTheme,
tooltip: theme.isDarkMode
? 'Switch to light mode'
: 'Switch to dark mode',
);
},
),
// Clear completed button
PopupMenuButton<String>(
onSelected: (value) {
if (value == 'clear_completed') {
context.read<TodoNotifier>().clearCompleted();
}
},
itemBuilder: (context) => [
const PopupMenuItem(
value: 'clear_completed',
child: Text('Clear completed'),
),
],
),
],
),
body: Column(
children: [
// Stats bar -- only rebuilds when counts change
const StatsBar(),
// Search field
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: 'Search todos...',
prefixIcon: const Icon(Icons.search),
suffixIcon: _searchController.text.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
_searchController.clear();
context.read<TodoNotifier>().setSearchQuery('');
},
)
: null,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
),
onChanged: (value) {
context.read<TodoNotifier>().setSearchQuery(value);
setState(() {}); // Update suffix icon visibility
},
),
),
// Filter chips
const FilterChips(),
// Todo list
const Expanded(child: TodoList()),
],
),
floatingActionButton: FloatingActionButton.extended(
onPressed: () => _showAddTodoSheet(context),
icon: const Icon(Icons.add),
label: const Text('Add Todo'),
),
);
}
void _showAddTodoSheet(BuildContext context) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (_) => const AddTodoSheet(),
);
}
}
// Filter chips -- only rebuilds when filter changes
class FilterChips extends StatelessWidget {
const FilterChips({super.key});
@override
Widget build(BuildContext context) {
// Using Selector to only rebuild when filter changes
final currentFilter = context.select<TodoNotifier, FilterType>(
(notifier) => notifier.filter,
);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: Row(
children: FilterType.values.map((filter) {
final label = switch (filter) {
FilterType.all => 'All',
FilterType.active => 'Active',
FilterType.completed => 'Completed',
};
return Padding(
padding: const EdgeInsets.only(right: 8),
child: FilterChip(
label: Text(label),
selected: currentFilter == filter,
onSelected: (_) {
context.read<TodoNotifier>().setFilter(filter);
},
),
);
}).toList(),
),
);
}
}
Step 6: Stats Bar Widget
The stats bar uses Selector to rebuild only when the actual count values change -- not when a todo title is edited or the filter changes.
lib/widgets/stats_bar.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/todo_notifier.dart';
class StatsBar extends StatelessWidget {
const StatsBar({super.key});
@override
Widget build(BuildContext context) {
// Selector rebuilds only when (total, active, completed) changes
return Selector<TodoNotifier, ({int total, int active, int completed})>(
selector: (_, notifier) => (
total: notifier.totalCount,
active: notifier.activeCount,
completed: notifier.completedCount,
),
builder: (context, counts, child) {
return Container(
padding: const EdgeInsets.all(16),
margin: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_StatItem(
label: 'Total',
count: counts.total,
icon: Icons.list,
),
_StatItem(
label: 'Active',
count: counts.active,
icon: Icons.radio_button_unchecked,
),
_StatItem(
label: 'Done',
count: counts.completed,
icon: Icons.check_circle_outline,
),
],
),
);
},
);
}
}
class _StatItem extends StatelessWidget {
final String label;
final int count;
final IconData icon;
const _StatItem({
required this.label,
required this.count,
required this.icon,
});
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 24),
const SizedBox(height: 4),
Text(
'\$count',
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
Text(label, style: Theme.of(context).textTheme.bodySmall),
],
);
}
}
Step 7: Todo List and Todo Tile
The list displays filtered todos with swipe-to-delete and tap-to-toggle functionality.
lib/widgets/todo_list.dart and todo_tile.dart
// todo_list.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/todo_notifier.dart';
import 'todo_tile.dart';
class TodoList extends StatelessWidget {
const TodoList({super.key});
@override
Widget build(BuildContext context) {
return Consumer<TodoNotifier>(
builder: (context, notifier, _) {
if (notifier.isLoading) {
return const Center(child: CircularProgressIndicator());
}
final todos = notifier.filteredTodos;
if (todos.isEmpty) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.inbox, size: 64,
color: Theme.of(context).colorScheme.outline),
const SizedBox(height: 16),
Text(
notifier.searchQuery.isNotEmpty
? 'No todos match your search'
: notifier.filter == FilterType.all
? 'No todos yet. Add one!'
: 'No \${notifier.filter.name} todos',
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: Theme.of(context).colorScheme.outline,
),
),
],
),
);
}
return ListView.builder(
padding: const EdgeInsets.only(bottom: 80),
itemCount: todos.length,
itemBuilder: (context, index) {
return TodoTile(
key: ValueKey(todos[index].id),
todo: todos[index],
);
},
);
},
);
}
}
// todo_tile.dart
import 'package:flutter/material.dart';
import '../models/todo.dart';
import '../providers/todo_notifier.dart';
import 'package:provider/provider.dart';
class TodoTile extends StatelessWidget {
final Todo todo;
const TodoTile({super.key, required this.todo});
@override
Widget build(BuildContext context) {
return Dismissible(
key: ValueKey(todo.id),
direction: DismissDirection.endToStart,
background: Container(
alignment: Alignment.centerRight,
padding: const EdgeInsets.only(right: 20),
color: Colors.red,
child: const Icon(Icons.delete, color: Colors.white),
),
confirmDismiss: (_) async {
return await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Delete Todo'),
content: Text('Delete "\${todo.title}"?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text('Delete',
style: TextStyle(color: Colors.red)),
),
],
),
);
},
onDismissed: (_) {
context.read<TodoNotifier>().deleteTodo(todo.id);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('"\${todo.title}" deleted')),
);
},
child: ListTile(
leading: Checkbox(
value: todo.isCompleted,
onChanged: (_) =>
context.read<TodoNotifier>().toggleTodo(todo.id),
),
title: Text(
todo.title,
style: TextStyle(
decoration: todo.isCompleted
? TextDecoration.lineThrough
: null,
),
),
subtitle: todo.description.isNotEmpty
? Text(
todo.description,
maxLines: 1,
overflow: TextOverflow.ellipsis,
)
: null,
onTap: () => _showEditDialog(context),
onLongPress: () =>
context.read<TodoNotifier>().toggleTodo(todo.id),
),
);
}
void _showEditDialog(BuildContext context) {
final titleCtrl = TextEditingController(text: todo.title);
final descCtrl = TextEditingController(text: todo.description);
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Edit Todo'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: titleCtrl,
decoration: const InputDecoration(labelText: 'Title'),
autofocus: true,
),
const SizedBox(height: 8),
TextField(
controller: descCtrl,
decoration: const InputDecoration(labelText: 'Description'),
maxLines: 3,
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () {
if (titleCtrl.text.trim().isNotEmpty) {
context.read<TodoNotifier>().updateTodo(
todo.id,
title: titleCtrl.text,
description: descCtrl.text,
);
Navigator.pop(ctx);
}
},
child: const Text('Save'),
),
],
),
);
}
}
Step 8: Add Todo Bottom Sheet
A bottom sheet with proper form handling for adding new todos.
lib/widgets/add_todo_sheet.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/todo_notifier.dart';
class AddTodoSheet extends StatefulWidget {
const AddTodoSheet({super.key});
@override
State<AddTodoSheet> createState() => _AddTodoSheetState();
}
class _AddTodoSheetState extends State<AddTodoSheet> {
final _formKey = GlobalKey<FormState>();
final _titleController = TextEditingController();
final _descController = TextEditingController();
@override
void dispose() {
_titleController.dispose();
_descController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Padding(
// Adjust for keyboard
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom,
left: 16,
right: 16,
top: 16,
),
child: Form(
key: _formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Handle bar
Center(
child: Container(
width: 40,
height: 4,
margin: const EdgeInsets.only(bottom: 16),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.outline,
borderRadius: BorderRadius.circular(2),
),
),
),
Text(
'New Todo',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 16),
TextFormField(
controller: _titleController,
decoration: const InputDecoration(
labelText: 'What needs to be done?',
border: OutlineInputBorder(),
),
autofocus: true,
validator: (value) {
if (value == null || value.trim().isEmpty) {
return 'Please enter a title';
}
return null;
},
),
const SizedBox(height: 12),
TextFormField(
controller: _descController,
decoration: const InputDecoration(
labelText: 'Description (optional)',
border: OutlineInputBorder(),
),
maxLines: 3,
),
const SizedBox(height: 16),
FilledButton.icon(
onPressed: _submit,
icon: const Icon(Icons.add),
label: const Text('Add Todo'),
),
const SizedBox(height: 16),
],
),
),
);
}
void _submit() {
if (_formKey.currentState!.validate()) {
context.read<TodoNotifier>().addTodo(
_titleController.text,
description: _descController.text,
);
Navigator.pop(context);
}
}
}
Architecture Summary
lib/
main.dart -- MultiProvider setup, app entry
models/
todo.dart -- Immutable Todo model with copyWith & JSON
providers/
todo_notifier.dart -- All todo business logic + persistence
theme_notifier.dart -- Theme toggle + persistence
screens/
home_screen.dart -- Main screen with search, filters, FAB
widgets/
stats_bar.dart -- Optimized stats with Selector
todo_list.dart -- Filtered list with Consumer
todo_tile.dart -- Individual tile with dismiss & edit
add_todo_sheet.dart -- Form bottom sheet for new todos1. MultiProvider at root for global state (TodoNotifier + ThemeNotifier)
2. Consumer for sections that need the full notifier (todo list, theme toggle)
3. Selector for targeted rebuilds (stats bar only rebuilds on count changes)
4. context.read in callbacks (never rebuilds, just dispatches actions)
5. context.select in build methods for fine-grained subscriptions (filter chips)
6. Immutable models with copyWith everywhere
7. Proper disposal of TextEditingControllers
8. SharedPreferences for persistence (loaded on startup, saved on every change)
- Don’t use
context.watch inside callbacks -- use context.read
- Don’t forget
ValueKey on list items for proper animation/rebuild behavior
- Don’t put async logic in
build() -- call it from initState or event handlers
- Don’t forget to dispose controllers in StatefulWidgets
- Don’t create new providers inside
build() -- always use create callback