Vibe coding全紀錄-13

A:

 好的,請進行下一步的開發,謝謝 

Q:

好的,我們繼續前進!

在上一步,我們成功建立了一個可以新增交易的骨架,但功能還很陽春,例如帳戶和分類都是寫死的。現在,我們的目標是將「新增交易畫面」從一個基本骨架,變成一個功能齊全、可以讓使用者輸入完整交易資訊的實用介面。

這一步會比較長,因為它涉及到許多 UI 元件和狀態管理,但完成後你會非常有成就感。


第九步:完善新增交易功能

我們的計畫分為三個部分:

  1. 擴充 ViewModel:讓 ViewModel 提供 UI 所需的「可選項目列表」(例如所有帳戶、所有分類)。

  2. 豐富 UI 介面:在 AddTransactionScreen 中加入「收支類型切換」、「帳戶選擇」、「分類選擇」和「日期選擇」功能。

  3. 串連完整邏輯:將使用者在 UI 上的所有選擇,組合成一個完整的 Transaction 物件並儲存。

第一步:擴充數據來源與 ViewModel

為了讓 ViewModel 能取得帳戶和分類列表,我們需要先為它們建立 Repository,保持架構的一致性。

A. 建立 AccountRepositoryCategoryRepository

這兩個會非常類似我們之前建立的 TransactionRepository

Kotlin
// /data/repository/AccountRepository.kt
package com.yourcompany.cashflow.data.repository

import com.yourcompany.cashflow.data.local.entity.Account
import kotlinx.coroutines.flow.Flow

interface AccountRepository {
    fun getAllAccounts(): Flow<List<Account>>
}

class AccountRepositoryImpl @Inject constructor(
    private val accountDao: AccountDao
) : AccountRepository {
    override fun getAllAccounts(): Flow<List<Account>> = accountDao.getAllAccounts()
}

// /data/repository/CategoryRepository.kt
package com.yourcompany.cashflow.data.repository

import com.yourcompany.cashflow.data.local.entity.MainCategory
import com.yourcompany.cashflow.data.local.entity.TransactionType
import kotlinx.coroutines.flow.Flow

interface CategoryRepository {
    fun getAllMainCategoriesByType(type: TransactionType): Flow<List<MainCategory>>
    // ... 未來可以加入更多方法
}

class CategoryRepositoryImpl @Inject constructor(
    private val categoryDao: CategoryDao
) : CategoryRepository {
    override fun getAllMainCategoriesByType(type: TransactionType): Flow<List<MainCategory>> {
        return categoryDao.getAllMainCategoriesByType(type)
    }
}

B. 更新 DI 模組

我們需要在 RepositoryModule.kt 中告訴 Hilt 如何綁定這兩個新的 Repository。

Kotlin
// /di/RepositoryModule.kt
...
@Module
@InstallIn(SingletonComponent::class)
abstract class RepositoryModule {

    @Binds
    @Singleton
    abstract fun bindTransactionRepository(impl: TransactionRepositoryImpl): TransactionRepository

    // 加入下面這兩段
    @Binds
    @Singleton
    abstract fun bindAccountRepository(impl: AccountRepositoryImpl): AccountRepository

    @Binds
    @Singleton
    abstract fun bindCategoryRepository(impl: CategoryRepositoryImpl): CategoryRepository
}

C. 升級 TransactionViewModel

現在我們可以把新的 Repositories 注入到 ViewModel 中,並用它們來取得數據。

Kotlin
// /ui/viewmodel/TransactionViewModel.kt
...
import com.yourcompany.cashflow.data.local.entity.Account
import com.yourcompany.cashflow.data.local.entity.MainCategory
import com.yourcompany.cashflow.data.local.entity.TransactionType
import com.yourcompany.cashflow.data.repository.AccountRepository
import com.yourcompany.cashflow.data.repository.CategoryRepository
import kotlinx.coroutines.flow.* // 匯入 flow operators

