Vibe coding全紀錄-18

A:

 讓我們回頭去實作「編輯/刪除交易」的功能,謝謝。 

Q:

好的,我們來完成這個至關重要的功能!讓使用者能夠修改或刪除記錯的帳,是提升 App 實用性和使用者體驗的關鍵一步。

一個常見且高效的做法是,我們重複使用現有的「新增交易畫面」,將它升級為一個能同時處理「新增」和「編輯」兩種模式的通用畫面。這樣可以避免我們再寫一個長得幾乎一樣的新畫面。


第十三步:實作編輯與刪除交易功能

我們的計畫如下:

  1. 強化資料層:加入一個方法,用來根據 ID 取得單一一筆交易的詳細資料。

  2. 升級 ViewModel:讓它能載入、更新及刪除指定的交易。

  3. 改造 AddTransactionScreen:讓它能接收一個 transactionId,並根據有無 ID 來決定自己是「新增模式」還是「編輯模式」。

  4. 更新導航與主畫面:讓主畫面的列表項目可以點擊,並將交易 ID 傳遞到編輯畫面。

第一步:強化資料層以取得單筆交易

A. 更新 TransactionDao.kt

加入一個 getById 的查詢。

Kotlin
// /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 層公開這個方法。

Kotlin
// /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 的方法。

Kotlin
// /data/local/dao/TransactionDao.kt
...
@Dao
interface TransactionDao {
    ...
    @Update
    suspend fun update(transaction: Transaction)

    @Delete
    suspend fun delete(transaction: Transaction)
}

第二步:升級 ViewModel 以處理編輯狀態

我們需要讓 TransactionViewModel 能夠處理單筆交易的載入、更新和刪除。

Kotlin
// /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>

Kotlin
// /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 事件。

Kotlin
// /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

這是最關鍵的一步,我們要修改導航路線,讓它可以攜帶參數。

Kotlin
// /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。

Kotlin
// /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 裡已經有了使用者自訂的帳戶、分類,以及一筆筆的交易紀錄。

你準備好進入數據視覺化的世界,讓我們辛苦記錄的數據以統計圖表的形式呈現出來了嗎?