สัปดาห์ที่ 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 สถานะเหมือนกัน! |
ส่วนที่ 2: เตรียม Firebase
2.1 ติดตั้งเครื่องมือ (ครั้งเดียวต่อเครื่อง)
npm install -g firebase-tools
firebase login
dart pub global activate flutterfire_cli
2.2 สร้าง/เชื่อมโปรเจกต์ Firebase
- ไปที่ console.firebase.google.com → สร้างโปรเจกต์ pdf File
- ในโฟลเดอร์โปรเจกต์ 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 ข้อ
- uid = รหัสประจำตัวไม่ซ้ำ ใช้ผูกข้อมูลว่าเป็นของใคร
- StreamBuilder = FutureBuilder เวอร์ชันเรียลไทม์ (จออัปเดตเองเมื่อ cloud เปลี่ยน)
- Firestore CRUD = หลักการเดียวกับ SQLite แค่อยู่บน cloud + กรอง userId
- Security Rules = ความปลอดภัยจริงอยู่ที่เซิร์ฟเวอร์ ไม่ใช่แอป
สัปดาห์หน้า (สุดท้าย!) เราจะ ใส่ไอคอน/ชื่อ + build เป็นไฟล์ APK ให้ติดตั้งบนมือถือใครก็ได้ — แอป MyDiary จะกลายเป็นแอปจริงที่ปล่อยได้