A:
我們先處理一下記帳圖表的功能新增,開發「統計圖表」的新功能功能,增加一個圖表,是能夠顯示主分類下的次分類的「統計次類別消費比例的圖表」功能,謝謝
Q:
好的,這是一個非常實用的功能擴充!在總覽了主分類的開銷後,能夠進一步「鑽取 (Drill Down)」查看該主分類底下的細項分佈,能讓使用者更精準地了解錢花到哪裡去了。
我們的目標是:在現有的「統計圖表」頁面,當使用者點擊某個主分類後,下方會再出現一個新的圖表,專門顯示該主分類下,各個子分類的消費比例。
第十九步:開發子分類消費比例圖表
這個功能的開發流程我們已經很熟悉了:修改數據層 -> 升級 ViewModel -> 打造 UI。
第一步:擴充數據層以查詢子分類消費
我們需要一個新的 DAO 查詢,它能根據我們指定的主分類名稱,去計算底下所有子分類的金額總和。
A. 建立新的資料模型 SubCategorySum.kt
用來接收新的查詢結果。
路徑: app/src/main/java/com/yourcompany/cashflow/data/model/FinancialSummary.kt
(加入這個 class)
// ... (檔案頂部已有 MonthlyTotal 和 AssetAllocation)
// 用於子分類消費比例圖:代表某個子分類的總額
data class SubCategorySum(
val subCategoryName: String,
val amount: Double
)
B. 更新 TransactionDao.kt
加入查詢特定主分類底下,各子分類匯總數據的方法。
路徑: app/src/main/java/com/yourcompany/cashflow/data/local/dao/TransactionDao.kt
// ...
import com.yourcompany.cashflow.data.model.SubCategorySum // 匯入
@Dao
interface TransactionDao {
// ... (其他查詢)
/**
* 查詢在特定時間範圍和主分類下,各子分類的總金額。
* 這個查詢比之前的更進一步,在 WHERE 條件中加入了對主分類名稱的篩選。
*/
@Query("""
SELECT sc.name as subCategoryName, SUM(t.amount) as amount
FROM transactions t
INNER JOIN sub_categories sc ON t.subCategoryId = sc.id
INNER JOIN main_categories mc ON sc.mainCategoryId = mc.id
WHERE t.type = :transactionType
AND t.transactionDate BETWEEN :startDate AND :endDate
AND mc.name = :mainCategoryName
GROUP BY sc.name
ORDER BY amount DESC
""")
fun getSubCategorySums(mainCategoryName: String, transactionType: TransactionType, startDate: Long, endDate: Long): Flow<List<SubCategorySum>>
}
C. 更新 TransactionRepository.kt
同樣地,在 Repository 層公開這個新方法。
// /data/repository/TransactionRepository.kt
// ...
import com.yourcompany.cashflow.data.model.SubCategorySum // 匯入
interface TransactionRepository {
// ...
fun getSubCategorySums(mainCategoryName: String, type: TransactionType, startDate: Long, endDate: Long): Flow<List<SubCategorySum>>
}
class TransactionRepositoryImpl @Inject constructor(...) : TransactionRepository {
// ...
override fun getSubCategorySums(mainCategoryName: String, type: TransactionType, startDate: Long, endDate: Long): Flow<List<SubCategorySum>> {
return transactionDao.getSubCategorySums(mainCategoryName, type, startDate, endDate)
}
}
第二步:升級 StatisticsViewModel 以管理選擇狀態
ViewModel 需要知道使用者「選取了哪個主分類」,並根據這個選擇去查詢對應的子分類數據。
路徑: app/src/main/java/com/yourcompany/cashflow/ui/viewmodel/StatisticsViewModel.kt
// ...
import com.yourcompany.cashflow.data.model.SubCategorySum // 匯入
@HiltViewModel
class StatisticsViewModel @Inject constructor(...) : ViewModel() {
// ... (現有的 _transactionType, _startDate, _endDate, chartData, transactionType)
// vvv --- 加入新的狀態管理 --- vvv
// 用來保存使用者點選的主分類名稱,可為 null (代表沒選)
private val _selectedMainCategory = MutableStateFlow<String?>(null)
val selectedMainCategory: StateFlow<String?> = _selectedMainCtegory.asStateFlow()
/**
* 子分類圖表的數據流。
* 它會監聽所有相關狀態的變化 (_transactionType, _startDate, _endDate, _selectedMainCategory)
* 當任何一個狀態改變時,它會自動重新執行查詢。
*/
@OptIn(ExperimentalCoroutinesApi::class)
val subCategoryChartData: StateFlow<List<SubCategorySum>> =
combine(_transactionType, _startDate, _endDate, _selectedMainCategory) { type, start, end, category ->
Triple(type, start, end) to category
}.flatMapLatest { (triple, category) ->
if (category == null) {
// 如果沒有選擇任何主分類,就回傳一個空的 Flow
flowOf(emptyList())
} else {
val (type, start, end) = triple
transactionRepository.getSubCategorySums(category, type, start, end)
}
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = emptyList()
)
/**
* 讓 UI 呼叫此方法來更新選擇的主分類
*/
fun selectMainCategory(categoryName: String?) {
// 如果點選同一個,就取消選擇,達到 Toggle 的效果
if (_selectedMainCategory.value == categoryName) {
_selectedMainCategory.value = null
} else {
_selectedMainCategory.value = categoryName
}
}
}
第三步:打造互動式統計圖表 UI
現在,我們來修改 StatisticsScreen
,加入新的互動元素和圖表。與其讓圖表本身可以點擊(這比較複雜),一個更可靠且使用者體驗更好的方式是讓圖表下方的列表可以點擊。
路徑: app/src/main/java/com/yourcompany/cashflow/ui/screens/StatisticsScreen.kt
<details>
<summary>點此展開更新後的 StatisticsScreen.kt 程式碼</summary>
// ... (imports)
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.clickable
import com.yourcompany.cashflow.data.model.SubCategorySum
import com.yourcompany.cashflow.ui.components.AssetAllocationSection // 我們可以重用這個元件的樣式
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun StatisticsScreen(
viewModel: StatisticsViewModel = hiltViewModel(),
onNavigateBack: () -> Unit
) {
val mainCategoryData by viewModel.chartData.collectAsStateWithLifecycle()
val subCategoryData by viewModel.subCategoryChartData.collectAsStateWithLifecycle()
val selectedMainCategory by viewModel.selectedMainCategory.collectAsStateWithLifecycle()
val transactionType by viewModel.transactionType.collectAsStateWithLifecycle()
val chartProducer = remember { ChartEntryModelProducer() }
val chartEntries = mainCategoryData.mapIndexed { index, categorySum -> entryOf(index.toFloat(), categorySum.amount.toFloat()) }
chartProducer.setEntries(chartEntries)
val bottomAxisFormatter = AxisValueFormatter<AxisPosition.Horizontal.Bottom> { value, _ ->
mainCategoryData.getOrNull(value.toInt())?.categoryName ?: ""
}
Scaffold(topBar = { ... }) { innerPadding ->
// 將整個頁面放入 LazyColumn,以便內容過多時可以滾動
LazyColumn(
modifier = Modifier.padding(innerPadding),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(24.dp)
) {
// -- 主分類圖表 --
item {
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp)) {
Text("主分類消費長條圖", style = MaterialTheme.typography.titleLarge)
TransactionTypeTabs(
selectedType = transactionType,
onTypeSelected = {
viewModel.selectMainCategory(null) // 切換收支類型時,清空主分類選擇
viewModel.setTransactionType(it)
}
)
Spacer(modifier = Modifier.height(16.dp))
if (mainCategoryData.isNotEmpty()) {
Chart(...) // Vico 長條圖 Composable (同上一版)
} else { ... }
}
}
}
// -- 主分類列表 (互動區) --
items(mainCategoryData) { categorySum ->
ListItem(
headlineText = { Text(categorySum.categoryName) },
trailingContent = { Text("NT$ ${"%,.0f".format(categorySum.amount)}") },
modifier = Modifier.clickable { viewModel.selectMainCategory(categorySum.categoryName) },
colors = if (categorySum.categoryName == selectedMainCategory) {
// 高亮顯示被選中的項目
ListItemDefaults.colors(containerColor = MaterialTheme.colorScheme.primaryContainer)
} else {
ListItemDefaults.colors()
}
)
}
// -- 子分類圖表 (條件式顯示) --
item {
AnimatedVisibility(visible = subCategoryData.isNotEmpty()) {
SubCategoryAllocationSection(
title = "『${selectedMainCategory ?: ""}』 消費佔比",
data = subCategoryData
)
}
}
}
}
}
/**
* 呈現子分類消費比例的區塊
*/
@Composable
fun SubCategoryAllocationSection(title: String, data: List<SubCategorySum>) {
val totalAmount = data.sumOf { it.amount }
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp)) {
Text(title, style = MaterialTheme.typography.titleLarge)
Spacer(modifier = Modifier.height(16.dp))
data.forEach { subCategory ->
val percentage = (subCategory.amount / totalAmount * 100)
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(text = subCategory.subCategoryName, modifier = Modifier.weight(1f))
LinearProgressIndicator(
progress = percentage.toFloat() / 100f,
modifier = Modifier.weight(2f).padding(horizontal = 8.dp)
)
Text(text = "%.1f%%".format(percentage), modifier = Modifier.weight(1f), textAlign = androidx.compose.ui.text.style.TextAlign.End)
}
}
}
}
}
</details>
功能完成!
現在,執行你的 App 並進入統計頁面:
上方的長條圖和以前一樣,顯示主分類的總覽。
長條圖下方新增了一個可點擊的主分類列表。
當你點擊列表中的任一主分類(例如「餐飲」),該項目會被高亮,同時下方會流暢地動畫出現一個新的區塊。
這個新區塊會顯示「餐飲」底下,各個子分類(如早餐、午餐、晚餐)的詳細消費金額與比例。
再次點擊同一個主分類,或切換收支類型,子分類區塊就會自動隱藏。
我們成功地為統計功能增加了一個強大的「鑽取分析」能力!
接下來,我們最初規劃的宏大藍圖中,還剩下兩塊功能完整的拼圖:「專案資金管理」和「年度預算規劃」。
你希望我們先進行哪個呢?