iT邦幫忙

2025 iThome 鐵人賽

DAY 23
0
自我挑戰組

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

Day 23: 讓數字說話 - 即時計算本月總支出

  • 分享至 

  • xImage
  •  

前言

大家好!在 Day 22,我們透過建立中央訊息服務,讓 App 的使用者體驗更加精緻。今天,我們將目光轉回主畫面的核心——那張寫著 NT$ 12,345 的「本月總支出」卡片。從 Day 5 建立至今,它一直是一個靜態的假數據,是時候讓它真正地「動」起來了。

今天的目標是:串接 Firestore 資料,即時計算並顯示使用者「當前月份」的總消費金額。我們將利用 Stream 的強大能力,讓總金額能隨著每筆消費的新增或刪除自動更新,使其成為使用者最直觀的財務儀表板。

Step 1: 釐清查詢邏輯 - 如何篩選「本月」資料?

要計算本月總支出,首先需要從 Firestore 中篩選出屬於「當前月份」的消費紀錄。這需要在查詢時加入時間範圍的過濾條件。

邏輯很簡單:我們需要找出「本月第一天的 00:00:00」和「下個月第一天的 00:00:00」,然後查詢所有 date 欄位介於這兩個時間點之間的紀錄。

在 Dart 中,我們可以這樣取得這兩個時間點:

final now = DateTime.now();
// 取得本月第一天
final startOfMonth = DateTime(now.year, now.month, 1);
// 取得下個月第一天 (月份+1,日期設為1)
final endOfMonth = DateTime(now.year, now.month + 1, 1);

Step 2: 擴充 FirestoreService - 新增月份查詢

既有的 getTransactionsStream 會抓取所有紀錄,不符需求。因此,我們在 lib/services/firestore_service.dart 中新增一個專門的方法,用來獲取當前月份的交易串流。

// lib/services/firestore_service.dart
// ...
class FirestoreService {
  // ... 其他方法 ...

  // Day 23 新增方法:取得當前月份的交易紀錄串流
  Stream<QuerySnapshot> getCurrentMonthTransactionsStream({required String userId}) {
    final now = DateTime.now();
    final startOfMonth = DateTime(now.year, now.month, 1);
    final endOfMonth = DateTime(now.year, now.month + 1, 1);

    return _usersCollection
        .doc(userId)
        .collection('transactions')
        // 篩選條件:date >= 本月第一天
        .where('date', isGreaterThanOrEqualTo: Timestamp.fromDate(startOfMonth))
        // 篩選條件:date < 下個月第一天
        .where('date', isLessThan: Timestamp.fromDate(endOfMonth))
        .snapshots();
  }
}

程式碼解析

  • 我們使用 Firestore 的 .where() 方法來增加篩選條件。
  • Firestore 在進行範圍查詢時,需要將 Dart 的 DateTime 物件轉換為 Timestamp 格式。

Step 3: 在 HomePage 中整合即時計算

現在,我們回到主畫面 lib/main.dart,用 StreamBuilder 來包裹總支出卡片,讓它訂閱我們剛剛建立的資料流,並即時計算總和。

import 'package:cloud_firestore/cloud_firestore.dart' hide Transaction;

// lib/main.dart -> _HomePageState -> build()

