State Management Fundamentals

Building a Stateful App with Provider

60 min Lesson 14 of 14

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.

What We’re Building:
- 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

Project Structure:

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 todos
Key Patterns Used:
1. 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)
Common Mistakes to Avoid:
- 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

Tutorial Complete!

Congratulations! You have completed all lessons in this tutorial.