แผนการเรียนรู้
/
Flutter Mobile Dev | Week 4 • Parts 1 & 2

สัปดาห์ที่ 4: ฟอร์มและการรับค่าข้อมูล (Forms & Input Validation)

🎯 สารบัญโครงสร้างเนื้อหาหมวดปฏิบัติการที่ 1 และ 2

⌨️ หมวดที่ 1: การรับค่าและควบคุมวิดเจ็ตข้อความ
🛡️ หมวดที่ 2: ระบบฟอร์มและการตรวจสอบความถูกต้อง
💡 คำชี้แนะ: คลิกหัวข้อบนสารบัญเพื่อข้ามไปยังหน้านั้นโดยตรง หรือใช้ปุ่มควบคุมด้านล่างเพื่อไล่ดูตามลำดับการสอน
Slide 1 • Input Widgets Comparison

การเปรียบเทียบวิดเจ็ตรับข้อมูล: TextField vs. TextFormField ⚔️

TextField (ระดับพื้นฐาน)

เป็นวิดเจ็ตอิสระสำหรับรับข้อมูลตัวอักษรเดี่ยวจากแป้นพิมพ์ เหมาะสำหรับการรับข้อมูลทั่วไปโดยไม่ต้องคำนึงถึงความสัมพันธ์กับตัวควบคุมภายนอก [1].

  • ดึงค่าทำงานผ่านการผูก Listener หรือตรวจเช็ค onChanged [2].
  • ข้อจำกัด: ไม่มีระบบตรวจสอบสิทธิ์และพ่นข้อความ Error สีแดงให้ในตัว (ต้องเขียนเงื่อนไขควบคุม UI เอง).

TextFormField (ระดับมืออาชีพ)

เป็นวิดเจ็ตพิเศษที่สืบทอดความสามารถมาจาก FormField ถูกออกแบบให้ทำงานร่วมกับวิดเจ็ต Form ด้านบน [1].

  • รองรับคุณสมบัติ validator สำหรับรันตรวจสอบค่าและคายค่าคำแจ้งเตือนได้ทันที [3].
  • มีคุณสมบัติ onSaved ใช้สั่งบันทึกสถานะลงโมเดลเมื่อรันฟอร์มผ่านเกณฑ์สำเร็จ.

📘 สรุปการตัดสินใจเลือกวิดเจ็ต (Decision Guide):

  • TextField: ใช้เมื่อรับข้อมูลเดี่ยวไม่ต้องการ validation เช่น ช่องค้นหา (Search Bar) หรือตัวกรองทริปแบบเรียลไทม์
  • TextFormField: ใช้เมื่ออยู่ในฟอร์มที่ต้องตรวจสอบความถูกต้อง เช่น ช่องล็อกอิน ลงทะเบียน หรือเพิ่มข้อมูลทริป
  • ทั้งคู่ใช้ TextEditingController ร่วมกันได้เหมือนกัน
Slide 2 • Controlling Inputs with controllers

การจัดการและล้างข้อมูลข้อความด้วย TextEditingController 🎮

`TextEditingController` คือกลไกเชื่อมโยงหน้าจอ View และโค้ดหลังบ้าน เพื่อเรียกอ่านค่าข้อความ (Read) เปลี่ยนแปลงเนื้อหา (Update) และล้างกล่องข้อความทิ้งเมื่อกดยืนยันสำเร็จ [2]:

// 1. การประกาศสร้างอินสแตนซ์และผูกควบคุมวิดเจ็ต

class _MyFormState extends State<MyForm> {

final _usernameController = TextEditingController();

// ⚠️ กฎเหล็ก: ต้องทำลายล้าง Controller ใน dispose เสมอเพื่อเลี่ยงหน่วยความจำรั่วไหล (Memory Leaks)

@override

void dispose() {

_usernameController.dispose();

super.dispose();

}

@override

Widget build(BuildContext context) {

return TextFormField(

controller: _usernameController,

decoration: const InputDecoration(labelText: 'ชื่อผู้ใช้'),

);

}

}

📋 วิธีใช้ TextEditingController ในสถานการณ์ต่าง ๆ:

  • อ่านค่า: _controller.text
  • อัปเดตค่า: _controller.text = 'ค่าใหม่'
  • ล้างข้อมูล: _controller.clear()
  • เลือกข้อความ: _controller.selection = TextSelection(...)
Slide 3 • Keyboard & Focus Management

การควบคุมแป้นพิมพ์และการโยกย้าย Focus ด้วย FocusNode ⌨️

ในการพิมพ์ข้อมูลบนแบบฟอร์มที่มีหลายๆ ช่องกรอก ยอดแย่ที่สุดคือการบีบให้ผู้เรียนต้องเอานิ้วกดจิ้มเปลี่ยนช่องกรอกด้วยตนเอง เราจึงใช้ `FocusNode` เข้ามาควบคุมระบบโฟกัสและทิศทางแป้นพิมพ์:

