A:
太好了,讓我們接著下去,進行開發「統計圖表」功能,謝謝。
Q:
太棒了!這一步是我們將枯燥的數字變成直覺故事的開始,也是許多使用者最期待的功能。開發圖表功能非常有趣,你看著數據在眼前視覺化時,會很有成就感。
我們的目標是建立一個統計頁面,能夠以圖表(例如圓餅圖或長條圖)的形式,顯示特定時間範圍內(例如本月)各個分類的支出或收入佔比。
第十四步:開發統計圖表功能
我們的計畫如下:
選擇並加入圖表函式庫:我們需要一個強大的工具來幫我們繪製圖表。
建立數據聚合查詢:修改數據層,讓它可以計算出「各分類總金額」而不是回傳一筆筆的交易。
建立
StatisticsViewModel
:專門處理統計頁面的邏輯,例如時間範圍選擇和數據加總。打造 UI 畫面:使用圖表函式庫,將 ViewModel 提供的數據畫出來。
整合導航:讓我們能從主畫面進入統計頁。
第一步:選擇並加入圖表函式庫
在 Jetpack Compose 中,有許多優秀的第三方圖表函式庫。我推薦使用 Vico,它是一個專為 Compose 設計、現代化且靈活的函式庫。
請在 app/build.gradle.kts
的 dependencies
區塊中加入 Vico 的依賴:
// 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 BY
和 SUM
語法。
A. 建立新的資料模型 CategorySum.kt
這個 class 用來存放 SQL 查詢的結果。
// /data/model/CategorySum.kt (新檔案)
package com.yourcompany.cashflow.data.model
data class CategorySum(
val categoryName: String,
val amount: Double
)
B. 更新 TransactionDao.kt
加入我們最關鍵的數據聚合查詢方法。
// /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
// /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
// /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) 來呈現數據。
// /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
在右上角加入一個圖示按鈕,用來跳轉到統計頁面。
// /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
// /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 功能了嗎?