Vibe coding全紀錄-45

A:

 好的,請開始第二十二步的階段 B:使用者介面開發中的第四步:建立 BudgetPlanningScreen,謝謝 

Q:

好的,我們來進行開發的視覺化階段!這一步的目標是打造一個簡潔、互動性強的儀表板,讓使用者可以輕鬆地調整參數,並即時看到自己的未來財務規劃結果。

我們將會建立 BudgetPlanningScreen,並在其中放置幾個核心的 UI 元件。


第廿二步,階段 B-4:建立 BudgetPlanningScreen

第一步:建立可重複使用的參數滑桿 (ParameterSlider)

為了避免重複撰寫三個幾乎一樣的滑桿,我們先建立一個通用的 Composable。

路徑: app/src/main/java/com/yourcompany/cashflow/ui/components/ParameterSlider.kt (新檔案)

Kotlin
package com.yourcompany.cashflow.ui.components

import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.width
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Slider
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp

@Composable
fun ParameterSlider(
    label: String,
    value: Float,
    onValueChange: (Float) -> Unit,
    valueRange: ClosedFloatingPointRange<Float>,
    steps: Int,
    formatValue: (Float) -> String
) {
    Column {
        Row(
            modifier = Modifier.fillMaxWidth(),
            verticalAlignment = Alignment.CenterVertically
        ) {
            Text(label, style = MaterialTheme.typography.bodyLarge)
            Spacer(modifier = Modifier.weight(1f))
            Text(formatValue(value), style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Bold)
        }
        Slider(
            value = value,
            onValueChange = onValueChange,
            valueRange = valueRange,
            steps = steps
        )
    }
}

第二步:建立 BudgetPlanningScreen 主畫面

現在,我們來組合出完整的規劃頁面。

路徑: app/src/main/java/com/yourcompany/cashflow/ui/screens/BudgetPlanningScreen.kt (新檔案)

<details>

<summary><strong>點此展開 BudgetPlanningScreen.kt 的完整程式碼</strong></summary>

Kotlin
package com.yourcompany.cashflow.ui.screens

import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Info
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.yourcompany.cashflow.ui.components.ParameterSlider
import com.yourcompany.cashflow.ui.viewmodel.BudgetViewModel

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun BudgetPlanningScreen(
    viewModel: BudgetViewModel = hiltViewModel()
) {
    val plannedReturnRate by viewModel.plannedReturnRate.collectAsStateWithLifecycle()
    val inflationRate by viewModel.inflationRate.collectAsStateWithLifecycle()
    val remainingYears by viewModel.remainingYears.collectAsStateWithLifecycle()
    val annualBudget by viewModel.annualDisposableBudget.collectAsStateWithLifecycle()
    
    // 我們需要從 ViewModel 取得最新淨值來顯示
    // 假設已在 ViewModel 中加入 val latestNetWorth: StateFlow<NetWorth?>
    // val netWorth by viewModel.latestNetWorth.collectAsStateWithLifecycle()

    Scaffold(
        topBar = {
            TopAppBar(title = { Text("年度預算規劃") })
        }
    ) { innerPadding ->
        LazyColumn(
            modifier = Modifier.padding(innerPadding),
            contentPadding = PaddingValues(16.dp),
            verticalArrangement = Arrangement.spacedBy(16.dp)
        ) {
            // 項目一:結果顯示區
            item {
                ResultCard(
                    annualBudget = annualBudget,
                    // currentNetWorth = netWorth?.value ?: 0.0 // 傳入當前淨值
                )
            }

            // 項目二:參數調整區
            item {
                ParametersCard(
                    returnRate = plannedReturnRate,
                    onReturnRateChange = { viewModel.setPlannedReturnRate(it) },
                    inflationRate = inflationRate,
                    onInflationRateChange = { viewModel.setInflationRate(it) },
                    years = remainingYears.toFloat(),
                    onYearsChange = { viewModel.setRemainingYears(it.toInt()) }
                )
            }

            // 項目三:儲存按鈕
            item {
                Button(
                    onClick = { /* TODO: Step 22-C: Save budget to DataStore */ },
                    modifier = Modifier.fillMaxWidth()
                ) {
                    Text("儲存為本年度預算")
                }
            }
            
            // 項目四:提示說明
            item {
                Text(
                    "提示:此計算基於 PMT 模型,結果僅為財務參考,實際市場狀況可能有所不同。",
                    style = MaterialTheme.typography.bodySmall,
                    color = MaterialTheme.colorScheme.onSurfaceVariant
                )
            }
        }
    }
}


