Vibe coding全紀錄-26

A:

 好的,讓我們進行下一步,建立 BalanceSheetViewModel 和對應的 Repository,並在其中實作需要的複雜查詢邏輯,謝謝 

Q:

好的,我們立刻開始!

這一步是「資產負債表」功能的核心,我們將建立它的「大腦」(ViewModel) 和「數據管道」(Repository)。最有趣也最具挑戰性的部分,是在 DAO 中撰寫幾個複雜的 SQL 查詢,以便從原始數據中提煉出圖表所需要的洞見。


第十七步:建立 ViewModel、Repository 及查詢邏輯

我們的計畫如下:

  1. 建立 SnapshotRepository:定義 ViewModel 與數據層之間的合約。

  2. SnapshotDao 中實作複雜查詢:這是本日的重頭戲,我們會撰寫 SQL 來執行數據聚合。

  3. 建立 BalanceSheetViewModel:負責管理 UI 狀態,並從 Repository 取得格式化後的數據。

第一步:建立 SnapshotRepository

A. 建立 SnapshotRepository.kt 介面與實作

這個 Repository 將會是 BalanceSheetViewModel 唯一的數據來源。

路徑: app/src/main/java/com/yourcompany/cashflow/data/repository/SnapshotRepository.kt (新檔案)

Kotlin
package com.yourcompany.cashflow.data.repository

import com.yourcompany.cashflow.data.local.entity.AccountSnapshot
import com.yourcompany.cashflow.data.model.AssetAllocation
import com.yourcompany.cashflow.data.model.MonthlyTotal
import kotlinx.coroutines.flow.Flow
import java.time.LocalDate
import javax.inject.Inject

interface SnapshotRepository {
    suspend fun addOrUpdateSnapshot(accountId: Long, value: Double, date: LocalDate)
    fun getYearlyAssetTrend(year: Int): Flow<List<MonthlyTotal>>
    fun getLatestAssetAllocation(): Flow<List<AssetAllocation>>
}

class SnapshotRepositoryImpl @Inject constructor(
    private val snapshotDao: com.yourcompany.cashflow.data.local.dao.SnapshotDao
) : SnapshotRepository {

    override suspend fun addOrUpdateSnapshot(accountId: Long, value: Double, date: LocalDate) {
        snapshotDao.addOrUpdateSnapshot(accountId, value, date)
    }
    
    override fun getYearlyAssetTrend(year: Int): Flow<List<MonthlyTotal>> {
        return snapshotDao.getYearlyAssetTrend(year)
    }

    override fun getLatestAssetAllocation(): Flow<List<AssetAllocation>> {
        return snapshotDao.getLatestAssetAllocation()
    }
}

B. 更新 DI 模組

在 RepositoryModule.kt 中加入 SnapshotRepository 的綁定。

路徑: app/src/main/java/com/yourcompany/cashflow/di/RepositoryModule.kt

Kotlin
...
@Module
@InstallIn(SingletonComponent::class)
abstract class RepositoryModule {
    // ... 其他 Binds
    
    @Binds
    @Singleton
    abstract fun bindSnapshotRepository(impl: SnapshotRepositoryImpl): SnapshotRepository
}

第二步:在 SnapshotDao 中實作複雜的 SQL 查詢

現在,我們來為 SnapshotDao 加入真正強大的查詢能力。

A. 建立查詢結果所需的資料模型

這些不是 Entity,只是用來承接特定查詢結果的容器。

路徑: app/src/main/java/com/yourcompany/cashflow/data/model/FinancialSummary.kt (新檔案)

Kotlin
package com.yourcompany.cashflow.data.model

import com.yourcompany.cashflow.data.local.entity.AssetClass

// 用於年度趨勢圖:代表某個月份的資產總額
data class MonthlyTotal(
    val yearMonth: String, // 格式: "2025-08"
    val totalValue: Double
)

// 用於資產配置圖:代表某個資產類別的總額
data class AssetAllocation(
    val assetClass: AssetClass,
    val totalValue: Double
)

B. 更新 SnapshotDao.kt

這是本步驟最核心的程式碼。我會用註解詳細解釋每個查詢的作用。

路徑: app/src/main/java/com/yourcompany/cashflow/data/local/dao/SnapshotDao.kt

Kotlin
package com.yourcompany.cashflow.data.local.dao

import androidx.room.*
import com.yourcompany.cashflow.data.local.entity.AccountSnapshot
import com.yourcompany.cashflow.data.local.entity.AssetClass
import com.yourcompany.cashflow.data.model.AssetAllocation
import com.yourcompany.cashflow.data.model.MonthlyTotal
import kotlinx.coroutines.flow.Flow
import java.time.LocalDate
import java.time.ZoneId

@Dao
interface SnapshotDao {

    /**
     * 查詢某個帳戶在特定月份是否已有快照
     */
    @Query("SELECT * FROM account_snapshots WHERE accountId = :accountId AND strftime('%Y-%m', snapshotDate / 1000, 'unixepoch') = :yearMonth LIMIT 1")
    suspend fun findSnapshotForMonth(accountId: Long, yearMonth: String): AccountSnapshot?

    @Insert
    suspend fun insert(snapshot: AccountSnapshot)

    @Update
    suspend fun update(snapshot: AccountSnapshot)

