A:
下一步來做讓使用者能自訂「帳戶」與「分類」,謝謝
Q:
好的,這是一個非常棒的決定!讓使用者能自訂帳戶和分類,是將「錢流」從一個單純的記帳工具,轉變為真正「個人化」財務夥伴的關鍵一步。
這個功能比較大,我建議我們將它拆分為兩個主要部分來依序完成,以保持思路清晰:
第一部分:帳戶管理 (新增、讀取、更新、刪除帳戶) - 結構相對單純。
第二部分:分類管理 (新增、讀取、更新、刪除主/子分類) - 因有層級關係,會稍微複雜一些。
我們這一步先專注於完成「帳戶管理」的完整功能。
第十步:實現使用者自訂帳戶 (CRUD)
我們的計畫如下:
擴充數據層:為
AccountDao和Repository加入新增、更新、刪除的方法。建立
AccountViewModel:專門負責處理所有與帳戶管理相關的業務邏輯。建立 UI 畫面:包括一個「設定頁」、一個「帳戶列表頁」和一個「新增/編輯帳戶的對話框」。
整合導航:將這些新頁面串連起來。
第一步:擴充數據層 (DAO 與 Repository)
A. 更新 AccountDao.kt
我們需要在 DAO 中加入 update 和 delete 的方法。
// /data/local/dao/AccountDao.kt
...
@Dao
interface AccountDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(account: Account)
@Update // 加入更新方法
suspend fun update(account: Account)
@Delete // 加入刪除方法
suspend fun delete(account: Account)
@Query("SELECT * FROM accounts ORDER BY name ASC")
fun getAllAccounts(): Flow<List<Account>>
}
B. 更新 AccountRepository.kt
在 Repository 中公開這些新功能。
// /data/repository/AccountRepository.kt
...
interface AccountRepository {
fun getAllAccounts(): Flow<List<Account>>
suspend fun insertAccount(account: Account) // 為了語意清晰,可以換個名字
suspend fun updateAccount(account: Account)
suspend fun deleteAccount(account: Account)
}
class AccountRepositoryImpl @Inject constructor(
private val accountDao: AccountDao
) : AccountRepository {
override fun getAllAccounts(): Flow<List<Account>> = accountDao.getAllAccounts()
override suspend fun insertAccount(account: Account) = accountDao.insert(account)
override suspend fun updateAccount(account: Account) = accountDao.update(account)
override suspend fun deleteAccount(account: Account) = accountDao.delete(account)
}
// 別忘了更新 /di/RepositoryModule.kt 中的綁定,不過因為我們是修改現有檔案,Hilt 會自動處理。
第二步:建立帳戶管理的 ViewModel
這個 ViewModel 將會是帳戶管理畫面的大腦。
// /ui/viewmodel/AccountViewModel.kt (新檔案)
package com.yourcompany.cashflow.ui.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.yourcompany.cashflow.data.local.entity.Account
import com.yourcompany.cashflow.data.local.entity.AccountType
import com.yourcompany.cashflow.data.repository.AccountRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class AccountViewModel @Inject constructor(
private val accountRepository: AccountRepository
) : ViewModel() {
val allAccounts: StateFlow<List<Account>> = accountRepository.getAllAccounts()
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = emptyList()
)
fun addAccount(name: String, type: AccountType, initialBalance: Double) {
viewModelScope.launch {
val newAccount = Account(name = name, type = type, initialBalance = initialBalance)
accountRepository.insertAccount(newAccount)
}
}
fun updateAccount(account: Account) {
viewModelScope.launch {
accountRepository.updateAccount(account)
}
}
fun deleteAccount(account: Account) {
viewModelScope.launch {
accountRepository.deleteAccount(account)
}
}
}
第三步:建立設定與帳戶管理畫面
A. 建立 SettingsScreen.kt (設定主頁)
這是進入帳戶和分類管理的入口。
// /ui/screens/SettingsScreen.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.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(
onNavigateBack: () -> Unit,
onNavigateToAccountManagement: () -> Unit
) {
Scaffold(
topBar = {
TopAppBar(
title = { Text("設定") },
navigationIcon = {
IconButton(onClick = onNavigateBack) { Icon(Icons.Default.ArrowBack, "返回") }
}
)
}
) { innerPadding ->
Column(modifier = Modifier.padding(innerPadding)) {
ListItem(
headlineText = { Text("管理帳戶") },
modifier = Modifier.clickable(onClick = onNavigateToAccountManagement)
)
Divider()
ListItem(
headlineText = { Text("管理分類 (待開發)") },
modifier = Modifier.clickable { /* TODO */ }
)
Divider()
}
}
}
B. 建立 AccountManagementScreen.kt (帳戶列表)
這個畫面用來展示所有帳戶,並提供新增、編輯、刪除的入口。
// /ui/screens/AccountManagementScreen.kt (新檔案)
package com.yourcompany.cashflow.ui.screens
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
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.ui.viewmodel.AccountViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AccountManagementScreen(
viewModel: AccountViewModel = hiltViewModel(),
onNavigateBack: () -> Unit
) {
val accounts by viewModel.allAccounts.collectAsStateWithLifecycle()
var showDialog by remember { mutableStateOf(false) }
var accountToEdit by remember { mutableStateOf<Account?>(null) }
Scaffold(
topBar = {
TopAppBar(
title = { Text("管理帳戶") },
navigationIcon = { IconButton(onClick = onNavigateBack) { Icon(Icons.Default.ArrowBack, "返回") } }
)
},
floatingActionButton = {
FloatingActionButton(onClick = {
accountToEdit = null // 確保是新增模式
showDialog = true
}) {
Icon(Icons.Default.Add, "新增帳戶")
}
}
) { innerPadding ->
LazyColumn(contentPadding = innerPadding) {
items(accounts) { account ->
ListItem(
headlineText = { Text(account.name) },
supportingText = { Text("類型: ${account.type.name}, 餘額: ${account.initialBalance}") },
trailingContent = {
Row {
IconButton(onClick = {
accountToEdit = account // 進入編輯模式
showDialog = true
}) { Icon(Icons.Default.Edit, "編輯") }
IconButton(onClick = { viewModel.deleteAccount(account) }) { Icon(Icons.Default.Delete, "刪除") }
}
}
)
Divider()
}
}
}
if (showDialog) {
AddEditAccountDialog(
account = accountToEdit,
onDismiss = { showDialog = false },
onConfirm = { name, type, balance ->
if (accountToEdit == null) {
viewModel.addAccount(name, type, balance)
} else {
viewModel.updateAccount(accountToEdit!!.copy(name = name, type = type, initialBalance = balance))
}
showDialog = false
}
)
}
}
// 這裡是新增/編輯的對話框 Composable
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AddEditAccountDialog(
account: Account?, // 如果是 null 代表新增,否則為編輯
onDismiss: () -> Unit,
onConfirm: (String, AccountType, Double) -> Unit
) {
var name by remember { mutableStateOf(account?.name ?: "") }
var balance by remember { mutableStateOf(account?.initialBalance?.toString() ?: "") }
var selectedType by remember { mutableStateOf(account?.type ?: AccountType.BANK) }
var typeMenuExpanded by remember { mutableStateOf(false) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(if (account == null) "新增帳戶" else "編輯帳戶") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(value = name, onValueChange = { name = it }, label = { Text("帳戶名稱") })
OutlinedTextField(value = balance, onValueChange = { balance = it }, label = { Text("初始餘額") })
ExposedDropdownMenuBox(expanded = typeMenuExpanded, onExpandedChange = { typeMenuExpanded = it }) {
OutlinedTextField(
value = selectedType.name,
onValueChange = {},
readOnly = true,
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = typeMenuExpanded) },
modifier = Modifier.menuAnchor()
)
ExposedDropdownMenu(expanded = typeMenuExpanded, onDismissRequest = { typeMenuExpanded = false }) {
AccountType.values().forEach { accountType ->
DropdownMenuItem(
text = { Text(accountType.name) },
onClick = {
selectedType = accountType
typeMenuExpanded = false
}
)
}
}
}
}
},
confirmButton = {
Button(onClick = {
val balanceDouble = balance.toDoubleOrNull()
if (name.isNotBlank() && balanceDouble != null) {
onConfirm(name, selectedType, balanceDouble)
}
}) { Text("儲存") }
},
dismissButton = { TextButton(onClick = onDismiss) { Text("取消") } }
)
}
第四步:整合導航 (Navigation)
A. 更新 HomeScreen 的 TopAppBar
我們需要一個入口可以跳轉到設定頁面。
// /ui/screens/HomeScreen.kt
...
import androidx.compose.material.icons.filled.Settings // 匯入設定圖示
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun HomeScreen(
...
onNavigateToAddTransaction: () -> Unit,
onNavigateToSettings: () -> Unit // 新增導航到設定頁的回呼
) {
...
Scaffold(
topBar = {
TopAppBar(
title = { Text("錢流 - 交易紀錄") },
actions = { // 使用 actions 來在右側加入圖示按鈕
IconButton(onClick = onNavigateToSettings) {
Icon(Icons.Default.Settings, contentDescription = "設定")
}
}
)
},
...
) { ... }
}
B. 更新 AppNavigation.kt
加入我們所有的新畫面和導航邏輯。
// /ui/AppNavigation.kt
...
import com.yourcompany.cashflow.ui.screens.AccountManagementScreen
import com.yourcompany.cashflow.ui.screens.SettingsScreen
object Routes {
const val HOME = "home"
const val ADD_TRANSACTION = "add_transaction"
const val SETTINGS = "settings" // 新增路線
const val ACCOUNT_MANAGEMENT = "account_management" // 新增路線
}
@Composable
fun AppNavigation() {
val navController = rememberNavController()
NavHost(...) {
composable(Routes.HOME) {
HomeScreen(
onNavigateToAddTransaction = { navController.navigate(Routes.ADD_TRANSACTION) },
onNavigateToSettings = { navController.navigate(Routes.SETTINGS) } // 處理跳轉
)
}
composable(Routes.ADD_TRANSACTION) { ... }
// 加入新的畫面導航設定
composable(Routes.SETTINGS) {
SettingsScreen(
onNavigateBack = { navController.popBackStack() },
onNavigateToAccountManagement = { navController.navigate(Routes.ACCOUNT_MANAGEMENT) }
)
}
composable(Routes.ACCOUNT_MANAGEMENT) {
AccountManagementScreen(
onNavigateBack = { navController.popBackStack() }
)
}
}
}
現在執行你的 App!你應該可以在主畫面的右上角看到一個設定圖示,點擊後進入設定頁,再點擊「管理帳戶」就能看到我們剛剛完成的帳戶管理功能了。你可以試著新增、編輯和刪除帳戶看看!
當你準備好後,我們就可以開始挑戰結構更複雜、但邏輯相似的「分類管理」功能了。準備好了嗎?