เมื่อพิมพ์ชื่อผู้ใช้เสร็จเรียบร้อยและกดยืนยันปุ่มส่งบนแป้นพิมพ์ (เช่น textInputAction: TextInputAction.next) เราสามารถเขียนรหัสย้ายโฟกัสข้ามพาสไปหาช่องรหัสผ่านได้ทันทีดังนี้:

// 1. ประกาศ FocusNode สำหรับช่องรหัสผ่าน
final FocusNode _passwordFocusNode = FocusNode();

// 2. ผูก textInputAction กับ onFieldSubmitted
TextFormField(
  textInputAction: TextInputAction.next,
  onFieldSubmitted: (_) => FocusScope.of(context)
    .requestFocus(_passwordFocusNode),
);

// 3. ผูก FocusNode เข้ากับช่องถัดไป
TextFormField(focusNode: _passwordFocusNode);

💡 Best Practices เรื่อง Focus:

  • อย่าลืม _passwordFocusNode.dispose() ใน dispose()
  • ช่องสุดท้ายในฟอร์มใช้ TextInputAction.done เพื่อปิดคีย์บอร์ด
  • ใช้ FocusScope.of(context).unfocus() เพื่อปิดคีย์บอร์ดทั้งหมด

🎯 แผนภาพจำลองการส่งต่อโฟกัสแบบอัตโนมัติ

Username Field I Next Action ⌨️ Password Field
🔗 คู่มือและชุดคำสั่งควบคุม Focus ในช่องรับข้อความ: 📖 Managing Focus — docs.flutter.dev 📖 FocusNode API — api.flutter.dev
Slide 4 • Form Widget & GlobalKeys

โครงสร้างหลักของ Form และการใช้ตัวนำทาง GlobalKey 🏗️

ในการสร้างแบบฟอร์มตรวจสอบคุณค่าข้อมูล เราจะใช้ตัวห่อหุ้มชั้นนอกสุดชื่อวิดเจ็ต Form เพื่อประสานความสัมพันธ์และควบคุมตรวจสอบความปลอดภัยของทุกๆ กล่อง TextFormField ภายใต้ร่มเงาเดียวกัน [1].

🛡️ GlobalKey<FormState> คืออะไร?

คือคีย์ลับสากลเฉพาะตัวที่สร้างขึ้นมาเพื่อผูกติดกับ Form และมอบอำนาจให้เราสามารถสั่งรันประมวลผลคำสั่งตรวจสอบความถูกต้องข้อมูลผ่าน State จากภายนอกวิดเจ็ตได้โดยตรง [3].

🎯 โครงสร้างความสัมพันธ์ของโหนด Form ต้นไม้

Form (key: _formKey) TextFormField (Email) TextFormField (Pass)
🔗 อ่านคู่มือทางการโครงสร้าง Form และการตั้งค่าคีย์ตรวจสอบสิทธิ์: https://docs.flutter.dev/cookbook/forms/validation 🚀
Slide 5 • Form Validation Logic

การออกแบบเงื่อนไขคัดกรองข้อมูลด้วยคุณสมบัติ validator 🧪

ฟังก์ชัน `validator` จะทำงานดักตรวจสอบสายตัวอักษรดิบที่ผู้ใช้งานพิมพ์เข้าสู่กล่องกรอก หากข้อมูลถูกต้องครบถ้วนตามกฎระบบ ฟังก์ชันจะต้องคืนค่ากลับเป็น null แต่หากตรวจพบค่าผิดกฎ ให้พ่นส่งกลับเป็นข้อความ String ผลลัพธ์เตือนภัย ทันที [3]:

// 1. การเขียนเงื่อนไขคัดกรองความมั่นคงของกล่องอีเมล (Email Validator)

TextFormField(

decoration: const InputDecoration(labelText: 'อีเมลแอดเดรส'),

validator: (value) {

// ตรวจช่องว่างข้อมูล

if (value == null || value.isEmpty) {

return 'กรุณากรอกอีเมลสะกดจริง';

}

// ตรวจสอบเงื่อนไขรูปแบบอีเมลด้วยนิพจน์ Regular Expression (Regex)

if (!RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$').hasMatch(value)) {

return 'รูปแบบโครงสร้างอีเมลไม่ถูกต้อง';

}

return null; // 🟢 ข้อมูลผ่านเกณฑ์อย่างไร้กังวล

},

);

// 2. การตรวจรหัสผ่านกำหนดความยาวขั้นต่ำ (Password Length Validator)

validator: (value) {

if (value == null || value.isEmpty) {

return 'กรุณากรอกรหัสผ่าน';

}

if (value.length < 8) {

return 'รหัสผ่านต้องมีอย่างน้อย 8 ตัวอักษร';

}

return null;

},

