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

สัปดาห์ที่ 5: ล็อกอินและขึ้น Cloud (Firebase Auth + Firestore)

ก่อนเริ่ม: สัปดาห์นี้เราจะทำอะไร

เราจะอัปเกรด MyDiary ให้
- มี หน้าล็อกอิน (สมัคร/เข้าสู่ระบบ/ออกจากระบบ)
- ทำ หน้าบันทึกบน Firestore (cloud) แยกใหม่ — ไม่แตะหน้า SQLite+คำคมเดิม
- แต่ละคนเห็นเฉพาะบันทึกของตัวเอง (ผูกกับบัญชีที่ล็อกอิน)
- ข้อมูลอยู่บน cloud → ล็อกอินเครื่องไหนก็เห็นบันทึกเดิม
- Security Rules ป้องกันคนอื่นเข้าถึงข้อมูลเรา


เส้นทางสัปดาห์นี้แบ่ง 3 ส่วน:

ส่วน A: ระบบล็อกอิน       → สมัคร/เข้า/ออก + ประตูอัตโนมัติ
ส่วน B: ข้อมูลขึ้น Cloud   → Firestore CRUD ผูกกับผู้ใช้ (StreamBuilder)
ส่วน C: ความปลอดภัย       → Security Rules

ส่วนที่ 1: ทำความเข้าใจ "อะไรเป็นอะไร"

1.1 ปัญหาที่ค้างจากสัปดาห์ที่แล้ว

บันทึกเราเก็บใน SQLite (ในเครื่อง) — มี 2 ข้อจำกัด
1. เปลี่ยนมือถือ ข้อมูลไม่ตามไป (อยู่แค่เครื่องเดียว)
2. ไม่มีระบบล็อกอิน — ใครเปิดแอปก็เห็นบันทึกทั้งหมด ทางแก้: เก็บบน Cloud + มี ระบบล็อกอิน → นี่คือสิ่งที่ Firebase ให้เรา

1.2 Firebase คืออะไร

Firebase = บริการของ Google ที่ทำ "ฝั่งเซิร์ฟเวอร์" ให้เราเสร็จ — ปกติต้องเขียนเซิร์ฟเวอร์เอง แต่ Firebase ทำให้แล้ว เราแค่เรียกใช้

2 บริการที่ใช้สัปดาห์นี้:

บริการ หน้าที่
Authentication ระบบล็อกอิน (จำอีเมล/รหัส)
Cloud Firestore ฐานข้อมูลบน cloud (เหมือน SQLite แต่อยู่บนเน็ต)

1.3 Authentication (ยืนยันตัวตน) ของใหม่หลักของสัปดาห์

2 คำที่ต้องแยก: - Authentication = "คุณคือใคร" (ล็อกอินด้วยอีเมล/รหัส)
- Authorization = "คุณเห็นข้อมูลอะไรได้บ้าง" (เห็นแค่ของตัวเอง)

หัวใจ: เมื่อล็อกอินสำเร็จ เราได้ uid = รหัสประจำตัวไม่ซ้ำของแต่ละคน → ใช้ผูกกับข้อมูลว่า "บันทึกนี้เป็นของใคร"

1.4 Firestore — เทียบกับ SQLite ที่รู้แล้ว

SQLite (สัปดาห์ 2) Firestore (สัปดาห์นี้)
เก็บที่ไหน ไฟล์ในเครื่อง cloud ของ Google
โครงสร้าง ตาราง (Table) → แถว (Row) Collection → Document
แต่ละรายการ Row (Map) Document (ก็คือ Map!)
id int AUTOINCREMENT String สุ่ม
แชร์ข้ามเครื่อง ❌ ✅
FutureBuilder (สัปดาห์ 3) StreamBuilder (สัปดาห์นี้)
ข้อมูล ครั้งเดียวจบ ไหลต่อเนื่อง
อัปเดตจอ ต้องกดรีเฟรช อัปเดตเองเมื่อ cloud เปลี่ยน
โครง builder เช็ค 3 สถานะ เช็ค 3 สถานะเหมือนกัน!
จุดมหัศจรรย์: พอมีคนแก้ข้อมูลบน cloud จอเราอัปเดตเองทันที โดยไม่ต้องกดอะไร

