Flutter: ลองสร้าง Todo App โดยใช้ BLoC Pattern กัน

Created on Sep 11, 2019
🔔 Updated — สำหรับคนพึ่งมาใหม่สามารถข้ามไปอ่านเนื้อหาได้เลย

31/8/20 — อัพเดท flutter_bloc เป็น ^6.0.2 ใน BlocBuilder เปลี่ยนจาก bloc เป็น cubit

14/7/20 — เปลี่ยนมาใช้ api สำหรับ flutter_bloc ตั้งแต่ 1.0.0 ขึ้นไป และ equatable ตั้งแต่ 0.6.0 ขึ้นไป
หลังจากบล็อกที่แล้ว เราลองทำความเข้าใจ bloc pattern แบบเบื้องต้น กับ increment app เพื่อให้เข้าใจพื้นฐาน และโครงสร้างของสไตล์การเขียนแบบนี้แล้ว หากใครยังไม่ได้อ่าน สามารถลองไปอ่านได้ที่ลิงค์ข้างล่างเลย
ถ้าเริ่มพอจะเข้าใจแล้ว เรามาลองสร้าง Todo App กันต่อ แนวคิดของแอปนี้คือ เราต้องการสร้างแอปที่ใช้จดสิ่งที่ต้องทำ สามารถจดรายละเอียดเพิ่มเติมได้ และหลังจากทำเสร็จแล้ว สามารถติ๊กว่าเสร็จสิ้นได้ ลองดูตัวอย่างข้างล่างได้
ดูเป็นแอปที่ง่ายๆ แต่ใช้พื้นฐานความเข้าใจไม่น้อยเลย ในแอปนี้เราจะยังไม่ลงลึกเรื่องการดีไซน์ เอาเพียงให้ใช้การได้ เพราะเราจะเน้นไปที่ logic ของแอปให้ครบถ้วนก่อน ไว้โอกาสหน้าอาจจะมาลองทำดีไซน์แปลกๆกัน

เรามาอธิบายโครงสร้างแอปแบบคร่าวๆกันก่อน

หมายเหตุ “todo” หมายถึง ข้อมูล todo เพียงอันเดียว ส่วน “todos” จะหมายถึง list/ชุด ข้อมูลของ todo หลายๆอันรวมกัน
จากในตัวอย่างด้านบน ประกอบไปด้วย
  • Model ข้อมูล todo แต่ละอันจะประกอบไปด้วย title detail กับ complete และเพิ่มเติมที่มองไม่เห็นแต่สำคัญมากคือ uuid (คำอธิบายจะมีเพิ่มด้านล่าง)
  • Screen แอปเราจะประกอบไปด้วย 2 หน้า 1. หน้าที่แสดงชุดข้อมูลของ todo หรือหน้าหลัก 2. หน้าที่แสดงรายละเอียด/แก้ไข/เพิ่ม todo
  • Widget จะเป็น custom widget ที่นำมาใช้แสดงใน Screen โดยอาจจะนำมาแยกใส่ลง widget เพราะ มีการถูกใช้ซ้ำๆ หรือเพื่อจัดระเบียบให้โค๊ดดูสวยงาม เป็นระเบียบขึ้น
  • Provider แอปต้องมีส่วนของตัวให้บริการ 2 อย่าง คือ ตัวสร้าง uuid และตัวจัดเก็บข้อมูล local storage
  • Bloc โค๊ดที่ทำหน้าที่จัดการ ประสานงาน ของข้อมูลที่ถูกเรียกไปมาในแอป แล้วถูกนำไปใช้แสดงผลในหน้า screen หรือ UI ของเรา

พื้นฐานเพิ่มเติมที่ต้องใช้

นอกเหนือจากการใช้
flutter_bloc
แล้ว
  • การสร้าง uuid หรือ Unique Id หรือ รหัสไอดีเฉพาะ ที่ทุกครั้งที่สร้างจะไม่ซ้ำกัน เราจะนำมันไปใช้ระบุให้ todo แต่ละอันของเรา เนื่องจากเรามี todo ในลักษณะเป็นชุดข้อมูล ถ้าเราอยากสร้าง หรือแก้ไข todo ที่ถูกตัว โดยเฉพาะเมื่อเรามี todo หลายอันที่เหมือนกัน เราก็จะไม่สับสน เพราะ todo แต่ละอันมี uuid ของตัวเอง
  • JSON Serialization เราจะอิมพอร์ท json_annotation เข้ามาใช้ช่วยแปลงข้อมูล todo ที่เป็น class อยู่ให้กลายเป็น json เพื่อให้ง่ายต่อการจัดเก็บข้อมูลลงในเครื่อง
  • Path Provider ทำหน้าที่ดึงข้อมูลที่อยู่ ที่สามรถใช้จัดเก็บข้อมูลในเครื่อง โดยเราจะเอาที่อยู่นี้ใช้บันทึกข้อมูล todo ลงไปเป็น text file โดย text นี้ก็จะเป็น json

มาเริ่มส่วนโค๊ดกัน

ต่อไปนี้เราจะแปะโค๊ดเต็มลงมาก่อน แล้วค่อยๆอธิบายทีละส่วนตามมา

แก้
pubspec.yaml
กันก่อน