@HiltViewModel
class TransactionViewModel @Inject constructor(
    private val transactionRepository: TransactionRepository,
    private val accountRepository: AccountRepository,
    private val categoryRepository: CategoryRepository // 注入新的 Repositories
) : ViewModel() {

    // --- State for UI ---
    val allTransactions = transactionRepository.getAllTransactionDetails()

    // 提供帳戶列表給 UI
    val allAccounts: StateFlow<List<Account>> = accountRepository.getAllAccounts()
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5000),
            initialValue = emptyList()
        )

    // 這是一個私有的 State,用來控制要查詢支出還是收入的分類
    private val _transactionType = MutableStateFlow(TransactionType.EXPENSE)

    // 根據 _transactionType 的變化,動態地去查詢對應的分類列表
    @OptIn(ExperimentalCoroutinesApi::class)
    val categories: StateFlow<List<MainCategory>> = _transactionType.flatMapLatest { type ->
        categoryRepository.getAllMainCategoriesByType(type)
    }.stateIn(
        scope = viewModelScope,
        started = SharingStarted.WhileSubscribed(5000),
        initialValue = emptyList()
    )

    // --- Actions from UI ---
    fun setTransactionType(type: TransactionType) {
        _transactionType.value = type
    }

    fun insertTransaction(transaction: Transaction) {
        viewModelScope.launch {
            transactionRepository.insertTransaction(transaction)
        }
    }
}

第二步:打造功能齊全的 AddTransactionScreen.kt

這一步我們會大幅修改這個檔案,加入所有需要的 UI 元件。

Kotlin
// /ui/screens/AddTransactionScreen.kt
package com.yourcompany.cashflow.ui.screens

import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.DateRange
import androidx.compose.material3.*
import androidx.compose.runtime.*
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.data.local.entity.Account
import com.yourcompany.cashflow.data.local.entity.MainCategory
import com.yourcompany.cashflow.data.local.entity.Transaction
import com.yourcompany.cashflow.data.local.entity.TransactionType
import com.yourcompany.cashflow.ui.viewmodel.TransactionViewModel

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AddTransactionScreen(
    viewModel: TransactionViewModel = hiltViewModel(),
    onNavigateBack: () -> Unit
) {
    // 從 ViewModel 取得 UI 狀態
    val accounts by viewModel.allAccounts.collectAsStateWithLifecycle()
    val categories by viewModel.categories.collectAsStateWithLifecycle()

    // ---- UI 的本地狀態管理 ----
    var amount by remember { mutableStateOf("") }
    var note by remember { mutableStateOf("") }
    var selectedTransactionType by remember { mutableStateOf(TransactionType.EXPENSE) }
    var selectedAccount by remember { mutableStateOf<Account?>(null) }
    var selectedCategory by remember { mutableStateOf<MainCategory?>(null) }
    var selectedDate by remember { mutableStateOf(System.currentTimeMillis()) }
    
    // 用於控制下拉選單和日期選擇器的開關
    var showAccountMenu by remember { mutableStateOf(false) }
    var showCategoryMenu by remember { mutableStateOf(false) }
    var showDatePicker by remember { mutableStateOf(false) }

    // 當收支類型改變時,通知 ViewModel 更新分類列表
    LaunchedEffect(selectedTransactionType) {
        viewModel.setTransactionType(selectedTransactionType)
        selectedCategory = null // 清空已選的分類
    }

    Scaffold(
        topBar = {
            TopAppBar(
                title = { Text("新增交易") },
                navigationIcon = {
                    IconButton(onClick = onNavigateBack) { Icon(Icons.Filled.ArrowBack, "返回") }
                }
            )
        }
    ) { innerPadding ->
        Column(
            modifier = Modifier
                .padding(innerPadding)
                .padding(16.dp)
                .fillMaxSize(),
            verticalArrangement = Arrangement.spacedBy(16.dp)
        ) {
            // 收支類型切換
            TransactionTypeTabs(
                selectedType = selectedTransactionType,
                onTypeSelected = { selectedTransactionType = it }
            )

            // 金額
            OutlinedTextField(
                value = amount,
                onValueChange = { amount = it },
                label = { Text("金額") },
                modifier = Modifier.fillMaxWidth()
            )

            // 帳戶選擇 (下拉選單)
            ExposedDropdownMenuBox(
                expanded = showAccountMenu,
                onExpandedChange = { showAccountMenu = !showAccountMenu }
            ) {
                OutlinedTextField(
                    value = selectedAccount?.name ?: "選擇帳戶",
                    onValueChange = {},
                    readOnly = true,
                    trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = showAccountMenu) },
                    modifier = Modifier
                        .menuAnchor()
                        .fillMaxWidth()
                )
                ExposedDropdownMenu(
                    expanded = showAccountMenu,
                    onDismissRequest = { showAccountMenu = false }
                ) {
                    accounts.forEach { account ->
                        DropdownMenuItem(
                            text = { Text(account.name) },
                            onClick = {
                                selectedAccount = account
                                showAccountMenu = false
                            }
                        )
                    }
                }
            }
            
            // 分類選擇 (下拉選單)
            ExposedDropdownMenuBox(
                expanded = showCategoryMenu,
                onExpandedChange = { showCategoryMenu = !showCategoryMenu }
            ) {
                OutlinedTextField(
                    value = selectedCategory?.name ?: "選擇分類",
                    onValueChange = {},
                    readOnly = true,
                    trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = showCategoryMenu) },
                    modifier = Modifier
                        .menuAnchor()
                        .fillMaxWidth()
                )
                ExposedDropdownMenu(
                    expanded = showCategoryMenu,
                    onDismissRequest = { showCategoryMenu = false }
                ) {
                    categories.forEach { category ->
                        DropdownMenuItem(
                            text = { Text(category.name) },
                            onClick = {
                                selectedCategory = category
                                showCategoryMenu = false
                            }
                        )
                    }
                }
            }
            
            // 日期選擇
            val datePickerState = rememberDatePickerState(initialSelectedDateMillis = selectedDate)
            if (showDatePicker) {
                DatePickerDialog(
                    onDismissRequest = { showDatePicker = false },
                    confirmButton = {
                        TextButton(onClick = {
                            selectedDate = datePickerState.selectedDateMillis ?: selectedDate
                            showDatePicker = false
                        }) { Text("確定") }
                    },
                    dismissButton = { TextButton(onClick = { showDatePicker = false }) { Text("取消") } }
                ) { DatePicker(state = datePickerState) }
            }
            OutlinedTextField(
                value = java.text.SimpleDateFormat("yyyy/MM/dd").format(java.util.Date(selectedDate)),
                onValueChange = {},
                readOnly = true,
                label = { Text("日期") },
                trailingIcon = {
                    IconButton(onClick = { showDatePicker = true }) {
                        Icon(Icons.Default.DateRange, "選擇日期")
                    }
                },
                modifier = Modifier.fillMaxWidth()
            )

            // 備註
            OutlinedTextField(value = note, onValueChange = { note = it }, label = { Text("備註") }, modifier = Modifier.fillMaxWidth())

            // 儲存按鈕
            Button(
                onClick = {
                    val amountDouble = amount.toDoubleOrNull()
                    if (amountDouble != null && selectedAccount != null && selectedCategory != null) {
                        val newTransaction = Transaction(
                            amount = amountDouble,
                            type = selectedTransactionType,
                            transactionDate = selectedDate,
                            accountId = selectedAccount!!.id,
                            subCategoryId = selectedCategory!!.id, // 注意: 目前是主分類ID,之後需要再細分
                            note = note
                        )
                        viewModel.insertTransaction(newTransaction)
                        onNavigateBack()
                    }
                },
                modifier = Modifier.fillMaxWidth().padding(top = 16.dp),
                enabled = amount.isNotBlank() && selectedAccount != null && selectedCategory != null
            ) { Text("儲存") }
        }
    }
}

