Flutter Project: Professional To-Do App with Provider | (Ch. 20)

Flutter Course Project 4: Professional To-Do App, showing a split comparison between 'Old Code' (setState) and 'New Architecture' (Provider) with a clean task list interface.

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.

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

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

Q: Why did we overcomplicate this? The `setState` version was shorter!
For a tiny app, `setState` is indeed shorter. But imagine this app grows.
  • What if we want a "Profile Screen" that shows "Total Tasks Completed"?
  • What if we want to save these tasks to a database?
With `setState`, you would have to rewrite everything to pass data up and down. With Provider, you just add a new method to `TodoProvider` and any screen can access it immediately. We accepted a little extra complexity now to save a huge headache later.
Q: Why use `UnmodifiableListView`?
This is a safety feature. It prevents a developer (or you, 3 months from now) from accidentally writing 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.
Q: Will the tasks stay if I close the app?
No. Right now, the data lives in the device's RAM (memory). When you close the app, the RAM is cleared. To save data permanently, we need Persistence. We will cover that in the upcoming chapters using Shared Preferences and Databases.

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