Flutter Project: Professional To-Do App with Provider | (Ch. 20)
Welcome to Chapter 20!
Back in Chapter 9, we built a simple "To-Do List." It was exciting because it was our first dynamic app. We used setState to add items to a list and update the UI.
But if you look back at that code now, you might notice something: The Logic and the UI were mixed together. The list of tasks lived inside the widget. The function to add tasks lived inside the widget. If we wanted to show the count of completed tasks on a different screen, we would have been stuck.
Confused about why mixing UI and Logic is bad?
⬅️ Review Chapter 19: State Management OverviewIn this project, we are going to Rebuild and Refactor that application. We will separate the "brains" (Logic) from the "beauty" (UI) using the Provider package.
By the end of this tutorial, you will have a clean, scalable architecture that mimics how professional apps are built.
📖 Project 4: Table of Contents
The Goal: Architecture Upgrade
We aren't changing what the app does. We are changing how it does it.
- Old Way (Imperative): Button Click -> Call Function -> Update List -> Call setState -> Rebuild Widget.
- New Way (Declarative): Button Click -> Call Provider -> Provider Updates Data -> Provider Notifies Listeners -> UI Rebuilds Automatically.
This separation means we can eventually add features like "Save to Database" or "Filter by Date" inside the Provider without ever touching the UI code.
Step 1: Setup & Dependencies
Start a new Flutter project or clean out your `lib` folder.
flutter create todo_provider_app
Add the `provider` package to your pubspec.yaml:
dependencies:
flutter:
sdk: flutter
provider: ^6.0.0
Run flutter pub get.
Step 2: The Data Model (Todo Class)
First, we define what a "Todo" actually is. This is a simple Dart class. It doesn't know about widgets, providers, or context. It's just data.
class Todo {
final String id;
final String title;
bool isDone;
Todo({
required this.id,
required this.title,
this.isDone = false
});
// Helper method to toggle status
void toggleDone() {
isDone = !isDone;
}
}
Step 3: The Brains (TodoProvider)
Here lies the magic. We create a class that extends ChangeNotifier. This class will hold our list and the methods to modify it.
Notice there is NO UI CODE here. No Widgets, no Colors, no Scaffolds.
import 'package:flutter/material.dart';
import 'dart:collection'; // For UnmodifiableListView
class TodoProvider extends ChangeNotifier {
// 1. The private state
final List<Todo> _tasks = [];
// 2. Public getter
// We use UnmodifiableListView so the UI can't modify the list directly.
// This enforces using our methods (add/toggle/delete) to make changes.
UnmodifiableListView<Todo> get tasks => UnmodifiableListView(_tasks);
// Computed value: Get count of active tasks
int get activeCount => _tasks.where((t) => !t.isDone).length;
// 3. Methods to modify state
void addTask(String title) {
final newTodo = Todo(
id: DateTime.now().toString(), // Simple unique ID
title: title,
);
_tasks.add(newTodo);
notifyListeners(); // Tell the UI to update!
}
void toggleTask(Todo task) {
final index = _tasks.indexOf(task);
if (index != -1) {
_tasks[index].toggleDone();
notifyListeners();
}
}
void removeTask(Todo task) {
_tasks.remove(task);
notifyListeners();
}
}
Step 4: Wiring Up `main.dart`
We need to inject this provider into the root of our app.
import 'package:provider/provider.dart';
void main() {
runApp(
// Wrap the app in MultiProvider (good habit, even for 1 provider)
MultiProvider(
providers: [
ChangeNotifierProvider(create: (context) => TodoProvider()),
],
child: const MyApp(),
),
);
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
theme: ThemeData(primarySwatch: Colors.teal),
home: const TodoListScreen(),
);
}
}
Step 5: Building the UI (Consumer)
Now we build the screen. We will use a Consumer to listen to the list.
Key concept: When we tap the checkbox, we don't call `setState`. We call `provider.toggleTask()`. The provider updates the data, notifies listeners, and the Consumer rebuilds the list automatically.
class TodoListScreen extends StatelessWidget {
const TodoListScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Provider To-Do'),
actions: [
// Display active count in the AppBar
Padding(
padding: const EdgeInsets.only(right: 20),
child: Center(
child: Consumer<TodoProvider>(
builder: (context, provider, child) {
return Text(
"${provider.activeCount} active",
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
);
},
),
),
)
],
),
body: Consumer<TodoProvider>(
builder: (context, provider, child) {
final tasks = provider.tasks;
if (tasks.isEmpty) {
return const Center(
child: Text(
'No tasks yet.\nTap + to add one!',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 18, color: Colors.grey),
),
);
}
return ListView.builder(
itemCount: tasks.length,
itemBuilder: (context, index) {
final task = tasks[index];
return ListTile(
title: Text(
task.title,
style: TextStyle(
decoration: task.isDone
? TextDecoration.lineThrough
: null,
color: task.isDone ? Colors.grey : Colors.black,
),
),
leading: Checkbox(
value: task.isDone,
onChanged: (value) {
// Action: Toggle
provider.toggleTask(task);
},
),
trailing: IconButton(
icon: const Icon(Icons.delete, color: Colors.red),
onPressed: () {
// Action: Remove
provider.removeTask(task);
},
),
);
},
);
},
),
floatingActionButton: FloatingActionButton(
onPressed: () {
// Open dialog
showDialog(
context: context,
builder: (context) => const AddTodoDialog(),
);
},
child: const Icon(Icons.add),
),
);
}
}
Step 6: Adding Tasks (Context.read)
This is a small popup dialog to type in the new task.
Important: Here we use context.read instead of Consumer. Why? Because this dialog does not need to display the list. It only needs to trigger an action (addTask). We don't want this dialog to rebuild when the list changes.
class AddTodoDialog extends StatefulWidget {
const AddTodoDialog({super.key});
@override
State<AddTodoDialog> createState() => _AddTodoDialogState();
}
class _AddTodoDialogState extends State<AddTodoDialog> {
final TextEditingController _controller = TextEditingController();
void _save() {
if (_controller.text.isNotEmpty) {
// Access the provider WITHOUT listening
context.read<TodoProvider>().addTask(_controller.text);
Navigator.pop(context); // Close dialog
}
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('New Task'),
content: TextField(
controller: _controller,
autofocus: true,
decoration: const InputDecoration(hintText: 'What needs to be done?'),
onSubmitted: (_) => _save(),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
ElevatedButton(
onPressed: _save,
child: const Text('Add'),
),
],
);
}
}
Full Source Code
Here is the entire application in one copy-paste block (for main.dart).
import 'dart:collection';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
// --- DATA MODEL ---
class Todo {
final String id;
final String title;
bool isDone;
Todo({required this.id, required this.title, this.isDone = false});
void toggleDone() => isDone = !isDone;
}
// --- PROVIDER (LOGIC) ---
class TodoProvider extends ChangeNotifier {
final List<Todo> _tasks = [];
UnmodifiableListView<Todo> get tasks => UnmodifiableListView(_tasks);
int get activeCount => _tasks.where((t) => !t.isDone).length;
void addTask(String title) {
_tasks.add(Todo(id: DateTime.now().toString(), title: title));
notifyListeners();
}
void toggleTask(Todo task) {
final index = _tasks.indexOf(task);
if (index != -1) {
_tasks[index].toggleDone();
notifyListeners();
}
}
void removeTask(Todo task) {
_tasks.remove(task);
notifyListeners();
}
}
// --- MAIN APP ---
void main() {
runApp(
MultiProvider(
providers: [ChangeNotifierProvider(create: (_) => TodoProvider())],
child: const MyApp(),
),
);
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Provider Todo',
theme: ThemeData(primarySwatch: Colors.teal),
home: const TodoListScreen(),
);
}
}
// --- SCREENS ---
class TodoListScreen extends StatelessWidget {
const TodoListScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Provider To-Do'),
actions: [
Padding(
padding: const EdgeInsets.only(right: 15),
child: Center(
child: Consumer<TodoProvider>(
builder: (ctx, provider, _) => Text(
"${provider.activeCount} active",
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
),
),
),
)
],
),
body: Consumer<TodoProvider>(
builder: (context, provider, child) {
if (provider.tasks.isEmpty) {
return const Center(child: Text('No tasks. Add one!'));
}
return ListView.builder(
itemCount: provider.tasks.length,
itemBuilder: (context, index) {
final task = provider.tasks[index];
return ListTile(
title: Text(task.title,
style: TextStyle(
decoration:
task.isDone ? TextDecoration.lineThrough : null,
color: task.isDone ? Colors.grey : Colors.black)),
leading: Checkbox(
value: task.isDone,
onChanged: (_) => provider.toggleTask(task),
),
trailing: IconButton(
icon: const Icon(Icons.delete, color: Colors.red),
onPressed: () => provider.removeTask(task),
),
);
},
);
},
),
floatingActionButton: FloatingActionButton(
onPressed: () {
showDialog(
context: context, builder: (_) => const AddTodoDialog());
},
child: const Icon(Icons.add),
),
);
}
}
class AddTodoDialog extends StatefulWidget {
const AddTodoDialog({super.key});
@override
State<AddTodoDialog> createState() => _AddTodoDialogState();
}
class _AddTodoDialogState extends State<AddTodoDialog> {
final _controller = TextEditingController();
void _save() {
if (_controller.text.isEmpty) return;
context.read<TodoProvider>().addTask(_controller.text);
Navigator.pop(context);
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('New Task'),
content: TextField(
controller: _controller,
autofocus: true,
onSubmitted: (_) => _save(),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context), child: const Text('Cancel')),
ElevatedButton(onPressed: _save, child: const Text('Add')),
],
);
}
}
❓ FAQ: Frequently Asked Questions
- What if we want a "Profile Screen" that shows "Total Tasks Completed"?
- What if we want to save these tasks to a database?
provider.tasks.add(newItem) inside the UI. If you try that, the app will crash. It forces you to use the proper provider.addTask() method, ensuring your logic stays central.
Conclusion
You have successfully refactored a legacy app into a modern architecture.
This pattern—Model, Provider, UI—is exactly what you will use in 90% of your professional Flutter career. It is clean, readable, and testable.
In the next chapter, we are going to tackle the missing piece mentioned in the FAQ: Data Persistence. We will learn how to save our settings and tasks so they are still there when we open the app tomorrow.

Comments
Post a Comment