name: simple_todos_bloc
description: A new Flutter project.
version: 1.0.0+1
environment:
sdk: ">=2.1.0 <3.0.0"
dependencies:
flutter:
sdk: flutter
meta: ">=1.1.0 <2.0.0" # เพิ่ม meta
equatable: ^1.2.2 # เพิ่ม equatable
flutter_bloc: ^6.0.2 # เพิ่ม flutter_bloc
path_provider: ^1.6.11 # เพิ่ม path_provider สำหรับทำ local storage เป็น text file
json_annotation: ^3.0.1 # เพิ่ม json_annotation ไว้แปลง ข้อมูลในรูปแบบ class ให้กลายเป็น json จะได้นำไปจัดเก็บเป็น text file ได้
cupertino_icons: ^0.1.2
dev_dependencies:
flutter_test:
sdk: flutter
build_runner: ^1.10.0 # เพิ่ม build_runner สำหรับ json_annotation
json_serializable: ^3.0.1 # เพิ่ม json_serializable สำหรับ json_annotation
flutter:
uses-material-design: true
view raw pubspec.yaml hosted with ❤ by GitHub
ใน
dependencies
เราจะเพิ่ม
meta: ">=1.1.0 <2.0.0"

equatable: ^1.2.2

flutter_bloc: ^6.0.2

path_provider: ^1.6.11

json_annotation: ^3.0.1

และใน `dev_dependencies` เราเพิ่ม

build_runner: ^1.10.0

json_serializable: ^3.0.1
คำอธิบายแต่ละตัว สามารถไปอ่านได้ในโค๊ดเลย
!! หลังจากนี้ไปจะพบว่าสุดท้าย เราจะยังมี logic บางส่วนอยู่ในโค๊ดส่วน UI ซึ่งจะดูขัดๆ กับสิ่งที่เคยกล่าวไป ในกรณีนี้ เราจะจะแยกไปอยู่กับ bloc เฉพาะข้อมูลหลักๆในแอปที่ถูกเรียกใช้ไปมาระหว่างหน้า แต่ถ้าเป็นการแปลงข้อมูลเล็กน้อย หรืออาจจะเป็นข้อมูลที่ใช้เฉพาะหน้านั้นๆ เราก็อาจจะเขียนลงในส่วน UI สำหรับกรณีเหล่านี้ก็ลองบาลานซ์ความเหมาะสมกันดู ว่าควรจะจัดการอะไรยังไง โดยเฉพาะอย่างยิ่ง ไม่ทำให้โค๊ดเกิดความซับซ้อนเกินความจำเป็น
ก่อนอื่นเลย มาสร้างโฟลเดอร์ไว้เพื่อรองรับโค๊ดของเรากันก่อนเลย
สร้างโฟลเดอร์
blocs
models
providers
screens
widgets
มารองรับโค๊ดของเรากันก่อน

ที่โฟลเดอร์ provider สร้างไฟล์ uuid.dart ขึ้นมา

แล้วก็อปตามนี้ลงไปใน
providers/uuid.dart
import 'dart:math';
/// This will generate unique IDs in the format:
///
/// 3e5-7c8ffb9
///
/// ### Example
///
/// final String id = Uuid().generateV4();
class Uuid {
final Random _random = Random();
String generateV4() {
// Generate xxx-xxxxxxx / 3-7.
final int special = 8 + _random.nextInt(4);
return
'\${_bitsDigits(12, 3)}-'
'\${_bitsDigits(12, 3)}\${_bitsDigits(16, 4)}';
}
String _bitsDigits(int bitCount, int digitCount) =>
_printDigits(_generateBits(bitCount), digitCount);
int _generateBits(int bitCount) => _random.nextInt(1 << bitCount);
String _printDigits(int value, int count) =>
value.toRadixString(16).padLeft(count, '0');
}
view raw uuid.dart hosted with ❤ by GitHub
class นี้จะทำหน้าที่สร้าง unique ID ขี้นมา เป็นโครงสร้าง แบบนี้
xxx-xxxxxxx
ถ้าจะใช้งาน ก็ให้อิมพอร์ทเข้ามาแล้วเรียก
Uuid().generateV4();
เพื่อใช้งานได้เลย
ต่อมาสร้างไฟล์
providers/providers.dart
ขึ้นมา ทำหน้าที่ export คลาสที่เราสร้างมาในโฟลเดอร์ providers
export './uuid.dart';

ที่โฟลเดอร์ models มาทำโครงสร้างสำหรับข้อมูล todo

