Flutter: Dự án thiết kế ứng dụng Todo List
· 8 min read
Todo List là một ứng dụng phổ biến và là dự án tuyệt vời để học Flutter. Bài viết này sẽ hướng dẫn bạn xây dựng một ứng dụng Todo List hoàn chỉnh với các tính năng cơ bản và nâng cao.
1. Cấu trúc dự án
1.1. Cấu trúc thư mục
lib/
├── models/
│ └── todo.dart
├── providers/
│ └── todo_provider.dart
├── screens/
│ ├── home_screen.dart
│ └── add_todo_screen.dart
├── widgets/
│ ├── todo_item.dart
│ └── todo_list.dart
└── main.dart
1.2. Cài đặt dependencies
dependencies:
flutter:
sdk: flutter
provider: ^6.0.0
sqflite: ^2.0.0
path: ^1.8.0
intl: ^0.17.0
2. Model
2.1. Todo Model
class Todo {
final int? id;
final String title;
final String description;
final DateTime dueDate;
final bool isCompleted;
final Priority priority;
Todo({
this.id,
required this.title,
required this.description,
required this.dueDate,
this.isCompleted = false,
this.priority = Priority.medium,
});
Todo copyWith({
int? id,
String? title,
String? description,
DateTime? dueDate,
bool? isCompleted,
Priority? priority,
}) {
return Todo(
id: id ?? this.id,
title: title ?? this.title,
description: description ?? this.description,
dueDate: dueDate ?? this.dueDate,
isCompleted: isCompleted ?? this.isCompleted,
priority: priority ?? this.priority,
);
}
Map<String, dynamic> toMap() {
return {
'id': id,
'title': title,
'description': description,
'dueDate': dueDate.toIso8601String(),
'isCompleted': isCompleted ? 1 : 0,
'priority': priority.index,
};
}
factory Todo.fromMap(Map<String, dynamic> map) {
return Todo(
id: map['id'],
title: map['title'],
description: map['description'],
dueDate: DateTime.parse(map['dueDate']),
isCompleted: map['isCompleted'] == 1,
priority: Priority.values[map['priority']],
);
}
}
enum Priority { low, medium, high }
3. Provider
3.1. Todo Provider
class TodoProvider extends ChangeNotifier {
List<Todo> _todos = [];
bool _isLoading = false;
String? _error;
List<Todo> get todos => _todos;
bool get isLoading => _isLoading;
String? get error => _error;
Future<void> loadTodos() async {
_isLoading = true;
_error = null;
notifyListeners();
try {
final db = await DatabaseHelper.instance.database;
final List<Map<String, dynamic>> maps = await db.query('todos');
_todos = maps.map((map) => Todo.fromMap(map)).toList();
} catch (e) {
_error = e.toString();
} finally {
_isLoading = false;
notifyListeners();
}
}
Future<void> addTodo(Todo todo) async {
try {
final db = await DatabaseHelper.instance.database;
final id = await db.insert('todos', todo.toMap());
_todos.add(todo.copyWith(id: id));
notifyListeners();
} catch (e) {
_error = e.toString();
notifyListeners();
}
}
Future<void> updateTodo(Todo todo) async {
try {
final db = await DatabaseHelper.instance.database;
await db.update(
'todos',
todo.toMap(),
where: 'id = ?',
whereArgs: [todo.id],
);
final index = _todos.indexWhere((t) => t.id == todo.id);
_todos[index] = todo;
notifyListeners();
} catch (e) {
_error = e.toString();
notifyListeners();
}
}
Future<void> deleteTodo(int id) async {
try {
final db = await DatabaseHelper.instance.database;
await db.delete(
'todos',
where: 'id = ?',
whereArgs: [id],
);
_todos.removeWhere((todo) => todo.id == id);
notifyListeners();
} catch (e) {
_error = e.toString();
notifyListeners();
}
}
}
3.2. Database Helper
class DatabaseHelper {
static final DatabaseHelper instance = DatabaseHelper._init();
static Database? _database;
DatabaseHelper._init();
Future<Database> get database async {
if (_database != null) return _database!;
_database = await _initDB('todos.db');
return _database!;
}
Future<Database> _initDB(String filePath) async {
final dbPath = await getDatabasesPath();
final path = join(dbPath, filePath);
return await openDatabase(
path,
version: 1,
onCreate: _createDB,
);
}
Future<void> _createDB(Database db, int version) async {
await db.execute('''
CREATE TABLE todos(
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
description TEXT,
dueDate TEXT NOT NULL,
isCompleted INTEGER NOT NULL,
priority INTEGER NOT NULL
)
''');
}
}
4. UI Components
4.1. Todo Item Widget
class TodoItem extends StatelessWidget {
final Todo todo;
final VoidCallback onToggle;
final VoidCallback onDelete;
const TodoItem({
Key? key,
required this.todo,
required this.onToggle,
required this.onDelete,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Card(
margin: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: ListTile(
leading: Checkbox(
value: todo.isCompleted,
onChanged: (_) => onToggle(),
),
title: Text(
todo.title,
style: TextStyle(
decoration: todo.isCompleted ? TextDecoration.lineThrough : null,
),
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(todo.description),
SizedBox(height: 4),
Text(
'Due: ${DateFormat('MMM dd, yyyy').format(todo.dueDate)}',
style: TextStyle(
color: todo.dueDate.isBefore(DateTime.now())
? Colors.red
: Colors.grey,
),
),
],
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
_buildPriorityIndicator(),
IconButton(
icon: Icon(Icons.delete),
onPressed: onDelete,
),
],
),
),
);
}
Widget _buildPriorityIndicator() {
Color color;
switch (todo.priority) {
case Priority.high:
color = Colors.red;
break;
case Priority.medium:
color = Colors.orange;
break;
case Priority.low:
color = Colors.green;
break;
}
return Container(
width: 12,
height: 12,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
),
);
}
}
4.2. Todo List Widget
class TodoList extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Consumer<TodoProvider>(
builder: (context, provider, child) {
if (provider.isLoading) {
return Center(child: CircularProgressIndicator());
}
if (provider.error != null) {
return Center(
child: Text(
'Error: ${provider.error}',
style: TextStyle(color: Colors.red),
),
);
}
if (provider.todos.isEmpty) {
return Center(
child: Text('No todos yet. Add one!'),
);
}
return ListView.builder(
itemCount: provider.todos.length,
itemBuilder: (context, index) {
final todo = provider.todos[index];
return TodoItem(
todo: todo,
onToggle: () {
provider.updateTodo(
todo.copyWith(isCompleted: !todo.isCompleted),
);
},
onDelete: () {
provider.deleteTodo(todo.id!);
},
);
},
);
},
);
}
}
5. Screens
5.1. Home Screen
class HomeScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('Todo List'),
actions: [
IconButton(
icon: Icon(Icons.sort),
onPressed: () {
// Hiển thị dialog sắp xếp
},
),
],
),
body: TodoList(),
floatingActionButton: FloatingActionButton(
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(builder: (_) => AddTodoScreen()),
);
},
child: Icon(Icons.add),
),
);
}
}
5.2. Add Todo Screen
class AddTodoScreen extends StatefulWidget {
@override
_AddTodoScreenState createState() => _AddTodoScreenState();
}
class _AddTodoScreenState extends State<AddTodoScreen> {
final _formKey = GlobalKey<FormState>();
final _titleController = TextEditingController();
final _descriptionController = TextEditingController();
DateTime _dueDate = DateTime.now();
Priority _priority = Priority.medium;
@override
void dispose() {
_titleController.dispose();
_descriptionController.dispose();
super.dispose();
}
Future<void> _selectDate() async {
final DateTime? picked = await showDatePicker(
context: context,
initialDate: _dueDate,
firstDate: DateTime.now(),
lastDate: DateTime.now().add(Duration(days: 365)),
);
if (picked != null && picked != _dueDate) {
setState(() {
_dueDate = picked;
});
}
}
void _submitForm() {
if (_formKey.currentState!.validate()) {
final todo = Todo(
title: _titleController.text,
description: _descriptionController.text,
dueDate: _dueDate,
priority: _priority,
);
context.read<TodoProvider>().addTodo(todo);
Navigator.pop(context);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('Add Todo'),
),
body: Form(
key: _formKey,
child: ListView(
padding: EdgeInsets.all(16),
children: [
TextFormField(
controller: _titleController,
decoration: InputDecoration(
labelText: 'Title',
border: OutlineInputBorder(),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter a title';
}
return null;
},
),
SizedBox(height: 16),
TextFormField(
controller: _descriptionController,
decoration: InputDecoration(
labelText: 'Description',
border: OutlineInputBorder(),
),
maxLines: 3,
),
SizedBox(height: 16),
ListTile(
title: Text('Due Date'),
subtitle: Text(
DateFormat('MMM dd, yyyy').format(_dueDate),
),
trailing: Icon(Icons.calendar_today),
onTap: _selectDate,
),
SizedBox(height: 16),
DropdownButtonFormField<Priority>(
value: _priority,
decoration: InputDecoration(
labelText: 'Priority',
border: OutlineInputBorder(),
),
items: Priority.values.map((priority) {
return DropdownMenuItem(
value: priority,
child: Text(priority.toString().split('.').last),
);
}).toList(),
onChanged: (value) {
if (value != null) {
setState(() {
_priority = value;
});
}
},
),
SizedBox(height: 24),
ElevatedButton(
onPressed: _submitForm,
child: Text('Add Todo'),
style: ElevatedButton.styleFrom(
padding: EdgeInsets.symmetric(vertical: 16),
),
),
],
),
),
);
}
}
6. Main App
void main() {
runApp(MyApp());
}
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return ChangeNotifierProvider(
create: (_) => TodoProvider(),
child: MaterialApp(
title: 'Todo List',
theme: ThemeData(
primarySwatch: Colors.blue,
visualDensity: VisualDensity.adaptivePlatformDensity,
),
home: HomeScreen(),
),
);
}
}
7. Tính năng nâng cao
7.1. Sắp xếp và lọc
enum SortOption { dueDate, priority, title }
class TodoProvider extends ChangeNotifier {
// ... existing code ...
SortOption _sortOption = SortOption.dueDate;
bool _showCompleted = true;
List<Todo> get filteredTodos {
var todos = _todos;
if (!_showCompleted) {
todos = todos.where((todo) => !todo.isCompleted).toList();
}
switch (_sortOption) {
case SortOption.dueDate:
todos.sort((a, b) => a.dueDate.compareTo(b.dueDate));
break;
case SortOption.priority:
todos.sort((a, b) => b.priority.index.compareTo(a.priority.index));
break;
case SortOption.title:
todos.sort((a, b) => a.title.compareTo(b.title));
break;
}
return todos;
}
void setSortOption(SortOption option) {
_sortOption = option;
notifyListeners();
}
void toggleShowCompleted() {
_showCompleted = !_showCompleted;
notifyListeners();
}
}
7.2. Thông báo
class NotificationService {
static Future<void> scheduleNotification(Todo todo) async {
final FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin =
FlutterLocalNotificationsPlugin();
const AndroidInitializationSettings initializationSettingsAndroid =
AndroidInitializationSettings('@mipmap/ic_launcher');
final InitializationSettings initializationSettings =
InitializationSettings(android: initializationSettingsAndroid);
await flutterLocalNotificationsPlugin.initialize(initializationSettings);
await flutterLocalNotificationsPlugin.zonedSchedule(
todo.id!,
'Todo Reminder',
todo.title,
TZDateTime.from(todo.dueDate, local),
NotificationDetails(
android: AndroidNotificationDetails(
'todo_reminders',
'Todo Reminders',
importance: Importance.high,
priority: Priority.high,
),
),
androidAllowWhileIdle: true,
uiLocalNotificationDateInterpretation:
UILocalNotificationDateInterpretation.absoluteTime,
);
}
}
Kết luận
Ứng dụng Todo List là một dự án tuyệt vời để học Flutter. Nó bao gồm nhiều khái niệm quan trọng như:
- Quản lý state với Provider
- Lưu trữ dữ liệu local với SQLite
- Xây dựng UI phức tạp
- Xử lý form và validation
- Quản lý thông báo
Bạn có thể mở rộng dự án này bằng cách thêm các tính năng như:
- Đồng bộ hóa với backend
- Phân loại todo theo danh mục
- Tìm kiếm và lọc nâng cao
- Giao diện người dùng tùy chỉnh
- Hỗ trợ đa ngôn ngữ
Tài liệu tham khảo: