Skip to content

Build a Flutter Todo App — Complete Project Tutorial with Dart

DodaTech Updated 2026-06-28 12 min read

In this tutorial, you will learn about Build a Flutter Todo App. We cover key concepts, practical examples, and best practices to help you master this topic.

Build a complete Flutter todo app using Dart that demonstrates CRUD operations, local persistence with sqflite, state management with Provider, and a clean material design UI that works across Android, iOS, and web platforms.

What You Will Learn

  • Setting up a Flutter project from scratch
  • Designing a todo data model with Dart classes
  • Implementing CRUD operations with SQLite via sqflite
  • Managing application state with the Provider pattern
  • Building a responsive UI with Material Design components
  • Persisting data across app restarts
  • Adding search, filtering, and sorting features

Why It Matters

The todo app is the software engineering equivalent of "Hello, World" for full-stack mobile development — it touches every layer of a real application: data modeling, persistence, state management, UI rendering, and user interaction. Completing this project gives you a reusable template you can extend into any CRUD-based application: note-taking apps, habit trackers, inventory systems, or task managers. Doda Browser's internal feature request tracker started as a todo app prototype that grew into a full project management tool used by the team.

Real-World Use

A field technician uses a todo app to track inspection tasks at different sites. Each task has a priority, location, due date, and status. Completed tasks generate a report. The app stores everything locally on the device since cellular coverage is unreliable. This exact pattern appears in Durga Antivirus Pro's scan scheduler, where scan tasks are persisted locally and synced to the cloud when connectivity is available.

Learning Path

flowchart LR
  A[GraphQL] --> B[Project Todo App\nYou are here]
  B --> C[Project Weather App]
  style B fill:#f90,color:#fff

Project Setup

Create a new Flutter project:

flutter create todo_app
cd todo_app

Add the required dependencies to pubspec.yaml:

dependencies:
  flutter:
    sdk: flutter
  provider: ^6.1.0
  sqflite: ^2.3.0
  path: ^1.9.0
  intl: ^0.19.0

The provider package handles state management, sqflite provides local SQLite persistence, path helps construct the database file location, and intl formats dates.

Data Model

Define the todo item data model with a Dart class:

// lib/models/todo.dart
class Todo {
  final int? id;
  final String title;
  final String description;
  final bool isCompleted;
  final int priority;
  final DateTime createdAt;
  final DateTime? dueDate;

  Todo({
    this.id,
    required this.title,
    this.description = '',
    this.isCompleted = false,
    this.priority = 0,
    DateTime? createdAt,
    this.dueDate,
  }) : createdAt = createdAt ?? DateTime.now();

  Todo copyWith({
    int? id,
    String? title,
    String? description,
    bool? isCompleted,
    int? priority,
    DateTime? createdAt,
    DateTime? dueDate,
  }) {
    return Todo(
      id: id ?? this.id,
      title: title ?? this.title,
      description: description ?? this.description,
      isCompleted: isCompleted ?? this.isCompleted,
      priority: priority ?? this.priority,
      createdAt: createdAt ?? this.createdAt,
      dueDate: dueDate ?? this.dueDate,
    );
  }

  Map<String, dynamic> toMap() {
    return {
      if (id != null) 'id': id,
      'title': title,
      'description': description,
      'isCompleted': isCompleted ? 1 : 0,
      'priority': priority,
      'createdAt': createdAt.toIso8601String(),
      'dueDate': dueDate?.toIso8601String(),
    };
  }

  factory Todo.fromMap(Map<String, dynamic> map) {
    return Todo(
      id: map['id'] as int?,
      title: map['title'] as String,
      description: map['description'] as String? ?? '',
      isCompleted: (map['isCompleted'] as int) == 1,
      priority: map['priority'] as int? ?? 0,
      createdAt: DateTime.parse(map['createdAt'] as String),
      dueDate: map['dueDate'] != null ? DateTime.parse(map['dueDate'] as String) : null,
    );
  }

  @override
  String toString() => 'Todo(id: $id, title: $title, completed: $isCompleted)';
}

The copyWith method enables immutable state updates — when you change a field, you get a new Todo instance instead of mutating the existing one.

Database Service