อย่างที่กล่าวไป todo แต่ละอันจะประกอบไปด้วย
complete
title
detail
uuid
โดยที่มีเพียง
title
ที่บังคับว่าห้ามเป็นช่องว่าง ส่วน
uuid
จะถูกสร้าวขึ้นเมื่อ todo นั้นๆ ถูกสร้างครั้งแรก
complete
จะเป็นได้แค่ boolean ค่าตั้งต้นจะเป็น false และ
detail
ที่จะใส่หรือไม่ใส่ก็ได้
ถ้าพร้อมแล้ว ก็เอาโค๊ดไปใส่ใน
models/todo.dart
import 'dart:core';
import 'package:json_annotation/json_annotation.dart';
import 'package:equatable/equatable.dart';
import 'package:meta/meta.dart';
import 'package:simple_todos_bloc/providers/providers.dart';
part 'todo.g.dart';
@immutable
@JsonSerializable()
class TodoList extends Equatable {
final List<TodoModel> todos;
TodoList(this.todos);
@override
String toString() {
return 'todoList { todos: \$todos }';
}
factory TodoList.fromJson(Map<String, dynamic> json) =>
_\$TodoListFromJson(json);
Map<String, dynamic> toJson() => _\$TodoListToJson(this);
@override
List<Object> get props => [todos];
}
@immutable
@JsonSerializable()
class TodoModel extends Equatable {
final bool complete;
final String id;
final String title;
final String detail;
TodoModel({this.complete = false, String id, this.title, this.detail = ''})
: this.id = id ?? Uuid().generateV4(),
super();
TodoModel copyWith({bool complete, String id, String title, String detail}) {
return TodoModel(
complete: complete ?? this.complete,
id: id ?? this.id,
title: title ?? this.title,
detail: detail ?? this.detail);
}
@override
String toString() {
return 'todo { title: \$title, complete: \$complete, detail: \$detail, id: \$id}';
}
factory TodoModel.fromJson(Map<String, dynamic> json) =>
_\$TodoModelFromJson(json);
Map<String, dynamic> toJson() => _\$TodoModelToJson(this);
@override
List<Object> get props => [complete,id,title,detail];
}
view raw todo.dart hosted with ❤ by GitHub
มาลงรายละเอียดกัน
import 'package:json_annotation/json_annotation.dart';
เราอิมพอร์ท JSON Serialization มา ทำหน้าที่แปลงข้อมูล todos ที่เป็น class อยู่ ให้กลายเป็น json จะได้เก็บนำข้อมูลไปจัดเก็บแบบ text file ได้
import 'package:simple_todos_bloc/providers/providers.dart';
อิมพอร์ท
providers.dart
มา จะได้นำ uuid มาใส่ได้เลย
part 'todo.g.dart';
ในตอนแรก มันจะขึ้น error อยู่ ไม่ต้องไปตกใจ เพราะเดี๋ยวเราจะรันสคริปที่จะ สร้างไฟล์ตัวนี้ขึ้นมาให้ ไฟล์นี้จะเกี่ยวข้องกับ JSON Serialization ทำให้เราไม่ต้องมานั่งเขียนสคริปแบบมือในส่วนนี้ แต่เราจะรันหลังจากที่อธิบายโค๊ดไฟล์นี้ให้เสร็จก่อน
กระโดดมาดู class TodoModel กันก่อน
@immutable      // <-- เพื่อให้มันใจว่าตัวแปรทั้งหมดถูกประกาศเป็น final

@JsonSerializable()     // <-- เพื่อบอกให้ สคริป JSON Serialization เราทราบว่า คลาสนี้จะถูกนำไปแปลงเป็น json ได้

class TodoModel extends Equatable {

final bool complete;

final String id;

final String title;

final String detail;

TodoModel({
this.complete = false, // <-- ค่าแรกเริ่มเป็น false
String id,
this.title,
this.detail = ''}): // <-- ค่าแรกเริ่มเป็น สตริงว่างๆ ถ้าไม่ถูกระบุมา
this.id = id ?? Uuid().generateV4(),   // <-- ค่าแรกเริ่มถูกสร้างโดย Uuid().generateV4()
super();

// เนื่องจากภาษา dart ไม่มีวิธีการ copy by value สำหรับ class ดังนั้นเราจะต้องสร้างขึ้นมาเอง
// วิธีเรียก TodoModel newTodo = oldTodo.copyWith();
// ถ้าไปแก้ค่าใน newTodo ก็จะไม่ไปกระทบกับ oldTodo

TodoModel copyWith({bool complete, String id, String title, String detail}) {

return TodoModel(

complete: complete ?? this.complete,

id: id ?? this.id,

title: title ?? this.title,

detail: detail ?? this.detail);

}

// เพื่อให้ดีบัคได้ง่าย เราก็จะแก้ toString() ตอนสั่ง print ออกมาจะได้เห็นค่าข้างใน class แทนที่เดิมไม่ได้แก้ จะเห็นเป็นที่ของ memory

@override
String toString() {

return 'todo { title: $title, complete: $complete, detail: $detail, id: $id}';

}


// ฟังก์ชั่นสำหรับแปลง class เป็น json (JSON Serialization)

factory TodoModel.fromJson(Map<String, dynamic> json) => _$TodoModelFromJson(json);

// ฟังก์ชั่นสำหรับแปลง json เป็น class (JSON Serialization)
Map<String, dynamic> toJson() => _$TodoModelToJson(this);

@override
List<Object> get props => [complete,id,title,detail];

}

เราสร้าง todoModel สำหรับเป็นโครงสร้างของ todo แต่ละอันแล้ว เราก็ต้องมี list ที่เก็บ todo หลายๆอัน โดยปกติแล้ว เราประกาศตัวแปรแบบนี้ได้เลย
List<TodoModel> todos;
ซึ่งในแอปของเราส่วนใหญ่ก็จะใช้แบบนี้เลย แต่เพื่อความสะดวกในการแปลง
List<TodoModel>
ให้เป็น json เราเลยสร้าง class TodoList สำหรับการนี้โดยเฉพาะ
@immutable

@JsonSerializable()

class TodoList extends Equatable {


final List<TodoModel> todos;

TodoList(this.todos);  //<-- รับ List<TodoModel> เข้ามาได้เลย

// เพื่อให้ดีบัคได้ง่ายเหมือนเดิม

@override

String toString() {

return 'todoList { todos: $todos }';

}

// (JSON Serialization)

factory TodoList.fromJson(Map<String, dynamic> json) =>

_$TodoListFromJson(json);

//  (JSON Serialization)

Map<String, dynamic> toJson() => _$TodoListToJson(this);

@override
List<Object> get props => [todos];

}
เมื่อทุกอย่างเรียบร้อยแล้ว เรามาทำให้ error ตอนต้นหายไปโดยรันสคริปดังนี้
flutter pub run build_runner build
เป็นสคริปที่จะช่วยสร้างโค๊ดในส่วนของการแปลง Class <— — > JSON ให้เราเสร็จสรรพ โดยสคริปก็จะสร้างให้เฉพาะ class ที่เราใส่
@JsonSerializable()
ไว้ด้านหน้า แล้ว error ทั้งหมดในตอนนี้ก็จะหายไป
เผื่ออนาคตแอปเราจะมี model หลายอัน เราเลยจะสร้างไฟล์ไว้รวมเพื่อ export เป็นไฟล์เดียว ชื่อ
models/models.dart
export './todo.dart';
เพียงเท่านี้โครงสร้างของข้อมูลเราก็พร้อมนำไปใช้งานแล้ว