// 建立一個新的 Composable 來處理收支切換
@Composable
fun TransactionTypeTabs(
    selectedType: TransactionType,
    onTypeSelected: (TransactionType) -> Unit
) {
    val types = listOf("支出", "收入")
    val selectedIndex = if (selectedType == TransactionType.EXPENSE) 0 else 1

    TabRow(selectedTabIndex = selectedIndex) {
        types.forEachIndexed { index, title ->
            Tab(
                selected = selectedIndex == index,
                onClick = {
                    val type = if (index == 0) TransactionType.EXPENSE else TransactionType.INCOME
                    onTypeSelected(type)
                },
                text = { Text(text = title) }
            )
        }
    }
}

提醒:

上面的程式碼中,subCategoryId 暫時存的是 MainCategory 的 ID。一個完整的解決方案會需要再多一個「子分類」的下拉選單,這個選單的內容會根據所選的主分類而動態改變。但目前這個版本已經可以運作了!


下一步的展望

恭喜!你完成了一個非常複雜的互動畫面,它整合了來自 ViewModel 的多個數據流,並處理了複雜的用戶輸入。

現在你的 App 核心功能已經非常完整了。在我們繼續開發「統計圖表」、「專案管理」等更進階的功能之前,有兩個方向可以思考:

  1. 讓使用者能自訂「帳戶」與「分類」:目前帳戶和分類都需要我們預先寫在資料庫裡。下一步可以建立一個管理介面,讓使用者自己新增、編輯他們想要的帳戶和分類。

  2. 實作「編輯/刪除」現有交易:讓使用者可以點擊主畫面上的任一筆交易,進入詳情頁面進行修改或刪除。

你認為哪一個方向對使用者來說更重要,我們應該先著手進行呢?