💡 เคล็ดลับ (Pro Tips):

  • ใช้ autovalidateMode: AutovalidateMode.onUserInteraction เพื่อตรวจสอบอัตโนมัติหลังผู้ใช้พิมพ์
  • แยก Validator เป็นฟังก์ชันสแตนด์อโลน (เช่น String? validateEmail(String?)) เพื่อนำไปใช้ Test ซ้ำได้
  • ไม่ควรตรวจสอบซับซ้อนใน Widget ตรง ๆ — ย้ายลอจิกออกไปที่ util หรือ domain layer
🔗 ดูโครงร่างคู่มือการทำแบบฟอร์มตรวจสอบสิทธิ์และอ้างอิงเพิ่มเติม (Flutter Cookbook): 📖 Form Validation Cookbook — docs.flutter.dev 📖 Handle Text Field Changes — docs.flutter.dev 📦 flutter_form_builder (ตัวช่วยสร้าง Form) — pub.dev
Slide 6 • Triggering Form Validation

การทริกเกอร์ตรวจสอบความเสถียรข้อมูลผ่าน validate() ⚡

เมื่อผู้ใช้งานกดปุ่มล็อกอินหรือยืนยันคำขอสมัครสมาชิก เราจะเขียนโค้ดเพื่อควบคุมการสั่งการคีย์อ้างอิงสากล _formKey.currentState!.validate() เพื่อสั่งให้ตรวจสอบความถูกต้องทันที [3]:

1. User Presses Button 🖱️ ยิงกระตุ้นลอจิก onPressed ของปุ่มยืนยัน
2. Run validate() ⚡ ระบบเรียกตรวจสอบฟังก์ชัน validator ทุกช่องกรอก
3. Evaluate Outcome 🚦 หากถูกต้อง (true) ดึงโค้ดยิง API หากผิดปล่อย Error แสดง UI

ElevatedButton(

onPressed: () {

// รันตรวจเช็คฟอร์มประมวลผล หากข้อมูลผ่านเกณฑ์ทั้งหมด

if (_formKey.currentState!.validate()) {

// เรียก onSaved เพื่อให้ TextFormField แต่ละช่องเก็บค่าลงตัวแปร

_formKey.currentState!.save();

// 🟢 เรียกส่งลอจิก ViewModel ดึงค่าไปรันประมวลผลเซิร์ฟเวอร์ต่อ

ScaffoldMessenger.of(context).showSnackBar(

const SnackBar(content: Text('กำลังประมวลผลข้อมูลลงทะเบียน...')),

);

}

},

child: const Text('สมัครสมาชิก'),

);

🔑 ความต่างระหว่าง validate() กับ save():

  • validate() → ตรวจสอบความถูกต้อง คืนค่า bool (true = ผ่าน)
  • save() → เรียก callback onSaved ของทุกช่องเพื่อบันทึกค่าลงตัวแปร/โมเดล
  • ใช้งานคู่กัน: if (validate()) { save(); submit(); }
Slide 7 • Registration Form Laboratory Work

ใบงานปฏิบัติการประจำคาบเรียน: พัฒนาระบบลงทะเบียนสมัครสมาชิก 📱🧪

ลงมือปฏิบัติด้วยตัวนักศึกษาเอง โดยนำความรู้คริปโทกราฟีของคู่คีย์ และการรับข้อมูลฟอร์ม มาร่วมกันเขียนสเกเลตันแบบฟอร์มลงทะเบียนสมาชิกที่ปลอดภัย มีเสถียรภาพ และสอดคล้องกับพอร์ตทางทฤษฎี:

📋 ความต้องการของระบบ (Form Specifications)

  • กล่องชื่อบัญชี (Username): ห้ามเป็นช่องว่างข้อมูลดิบเด็ดขาด [3]
  • กล่องอีเมล (Email): ตรวจรูปแบบสระและเครื่องหมาย @ ของสากลให้ถูกต้อง [3]
  • กล่องรหัสผ่าน (Password): ต้องระบุความยาวขั้นต่ำอย่างน้อย 8 ตัวอักษรเพื่อความปลอดภัย [3]

🧪 คำชี้ชวนการทดสอบปฏิบัติภารกิจ

ทดสอบรันผลลัพธ์ผ่านตัวเครื่องโทรศัพท์พกพาจำลอง และป้อนข้อมูลที่ผิดพลาดเพื่อเช็คสัญญาณพ่น Error UI สีแดงใต้กรอบทำงานอย่างลื่นไหล

🔗 ดูรายละเอียดโปรเจกต์และเปรียบเทียบชุดเทสจากโปรเจกต์ต้นแบบ Compass: 💻 Compass App — github.com/flutter/samples

// lib/ui/auth/screens/register_screen.dart (โครงร่างเริ่มต้น)

class _RegisterFormState extends State<RegisterForm> {

final _formKey = GlobalKey<FormState>();

final _usernameController = TextEditingController();

final _emailController = TextEditingController();

@override

void dispose() {

_usernameController.dispose(); _emailController.dispose();

super.dispose();

}

@override

Widget build(BuildContext context) => Form(

key: _formKey,

child: Column(children: [

// TextFormField 3 ช่อง: Username / Email / Password

// + ElevatedButton สมัครสมาชิก

]),

);

}

