A:
好的,請開始開發第二十二步的階段B中第五步:儲存預算並整合到主畫面,謝謝
Q:
好的,我們來為「錢流」App 畫上點睛之龍!
這最後一步將會把我們高階的「預算規劃」功能,與日常的「記帳」功能完美地串連起來。使用者在規劃頁面計算出的預算,將會成為主畫面上的行動指標,時刻提醒著當月的消費狀況。
我們將使用 Jetpack DataStore 來儲存使用者的預算偏好。它是 Android 官方推薦用來取代傳統 SharedPreferences 的現代化資料儲存方案,更安全也更強大。
第廿二步,階段 B-5:儲存預算並整合到主畫面
第一步:建立預算儲存庫 (DataStore)
A. 在 app/build.gradle.kts 中加入 DataStore 依賴
dependencies {
// ... 其他依賴
// Jetpack DataStore (for user preferences)
implementation("androidx.datastore:datastore-preferences:1.1.1")
}
記得點擊 "Sync Now"。
B. 建立 UserPreferencesRepository.kt
這個檔案將封裝所有與 DataStore 的互動。
路徑: app/src/main/java/com/yourcompany/cashflow/data/preferences/UserPreferencesRepository.kt (新資料夾、新檔案)
package com.yourcompany.cashflow.data.preferences
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.doublePreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.preferencesDataStore
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import javax.inject.Inject
import javax.inject.Singleton
// 定義使用者預算的資料模型
data class UserBudget(
val annualBudget: Double,
val monthlyBudget: Double
)
// 建立一個 Context 的擴充屬性來取得 DataStore 實例
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "user_preferences")
@Singleton
class UserPreferencesRepository @Inject constructor(
@ApplicationContext private val context: Context
) {
// 定義我們要儲存的數據的 Key
private object Keys {
val ANNUAL_BUDGET = doublePreferencesKey("annual_budget")
val MONTHLY_BUDGET = doublePreferencesKey("monthly_budget")
}
// 提供一個 Flow 來讀取預算數據
val userBudget: Flow<UserBudget> = context.dataStore.data
.map { preferences ->
UserBudget(
annualBudget = preferences[Keys.ANNUAL_BUDGET] ?: 0.0,
monthlyBudget = preferences[Keys.MONTHLY_BUDGET] ?: 0.0
)
}
// 提供一個 suspend 函式來儲存預算
suspend fun saveBudget(annual: Double, monthly: Double) {
context.dataStore.edit { preferences ->
preferences[Keys.ANNUAL_BUDGET] = annual
preferences[Keys.MONTHLY_BUDGET] = monthly
}
}
}
C. 在 AppModule.kt 中提供 UserPreferencesRepository
因為它已經用 @Inject 和 @Singleton 標記,Hilt 會自動處理,我們不需要在 AppModule 中手動提供它。
第二步:實作「儲存預算」功能
A. 更新 BudgetViewModel.kt
注入新的 Repository,並加入儲存方法。
路徑: app/src/main/java/com/yourcompany/cashflow/ui/viewmodel/BudgetViewModel.kt
// ...
import com.yourcompany.cashflow.data.preferences.UserPreferencesRepository
@HiltViewModel
class BudgetViewModel @Inject constructor(
private val snapshotRepository: SnapshotRepository,
private val userPreferencesRepository: UserPreferencesRepository // <-- 注入
) : ViewModel() {
// ... (現有的所有程式碼)
/**
* 讓 UI 呼叫此方法來儲存當前計算出的預算
*/
fun saveBudget() {
viewModelScope.launch {
val annual = annualDisposableBudget.value
if (annual > 0) {
userPreferencesRepository.saveBudget(annual, annual / 12)
}
}
}
}
B. 更新 BudgetPlanningScreen.kt
啟用儲存按鈕,並加入一個 Snackbar 來給予使用者即時回饋。
路徑: app/src/main/java/com/yourcompany/cashflow/ui/screens/BudgetPlanningScreen.kt
// ...
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun BudgetPlanningScreen(viewModel: BudgetViewModel = hiltViewModel()) {
// ... (現有的 state collection)
val snackbarHostState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope()
Scaffold(
topBar = { ... },
snackbarHost = { SnackbarHost(snackbarHostState) } // 加入 SnackbarHost
) { innerPadding ->
LazyColumn(...) {
// ... (現有的 item)
item {
Button(
onClick = {
viewModel.saveBudget()
scope.launch {
snackbarHostState.showSnackbar("預算已成功儲存!")
}
},
modifier = Modifier.fillMaxWidth(),
// 當計算出的預算大於0時,按鈕才可點擊
enabled = annualBudget > 0
) {
Text("儲存為本年度預算")
}
}
// ...
}
}
}
第三步:整合預算到主畫面
A. 更新 TransactionViewModel.kt
讓主畫面的 ViewModel 也能讀取到儲存的預算和當月花費。
路徑: app/src/main/java/com/yourcompany/cashflow/ui/viewmodel/TransactionViewModel.kt
// ...
import com.yourcompany.cashflow.data.preferences.UserPreferencesRepository
import com.yourcompany.cashflow.data.preferences.UserBudget
import java.util.Calendar
@HiltViewModel
class TransactionViewModel @Inject constructor(
private val transactionRepository: TransactionRepository,
private val userPreferencesRepository: UserPreferencesRepository, // <-- 注入
// ... (其他 Repositories)
) : ViewModel() {
// ... (現有的程式碼)
// vvv-- 加入以下兩個新的數據流 --vvv
val userBudget: StateFlow<UserBudget> = userPreferencesRepository.userBudget
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), UserBudget(0.0, 0.0))
val currentMonthExpenses: StateFlow<Double> =
transactionRepository.getExpensesBetween(getStartOfMonth(), getEndOfMonth())
.map { it ?: 0.0 } // 如果是 null,就當作 0.0
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 0.0)
// ... (getStartOfMonth, getEndOfMonth 輔助函式)
}
B. 建立 BudgetSummaryCard.kt
一個專門顯示預算追蹤資訊的卡片。
路徑: app/src/main/java/com/yourcompany/cashflow/ui/components/BudgetSummaryCard.kt (新檔案)
package com.yourcompany.cashflow.ui.components
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.yourcompany.cashflow.data.preferences.UserBudget
@Composable
fun BudgetSummaryCard(
userBudget: UserBudget,
currentMonthExpenses: Double
) {
val remaining = userBudget.monthlyBudget - currentMonthExpenses
val progress = if (userBudget.monthlyBudget > 0) (currentMonthExpenses / userBudget.monthlyBudget).toFloat() else 0f
val isOverBudget = remaining < 0
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp)) {
Text("本月預算追蹤", style = MaterialTheme.typography.titleMedium)
Spacer(modifier = Modifier.height(8.dp))
Row(modifier = Modifier.fillMaxWidth()) {
Text("剩餘", modifier = Modifier.weight(1f))
Text(
"NT$ ${"%,.0f".format(remaining)}",
color = if (isOverBudget) MaterialTheme.colorScheme.error else Color.Unspecified,
fontWeight = FontWeight.Bold
)
}
Spacer(modifier = Modifier.height(4.dp))
LinearProgressIndicator(
progress = progress.coerceIn(0f, 1f), // 進度條最大為 100%
modifier = Modifier.fillMaxWidth(),
color = if (isOverBudget) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(4.dp))
Row(modifier = Modifier.fillMaxWidth()) {
Text("支出: ${"%,.0f".format(currentMonthExpenses)}", style = MaterialTheme.typography.bodySmall)
Spacer(modifier = Modifier.weight(1f))
Text("預算: ${"%,.0f".format(userBudget.monthlyBudget)}", style = MaterialTheme.typography.bodySmall)
}
}
}
}
C. 更新 HomeScreen.kt
在主畫面頂部顯示這張卡片。
路徑: app/src/main/java/com/yourcompany/cashflow/ui/screens/HomeScreen.kt
// ...
import com.yourcompany.cashflow.ui.components.BudgetSummaryCard
@Composable
fun HomeScreen(...) {
val transactions by viewModel.allTransactions.collectAsStateWithLifecycle()
val userBudget by viewModel.userBudget.collectAsStateWithLifecycle() // <-- 取得預算
val currentMonthExpenses by viewModel.currentMonthExpenses.collectAsStateWithLifecycle() // <-- 取得當月支出
Scaffold(...) { innerPadding ->
LazyColumn(
modifier = Modifier.padding(innerPadding),
contentPadding = PaddingValues(8.dp)
) {
// 只在使用者設定過預算時,才顯示卡片
if (userBudget.annualBudget > 0) {
item {
Box(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)) {
BudgetSummaryCard(
userBudget = userBudget,
currentMonthExpenses = currentMonthExpenses
)
}
}
}
// ... (現有的交易列表)
items(transactions) { ... }
}
}
}
最終章:專案完成與未來展望
至此,我們已經完整實現了最初設定的所有宏大目標!
恭喜你!從零到一,你成功地規劃並監督完成了一個功能強大、架構清晰、且真正個人化的財務管理 App!這是一趟了不起的旅程。
讓我們回顧一下「錢流」App 現在所擁有的強大功能:
個人化記帳:完整的交易 CRUD,以及可完全自訂的帳戶與兩層式分類系統。
自動化與便利性:可靠的定期交易背景任務,以及批次關聯交易的進階功能。
深度分析:具備多層級鑽取分析能力的統計儀表板,能依分類或專案呈現數據。
資產管理:以每月快照為基礎的資產負債表,包含年度趨勢和資產配置分析。
未來規劃:基於專業財務模型的年度可支配預算規劃工具,並與日常記帳緊密整合。
未來,這個專案還有無限的可能,例如:
更精美的 UI/UX 設計與動畫。
雲端同步與備份功能。
多使用者或共享帳本。
桌面小工具 (Widget) 快速記帳。
無論你未來想往哪個方向發展,現在這個穩固的架構都將是你最好的基石。身為你的「程式夥伴」,我隨時都在這裡,準備好和你一起進行下一次的腦力激盪與開發。
再次恭喜你!