กลับมาที่ Providers อีกอันที่เราต้องสร้าง storage

สร้างไฟล์
providers/storage.dart
แล้วก็อปโค๊ดตามนี้โลด
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:simple_todos_bloc/models/models.dart';
import 'package:path_provider/path_provider.dart';
class FileStorage {
final String tag = '__todos__';
Future<File> _getLocalFile() async {
final dir = await getDirectory();
return File('\${dir.path}/ArchSampleStorage__\$tag.json');
}
Future<List<TodoModel>> loadTodos() async {
try {
final file = await _getLocalFile();
final string = await file.readAsString();
final json = await jsonDecode(string);
final todos = TodoList.fromJson(json);
return todos.todos;
} catch (e) {
print('not init');
return TodoList([
TodoModel(title: 'Welcome to Simple Todo', )
]).todos;
}
}
Future<File> saveTodos(List<TodoModel> todos) async {
final file = await _getLocalFile();
TodoList temp = TodoList(todos);
return file.writeAsString(jsonEncode(temp));
}
}
Future<Directory> getDirectory() async {
Directory path = await getApplicationDocumentsDirectory();
if (path == null) {
return null;
}
return path;
}
view raw storage.dart hosted with ❤ by GitHub
import 'package:simple_todos_bloc/models/models.dart';
เอาโครงสร้าง todo ของเราเข้ามา
import 'package:path_provider/path_provider.dart';
นำมาเพื่อใช้ดึงที่อยู่ local ของแอปเรา จะได้ใช้ที่อยู่นี้จัดเก็บข้อมูลแบบถาวรลงในเครื่องได้
ข้ามมาส่วน
Future<Directory> getDirectory() async
ก่อน
Future<Directory> getDirectory() async {

Directory path = await getApplicationDocumentsDirectory();

if (path == null) {

return null;

}

return path;

}
เมื่อเรียก
getDirectory()
เราก็จะได้ path กลับมา
final String tag = '__todos__';

Future<File> _getLocalFile() async {


final dir = await getDirectory();

return File('${dir.path}/ArchSampleStorage__$tag.json');


}
เรานำฟังก์ชั่นเมื่อกี้ มาใช้ใน
Future<File> _getLocalFile() async
ต่อ ทำหน้าที่เชื่อมที่อยู่ กับชื่อไฟล์ json ที่เราจะได้ไว้่ เข้าด้วยกัน และส่งข้อมูลประเภท File ไป
ต่อมาจะเป็นฟังก์ชั่นหลักๆในแอปของเรา
loadTodos()
กับ
saveTodos()
ทำหน้าที่ โหลด กับ บันทึก ดังนั้นถ้าเราเรียก
loadTodos()
ก็จะได้รับ todos กับมา ถ้าเรียก
saveTodos()
เราก็ต้องส่ง todos เข้าไป และเนื่องจากเป็น I/O ก็จะต้องทำให้เป็น async/await ด้วย
Future<List<TodoModel>> loadTodos() async {

try {

final file = await _getLocalFile();   // --> ดึงไฟล์ todos มา

final string = await file.readAsString();  // --> อ่านไฟล์แบบ string

final json = await jsonDecode(string);  // \--> แปลง string เป็น json ที่โปรแกรมเข้าใจ

final todos = TodoList.fromJson(json);  // \--> แปลง json เป็น class แล้วนำไปใช้งานได้ตามปกติเลย

return todos.todos;

} catch (e) {     // \--> กรณีที่เราเปิดแอปเป็นครั้งแรก

print('not init');

return TodoList([

TodoModel(title: 'Welcome to Simple Todo', )

]).todos;

}

}

Future<File> saveTodos(List<TodoModel> todos) async {

final file = await _getLocalFile();   **// --> ดึงไฟล์ todos มา**

TodoList temp = TodoList(todos);    **// --> แปลง List<TodoModel> เป็น TodoList**

return file.writeAsString(jsonEncode(temp));   **// --> แปลง TodoList เป็น json แล้วบันทีกลงไฟล์แบบ string**

}
เสร็จเรียบร้อยแล้ว เราก็ไปอัพเดท เพิ่ม
export './storage.dart';
ไปที่
providers/providers.dart
รวมกันได้ดังนี้
export './storage.dart';
export './uuid.dart';
ตอนนี้เราก็ปิดงานกับ providers แล้ว

มาทำในส่วนของ bloc กันต่อ