Create the database helper class that manages the SQLite connection:

// lib/services/database_service.dart
import 'package:sqflite/sqflite.dart';
import 'package:path/path.dart';
import '../models/todo.dart';

class DatabaseService {
  static final DatabaseService _instance = DatabaseService._internal();
  factory DatabaseService() => _instance;
  DatabaseService._internal();

  Database? _database;

  Future<Database> get database async {
    if (_database != null) return _database!;
    _database = await _initDatabase();
    return _database!;
  }

  Future<Database> _initDatabase() async {
    final dbPath = await getDatabasesPath();
    final path = join(dbPath, 'todo_app.db');

    return await openDatabase(
      path,
      version: 1,
      onCreate: (db, version) async {
        await db.execute('''
          CREATE TABLE todos(
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            title TEXT NOT NULL,
            description TEXT,
            isCompleted INTEGER NOT NULL DEFAULT 0,
            priority INTEGER NOT NULL DEFAULT 0,
            createdAt TEXT NOT NULL,
            dueDate TEXT
          )
        ''');
      },
    );
  }

  Future<int> insertTodo(Todo todo) async {
    final db = await database;
    return await db.insert('todos', todo.toMap());
  }

  Future<List<Todo>> getTodos() async {
    final db = await database;
    final maps = await db.query('todos', orderBy: 'priority DESC, createdAt DESC');
    return maps.map((map) => Todo.fromMap(map)).toList();
  }

  Future<int> updateTodo(Todo todo) async {
    final db = await database;
    return await db.update(
      'todos',
      todo.toMap(),
      where: 'id = ?',
      whereArgs: [todo.id],
    );
  }

  Future<int> deleteTodo(int id) async {
    final db = await database;
    return await db.delete('todos', where: 'id = ?', whereArgs: [id]);
  }

  Future<List<Todo>> searchTodos(String query) async {
    final db = await database;
    final maps = await db.query(
      'todos',
      where: 'title LIKE ? OR description LIKE ?',
      whereArgs: ['%$query%', '%$query%'],
      orderBy: 'priority DESC, createdAt DESC',
    );
    return maps.map((map) => Todo.fromMap(map)).toList();
  }

  Future<void> close() async {
    final db = await database;
    await db.close();
    _database = null;
  }
}

The Singleton Patternton" >}} pattern ensures only one database connection exists throughout the app's lifecycle.

State Management with Provider

Create the TodoProvider class that manages the application state:

// lib/providers/todo_provider.dart
import 'package:flutter/foundation.dart';
import '../models/todo.dart';
import '../services/database_service.dart';

class TodoProvider extends ChangeNotifier {
  final DatabaseService _db = DatabaseService();
  List<Todo> _todos = [];
  bool _isLoading = false;
  String _filterQuery = '';
  int _filterPriority = -1;
  bool _showCompleted = true;

  List<Todo> get todos {
    var filtered = List<Todo>.from(_todos);

    if (!_showCompleted) {
      filtered = filtered.where((t) => !t.isCompleted).toList();
    }

    if (_filterPriority >= 0) {
      filtered = filtered.where((t) => t.priority == _filterPriority).toList();
    }

    if (_filterQuery.isNotEmpty) {
      final query = _filterQuery.toLowerCase();
      filtered = filtered
          .where((t) => t.title.toLowerCase().contains(query) || t.description.toLowerCase().contains(query))
          .toList();
    }

    return filtered;
  }

  bool get isLoading => _isLoading;

  Future<void> loadTodos() async {
    _isLoading = true;
    notifyListeners();

    _todos = await _db.getTodos();

    _isLoading = false;
    notifyListeners();
  }

  Future<void> addTodo(Todo todo) async {
    final id = await _db.insertTodo(todo);
    _todos.insert(0, todo.copyWith(id: id));
    notifyListeners();
  }

  Future<void> toggleTodo(int id) async {
    final index = _todos.indexWhere((t) => t.id == id);
    if (index == -1) return;

    final updated = _todos[index].copyWith(isCompleted: !_todos[index].isCompleted);
    await _db.updateTodo(updated);
    _todos[index] = updated;
    notifyListeners();
  }

