Flutter Project: Build a Simple To-Do List App | (Ch. 11)

Flutter Course Project 2: A simple To-Do List app showing a text input field, an "Add" button, and a list of tasks on a mobile screen.


Welcome to Project 2! This is a huge milestone.

In Project 1 (the Business Card), we built a static app. It looked great, but it didn't *do* anything. It was a StatelessWidget.

Today, we are going to build a dynamic app. We are going to build a "To-Do List" that allows users to:

  • View a list of tasks.
  • Add new tasks using a text input.
  • Delete tasks they've completed.

This project will force you to combine everything you've learned so far: StatefulWidget, ListView.builder, TextField, FloatingActionButton, and basic Dart logic (`List` manipulation).

Note: We are not saving data to the phone's storage yet (we'll learn that in Module 5). If you restart the app, the list will reset. That's okay for now!

Ready? Let's code.

Step 1: Setup & Cleaning the Canvas

Create a new Flutter project (e.g., flutter create todo_app) or clear out your existing lib/main.dart file.

We'll start with the standard boilerplate, but this time, our main widget MUST be a StatefulWidget because our list of tasks will change over time.


import 'package:flutter/material.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      title: 'Simple To-Do',
      theme: ThemeData(
        primarySwatch: Colors.deepPurple,
      ),
      home: const TodoListScreen(),
    );
  }
}

// We need a StatefulWidget because our list of tasks will change!
class TodoListScreen extends StatefulWidget {
  const TodoListScreen({super.key});

  @override
  State<TodoListScreen> createState() => _TodoListScreenState();
}

class _TodoListScreenState extends State<TodoListScreen> {
  // We'll put our logic here...
  
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('My Tasks')),
      body: const Center(child: Text('To-Do List goes here')),
    );
  }
}

Step 2: Designing the Data Structure

Before we build the UI, we need a place to store our data.

Inside the `_TodoListScreenState` class, we need two things:

  1. A List<String> to hold our tasks.
  2. A TextEditingController to listen to the user's input.

class _TodoListScreenState extends State<TodoListScreen> {
  // 1. The list of tasks (our "State")
  final List<String> _tasks = [];

  // 2. The controller for the text field
  final TextEditingController _textController = TextEditingController();
  
  // Don't forget to dispose the controller!
  @override
  void dispose() {
    _textController.dispose();
    super.dispose();
  }

  // ... build method ...
}

Currently, `_tasks` is empty. That's fine. We'll add to it soon.

Step 3: Building the UI Skeleton

We want a layout that has:

  1. A list of items filling the main screen.
  2. An input area at the bottom to add new items.

We could put the input area in a `BottomNavigationBar` or just below the list, but a classic pattern is to put the input field in a Row at the top or bottom.

Let's use a Column layout.


  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('My Tasks'),
        backgroundColor: Colors.deepPurple,
        foregroundColor: Colors.white,
      ),
      body: Column(
        children: [
          // Part 1: The Input Area (Fixed height)
          Padding(
            padding: const EdgeInsets.all(16.0),
            child: Row(
              children: [
                // TextField needs to be flexible
                Expanded(
                  child: TextField(
                    controller: _textController,
                    decoration: const InputDecoration(
                      hintText: 'Enter a new task...',
                      border: OutlineInputBorder(),
                    ),
                  ),
                ),
                const SizedBox(width: 10),
                // The "Add" Button
                ElevatedButton(
                  onPressed: () {
                    // Logic to add task goes here
                  },
                  child: const Text('Add'),
                ),
              ],
            ),
          ),
          
          // Part 2: The List (Fills remaining space)
          Expanded(
            child: ListView.builder(
              itemCount: _tasks.length,
              itemBuilder: (context, index) {
                return ListTile(
                  title: Text(_tasks[index]),
                );
              },
            ),
          ),
        ],
      ),
    );
  }

Why did we use `Expanded` twice?

  1. In the `Row`: The `TextField` needs to take up all width except what the button uses. `Expanded` handles that.
  2. In the `Column`: The `ListView` needs to take up all height except what the input area uses. `Expanded` handles that too. If we didn't use `Expanded` around the `ListView`, Flutter would throw an error because `ListView` tries to be infinitely tall!

Step 4: The Input Area (TextField + Button)

We already sketched this out above. Let's look closer at the `TextField`.

We attached `_textController` to it. This means whenever the user types, the controller knows the value.

We also added a `hintText` ("Enter a new task...") so the user knows what to do.

Step 5: Wiring Up the Logic (Adding Tasks)

Now for the fun part. When the user taps the "Add" button, we need to:

  1. Get the text from the controller.
  2. Check if it's empty (we don't want blank tasks).
  3. Add it to our `_tasks` list.
  4. Clear the text field so they can type another.
  5. Call `setState()` so the UI updates!

Let's create a function for this:


  void _addTask() {
    // 1. Get the text
    String newTask = _textController.text;

    // 2. Validation
    if (newTask.isEmpty) return;

    // 3. Update State
    setState(() {
      _tasks.add(newTask); // Add to list
      _textController.clear(); // Clear the input
    });
  }