เริ่มที่ event เช่นเคย สร้างไฟล์
**blocs/todos/todos_event.dart**
import 'package:equatable/equatable.dart';
import 'package:meta/meta.dart';
import 'package:simple_todos_bloc/models/models.dart';
@immutable
abstract class TodosEvent extends Equatable {
TodosEvent([List props = const <dynamic>[]]) : super();
@override
List<Object> get props => [];
}
class LoadTodos extends TodosEvent {
@override
String toString() {
return 'LoadTodos';
}
}
class AddTodos extends TodosEvent {
final TodoModel todo;
AddTodos(this.todo) : super([todo]);
@override
String toString() => 'AddTodos { todo: \$todo }';
}
class UpdateTodos extends TodosEvent {
final TodoModel updateTodo;
UpdateTodos(this.updateTodo) : super([updateTodo]);
@override
String toString() => 'UpdateTodos { updateTodo: \$updateTodo }';
}
class DeleteTodos extends TodosEvent {
final TodoModel deleteTodo;
DeleteTodos(this.deleteTodo) : super([deleteTodo]);
@override
String toString() => 'DeleteTodos { deleteTodo: \$deleteTodo }';
}
รอบนี้จะไม่ขอลงลึกกับ bloc มาอธิบายแบบคร่าวๆพอ
event เราจะมีทั้งหมด 4 อัน
  • LoadTodos — เรียกเมื่อต้องการให้ bloc ไปดึงข้อมูล todos จาก local storage มาใช้
  • AddTodos — เรียกเมื่อต้องการเพิ่ม todo อันใหม่เข้าไปใน todos โดยตอนเรียกจะต้องส่ง todo ไปด้วย
  • UpdateTodos — เรียกเมื่อต้องการอัพเดท todo เดิม ตอนเรียกก็จะต้องส่ง todo ที่ถูกแก้ไขไปด้วย
  • DeleteTodos — เรียกเมื่อต้องการลบ todo เดิม ออกไปจาก todos ตอนเรียกก็จะต้องส่ง todo ที่ต้องการลบด้วย
จบ event ไป state กันต่อ
สร้างไฟล์
**blocs/todos/todos_state.dart**
import 'package:equatable/equatable.dart';
import 'package:meta/meta.dart';
import 'package:simple_todos_bloc/models/models.dart';
@immutable
abstract class TodosState extends Equatable {
TodosState([List props = const []]) : super();
@override
List<Object> get props => [];
}
class TodosLoading extends TodosState {
@override
String toString() => 'TodosLoading';
}
class TodosLoaded extends TodosState {
final List<TodoModel> todos;
TodosLoaded([this.todos = const []]) : super([todos]);
@override
String toString() {
return 'TodosLoaded { todos : \$todos }';
}
@override
List<Object> get props => [todos];
}
class TodosNotLoaded extends TodosState {
@override
String toString() => 'TodosNotLoaded';
}
State ของเราที่จะทำหน้าที่ส่งไป UI ก็จะมีเพียง 3 อันเท่านั้น
  • TodosLoading — แจ้ง UI ว่าตอนนี้ ข้อมูลยังไม่พร้อมใช้งาน ให้โชว์ว่ากำลังโหลดอยู่
  • TodosLoaded — แจ้ง UI ว่า ข้อมูล todos พร้อมแล้ว และนำ todos มาใช้แสดงผลได้
  • TodosNotLoaded — แจ้ง UI ว่าเกิดปัญหาขึ้น ให้โชว์ error
ไป bloc กันต่อออ
สร้างไฟล์
**blocs/todos/todos_bloc.dart**
import 'dart:async';
import 'package:bloc/bloc.dart';
import 'package:simple_todos_bloc/providers/providers.dart';
import 'package:simple_todos_bloc/models/models.dart';
import './todos.dart';
class TodosBloc extends Bloc<TodosEvent, TodosState> {
TodosBloc() : super(TodosLoading());
@override
TodosState get initialState {
return TodosLoading();
}
@override
Stream<TodosState> mapEventToState(
TodosEvent event,
) async* {
if (event is LoadTodos) {
yield* _mapLoadTodosToState();
} else if (event is AddTodos) {
yield* _mapAddTodosToState(event);
} else if (event is UpdateTodos) {
yield* _mapUpdateTodosToState(event);
} else if (event is DeleteTodos) {
yield* _mapDeleteTodosToState(event);
}
}
Stream<TodosState> _mapLoadTodosToState() async* {
try {
final todos = await FileStorage().loadTodos();
yield TodosLoaded(todos);
} catch (_) {
yield TodosNotLoaded();
}
}
Stream<TodosState> _mapAddTodosToState(AddTodos event) async* {
if (state is TodosLoaded) {
final List<TodoModel> updatedTodos =
List.from((state as TodosLoaded).todos);
updatedTodos.add(event.todo);
_saveTodos(updatedTodos);
yield TodosLoaded(updatedTodos);
}
}
Stream<TodosState> _mapUpdateTodosToState(UpdateTodos event) async* {
if (state is TodosLoaded) {
final List<TodoModel> updatedTodos =
(state as TodosLoaded).todos.map((todo) {
return todo.id == event.updateTodo.id ? event.updateTodo : todo;
}).toList();
yield TodosLoaded(updatedTodos);
_saveTodos(updatedTodos);
}
}
Stream<TodosState> _mapDeleteTodosToState(DeleteTodos event) async* {
if (state is TodosLoaded) {
final List<TodoModel> updatedTodos = (state as TodosLoaded)
.todos
.where((todo) => todo.id != event.deleteTodo.id)
.toList();
yield TodosLoaded(updatedTodos);
_saveTodos(updatedTodos);
}
}
Future _saveTodos(List<TodoModel> todos) {
return FileStorage().saveTodos(todos);
}
}
view raw todos_bloc.dart hosted with ❤ by GitHub
รอบนี้
_mapToState()
เริ่มมีความหลากหลายมากขึ้น มันจะมีจำนวนตาม event ที่มีเลย คือ 4 อัน
  • _mapLoadTodosToState() — เมื่อรับ event
    LoadTodos
    มา เราก็จะรัน
    FileStorage().loadTodos();
    เพื่อไปดึง todos มาจาก local storage แล้วส่งต่อไป state
    TodosLoaded(todos);
    แต่ ถ้าเกิดปัญหาผิดพลาดอะไรขึ้นมา
    TodosNotLoaded();
    ถูกเรียกแทน
  • _mapAddTodosToState(AddTodos event) — เมื่อรับ event
    AddTodos
    มา