@override
Widget build(BuildContext context) {
  final user = FirebaseAuth.instance.currentUser;
  return Scaffold(
    // ... AppBar 和 FloatingActionButton ...
    body: Column(
      children: [
        // --- 核心改造開始 ---
        // 1. 用 StreamBuilder 包裹總支出卡片
        StreamBuilder<QuerySnapshot>(
          // 2. stream 指向我們的新方法
          stream: user != null
              ? _firestoreService.getCurrentMonthTransactionsStream(userId: user.uid)
              : null,
          builder: (context, snapshot) {
            // 處理載入中
            if (snapshot.connectionState == ConnectionState.waiting) {
              return const Center(child: CircularProgressIndicator());
            }
            // 處理錯誤
            if (snapshot.hasError) {
              return Center(child: Text('Error: ${snapshot.error}'));
            }
            // 處理沒有資料
            if (!snapshot.hasData || snapshot.data!.docs.isEmpty) {
              // 即使沒資料,也顯示 0 元,而不是不顯示卡片
              return _buildTotalSpentCard(0.0);
            }

            // 3. 計算總和
            double totalSpent = 0.0;
            for (var doc in snapshot.data!.docs) {
              final data = doc.data() as Map<String, dynamic>;
              // 確保 amount 欄位存在且為數字型別
              if (data.containsKey('amount') && data['amount'] is num) {
                totalSpent += data['amount'];
              }
            }
            
            // 4. 使用計算出的 totalSpent 來建立卡片
            return _buildTotalSpentCard(totalSpent);
          },
        ),
        // --- 核心改造結束 ---
        
        // ... 中間功能按鈕區塊 ...
        // ... 列表標題 ...
        Expanded(
          // ... 顯示交易列表的 StreamBuilder (維持不變) ...
        ),
      ],
    ),
  );
}

// 5. 將原本的 Container 抽離成一個獨立的輔助函式,方便重用
Widget _buildTotalSpentCard(double totalSpent) {
  return Container(
    padding: const EdgeInsets.all(24.0),
    margin: const EdgeInsets.all(16.0),
    decoration: BoxDecoration(
      color: Colors.grey.shade200,
      borderRadius: BorderRadius.circular(10),
    ),
    child: Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        const Text(
          '本月總支出',
          style: TextStyle(fontSize: 16, color: Colors.black54),
        ),
        Text(
          'NT\$ ${totalSpent.toStringAsFixed(0)}', // 顯示計算出的總金額
          style: Theme.of(context).textTheme.headlineLarge?.copyWith(
                color: Colors.teal.shade700,
              ),
        ),
      ],
    ),
  );
}
  • StreamBuilderstream 指向我們在 FirestoreService 中建立的新方法 getCurrentMonthTransactionsStream
  • builder 回呼函式中,我們先處理載入、錯誤與無資料等狀態。
  • 接著,我們遍歷 snapshot 提供的文件,即時計算出總金額。
  • 為了讓 build 方法更清晰,我們將卡片的 UI 抽離成一個 _buildTotalSpentCard 獨立的輔助函式。

重啟 App,你會發現總支出卡片已能顯示 Firestore 中當前月份的真實總和。試著新增或刪除一筆消費,數字會神奇地即時更新!

鐵人賽的最後一哩路:收尾規劃

今天我們完成了 App 核心功能的最後一項動態化。至此,「省錢拍拍」已從一個概念,成長為功能完整、架構清晰,並具備 AI 能力的雲端應用。

接下來的一週,我們將進行專案的收尾工作:

  • Day 24-25: 上架前置作業:為 為 App 注入靈魂!使用 flutter_launcher_iconsflutter_native_splash 產生自訂的 App 圖示與啟動畫面。
  • Day 26-27: 應用程式打包與發布:學習將 Flutter 專案打包成 Android 的 AAB/APK 與 iOS 的 IPA 檔案,並簡介上架流程。
  • Day 28-29: 專案總結與回顧:回顧這 30 天的旅程,我們遇到了哪些挑戰、學到了哪些關鍵知識,以及有哪些可以做得更好的地方。
  • Day 30: 最終章 - 未來展望:為「省錢拍拍」畫下句點,展望未來可擴充的功能,為這次鐵人賽做個完美總結。

今日結語

今天我們讓主畫面的儀表板真正活了起來,重點如下:

  1. 如何設計 Firestore 的時間範圍查詢。
  2. 如何利用 StreamBuilder 進行即時的數據聚合運算(加總)。
  3. 將重複的 UI 程式碼抽離成輔助函式,提升程式碼可讀性。

我們的 App 現在不僅智慧,數據也完全即時同步。從明天開始,讓我們一起為 App 的「上市」做最後的準備吧!


上一篇
Day 22: 專案打磨 (II) - 建立統一的訊息回饋系統
系列文
攜手 AI 從零開始打造一款 Flutter 應用程式23
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言