Vibe coding全紀錄-16

A:

 好的,來進行分類管理,謝謝。 

Q:

好的,我們來挑戰結構更豐富的「分類管理」功能。

這個功能比帳戶管理稍微複雜一些,因為它有「主分類」和「子分類」的層級關係。我們需要設計一個能清晰展示這種關係的介面。我會帶你一步步完成,最終你會做出一個非常實用且看起來很專業的可展開列表。

我們的計畫和上次類似:

  1. 強化資料層:建立一個能同時查詢主分類及其底下所有子分類的方法。

  2. 建立 CategoryViewModel:專門處理分類管理的邏輯。

  3. 打造 UI 介面:設計一個可展開的列表來呈現層級關係。

  4. 串連導航:將新畫面加入到我們的 App 中。


第十一步:實現使用者自訂分類

第一步:強化資料層 - 關聯查詢與 CRUD

為了在一個查詢中就拿到主分類和它所有的子分類,我們需要使用 Room 的 @Relation 註解,並建立一個新的資料模型。

A. 建立 MainCategoryWithSubCategories.kt

這個檔案代表一個「組合物件」,它不是資料表。

Kotlin
// /data/model/MainCategoryWithSubCategories.kt (新檔案)
package com.yourcompany.cashflow.data.model

import androidx.room.Embedded
import androidx.room.Relation
import com.yourcompany.cashflow.data.local.entity.MainCategory
import com.yourcompany.cashflow.data.local.entity.SubCategory

data class MainCategoryWithSubCategories(
    @Embedded
    val mainCategory: MainCategory,

    @Relation(
        parentColumn = "id", // MainCategory 的主鍵
        entityColumn = "mainCategoryId" // SubCategory 的外鍵
    )
    val subCategories: List<SubCategory>
)

B. 更新 CategoryDao.kt

加入完整的 CRUD (Create, Read, Update, Delete) 方法,以及我們新的關聯查詢。

Kotlin
// /data/local/dao/CategoryDao.kt
...
import com.yourcompany.cashflow.data.model.MainCategoryWithSubCategories // 匯入

@Dao
interface CategoryDao {
    // --- Create ---
    @Insert suspend fun insertMainCategory(category: MainCategory)
    @Insert suspend fun insertSubCategory(category: SubCategory)

    // --- Read ---
    @androidx.room.Transaction // 關聯查詢必須加上這個註解
    @Query("SELECT * FROM main_categories")
    fun getMainCategoriesWithSubCategories(): Flow<List<MainCategoryWithSubCategories>>

    // --- Update ---
    @Update suspend fun updateMainCategory(category: MainCategory)
    @Update suspend fun updateSubCategory(category: SubCategory)

    // --- Delete ---
    @Delete suspend fun deleteMainCategory(category: MainCategory)
    @Delete suspend fun deleteSubCategory(category: SubCategory)
}

C. 更新 CategoryRepository.kt

同樣地,在 Repository 層公開這些新方法。

Kotlin
// /data/repository/CategoryRepository.kt
...
import com.yourcompany.cashflow.data.model.MainCategoryWithSubCategories // 匯入

interface CategoryRepository {
    fun getMainCategoriesWithSubCategories(): Flow<List<MainCategoryWithSubCategories>>
    suspend fun insertMainCategory(category: MainCategory)
    suspend fun insertSubCategory(category: SubCategory)
    suspend fun updateMainCategory(category: MainCategory)
    suspend fun updateSubCategory(category: SubCategory)
    suspend fun deleteMainCategory(category: MainCategory)
    suspend fun deleteSubCategory(category: SubCategory)
}

class CategoryRepositoryImpl @Inject constructor(
    private val categoryDao: CategoryDao
) : CategoryRepository {
    override fun getMainCategoriesWithSubCategories(): Flow<List<MainCategoryWithSubCategories>> =
        categoryDao.getMainCategoriesWithSubCategories()

    override suspend fun insertMainCategory(category: MainCategory) = categoryDao.insertMainCategory(category)
    override suspend fun insertSubCategory(category: SubCategory) = categoryDao.insertSubCategory(category)
    override suspend fun updateMainCategory(category: MainCategory) = categoryDao.updateMainCategory(category)
    override suspend fun updateSubCategory(category: SubCategory) = categoryDao.updateSubCategory(category)
    override suspend fun deleteMainCategory(category: MainCategory) = categoryDao.deleteMainCategory(category)
    override suspend fun deleteSubCategory(category: SubCategory) = categoryDao.deleteSubCategory(category)
}