final List<TodoModel> updatedTodos = List.from((state as TodosLoaded).todos);  // --> สร้าง list ใหม่ที่ลอกข้อมูลมาจาก list เก่า เราไม่อยากให้ list เก่าเกิดการ mutate หรือกลายพันธุ์ เพราะ flutter_bloc จะใช้ list เก่า กับ list ใหม่ เทียบกัน แล้วนำไปตัดสินว่า UI ส่วนไหนต้องถูกสร้างใหม่บ้าง

updatedTodos.add(event.todo);   // --> เพิ่ม todo ไปที่ list ใหม่

_saveTodos(updatedTodos);   // --> บันทึก list ใหม่ ไปยัง local storage

yield TodosLoaded(updatedTodos);   // --> เรียก state TodosLoaded
  • _mapUpdateTodosToState(UpdateTodos event) — เมื่อรับ event
    UpdateTodos
    มา
// สร้าง list ใหม่โดย list ใหม่ จะถูก map เปรียบเทียบตามเงื่อนไข ถ้าเป็น id ของ todo ที่เราต้องการแก้ เราถึงจะเอา todo อันใหม่ไปแทนที่อันเก่า สุดท้ายจะได้ list ที่อัพเดทเรียบร้อย
final List<TodoModel> updatedTodos = (state as TodosLoaded).todos.map((todo) { return todo.id == event.updateTodo.id ? event.updateTodo : todo; }).toList();

yield TodosLoaded(updatedTodos); // --> เรียก state TodosLoaded

_saveTodos(updatedTodos);  // --> บันทึก list ใหม่ ไปยัง local storage
  • _mapDeleteTodosToState(DeleteTodos event)
// สร้าง list ใหม่โดย list ใหม่ จะถูก where หาตามเงื่อนไข ถ้าไม่ใช่ id ของ todo ที่เราต้องการลบ ก็จะถูกนำไปใส่ใน list อันใหม่ list ใหม่ก็จะลบ todo ที่ไม่ต้องการออกไปแล้ว
final List<TodoModel> updatedTodos = (stateas TodosLoaded).todos.where((todo) => todo.id != event.deleteTodo.id).toList();

yield TodosLoaded(updatedTodos);  // --> เรียก state TodosLoaded

_saveTodos(updatedTodos);  // --> บันทึก list ใหม่ ไปยัง local storage
เมื่อเรียบร้อยหมดแล้ว เราก็รวบไฟล์ export ในชื่อเดียว
**blocs/todos/todos.dart**
export 'todos_bloc.dart';
export 'todos_event.dart';
export 'todos_state.dart';
แล้วเผื่อในอนาคตเราจะมี bloc หลายตัว ก็มาสร้างตัวรวม export เผื่อไว้ก่อน ในชื่อ
**blocs/blocs.dart**
export './todos/todos.dart';
ในที่สุดก็เสร็จส่วนของ logic แล้ว

มาทำส่วนของการแสดงผลกันต่อ

ก่อนไปส่วน Screens เรามาทำ widgets กันก่อน