@Composable
fun ResultCard(annualBudget: Double, /* currentNetWorth: Double */) {
    Card(
        modifier = Modifier.fillMaxWidth(),
        elevation = CardDefaults.cardElevation(4.dp)
    ) {
        Column(
            modifier = Modifier.padding(16.dp),
            horizontalAlignment = Alignment.CenterHorizontally
        ) {
            Text("年度可支配預算", style = MaterialTheme.typography.titleMedium)
            Text(
                text = "NT$ ${"%,.0f".format(annualBudget)}",
                style = MaterialTheme.typography.displayMedium,
                fontWeight = FontWeight.Bold,
                color = MaterialTheme.colorScheme.primary
            )
            Spacer(modifier = Modifier.height(8.dp))
            Text(
                text = "每月平均 NT$ ${"%,.0f".format(annualBudget / 12)}",
                style = MaterialTheme.typography.bodyLarge
            )
            // Divider(modifier = Modifier.padding(vertical = 16.dp))
            // Text("計算基礎:當前總淨值 NT$ ${"%,.0f".format(currentNetWorth)}")
        }
    }
}

@Composable
fun ParametersCard(
    returnRate: Float, onReturnRateChange: (Float) -> Unit,
    inflationRate: Float, onInflationRateChange: (Float) -> Unit,
    years: Float, onYearsChange: (Float) -> Unit
) {
    Card(modifier = Modifier.fillMaxWidth()) {
        Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
            Text("規劃參數", style = MaterialTheme.typography.titleLarge)
            // 規劃報酬率滑桿
            ParameterSlider(
                label = "規劃報酬率",
                value = returnRate,
                onValueChange = onReturnRateChange,
                valueRange = 0f..0.15f, // 0% to 15%
                steps = 14, // 15 steps
                formatValue = { "%.1f %%".format(it * 100) }
            )
            // 通貨膨脹率滑桿
            ParameterSlider(
                label = "通貨膨脹率",
                value = inflationRate,
                onValueChange = onInflationRateChange,
                valueRange = 0f..0.05f, // 0% to 5%
                steps = 9, // 10 steps
                formatValue = { "%.1f %%".format(it * 100) }
            )
            // 剩餘年數滑桿
            ParameterSlider(
                label = "剩餘年數",
                value = years,
                onValueChange = onYearsChange,
                valueRange = 5f..50f, // 5 to 50 years
                steps = 44, // 45 steps
                formatValue = { "%.0f 年".format(it) }
            )
        }
    }
}

</details>

第三步:整合導覽,加入「規劃」分頁

現在 App 有了第四個主要功能,我們在底部導覽列為它加上一個專屬的入口。

A. 更新 BottomNavItem.kt

路徑: app/src/main/java/com/yourcompany/cashflow/ui/navigation/BottomNavItem.kt (假設你已將它移到新資料夾)

Kotlin
// ...
import androidx.compose.material.icons.filled.QueryStats // 匯入一個新圖示

sealed class BottomNavItem(val route: String, val icon: ImageVector, val title: String) {
    object Transactions : BottomNavItem(Routes.HOME, Icons.Default.List, "明細")
    object BalanceSheet : BottomNavItem(Routes.BALANCE_SHEET, Icons.Default.AccountBalance, "資產")
    object Planning : BottomNavItem(Routes.PLANNING, Icons.Default.QueryStats, "規劃") // <-- 新增項目
    object Settings : BottomNavItem(Routes.SETTINGS, Icons.Default.Settings, "設定")
}

B. 在 Routes.ktAppNavHost.kt 中註冊新畫面

  1. Routes 物件中新增 const val PLANNING = "planning"

  2. MainScreen.kt (或 AppNavHost.kt) 的 NavHost 中,加入新的 composable(BottomNavItem.Planning.route) { BudgetPlanningScreen() }

  3. BottomNavigationBar Composable 中,將 BottomNavItem.Planning 加入到 items 列表中。

執行與預期結果

當你完成以上步驟並執行 App:

  1. 你會在底部導覽列看到一個新的「規劃」分頁。

  2. 點擊進入後,會看到我們剛剛建立的儀表板。

  3. 最重要的是,你可以試著拖動滑桿,你會發現上方的「年度可支配預算」和「每月平均金額」會像施了魔法一樣,即時地跟著你的調整而變化!這完美地展示了我們從一開始就建立的「響應式」架構的強大威力。


UI 開發完成!

我們已經成功地為這個複雜的財務規劃功能,打造了一個直觀、互動的介面。

現在,只剩下最後的臨門一腳:實作「儲存」按鈕的邏輯,將使用者辛苦規劃出來的預算數字保存下來,並最終整合到我們的記帳主畫面上,讓它真正發揮「預算追蹤」的作用。準備好完成這最後的整合了嗎?