  Future<void> updateTodo(Todo todo) async {
    await _db.updateTodo(todo);
    final index = _todos.indexWhere((t) => t.id == todo.id);
    if (index != -1) {
      _todos[index] = todo;
      notifyListeners();
    }
  }

  Future<void> deleteTodo(int id) async {
    await _db.deleteTodo(id);
    _todos.removeWhere((t) => t.id == id);
    notifyListeners();
  }

  void setFilterQuery(String query) {
    _filterQuery = query;
    notifyListeners();
  }

  void setFilterPriority(int priority) {
    _filterPriority = priority;
    notifyListeners();
  }

  void toggleShowCompleted() {
    _showCompleted = !_showCompleted;
    notifyListeners();
  }
}

The provider extends ChangeNotifier, which allows widgets to listen for changes and rebuild automatically when notifyListeners() is called.

Main Application Entry

Wire up the provider and define the app entry point:

// lib/main.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'providers/todo_provider.dart';
import 'screens/home_screen.dart';

void main() {
  runApp(
    ChangeNotifierProvider(
      create: (_) => TodoProvider()..loadTodos(),
      child: MaterialApp(
        title: 'Todo App',
        theme: ThemeData(
          colorSchemeSeed: Colors.indigo,
          useMaterial3: true,
        ),
        home: HomeScreen(),
      ),
    ),
  );
}

Home Screen UI

Build the main home screen with a list of todos, filtering, and an add button:

// lib/screens/home_screen.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:intl/intl.dart';
import '../providers/todo_provider.dart';
import '../models/todo.dart';
import 'add_todo_screen.dart';

class HomeScreen extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('My Tasks'),
        actions: [
          IconButton(
            icon: Icon(Icons.filter_list),
            onPressed: () => _showFilterDialog(context),
          ),
        ],
      ),
      body: Consumer<TodoProvider>(
        builder: (context, provider, child) {
          if (provider.isLoading) {
            return Center(child: CircularProgressIndicator());
          }

          final todos = provider.todos;

          if (todos.isEmpty) {
            return Center(
              child: Column(
                mainAxisAlignment: MainAxisAlignment.center,
                children: [
                  Icon(Icons.task_alt, size: 64, color: Colors.grey),
                  SizedBox(height: 16),
                  Text('No tasks yet', style: Theme.of(context).textTheme.headlineSmall),
                  SizedBox(height: 8),
                  Text('Tap + to add your first task'),
                ],
              ),
            );
          }

          return ListView.builder(
            itemCount: todos.length,
            itemBuilder: (context, index) {
              final todo = todos[index];
              return _TodoCard(todo: todo);
            },
          );
        },
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () => Navigator.push(
          context,
          MaterialPageRoute(builder: (_) => AddTodoScreen()),
        ),
        child: Icon(Icons.add),
      ),
    );
  }

  void _showFilterDialog(BuildContext context) {
    final provider = context.read<TodoProvider>();
    showDialog(
      context: context,
      builder: (context) => AlertDialog(
        title: Text('Filter'),
        content: Column(
          mainAxisSize: MainAxisSize.min,
          children: [
            CheckboxListTile(
              title: Text('Show completed'),
              value: provider._showCompleted,
              onChanged: (_) {
                provider.toggleShowCompleted();
                Navigator.pop(context);
              },
            ),
          ],
        ),
      ),
    );
  }
}

class _TodoCard extends StatelessWidget {
  final Todo todo;

  const _TodoCard({required this.todo});

