A:
好的,讓我們進行下一步,建立 BalanceSheetViewModel 和對應的 Repository,並在其中實作需要的複雜查詢邏輯,謝謝
Q:
好的,我們立刻開始!
這一步是「資產負債表」功能的核心,我們將建立它的「大腦」(ViewModel) 和「數據管道」(Repository)。最有趣也最具挑戰性的部分,是在 DAO
中撰寫幾個複雜的 SQL 查詢,以便從原始數據中提煉出圖表所需要的洞見。
第十七步:建立 ViewModel、Repository 及查詢邏輯
我們的計畫如下:
建立
SnapshotRepository
:定義 ViewModel 與數據層之間的合約。在
SnapshotDao
中實作複雜查詢:這是本日的重頭戲,我們會撰寫 SQL 來執行數據聚合。建立
BalanceSheetViewModel
:負責管理 UI 狀態,並從 Repository 取得格式化後的數據。
第一步:建立 SnapshotRepository
A. 建立 SnapshotRepository.kt 介面與實作
這個 Repository 將會是 BalanceSheetViewModel 唯一的數據來源。
路徑: app/src/main/java/com/yourcompany/cashflow/data/repository/SnapshotRepository.kt
(新檔案)
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
...
@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
(新檔案)
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
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
(新檔案)
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 提供的數據流,變成使用者看得懂的年度趨勢圖、資產配置圖和帳戶列表,完成我們對資產負債表功能的最後一塊拼圖。
準備好開始繪製這些漂亮的圖表了嗎?