ส่วนที่ 2: เตรียม Firebase

2.1 ติดตั้งเครื่องมือ (ครั้งเดียวต่อเครื่อง)

npm install -g firebase-tools
firebase login
dart pub global activate flutterfire_cli

2.2 สร้าง/เชื่อมโปรเจกต์ Firebase

  1. ไปที่ console.firebase.google.com → สร้างโปรเจกต์ pdf File
  2. ในโฟลเดอร์โปรเจกต์ Flutter รัน:
flutterfire configure

เลือกโปรเจกต์ → เลือก android → ได้ไฟล์ lib/firebase_options.dart อัตโนมัติ

2.3 เปิดบริการใน Console (ทำในเว็บ)

  • Authentication → Get started → เลือก Email/Password → Enable → Save
  • Firestore Database → Create database → Start in test mode → เลือก location (asia-southeast3 (Bangkok)) → Enable

ระวัง 2 จุด: - อย่าสับสน Firestore กับ Realtime Database(คนละตัว! เราใช้ Firestore) - test mode หมดอายุ ~30 วัน

2.4 ติดตั้ง package

flutter pub add firebase_core cloud_firestore firebase_auth

2.5 เริ่ม Firebase ใน main.dart

import 'package:firebase_core/firebase_core.dart';
import 'firebase_options.dart';

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
  runApp(const MyApp());
}

ส่วนที่ 3: ลงมือทำทีละขั้น

ภาพรวมก่อนเริ่ม: เราจะสร้าง/แก้ไฟล์อะไรบ้าง

สัปดาห์นี้เราทำ 3 ไฟล์ใหม่ + แก้ main.dart นิดเดียว — ของเดิม (DiaryListPage ที่มีคำคม+SQLite) ไม่แตะเลย

lib/
├── main.dart              ← แก้นิดเดียว: ให้ home ชี้ไป AuthGate 
├── firebase_options.dart  ← มีแล้ว (จาก flutterfire configure)
│
├── login_page.dart        ← สร้างใหม่: หน้าล็อกอิน/สมัคร 
├── firestore_service.dart ← สร้างใหม่: คำสั่ง CRUD บน cloud 
└── firestore_diary.dart   ← สร้างใหม่: หน้าบันทึก cloud + ฟอร์ม 

ส่วน A: ระบบล็อกอิน

ขั้นที่ 1 สร้างประตูอัตโนมัติ (AuthGate)

ไฟล์: lib/login_page.dart (สร้างใหม่ วาง AuthGate ไว้บนสุดของไฟล์นี้)

AuthGate คืออะไร: "ยาม" หน้าประตูแอป เช็คอัตโนมัติว่าตอนนี้ล็อกอินอยู่ไหม แล้วพาไปหน้าที่ถูก - ล็อกอินอยู่ → ไปหน้า Application - ยังไม่ล็อกอิน → ไปหน้า login

สร้างไฟล์ lib/login_page.dart แล้ววาง import + AuthGate นี้ก่อน (เติม LoginPage ต่อในไฟล์เดียวกัน)

import 'package:flutter/material.dart';
import 'package:firebase_auth/firebase_auth.dart';
import 'main.dart';    

// ── "ยาม" หน้าประตู: เช็คสถานะล็อกอินแล้วพาไปหน้าที่ถูก ──
class AuthGate extends StatelessWidget {
  const AuthGate({super.key});
  @override
  Widget build(BuildContext context) {
    return StreamBuilder<User?>(
      stream: FirebaseAuth.instance.authStateChanges(), // ฟังสถานะล็อกอินแบบเรียลไทม์
      builder: (context, snapshot) {
        if (snapshot.connectionState == ConnectionState.waiting) {
          return const Scaffold(body: Center(child: CircularProgressIndicator()));
        }
        if (snapshot.hasData) {
          return const MainPage();   // ล็อกอินอยู่ → หน้าหลักที่มีแท็บ 
        }
        return const LoginPage();    // ถ้ายังไม่ล็อกอิน → หน้า login 
      },
    );
  }
}

