Vibe coding全紀錄-20

A:

 太好了,讓我們接著下去,進行開發「統計圖表」功能,謝謝。 

Q:

太棒了!這一步是我們將枯燥的數字變成直覺故事的開始,也是許多使用者最期待的功能。開發圖表功能非常有趣,你看著數據在眼前視覺化時,會很有成就感。

我們的目標是建立一個統計頁面,能夠以圖表(例如圓餅圖或長條圖)的形式,顯示特定時間範圍內(例如本月)各個分類的支出或收入佔比。


第十四步:開發統計圖表功能

我們的計畫如下:

  1. 選擇並加入圖表函式庫:我們需要一個強大的工具來幫我們繪製圖表。

  2. 建立數據聚合查詢:修改數據層,讓它可以計算出「各分類總金額」而不是回傳一筆筆的交易。

  3. 建立 StatisticsViewModel:專門處理統計頁面的邏輯,例如時間範圍選擇和數據加總。

  4. 打造 UI 畫面:使用圖表函式庫,將 ViewModel 提供的數據畫出來。

  5. 整合導航:讓我們能從主畫面進入統計頁。

第一步:選擇並加入圖表函式庫

在 Jetpack Compose 中,有許多優秀的第三方圖表函式庫。我推薦使用 Vico,它是一個專為 Compose 設計、現代化且靈活的函式庫。

請在 app/build.gradle.ktsdependencies 區塊中加入 Vico 的依賴:

Kotlin
// Vico Charting Library
implementation("com.patrykandpatrick.vico:compose:1.14.0")
implementation("com.patrykandpatrick.vico:compose-m3:1.14.0") // M3 風格
implementation("com.patrykandpatrick.vico:core:1.14.0")

記得點擊 "Sync Now" 同步專案。

第二步:建立數據聚合查詢 (Data Aggregation)

我們需要的不再是單筆交易,而是「按分類匯總」後的數據。這需要在 DAO 中使用 SQL 的 GROUP BYSUM 語法。

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

這個 class 用來存放 SQL 查詢的結果。

Kotlin
// /data/model/CategorySum.kt (新檔案)
package com.yourcompany.cashflow.data.model

data class CategorySum(
    val categoryName: String,
    val amount: Double
)

B. 更新 TransactionDao.kt

加入我們最關鍵的數據聚合查詢方法。

Kotlin
// /data/local/dao/TransactionDao.kt
...
import com.yourcompany.cashflow.data.model.CategorySum
import com.yourcompany.cashflow.data.local.entity.TransactionType

@Dao
interface TransactionDao {
    ...
    // 這個查詢會連接交易、子分類、主分類三個資料表,
    // 並根據傳入的類型和時間範圍,計算出每個主分類的總金額。
    @Query("""
        SELECT mc.name as categoryName, 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
        GROUP BY mc.name
        ORDER BY amount DESC
    """)
    fun getCategorySums(transactionType: TransactionType, startDate: Long, endDate: Long): Flow<List<CategorySum>>
}

C. 更新 TransactionRepository.kt

Kotlin
// /data/repository/TransactionRepository.kt
...
import com.yourcompany.cashflow.data.model.CategorySum

interface TransactionRepository {
    ...
    fun getCategorySums(type: TransactionType, startDate: Long, endDate: Long): Flow<List<CategorySum>>
}

class TransactionRepositoryImpl @Inject constructor(...) : TransactionRepository {
    ...
    override fun getCategorySums(type: TransactionType, startDate: Long, endDate: Long): Flow<List<CategorySum>> {
        return transactionDao.getCategorySums(type, startDate, endDate)
    }
}

第三步:建立統計畫面的 ViewModel

Kotlin
// /ui/viewmodel/StatisticsViewModel.kt (新檔案)
package com.yourcompany.cashflow.ui.viewmodel

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.yourcompany.cashflow.data.local.entity.TransactionType
import com.yourcompany.cashflow.data.model.CategorySum
import com.yourcompany.cashflow.data.repository.TransactionRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.*
import java.util.Calendar
import javax.inject.Inject

@HiltViewModel
class StatisticsViewModel @Inject constructor(
    private val transactionRepository: TransactionRepository
) : ViewModel() {

    private val _transactionType = MutableStateFlow(TransactionType.EXPENSE)
    
    // 使用 Calendar 來取得本月的開始與結束時間戳
    private val _startDate = MutableStateFlow(getStartOfMonth())
    private val _endDate = MutableStateFlow(getEndOfMonth())

    @OptIn(ExperimentalCoroutinesApi::class)
    val chartData: StateFlow<List<CategorySum>> =
        combine(_transactionType, _startDate, _endDate) { type, start, end ->
            Triple(type, start, end)
        }.flatMapLatest { (type, start, end) ->
            transactionRepository.getCategorySums(type, start, end)
        }.stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSub Doklady(5000),
            initialValue = emptyList()
        )
    
    val transactionType: StateFlow<TransactionType> = _transactionType.asStateFlow()
    
    fun setTransactionType(type: TransactionType) {
        _transactionType.value = type
    }
    
    private fun getStartOfMonth(): Long {
        return Calendar.getInstance().apply {
            set(Calendar.DAY_OF_MONTH, 1)
            set(Calendar.HOUR_OF_DAY, 0)
            set(Calendar.MINUTE, 0)
            set(Calendar.SECOND, 0)
            set(Calendar.MILLISECOND, 0)
        }.timeInMillis
    }

    private fun getEndOfMonth(): Long {
        return Calendar.getInstance().apply {
            add(Calendar.MONTH, 1)
            set(Calendar.DAY_OF_MONTH, 1)
            add(Calendar.DATE, -1)
            set(Calendar.HOUR_OF_DAY, 23)
            set(Calendar.MINUTE, 59)
            set(Calendar.SECOND, 59)
            set(Calendar.MILLISECOND, 999)
        }.timeInMillis
    }
}