  @override
  Widget build(BuildContext context) {
    final provider = context.read<TodoProvider>();
    final dateFormat = DateFormat('MMM d, yyyy');

    return Card(
      margin: EdgeInsets.symmetric(horizontal: 16, vertical: 4),
      child: ListTile(
        leading: Checkbox(
          value: todo.isCompleted,
          onChanged: (_) => provider.toggleTodo(todo.id!),
        ),
        title: Text(
          todo.title,
          style: TextStyle(
            decoration: todo.isCompleted ? TextDecoration.lineThrough : null,
          ),
        ),
        subtitle: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            if (todo.description.isNotEmpty)
              Text(todo.description, maxLines: 1, overflow: TextOverflow.ellipsis),
            SizedBox(height: 4),
            Row(
              children: [
                Icon(Icons.flag, size: 14, color: _priorityColor(todo.priority)),
                SizedBox(width: 4),
                if (todo.dueDate != null) ...[
                  Icon(Icons.calendar_today, size: 14),
                  SizedBox(width: 4),
                  Text(dateFormat.format(todo.dueDate!)),
                ],
              ],
            ),
          ],
        ),
        trailing: IconButton(
          icon: Icon(Icons.delete_outline),
          onPressed: () => _confirmDelete(context, todo),
        ),
        onTap: () => _editTodo(context, todo),
      ),
    );
  }

  Color _priorityColor(int priority) {
    switch (priority) {
      case 2: return Colors.red;
      case 1: return Colors.orange;
      default: return Colors.grey;
    }
  }

  void _confirmDelete(BuildContext context, Todo todo) {
    showDialog(
      context: context,
      builder: (context) => AlertDialog(
        title: Text('Delete task?'),
        actions: [
          TextButton(onPressed: () => Navigator.pop(context), child: Text('Cancel')),
          TextButton(
            onPressed: () {
              context.read<TodoProvider>().deleteTodo(todo.id!);
              Navigator.pop(context);
            },
            child: Text('Delete', style: TextStyle(color: Colors.red)),
          ),
        ],
      ),
    );
  }

  void _editTodo(BuildContext context, Todo todo) {
    Navigator.push(
      context,
      MaterialPageRoute(builder: (_) => AddTodoScreen(existingTodo: todo)),
    );
  }
}

Add/Edit Todo Screen

Create the form screen for adding or editing todos:

// lib/screens/add_todo_screen.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/todo_provider.dart';
import '../models/todo.dart';

class AddTodoScreen extends StatefulWidget {
  final Todo? existingTodo;

  const AddTodoScreen({this.existingTodo});

  @override
  State<AddTodoScreen> createState() => _AddTodoScreenState();
}

class _AddTodoScreenState extends State<AddTodoScreen> {
  final _formKey = GlobalKey<FormState>();
  late final TextEditingController _titleController;
  late final TextEditingController _descriptionController;
  late int _priority;
  DateTime? _dueDate;

  @override
  void initState() {
    super.initState();
    _titleController = TextEditingController(text: widget.existingTodo?.title ?? '');
    _descriptionController = TextEditingController(text: widget.existingTodo?.description ?? '');
    _priority = widget.existingTodo?.priority ?? 0;
    _dueDate = widget.existingTodo?.dueDate;
  }

  @override
  void dispose() {
    _titleController.dispose();
    _descriptionController.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    final isEditing = widget.existingTodo != null;

    return Scaffold(
      appBar: AppBar(title: Text(isEditing ? 'Edit Task' : 'New Task')),
      body: Form(
        key: _formKey,
        child: ListView(
          padding: EdgeInsets.all(16),
          children: [
            TextFormField(
              controller: _titleController,
              decoration: InputDecoration(labelText: 'Title', border: OutlineInputBorder()),
              validator: (value) => value == null || value.isEmpty ? 'Title is required' : null,
            ),
            SizedBox(height: 16),
            TextFormField(
              controller: _descriptionController,
              decoration: InputDecoration(labelText: 'Description', border: OutlineInputBorder()),
              maxLines: 3,
            ),
            SizedBox(height: 16),
            DropdownButtonFormField<int>(
              value: _priority,
              decoration: InputDecoration(labelText: 'Priority', border: OutlineInputBorder()),
              items: [
                DropdownMenuItem(value: 0, child: Text('Low')),
                DropdownMenuItem(value: 1, child: Text('Medium')),
                DropdownMenuItem(value: 2, child: Text('High')),
              ],
              onChanged: (value) => setState(() => _priority = value!),
            ),
            SizedBox(height: 16),
            ListTile(
              title: Text(_dueDate != null ? 'Due: ${_dueDate!.toLocal().toString().split(' ')[0]}' : 'Set due date'),
              trailing: Icon(Icons.calendar_today),
              onTap: () async {
                final date = await showDatePicker(
                  context: context,
                  initialDate: _dueDate ?? DateTime.now(),
                  firstDate: DateTime.now(),
                  lastDate: DateTime.now().add(Duration(days: 365)),
                );
                if (date != null) setState(() => _dueDate = date);
              },
            ),
            SizedBox(height: 24),
            FilledButton(
              onPressed: _saveTodo,
              child: Text(isEditing ? 'Update' : 'Add Task'),
            ),
          ],
        ),
      ),
    );
  }

