A:
好的,請開始階段 B 的第一個作業,第十九步:實作「批次關聯交易」的進階功能,謝謝
Q:
好的,我們立刻開始執行階段 B 的第一個任務。
這一步將實作一個相當進階且實用的功能:讓使用者可以回溯過去,將已經存在的交易「批次」地關聯到某個專案中。這會大大增加「專案管理」功能的彈性。
第十九步:實作「批次關聯交易」功能
我們的計畫如下:
強化數據層:加入兩個關鍵方法,一個用來查詢所有「尚未歸屬」的交易,另一個用來執行「批次更新」。
升級
ProjectViewModel:讓它能夠提供待選交易的列表,並處理關聯操作。建立
LinkTransactionsScreen:打造一個全新的介面,讓使用者可以在交易列表上進行勾選。整合導航:從「專案詳情頁」連結到我們的新畫面。
第一步:強化數據層以支援批次更新
A. 更新 TransactionDao.kt
我們需要能夠查詢未歸屬的交易,以及一個能一次更新多筆交易的強大方法。
路徑: app/src/main/java/com/yourcompany/cashflow/data/local/dao/TransactionDao.kt
// ...
@Dao
interface TransactionDao {
// ... (現有方法)
// vvv-- 加入以下兩個新方法 --vvv
/**
* 查詢所有未被指派到任何專案的交易 (projectId IS NULL)
*/
@Transaction
@Query("SELECT * FROM transactions WHERE projectId IS NULL ORDER BY transactionDate DESC")
fun getUnassignedTransactions(): Flow<List<TransactionWithDetails>>
/**
* 批次更新一組交易,將它們的 projectId 設定為指定值
* 這是透過 `WHERE id IN (:transactionIds)` 語法來實現的
*/
@Query("UPDATE transactions SET projectId = :projectId WHERE id IN (:transactionIds)")
suspend fun linkTransactionsToProject(transactionIds: List<Long>, projectId: Long)
}
B. 更新 TransactionRepository.kt
在 Repository 層公開這兩個新功能。
路徑: app/src/main/java/com/yourcompany/cashflow/data/repository/TransactionRepository.kt
// ...
interface TransactionRepository {
// ... (現有方法)
fun getUnassignedTransactions(): Flow<List<TransactionWithDetails>>
suspend fun linkTransactionsToProject(transactionIds: List<Long>, projectId: Long)
}
class TransactionRepositoryImpl @Inject constructor(...) : TransactionRepository {
// ... (現有實作)
override fun getUnassignedTransactions(): Flow<List<TransactionWithDetails>> = transactionDao.getUnassignedTransactions()
override suspend fun linkTransactionsToProject(transactionIds: List<Long>, projectId: Long) = transactionDao.linkTransactionsToProject(transactionIds, projectId)
}
第二步:升級 ProjectViewModel 以管理待選交易
我們繼續在 ProjectViewModel 中擴充邏輯,因為這個功能是從專案詳情頁發起的。
路徑: app/src/main/java/com/yourcompany/cashflow/ui/viewmodel/ProjectViewModel.kt
// ...
@HiltViewModel
class ProjectViewModel @Inject constructor(...) : ViewModel() {
// ... (現有屬性與方法)
// vvv-- 加入以下數據流與方法 --vvv
/**
* 提供給「批次關聯畫面」使用的、所有未歸屬的交易列表
*/
val unassignedTransactions: StateFlow<List<TransactionWithDetails>> =
transactionRepository.getUnassignedTransactions()
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = emptyList()
)
/**
* 讓 UI 呼叫此方法,執行批次關聯操作
*/
fun linkTransactions(transactionIds: List<Long>) {
viewModelScope.launch {
// 確保我們有關聯的目標專案
val projectId = selectedProject.value?.id ?: return@launch
if (transactionIds.isNotEmpty()) {
transactionRepository.linkTransactionsToProject(transactionIds, projectId)
}
}
}
}
第三步:建立批次關聯畫面 (LinkTransactionsScreen)
這個新畫面是本步驟的核心,它包含了一個帶有勾選框的列表。
路徑: app/src/main/java/com/yourcompany/cashflow/ui/screens/LinkTransactionsScreen.kt (新檔案)
<details>
<summary>點此展開 LinkTransactionsScreen.kt 的完整程式碼</summary>
package com.yourcompany.cashflow.ui.screens
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.ArrowBack
import androidx.compose.material.icons.filled.Done
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.yourcompany.cashflow.data.model.TransactionWithDetails
import com.yourcompany.cashflow.ui.viewmodel.ProjectViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LinkTransactionsScreen(
viewModel: ProjectViewModel = hiltViewModel(),
onNavigateBack: () -> Unit
) {
val unassignedTransactions by viewModel.unassignedTransactions.collectAsStateWithLifecycle()
val project by viewModel.selectedProject.collectAsStateWithLifecycle()
// 使用 Set 來管理被勾選的交易 ID,效率高
var selectedIds by remember { mutableStateOf<Set<Long>>(emptySet()) }
Scaffold(
topBar = {
TopAppBar(
title = { Text("關聯交易至 \"${project?.name ?: ""}\"", maxLines = 1, overflow = TextOverflow.Ellipsis) },
navigationIcon = { IconButton(onClick = onNavigateBack) { Icon(Icons.Default.ArrowBack, "返回") } },
actions = {
// 只有在至少選擇一筆交易時,儲存按鈕才能點擊
IconButton(
onClick = {
viewModel.linkTransactions(selectedIds.toList())
onNavigateBack()
},
enabled = selectedIds.isNotEmpty()
) {
Icon(Icons.Default.Done, "儲存關聯")
}
}
)
}
) { innerPadding ->
LazyColumn(
modifier = Modifier.padding(innerPadding),
contentPadding = PaddingValues(vertical = 8.dp)
) {
if (unassignedTransactions.isEmpty()) {
item {
Box(modifier = Modifier.fillParentMaxSize(), contentAlignment = Alignment.Center) {
Text("沒有可關聯的交易")
}
}
} else {
items(unassignedTransactions) { transactionDetail ->
SelectableTransactionRow(
transactionDetails = transactionDetail,
isSelected = transactionDetail.transaction.id in selectedIds,
onToggleSelection = {
selectedIds = if (it) {
selectedIds + transactionDetail.transaction.id
} else {
selectedIds - transactionDetail.transaction.id
}
}
)
}
}
}
}
}
// 帶有勾選框的交易列表項目
@Composable
fun SelectableTransactionRow(
transactionDetails: TransactionWithDetails,
isSelected: Boolean,
onToggleSelection: (Boolean) -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { onToggleSelection(!isSelected) }
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(
checked = isSelected,
onCheckedChange = null // 點擊事件由 Row 處理
)
Spacer(modifier = Modifier.width(16.dp))
// 這裡可以重用 TransactionItem,或者為了版面簡潔,自己客製化
Column {
Text(transactionDetails.subCategory.name, style = MaterialTheme.typography.bodyLarge)
Text(
"NT$ ${"%,.0f".format(transactionDetails.transaction.amount)}",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary
)
}
}
}
</details>
第四步:整合導航流程
最後,我們從「專案詳情頁」啟用那個預留的按鈕。
A. 更新 AppNavigation.kt (或 MainScreen.kt 中的 AppNavHost)
加入新的路由。
// ...
object Routes {
// ...
const val LINK_TRANSACTIONS = "link_transactions"
}
// 在 NavHost 中...
@Composable
fun AppNavHost(...) {
NavHost(...) {
// ...
// 從 ProjectDetailScreen 跳轉到 LinkTransactionsScreen
composable(
route = "${Routes.PROJECT_DETAIL}/{projectId}",
...
) { backStackEntry ->
val projectId = backStackEntry.arguments?.getLong("projectId") ?: 0L
ProjectDetailScreen(
projectId = projectId,
onNavigateBack = { navController.popBackStack() },
onNavigateToEditProject = { id -> ... },
// 新增導航到關聯頁面的回呼
onNavigateToLinkTransactions = {
navController.navigate(Routes.LINK_TRANSACTIONS)
}
)
}
// 新增關聯交易畫面的路由
composable(Routes.LINK_TRANSACTIONS) {
// 這個畫面會自動使用 ProjectDetailScreen 載入的同一個 ProjectViewModel 實例
LinkTransactionsScreen(
onNavigateBack = { navController.popBackStack() }
)
}
}
}
B. 更新 ProjectDetailScreen.kt
啟用「關聯現有交易」按鈕。
路徑: app/src/main/java/com/yourcompany/cashflow/ui/screens/ProjectDetailScreen.kt
// ...
@Composable
fun ProjectDetailScreen(
...
onNavigateToLinkTransactions: () -> Unit // 新增導航回呼
) {
// ...
Scaffold(...) {
project?.let { p ->
LazyColumn(...) {
// ...
item {
OutlinedButton(
onClick = onNavigateToLinkTransactions, // <-- 啟用按鈕
modifier = Modifier.fillMaxWidth()
) {
Text("關聯現有交易")
}
}
// ...
}
}
}
}
進階功能完成!
現在,使用者擁有極大的彈性,即使在專案建立之後,也能輕鬆地將過去的交易納入追蹤。這讓專案管理功能變得非常完整和強大。
這個進階功能完成後,「專案管理」模組的核心部分就全部完成了。下一步,我們準備好回頭去重構第廿步:重構「統計圖表」,讓它也能理解並呈現我們剛剛建立的這些專愛的專案數據了嗎?