第四步:打造你的第一個統計圖表畫面

A. 建立 StatisticsScreen.kt

我們將使用 Vico 的長條圖 (ColumnChart) 來呈現數據。

Kotlin
// /ui/screens/StatisticsScreen.kt (新檔案)
package com.yourcompany.cashflow.ui.screens

import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.yourcompany.cashflow.ui.components.TransactionTypeTabs
import com.yourcompany.cashflow.ui.viewmodel.StatisticsViewModel
import com.patrykandpatrick.vico.compose.axis.horizontal.rememberBottomAxis
import com.patrykandpatrick.vico.compose.axis.vertical.rememberStartAxis
import com.patrykandpatrick.vico.compose.chart.Chart
import com.patrykandpatrick.vico.compose.chart.column.columnChart
import com.patrykandpatrick.vico.core.axis.AxisPosition
import com.patrykandpatrick.vico.core.axis.formatter.AxisValueFormatter
import com.patrykandpatrick.vico.core.entry.ChartEntryModelProducer
import com.patrykandpatrick.vico.core.entry.entryOf

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun StatisticsScreen(
    viewModel: StatisticsViewModel = hiltViewModel(),
    onNavigateBack: () -> Unit
) {
    val chartData by viewModel.chartData.collectAsStateWithLifecycle()
    val transactionType by viewModel.transactionType.collectAsStateWithLifecycle()

    // Vico 需要一個 ChartEntryModelProducer 來提供數據
    val chartEntryModelProducer = remember { ChartEntryModelProducer() }
    
    // 將我們的數據轉換成 Vico 需要的格式
    val chartEntries = chartData.mapIndexed { index, categorySum ->
        entryOf(index.toFloat(), categorySum.amount.toFloat())
    }
    chartEntryModelProducer.setEntries(chartEntries)

    // 建立 X 軸的標籤 (分類名稱)
    val bottomAxisValueFormatter = AxisValueFormatter<AxisPosition.Horizontal.Bottom> { value, _ ->
        chartData.getOrNull(value.toInt())?.categoryName ?: ""
    }

    Scaffold(
        topBar = {
            TopAppBar(
                title = { Text("財務統計") },
                navigationIcon = { IconButton(onClick = onNavigateBack) { Icon(Icons.Default.ArrowBack, "返回") } }
            )
        }
    ) { innerPadding ->
        Column(modifier = Modifier.padding(innerPadding).padding(16.dp)) {
            TransactionTypeTabs(
                selectedType = transactionType,
                onTypeSelected = { viewModel.setTransactionType(it) }
            )
            
            Spacer(modifier = Modifier.height(24.dp))
            
            if (chartData.isNotEmpty()) {
                Chart(
                    chart = columnChart(),
                    chartModelProducer = chartEntryModelProducer,
                    startAxis = rememberStartAxis(),
                    bottomAxis = rememberBottomAxis(
                        valueFormatter = bottomAxisValueFormatter,
                        labelRotationDegrees = 45f // 旋轉標籤以防重疊
                    ),
                    modifier = Modifier.height(300.dp)
                )
            } else {
                Box(modifier = Modifier.height(300.dp)) {
                    Text("本月尚無${transactionType.name}紀錄")
                }
            }
        }
    }
}

第五步:整合導航

A. 更新 HomeScreen

在右上角加入一個圖示按鈕,用來跳轉到統計頁面。

Kotlin
// /ui/screens/HomeScreen.kt
...
import androidx.compose.material.icons.filled.PieChart // 匯入圖示

@Composable
fun HomeScreen(
    ...
    onNavigateToStatistics: () -> Unit, // 新增回呼
    onNavigateToSettings: () -> Unit
) {
    Scaffold(
        topBar = {
            TopAppBar(
                ...
                actions = {
                    IconButton(onClick = onNavigateToStatistics) { // 新增按鈕
                        Icon(Icons.Default.PieChart, contentDescription = "統計")
                    }
                    IconButton(onClick = onNavigateToSettings) { ... }
                }
            )
        },
        ...
    )
}

B. 更新 AppNavigation.kt

Kotlin
// /ui/AppNavigation.kt
...
object Routes {
    // ...
    const val STATISTICS = "statistics" // 新增路線
}

@Composable
fun AppNavigation() {
    ...
    NavHost(...) {
        composable(Routes.HOME) {
            HomeScreen(
                ...
                onNavigateToStatistics = { navController.navigate(Routes.STATISTICS) },
                onNavigateToSettings = { ... }
            )
        }
        ...
        composable(Routes.STATISTICS) {
            StatisticsScreen(
                onNavigateBack = { navController.popBackStack() }
            )
        }
    }
}

功能完成!

現在,執行你的 App!只要你的資料庫中有本月的交易紀錄,進入統計頁面後,你就能看到一個清晰的長條圖,顯示各個主分類的總支出或總收入。你可以點擊上方的「收入/支出」Tab 來切換圖表內容。

這只是統計功能的開始,但你已經掌握了最重要的核心:數據聚合圖表呈現

現在 App 的核心功能越來越完整了!我們還剩下最後一個規劃中的主要功能:實現定期交易。這是一個後端功能,需要使用 WorkManager 在背景自動為使用者新增交易。

準備好挑戰這個比較底層的 Android 功能了嗎?