    /**
     * 這是一個交易型函式,用來實現「新增或更新」的邏輯
     * 它會先查找當月是否已有紀錄,有則更新,無則新增。
     */
    @Transaction
    suspend fun addOrUpdateSnapshot(accountId: Long, value: Double, date: LocalDate) {
        val yearMonth = date.toString().substring(0, 7) // 格式 "YYYY-MM"
        val existingSnapshot = findSnapshotForMonth(accountId, yearMonth)

        if (existingSnapshot != null) {
            // 更新現有快照
            update(existingSnapshot.copy(value = value, snapshotDate = date.atStartOfDay(ZoneId.systemDefault()).toInstant().toEpochMilli()))
        } else {
            // 新增一筆快照
            insert(AccountSnapshot(accountId = accountId, value = value, snapshotDate = date.atStartOfDay(ZoneId.systemDefault()).toInstant().toEpochMilli()))
        }
    }

    /**
     * 查詢某一年中,所有資產型帳戶的每月總價值,用於年度趨勢圖。
     * 1. JOIN 兩個資料表
     * 2. 篩選出 ASSET 類型的帳戶 (BANK, INVESTMENT)
     * 3. 篩選出指定年份
     * 4. 按月份分組 (GROUP BY) 並加總 (SUM)
     */
    @Query("""
        SELECT strftime('%Y-%m', s.snapshotDate / 1000, 'unixepoch') as yearMonth, SUM(s.value) as totalValue
        FROM account_snapshots s
        INNER JOIN accounts a ON s.accountId = a.id
        WHERE a.type IN ('BANK', 'INVESTMENT') AND strftime('%Y', s.snapshotDate / 1000, 'unixepoch') = :year
        GROUP BY yearMonth
        ORDER BY yearMonth ASC
    """)
    fun getYearlyAssetTrend(year: String): Flow<List<MonthlyTotal>>

    /**
     * 查詢最新一次快照中,各資產類別的總價值,用於資產配置比例圖。
     * 1. 使用子查詢找到每個帳戶的最新快照日期
     * 2. 根據子查詢的結果,篩選出所有帳戶的最新一筆快照
     * 3. JOIN accounts 表以取得 assetClass
     * 4. 按 assetClass 分組並加總
     */
    @Query("""
        SELECT a.assetClass, SUM(s.value) as totalValue
        FROM account_snapshots s
        INNER JOIN accounts a ON s.accountId = a.id
        WHERE s.snapshotDate IN (SELECT MAX(snapshotDate) FROM account_snapshots GROUP BY accountId)
        AND a.type IN ('BANK', 'INVESTMENT')
        GROUP BY a.assetClass
    """)
    fun getLatestAssetAllocation(): Flow<List<AssetAllocation>>
}

注意: getYearlyAssetTrend 中的 :year 參數需要傳入字串格式,例如 "2025"

第三步:建立 BalanceSheetViewModel

現在,我們可以建立 ViewModel,將所有數據管道連接起來。

路徑: app/src/main/java/com/yourcompany/cashflow/ui/viewmodel/BalanceSheetViewModel.kt (新檔案)

Kotlin
package com.yourcompany.cashflow.ui.viewmodel

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.yourcompany.cashflow.data.model.AssetAllocation
import com.yourcompany.cashflow.data.model.MonthlyTotal
import com.yourcompany.cashflow.data.repository.SnapshotRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import java.time.LocalDate
import java.util.Calendar
import javax.inject.Inject

@HiltViewModel
class BalanceSheetViewModel @Inject constructor(
    private val snapshotRepository: SnapshotRepository
) : ViewModel() {

    private val currentYear = Calendar.getInstance().get(Calendar.YEAR).toString()

    // 年度資產趨勢圖的數據流
    val yearlyAssetTrend: StateFlow<List<MonthlyTotal>> =
        snapshotRepository.getYearlyAssetTrend(currentYear)
            .stateIn(
                scope = viewModelScope,
                started = SharingStarted.WhileSubscribed(5000),
                initialValue = emptyList()
            )

    // 資產配置圖的數據流
    val assetAllocation: StateFlow<List<AssetAllocation>> =
        snapshotRepository.getLatestAssetAllocation()
            .stateIn(
                scope = viewModelScope,
                started = SharingStarted.WhileSubscribed(5000),
                initialValue = emptyList()
            )

    /**
     * 讓 UI 呼叫此方法來更新或新增一筆當月快照
     */
    fun addOrUpdateSnapshot(accountId: Long, value: Double) {
        viewModelScope.launch {
            // 我們預設快照日期為今天
            snapshotRepository.addOrUpdateSnapshot(accountId, value, LocalDate.now())
        }
    }
}

大腦與管道已就緒!

我們已經成功建立了資產負債表功能的「大腦」(ViewModel) 和「數據管道」(Repository/DAO)。我們撰寫了幾個相當複雜但高效的 SQL 查詢,現在 ViewModel 已經能源源不絕地提供 UI 所需的、格式化好的統計數據了。

下一步,我們將利用 Vico 圖表函式庫,將這些 ViewModel 提供的數據流,變成使用者看得懂的年度趨勢圖、資產配置圖和帳戶列表,完成我們對資產負債表功能的最後一塊拼圖。

準備好開始繪製這些漂亮的圖表了嗎?