อธิบาย:
- authStateChanges() = Stream ที่ส่งสัญญาณทุกครั้งที่สถานะล็อกอินเปลี่ยน (มี User = ล็อกอินอยู่ / null = ยังไม่)
- นี่คือ StreamBuilder สังเกตว่าโครงเหมือน FutureBuilder (สัปดาห์ 3) เป๊ะ: เช็ค waiting → hasData → แสดงผล
- snapshot.hasData = มี User object = ล็อกอินอยู่ → ไป MainPage (หน้าที่มีแท็บ บันทึก/ออนไลน์/Cloud/เกี่ยวกับ)
- พอ login/logout สำเร็จ Stream ส่งค่าใหม่ → StreamBuilder สร้างใหม่ → หน้าจอสลับเอง ไม่ต้องเขียน Navigator

ขั้นที่ 2 — สร้างหน้าล็อกอิน/สมัครสมาชิก

ไฟล์: lib/login_page.dart (ไฟล์เดิมจากขั้น 1 — วาง LoginPage ต่อท้าย AuthGate)

เป้าหมาย: หน้าเดียวที่สลับโหมดสมัคร↔เข้าสู่ระบบได้ (เป็นหน้าที่ AuthGate พาไปเมื่อยังไม่ล็อกอิน)

ทำไมอยู่ไฟล์เดียวกับ AuthGate: AuthGate กับ LoginPage ทำงานคู่กัน วางไฟล์เดียวกันเพื่อให้เห็นภาพว่าเป็นชุดเดียวกัน วาง class นี้ต่อท้าย AuthGate ในไฟล์ login_page.dart:

class LoginPage extends StatefulWidget {
  const LoginPage({super.key});
  @override
  State<LoginPage> createState() => _LoginPageState();
}

class _LoginPageState extends State<LoginPage> {
  final _emailController = TextEditingController();
  final _passwordController = TextEditingController();
  final _auth = FirebaseAuth.instance;
  bool _isLogin = true;    // true = เข้าสู่ระบบ / false = สมัคร
  String _error = '';

  @override
  void dispose() {
    _emailController.dispose();
    _passwordController.dispose();
    super.dispose();
  }

  Future<void> _submit() async {
    setState(() => _error = '');
    try {
      if (_isLogin) {
        await _auth.signInWithEmailAndPassword(
          email: _emailController.text.trim(),
          password: _passwordController.text.trim(),
        );
      } else {
        await _auth.createUserWithEmailAndPassword(
          email: _emailController.text.trim(),
          password: _passwordController.text.trim(),
        );
      }
      // สำเร็จ AuthGate พาไปหน้าบันทึกเอง 
    } on FirebaseAuthException catch (e) {
      setState(() {
        if (e.code == 'weak-password') {
          _error = 'รหัสผ่านสั้นเกินไป (ต้อง ≥ 6 ตัว)';
        } else if (e.code == 'email-already-in-use') {
          _error = 'อีเมลนี้มีคนใช้แล้ว';
        } else if (e.code == 'invalid-email') {
          _error = 'รูปแบบอีเมลไม่ถูกต้อง';
        } else {
          _error = 'อีเมลหรือรหัสผ่านไม่ถูกต้อง';
        }
      });
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text(_isLogin ? 'เข้าสู่ระบบ' : 'สมัครสมาชิก')),
      body: Padding(
        padding: const EdgeInsets.all(24),
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            const Icon(Icons.book, size: 80, color: Colors.indigo),
            const SizedBox(height: 24),
            TextField(
              controller: _emailController,
              keyboardType: TextInputType.emailAddress,
              decoration: const InputDecoration(
                  labelText: 'อีเมล', border: OutlineInputBorder(),
                  prefixIcon: Icon(Icons.email)),
            ),
            const SizedBox(height: 12),
            TextField(
              controller: _passwordController,
              obscureText: true,       // ซ่อนรหัสผ่าน
              decoration: const InputDecoration(
                  labelText: 'รหัสผ่าน', border: OutlineInputBorder(),
                  prefixIcon: Icon(Icons.lock)),
            ),
            if (_error.isNotEmpty)
              Padding(
                padding: const EdgeInsets.only(top: 12),
                child: Text(_error, style: const TextStyle(color: Colors.red)),
              ),
            const SizedBox(height: 20),
            SizedBox(
              width: double.infinity,
              child: ElevatedButton(
                onPressed: _submit,
                child: Text(_isLogin ? 'เข้าสู่ระบบ' : 'สมัครสมาชิก'),
              ),
            ),
            TextButton(
              onPressed: () => setState(() => _isLogin = !_isLogin),
              child: Text(_isLogin ? 'ยังไม่มีบัญชี? สมัครเลย' : 'มีบัญชีแล้ว? เข้าสู่ระบบ'),
            ),
          ],
        ),
      ),
    );
  }
}

