Vibe coding全紀錄-28

A:

 我們先處理一下記帳圖表的功能新增,開發「統計圖表」的新功能功能,增加一個圖表,是能夠顯示主分類下的次分類的「統計次類別消費比例的圖表」功能,謝謝 

Q:

好的,這是一個非常實用的功能擴充!在總覽了主分類的開銷後,能夠進一步「鑽取 (Drill Down)」查看該主分類底下的細項分佈,能讓使用者更精準地了解錢花到哪裡去了。

我們的目標是:在現有的「統計圖表」頁面,當使用者點擊某個主分類後,下方會再出現一個新的圖表,專門顯示該主分類下,各個子分類的消費比例。


第十九步:開發子分類消費比例圖表

這個功能的開發流程我們已經很熟悉了:修改數據層 -> 升級 ViewModel -> 打造 UI。

第一步:擴充數據層以查詢子分類消費

我們需要一個新的 DAO 查詢,它能根據我們指定的主分類名稱,去計算底下所有子分類的金額總和。

A. 建立新的資料模型 SubCategorySum.kt

用來接收新的查詢結果。

路徑: app/src/main/java/com/yourcompany/cashflow/data/model/FinancialSummary.kt (加入這個 class)

Kotlin
// ... (檔案頂部已有 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

Kotlin
// ...
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 層公開這個新方法。

Kotlin
// /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

Kotlin
// ...
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>

Kotlin
// ... (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 並進入統計頁面:

  1. 上方的長條圖和以前一樣,顯示主分類的總覽。

  2. 長條圖下方新增了一個可點擊的主分類列表

  3. 當你點擊列表中的任一主分類(例如「餐飲」),該項目會被高亮,同時下方會流暢地動畫出現一個新的區塊。

  4. 這個新區塊會顯示「餐飲」底下,各個子分類(如早餐、午餐、晚餐)的詳細消費金額與比例。

  5. 再次點擊同一個主分類,或切換收支類型,子分類區塊就會自動隱藏。

我們成功地為統計功能增加了一個強大的「鑽取分析」能力!

接下來,我們最初規劃的宏大藍圖中,還剩下兩塊功能完整的拼圖:「專案資金管理」和「年度預算規劃」

你希望我們先進行哪個呢?