📘 ขั้นตอนปฏิบัติ (Step-by-Step):

  1. สร้าง GlobalKey<FormState> และ TextEditingController 3 ตัว
  2. เขียน validator สำหรับ Username (ไม่ว่าง), Email (Regex), Password (≥8 ตัว)
  3. ใช้ autovalidateMode.onUserInteraction เพื่อตรวจอัตโนมัติ
  4. ปุ่มกดเรียก validate() + save() แล้วส่งต่อ ViewModel
  5. อย่าลืม dispose() controller ทุกตัว
Slide 8 • Real-time Input Validation

การดักสแกนพฤติกรรมความถูกต้องแบบเรียลไทม์ (onChanged) 📡

คุณลักษณะ onChanged จะประมวลและรันลอจิกความถูกต้องทันทีที่ปลายนิ้วสัมผัสอักษรใดๆ บนแป้นพิมพ์ ซึ่งมีพฤติกรรมและแนวปฏิบัติเทียบกับระบบจัดเก็บคีย์บอร์ด Controller ดังนี้ [2]:

📡 การสแกนผ่าน onChanged (ระดับพื้นฐาน)

ยิงลอจิกตรวจสอบสถานะและวาดหน้าจอ Rebuild UI ซ้ำๆ แบบเบาตัว เหมาะกับการใช้ทำระบบกรองค้นหาทริปท่องเที่ยวแบบทันท่วงที (Live Filtering)

💾 การผูก Controller Listeners (ระดับโปรดักชัน)

นำเสนอการติดตั้ง Listener ดักสแกนพจนานุกรมในคลาส State เพื่อควบคุมและล็อกอินประเมินรูปแบบการกรอกได้ละเอียดและปลอดภัยสูง

💡 คำแนะนำจาก OWASP: การตรวจสอบฝั่งไคลเอนต์หน้าแอป Flutter (Client-Side Validation) ช่วยเพื่อความลื่นไหลของแอปพกพาเป็นสำคัญ แต่ห้ามลืมเขียนตรวจสอบสิทธิ์ร่วมกันฝั่งเซิร์ฟเวอร์หลังบ้าน Django (Server-Side Validation) เพื่อสกัดการลักลอบบันทึกรหัสแปลกปลอมเข้าฐานข้อมูลเด็ดขาด [3].
Flutter Mobile Dev | Week 4 • Parts 3 & 4

สัปดาห์ที่ 4: ฟอร์มและการรับค่าข้อมูล (Forms & Input Validation)

🎯 สารบัญโครงสร้างเนื้อหาหมวดปฏิบัติการที่ 3 และ 4

🎨 หมวดที่ 3: การจัดรูปแบบและยกระดับประสบการณ์ผู้ใช้งานฟอร์ม (UX)
🏗️ หมวดที่ 4: การเชื่อมโยงข้อมูลฟอร์มสู่สถาปัตยกรรมแอปพลิเคชัน (MVVM)
💡 คำชี้แนะ: คลิกหัวข้อบนสารบัญเพื่อข้ามไปยังหน้านั้นโดยตรง หรือใช้ปุ่มควบคุมด้านล่างเพื่อไล่ดูตามลำดับการสอน
Slide 10 • Input Aesthetics & InputDecoration

ยกระดับความสวยงามของฟอร์มด้วย InputDecoration 🎨

การจัดรูปแบบที่ดีช่วยสร้างประสบการณ์ผู้ใช้งาน (UX) ที่ราบรื่น โดยการเพิ่มคำแนะนำ (Hint) ป้ายกำกับ (Label) ไอคอนนำหน้า/ต่อท้าย (Prefix/Suffix) และเส้นขอบเมื่ออยู่ในสถานะปกติ โฟกัส หรือเกิดข้อผิดพลาด:

TextFormField(

decoration: InputDecoration(

// ป้ายกำกับแบบลอยตัวและไอคอนนำหน้า

labelText: 'อีเมลผู้ใช้',

hintText: 'example@email.com',

prefixIcon: const Icon(Icons.email_outlined),

suffixIcon: IconButton(icon: const Icon(Icons.clear), onPressed: () => _emailController.clear()),

// สถานะข้อผิดพลาด (แสดงอัตโนมัติเมื่อ validator คืนค่า String)

errorStyle: TextStyle(color: Colors.red.shade300, fontSize: 12),

// การตกแต่งเส้นขอบสไตล์กล่องมน (OutlineInputBorder)

border: const OutlineInputBorder(

borderRadius: BorderRadius.all(Radius.circular(12.0)),

),

focusedBorder: OutlineInputBorder(

borderSide: BorderSide(color: Colors.blue.shade400, width: 2.0),

borderRadius: const BorderRadius.all(Radius.circular(12.0)),

),

),

);