อธิบาย:
- createUserWithEmailAndPassword = สมัคร / signInWithEmailAndPassword = เข้าสู่ระบบ
- FirebaseAuthException มี e.code บอกสาเหตุ → แปลงเป็นข้อความไทย
- ความปลอดภัย: login ผิด บอกรวมว่า "อีเมลหรือรหัสไม่ถูกต้อง" ไม่แยกว่าอันไหนผิด


ขั้นที่ 3 เชื่อม AuthGate เข้าแอป (แก้ main.dart)

ไฟล์: lib/main.dart (เปลี่ยน home + เพิ่ม import)

ทำไมต้องแก้ตรงนี้ ตอนนี้ main.dart ยังตั้ง home: const MainPage() (หน้าเดิม) อยู่ ต้องเปลี่ยนให้ชี้ไป AuthGate เพื่อให้ "ยาม" ทำงานตั้งแต่เปิดแอป

โค้ดเดิมใน main.dart:

return MaterialApp(
  title: 'MyDiary',
  theme: ThemeData(colorSchemeSeed: Colors.indigo),
  home: const MainPage(),        // ของเดิม
);

แก้เป็น:

return MaterialApp(
  title: 'MyDiary',
  theme: ThemeData(colorSchemeSeed: Colors.indigo),
  home: const AuthGate(),        // เปลี่ยนเป็น AuthGate (ยาม)
);

และเพิ่ม import บนสุดของ main.dart:

import 'login_page.dart';        // ไฟล์ที่มี AuthGate

พอ home เป็น AuthGate → เปิดแอปมา ยามเช็คทันทีว่าล็อกอินไหม → ยังไม่ล็อกอินก็โชว์ LoginPage / ล็อกอินแล้วก็ไป MainPage

แอปเปิดมาหน้า login→ สมัคร/เข้าได้ → เข้า MainPage


ส่วน B: ข้อมูลขึ้น Cloud

ขั้นที่ 4 สร้างคำสั่ง CRUD บน Cloud (FirestoreService)

ไฟล์: lib/firestore_service.dart

เป้าหมาย: สร้าง "ชุดคำสั่ง" เพิ่ม/อ่าน/แก้/ลบ บน Firestore ผูกกับ uid เตรียมไว้ก่อนเอาไปใช้แสดงผล แนวคิด: ทุกบันทึกมี field userId = uid ของเจ้าของ → ตอนอ่านกรองด้วย userId ให้เห็นแค่ของตัวเอง

import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:firebase_auth/firebase_auth.dart';

class FirestoreService {
  static final _diaries = FirebaseFirestore.instance.collection('diaries');
  static String get _uid => FirebaseAuth.instance.currentUser!.uid;

  // CREATE (เทียบ SQLite: db.insert)
  static Future<void> add(String title, String content, String date) async {
    await _diaries.add({
      'title': title,
      'content': content,
      'date': date,
      'userId': _uid,        // ผูกกับเจ้าของ
      'createdAt': FieldValue.serverTimestamp(),
    });
  }

  // READ (เทียบ SQLite: db.query) — เฉพาะของฉัน, เรียลไทม์
  static Stream<QuerySnapshot> getMyDiaries() {
    return _diaries
        .where('userId', isEqualTo: _uid)        // กรองเฉพาะของฉัน
        .snapshots();                            // Stream = เรียลไทม์
  }

