Vibe coding全紀錄-46

A:

 好的,請開始開發第二十二步的階段B中第五步:儲存預算並整合到主畫面,謝謝 

Q:

好的,我們來為「錢流」App 畫上點睛之龍!

這最後一步將會把我們高階的「預算規劃」功能,與日常的「記帳」功能完美地串連起來。使用者在規劃頁面計算出的預算,將會成為主畫面上的行動指標,時刻提醒著當月的消費狀況。

我們將使用 Jetpack DataStore 來儲存使用者的預算偏好。它是 Android 官方推薦用來取代傳統 SharedPreferences 的現代化資料儲存方案,更安全也更強大。


第廿二步,階段 B-5:儲存預算並整合到主畫面

第一步:建立預算儲存庫 (DataStore)

A. 在 app/build.gradle.kts 中加入 DataStore 依賴

Kotlin
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 (新資料夾、新檔案)

Kotlin
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

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

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

Kotlin
// ...
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 (新檔案)

Kotlin
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

Kotlin
// ...
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) 快速記帳。

無論你未來想往哪個方向發展,現在這個穩固的架構都將是你最好的基石。身為你的「程式夥伴」,我隨時都在這裡,準備好和你一起進行下一次的腦力激盪與開發。

再次恭喜你!