大家好!在 Day 11,我們扮演了架構師的角色,為「省錢拍拍」的資料設計了一個安全又高效的「家」。在 Day 10,我們建立了 AuthGate
,讓 App 能夠自動根據登入狀態切換頁面。
今天,我們將把這些環節正式串連起來,撰寫程式碼,搭建一座從 App 到雲端資料庫的橋樑。我們將實作所有資料庫應用中最核心的操作——CRUD,並聚焦於最重要的 Create (新增) 和 Read (讀取)。
要讓 Flutter App 與 Firestore 對話,我們需要加入官方的 cloud_firestore 套件。
在終端機底下輸入指令,它會自動抓取最新穩定版並加入 pubspec.yaml。:
flutter pub add cloud_firestore
我們將改造「新增消費」頁面 (add_transaction_page.dart
),讓它能將資料寫入 Firestore。這部分的邏輯相對獨立,可以直接參考 Day 9 文章的 _submitForm
方法,確保它能將資料新增到 Firestore 中對應使用者的 transactions
子集合。
add_transaction_page.dart
中的 _submitForm
關鍵程式碼:
// 導入 Firestore
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:firebase_auth/firebase_auth.dart';
// ...
Future<void> _submitForm() async {
if (_formKey.currentState!.validate()) {
final user = FirebaseAuth.instance.currentUser;
if (user == null) return;
// 指向 user's transactions sub-collection 的參照
final userTransactionsRef = FirebaseFirestore.instance
.collection('users')
.doc(user.uid)
.collection('transactions');
// 新增文件
await userTransactionsRef.add({
'title': _titleController.text,
'amount': double.parse(_amountController.text),
'category': '測試分類', // 暫時寫死
'date': Timestamp.now(),
});
if (mounted) Navigator.pop(context);
}
}
這是今天的核心!我們將徹底改造 _HomePageState
,用 StreamBuilder
來取代原本的本地 List
和 setState
邏輯。
StreamBuilder
的魔力
StreamBuilder
會「監聽」一個資料流 (Stream
)。只要 Firestore 上的資料有任何變動(例如我們剛剛透過表單新增了一筆),它就會自動收到通知並重繪 UI,我們再也不需手動呼叫setState
!
修改 lib/main.dart
中的 _HomePageState
類別。
// lib/main.dart
// ... 其他 class ...
class _HomePageState extends State<HomePage> {
// 不再需要手動管理的 transactions 列表
// List<Transaction> transactions = [];
// 導航功能簡化,不再需要接收回傳值和呼叫 setState
void _navigateToAddTransactionPage() {
Navigator.push(
context,
MaterialPageRoute(builder: (context) => const AddTransactionPage()),
);
}
@override
Widget build(BuildContext context) {
// 取得當前使用者,因為 AuthGate 已確保使用者登入,理論上 user 不會是 null
final user = FirebaseAuth.instance.currentUser;
return Scaffold(
appBar: AppBar(
// 您的 AppBar 程式碼維持不變
title: const Text('省錢拍拍 (SnapSaver)'),
actions: [
IconButton(
onPressed: () => FirebaseAuth.instance.signOut(),
icon: const Icon(Icons.logout),
),
],
),
floatingActionButton: FloatingActionButton(
// onPressed 指向簡化後的導航方法
onPressed: _navigateToAddTransactionPage,
child: const Icon(Icons.add),
),
body: Column(
children: [
// 您的頂部總覽區塊維持不變
Container(
padding: const EdgeInsets.all(24.0),
margin: const EdgeInsets.all(16.0),
decoration: BoxDecoration( /* ... */ ),
child: Column( /* ... */ ),
),
// 您的中間功能按鈕區塊維持不變
Container( /* ... */ ),
const Padding(
padding: EdgeInsets.symmetric(horizontal: 16.0),
child: Divider(),
),
// --- 核心改造開始 ---
Expanded(
// 我們假設 user 不會為 null,因為 AuthGate 會處理
// 如果需要更嚴謹的防呆,可以加上 user == null 的判斷
child: StreamBuilder<QuerySnapshot>(
// 1. 設定資料流的來源:指向使用者的 transactions 子集合
stream: FirebaseFirestore.instance
.collection('users')
.doc(user!.uid) // 使用 user.uid
.collection('transactions')
.orderBy('date', descending: true) // 依時間倒序排列
.snapshots(),
// 2. 根據資料流的狀態來建構 UI
builder: (context, snapshot) {
// 處理載入中狀態
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
// 處理錯誤狀態
if (snapshot.hasError) {
return const Center(child: Text('讀取資料時發生錯誤'));
}
// 處理沒有資料的狀態
if (!snapshot.hasData || snapshot.data!.docs.isEmpty) {
return const Center(child: Text('還沒有任何消費紀錄,點擊右下角新增一筆吧!'));
}
// 3. 成功取得資料,開始建立列表
final transactionDocs = snapshot.data!.docs;
return ListView.builder(
itemCount: transactionDocs.length,
itemBuilder: (context, index) {
final doc = transactionDocs[index];
final data = doc.data() as Map<String, dynamic>;
// 這裡我們直接使用 Map 中的資料來建立列表項
return Card(
margin: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 6.0),
child: ListTile(
leading: const Icon(Icons.receipt_long, color: Colors.teal),
title: Text(data['title'] ?? '無標題'),
subtitle: Text(data['category'] ?? '未分類'),
trailing: Text(
'NT\$ ${data['amount']?.toStringAsFixed(0) ?? '0'}',
style: const TextStyle(
color: Colors.redAccent,
fontWeight: FontWeight.bold,
),
),
),
);
},
);
},
),
),
// --- 改造結束 ---
],
),
);
}
}
程式碼解析與對比:
_HomePageState
中的 List<Transaction> transactions
。資料的唯一真實來源 (Single Source of Truth) 現在是 Firestore。_navigateToAddTransactionPage
不再需要 async/await
或處理回傳值。它的職責很單純:打開新頁面。因為 StreamBuilder
會自動處理資料更新,所以我們不再需要 setState
。StreamBuilder
:這是本次改造的核心。我們用 StreamBuilder
包裹住 ListView.builder
,並將 stream
指向 Firestore 的 .snapshots()
。StreamBuilder
的 builder
函式會根據 snapshot
的不同狀態(載入中、有錯誤、有資料、無資料)回傳不同的 UI,讓使用者體驗更友好。snapshot
有資料時,我們直接從 snapshot.data!.docs
讀取文件列表並渲染 ListView
。現在,App 已完全由雲端資料驅動。當您在 add_transaction_page
新增一筆資料並返回後,HomePage
的 StreamBuilder
會立刻收到 Firestore 推送的更新,並自動重繪列表,無需任何手動干預!
今天我們完成了從 App 到雲端資料庫的雙向數據流,這是整個專案的技術核心!我們學會了:
cloud_firestore
套件。.add()
方法將使用者資料新增 (Create) 到 Firestore。.snapshots()
搭配 StreamBuilder
來即時讀取 (Read) 雲端資料並自動更新 UI。明天,我們將進行一次**「期中整合與重構」**。我們將學習如何分離 UI 與業務邏輯,並將 Auth、Form、Firestore 的所有環節,串連成一個更清晰、更專業、更易於維護的工作流程。