Todo App Flutter – Real Code

Công Nghệ
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á...

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';

  @override
  _MainScreenState createState() => _MainScreenState();
}

class _MainScreenState extends State<MainScreen> {
  @override
  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';

  @override
  _AddTaskScreenState createState() => _AddTaskScreenState();
}

class _AddTaskScreenState extends State<AddTaskScreen> {
  final _taskController = TextEditingController();
  bool _inSync = false;
  String _taskError;

  @override
  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 {
  @override
  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

  @override
  _AddTaskScreenState createState() => _AddTaskScreenState();
}

class _AddTaskScreenState extends State<AddTaskScreen> {
  final _taskController = TextEditingController();
  bool _inSync = false;
  String _taskError;

  @override // 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

  @override
  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';

  @override
  _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

  @override // new
  void initState() { // new
    getTasks(); // new
    super.initState(); // new
  } // new

  @override
  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 Station D

Bài viết liên quan

Ngành IT: Làm việc “trên mây” kiếm nhiều tiền nhất hiện nay

Ngành IT: Làm việc “trên mây” kiếm nhiều tiền nhất hiện nay

Kết quả từ cuộc khảo sát đầu năm của Station D về lương bổng của lập trình viên cho thấy nhiều thay đổi đã và đang diễn ra trong ngành IT – cuộc khảo sát tập trung vào các câu hỏi về khối lượng công việc, triển vọng cũng như...

By stationd
Đâu chỉ mỗi Bitcoin, công nghệ Blockchain còn nhiều ứng dụng hơn thế!

Đâu chỉ mỗi Bitcoin, công nghệ Blockchain còn nhiều ứng dụng hơn thế!

Khi nhắc đến blockchain , lập tức mọi người thường nghĩ ngay đến các loại tiền mã hóa, chẳng hạn như bitcoin. Tuy nhiên, blockchain lại là công nghệ tạo ra tiền mã hóa nhưng bản thân công nghệ này không phải là tiền mã hóa như cách mà chúng...

By stationd
Mock phương thức static trong Unit Test sử dụng PowerMock

Mock phương thức static trong Unit Test sử dụng PowerMock

Bài viết được sự cho phép của tác giả Nguyễn Hữu Khanh Trong bài viết này, mình sẽ hướng dẫn các bạn Mock các phương thức static trong Unit Test các bạn nhé! Nếu bạn nào chưa biết về Mock trong Unit Test thì mình có thể nói sơ qua...

By stationd