A:
好的,請開始下一步,第十八步:建立專案詳情與進度追蹤頁面,謝謝
Q:
好的,我們來進行階段 A 的最後一步,這將讓我們完整的專案管理功能閉環。
這一步的目標是建立一個「專案儀表板」,也就是專案的詳情頁面。使用者在這裡可以一目了然地看到專案的預算使用狀況、剩餘多少錢,並能檢視所有與此專案相關的交易紀錄。
第十八步:建立專案詳情與進度追蹤頁面
第一步:擴充數據層以取得專案交易
我們需要讓數據層能夠回答兩個問題:「這個專案總共花了多少錢?」和「這個專案底下有哪些交易?」。這兩個查詢應該放在 TransactionDao
中,因為它們操作的是 transactions
資料表。
A. 更新 TransactionDao.kt
路徑: app/src/main/java/com/yourcompany/cashflow/data/local/dao/TransactionDao.kt
// ...
@Dao
interface TransactionDao {
// ... (現有方法)
// vvv-- 加入以下兩個新方法 --vvv
/**
* 根據 project ID,計算該專案所有交易的總金額
* 回傳的 Double? 表示如果沒有任何交易,結果可能為 null
*/
@Query("SELECT SUM(amount) FROM transactions WHERE projectId = :projectId")
fun getSpendingForProject(projectId: Long): Flow<Double?>
/**
* 根據 project ID,取得所有相關交易的詳細資料列表
*/
@Transaction
@Query("SELECT * FROM transactions WHERE projectId = :projectId ORDER BY transactionDate DESC")
fun getTransactionsForProject(projectId: Long): Flow<List<TransactionWithDetails>>
}
B. 更新 TransactionRepository.kt
在 Repository 層公開這兩個新方法。
路徑: app/src/main/java/com/yourcompany/cashflow/data/repository/TransactionRepository.kt
// ...
interface TransactionRepository {
// ... (現有方法)
fun getSpendingForProject(projectId: Long): Flow<Double?>
fun getTransactionsForProject(projectId: Long): Flow<List<TransactionWithDetails>>
}
class TransactionRepositoryImpl @Inject constructor(...) : TransactionRepository {
// ... (現有實作)
override fun getSpendingForProject(projectId: Long): Flow<Double?> = transactionDao.getSpendingForProject(projectId)
override fun getTransactionsForProject(projectId: Long): Flow<List<TransactionWithDetails>> = transactionDao.getTransactionsForProject(projectId)
}
第二步:升級 ProjectViewModel
以載入詳情數據
我們需要讓 ProjectViewModel
在使用者選擇某個專案後,去取得該專案的總花費和交易列表。
路徑: app/src/main/java/com/yourcompany/cashflow/ui/viewmodel/ProjectViewModel.kt
// ...
import com.yourcompany.cashflow.data.model.TransactionWithDetails
import com.yourcompany.cashflow.data.repository.TransactionRepository // 注入新的 Repository
@HiltViewModel
class ProjectViewModel @Inject constructor(
private val projectRepository: ProjectRepository,
private val transactionRepository: TransactionRepository // <-- 1. 注入 TransactionRepository
) : ViewModel() {
// ... (現有的 projects, selectedProject 等)
// vvv-- 加入以下兩個新的數據流 --vvv
/**
* 監聽 selectedProject 的變化,當它不為 null 時,
* 就去查詢該專案的總花費。
*/
@OptIn(ExperimentalCoroutinesApi::class)
val projectSpending: StateFlow<Double> = selectedProject.flatMapLatest { project ->
project?.let {
transactionRepository.getSpendingForProject(it.id)
.map { spending -> spending ?: 0.0 } // 如果是 null (沒花費),就當作 0.0
} ?: flowOf(0.0) // 如果沒有選擇專案,花費就是 0.0
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = 0.0
)
/**
* 同樣地,監聽 selectedProject 的變化,取得其交易列表
*/
@OptIn(ExperimentalCoroutinesApi::class)
val projectTransactions: StateFlow<List<TransactionWithDetails>> = selectedProject.flatMapLatest { project ->
project?.let {
transactionRepository.getTransactionsForProject(it.id)
} ?: flowOf(emptyList())
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = emptyList()
)
}
第三步:建立專案詳情畫面 (ProjectDetailScreen
)
這個畫面是專案的儀表板。
路徑: app/src/main/java/com/yourcompany/cashflow/ui/screens/ProjectDetailScreen.kt
(新檔案)
<details>
<summary>點此展開 ProjectDetailScreen.kt 的完整程式碼</summary>
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.ArrowBack
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.yourcompany.cashflow.data.local.entity.Project
import com.yourcompany.cashflow.data.local.entity.ProjectStatus
import com.yourcompany.cashflow.ui.components.TransactionItem
import com.yourcompany.cashflow.ui.viewmodel.ProjectViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ProjectDetailScreen(
projectId: Long,
viewModel: ProjectViewModel = hiltViewModel(),
onNavigateBack: () -> Unit,
onNavigateToEditProject: (Long) -> Unit
) {
val project by viewModel.selectedProject.collectAsStateWithLifecycle()
val spending by viewModel.projectSpending.collectAsStateWithLifecycle()
val transactions by viewModel.projectTransactions.collectAsStateWithLifecycle()
LaunchedEffect(key1 = projectId) {
viewModel.loadProject(projectId)
}
Scaffold(
topBar = {
TopAppBar(
title = { Text(project?.name ?: "專案詳情") },
navigationIcon = { IconButton(onClick = onNavigateBack) { Icon(Icons.Default.ArrowBack, "返回") } },
actions = {
// 編輯按鈕
IconButton(onClick = { onNavigateToEditProject(projectId) }) {
Icon(Icons.Default.Edit, "編輯專案")
}
// 標記為完成按鈕
if (project?.status == ProjectStatus.ACTIVE) {
IconButton(onClick = {
project?.let { viewModel.updateProjectStatus(it, ProjectStatus.COMPLETED) }
}) {
Icon(Icons.Default.CheckCircle, "標記為完成")
}
}
}
)
}
) { innerPadding ->
project?.let { p ->
LazyColumn(
modifier = Modifier.padding(innerPadding),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// 項目一:進度總覽卡片
item {
ProjectSummaryCard(project = p, totalSpending = spending)
}
// 項目二:關聯交易按鈕 (為下一步準備)
item {
OutlinedButton(
onClick = { /* TODO: Step 19: Navigate to Batch Link Screen */ },
modifier = Modifier.fillMaxWidth()
) {
Text("關聯現有交易")
}
}
// 項目三:交易明細列表
item {
Text("相關交易明細", style = MaterialTheme.typography.titleMedium)
}
if (transactions.isEmpty()) {
item { Text("目前沒有任何相關交易。") }
} else {
items(transactions) { transactionDetail ->
TransactionItem(transactionDetails = transactionDetail, onClick = {
// TODO: 可選功能,點擊後跳轉到交易編輯頁面
})
}
}
}
} ?: Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator() // 如果資料還在載入中,顯示讀取動畫
}
}
}
@Composable
fun ProjectSummaryCard(project: Project, totalSpending: Double) {
val remaining = project.budget - totalSpending
val progress = if (project.budget > 0) (totalSpending / project.budget).toFloat() else 0f
val isOverBudget = remaining < 0
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(project.description ?: "沒有描述", style = MaterialTheme.typography.bodyMedium)
Divider()
// 進度條
LinearProgressIndicator(
progress = progress,
modifier = Modifier.fillMaxWidth().height(8.dp),
color = if (isOverBudget) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary
)
// 數字總覽
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceAround) {
SummaryItem(label = "預算", amount = project.budget)
SummaryItem(label = "已花費", amount = totalSpending)
SummaryItem(label = "剩餘", amount = remaining, color = if (isOverBudget) MaterialTheme.colorScheme.error else Color.Unspecified)
}
}
}
}
@Composable
fun SummaryItem(label: String, amount: Double, color: Color = Color.Unspecified) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(label, style = MaterialTheme.typography.labelSmall)
Text(
text = "NT$ ${"%,.0f".format(amount)}",
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Bold,
color = color
)
}
}
</details>
第四步:調整並整合導航流程
我們需要調整一下點擊專案列表項目後的行為,讓它先進入「詳情頁」,而不是直接進入「編輯頁」。
A. 調整 ProjectListScreen.kt 的導航
點擊列表項目時,應該傳遞 projectId 給詳情頁。
路徑: app/src/main/java/com/yourcompany/cashflow/ui/screens/ProjectListScreen.kt
@Composable
fun ProjectListScreen(
...
// 這個回呼的語意改為導航到詳情或編輯頁
onNavigateToProject: (Long) -> Unit
) {
...
Scaffold(
...
floatingActionButton = {
FloatingActionButton(onClick = { onNavigateToProject(0L) }) { ... } // 傳入 0 代表新增
}
) { innerPadding ->
Column(...) {
...
LazyColumn(...) {
items(projects) { project ->
ListItem(
...
// 點擊列表項目,傳入該專案的 ID
modifier = Modifier.clickable { onNavigateToProject(project.id) }
)
...
}
}
}
}
}
B. 更新 AppNavigation.kt (或 MainScreen.kt 中的 AppNavHost)
加入新的詳情頁路由,並調整現有路由的跳轉邏輯。
// ...
object Routes {
// ...
const val PROJECT_DETAIL = "project_detail" // 新增詳情頁路由
}
// 在 NavHost 中...
@Composable
fun AppNavHost(navController: NavHostController, modifier: Modifier = Modifier) {
NavHost(...) {
// ...
// 設定頁 -> 專案列表頁
composable(Routes.SETTINGS) {
SettingsScreen(
...
onNavigateToProjectManagement = { navController.navigate(Routes.PROJECT_LIST) }
)
}
// 專案列表頁 -> 新增頁 or 詳情頁
composable(Routes.PROJECT_LIST) {
ProjectListScreen(
onNavigateBack = { navController.popBackStack() },
onNavigateToProject = { projectId ->
if (projectId == 0L) {
// 新增專案
navController.navigate("${Routes.ADD_EDIT_PROJECT}?projectId=0")
} else {
// 查看專案詳情
navController.navigate("${Routes.PROJECT_DETAIL}/$projectId")
}
}
)
}
// 新增:專案詳情頁路由
composable(
route = "${Routes.PROJECT_DETAIL}/{projectId}",
arguments = listOf(navArgument("projectId") { type = NavType.LongType })
) { backStackEntry ->
val projectId = backStackEntry.arguments?.getLong("projectId") ?: 0L
ProjectDetailScreen(
projectId = projectId,
onNavigateBack = { navController.popBackStack() },
onNavigateToEditProject = { id ->
navController.navigate("${Routes.ADD_EDIT_PROJECT}?projectId=$id")
}
)
}
// 新增/編輯專案頁 (路徑保持不變)
composable(
route = "${Routes.ADD_EDIT_PROJECT}?projectId={projectId}",
...
) { ... }
}
}
儀表板完成!
現在執行 App,整個專案管理的流程就非常順暢了:
從「設定」進入「管理專案」頁面。
點擊右下角
+
新增一個專案。回到專案列表,點擊你剛剛建立的專案,就會進入我們這一步製作的「詳情儀表板」。
在詳情頁,你可以看到所有數據,並可以點擊右上角的「編輯」按鈕,跳轉到編輯頁面。
這個儀表板讓專案追蹤變得非常直覺。下一步,我們將處理階段 B 的第一個挑戰,也就是你在需求中特別提到的第十九步:實作「批次關聯交易」的進階功能。準備好了嗎?