💡 คุณสมบัติ InputDecoration ที่ควรรู้:

  • labelText — ป้ายกำกับลอยตัว (Floating Label) เลื่อนขึ้นเมื่อโฟกัส
  • hintText — ข้อความตัวอย่าง (Placeholder) ในกล่อง
  • prefixIcon / suffixIcon — ไอคอนนำหน้า / ต่อท้าย (เช่น ปุ่มล้าง, ตาดูรหัส)
  • errorText / errorStyle — แสดงข้อความผิดพลาดพร้อมสี (จัดการอัตโนมัติด้วย validator)
  • helperText — ข้อความช่วยเหลือใต้ช่องกรอก
🔗 ดูรูปแบบและการตกแต่งกล่องข้อความระดับมาตรฐานเพิ่มเติม (Flutter Documentation): 📖 Text Input — docs.flutter.dev 📖 InputDecoration API — api.flutter.dev 📖 Material 3 Text Fields — m3.material.io
Slide 11 • Form Feedback with SnackBar

การแสดงผลการทำงานและแจ้งเตือนผู้ใช้ด้วย SnackBar 💬

เมื่อแบบฟอร์มผ่านเกณฑ์การตรวจสอบ (Validation) เรียบร้อยแล้ว การรายงานสถานะหรือผลลัพธ์เป็นสิ่งจำเป็นอย่างยิ่ง โดยทีมงานวิศวกร Flutter แนะนำให้แจ้งข้อมูลแบบไม่ขัดจังหวะสายตาผู้ใช้ด้วยการเรียก **`ScaffoldMessenger`** เพื่อวาด SnackBar:

// แนวทางรันข้อความด่วนแบบ SnackBar ระดับสากล

void _showSuccessFeedback(BuildContext context) {

ScaffoldMessenger.of(context).showSnackBar(

SnackBar(

content: const Text('บันทึกข้อมูลเรียบร้อยแล้ว!'),

backgroundColor: Colors.green.shade600,

duration: const Duration(seconds: 3),

action: SnackBarAction(

label: 'ตกลง',

textColor: Colors.white,

onPressed: () {},

),

),

);

}

💡 ข้อสังเกตความเสถียร: การใช้ ScaffoldMessenger.of(context) เป็นกลไกใหม่ที่ทำงานแยกอิสระจากสิทธิ์ควบคุม State ของ Scaffold เดิม ส่งผลให้ระบบทำงานได้อย่างต่อเนื่องแม้ผู้ใช้กดปิดหน้าจอดังกล่าวไปแล้ว.

🎨 รูปแบบ SnackBar ตามสถานะ (State Variants):

✅ สำเร็จ
backgroundColor: Colors.green
❌ ผิดพลาด
backgroundColor: Colors.red
ℹ️ ข้อมูล
backgroundColor: Colors.blueGrey

กำหนดด้วย property behavior: SnackBarBehavior.floating เพื่อให้เป็นป้ายลอยมุมมนสวยงาม

🔗 ดูรายละเอียดคู่มือสอนแสดงข้อความความสำเร็จผ่าน SnackBar และแนวทาง Material Design: 📖 SnackBar Cookbook — docs.flutter.dev 📖 SnackBar API — api.flutter.dev 📖 Material 3 Snackbar — m3.material.io
Slide 12 • Keyboard Dismissal & GestureDetector

การปิดแป้นพิมพ์คีย์บอร์ดเมื่อสัมผัสพื้นที่ว่างด้วย GestureDetector ⌨️❌

ปัญหา UX ยอดนิยมในระบบมือถือคือ แป้นคีย์บอร์ดไม่ยอมยุบตัวลงเมื่อพิมพ์ข้อมูลเสร็จ วิธีแก้ปัญหาระดับโปรคือการหุ้มหน้าจอหลัก (`Scaffold`) ด้วยวิดเจ็ต **`GestureDetector`** เพื่อคอยดักตรวจจับสิทธิ์สัมผัส แล้วเรียกคืนโฟกัสผ่านคำสั่ง **`unfocus()`**:

// ห่อหุ้ม Scaffold ชั้นนอกสุดเพื่อดักล้าง โฟกัส (Focus Clear)

@override

Widget build(BuildContext context) {

return GestureDetector(

onTap: () {

// ตรวจสอบและยกเลิกสถานะโฟกัสของคีย์บอร์ด

FocusScopeNode currentFocus = FocusScope.of(context);

if (!currentFocus.hasPrimaryFocus) {

currentFocus.unfocus();

}

},

child: Scaffold(

appBar: AppBar(title: const Text('สมัครสมาชิก')),

body: const MyRegistrationForm(),

),

);

}

🔗 อ่านรายละเอียดเกี่ยวกับการจัดสรรพฤติกรรมและการดักรับค่าสัมผัส (Flutter Interactivity): https://docs.flutter.dev/ui/interactivity/gestures 🚀
Slide 13 • Obscure Password Toggle

