iT邦幫忙

2025 iThome 鐵人賽

DAY 12
0
自我挑戰組

攜手 AI 從零開始打造一款 Flutter 應用程式系列 第 12

Day 12: 與雲端對話 - Firestore 的 CRUD 實戰操作

  • 分享至 

  • xImage
  •  

前言

大家好!在 Day 11,我們扮演了架構師的角色,為「省錢拍拍」的資料設計了一個安全又高效的「家」。在 Day 10,我們建立了 AuthGate,讓 App 能夠自動根據登入狀態切換頁面。

今天,我們將把這些環節正式串連起來,撰寫程式碼,搭建一座從 App 到雲端資料庫的橋樑。我們將實作所有資料庫應用中最核心的操作——CRUD,並聚焦於最重要的 Create (新增) 和 Read (讀取)。

Step 1: 準備工作 - 加入 Firestore 套件

要讓 Flutter App 與 Firestore 對話,我們需要加入官方的 cloud_firestore 套件。

在終端機底下輸入指令,它會自動抓取最新穩定版並加入 pubspec.yaml。:

flutter pub add cloud_firestore

Step 2: C for Create - 將消費紀錄存入雲端

我們將改造「新增消費」頁面 (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);
  }
}

https://ithelp.ithome.com.tw/upload/images/20250926/20163912Ko5xdHgesz.png

Step 3: R for Read - 即時讀取雲端資料 (核心改造)

這是今天的核心!我們將徹底改造 _HomePageState,用 StreamBuilder 來取代原本的本地 ListsetState 邏輯。

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,
                          ),
                        ),
                      ),
                    );
                  },
                );
              },
            ),
          ),
          // --- 改造結束 ---
        ],
      ),
    );
  }
}

程式碼解析與對比:

  1. 移除本地狀態:我們完全刪除了 _HomePageState 中的 List<Transaction> transactions。資料的唯一真實來源 (Single Source of Truth) 現在是 Firestore。
  2. 簡化導航:_navigateToAddTransactionPage 不再需要 async/await 或處理回傳值。它的職責很單純:打開新頁面。因為 StreamBuilder 會自動處理資料更新,所以我們不再需要 setState
  3. 引入 StreamBuilder:這是本次改造的核心。我們用 StreamBuilder 包裹住 ListView.builder,並將 stream 指向 Firestore 的 .snapshots()
  4. 即時 UI:StreamBuilderbuilder 函式會根據 snapshot 的不同狀態(載入中、有錯誤、有資料、無資料)回傳不同的 UI,讓使用者體驗更友好。
  5. 渲染資料:當 snapshot 有資料時,我們直接從 snapshot.data!.docs 讀取文件列表並渲染 ListView

現在,App 已完全由雲端資料驅動。當您在 add_transaction_page 新增一筆資料並返回後,HomePageStreamBuilder 會立刻收到 Firestore 推送的更新,並自動重繪列表,無需任何手動干預!

https://ithelp.ithome.com.tw/upload/images/20250926/20163912KiJsIAGF4s.png

今日結語

今天我們完成了從 App 到雲端資料庫的雙向數據流,這是整個專案的技術核心!我們學會了:

  1. 如何使用 cloud_firestore 套件。
  2. 透過 .add() 方法將使用者資料新增 (Create) 到 Firestore。
  3. 利用 .snapshots() 搭配 StreamBuilder 來即時讀取 (Read) 雲端資料並自動更新 UI。

明天,我們將進行一次**「期中整合與重構」**。我們將學習如何分離 UI 與業務邏輯,並將 Auth、Form、Firestore 的所有環節,串連成一個更清晰、更專業、更易於維護的工作流程。


上一篇
Day 11: 為資料找個家 - Firestore 雲端資料庫結構設計
系列文
攜手 AI 從零開始打造一款 Flutter 應用程式12
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言