MyDiaryคอร์ส Flutter · 6 สัปดาห์ ทุกสัปดาห์
สัปดาห์ที่ 4 · เอกสารประกอบการสอน
สารบัญหน้านี้

สัปดาห์ที่ 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) แต่หลักการเดียวกับที่ทำวันนี้