Todo App Flutter – Real Code

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 đó.

  Biết chọn gì đây? Flutter, React Native hay Xamarin?
  Custom page transition – Flutter

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:

Xem thêm Việc làm Developer hấp dẫn trên TopDev