Vibe coding全紀錄-14

A:

 下一步來做讓使用者能自訂「帳戶」與「分類」,謝謝 

Q:

好的,這是一個非常棒的決定!讓使用者能自訂帳戶和分類,是將「錢流」從一個單純的記帳工具,轉變為真正「個人化」財務夥伴的關鍵一步。

這個功能比較大,我建議我們將它拆分為兩個主要部分來依序完成,以保持思路清晰:

  1. 第一部分:帳戶管理 (新增、讀取、更新、刪除帳戶) - 結構相對單純。

  2. 第二部分:分類管理 (新增、讀取、更新、刪除主/子分類) - 因有層級關係,會稍微複雜一些。

我們這一步先專注於完成「帳戶管理」的完整功能。


第十步:實現使用者自訂帳戶 (CRUD)

我們的計畫如下:

  1. 擴充數據層:為 AccountDaoRepository 加入新增、更新、刪除的方法。

  2. 建立 AccountViewModel:專門負責處理所有與帳戶管理相關的業務邏輯。

  3. 建立 UI 畫面:包括一個「設定頁」、一個「帳戶列表頁」和一個「新增/編輯帳戶的對話框」。

  4. 整合導航:將這些新頁面串連起來。

第一步:擴充數據層 (DAO 與 Repository)

A. 更新 AccountDao.kt

我們需要在 DAO 中加入 update 和 delete 的方法。

Kotlin
// /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 中公開這些新功能。

Kotlin
// /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 將會是帳戶管理畫面的大腦。

Kotlin
// /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 (設定主頁)

這是進入帳戶和分類管理的入口。

Kotlin
// /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 (帳戶列表)

這個畫面用來展示所有帳戶,並提供新增、編輯、刪除的入口。

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

我們需要一個入口可以跳轉到設定頁面。

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

加入我們所有的新畫面和導航邏輯。

Kotlin
// /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!你應該可以在主畫面的右上角看到一個設定圖示,點擊後進入設定頁,再點擊「管理帳戶」就能看到我們剛剛完成的帳戶管理功能了。你可以試著新增、編輯和刪除帳戶看看!

當你準備好後,我們就可以開始挑戰結構更複雜、但邏輯相似的「分類管理」功能了。準備好了嗎?