第二步:建立分類管理的 ViewModel

Kotlin
// /ui/viewmodel/CategoryViewModel.kt (新檔案)
package com.yourcompany.cashflow.ui.viewmodel

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.yourcompany.cashflow.data.local.entity.MainCategory
import com.yourcompany.cashflow.data.local.entity.SubCategory
import com.yourcompany.cashflow.data.local.entity.TransactionType
import com.yourcompany.cashflow.data.model.MainCategoryWithSubCategories
import com.yourcompany.cashflow.data.repository.CategoryRepository
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 CategoryViewModel @Inject constructor(
    private val repository: CategoryRepository
) : ViewModel() {

    val categories: StateFlow<List<MainCategoryWithSubCategories>> =
        repository.getMainCategoriesWithSubCategories().stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5000),
            initialValue = emptyList()
        )

    fun addMainCategory(name: String, type: TransactionType) = viewModelScope.launch {
        repository.insertMainCategory(MainCategory(name = name, type = type))
    }

    fun addSubCategory(name: String, mainCategoryId: Long) = viewModelScope.launch {
        repository.insertSubCategory(SubCategory(name = name, mainCategoryId = mainCategoryId))
    }
    
    fun updateMainCategory(category: MainCategory) = viewModelScope.launch {
        repository.updateMainCategory(category)
    }

    fun updateSubCategory(category: SubCategory) = viewModelScope.launch {
        repository.updateSubCategory(category)
    }

    fun deleteMainCategory(category: MainCategory) = viewModelScope.launch {
        repository.deleteMainCategory(category)
    }

    fun deleteSubCategory(category: SubCategory) = viewModelScope.launch {
        repository.deleteSubCategory(category)
    }
}

第三步:打造可展開的分類管理介面

A. 建立 CategoryManagementScreen.kt

這是我們的主畫面,將會使用 LazyColumn 顯示一個可展開的列表。

Kotlin
// /ui/screens/CategoryManagementScreen.kt (新檔案)
package com.yourcompany.cashflow.ui.screens

import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
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.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.yourcompany.cashflow.data.local.entity.MainCategory
import com.yourcompany.cashflow.data.local.entity.SubCategory
import com.yourcompany.cashflow.data.local.entity.TransactionType
import com.yourcompany.cashflow.data.model.MainCategoryWithSubCategories
import com.yourcompany.cashflow.ui.viewmodel.CategoryViewModel

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CategoryManagementScreen(
    viewModel: CategoryViewModel = hiltViewModel(),
    onNavigateBack: () -> Unit
) {
    val categories by viewModel.categories.collectAsStateWithLifecycle()
    // ... (Dialog state management will be here)

    Scaffold(
        topBar = {
            TopAppBar(
                title = { Text("管理分類") },
                navigationIcon = { IconButton(onClick = onNavigateBack) { Icon(Icons.Default.ArrowBack, "返回") } }
            )
        },
        floatingActionButton = {
            FloatingActionButton(onClick = { /* TODO: Open 'Add Main Category' Dialog */ }) {
                Icon(Icons.Default.Add, "新增主分類")
            }
        }
    ) { innerPadding ->
        LazyColumn(
            modifier = Modifier.padding(innerPadding),
            contentPadding = PaddingValues(vertical = 8.dp)
        ) {
            items(categories) { categoryWithSubs ->
                ExpandableMainCategoryItem(
                    categoryWithSubCategories = categoryWithSubs,
                    onAddSubCategory = { /* TODO */ },
                    onEditMainCategory = { /* TODO */ },
                    onDeleteMainCategory = { viewModel.deleteMainCategory(it) },
                    onEditSubCategory = { /* TODO */ },
                    onDeleteSubCategory = { viewModel.deleteSubCategory(it) }
                )
            }
        }
    }
}

