A:
讓我們回頭去實作「編輯/刪除交易」的功能,謝謝。
Q:
好的,我們來完成這個至關重要的功能!讓使用者能夠修改或刪除記錯的帳,是提升 App 實用性和使用者體驗的關鍵一步。
一個常見且高效的做法是,我們重複使用現有的「新增交易畫面」,將它升級為一個能同時處理「新增」和「編輯」兩種模式的通用畫面。這樣可以避免我們再寫一個長得幾乎一樣的新畫面。
第十三步:實作編輯與刪除交易功能
我們的計畫如下:
強化資料層:加入一個方法,用來根據 ID 取得單一一筆交易的詳細資料。
升級
ViewModel:讓它能載入、更新及刪除指定的交易。改造
AddTransactionScreen:讓它能接收一個transactionId,並根據有無 ID 來決定自己是「新增模式」還是「編輯模式」。更新導航與主畫面:讓主畫面的列表項目可以點擊,並將交易 ID 傳遞到編輯畫面。
第一步:強化資料層以取得單筆交易
A. 更新 TransactionDao.kt
加入一個 getById 的查詢。
// /data/local/dao/TransactionDao.kt
...
@Dao
interface TransactionDao {
...
@androidx.room.Transaction
@Query("SELECT * FROM transactions WHERE id = :id")
fun getTransactionDetailsById(id: Long): Flow<TransactionWithDetails?> // 回傳 Flow,當資料更新時 UI 可即時反應
}
B. 更新 TransactionRepository.kt
在 Repository 層公開這個方法。
// /data/repository/TransactionRepository.kt
...
interface TransactionRepository {
fun getAllTransactionDetails(): Flow<List<TransactionWithDetails>>
fun getTransactionDetailsById(id: Long): Flow<TransactionWithDetails?> // 新增方法
suspend fun insertTransaction(transaction: Transaction)
suspend fun updateTransaction(transaction: Transaction) // 新增方法
suspend fun deleteTransaction(transaction: Transaction) // 新增方法
}
class TransactionRepositoryImpl @Inject constructor(
private val transactionDao: TransactionDao
) : TransactionRepository {
...
override fun getTransactionDetailsById(id: Long): Flow<TransactionWithDetails?> = transactionDao.getTransactionDetailsById(id)
override suspend fun insertTransaction(transaction: Transaction) = transactionDao.insert(transaction)
override suspend fun updateTransaction(transaction: Transaction) = transactionDao.update(transaction)
override suspend fun deleteTransaction(transaction: Transaction) = transactionDao.delete(transaction)
}
C. 更新 TransactionDao.kt
我們還需要在 DAO 中加入 update 和 delete 的方法。
// /data/local/dao/TransactionDao.kt
...
@Dao
interface TransactionDao {
...
@Update
suspend fun update(transaction: Transaction)
@Delete
suspend fun delete(transaction: Transaction)
}
第二步:升級 ViewModel 以處理編輯狀態
我們需要讓 TransactionViewModel 能夠處理單筆交易的載入、更新和刪除。
// /ui/viewmodel/TransactionViewModel.kt
...
import com.yourcompany.cashflow.data.model.TransactionWithDetails
import kotlinx.coroutines.flow.*
@HiltViewModel
class TransactionViewModel @Inject constructor(...) : ViewModel() {
...
// 用來持有當前正在編輯的交易資料
private val _selectedTransaction = MutableStateFlow<TransactionWithDetails?>(null)
val selectedTransaction: StateFlow<TransactionWithDetails?> = _selectedTransaction.asStateFlow()
// --- Actions from UI ---
/**
* 當進入編輯模式時,根據 ID 載入交易資料
*/
fun loadTransaction(id: Long) {
if (id == 0L) { // 0L 代表是新增模式,不是有效的 ID
_selectedTransaction.value = null
return
}
viewModelScope.launch {
transactionRepository.getTransactionDetailsById(id).collect { transaction ->
_selectedTransaction.value = transaction
}
}
}
fun updateTransaction(transaction: Transaction) = viewModelScope.launch {
transactionRepository.updateTransaction(transaction)
}
fun deleteTransaction(transaction: Transaction) = viewModelScope.launch {
transactionRepository.deleteTransaction(transaction)
}
...
}
第三步:改造「新增/編輯交易」畫面
現在,我們要讓 AddTransactionScreen 變得更聰明。
<details>
<summary>點此展開更新後的 AddTransactionScreen.kt 程式碼</summary>
// /ui/screens/AddTransactionScreen.kt
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AddTransactionScreen(
transactionId: Long, // 接收傳入的 ID
viewModel: TransactionViewModel = hiltViewModel(),
onNavigateBack: () -> Unit
) {
// 從 ViewModel 取得 UI 狀態
val accounts by viewModel.allAccounts.collectAsStateWithLifecycle()
val categories by viewModel.categories.collectAsStateWithLifecycle()
val selectedTransaction by viewModel.selectedTransaction.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 showDeleteConfirmDialog by remember { mutableStateOf(false) }
val isEditMode = transactionId != 0L
// 當 transactionId 改變時 (即進入畫面時),觸發 ViewModel 載入資料
LaunchedEffect(key1 = transactionId) {
viewModel.loadTransaction(transactionId)
}
// 當 ViewModel 的 selectedTransaction 載入成功後,更新 UI 上的所有狀態
LaunchedEffect(key1 = selectedTransaction) {
if (isEditMode && selectedTransaction != null) {
val details = selectedTransaction!!
amount = details.transaction.amount.toString()
note = details.transaction.note ?: ""
selectedTransactionType = details.transaction.type
selectedAccount = details.account
// 注意:這裡需要從 categories 列表找到對應的 MainCategory 物件
// selectedCategory = categories.find { it.id == details.subCategory.mainCategoryId }
selectedDate = details.transaction.transactionDate
}
}
// ... (其他 UI 元件的狀態管理)
Scaffold(
topBar = {
TopAppBar(
title = { Text(if (isEditMode) "編輯交易" else "新增交易") },
navigationIcon = {
IconButton(onClick = onNavigateBack) { Icon(Icons.Filled.ArrowBack, "返回") }
},
actions = {
// 只在編輯模式下顯示刪除按鈕
if (isEditMode) {
IconButton(onClick = { showDeleteConfirmDialog = true }) {
Icon(Icons.Default.Delete, contentDescription = "刪除")
}
}
}
)
}
) { innerPadding ->
Column(...) {
// ... (所有 OutlinedTextField, DropdownMenu 等 UI 元件保持不變)
// 儲存按鈕
Button(
onClick = {
val amountDouble = amount.toDoubleOrNull()
if (amountDouble != null && selectedAccount != null && selectedCategory != null) {
if (isEditMode) {
// 更新模式
val updatedTransaction = selectedTransaction!!.transaction.copy(
amount = amountDouble,
type = selectedTransactionType,
transactionDate = selectedDate,
accountId = selectedAccount!!.id,
subCategoryId = selectedCategory!!.id,
note = note
)
viewModel.updateTransaction(updatedTransaction)
} else {
// 新增模式
val newTransaction = Transaction(...)
viewModel.insertTransaction(newTransaction)
}
onNavigateBack()
}
},
...
) { Text("儲存") }
}
}
// 刪除確認對話框
if (showDeleteConfirmDialog) {
AlertDialog(
onDismissRequest = { showDeleteConfirmDialog = false },
title = { Text("確認刪除") },
text = { Text("您確定要刪除這筆交易嗎?此操作無法復原。") },
confirmButton = {
Button(
onClick = {
viewModel.deleteTransaction(selectedTransaction!!.transaction)
showDeleteConfirmDialog = false
onNavigateBack()
},
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error)
) { Text("刪除") }
},
dismissButton = {
TextButton(onClick = { showDeleteConfirmDialog = false }) { Text("取消") }
}
)
}
}
</details>
第四步:更新導航與主畫面
A. 讓 HomeScreen 的列表項目可以點擊
我們需要修改 TransactionItem 和 TransactionList 來接收一個 onClick 事件。
// /ui/components/TransactionItem.kt
@Composable
fun TransactionItem(
transactionDetails: TransactionWithDetails,
onClick: () -> Unit // 新增 onClick 回呼
) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp, horizontal = 8.dp)
.clickable(onClick = onClick), // 讓卡片可以點擊
...
) { ... }
}
// /ui/components/TransactionList.kt
@Composable
fun TransactionList(
transactions: List<TransactionWithDetails>,
onTransactionClick: (Long) -> Unit // 新增回呼,傳遞交易 ID
) {
...
LazyColumn(...) {
items(transactions) { transactionDetail ->
TransactionItem(
transactionDetails = transactionDetail,
onClick = { onTransactionClick(transactionDetail.transaction.id) } // 呼叫回呼
)
}
}
}
B. 更新 AppNavigation.kt
這是最關鍵的一步,我們要修改導航路線,讓它可以攜帶參數。
// /ui/AppNavigation.kt
...
import androidx.navigation.NavType
import androidx.navigation.navArgument
object Routes {
// 將 add_transaction 改為更通用的名稱
const val TRANSACTION_SCREEN = "transaction_screen"
...
}
@Composable
fun AppNavigation() {
val navController = rememberNavController()
NavHost(
navController = navController,
startDestination = Routes.HOME
) {
composable(Routes.HOME) {
HomeScreen(
onNavigateToTransaction = { transactionId ->
// 根據有無 ID 決定是新增還是編輯
if (transactionId == 0L) {
navController.navigate(Routes.TRANSACTION_SCREEN)
} else {
navController.navigate("${Routes.TRANSACTION_SCREEN}?transactionId=$transactionId")
}
},
onNavigateToSettings = { navController.navigate(Routes.SETTINGS) }
)
}
// 修改原本的導航設定
composable(
route = "${Routes.TRANSACTION_SCREEN}?transactionId={transactionId}",
arguments = listOf(navArgument("transactionId") {
type = NavType.LongType
defaultValue = 0L // 如果沒傳入 ID,預設值為 0,代表新增
})
) { backStackEntry ->
// 從 backStackEntry 取得 transactionId
val transactionId = backStackEntry.arguments?.getLong("transactionId") ?: 0L
AddTransactionScreen(
transactionId = transactionId,
onNavigateBack = { navController.popBackStack() }
)
}
...
}
}
C. 最後更新 HomeScreen.kt
將 onNavigateToAddTransaction 換成我們新的 onNavigateToTransaction。
// /ui/screens/HomeScreen.kt
...
@Composable
fun HomeScreen(
...
onNavigateToTransaction: (Long) -> Unit, // 更新回呼
onNavigateToSettings: () -> Unit
) {
...
Scaffold(
...
floatingActionButton = {
FloatingActionButton(onClick = { onNavigateToTransaction(0L) }) { // 傳入 0L 代表新增
...
}
}
) { innerPadding ->
TransactionList(
transactions = transactions,
onTransactionClick = onNavigateToTransaction, // 將點擊事件傳下去
modifier = Modifier.padding(innerPadding)
)
}
}
功能完成!
現在,執行你的 App:
點擊右下角的
+按鈕,會進入「新增模式」。點擊列表中的任何一筆交易,會帶著該筆交易的 ID 進入「編輯模式」,畫面上會預先填好該筆交易的資料。
在編輯模式下,右上角會出現刪除按鈕,點擊後會跳出確認對話框。
App 的核心資料操作 (CRUD) 功能至此已全部完備!我們的 App 變得非常強大且實用。
接下來,我們終於可以進入最有趣的階段了!現在 App 裡已經有了使用者自訂的帳戶、分類,以及一筆筆的交易紀錄。
你準備好進入數據視覺化的世界,讓我們辛苦記錄的數據以統計圖表的形式呈現出來了嗎?