สัปดาห์ที่ 2: เพิ่มข้อมูลเองและเก็บถาวร (State + Form + SQLite)
เป้าหมายสัปดาห์นี้: ทำให้ผู้ใช้ เขียนบันทึกเอง เพิ่ม/ลบ/แก้ได้ และ เก็บถาวร (ปิดแอปเปิดใหม่ข้อมูลยังอยู่)
ต่อจากสัปดาห์ที่แล้ว: แอปเราสวยแล้ว แต่ข้อมูลถูกพิมพ์ไว้ในโค้ด ผู้ใช้เพิ่มเองไม่ได้ — วันนี้แก้ให้ผู้ใช้เพิ่มบันทึกเองได้จริง
ก่อนเริ่ม: สัปดาห์นี้เราจะทำอะไร
เราจะอัปเกรด MyDiary ให้
- มี ฟอร์มเขียนบันทึก (กรอกชื่อเรื่อง + เนื้อหา)
- เพิ่ม บันทึกลงรายการได้ (กดปุ่ม + ที่เตรียมไว้สัปดาห์ที่แล้ว)
- ลบ / แก้ บันทึกได้
- เก็บลงฐานข้อมูล SQLite → ปิดแอปเปิดใหม่ข้อมูลยังอยู่
เส้นทางของสัปดาห์นี้แบ่ง 3 ส่วน:
ส่วน A: State → เข้าใจการเปลี่ยนหน้าจอ (setState)
ส่วน B: Form → รับข้อมูลจากผู้ใช้ (ฟอร์ม + ตรวจสอบ, Dropdown, DatePicker, Checkbox, Radio)
ส่วน C: SQLite → เก็บข้อมูลถาวร (ไม่หายตอนปิดแอป)
ส่วนที่ 1: ทำความเข้าใจ "อะไรเป็นอะไร"
1.1 State (สถานะ) คืออะไร
State = ข้อมูลที่เปลี่ยนได้ และเมื่อเปลี่ยนแล้วหน้าจอต้องอัปเดตตาม
สัปดาห์ที่แล้วข้อมูล "นิ่ง" (พิมพ์ในโค้ด) — สัปดาห์นี้ข้อมูล "มีชีวิต" (ผู้ใช้เพิ่ม/ลบได้) พอข้อมูลเปลี่ยน หน้าจอต้องวาดใหม่
ตัวอย่าง: รายการบันทึกมี 5 อัน → ผู้ใช้เพิ่มอันที่ 6 → หน้าจอต้องแสดง 6 อันทันที
1.2 setState — คำสั่งสำคัญที่สุดของสัปดาห์นี้
setState = "ฉันเปลี่ยนข้อมูลแล้ว ช่วยวาดหน้าจอใหม่ด้วย"
setState(() {
diaries.add(newDiary); // เปลี่ยนข้อมูล
}); // Flutter วาดจอใหม่ให้
หัวใจ: เปลี่ยนข้อมูลเฉยๆ หน้าจอไม่อัปเดต — ต้องเปลี่ยนใน setState เท่านั้น หน้าจอถึงจะวาดใหม่
1.3 StatefulWidget vs StatelessWidget (ทบทวน + ต่อยอด)
| Stateless (สัปดาห์ 1) | Stateful (สัปดาห์นี้) | |
|---|---|---|
| ข้อมูล | นิ่ง ไม่เปลี่ยน | เปลี่ยนได้ |
| ใช้ setState | ไม่ได้ | ได้ |
| ตัวอย่าง | หน้าเกี่ยวกับ | หน้ารายการที่เพิ่ม/ลบได้ |
สัปดาห์นี้หน้ารายการต้องเปลี่ยนเป็น StatefulWidget เพราะข้อมูลเปลี่ยนได้แล้ว
1.4 Form (ฟอร์ม) — รับข้อมูลจากผู้ใช้
ส่วนประกอบของฟอร์ม
| Widget | หน้าที่ |
| Form | กล่องครอบช่องกรอกทั้งหมด |
| TextFormField | ช่องกรอกที่ตรวจสอบข้อมูลได้ |
| TextEditingController | ตัวอ่านค่าที่ผู้ใช้พิมพ์ |
| validator | กติกาตรวจสอบ (เช่น ห้ามว่าง) |
1.5 SQLite — ฐานข้อมูลในเครื่อง
ปัญหา: ข้อมูลใน List อยู่ในหน่วยความจำ (RAM) — ปิดแอป = หายหมด
ทางแก้: SQLite = ฐานข้อมูลที่เก็บลงไฟล์ในเครื่อง ปิดแอปข้อมูลยังอยู่
| คำศัพท์ SQLite | คือ | เปรียบเทียบ |
|---|---|---|
| Database | ฐานข้อมูล 1 ไฟล์ | สมุด 1 เล่ม |
| Table | ตารางเก็บข้อมูล | หน้าในสมุด |
| Row | 1 แถว (1 บันทึก) | 1 บรรทัด |
| Column | 1 ช่อง (title, content) | ช่องในตาราง |
ส่วนที่ 2: เตรียมเครื่องมือ
2.1 เปิดโปรเจกต์เดิม
เปิดโปรเจกต์ mydiary จากสัปดาห์ที่แล้ว (ไม่สร้างใหม่)
2.2 ติดตั้ง package SQLite
เปิด Terminal ใน VS Code (Ctrl+~) พิมพ์:
flutter pub add sqflite path
sqflite= ตัวจัดการฐานข้อมูล SQLitepath= ตัวช่วยหาที่อยู่ไฟล์ฐานข้อมูล
รอจนขึ้น Changed X dependencies! = ติดตั้งเสร็จ
ส่วนที่ 3: ลงมือทำทีละขั้น
ทำตามทีละขั้น รันดูผลทุกขั้น — เราจะทำ State ก่อน (เพิ่มในหน่วยความจำ) แล้วค่อยเปลี่ยนเป็น SQLite (ถาวร)
ส่วน A: State — เพิ่มบันทึกได้ (ยังไม่ถาวร)
ขั้นที่ 1 — เปลี่ยนหน้ารายการเป็น StatefulWidget
เป้าหมาย: ให้หน้ารายการเปลี่ยนข้อมูลได้
แนวคิด: สัปดาห์ที่แล้ว DiaryListPage เป็น Stateless (นิ่ง) — ตอนนี้ข้อมูลต้องเพิ่มได้ ต้องเปลี่ยนเป็น Stateful
แก้ DiaryListPage จาก Stateless เป็น Stateful
เพิ่ม Class _DiaryListPageState แล้วย้าย List diaries ย้ายออกมานอก build
class DiaryListPage extends StatefulWidget {
const DiaryListPage({super.key});
@override
State<DiaryListPage> createState() => _DiaryListPageState();
}
class _DiaryListPageState extends State<DiaryListPage> {
// ย้าย List ข้อมูลมาไว้ตรงนี้ (นอก build)
final List<Diary> diaries = [
Diary(title: 'วันแรกของการเรียน Flutter', content: 'เริ่มเขียนแอปตัวแรก', date: '1 มิ.ย. 2568'),
Diary(title: 'เข้าใจ Widget แล้ว', content: 'ทุกอย่างคือ Widget', date: '3 มิ.ย. 2568'),
];
@override
Widget build(BuildContext context) {
// ... โค้ด build เหมือนเดิม (ListView + FloatingActionButton)
}
}
อธิบายความต่าง:
- Stateful แยกเป็น 2 class: ตัว Widget + ตัว State (_DiaryListPageState)
- List diaries ย้ายออกมานอก build — เพราะต้องอยู่ยาว ไม่ถูกสร้างใหม่ทุกครั้งที่วาดจอ
เช็คพอยต์ 1: แอปยังรันได้เหมือนเดิม (ยังไม่เห็นความต่าง แต่โครงพร้อมแล้ว)
ขั้นที่ 2 — สร้างหน้าฟอร์มเขียนบันทึก
เป้าหมาย: หน้าใหม่ที่มีช่องกรอกชื่อเรื่อง + เนื้อหา
สร้าง class ใหม่ (ท้ายไฟล์):
class AddDiaryPage extends StatefulWidget {
const AddDiaryPage({super.key});
@override
State<AddDiaryPage> createState() => _AddDiaryPageState();
}
class _AddDiaryPageState extends State<AddDiaryPage> {
final _formKey = GlobalKey<FormState>(); // กุญแจคุมฟอร์ม
final _titleController = TextEditingController(); // อ่านค่าช่องชื่อเรื่อง
final _contentController = TextEditingController();// อ่านค่าช่องเนื้อหา
@override
void dispose() {
_titleController.dispose(); // คืนทรัพยากรตอนปิดหน้า
_contentController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('เขียนบันทึกใหม่')),
body: Padding(
padding: const EdgeInsets.all(16),
child: Form(
key: _formKey,
child: Column(
children: [
TextFormField(
controller: _titleController,
decoration: const InputDecoration(
labelText: 'ชื่อเรื่อง',
border: OutlineInputBorder(),
),
validator: (value) {
if (value == null || value.trim().isEmpty) {
return 'กรุณากรอกชื่อเรื่อง';
}
return null; // null = ผ่าน
},
),
const SizedBox(height: 16),
TextFormField(
controller: _contentController,
maxLines: 5, // ช่องเนื้อหาสูง 5 บรรทัด
decoration: const InputDecoration(
labelText: 'เนื้อหา',
border: OutlineInputBorder(),
),
validator: (value) {
if (value == null || value.trim().isEmpty) {
return 'กรุณากรอกเนื้อหา';
}
return null;
},
),
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: () {
// ขั้นถัดไปจะเติมโค้ดบันทึกตรงนี้
},
child: const Text('บันทึก'),
),
),
],
),
),
),
);
}
}
ส่วนสำคัญ:
- _formKey = กุญแจไว้สั่งตรวจสอบฟอร์มทั้งหมดพร้อมกัน
- TextEditingController = ตัวอ่านค่าที่พิมพ์ (ต้อง dispose ตอนปิดหน้า)
- validator = กติกา คืน String = ผิด (โชว์ข้อความแดง) / คืน null = ผ่าน
เช็คพอยต์ 2: (ยังเปิดหน้านี้ไม่ได้ — ต่อในขั้น 3)
ขั้นที่ 3 — เปิดหน้าฟอร์มจากปุ่ม + (Navigation ส่งค่ากลับ)
เป้าหมาย: กดปุ่ม + → เปิดฟอร์ม → กรอกเสร็จส่งบันทึกกลับมา
แนวคิด: สัปดาห์ที่แล้วเรา push ไปหน้าใหม่ — คราวนี้ push แล้ว รับค่ากลับ ด้วย (ใช้ await)
ขั้น 3.1 — แก้ปุ่มบันทึกในฟอร์ม ให้ส่งข้อมูลกลับ:
onPressed: () {
if (_formKey.currentState!.validate()) { // ตรวจฟอร์มก่อน
final newDiary = Diary(
id: widget.existing?.id,
title: _titleController.text.trim(),
content: _contentController.text.trim(),
date: DateTime.now().toString().substring(0, 10), // วันที่วันนี้
);
Navigator.pop(context, newDiary); // ส่งบันทึกกลับหน้ารายการ
}
},
ขั้น 3.2 — แก้ปุ่ม + ในหน้ารายการ ให้เปิดฟอร์มแล้วรับค่า:
floatingActionButton: FloatingActionButton(
onPressed: () async { // async เพราะต้องรอค่ากลับ
final result = await Navigator.push(
context,
MaterialPageRoute(builder: (context) => const AddDiaryPage()),
);
if (result != null && result is Diary) { // ถ้าได้บันทึกกลับมา
setState(() {
diaries.add(result); // เพิ่มลงรายการ + วาดจอใหม่
});
}
},
child: const Icon(Icons.add),
),
อธิบาย:
- Navigator.pop(context, newDiary) = ปิดหน้าฟอร์ม พร้อมส่งค่ากลับ
- await Navigator.push(...) = เปิดหน้าแล้ว รอ จนหน้าถูกปิด รับค่าที่ส่งกลับ
- setState(() => diaries.add(result)) = เพิ่มบันทึก + สั่งวาดจอใหม่ → รายการอัปเดตทันที
กด F5 → ควรเห็น: กดปุ่ม + → กรอกฟอร์ม → กดบันทึก → กลับมาเห็นบันทึกใหม่ในรายการ!
เช็คพอยต์ 3: เพิ่มบันทึกได้แล้ว! (แต่ปิดแอปยังหาย — แก้ในส่วน C)
ขั้นที่ 4 — ลบบันทึก
เป้าหมาย: ปัดหรือกดลบบันทึกได้
แก้ ListTile ในรายการ เพิ่มปุ่มลบ:
trailing: IconButton(
icon: const Icon(Icons.delete, color: Colors.red),
onPressed: () {
setState(() {
diaries.removeAt(i); // ลบตัวที่ตำแหน่ง i + วาดจอใหม่
});
},
),
กด F5 → ควรเห็น: กดถังขยะ → บันทึกหายจากรายการ
เช็คพอยต์ 4: เพิ่มและลบได้แล้ว — แต่ทั้งหมดยังอยู่ใน RAM ปิดแอปหาย → ส่วน C แก้
ส่วน B ขยาย: ฟอร์มที่มีมากกว่าช่องข้อความ
แนวคิดสำคัญก่อนเริ่ม: Widget เลือกค่า ≠ TextFormField
| TextFormField (ช่องพิมพ์) | Dropdown/Checkbox/Radio (เลือกค่า) | |
|---|---|---|
| เก็บค่าด้วย | TextEditingController |
ตัวแปร state ธรรมดา |
| อัปเดตค่า | อัตโนมัติเมื่อพิมพ์ | ต้อง setState เอง |
| ตรวจสอบ | ใช้ validator |
เช็คเองด้วย if (ไม่ผ่าน validator ของ Form) |
จุดที่นักศึกษางงบ่อย: Checkbox/Dropdown ไม่ได้ใช้ controller และ ไม่เข้า
_formKey.validate()เหมือน TextFormField — ต้องจัดการ state เองด้วย setState และเช็คค่าเองก่อนบันทึก
ขั้นที่ 4.1 — เพิ่ม field ใหม่ใน Model
เป้าหมาย: เตรียม Model รับข้อมูลใหม่ (อารมณ์ + วันที่ที่เลือกเอง)
แก้ Model Diary เพิ่ม field mood:
class Diary {
final String title;
final String content;
final String date;
final String mood; // << เพิ่ม: อารมณ์ (เก็บเป็นข้อความ เช่น 'มีความสุข')
Diary({required this.title, required this.content, required this.date, required this.mood});
}
dateเดิมมีอยู่แล้ว แต่สัปดาห์นี้จะให้ผู้ใช้ เลือกวันที่เอง แทนใช้วันปัจจุบันอัตโนมัติ
ขั้นที่ 4.2 — Dropdown: เลือกอารมณ์
เป้าหมาย: เพิ่มเมนูเลือกอารมณ์ในฟอร์ม
ขั้น 1 — ประกาศตัวแปร state เก็บค่าที่เลือก (ใน _AddDiaryPageState):
String _selectedMood = 'มีความสุข'; // ค่าเริ่มต้น
final List<String> _moods = ['มีความสุข', 'เฉยๆ', 'เศร้า', 'เหนื่อย', 'ตื่นเต้น'];
ขั้น 2 — เพิ่ม Dropdown ในฟอร์ม (ใต้ช่องเนื้อหา):
DropdownButtonFormField<String>(
value: _selectedMood,
decoration: const InputDecoration(
labelText: 'อารมณ์วันนี้',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.mood),
),
items: _moods.map((mood) {
return DropdownMenuItem(value: mood, child: Text(mood));
}).toList(),
onChanged: (value) {
setState(() { // เลือกค่าเลือกต้อง setState เอง
_selectedMood = value!;
});
},
),
อธิบาย:
- DropdownButtonFormField = Dropdown เวอร์ชันฟอร์ม (มีกรอบเข้าชุดกับ TextFormField)>
- value: _selectedMood = ค่าที่เลือกอยู่ตอนนี้
- items = แปลงรายการอารมณ์เป็นตัวเลือก (ใช้ .map เหมือน ListView)
- onChanged + setState = เมื่อเลือก อัปเดตค่า + วาดจอใหม่
ผลที่ควรได้: เห็นเมนูเลือกอารมณ์ แตะแล้วเลือกได้
ขั้นที่ 4.3 — Date Picker: เลือกวันที่เอง
เป้าหมาย: ให้ผู้ใช้เลือกวันที่ (แทนใช้วันปัจจุบันอัตโนมัติ)
ขั้น 1 — ประกาศตัวแปรเก็บวันที่:
DateTime _selectedDate = DateTime.now(); // เริ่มที่วันนี้
ขั้น 2 — เพิ่มปุ่มเลือกวันที่ในฟอร์ม:
ListTile(
leading: const Icon(Icons.calendar_today),
title: Text('วันที่: ${_selectedDate.toString().substring(0, 10)}'),
trailing: const Icon(Icons.edit),
onTap: () async {
final picked = await showDatePicker( // เปิดปฏิทินให้เลือก
context: context,
initialDate: _selectedDate,
firstDate: DateTime(2020), // เลือกได้ตั้งแต่ปี 2020
lastDate: DateTime(2030), // ถึงปี 2030
);
if (picked != null) { // ถ้าเลือก (ไม่กดยกเลิก)
setState(() {
_selectedDate = picked;
});
}
},
),
อธิบาย:
- showDatePicker = เปิดปฏิทินมาตรฐานให้เลือกวันที่ (Flutter มีให้ ไม่ต้องทำเอง)
- คืน DateTime? — เป็น null ถ้าผู้ใช้กดยกเลิก (ต้องเช็ค)
- firstDate / lastDate = ขอบเขตวันที่ที่เลือกได้
ผลที่ควรได้: แตะแถววันที่ → ปฏิทินเด้ง → เลือกวันแล้วแสดงวันที่ใหม่
ขั้นที่ 4.4 — บันทึกค่าจากทุก Widget
เป้าหมาย: ตอนกดบันทึก เก็บค่าจากทุกช่อง (รวม Dropdown + วันที่)
แก้ปุ่มบันทึกให้รวมค่าใหม่:
onPressed: () {
if (_formKey.currentState!.validate()) { // validate เช็คแค่ TextFormField
final newDiary = Diary(
title: _titleController.text.trim(),
content: _contentController.text.trim(),
date: _selectedDate.toString().substring(0, 10), // วันที่ที่เลือก
mood: _selectedMood, // อารมณ์ที่เลือก
);
Navigator.pop(context, newDiary);
}
},
จุดสำคัญ: สังเกตว่า validate() ตรวจแค่ TextFormField (ชื่อเรื่อง/เนื้อหา)
— Dropdown กับวันที่ไม่ต้อง validate เพราะมีค่าเริ่มต้นเสมอ (เลือกอยู่แล้ว ไม่มีทางว่าง) นี่คือความต่างของ "ช่องพิมพ์" (อาจว่าง ต้องเช็ค) กับ "ช่องเลือก" (มีค่าเสมอ)
แสดง mood ในรายการ/รายละเอียด: เพิ่มการแสดงอารมณ์ เช่นใน subtitle:
subtitle: Text('${diaries[i].mood} • ${diaries[i].date}'),
ผลที่ควรได้: เพิ่มบันทึกที่มีอารมณ์ + วันที่ที่เลือกเอง แสดงครบในรายการ
Widget ฟอร์มอื่นที่ควรรู้จัก (เลือกใช้ตามงาน)
นอกจาก Dropdown/DatePicker ยังมีอีก 3 ตัวที่ใช้บ่อย — ทุกตัวใช้แพตเทิร์นเดียวกัน: ตัวแปร state + setState
Checkbox — ติ๊กถูก (เช่น "บันทึกสำคัญ"):
bool _isImportant = false;
CheckboxListTile(
title: const Text('ทำเครื่องหมายว่าสำคัญ'),
value: _isImportant,
onChanged: (value) => setState(() => _isImportant = value!),
),
Switch — สวิตช์เปิด-ปิด (เช่น "ปักหมุด"):
bool _isPinned = false;
SwitchListTile(
title: const Text('ปักหมุดบันทึก'),
value: _isPinned,
onChanged: (value) => setState(() => _isPinned = value),
),
Radio — เลือก 1 จากไม่กี่ตัวเลือก (เช่น ประเภทบันทึก):
String _type = 'ส่วนตัว';
Column(children: [
RadioListTile<String>(
title: const Text('ส่วนตัว'),
value: 'ส่วนตัว', groupValue: _type,
onChanged: (v) => setState(() => _type = v!),
),
RadioListTile<String>(
title: const Text('งาน'),
value: 'งาน', groupValue: _type,
onChanged: (v) => setState(() => _type = v!),
),
]),
เลือกใช้ตัวไหนดี: เลือก 1 จากหลายตัว + ประหยัดพื้นที่ → Dropdown / เลือก 1 จาก 2-3 ตัวเห็นหมด → Radio / เปิด-ปิด อย่างเดียว → Switch/Checkbox / เลือกวันเวลา → DatePicker
ดูภาพหน้าตาจริงของทุกตัวได้ใน แคตตาล็อกภาพ Widget (ไฟล์ HTML)
ส่วน C: SQLite — เก็บถาวร
หมายเหตุ: โค้ด SQLite ด้านล่างใช้ Model เดิม (title/content/date) เพื่อความกระชับ — ถ้าทำส่วน B ขยายแล้ว อย่าลืมเพิ่ม
moodใน toMap/fromMap และ CREATE TABLE ด้วย (ดูวิธีในใบงาน Lab)
ขั้นที่ 5 — เพิ่ม id ใน Model + เตรียมแปลงข้อมูล
เป้าหมาย: เตรียม Model ให้เก็บลงฐานข้อมูลได้
แนวคิด: ฐานข้อมูลต้องการ "id" ประจำตัวแต่ละบันทึก + ต้องแปลงบันทึกเป็น Map (รูปแบบที่ SQLite เก็บได้)
แก้ Model Diary:
class Diary {
final int? id; // id? เพราะก่อนบันทึกยังไม่มี (DB ออกให้)
final String title;
final String content;
final String date;
Diary({this.id, required this.title, required this.content, required this.date});
// แปลง object → Map (สำหรับบันทึกลง DB)
Map<String, dynamic> toMap() {
return {'id': id, 'title': title, 'content': content, 'date': date};
}
// แปลง Map (จาก DB) → object
factory Diary.fromMap(Map<String, dynamic> map) {
return Diary(
id: map['id'],
title: map['title'],
content: map['content'],
date: map['date'],
);
}
}
อธิบาย:
- int? id = id เป็นตัวเลข แต่มี ? เพราะก่อนบันทึกยังไม่มี (ฐานข้อมูลจะออกเลขให้)
- toMap() = แปลงบันทึกเป็น Map เพื่อเก็บลง DB
- fromMap() = แปลง Map จาก DB กลับเป็นบันทึก
ขั้นที่ 6 — สร้างตัวจัดการฐานข้อมูล (DatabaseHelper)
เป้าหมาย: สร้างไฟล์จัดการ SQLite แยกต่างหาก
สร้างไฟล์ใหม่ lib/database_helper.dart:
import 'package:sqflite/sqflite.dart';
import 'package:path/path.dart';
import 'main.dart'; // เพื่อใช้ class Diary
class DatabaseHelper {
static Database? _db;
// เปิดฐานข้อมูล (เปิดครั้งเดียว ใช้ซ้ำ)
static Future<Database> get database async {
if (_db != null) return _db!;
_db = await openDatabase(
join(await getDatabasesPath(), 'mydiary.db'), // ที่อยู่ไฟล์ DB
version: 1,
onCreate: (db, version) {
// สร้างตาราง (รันครั้งแรกครั้งเดียว)
return db.execute(
'CREATE TABLE diaries('
'id INTEGER PRIMARY KEY AUTOINCREMENT, '
'title TEXT NOT NULL, '
'content TEXT NOT NULL, '
'date TEXT NOT NULL)',
);
},
);
return _db!;
}
// เพิ่มบันทึก
static Future<void> insert(Diary diary) async {
final db = await database;
await db.insert('diaries', diary.toMap());
}
// อ่านบันทึกทั้งหมด
static Future<List<Diary>> getAll() async {
final db = await database;
final maps = await db.query('diaries', orderBy: 'id DESC');
return maps.map((map) => Diary.fromMap(map)).toList();
}
// ลบบันทึก
static Future<void> delete(int id) async {
final db = await database;
await db.delete('diaries', where: 'id = ?', whereArgs: [id]);
}
}
อธิบาย:
- CREATE TABLE = สร้างตารางเก็บบันทึก (id, title, content, date)
- id INTEGER PRIMARY KEY AUTOINCREMENT = ฐานข้อมูลออกเลข id ให้เองอัตโนมัติ
- insert/getAll/delete = คำสั่งเพิ่ม/อ่าน/ลบ
- where: 'id = ?', whereArgs: [id] = ลบเฉพาะแถวที่ id ตรง (? กันข้อมูลอันตราย)
ขั้นที่ 7 — เชื่อมฐานข้อมูลเข้าหน้ารายการ
เป้าหมาย: เปลี่ยนจากเก็บใน List (RAM) เป็นเก็บใน SQLite (ถาวร)
แนวคิด — แพตเทิร์นทอง: ทุกครั้งที่เปลี่ยนข้อมูล = เขียน DB → โหลดใหม่
แก้ _DiaryListPageState:
class _DiaryListPageState extends State<DiaryListPage> {
List<Diary> diaries = []; // เริ่มจากว่าง (จะโหลดจาก DB)
@override
void initState() {
super.initState();
_loadDiaries(); // โหลดข้อมูลตอนเปิดหน้า
}
// โหลดจาก DB
Future<void> _loadDiaries() async {
final data = await DatabaseHelper.getAll();
setState(() {
diaries = data;
});
}
// เพิ่ม: เขียน DB แล้วโหลดใหม่
Future<void> _addDiary(Diary diary) async {
await DatabaseHelper.insert(diary);
_loadDiaries();
}
// ลบ: เขียน DB แล้วโหลดใหม่
Future<void> _deleteDiary(int id) async {
await DatabaseHelper.delete(id);
_loadDiaries();
}
@override
Widget build(BuildContext context) {
// ... (โค้ด build ปรับตามด้านล่าง)
}
}
แก้จุดเชื่อมใน build:
// ปุ่ม + : เปลี่ยนจาก diaries.add เป็น _addDiary
if (result != null && result is Diary) {
_addDiary(result); // แทน setState(() => diaries.add(result))
}
// ปุ่มลบ : เปลี่ยนจาก removeAt เป็น _deleteDiary
onPressed: () => _deleteDiary(diaries[i].id!),
อธิบาย:
- initState() = ทำงานครั้งเดียวตอนเปิดหน้า → โหลดข้อมูลจาก DB
- แพตเทิร์น "เขียน DB → โหลดใหม่" = เพิ่ม/ลบเสร็จโหลดใหม่ทุกครั้ง ข้อมูลตรงเสมอ
กด F5 → เพิ่มบันทึก 2-3 อัน → ปิดแอปจริง (หยุดใน VS Code) → รันใหม่
เช็คพอยต์ 5 (โมเมนต์สำคัญ!): ปิดแอปเปิดใหม่ บันทึกยังอยู่! นี่คือความต่างของ SQLite กับ RAM
สรุปสิ่งที่ได้สัปดาห์นี้
| ขั้น | ทำอะไร | แนวคิดหลัก |
|---|---|---|
| 1 | หน้ารายการเป็น Stateful | StatefulWidget + setState |
| 2 | หน้าฟอร์ม | Form + TextFormField + validator |
| ฟอร์มเลือกค่า | Dropdown, DatePicker, Checkbox, Switch, Radio | |
| 3 | เพิ่มบันทึก | Navigator.pop ส่งค่ากลับ + await |
| 4 | ลบบันทึก | removeAt + setState |
| 5 | เตรียม Model | toMap / fromMap + id |
| 6 | ตัวจัดการ DB | CREATE TABLE, insert/getAll/delete |
| 7 | เชื่อม SQLite | เขียน DB → โหลดใหม่ |
หัวใจที่ต้องจำ 4 ข้อ:
1. setState = เปลี่ยนข้อมูลแล้วสั่งวาดจอใหม่
2. validator คืน null = ผ่าน / คืน String = ผิด
3. SQLite = เขียน DB แล้วโหลดใหม่ ข้อมูลไม่หายตอนปิดแอป
4. Widget เลือกค่า (Dropdown/Checkbox/Radio/DatePicker) จัดการ state เองด้วย setState — ไม่ใช้ controller และไม่เข้า validator เหมือนช่องพิมพ์
ข้อจำกัด → โจทย์สัปดาห์หน้า
ตอนนี้แอปเก็บข้อมูลถาวรได้แล้ว แต่: - ข้อมูลอยู่แค่เครื่องเดียว — เปลี่ยนมือถือข้อมูลไม่ตามไป - บันทึกยังไม่มีอะไรเชื่อมโลกภายนอก
สัปดาห์หน้า เราจะให้แอป ดึงข้อมูลจากอินเทอร์เน็ต ด้วย Async + REST API