Build a Flutter Todo App — Complete Project Tutorial with Dart
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
Not handling database versioning: When you add a new column or table, the existing database schema does not match. Use
onUpgradecallback inopenDatabasewith version increments to run ALTER TABLE statements.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
awaitwith sqflite methods.Forgetting to dispose controllers:
TextEditingControllerandFocusNodemust be disposed in thedispose()method to prevent memory leaks. Flutter does not garbage-collect these automatically.Storing full objects in Provider state: Provider calls
notifyListeners()on every change, which rebuilds all listening widgets. If your state object is large, consider usingSelectoror splitting providers to minimize rebuilds.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 usecopyWithto produce a new instance.Ignoring database close: Opening a database connection without closing it leaks file descriptors. Close the database in the provider's
dispose()method.Hard-coding database path during tests: Unit tests that write to the real app database leave test data behind. Use
inMemoryDatabaseor a temporary path in tests.
Practice Questions
- Why does the Todo model use
copyWithinstead of allowing direct field mutation? - How does the Provider pattern notify widgets when the todo list changes?
- What would you change to add a "category" field to each todo?
- Why is the database service implemented as a singleton?
- 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_pluspackage 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
Dismissiblewidget 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_widgetpackage)
FAQ
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