สลับซ่อน/แสดงรหัสผ่าน (Obscure Text Toggle) 👁️🔒

เพื่อความปลอดภัยขั้นพื้นฐานและสอดคล้องกับมาตรฐานความปลอดภัยสากล การส่งรหัสผ่านต้องปิดบังอักษรดิบด้วยคุณลักษณะ obscureText: true และ open ช่องทางให้ผู้ใช้สลับมองรหัสผ่านได้ผ่านการคลิกไอคอนรูปดวงตา:

// สร้างตัวแปร boolean เพื่อสับเปลี่ยน State

bool _obscurePassword = true;

// พอร์ตโครงสร้างวิดเจ็ตใน InputDecoration

TextFormField(

obscureText: _obscurePassword,

decoration: InputDecoration(

labelText: 'รหัสผ่าน',

suffixIcon: IconButton(

icon: Icon(_obscurePassword ? Icons.visibility_off : Icons.visibility),

onPressed: () {

setState(() {

_obscurePassword = !_obscurePassword;

});

},

),

),

);

Compass App Mockup View
📱 แผนภาพตัวอย่างหน้าจอเข้ายืนยันสิทธิ์ที่มีการสลับปุ่มเปิด/ปิดตาของรหัสผ่านเพื่อความมั่นใจ
Slide 14 • MVVM Form Integration

การส่งข้อมูลฟอร์มสู่เลเยอร์ ViewModel (MVVM Binding) 🏗️

ตามคู่มือสากลของสถาปัตยกรรมแอปพลิเคชัน Flutter แบบ MVVM ชั้นหน้าจอ View จะต้องไม่ส่งค่าไปคุยกับเครือข่ายตรงๆ [1] แต่จะรวบรวมค่าข้อมูลดิบผ่านคำสั่ง onSaved หรือดึงสดผ่าน TextEditingController แล้วส่งต่อสิทธิ์ให้ **ChangeNotifier ViewModel** เป็นผู้ประสานงานหลัก [2, 3]:

// 1. View เรียกคำสั่งเมื่อ Validation ผ่านฉลุย

if (_formKey.currentState!.validate()) {

_formKey.currentState!.onSaved();

// เรียก ViewModel ประมวลผลคำขอล็อกอินผ่านคลัง Provider

context.read<LoginViewModel>().submitLogin(

email: _emailValue,

password: _passwordValue,

);

}

🔗 เชื่อมโยงสัปดาห์ที่ 12: ในระบบของเรา การล็อกอินจริงเดินทางผ่าน OIDC Redirect Flow — AuthRepositoryRemote.login() จะสั่ง Authenticator.authorize() ของแพ็กเกจ openid_client พาผู้ใช้ไปยืนยันตัวตนที่ django-oidc-provider แล้วนำ Credential (ID Token / Access Token) กลับมาเก็บใน Secure Storage แทนการส่งรหัสผ่านตรงถึง API
🔗 เจาะลึกการจัดทำระบบการประมวลผลคำสั่งแบบปลอดภัยผ่านเลเยอร์ (UI Layer Case Study): https://docs.flutter.dev/app-architecture/case-study/ui-layer 🚀
MVVM Architecture Flow
📊 ความสัมพันธ์ในแอปพลิเคชัน Compass: ฟอร์มใน View รวบรวมส่งต่อหา ViewModel เพื่อเตรียมส่งลงสู่ชั้น Repository เลเยอร์ล่างตามลำดับแบบทางเดียว (UDF) [4, 5]
Slide 15 • Server-Side Validation Errors

การจัดการข้อผิดพลาดข้อมูลจากฝั่ง REST API หลังบ้าน 📡🚨

แม้ว่าข้อมูลจะผ่านการดักกรองจากโค้ดเครื่องหน้าบ้าน (Client-Side Validation) สำเร็จ แต่อาจเกิดข้อผิดพลาดในการประมวลผลฝั่งเซิร์ฟเวอร์หลังบ้าน เช่น **"อีเมลซ้ำซ้อน" (Email Already Exists)** หรือ **"รหัสผ่านไม่ผ่านเกณฑ์วิเคราะห์จริง"** ในรูปโครงสร้างข้อมูล JSON:

// ปรับปรุง ViewModel ให้อัปเดต UI เมื่อได้รับข้อผิดพลาดรหัส HTTP 400 จาก Django

class LoginViewModel extends ChangeNotifier {

String? _serverErrorMessage;

bool _isLoading = false;

String? get serverErrorMessage => _serverErrorMessage;

bool get isLoading => _isLoading;

Future<void> submitLogin(String email, String password) async {

_isLoading = true;

_serverErrorMessage = null;

notifyListeners();

final result = await _authRepo.login(email, password);

_isLoading = false;

if (result is Error) {

_serverErrorMessage = result.error.message; // ดึงข้อความล้มเหลวจากเซิร์ฟเวอร์ [6]

notifyListeners(); // สั่งสแกนและพ่นสีแดงเตือนบนหน้าจอ View [7]

} else {

notifyListeners(); // สำเร็จ → นำทางไปหน้า Home

}

}

}