Now, update your `ElevatedButton` to call this function:


    ElevatedButton(
      onPressed: _addTask, // Call our function
      child: const Text('Add'),
    ),

Step 6: Displaying the Tasks (ListView.builder)

We used `ListView.builder` in our skeleton. It's the perfect choice.


    Expanded(
      child: ListView.builder(
        itemCount: _tasks.length,
        itemBuilder: (context, index) {
          return Card( // Wrap in a Card for looks
            margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
            child: ListTile(
              leading: const CircleAvatar(
                backgroundColor: Colors.deepPurple,
                child: Icon(Icons.check, color: Colors.white, size: 20),
              ),
              title: Text(_tasks[index]),
              // We'll add a delete button next!
            ),
          );
        },
      ),
    ),

Try running the app now! You can type a task, hit "Add", and watch it appear in the list instantly. This is the magic of `setState()`.

Step 7: Deleting Tasks (The Final Touch)

A to-do list isn't useful if you can't remove items.

We'll add a "Delete" button to the `trailing` property of our `ListTile`. When tapped, we'll remove that specific item from the list and call `setState` again.


    // Inside ListTile...
    trailing: IconButton(
      icon: const Icon(Icons.delete, color: Colors.red),
      onPressed: () {
        // Logic to delete THIS item
        setState(() {
          _tasks.removeAt(index);
        });
      },
    ),

`removeAt(index)` is a Dart list method that removes the item at a specific position. Since `itemBuilder` gives us the `index`, this works perfectly.

The Final Code

Here is the complete, working code for your `lib/main.dart` file.


import 'package:flutter/material.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      title: 'Simple To-Do',
      theme: ThemeData(
        primarySwatch: Colors.deepPurple,
        useMaterial3: true,
      ),
      home: const TodoListScreen(),
    );
  }
}

class TodoListScreen extends StatefulWidget {
  const TodoListScreen({super.key});

  @override
  State<TodoListScreen> createState() => _TodoListScreenState();
}

class _TodoListScreenState extends State<TodoListScreen> {
  final List<String> _tasks = [];
  final TextEditingController _textController = TextEditingController();

  void _addTask() {
    String newTask = _textController.text;
    if (newTask.isEmpty) return;

    setState(() {
      _tasks.add(newTask);
      _textController.clear();
    });
  }

  void _deleteTask(int index) {
    setState(() {
      _tasks.removeAt(index);
    });
  }

  @override
  void dispose() {
    _textController.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('My Tasks'),
        backgroundColor: Colors.deepPurple,
        foregroundColor: Colors.white,
      ),
      body: Column(
        children: [
          // Input Area
          Padding(
            padding: const EdgeInsets.all(16.0),
            child: Row(
              children: [
                Expanded(
                  child: TextField(
                    controller: _textController,
                    decoration: const InputDecoration(
                      hintText: 'Enter a new task...',
                      border: OutlineInputBorder(),
                    ),
                  ),
                ),
                const SizedBox(width: 10),
                ElevatedButton(
                  onPressed: _addTask,
                  style: ElevatedButton.styleFrom(
                    backgroundColor: Colors.deepPurple,
                    foregroundColor: Colors.white,
                  ),
                  child: const Text('Add'),
                ),
              ],
            ),
          ),
          
          // Task List
          Expanded(
            child: _tasks.isEmpty
                ? const Center(
                    child: Text(
                      'No tasks yet. Add some!',
                      style: TextStyle(fontSize: 18, color: Colors.grey),
                    ),
                  )
                : ListView.builder(
                    itemCount: _tasks.length,
                    itemBuilder: (context, index) {
                      return Card(
                        margin: const EdgeInsets.symmetric(
                            horizontal: 16, vertical: 4),
                        child: ListTile(
                          leading: CircleAvatar(
                            backgroundColor: Colors.deepPurple.shade100,
                            child: Text(
                              (index + 1).toString(),
                              style: const TextStyle(color: Colors.deepPurple),
                            ),
                          ),
                          title: Text(_tasks[index]),
                          trailing: IconButton(
                            icon: const Icon(Icons.delete, color: Colors.red),
                            onPressed: () => _deleteTask(index),
                          ),
                        ),
                      );
                    },
                  ),
          ),
        ],
      ),
    );
  }
}

Conclusion & Challenge

You did it! You built a fully functional CRUD (Create, Read, Update, Delete) app—well, mostly CRD, but close enough!

You learned how to:

  • Manage a list of data in State.
  • Get user input with a Controller.
  • Update the UI dynamically with `setState`.
  • Render that list efficiently with `ListView.builder`.

Your Challenge

Right now, the tasks are just Strings. Try upgrading your data structure to use a custom Class!

  1. Create a class `Task` with properties `String title` and `bool isDone`.
  2. Change your list to `List<Task>`.
  3. Add a `Checkbox` to the `ListTile` so you can mark tasks as done without deleting them!

In the next module, we will start looking at multi-screen apps. We'll learn how to navigate from a "List" screen to a "Details" screen and back again.

Comments