Skip to content
FlutterLearn

Beginner · Lesson 11 · 18 min read

Build a To-Do App from Scratch

Turn widgets, lists, text input and local state into a complete first Flutter app with add, complete, filter and delete flows.

Updated September 24, 2026

What you will learn

  • Model a small piece of app data with a Dart class
  • Add, complete, filter and delete tasks with setState
  • Connect a TextField to a button and keyboard submission
  • Render dynamic lists with stable keys
  • Recognise when local state should move into a separate store

A to-do app is small enough to understand completely, but real enough to teach the shape of most Flutter screens: input at the top, a collection in the middle, empty and loading states, and actions that change what is visible. We will keep the data in memory so every line is visible and there is nowhere for a package to hide the fundamentals.

The smallest useful version

  • Type a task and submit it with the keyboard or an add button.
  • See tasks in a scrollable list.
  • Mark a task complete without deleting it.
  • Filter between all, active and completed tasks.
  • Delete a task with a clear destructive action.

Model a task

Do not start with a loose map for data your app will use in several places. A small class gives each field a name, a type and one place to evolve.

lib/task.dart
class Task {
  const Task({
    required this.id,
    required this.title,
    this.completed = false,
  });

  final String id;
  final String title;
  final bool completed;

  Task copyWith({String? title, bool? completed}) {
    return Task(
      id: id,
      title: title ?? this.title,
      completed: completed ?? this.completed,
    );
  }
}

The id lets us identify a task even when its title changes. copyWith creates an updated value without changing the object already held by another part of the UI.

Own local state in the screen

This first screen has state that belongs only to it: the task list and the selected filter. A StatefulWidget is the right amount of architecture for a small, local feature.

lib/main.dart
import 'package:flutter/material.dart';

import 'task.dart';

void main() => runApp(const TodoApp());

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'To-do',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.teal),
        useMaterial3: true,
      ),
      home: const TodoScreen(),
    );
  }
}

enum TaskFilter { all, active, completed }

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

  @override
  State<TodoScreen> createState() => _TodoScreenState();
}

class _TodoScreenState extends State<TodoScreen> {
  final titleController = TextEditingController();
  final tasks = <Task>[];
  TaskFilter filter = TaskFilter.all;

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

  void addTask() {
    final title = titleController.text.trim();
    if (title.isEmpty) return;

    setState(() {
      tasks.insert(
        0,
        Task(
          id: DateTime.now().microsecondsSinceEpoch.toString(),
          title: title,
        ),
      );
      titleController.clear();
    });
  }

  void toggleTask(Task task) {
    setState(() {
      final index = tasks.indexWhere((item) => item.id == task.id);
      tasks[index] = task.copyWith(completed: !task.completed);
    });
  }

  void removeTask(Task task) {
    setState(() => tasks.removeWhere((item) => item.id == task.id));
  }
}

Build the input row

A controller gives the code access to the current text. onSubmitted makes the keyboard action do the same thing as the button, so the form does not have two subtly different paths.

Dart
Widget buildInput() {
  return Row(
    crossAxisAlignment: CrossAxisAlignment.start,
    children: [
      Expanded(
        child: TextField(
          controller: titleController,
          textInputAction: TextInputAction.done,
          onSubmitted: (_) => addTask(),
          decoration: const InputDecoration(
            labelText: 'What needs doing?',
            border: OutlineInputBorder(),
          ),
        ),
      ),
      const SizedBox(width: 8),
      IconButton.filled(
        onPressed: addTask,
        tooltip: 'Add task',
        icon: const Icon(Icons.add),
      ),
    ],
  );
}

Filter and render the list

Keep the full list as the source of truth and derive the visible list from it. Storing a second filtered list creates two values that can drift out of sync.

Dart
List<Task> get visibleTasks {
  return switch (filter) {
    TaskFilter.all => tasks,
    TaskFilter.active => tasks.where((task) => !task.completed).toList(),
    TaskFilter.completed => tasks.where((task) => task.completed).toList(),
  };
}

Widget buildTaskList() {
  final visible = visibleTasks;
  if (visible.isEmpty) {
    return const Center(child: Text('Nothing here yet.'));
  }

  return ListView.builder(
    itemCount: visible.length,
    itemBuilder: (context, index) {
      final task = visible[index];
      return Dismissible(
        key: ValueKey(task.id),
        background: Container(color: Colors.red),
        onDismissed: (_) => removeTask(task),
        child: CheckboxListTile(
          value: task.completed,
          onChanged: (_) => toggleTask(task),
          title: Text(
            task.title,
            style: TextStyle(
              decoration:
                  task.completed ? TextDecoration.lineThrough : null,
            ),
          ),
        ),
      );
    },
  );
}

ValueKey(task.id) tells Flutter which row is which when the list changes. Without a stable key, stateful row widgets can keep the state of a different task after an insertion or deletion.

Assemble the screen

Dart
@override
Widget build(BuildContext context) {
  return Scaffold(
    appBar: AppBar(title: const Text('My tasks')),
    body: Padding(
      padding: const EdgeInsets.all(16),
      child: Column(
        children: [
          buildInput(),
          const SizedBox(height: 16),
          SegmentedButton<TaskFilter>(
            segments: const [
              ButtonSegment(value: TaskFilter.all, label: Text('All')),
              ButtonSegment(value: TaskFilter.active, label: Text('Active')),
              ButtonSegment(
                value: TaskFilter.completed,
                label: Text('Done'),
              ),
            ],
            selected: {filter},
            onSelectionChanged: (selection) {
              setState(() => filter = selection.first);
            },
          ),
          const SizedBox(height: 8),
          Expanded(child: buildTaskList()),
        ],
      ),
    ),
  );
}

Where this stops scaling

The screen is now useful, but its State owns the model, filtering rules and mutations. That is fine for one screen. When another screen needs the tasks, or when loading and saving become asynchronous, move the list and methods into a store or view model while keeping the widgets focused on presentation.

  • Keep TextEditingController in the form screen because it belongs to the input lifecycle.
  • Move tasks and task operations to a shared store when another widget needs them.
  • Move disk or network work behind a repository; widgets should not know how data is stored.
  • Add tests around filtering and mutations before adding more UI.

Official references

Continue with the primary Flutter and Dart documentation for this topic.

Key takeaways

  • Model app data with typed classes instead of loose maps.
  • Use setState for state that belongs to one screen.
  • Derive filtered lists instead of maintaining duplicate state.
  • Give dynamic rows stable keys based on identity.
  • Keep the first version small; add architecture when the feature actually needs it.

Practice

Make the to-do app yours

Add a due date, an edit action, an undo message after deletion and an empty state that changes for each filter. Then write a widget test that adds a task, marks it complete and confirms it appears in the Done filter.

Show hints
  • Use showDatePicker for the due date, but keep the selected date on the Task model rather than only in the widget.
  • A SnackBarAction can restore a deleted task if you keep the removed object.
  • Test user-visible behaviour with find.text and tester.tap, not private State fields.

Lesson 5

State, setState and User Input

Handle taps, text input and switches, and understand exactly what setState does — and where state should live.

Lesson 11

Build a Flutter Notes App

Build a small notes app end to end: model the data, manage CRUD state, search, navigate, validate edits and plan persistence.

Lesson 7

Lists, Grids and Scrolling

Render long lists efficiently with ListView.builder, lay out grids, add pull-to-refresh and swipe-to-delete, and meet slivers.