# 🧪 จำลองข้อผิดพลาดฝั่ง server ด้วย HTTPie (backend: uv run manage.py runserver)

$ http POST :8000/api/bookings/ destination_name="" price=-5

↳ HTTP/1.1 400 Bad Request → {"price": ["Ensure this value is greater than 0."]}

# แสดงข้อความ error บน UI ด้วย Flutter:

if (viewModel.serverErrorMessage != null) {

Text(viewModel.serverErrorMessage!, style: TextStyle(color: Colors.red));

}

⚠️ กฎเหล็กความปลอดภัย: นักศึกษาต้องเขียนสคริปต์ให้ดักเช็คค่าตอบรับ result.serverErrorMessage หากไม่เป็นว่างเปล่า (Null) ให้ดึงไปแสดงผลแบบกล่องเตือน หรือพ่นลงป้ายกำกับข้อผิดพลาดของช่องนั้นๆ โดยด่วน.
📌 ตัวอย่าง HTTP Status Code ที่มักพบบน Django DRF:
200 OK / 201 Created / 400 Bad Request / 401 Unauthorized / 403 Forbidden / 404 Not Found / 500 Server Error
🔗 ข้อมูลสนับสนุนการรับมือข้อผิดพลาดจาก REST API: 📖 DRF Exceptions — django-rest-framework.org 📖 HTTP 400 Bad Request — MDN 📖 Flutter UI Layer Case Study — docs.flutter.dev
Slide 16 • Diverse Form Input Fields

วิดเจ็ตกรอกแบบฟอร์มประเภทจำกัดตัวเลือก (Selection Inputs) 🗳️

นอกจากการพิมพ์ข้อความทั่วไป ระบบแอปพลิเคชัน Compass ยังรองรับกล่องรับข้อมูลประเภทกำหนดสิทธิ์การตรวจสอบและการติ๊กเลือกชนิดต่างๆ ภายใต้วิดเจ็ตตระกูล **`FormField`** สำหรับควบคุมความต่อเนื่องในกล่องเดียวกัน [8, 9]:

🔽 DropdownButtonFormField

จำกัดขอบเขตข้อมูลที่นักท่องเที่ยวสามารถจองได้ (เช่น เลือกจุดหมายปลายทาง ทริปเมืองโตเกียว ลอนดอน) สามารถแนบฟังก์ชันตรวจสอบคุณค่า validator ได้เหมือนช่องพิมพ์ทั่วไป.

DropdownButtonFormField<String>(
  items: destinations,
  onChanged: (v) => _dest = v!,
  validator: (v) => v == null
    ? 'เลือกจุดหมาย' : null,
);
☑️ CheckboxListTile

ให้ยืนยันเงื่อนไขความสอดคล้องหรือความประสงค์พิเศษเพิ่มเติม (เช่น ต้องการไกด์นำเดินทางผู้เชี่ยวชาญภาษาไทย) บันทึกและควบคุมเป็นพารามิเตอร์ประเภท boolean ในระบบ View.

CheckboxListTile(
  value: _needGuide,
  onChanged: (v) => setState(() => _needGuide = v!),
  title: const Text('ต้องการไกด์'),
);
🔛 SwitchListTile

สวิตช์สำหรับเปิด/ปิดฟังก์ชันการทำงานบางอย่างแบบทันที เช่น เปิดปิดสิทธิ์การรับรายงานด่วนทริปลดราคาพิเศษผ่านกลไกระบบ Notification ในอุปกรณ์เคลื่อนที่.

SwitchListTile(
  value: _notify,
  onChanged: (v) => setState(() => _notify = v!),
  title: const Text('รับการแจ้งเตือน'),
);

💡 เคล็ดลับการใช้งาน:

  • ทุกวิดเจ็ตประเภท Selection ที่อยู่ใน Form ยังถูกรวมอยู่ใน _formKey.currentState!.validate()
  • เก็บค่าจาก onChanged ลงตัวแปร State ก่อนส่งต่อ ViewModel
  • DropDownButtonFormField ต้องกำหนด items เป็น List ของ DropdownMenuItem
🔗 ดูรูปแบบคู่มือประยุกต์จัดทำ Form ยืนยันสิทธิ์ชนิดต่างๆ และเอกสารวิดเจ็ต: 📖 Form Validation — docs.flutter.dev 📖 DropdownButtonFormField API — api.flutter.dev 📖 CheckboxListTile API — api.flutter.dev 📖 SwitchListTile API — api.flutter.dev
Slide 17 • Week 4 Laboratory Assignment

ใบงานปฏิบัติการ: พัฒนาฟอร์มเพิ่มข้อมูลทริปเดินทางใหม่ 🧭🧪

