Flutter Project: Build a Simple To-Do List App | (Ch. 11)
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.
📖 Project 2: Table of Contents
- Step 1: Setup & Cleaning the Canvas
- Step 2: Designing the Data Structure
- Step 3: Building the UI Skeleton
- Step 4: The Input Area (TextField + Button)
- Step 5: Wiring Up the Logic (Adding Tasks)
- Step 6: Displaying the Tasks (ListView.builder)
- Step 7: Deleting Tasks (The Final Touch)
- The Final Code
- Conclusion & Challenge
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:
- A
List<String>to hold our tasks. - A
TextEditingControllerto 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:
- A list of items filling the main screen.
- 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?
- In the `Row`: The `TextField` needs to take up all width except what the button uses. `Expanded` handles that.
- 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:
- Get the text from the controller.
- Check if it's empty (we don't want blank tasks).
- Add it to our `_tasks` list.
- Clear the text field so they can type another.
- 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!
- Create a class `Task` with properties `String title` and `bool isDone`.
- Change your list to `List<Task>`.
- 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
Post a Comment