สัปดาห์ที่ 4: จัดการข้อมูล CRUD ผ่าน REST API
เป้าหมายสัปดาห์นี้: ต่อยอดจากสัปดาห์ 3 (ดึงข้อมูล GET อ่านอย่างเดียว) → ส่งข้อมูลกลับไปที่ server ผ่าน API (POST/PUT/DELETE) = CRUD เต็มรูปแบบผ่านเน็ต
ต่อจากสัปดาห์ที่แล้ว: สัปดาห์ 3 เราดึงคำคม/รูปจากเน็ตมาดูได้ แต่ส่งอะไรกลับไม่ได้ — วันนี้ทำให้เพิ่ม/แก้/ลบ ข้อมูลบน server ได้จริง
ก่อนเริ่ม: GET อย่างเดียวไม่พอ
สัปดาห์ 3 เราทำแค่ ดึงข้อมูลมาดู (GET) — คำคม, รูปภาพ แต่ไม่ได้ส่งอะไรกลับ
REST API จริงมี 4 คำสั่งหลัก (ตรงกับ CRUD):
| HTTP Method | ทำอะไร | ตรงกับ CRUD | เทียบ SQLite (สัปดาห์ 2) |
|---|---|---|---|
GET |
อ่านข้อมูล | Read | db.query |
POST |
เพิ่มข้อมูล | Create | db.insert |
PUT |
แก้ไขข้อมูล | Update | db.update |
DELETE |
ลบข้อมูล | Delete | db.delete |
ส่วนที่ 1: API ที่บันทึกจริง
API ที่เราจะใช้: server ของห้องเรา
- endpoint:
https://thipwimon.comsciproject.net/mydiary/api.php/<รหัสนักศึกษา> - บันทึกจริง — POST แล้ว GET กลับมาเห็นข้อมูล
- รองรับ CRUD ครบ (GET/POST/PUT/DELETE)
⚠ สิ่งสำคัญ: แยกข้อมูลของแต่ละคนด้วย "รหัสท้าย URL"
ทุกคนใช้ โดเมนเดียวกัน แต่ต่อ รหัสนักศึกษาของตัวเองท้าย URL — server จะเก็บข้อมูลของแต่ละรหัสไว้ คนละไฟล์ (คนละกล่อง) ให้อัตโนมัติ จึง เห็นเฉพาะของตัวเอง ไม่ปนกับเพื่อน
เพื่อน 66000001 → .../api.php/66000001 (กล่องของเพื่อน)
เรา 66xxxxxx → .../api.php/66xxxxxx (กล่องของเรา — GET ได้เฉพาะของเรา)
ต่างจากสัปดาห์ที่แล้ว: ไม่ต้องเติมรหัสนำหน้าชื่อแล้ว และไม่ต้องกรองเอง — แค่ใส่รหัสใน URL ให้ถูก server แยกให้เลย
โครง JSON ที่เราจะส่ง
ส่ง field ตรงๆ (แบน) — ตรงกับที่จะใช้ในฟอร์ม:
{
"title": "ชื่อเรื่อง",
"content": "เนื้อหา",
"mood": "มีความสุข",
"date": "2026-09-27"
}
server จะเติม
id(และ_id) ให้อัตโนมัติตอน POST — เอาไว้ใช้ตอนแก้/ลบ
ส่วนที่ 2: ลงมือทำ CRUD ครบ 4
ต่อยอด ApiService จากสัปดาห์ 3 — เพิ่ม method สำหรับ POST/PUT/DELETE
ขั้นที่ 1 — POST เพิ่มบันทึกขึ้น server
เป้าหมาย: ส่งบันทึกใหม่ไปเก็บบน server
เพิ่มใน ApiService (api_service.dart):
import 'dart:convert';
import 'package:http/http.dart' as http;
class ApiService {
// เปลี่ยนแค่ studentId เป็นรหัสของคุณ (โดเมนตั้งให้แล้ว)
static const String studentId = '66xxxxxx'; // << ใส่รหัสของคุณ
static const String baseUrl =
'https://thipwimon.comsciproject.net/mydiary/api.php/$studentId'; // << กล่องข้อมูลของเราโดยเฉพาะ
// CREATE — เพิ่มบันทึก (POST)
static Future<void> createDiary(String title, String content, String mood, String date) async {
final url = Uri.parse(baseUrl);
final response = await http.post(
url,
headers: {'Content-Type': 'application/json'}, // บอกว่าส่ง JSON
body: jsonEncode({ // แปลง Map → JSON string
'title': title,
'content': content,
'mood': mood,
'date': date,
}),
);
if (response.statusCode != 200 && response.statusCode != 201) {
throw Exception('เพิ่มไม่สำเร็จ (${response.statusCode})');
}
}
}
อธิบายของใหม่:
- http.post(url, headers, body) = ส่งคำสั่งเพิ่มข้อมูล (ต่างจาก http.get ที่แค่ขอ)
- headers: {'Content-Type': 'application/json'} = บอก server ว่าเราส่ง JSON มา
- jsonEncode(...) = แปลง Map เป็นข้อความ JSON (ตรงข้ามกับ jsonDecode ที่ใช้ตอน GET สัปดาห์ 3)
- statusCode 200/201 = สำเร็จ (201 = created)
จุดเชื่อม: สัปดาห์ 3 ใช้
jsonDecode(JSON→Map ตอนอ่าน) — คราวนี้jsonEncode(Map→JSON ตอนส่ง) เป็นคู่ตรงข้ามกัน
ขั้นที่ 2 — GET: อ่านบันทึกทั้งหมด
เป้าหมาย: ดึงบันทึกจาก server (ได้เฉพาะของเราอยู่แล้ว เพราะ URL มีรหัสเรา)
// READ — อ่านบันทึกทั้งหมด (GET)
static Future<List<dynamic>> getDiaries() async {
final url = Uri.parse(baseUrl);
final response = await http.get(url);
if (response.statusCode == 200) {
final List<dynamic> all = jsonDecode(response.body);
return all; // ได้เฉพาะกล่องของเรา ไม่ต้องกรอง
}
throw Exception('โหลดไม่สำเร็จ');
}
อธิบาย:
- http.get = เหมือนสัปดาห์ 3
- ไม่ต้องกรองแล้ว — เพราะ baseUrl มีรหัสเราต่อท้าย server จึงคืนเฉพาะบันทึกในกล่องของเรา (ต่างจากวิธีกรองด้วย where ที่ต้องใช้ตอน server รวมทุกคนไว้กล่องเดียว)
ขั้นที่ 3 — PUT: แก้ไขบันทึก
เป้าหมาย: แก้บันทึกที่มีอยู่ (ต้องรู้ id ของ object)
// UPDATE — แก้ไขบันทึก (PUT)
static Future<void> updateDiary(String id, String title, String content, String mood, String date) async {
final url = Uri.parse('$baseUrl/$id'); // ระบุ id ที่จะแก้
final response = await http.put(
url,
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'title': title,
'content': content,
'mood': mood,
'date': date,
}),
);
if (response.statusCode != 200) {
throw Exception('แก้ไขไม่สำเร็จ');
}
}
อธิบาย:
- $baseUrl/$id = ระบุว่าจะแก้ object ไหน (ต่อ id ท้าย URL)
- http.put = คำสั่งแก้ไข (ส่งข้อมูลใหม่ทั้งชุดไปทับ)
เทียบ SQLite:
db.update(where: 'id = ?')= ระบุ id เหมือนกัน แค่ REST ใส่ id ใน URL
ขั้นที่ 4 — DELETE: ลบบันทึก
เป้าหมาย: ลบบันทึกออกจาก server
// DELETE — ลบบันทึก
static Future<void> deleteDiary(String id) async {
final url = Uri.parse('$baseUrl/$id');
final response = await http.delete(url);
if (response.statusCode != 200) {
throw Exception('ลบไม่สำเร็จ');
}
}
อธิบาย:
- http.delete($baseUrl/$id) = ลบ object ที่ id นั้น (สั้นสุดในบรรดา CRUD)
ส่วนที่ 3: เชื่อมเข้าหน้าจอ
ใช้ FutureBuilder แสดง + ปุ่มเพิ่ม/แก้/ลบ
แนวคิด: เหมือนสัปดาห์ 2 (CRUD SQLite) แค่เปลี่ยนจาก DatabaseHelper เป็น ApiService
// ตัว Widget ของแท็บ "ออนไลน์" (เพิ่มเข้า BottomNavigationBar เหมือนแท็บอื่น)
class ApiDiaryPage extends StatefulWidget {
const ApiDiaryPage({super.key});
@override
State<ApiDiaryPage> createState() => _ApiDiaryPageState();
}
class _ApiDiaryPageState extends State<ApiDiaryPage> {
late Future<List<dynamic>> _diariesFuture; // รหัสอยู่ใน ApiService.baseUrl แล้ว
@override
void initState() {
super.initState();
_loadDiaries();
}
void _loadDiaries() {
setState(() {
_diariesFuture = ApiService.getDiaries(); // โหลดใหม่ (ได้เฉพาะของเรา)
});
}
// เพิ่มแล้วโหลดใหม่ (แพตเทิร์นเดียวกับ SQLite สัปดาห์ 2!)
Future<void> _addDiary(String title, String content, String mood, String date) async {
await ApiService.createDiary(title, content, mood, date);
_loadDiaries();
}
Future<void> _deleteDiary(String id) async {
await ApiService.deleteDiary(id);
_loadDiaries();
}
// แก้ไขแล้วโหลดใหม่
Future<void> _updateDiary(String id, String title, String content, String mood, String date) async {
await ApiService.updateDiary(id, title, content, mood, date);
_loadDiaries();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('บันทึกบน Cloud (REST API)')),
body: FutureBuilder<List<dynamic>>(
future: _diariesFuture,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError) {
return Center(child: Text('ผิดพลาด: ${snapshot.error}'));
}
final diaries = snapshot.data!;
if (diaries.isEmpty) {
return const Center(child: Text('ยังไม่มีบันทึก กดปุ่ม +'));
}
return ListView.builder(
itemCount: diaries.length,
itemBuilder: (context, i) {
final obj = diaries[i];
return Card(
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
child: ListTile(
title: Text(obj['title'] ?? ''),
subtitle: Text('${obj['mood'] ?? ''} • ${obj['date'] ?? ''} • ${obj['content'] ?? ''}'),
trailing: Row(
mainAxisSize: MainAxisSize.min, // ให้ Row กว้างเท่าที่จำเป็น
children: [
// ── ปุ่มแก้ไข: เปิดฟอร์มโหมดแก้ (ส่ง obj เดิมไป) ──
IconButton(
icon: const Icon(Icons.edit, color: Colors.blue),
onPressed: () async {
final result = await Navigator.push(
context,
MaterialPageRoute(
builder: (_) => ApiFormPage(existing: obj), // โหมดแก้
),
);
if (result is Map) {
await _updateDiary(obj['id'], result['title'],
result['content'], result['mood'], result['date']);
}
},
),
// ── ปุ่มลบ ──
IconButton(
icon: const Icon(Icons.delete, color: Colors.red),
onPressed: () => _deleteDiary(obj['id']),
),
],
),
),
);
},
);
},
),
// ── ปุ่มเพิ่ม: เปิดฟอร์มโหมดเพิ่ม แล้วรับค่ากลับมา createDiary ──
floatingActionButton: FloatingActionButton(
onPressed: () async {
final result = await Navigator.push(
context,
MaterialPageRoute(builder: (_) => const ApiFormPage()), // โหมดเพิ่ม
);
if (result is Map) {
await _addDiary(result['title'], result['content'],
result['mood'], result['date']);
}
},
child: const Icon(Icons.add),
),
);
}
}
อธิบายจุดสำคัญ:
- แพตเทิร์น "เขียน API → โหลดใหม่" = เหมือน "เขียน DB → โหลดใหม่" สัปดาห์ 2 เป๊ะ
- อ่าน field ตรงๆ (obj['title'], obj['mood'], ...) เพราะเราส่งแบบแบน
- obj['id'] = id ที่ server ออกให้ (ใช้ตอนลบ/แก้)
ทดสอบว่าแยกกันจริง: เพิ่มบันทึกด้วยรหัสเรา → เปิดเบราว์เซอร์ไปที่
.../api.php/<รหัสเรา>เห็นเฉพาะของเรา → ลองเปลี่ยนเป็นรหัสเพื่อน เห็นคนละชุด → ปิดแอปเปิดใหม่ข้อมูลยังอยู่
สร้างฟอร์มเพิ่ม/แก้ไข (ApiFormPage)
เป้าหมาย: หน้าฟอร์มหน้าเดียว ใช้ได้ทั้ง เพิ่มใหม่ และ แก้ไข — ปุ่ม + กับปุ่มดินสอในขั้นที่ 5 เรียกหน้านี้
แนวคิด: เหมือนฟอร์ม AddDiaryPage สัปดาห์ 2 เป๊ะ ต่างแค่ตอนกด "บันทึก" ไม่ได้เขียน DB เอง แต่ ส่งค่ากลับเป็น Map ให้หน้ารายการเอาไปเรียก API (createDiary/updateDiary) ต่อ
class ApiFormPage extends StatefulWidget {
final Map? existing; // ส่ง obj เดิมมา = โหมดแก้ / null = เพิ่มใหม่
const ApiFormPage({super.key, this.existing});
@override
State<ApiFormPage> createState() => _ApiFormPageState();
}
class _ApiFormPageState extends State<ApiFormPage> {
final _formKey = GlobalKey<FormState>();
final _titleController = TextEditingController();
final _contentController = TextEditingController();
String _mood = 'มีความสุข';
final List<String> _moods = ['มีความสุข', 'เฉยๆ', 'เศร้า', 'เหนื่อย', 'ตื่นเต้น'];
DateTime _date = DateTime.now();
@override
void initState() {
super.initState();
if (widget.existing != null) { // โหมดแก้: เติมค่าเดิมลงฟอร์ม
_titleController.text = widget.existing!['title'] ?? '';
_contentController.text = widget.existing!['content'] ?? '';
_mood = widget.existing!['mood'] ?? 'มีความสุข';
_date = DateTime.tryParse(widget.existing!['date'] ?? '') ?? DateTime.now();
}
}
@override
void dispose() {
_titleController.dispose();
_contentController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.existing == null ? 'เพิ่มบันทึก' : 'แก้ไขบันทึก'),
),
body: Padding(
padding: const EdgeInsets.all(16),
child: Form(
key: _formKey,
child: SingleChildScrollView(
child: Column(
children: [
TextFormField(
controller: _titleController,
decoration: const InputDecoration(
labelText: 'ชื่อเรื่อง', border: OutlineInputBorder(),
),
validator: (v) => (v == null || v.trim().isEmpty) ? 'กรุณากรอกชื่อเรื่อง' : null,
),
const SizedBox(height: 16),
TextFormField(
controller: _contentController,
maxLines: 4,
decoration: const InputDecoration(
labelText: 'เนื้อหา', border: OutlineInputBorder(),
),
validator: (v) => (v == null || v.trim().isEmpty) ? 'กรุณากรอกเนื้อหา' : null,
),
const SizedBox(height: 16),
DropdownButtonFormField<String>(
value: _mood,
decoration: const InputDecoration(
labelText: 'อารมณ์', border: OutlineInputBorder(),
prefixIcon: Icon(Icons.mood),
),
items: _moods.map((m) => DropdownMenuItem(value: m, child: Text(m))).toList(),
onChanged: (v) => setState(() => _mood = v!),
),
const SizedBox(height: 16),
ListTile(
leading: const Icon(Icons.calendar_today),
title: Text('วันที่: ${_date.toString().substring(0, 10)}'),
trailing: const Icon(Icons.edit),
onTap: () async {
final picked = await showDatePicker(
context: context,
initialDate: _date,
firstDate: DateTime(2020),
lastDate: DateTime(2030),
);
if (picked != null) setState(() => _date = picked);
},
),
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: () {
if (_formKey.currentState!.validate()) {
// ส่งค่ากลับเป็น Map ให้หน้ารายการเอาไปเรียก API
Navigator.pop(context, {
'title': _titleController.text.trim(),
'content': _contentController.text.trim(),
'mood': _mood,
'date': _date.toString().substring(0, 10),
});
}
},
child: const Text('บันทึก'),
),
),
],
),
),
),
),
);
}
}
อธิบายจุดสำคัญ:
- หน้าเดียวใช้ 2 โหมด — ถ้ามี existing = แก้ (เติมค่าเดิม + หัวข้อ "แก้ไขบันทึก") / ถ้า null = เพิ่ม
- ฟอร์มไม่เรียก API เอง — กด "บันทึก" แล้ว Navigator.pop(context, {...}) ส่ง Map กลับ หน้ารายการ (ขั้นที่ 5) เป็นคนเรียก createDiary/updateDiary ต่อ → แยกหน้าที่ชัด ทดสอบง่าย
- เทียบสัปดาห์ 2: โครงฟอร์มเหมือน AddDiaryPage เป๊ะ ต่างแค่ปลายทางบันทึก (DB → server)
ครบวงจรแล้ว: ปุ่ม + (ขั้น 5) เปิดฟอร์มโหมดเพิ่ม → กรอก → pop Map →
createDiary· ปุ่มดินสอเปิดฟอร์มโหมดแก้ →updateDiary· ปุ่มถังขยะ →deleteDiary
สรุป: CRUD ผ่าน REST API
| คำสั่ง | HTTP | โค้ด |
|---|---|---|
| เพิ่ม | POST | http.post(url, body: jsonEncode(...)) |
| อ่าน | GET | http.get(url) |
| แก้ | PUT | http.put('$url/$id', body: ...) |
| ลบ | DELETE | http.delete('$url/$id') |
หัวใจที่ต้องจำ 3 ข้อ: 1. 4 HTTP methods = CRUD (POST/GET/PUT/DELETE = Create/Read/Update/Delete) 2. jsonEncode (ส่ง) คู่กับ jsonDecode (รับ) — แปลง Map↔JSON 3. หลักการ CRUD เหมือน SQLite/Firebase — ต่างแค่ปลายทาง (เครื่อง/server/cloud)
🔗 เปรียบเทียบ CRUD 3 แบบ (ภาพรวมทั้งหลักสูตร)
| SQLite (สัปดาห์ 2) | REST API (สัปดาห์นี้) | Firebase (สัปดาห์ 5) | |
|---|---|---|---|
| เก็บที่ | ไฟล์ในเครื่อง | server กลาง | cloud Google |
| Create | db.insert |
http.post |
.add() |
| Read | db.query |
http.get |
.snapshots() |
| Update | db.update |
http.put |
.doc().update() |
| Delete | db.delete |
http.delete |
.doc().delete() |
| แยกข้อมูลแต่ละคน | ❌ | รหัสท้าย URL (กล่องแยก) | userId + ล็อกอิน |
| เรียลไทม์ | ❌ | ❌ (ต้องโหลดเอง) | ✅ (Stream) |
ข้อคิด: พอเห็นตารางนี้ นักศึกษาจะเข้าใจว่า CRUD คือแนวคิดเดียว ที่ใช้ได้ทุกที่ — เปลี่ยนแค่เครื่องมือ นี่คือเหตุผลที่ Firebase (สัปดาห์ 5) ไม่ใช่ของใหม่ทั้งหมด
ข้อจำกัด → โจทย์สัปดาห์หน้า
ตอนนี้เราเพิ่ม/แก้/ลบ ข้อมูลบน server ได้แล้ว แต่: - ใครก็เข้าถึงข้อมูลได้ — ใช้แค่รหัสนักศึกษากรอง ไม่มีระบบล็อกอินจริง ใครรู้รหัสก็เห็นข้อมูลเรา - ไม่มีการยืนยันตัวตน — server ไม่รู้ว่าใครเป็นใคร
สัปดาห์หน้า เราจะเพิ่ม ระบบล็อกอิน (Authentication) + ย้ายขึ้น Firebase cloud ให้แต่ละคนมีบัญชีจริง เห็นเฉพาะข้อมูลของตัวเอง และข้อมูลอัปเดตเรียลไทม์
จุดเชื่อมสำคัญ: CRUD ที่ทำวันนี้ (POST/GET/PUT/DELETE) + การแยกข้อมูลด้วยรหัสใน URL = รากฐานตรงของ Firebase สัปดาห์หน้า แค่เปลี่ยนจาก "รหัสที่เราพิมพ์เองใน URL" เป็น "userId จากระบบล็อกอิน" (Firebase รู้เองว่าใครล็อกอินอยู่ จึงแยกข้อมูลให้อัตโนมัติ + ปลอดภัยกว่า) และได้เรียลไทม์เพิ่ม
- ข้อควรระวัง (server เป็น http): ถ้า server ของอาจารย์เป็น
http://(ไม่ใช่https://) Android จะบล็อก ต้องเพิ่มandroid:usesCleartextTraffic="true"ใน<application>ของAndroidManifest.xml— ถ้าเป็น https ไม่ต้อง - ใส่รหัสให้ถูก: ถ้าพิมพ์รหัสใน
baseUrlผิด จะไปเขียน/อ่านกล่องของคนอื่น (หรือกล่องใหม่ที่ว่าง) — ตรวจให้ตรงรหัสตัวเองเสมอ - เชื่อมสัปดาห์ 5: Firebase คือ CRUD บน server ที่ดีกว่า (มีล็อกอิน + เรียลไทม์ + security) แต่หลักการเดียวกับที่ทำวันนี้