A:
好的,請進行下一步的開發,謝謝
Q:
好的,我們繼續前進!
在上一步,我們成功建立了一個可以新增交易的骨架,但功能還很陽春,例如帳戶和分類都是寫死的。現在,我們的目標是將「新增交易畫面」從一個基本骨架,變成一個功能齊全、可以讓使用者輸入完整交易資訊的實用介面。
這一步會比較長,因為它涉及到許多 UI 元件和狀態管理,但完成後你會非常有成就感。
第九步:完善新增交易功能
我們的計畫分為三個部分:
擴充 ViewModel:讓 ViewModel 提供 UI 所需的「可選項目列表」(例如所有帳戶、所有分類)。
豐富 UI 介面:在
AddTransactionScreen中加入「收支類型切換」、「帳戶選擇」、「分類選擇」和「日期選擇」功能。串連完整邏輯:將使用者在 UI 上的所有選擇,組合成一個完整的
Transaction物件並儲存。
第一步:擴充數據來源與 ViewModel
為了讓 ViewModel 能取得帳戶和分類列表,我們需要先為它們建立 Repository,保持架構的一致性。
A. 建立 AccountRepository 和 CategoryRepository
這兩個會非常類似我們之前建立的 TransactionRepository。
// /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。
// /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 中,並用它們來取得數據。
// /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 元件。
// /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 核心功能已經非常完整了。在我們繼續開發「統計圖表」、「專案管理」等更進階的功能之前,有兩個方向可以思考:
讓使用者能自訂「帳戶」與「分類」:目前帳戶和分類都需要我們預先寫在資料庫裡。下一步可以建立一個管理介面,讓使用者自己新增、編輯他們想要的帳戶和分類。
實作「編輯/刪除」現有交易:讓使用者可以點擊主畫面上的任一筆交易,進入詳情頁面進行修改或刪除。
你認為哪一個方向對使用者來說更重要,我們應該先著手進行呢?