// 可展開的主分類項目 Composable
@Composable
fun ExpandableMainCategoryItem(
    categoryWithSubCategories: MainCategoryWithSubCategories,
    onAddSubCategory: (MainCategory) -> Unit,
    onEditMainCategory: (MainCategory) -> Unit,
    onDeleteMainCategory: (MainCategory) -> Unit,
    onEditSubCategory: (SubCategory) -> Unit,
    onDeleteSubCategory: (SubCategory) -> Unit
) {
    var isExpanded by remember { mutableStateOf(false) }
    val mainCategory = categoryWithSubCategories.mainCategory
    val subCategories = categoryWithSubCategories.subCategories

    Column {
        ListItem(
            modifier = Modifier.clickable { isExpanded = !isExpanded },
            headlineText = { Text(mainCategory.name) },
            leadingContent = {
                Icon(
                    if (isExpanded) Icons.Default.KeyboardArrowDown else Icons.Default.KeyboardArrowRight,
                    contentDescription = "展開/收合"
                )
            },
            trailingContent = {
                Row {
                    IconButton(onClick = { onAddSubCategory(mainCategory) }) { Icon(Icons.Default.Add, "新增子分類") }
                    IconButton(onClick = { onEditMainCategory(mainCategory) }) { Icon(Icons.Default.Edit, "編輯主分類") }
                    IconButton(onClick = { onDeleteMainCategory(mainCategory) }) { Icon(Icons.Default.Delete, "刪除主分類") }
                }
            }
        )
        AnimatedVisibility(visible = isExpanded) {
            Column(modifier = Modifier.padding(start = 24.dp)) {
                subCategories.forEach { subCategory ->
                    ListItem(
                        headlineText = { Text(subCategory.name) },
                        trailingContent = {
                             Row {
                                IconButton(onClick = { onEditSubCategory(subCategory) }) { Icon(Icons.Default.Edit, "編輯子分類", tint = MaterialTheme.colorScheme.secondary) }
                                IconButton(onClick = { onDeleteSubCategory(subCategory) }) { Icon(Icons.Default.Delete, "刪除子分類", tint = MaterialTheme.colorScheme.secondary) }
                            }
                        }
                    )
                }
                if (subCategories.isEmpty()) {
                    ListItem(headlineText = { Text("沒有子分類", color = Color.Gray) })
                }
            }
        }
    }
}

注意:上面這段程式碼還需要一個新增/編輯的對話框 Dialog。因為這個 Dialog 需要處理新增/編輯主分類和子分類四種情況,程式碼會比較複雜。為了保持本步驟清晰,我將其邏輯簡化,只先完成刪除功能和列表顯示。新增與編輯的 Dialog 會在下一步提供。

第四步:串連導航

A. 更新 SettingsScreen.kt

啟用「管理分類」按鈕的點擊事件。

Kotlin
// /ui/screens/SettingsScreen.kt
...
@Composable
fun SettingsScreen(
    onNavigateBack: () -> Unit,
    onNavigateToAccountManagement: () -> Unit,
    onNavigateToCategoryManagement: () -> Unit // 新增回呼
) {
    ...
    Column(...) {
        ListItem(
            headlineText = { Text("管理帳戶") },
            modifier = Modifier.clickable(onClick = onNavigateToAccountManagement)
        )
        Divider()
        ListItem(
            headlineText = { Text("管理分類") }, // 移除「待開發」
            modifier = Modifier.clickable(onClick = onNavigateToCategoryManagement) // 啟用點擊
        )
        Divider()
    }
}

B. 更新 AppNavigation.kt

Kotlin
// /ui/AppNavigation.kt
...
import com.yourcompany.cashflow.ui.screens.CategoryManagementScreen

object Routes {
    // ...
    const val CATEGORY_MANAGEMENT = "category_management" // 新增路線
}

@Composable
fun AppNavigation() {
    ...
    NavHost(...) {
        ...
        composable(Routes.SETTINGS) {
            SettingsScreen(
                onNavigateBack = { navController.popBackStack() },
                onNavigateToAccountManagement = { navController.navigate(Routes.ACCOUNT_MANAGEMENT) },
                onNavigateToCategoryManagement = { navController.navigate(Routes.CATEGORY_MANAGEMENT) } // 處理跳轉
            )
        }
        composable(Routes.ACCOUNT_MANAGEMENT) { ... }
        
        // 加入分類管理畫面的導航設定
        composable(Routes.CATEGORY_MANAGEMENT) {
            CategoryManagementScreen(
                onNavigateBack = { navController.popBackStack() }
            )
        }
    }
}

現在,我們已經完成了分類管理畫面的基本框架和數據讀取/刪除功能。你可以執行 App,雖然還不能新增和編輯,但你已經可以看到一個能清晰展示層級關係、可展開的列表了!

接下來,我們最後一步就是完成那個最核心的新增/編輯對話框。準備好了嗎?