A:
好的,來進行分類管理,謝謝。
Q:
好的,我們來挑戰結構更豐富的「分類管理」功能。
這個功能比帳戶管理稍微複雜一些,因為它有「主分類」和「子分類」的層級關係。我們需要設計一個能清晰展示這種關係的介面。我會帶你一步步完成,最終你會做出一個非常實用且看起來很專業的可展開列表。
我們的計畫和上次類似:
強化資料層:建立一個能同時查詢主分類及其底下所有子分類的方法。
建立
CategoryViewModel:專門處理分類管理的邏輯。打造 UI 介面:設計一個可展開的列表來呈現層級關係。
串連導航:將新畫面加入到我們的 App 中。
第十一步:實現使用者自訂分類
第一步:強化資料層 - 關聯查詢與 CRUD
為了在一個查詢中就拿到主分類和它所有的子分類,我們需要使用 Room 的 @Relation 註解,並建立一個新的資料模型。
A. 建立 MainCategoryWithSubCategories.kt
這個檔案代表一個「組合物件」,它不是資料表。
// /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) 方法,以及我們新的關聯查詢。
// /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 層公開這些新方法。
// /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
// /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 顯示一個可展開的列表。
// /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
啟用「管理分類」按鈕的點擊事件。
// /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
// /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,雖然還不能新增和編輯,但你已經可以看到一個能清晰展示層級關係、可展開的列表了!
接下來,我們最後一步就是完成那個最核心的新增/編輯對話框。準備好了嗎?