  void _saveTodo() {
    if (!_formKey.currentState!.validate()) return;

    final provider = context.read<TodoProvider>();
    final todo = Todo(
      id: widget.existingTodo?.id,
      title: _titleController.text,
      description: _descriptionController.text,
      isCompleted: widget.existingTodo?.isCompleted ?? false,
      priority: _priority,
      dueDate: _dueDate,
    );

    if (widget.existingTodo != null) {
      provider.updateTodo(todo);
    } else {
      provider.addTodo(todo);
    }

    Navigator.pop(context);
  }
}

Common Mistakes

  1. Not handling database versioning: When you add a new column or table, the existing database schema does not match. Use onUpgrade callback in openDatabase with version increments to run ALTER TABLE statements.

  2. Blocking the UI thread with database calls: SQLite operations are asynchronous in sqflite, but if you accidentally use the synchronous version of a method, the UI freezes. Always use await with sqflite methods.

  3. Forgetting to dispose controllers: TextEditingController and FocusNode must be disposed in the dispose() method to prevent memory leaks. Flutter does not garbage-collect these automatically.

  4. Storing full objects in Provider state: Provider calls notifyListeners() on every change, which rebuilds all listening widgets. If your state object is large, consider using Selector or splitting providers to minimize rebuilds.

  5. Not using immutable state: Mutating a Todo object directly (e.g., todo.isCompleted = true) without creating a new instance prevents the UI from detecting the change. Always use copyWith to produce a new instance.

  6. Ignoring database close: Opening a database connection without closing it leaks file descriptors. Close the database in the provider's dispose() method.

  7. Hard-coding database path during tests: Unit tests that write to the real app database leave test data behind. Use inMemoryDatabase or a temporary path in tests.

Practice Questions

  1. Why does the Todo model use copyWith instead of allowing direct field mutation?
  2. How does the Provider pattern notify widgets when the todo list changes?
  3. What would you change to add a "category" field to each todo?
  4. Why is the database service implemented as a singleton?
  5. Challenge: Add a "sync to cloud" feature using HTTP. When the device has network connectivity, upload new and modified todos to a REST API and download remote changes. Use the connectivity_plus package to detect network state changes.

Mini Project

Extend the todo app with these features:

  • Search bar with debounced text input that filters todos in real time
  • Swipe-to-delete with Dismissible widget and undo snackbar
  • Dark mode toggle persisted in SharedPreferences
  • Todo categories with a separate table, displayed as chips that filter the list
  • Statistics screen showing total tasks, completion rate, and average priority
  • Widget for Android home screen showing today's due tasks (using home_widget package)

FAQ

Why use sqflite instead of SharedPreferences?

SharedPreferences stores simple key-value pairs and is not suitable for structured data with multiple fields. sqflite provides relational storage with SQL queries, indexing, and transactions — essential for any app with more than a handful of records.

How do I test the database layer?

Use sqflite's inMemoryDatabase factory to create an isolated database for each test. Insert test data, run queries, and assert on the results without affecting the production database.

Can I use this same architecture for a note-taking app?

Yes. The CRUD + Provider + sqflite pattern is identical for notes, journals, or any list-based content. Replace the Todo model with a Note model and adjust the UI fields.

How do I add undo functionality?

When a todo is deleted, store it in a temporary list and show a SnackBar with 'Undo'. If the user taps Undo within 3 seconds, re-insert the todo. This pattern is used in Gmail and Google Keep.

What is the difference between Provider and Riverpod?

Provider is the simpler of the two and is included in Flutter's recommended architecture. Riverpod offers more features like autodispose, family modifiers, and compile-time safety. For this todo app, Provider is sufficient.

What is Next

Proceed to Project Weather App to build an app that fetches and displays live weather data from a REST API. Then explore Project E-Commerce UI for complex UI layouts with navigation and state.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro