Bài viết được sự cho phép của tác giả Khiêm Lê
Todo App Flutter
Todo App Flutter là một ứng dụng giúp chúng ta có thể lưu lại những công việc cần làm, tránh việc chúng ta quên đi sau một thời gian. Todo app là một ứng dụng khá đơn gian mà ai học qua lập trình di động đều biết và code khi mới bắt đầu, hôm nay chúng ta sẽ cùng thực hiện điều đó.
Trong bài này sẽ có các phần sau:
- Thiết kế giao diện ứng dụng
- Thiết lập sqlite database
- Viết code thực thi
Tạo project named Todo và bắt đầu với phần đầu tiên nào!
Thiết kế giao diện
Ý tưởng ứng dụng như sau: màn hình chính sẽ có một ListView hiện ra tất cả các task, mỗi item thì sẽ có một trailing là một button, nhấn vào sẽ hiện ra PopupMenu có hai tùy chọn là Edit và Delete. Nhấn vào Delete sẽ hiện một AlertDialog xác nhận xóa task đó. Nhấn vào Edit sẽ cho phép mình sửa task đó. Một FAB nhấn vào sẽ đưa mình đến màn hình thêm task. Màn hình thêm task đơn giản chỉ có một TextField để nhập task, một nút save phía trên thanh AppBar. Ok, bắt tay vào code nào.
Màn hình chính
Đầu tiên mình tạo một folder đặt tên là screens nằm trong folder lib. Tiếp theo, tạo một file main_screen.dart – đây chính là file màn hình chính của mình. Trong màn hình chính, mình sẽ có một ListView, một FAB, và một cái AppBar hiện tên ứng dụng. Vậy chúng ta sẽ có code sau:
import 'package:flutter/material.dart';
// Vì sau này mình sẽ lấy dữ liệu từ Database đổ vào ListView
// nên dùng StatefulWidget để có thể thay đổi được UI
class MainScreen extends StatefulWidget {
// Mình đặt id để xíu nữa mình dùng trong routes
static const id = 'main_screen';
_MainScreenState createState() => _MainScreenState();
}
class _MainScreenState extends State<MainScreen> {
Widget build(BuildContext context) {
return Scaffold(
// Một cái AppBar đơn giản hiển thị tên app
appBar: AppBar(
title: Text('Todo App'),
),
// FAB sẽ là biểu tượng Add (ý là add task vào ý)
floatingActionButton: FloatingActionButton(
onPressed: () {},
child: Icon(Icons.add),
),
body: ListView.builder(
// Mình demo với 9 item nha
// Phần sau mình sẽ lấy data từ database sau
itemCount: 9,
itemBuilder: (context, index) {
// Mỗi item là một ListTile
return ListTile(
title: Text('Task $index'),
);
},
),
);
}
}
Mình muốn là mỗi item có trailing là một button, khi nhấn vào nó sẽ show một PopupMenu, mình sẽ chọn sử dụng Widget PopupMenuItem. Mình muốn menu có hai item là Edit và Delete, khi nhấn vào Edit sẽ đưa mình đến màn hình Edit, khi nhấn Delete thì sẽ hiển thị AlertDialog xác nhận. Code của mình như sau:
// ...
return ListTile(
title: Text('Task $index'),
trailing: PopupMenuButton(
onSelected: (i) {
if (i == 0) {
// Code chuyển sang màn hình edit
} else if (i == 1) {
// Hiện dialog
showDialog(
context: context,
builder: (context) {
return AlertDialog(
title: Text('Confirm your deletion'),
content: Text(
'This task will be deleted permanently. Do you want to do it?'),
actions: <Widget>[
// Nút hủy, nhấn vào chỉ pop cái dialog đi thôi không làm gì thêm
FlatButton(
onPressed: () {
Navigator.pop(context);
},
child: Text('CANCEL'),
),
FlatButton(
onPressed: () {
// Xóa task...
Navigator.pop(context);
},
child: Text(
'DELETE',
style: TextStyle(color: Colors.red),
),
),
],
);
},
);
}
},
itemBuilder: (context) {
return [
PopupMenuItem(
value: 0,
child: Text('Edit'),
),
PopupMenuItem(
value: 1,
child: Text('Delete'),
),
];
},
),
);
// ...
Màn hình thêm task
Trong thư mục screens, mình tạo một file mới tên là add_task_screen.dart. Trong màn hình này, mình muốn trên AppBar có một IconButton save, nút back cũng sẽ được mình Override (mình sẽ giải thích phần này sau). Code của mình như sau:
import 'package:flutter/material.dart';
class AddTaskScreen extends StatefulWidget {
// Mình đặt id dùng trong routes
static const id = 'add_task_screen';
_AddTaskScreenState createState() => _AddTaskScreenState();
}
class _AddTaskScreenState extends State<AddTaskScreen> {
final _taskController = TextEditingController();
bool _inSync = false;
String _taskError;
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('Add task'),
backgroundColor: Colors.white,
leading: IconButton(
icon: Icon(Icons.arrow_back),
// Nút back trên appbar sẽ không nhấn được khi đang lưu dữ liệu
onPressed: !_inSync
? () {
Navigator.pop(context);
}
: null,
),
actions: <Widget>[
// Tương tự, như nút back tránh trường hợp user nhấn 2 lần
!_inSync
? IconButton(
icon: Icon(Icons.done),
onPressed: () {
},
)
: Icon(Icons.refresh),
],
elevation: 0.0,
textTheme: TextTheme(
title: Theme.of(context).textTheme.title,
),
iconTheme: IconThemeData(
color: Colors.black87,
),
),
body: WillPopScope(
// Ngăn nút người dùng nhấn back trên android khi đang lưu dữ liệu
onWillPop: () async {
if (!_inSync) return true;
return false;
},
child: Padding(
padding: EdgeInsets.all(16.0),
child: TextField(
controller: _taskController,
decoration: InputDecoration(
labelText: 'Task',
errorText: _taskError,
border: OutlineInputBorder(),
),
),
),
),
);
}
}
Giờ đến lượt file main.dart, chúng ta cần phải thêm các màn hình này vào để navigate giữa chúng. File main.dart như sau:
import 'package:flutter/material.dart';
// import screens
import 'screens/main_screen.dart';
import 'screens/add_task_screen.dart';
void main() => runApp(MyApp());
class MyApp extends StatelessWidget {
Widget build(BuildContext context) {
return MaterialApp(
initialRoute: MainScreen.id,
routes: {
MainScreen.id: (_) => MainScreen(),
AddTaskScreen.id: (_) => AddTaskScreen(),
},
);
}
}
Giờ chúng ta sẽ sửa lại file main_screen.dart, chúng ta sẽ bắt sự kiện onPress FAB thì đi sang màn hình add task. Code sửa lại như sau:
import 'package:flutter/material.dart';
// import screens
import 'add_task_screen.dart';
// ...
floatingActionButton: FloatingActionButton(
onPressed: () {
Navigator.pushNamed(context, AddTaskScreen.id);
},
child: Icon(Icons.add),
),
// ...
Giờ bạn có thể run app để check thử. Chúng ta sẽ chuyển sang phần tiếp theo là thiết lập sqlite database.
Thiết lập SQLite database
Đầu tiên, tạo một folder mới trong folder lib và đặt tên là models. Trong folder models, bạn tạo một file mới có tên là task.dart, đây sẽ là model data của mình. Code như sau:
class Task {
// Task đơn giản chỉ cần 1 id và task
final int id;
final String task;
// constructor
Task({this.id, this.task});
// function chuyển properties của class Task sang Map để lưu trong database
Map<String, dynamic> toMap() {
return {
'id': id,
'task': task,
};
}
}
Giờ chúng ta đã có model Task, tiếp theo chúng ta cần phải lưu trữ data trong database. Tạo folder có tên database trong folder lib, trong folder database tạo một file mới có tên là tasks_db.dart. Trước khi code trong file này, mình cần phải thêm 2 dependencies là path và sqflite và file pubspec.yaml:
// ...
dependencies:
flutter:
sdk: flutter
path:
sqflite:
// ...
Nhớ chạy lệnh “flutter pub get” để lấy dependencies nha. Tiếp tục với file tasks_db.dart, mình sẽ có code sau:
import 'dart:async';
import 'package:path/path.dart';
import 'package:sqflite/sqflite.dart';
// import Task model
import '../models/task.dart';
class TasksDB {
Database _database;
// Mình để các biến final để sau này chỉ cần đổi một chỗ thôi cho tiện
final String kTableName = 'tasks';
final String kId = 'id';
final String kTask = 'task';
// Hàm mở database
Future _openDB() async {
// openDatabase được cung cấp bởi sqflite
_database = await openDatabase(
// lấy đường dẫn database, tasks.db là tên file do mình đặt
join(await getDatabasesPath(), 'tasks.db'),
onCreate: (db, version) {
// Truy vấn tạo table khi database được tạo
return db.execute(
'CREATE TABLE $kTableName($kId INTEGER PRIMARY KEY AUTOINCREMENT, $kTask TEXT)');
},
// Phiên bản của database
version: 1,
);
}
// Thêm task vào database
Future insert(Task task) async {
// Phải chờ mở database trước khi thao tác tiếp
await _openDB();
// Thêm task sau khi đã được convert sang Map vào table kTableName
await _database.insert(kTableName, task.toMap());
print('Task inserted');
}
// Hàm cập nhật task
Future update(Task task) async {
await _openDB();
// Cập nhật lại task tại record có id là id của task truyền vào
await _database.update(
kTableName,
task.toMap(),
where: '$kId = ?',
whereArgs: [task.id],
);
print('Task updated');
}
// Xóa task
Future delete(int id) async {
await _openDB();
// Xóa task có id là id được truyền vào
print((await _database.delete(
kTableName,
where: '$kId = ?',
whereArgs: [id],
)));
print('Task deleted');
}
// Lấy toàn bộ task trong database
Future<List<Task>> getTasks() async {
await _openDB();
// Query toàn bộ table kTableName về một List<Map>
List<Map<String, dynamic>> maps = await _database.query(kTableName);
// Chuyển List<Map> về dạng List<Task> và return về List đó
return List.generate(
maps.length,
(i) => Task(
id: maps[i][kId],
task: maps[i][kTask],
));
}
}
Vậy là chúng ta đã thiết lập xong database. Giờ chúng ta sẽ thực hiện nối UI và code thực thi lại với nhau.
Viết code thực thi
Chúng ta sẽ bắt đầu với file add_task_screen.dart trước. Sẽ có một sự thay đổi lớn ở đoạn này, mình sẽ giải thích trong code. Đoạn code nào được add comment “// new” là mới thêm vào.
import 'package:flutter/material.dart';
import '../database/tasks_db.dart'; // new
import '../models/task.dart'; // new
class AddTaskScreen extends StatefulWidget {
static const id = 'add_task_screen';
final Task task; // new
AddTaskScreen(this.task); // new
_AddTaskScreenState createState() => _AddTaskScreenState();
}
class _AddTaskScreenState extends State<AddTaskScreen> {
final _taskController = TextEditingController();
bool _inSync = false;
String _taskError;
// new
void initState() { // new
Task task = widget.task; // new
// Nếu có task được truyền qua màn hình add, tức là đang chỉnh sửa task
if (task != null) { // new
// Thực hiện gán task vào TextField
_taskController.text = task.task; // new
} // new
super.initState(); // new
} // new
void addTask() async { // new
// Kiểm tra TextField xem có trống hay không
if (_taskController.text.isEmpty) { // new
setState(() { // new
_taskError = 'Please enter this field'; // new
}); // new
return null; // new
} // new
setState(() { // new
_taskError = null; // new
_inSync = true; // new
}); // new
final db = TasksDB(); // new
final task = Task( // new
task: _taskController.text.trim(), // new
); // new
// insert task vào database
await db.insert(task); // new
setState(() { // new
_inSync = false; // new
}); // new
// Trở về màn hình chính với giá trị trả về là true
Navigator.pop(context, true); // new
} // new
void updateTask() async { // new
if (_taskController.text.isEmpty) { // new
setState(() { // new
_taskError = 'Please enter this field'; // new
}); // new
return null; // new
} // new
setState(() { // new
_taskError = null; // new
_inSync = true; // new
}); // new
final db = TasksDB(); // new
// Update task với giá trị mới ở record có id là id của task truyền vào
final task = Task( // new
id: widget.task.id, // new
task: _taskController.text.trim(), // new
); // new
await db.update(task); // new
setState(() { // new
_inSync = false; // new
}); // new
Navigator.pop(context, true); // new
} // new
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('Add task'),
backgroundColor: Colors.white,
leading: IconButton(
icon: Icon(Icons.arrow_back),
onPressed: !_inSync
? () {
Navigator.pop(context);
}
: null,
),
actions: <Widget>[
!_inSync
? IconButton(
icon: Icon(Icons.done),
onPressed: () {
// Nếu như có truyền vào task tức là mình update
// nếu không thì add task
widget.task == null ? addTask() : updateTask(); // new
},
)
: Icon(Icons.refresh),
],
elevation: 0.0,
textTheme: TextTheme(
title: Theme.of(context).textTheme.title,
),
iconTheme: IconThemeData(
color: Colors.black87,
),
),
body: WillPopScope(
onWillPop: () async {
if (!_inSync) return true;
return false;
},
child: Padding(
padding: EdgeInsets.all(16.0),
child: TextField(
controller: _taskController,
decoration: InputDecoration(
labelText: 'Task',
errorText: _taskError,
border: OutlineInputBorder(),
),
),
),
),
);
}
}
Giờ là đến file main_screen.dart chúng ta có code như sau:
import 'package:flutter/material.dart';
import '../database/tasks_db.dart'; // new
import '../models/task.dart'; // new
// import screens
import 'add_task_screen.dart';
class MainScreen extends StatefulWidget {
static const id = 'main_screen';
_MainScreenState createState() => _MainScreenState();
}
class _MainScreenState extends State<MainScreen> {
List<Task> tasks = []; // new
Future getTasks() async { // new
// Lấy tất cả task và gán vào list tasks
final db = TasksDB(); // new
tasks = await db.getTasks(); // new
setState(() {}); // new
} // new
Future deleteTask(int id) async { // new
// Xóa task ở record có id là id được truyền vào
final db = TasksDB(); // new
await db.delete(id); // new
tasks = await db.getTasks(); // new
await getTasks(); // new
setState(() {}); // new
} // new
// new
void initState() { // new
getTasks(); // new
super.initState(); // new
} // new
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('Todo App'),
),
floatingActionButton: FloatingActionButton(
onPressed: () async {
// Navigate sang màn hình add task và chờ kết quả trả về
final result = await Navigator.pushNamed(context, AddTaskScreen.id); // Edited
// Nếu kết quả trả về là true tức là có thêm task nên ta sẽ cập nhật lại list tasks
if (result == true) getTasks(); // new
},
child: Icon(Icons.add),
),
body: ListView.builder(
itemCount: tasks.length, // Edited
itemBuilder: (context, index) {
return ListTile(
title: Text(tasks[index].task), // Edited
trailing: PopupMenuButton(
onSelected: (i) async {
if (i == 0) {
// Tương tự như FAB add task, ta chờ xem có update task thì up
// lại list tasks
final result = await Navigator.pushNamed( // Edited
context, // new
AddTaskScreen.id, // new
// truyền task qua màn hình add task để edit
arguments: tasks[index], // new
); // new
if (result == true) getTasks(); // new
} else if (i == 1) {
showDialog(
context: context,
builder: (context) {
return AlertDialog(
title: Text('Confirm your deletion'),
content: Text(
'This task will be deleted permanently. Do you want to do it?'),
actions: <Widget>[
FlatButton(
onPressed: () {
Navigator.pop(context);
},
child: Text('CANCEL'),
),
FlatButton(
onPressed: () {
// delete task có id là id của item hiện tại
deleteTask(tasks[index].id); // new
Navigator.pop(context);
},
child: Text(
'DELETE',
style: TextStyle(color: Colors.red),
),
),
],
);
},
);
}
},
itemBuilder: (context) {
return [
PopupMenuItem(
value: 0,
child: Text('Edit'),
),
PopupMenuItem(
value: 1,
child: Text('Delete'),
),
];
},
),
);
},
),
);
}
}
Chúng ta đã xong 2 file screen rồi, nhưng nếu bạn để ý bạn sẽ thấy, mình sử dụng Constructor để nhận dữ liệu, vậy làm sao có thể dùng thuộc tính arguments để truyền dữ liệu? Chúng ta sẽ chỉnh sửa lại file main.dart để hoàn thành việc đó. Ta sẽ có code như sau:
routes: {
MainScreen.id: (_) => MainScreen(),
AddTaskScreen.id: (_) => AddTaskScreen(), // Xóa dòng này đi
},
// Thêm đoạn code bên dưới vào
onGenerateRoute: (settings) {
// Nếu Navigator được gọi và màn hình đến là AddTaskScreen
if (settings.name == AddTaskScreen.id) {
return MaterialPageRoute(
builder: (context) {
// Nếu có dữ liệu truyền vào thì đưa qua constructor
if (settings.arguments != null) {
Task task = settings.arguments;
return AddTaskScreen(task);
}
// default là null
return AddTaskScreen(null);
},
);
}
return null;
},
Tổng kết
Vậy là chúng ta đã viết được một Todo App Flutter đơn giản rồi. Mình đã upload toàn bộ Source code lên github rồi.
Vậy là trong bài này, mình đã code xong app Todo sử dụng Flutter và các plugin Flutter như path, sqflite. Hy vọng bài viết này sẽ có ích cho các bạn, nếu bạn thấy hay có thể share để mọi người cùng đọc. Cảm ơn các bạn đã đọc bài viết của mình!
Bài viết gốc được đăng tải tại khiemle.dev
Có thể bạn quan tâm:
- Biết chọn gì đây? Flutter, React Native hay Xamarin?
- Flutter chiến với React Native ai ngon hơn?
- Flutter Vs. React Native: So sánh chi tiết về những điểm tương đồng và ưu việt
Xem thêm Việc làm Developer hấp dẫn trên TopDev