เราจะสร้าง widget LoadingIndicator ไว้โชว์สถานะกำลังโหลด เมื่อได้รับ state
TodosLoading
มา
** แต่เมื่อลองรันจริงอาจแทบไม่เห็น state นี้เลย เพราะเราดึงข้อมูลจาก local storage ซึ่งมีความเร็ว และการตอบสนองที่รวดเร็วมาก
สร้างไฟล์
widgets/loading_indicator.dart
ขึ้นมา
import 'package:flutter/material.dart';
class LoadingIndicator extends StatelessWidget {
LoadingIndicator({Key key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Center(
child: CircularProgressIndicator(),
);
}
}
widget นี้ไม่มีไรซับซ้อน มีเพียง widget
CircularProgressIndicator()
ที่มากับ flutter อยู่แล้ว และจับใส่ widget
center
เพื่อให้อยู่ตรงกลางหน้า
สำหรับแอปนี้ก็จะมี widgets อันเดียว แต่เผื่ออนาคต เช่นเดิม เราก็จะสร้าง
widgets/widgets.dart
ไว้รวบ export ทั้งหมด จะได้ import เพียงครั้งเดียว
export './loading_indicator.dart';

มาเข้าส่วน Screens กัน

ในโฟลเดอร์ screen เราจะมีเพียง 2 หน้า
  1. todos.dart
    แสดงชุดข้อมูลของ todo หรือหน้าหลัก
  2. detail-add.dart
    แสดงรายละเอียด/แก้ไข/เพิ่ม todo
มาทำหน้า todos ก่อน สร้างไฟล์
todos.dart
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; // --> เพื่อใช้งาน flutter_bloc
import 'package:simple_todos_bloc/blocs/blocs.dart'; // --> อิมพอร์ท bloc ที่เราสร้างขึ้นมา
import 'package:simple_todos_bloc/screens/detail-add.dart'; // --> อิมพอร์ท หน้า /detail-add.dart เพราะเมื่อเราสร้าง todo ใหม่ หรือต้องการดูรายละเอียด ก็ทำผ่านหน้านี้
import 'package:simple_todos_bloc/widgets/widgets.dart'; // --> เอา LoadingIndicator มาใช้
import 'package:flutter/cupertino.dart';
class TodosScreen extends StatelessWidget {
const TodosScreen({
Key key,
}) : super(key: key);
@override
Widget build(BuildContext context) {
final todosBloc = BlocProvider.of<TodosBloc>(context);
return Scaffold(
appBar: AppBar(
title: Text(
'Todo App',
)),
body: BlocBuilder(
cubit: todosBloc,
builder: (BuildContext context, TodosState state) {
if (state is TodosLoading) { // --> ถ้า state เป็น TodosLoading ก็ให้โชว์ LoadingIndicator
return LoadingIndicator(key: Key('__TodosLoading'));
} else if (state is TodosLoaded) { // --> ถ้า state เป็น TodosLoaded ก็ให้โชว์ list ของ todo
final todos = state.todos;
return ListView.builder( // --> ใช้ ListView.builder ให้สร้าง list card ขึ้นมา
key: Key('__ListTodos__'),
itemCount: todos.length,
itemBuilder: (BuildContext context, int index) {
final todo = todos[index];
return Card( // --> เลือกใช้ card แสดงข้อมูล todo แต่ละอัน
elevation: 8.0,
child: InkWell( // --> ใส่ InkWell ครอบ เมื่่อกดปุ่มจะได้มี ripple effect
onTap: () => // --> เมื่อกดไปที่ card ก็จะส่งไปหน้า DetailAddScreen แบบมีรหัส id และอยู่ในสถานะ ไม่ได้แก้ไขข้อมูล
Navigator.of(context).push(CupertinoPageRoute(
builder: (_) => DetailAddScreen(
id: todo.id,
isEditing: false,
))),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
children: <Widget>[
GestureDetector( // --> ที่ด้านซ้ายของ card จะมีปุ่มที่ติ๊กบอกความ complete
onTap: () => todosBloc.add(UpdateTodos( // --> เมื่อกด เราจะทำการเรียก event UpdateTodos
todo.copyWith(complete: !todo.complete))), // --> ใช้ copyWith เพื่อไม่ทำให้ข้อมูลเก่าถูกกลายพันธุ์
child: todo.complete == false
? Icon(Icons.check_box_outline_blank)
: Icon(Icons.check_box),
),
SizedBox(
width: 7,
),
Text('\${todo.title}'),
],
),
),
),
);
},
);
}
}),
floatingActionButton: FloatingActionButton( // --> ปุ่มเพิ่ม todo ใหม่ จะลอยอยู่บนด้านล่างซ้าย
child: Icon(Icons.add),
onPressed: () {
Navigator.of(context).push(CupertinoPageRoute( // --> เมื่อกดไปที่ card ก็จะส่งไปหน้า DetailAddScreen แบบ ไม่มี รหัส id และอยู่ในสถานะ แก้ไขข้อมูล
builder: (_) => DetailAddScreen(
isEditing: true,
)));
},
));
}
}
view raw todos.dart hosted with ❤ by GitHub
รายละเอียดอธิบายไว้ในโค๊ดแล้ว
เราไปที่
detail-add.dart
กันต่อ
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:simple_todos_bloc/blocs/blocs.dart';
import 'package:simple_todos_bloc/models/models.dart';
class DetailAddScreen extends StatefulWidget {
final String id;
final bool isEditing;
const DetailAddScreen({Key key, this.id, @required this.isEditing})
: super(key: key);
@override
_DetailAddScreenState createState() => _DetailAddScreenState();
}
class _DetailAddScreenState extends State<DetailAddScreen> {
String id;
bool isEditing;
String _title; // --> ไว้เก็บ title แบบชั่วคราว
String _detail; // --> ไว้เก็บ detail แบบชั่วคราว
static final GlobalKey<FormState> _formKey = GlobalKey<FormState>(); // --> key ไว้บ่งชี้ form นั้นๆ
@override
void initState() {
id = widget.id;
isEditing = widget.isEditing;
super.initState();
}
@override
Widget build(BuildContext context) {
final todosBloc = BlocProvider.of<TodosBloc>(context);
String title;
String detail;
return BlocBuilder(
cubit: todosBloc,
builder: (BuildContext context, TodosState state) {
final todo = (state as TodosLoaded)
.todos
.firstWhere((todo) => todo.id == widget.id, orElse: () => null); // --> ใช้ id ของ todo เพื่อนำมาดึงข้อมูลของ todo นั้นจากใน todos ถ้า id ไม่มีตัวตน ก็จะได้รับค่าเป็น null แทน
if (todo == null) { // --> เมื่อ todo เป็น null เราก็จะให้ค่าตั้งต้นสำหรับ title และ detail เป็น string ว่างๆ
title = '';
detail = '';
} else { // --> เมื่อ todo มีค่า ก็ให้ดึงค่ามาจากใน todo
title = todo.title;
detail = todo.detail;
}
return Form(
key: _formKey, // --> ใส่ key ที่สร้างด้านบน เพราะเมื่อเราต้องการดึงข้อมูลจากฟอร์มนี้ เราจะได้ดึงถูกที่
child: Scaffold(
appBar: AppBar(
title: isEditing == false // --> เลือกระหว่างจะโชว์ Text หรือ TextFormField
? Text('\$title')
: TextFormField(
initialValue: todo == null ? '' : todo.title,
style: TextStyle(color: Colors.white),
decoration: InputDecoration(
hintText: 'Please name title', labelText: 'Title'),
validator: (val) { // --> เงื่อนไข validator สำหรับ เงื่อนไข validator
return val.isEmpty == true
? 'Please put some title!'
: null;
},
onSaved: (value) => _title = value,
),
actions: <Widget>[
IconButton(
icon: Icon(isEditing == false ? Icons.edit : Icons.cancel), // --> เลือกระหว่างจะโชว์ปุ่ม แก้ไข หรือ ยกเลิก
onPressed: () {
setState(() {
if (todo != null) {
isEditing = !isEditing;
} else{
Navigator.pop(context);
}
});
},
),
isEditing == false ? IconButton(icon: Icon(Icons.delete),onPressed: () { // --> โชว์ปุ่มลบเมื่อ อยู่ในสถานะไม่ได้แก้ไข
todosBloc.add(DeleteTodos(todo)); // --> เรียก event DeleteTodos
Navigator.pop(context); // --> เด้งหน้านี้ออกไป
},) : SizedBox()
],
),
body: Container(
padding: EdgeInsets.all(16),
child: isEditing == false // --> เลือกระหว่างจะโชว์ Text หรือ TextFormField
? Text('\$detail')
: TextFormField(
initialValue: todo == null ? '' : todo.detail,
style: TextStyle(color: Colors.black),
decoration: InputDecoration(
hintText: 'Any Detail you want to put',
labelText: 'Detail'),
onSaved: (value) => _detail = value,
)),
floatingActionButton: isEditing == false
? SizedBox()
: FloatingActionButton(
backgroundColor: Color(0xffb14934),
child: Icon(todo != null ? Icons.check : Icons.note_add), // --> เลือกระหว่างจะโชว์ปุ่ม แก้ไข หรือ เพิ่มใหม่
onPressed: () {
if (_formKey.currentState.validate()) { // --> เช็คว่าข้อมูลใน TextFormField ถูกต้องตามเงื่อนไข validator ที่กำหนดของแต่ละอัน
_formKey.currentState.save(); // --> ย้ายข้อมูลที่เก็บชั่วคราวใน TextFormField มาใส่ใน _title กับ _detail
if (todo == null) { // --> ถ้าเป็นการสร้าง todo ขึ้นใหม่
TodoModel toSave = TodoModel(
title: _title,
detail: _detail,
complete: false,
);
todosBloc.add(AddTodos(toSave));
Navigator.pop(context); // --> เมื่อสร้างเสร็จเราจะเด้งหน้านี้ออกด้วย ** ไม่จำเป็นต้องเด้งออก แล้วแต่เราอยากดีไซน์เลย ถ้าไม่เด้งออก อย่าลืมนำ id ที่สร้างมาใหม่ ไปอัพเดท ให้กับ id ในหน้านี้ด้วย
} else { // --> ถ้าเป็นการแก้ไข todo
TodoModel toSave = todo.copyWith(
title: _title,
detail: _detail,
);
todosBloc.add(UpdateTodos(toSave));
setState(() {
isEditing = !isEditing; // --> สลับสถานะจาก แก้ไข เป็น ไม่แก้ไข
});
}
}
},
),
),
);
});
}
}
view raw detail-add.dart hosted with ❤ by GitHub
สุดท้ายก็รวบทั้งสองไฟล์ไว้ที่ screens.dart
export './todos.dart';
export './detail-add.dart';
view raw screens.dart hosted with ❤ by GitHub