  // UPDATE (เทียบ SQLite: db.update)
  static Future<void> update(String docId, String title, String content) async {
    await _diaries.doc(docId).update({'title': title, 'content': content});
  }

  // DELETE (เทียบ SQLite: db.delete)
  static Future<void> delete(String docId) async {
    await _diaries.doc(docId).delete();
  }
}

อธิบาย: สังเกตว่า CRUD เหมือนที่เคยทำมา 2 แบบ

ท่า SQLite (สัปดาห์ 2) REST API (สัปดาห์ 4) Firestore
Create db.insert(...) http.post(...) .add({...})
Read db.query(...) http.get(...) .where().snapshots()
Update db.update(where id) http.put('/$id') .doc(id).update()
Delete db.delete(where id) http.delete('/$id') .doc(id).delete()

ขั้นที่ 5 สร้างหน้า Cloud เป็น "หน้าใหม่แยก"

ไฟล์: lib/firestore_diary.dart

แนวคิดสำคัญ (เหมือนสัปดาห์ 4): เราไม่แก้ DiaryListPage เดิม (ที่มีคำคม + SQLite) — แต่สร้าง หน้าใหม่ FirestoreDiaryPage แยกต่างหาก สำหรับบันทึกบน cloud ล้วนๆ ของเดิมไม่พัง และโค้ด Firestore สะอาด ไม่ปนกัน เป้าหมาย: สร้างหน้าใหม่ที่แสดงบันทึกจาก Firestore ด้วย StreamBuilder (เรียลไทม์)

สร้างไฟล์ใหม่ lib/firestore_diary.dart วางโค้ดนี้

import 'package:flutter/material.dart';
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:firebase_auth/firebase_auth.dart';
import 'firestore_service.dart';

class FirestoreDiaryPage extends StatelessWidget {
  const FirestoreDiaryPage({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('บันทึก Cloud (Firestore)'),
        actions: [
          IconButton(
            icon: const Icon(Icons.logout),
            onPressed: () => FirebaseAuth.instance.signOut(),   // ออกจากระบบ
          ),
        ],
      ),
      body: StreamBuilder<QuerySnapshot>(
        stream: FirestoreService.getMyDiaries(),
        builder: (context, snapshot) {
          if (snapshot.connectionState == ConnectionState.waiting) {
            return const Center(child: CircularProgressIndicator());
          }
          if (snapshot.hasError) {
            return Center(child: Text('ผิดพลาด: ${snapshot.error}'));
          }
          final docs = snapshot.data!.docs;
          if (docs.isEmpty) {
            return const Center(child: Text('ยังไม่มีบันทึก กดปุ่ม +'));
          }
          return ListView.builder(
            itemCount: docs.length,
            itemBuilder: (context, i) {
              final data = docs[i].data() as Map<String, dynamic>;
              final docId = docs[i].id;      // id ของ document (String)
              return Card(
                margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
                child: ListTile(
                  title: Text(data['title'] ?? ''),
                  subtitle: Text('${data['date'] ?? ''} • ${data['content'] ?? ''}',
                      maxLines: 1, overflow: TextOverflow.ellipsis),
                  trailing: IconButton(
                    icon: const Icon(Icons.delete, color: Colors.red),
                    onPressed: () => FirestoreService.delete(docId),
                  ),
                ),
              );
            },
          );
        },
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () async {
          final result = await Navigator.push(context,
              MaterialPageRoute(builder: (_) => const FirestoreFormPage()));
          if (result is Map) {
            await FirestoreService.add(
                result['title'], result['content'], result['date']);
            // ไม่ต้อง reload! StreamBuilder อัปเดตเอง
          }
        },
        child: const Icon(Icons.add),
      ),
    );
  }
}

แล้วเพิ่มเป็นแท็บใหม่ใน MainPage (เหมือนที่เพิ่มแท็บ REST สัปดาห์ 4) — แก้ 2 จุดใน _MainPageState (ไฟล์ main.dart):

จุดที่ 1 — เพิ่ม FirestoreDiaryPage เข้า list หน้า