นักศึกษาแต่ละกลุ่มจะต้องลงมือพัฒนาหน้าจอส่งต่อข้อมูลสลับจองทริปเดินทางใหม่ **`Create Booking Form`** ภายใต้โครงร่างสถาปัตยกรรมของโครงการ Compass ให้ใช้งานและรันงานได้สมบูรณ์ [10]:

🔥 เกณฑ์ปฏิบัติและข้อตกลงการทดสอบ (Specifications):

  • สร้างแบบฟอร์มรองรับคีย์บอร์ด Unfocus และแสดง SnackBar คล่องตัวเมื่อสำเร็จ
  • ช่องจุดหมาย (Destination): ใช้ DropdownFormField และห้ามปล่อยว่าง
  • ช่องงบประมาณ (Price): ตรวจสอบว่าต้องพิมพ์ระบุเป็นตัวเลขจำนวนเต็มบวกเท่านั้น
  • จัดส่งข้อมูลเข้าสู่ ViewModel เพื่อเตรียมพร้อมส่งต่อไปยัง REST API หลังบ้านจริง

🎯 ทิศทางการเรียนรู้นอกเหนือชั่วโมง

นักศึกษาสามารถตรวจสอบชุดคำสั่งและเทมเพลตฟอร์มรับส่งข้อมูลของโครงการตัวอย่างทางการบนกิธฮับ [4]:

Slide 18 • Week 4 Summary & Preview

สรุปบทเรียนสัปดาห์ที่ 4 และปูทางสู่ปฏิบัติการสัปดาห์ที่ 5 🏁

วันนี้เราได้เรียนรู้การขัดเกลาอินเทอร์เฟซรับข้อมูลอย่างประณีตผ่าน InputDecoration, แสดง SnackBar [8], ปิดคีย์บอร์ดอัตโนมัติ, สร้างปุ่มสลับซ่อนรหัสผ่าน และทำการผูกส่งข้อมูลอย่างมั่นคงปลอดภัยเข้าสู่เลเยอร์ ViewModel (MVVM) ได้เสร็จสิ้นเรียบร้อยแล้ว [2]

🚀 ปูทางสู่ สัปดาห์ที่ 5: Dependency Injection & Controlled Testing

  • Dependency Injection (DI): ถอดเปลี่ยนคลังเก็บข้อมูลข้ามสภาพแวดล้อมด้วย package:provider ทั่วทั้งแอป [11, 12]
  • Controlled Testing: เขียนคำสั่งและกระบวนการจำลองข้อมูลเพื่อทำ Unit Test บนเลเยอร์ ViewModel โดยปราศจากเงื่อนไขเครือข่ายเน็ตเวิร์ก [13, 14]
  • Widget Testing: เขียนโปรแกรมวาดและทดสอบสกรีน UI เพื่อทดลองคลิกสุ่มตรวจสอบการสะท้อนสถานะ (State) โดยอัตโนมัติ [13, 14]
🔗 คู่มือประเมินความปลอดภัยและการบริหารค่า Dependency ในสถาปัตยกรรม Flutter ล่วงหน้า: 📖 Dependency Injection — docs.flutter.dev 📖 Flutter Testing — docs.flutter.dev 📦 provider Package — pub.dev
Slide 19 • Complete Form Example

ตัวอย่างโค้ดสมบูรณ์: ฟอร์มลงทะเบียนแบบครบวงจร 🧩

รวบรวมทุกแนวคิดที่เรียนมาในสัปดาห์นี้ (Form, GlobalKey, Controller, Validator, InputDecoration, Password Toggle, SnackBar, unfocus) ในไฟล์เดียวที่รันได้จริง:

// lib/ui/auth/register_screen.dart — Full registration form

class _RegisterScreenState extends State<RegisterScreen> {

final _formKey = GlobalKey<FormState>();

final _username = TextEditingController();

final _email = TextEditingController();

final _password = TextEditingController();

final _passwordFocus = FocusNode();

bool _obscurePassword = true;

@override

void dispose() {

_username.dispose(); _email.dispose(); _password.dispose(); _passwordFocus.dispose();

super.dispose();

}

void _submit() {

if (!_formKey.currentState!.validate()) return;

_formKey.currentState!.save();

// เรียก ViewModel → Repository → API (MVVM)

context.read<RegisterViewModel>().register(

username: _username.text,

email: _email.text,

password: _password.text,

);

}

Widget build(BuildContext context) => GestureDetector(

onTap: () => FocusScope.of(context).unfocus(),

child: Form(

key: _formKey,

autovalidateMode: AutovalidateMode.onUserInteraction,

child: Column(children: [

// TextFormField (username/email) + obscure password ...

]),

),

);

}

📌 Challenge ขยายผล: ลองเพิ่ม TextFormField ยืนยันรหัสผ่าน (Confirm Password) โดย validator ต้องเช็คว่าค่าตรงกับ _password.text หรือไม่ — ถ้าต่างกันให้พ่นข้อความ 'รหัสผ่านไม่ตรงกัน'