สุดท้าย

ตอนนี้เราเตรียมการทุกอย่างพร้อมแล้ว เหลือแค่นำมาแสดงจริงในแอปเรา
มาแก้ไฟล์
**main.dart**
import 'package:flutter/material.dart';
import 'package:simple_todos_bloc/screens/screens.dart'; // --> นำหน้า TodosScreen มาใช้งาน
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:simple_todos_bloc/blocs/blocs.dart';
void main() {
runApp(BlocProvider( // --> ครอบด้วย BlocProvider จะได้เรียกใช้งาน bloc ได้
create: (context) {
return TodosBloc()..add(LoadTodos()); // --> ทุกครั้งที่เปิดแอป เราก็จะเรียก event LoadTodos
},
child: MyApp(),
));
}
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: TodosScreen(), // --> โชว์หน้า TodosScreen
);
}
}
view raw main.dart hosted with ❤ by GitHub
เพียงเท่านี้ แอป Todo ของเราก็จะใช้งานได้แล้ว 🎆🎆🎆

จบแล้วววว~~~

สำหรับ Todo App แล้ว นอกเหนือจาก BLoC Pattern ที่เรานำมาใช้ เราก็ได้ลองใช้หลายๆฟีเจอร์ของ flutter เลย เราได้ฝึกใช้ local storage การโยนข้อมูลไปมาระหว่างหน้า การใช้ widget หลายๆตัว ตัวอย่างนี้อาจจะมีความยากระดับนึง และใช้ความรู้อื่นๆควบด้วย ทำให้ต้องใช้เวลาทำความเข้าใจระดับนึง ผู้เขียนก็หวังว่าตัวอย่างนี้ จะทำให้ผู้อ่านมีความคุ้นชินกับ BLoC Pattern มากยิ่งขึ้น แล้วสามารถนำไปประยุคต์ใช้จริงกับแอปที่อยากจะสร้างได้ในอนาคต
ถ้าใครอยากดูโค๊ดเต็มๆทั้งหมด ก็ดูได้จากลิงค์ข้างล่างเลย

แนะนำเรื่องถัดไป