final List<Widget> _pages = [
  const DiaryListPage(),      // แท็บ SQLite + คำคม (ของเดิม)
  const ApiDiaryPage(),       // แท็บ REST API (สัปดาห์ 4)
  const FirestoreDiaryPage(), // แท็บ Cloud (สัปดาห์นี้)
  const AboutPage(),
];

จุดที่ 2 — เพิ่มปุ่มแท็บใน items + ใส่ type: fixed (เพราะมี 4 แท็บแล้ว)

bottomNavigationBar: BottomNavigationBar(
  currentIndex: _selectedIndex,
  type: BottomNavigationBarType.fixed,   // ต้องมีเมื่อ 4 แท็บขึ้นไป (ไม่งั้นแท็บซ่อน)
  onTap: (index) => setState(() => _selectedIndex = index),
  items: const [
    BottomNavigationBarItem(icon: Icon(Icons.book), label: 'บันทึก'),
    BottomNavigationBarItem(icon: Icon(Icons.cloud_queue), label: 'ออนไลน์'),
    BottomNavigationBarItem(icon: Icon(Icons.cloud), label: 'Cloud'),   // เพิ่ม
    BottomNavigationBarItem(icon: Icon(Icons.info), label: 'เกี่ยวกับ'),
  ],
),

อย่าลืม import 'firestore_diary.dart'; ในไฟล์ main.dart

อธิบาย — เทียบ FutureBuilder สัปดาห์ 3: - โครง 3 สถานะ เหมือน FutureBuilder เป๊ะ (waiting/hasError/success) - ต่างแค่ stream: แทน future: และข้อมูลมาเป็น QuerySnapshot - docs[i].data() = ข้อมูล 1 บันทึก (เป็น Map!) / docs[i].id = รหัส document

เห็นภาพครบ: แท็บ "บันทึก" (ในเครื่อง) / "ออนไลน์" (server REST) / "Cloud" (Firebase + ล็อกอิน) — CRUD เหมือนกัน ต่างแค่ปลายทาง + Cloud มีล็อกอิน+เรียลไทม์

✅ เช็คพอยต์ 5: ล็อกอินแล้วเห็น 4 แท็บ → แท็บ Cloud แสดงบันทึกจาก Firestore + ปุ่ม logout บนแท็บนี้ทำงาน


ขั้นที่ 6 — สร้างฟอร์มเพิ่มบันทึก

ไฟล์: lib/firestore_diary.dart

เป้าหมาย: ปุ่ม + เปิดฟอร์ม → บันทึกลง Firestore

สร้าง class FirestoreFormPage

class FirestoreFormPage extends StatefulWidget {
  const FirestoreFormPage({super.key});
  @override
  State<FirestoreFormPage> createState() => _FirestoreFormPageState();
}

class _FirestoreFormPageState extends State<FirestoreFormPage> {
  final _formKey = GlobalKey<FormState>();
  final _titleController = TextEditingController();
  final _contentController = TextEditingController();
  DateTime _selectedDate = DateTime.now();

  @override
  void dispose() {
    _titleController.dispose();
    _contentController.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('เพิ่มบันทึก (Cloud)')),
      body: Padding(
        padding: const EdgeInsets.all(16),
        child: Form(
          key: _formKey,
          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),
              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),
                    lastDate: DateTime(2030),
                  );
                  if (picked != null) setState(() => _selectedDate = picked);
                },
              ),
              const SizedBox(height: 24),
              SizedBox(
                width: double.infinity,
                child: ElevatedButton(
                  onPressed: () {
                    if (_formKey.currentState!.validate()) {
                      Navigator.pop(context, {
                        'title': _titleController.text.trim(),
                        'content': _contentController.text.trim(),
                        'date': _selectedDate.toString().substring(0, 10),
                      });
                    }
                  },
                  child: const Text('เพิ่ม'),
                ),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

อธิบาย:
- ฟอร์มนี้เป็นของ cloud โดยเฉพาะ — ไม่ปนกับ AddDiaryPage เดิม (ที่มี mood + คำคม)
- ฟอร์มไม่เรียก Firestore เอง — แค่ Navigator.pop ส่งค่ากลับเป็น Map ให้หน้าหลักเรียก add (แพตเทิร์นเดียวกับสัปดาห์ 2/4)
- จุดต่างจาก SQLite: สัปดาห์ 2 ต้องเรียก _loadDiaries() เองหลังเพิ่ม — แต่ Firestore ใช้ Stream ไม่ต้อง reload จออัปเดตเอง

กด F5 → เพิ่มบันทึก → โผล่ในรายการทันทีโดยไม่ต้องรีเฟรช (พลังของ Stream)

✅ เช็คพอยต์ 6: เพิ่มบันทึกได้ + จออัปเดตเอง + ดูใน Firebase console เห็นข้อมูลโผล่พร้อมกัน


ขั้นที่ 7 — พิสูจน์ความเป็นส่วนตัว

ทดสอบในแอป

เป้าหมาย: พิสูจน์ว่าแต่ละคนเห็นแค่บันทึกของตัวเอง

วิธีทดสอบ: 1. ล็อกอินบัญชี A → เพิ่มบันทึก "บันทึกของ A" 2. Logout → สมัคร/ล็อกอินบัญชี B → เพิ่มบันทึก "บันทึกของ B" 3. สังเกต: บัญชี B เห็นแค่บันทึกของ B (ของ A ไม่โผล่) 4. Logout → กลับไปบัญชี A → บันทึกของ A ยังอยู่

นี่คือหัวใจของสัปดาห์นี้: .where('userId', isEqualTo: uid) ทำให้แต่ละคนเห็นแค่ของตัวเอง — จับต้องได้จริง


ส่วน C: ความปลอดภัย

ขั้นที่ 8 Security Rules

ทำใน Firebase Console (ไม่ใช่ในโค้ด Flutter)

เป้าหมาย: ป้องกันคนอื่นเข้าถึงบันทึกเรา (แม้ยิงคำสั่งข้ามแอป)

แนวคิดสำคัญ: การกรอง .where('userId') ในแอป ยังไม่ปลอดภัยพอ คนที่รู้วิธีสามารถยิงคำสั่งตรงเข้า Firestore ข้ามแอปเราได้ ต้องตั้ง Security Rules ที่ฝั่งเซิร์ฟเวอร์เป็นด่านจริง ใน Firebase console → Firestore → Rules → วางแล้ว Publish:

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /diaries/{diaryId} {
      // อ่าน/แก้/ลบได้เฉพาะเจ้าของ
      allow read, update, delete: if request.auth != null
                                  && request.auth.uid == resource.data.userId;
      // สร้างใหม่: ต้องล็อกอิน + userId ต้องเป็นของตัวเอง
      allow create: if request.auth != null
                    && request.auth.uid == request.resource.data.userId;
    }
  }
}

Rules: - request.auth != null = ต้องล็อกอินก่อน - request.auth.uid == resource.data.userId = uid คนขอ ต้องตรงกับเจ้าของข้อมูล จุดสอนที่ต้องย้ำ: ความปลอดภัยจริงอยู่ที่ Rules ฝั่งเซิร์ฟเวอร์ ไม่ใช่โค้ดในแอป (เหมือนล็อกประตูจริง ไม่ใช่แค่ติดป้าย "ห้ามเข้า")


ประเด็นที่ต้องจำ 4 ข้อ

  1. uid = รหัสประจำตัวไม่ซ้ำ ใช้ผูกข้อมูลว่าเป็นของใคร
  2. StreamBuilder = FutureBuilder เวอร์ชันเรียลไทม์ (จออัปเดตเองเมื่อ cloud เปลี่ยน)
  3. Firestore CRUD = หลักการเดียวกับ SQLite แค่อยู่บน cloud + กรอง userId
  4. Security Rules = ความปลอดภัยจริงอยู่ที่เซิร์ฟเวอร์ ไม่ใช่แอป

สัปดาห์หน้า (สุดท้าย!) เราจะ ใส่ไอคอน/ชื่อ + build เป็นไฟล์ APK ให้ติดตั้งบนมือถือใครก็ได้ — แอป MyDiary จะกลายเป็นแอปจริงที่ปล่อยได้