A:
好的,接下來請一步一步慢慢分析進行開始建構使用者介面 (UI) ,謝謝。
Q:
太棒了!這是最令人興奮的部分,因為我們終於要開始打造使用者能看到並互動的介面了。我們將使用 Google 推薦的現代化 UI 工具包 Jetpack Compose。
Compose 的核心理念是:你只需要用程式碼描述你的 UI 應該長什麼樣子,而不需要去寫繁瑣的程式碼來手動更新它。當你的資料變化時,Compose 會自動更新畫面。
我們的第一個目標是:建立主畫面,用來顯示交易紀錄列表。
第七步:使用 Jetpack Compose 建立主畫面
我們會將這個任務分解成幾個小部分,由小到大組合起來:
TransactionItem:一個獨立的 Composable,負責顯示「單一一筆」交易紀錄的樣式。
TransactionList:一個 Composable,負責顯示「整個交易列表」,並處理列表為空時的狀況。
HomeScreen:整個主畫面的容器,它將會持有 ViewModel,並將數據傳遞給
TransactionList
。MainActivity:最後,在我們的主活動中載入
HomeScreen
。
1. 建立 TransactionItem.kt
(單筆交易卡片)
這是最小的 UI 元件。我們先把它做好,之後可以重複使用。
在 ui
套件下建立一個新的 components
子套件,並在裡面建立 TransactionItem.kt
檔案。
// /ui/components/TransactionItem.kt
package com.yourcompany.cashflow.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
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.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.yourcompany.cashflow.data.local.entity.TransactionType
import com.yourcompany.cashflow.data.model.TransactionWithDetails
// 為了預覽,我們需要自己建立假的資料
import com.yourcompany.cashflow.ui.theme.CashFlowTheme
import com.yourcompany.cashflow.util.formatAsCurrency
import com.yourcompany.cashflow.util.toFormattedDateString
import java.util.Date
@Composable
fun TransactionItem(transactionDetails: TransactionWithDetails) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp, horizontal = 8.dp),
shape = RoundedCornerShape(8.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
) {
Row(
modifier = Modifier
.padding(12.dp)
.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
// 左側:類別與備註
Column(modifier = Modifier.weight(1f)) {
Text(
text = transactionDetails.subCategory.name, // 顯示子類別名稱
fontWeight = FontWeight.Bold,
fontSize = 16.sp
)
if (!transactionDetails.transaction.note.isNullOrEmpty()) {
Spacer(modifier = Modifier.height(4.dp))
Text(
text = transactionDetails.transaction.note,
style = MaterialTheme.typography.bodySmall,
color = Color.Gray
)
}
}
// 右側:金額與日期
Column(horizontalAlignment = Alignment.End) {
val amountColor = if (transactionDetails.transaction.type == TransactionType.INCOME)
Color(0xFF2E7D32) // 深綠色
else
MaterialTheme.colorScheme.error // 使用主題的紅色
Text(
text = transactionDetails.transaction.amount.formatAsCurrency(), // 使用格式化函式
color = amountColor,
fontWeight = FontWeight.SemiBold,
fontSize = 18.sp
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = transactionDetails.transaction.transactionDate.toFormattedDateString(),
style = MaterialTheme.typography.bodySmall,
color = Color.Gray
)
}
}
}
}
// --- 為了讓 Preview 正常運作,我們需要一些輔助工具和假資料 ---
// 建議建立一個 util 套件來放這些共用函式
// /util/Formatters.kt
fun Double.formatAsCurrency(): String {
return "%,.0f".format(this) // 格式化為有千位分隔符的整數
}
fun Long.toFormattedDateString(): String {
// 這裡只是簡單示範,真實專案建議使用更完整的日期時間函式庫
val sdf = java.text.SimpleDateFormat("MM/dd", java.util.Locale.getDefault())
return sdf.format(Date(this))
}
// 這是 Preview 專用的 Composable
@Preview(showBackground = true)
@Composable
fun TransactionItemPreview() {
// 建立一筆假的交易資料來預覽 UI
val fakeTransaction = com.yourcompany.cashflow.data.local.entity.Transaction(
id = 1,
accountId = 1,
subCategoryId = 1,
amount = 1250.0,
type = TransactionType.EXPENSE,
transactionDate = System.currentTimeMillis(),
note = "跟同事聚餐"
)
val fakeSubCategory = com.yourcompany.cashflow.data.local.entity.SubCategory(id=1, mainCategoryId=1, name="餐飲")
val fakeAccount = com.yourcompany.cashflow.data.local.entity.Account(id=1, name="現金", type = com.yourcompany.cashflow.data.local.entity.AccountType.BANK, initialBalance = 0.0)
val fakeDetails = TransactionWithDetails(
transaction = fakeTransaction,
subCategory = fakeSubCategory,
account = fakeAccount
)
CashFlowTheme {
TransactionItem(transactionDetails = fakeDetails)
}
}
2. 建立 TransactionList.kt
(交易列表)
這個 Composable 負責顯示多個 TransactionItem
。我們使用 LazyColumn
,它非常高效,只會渲染畫面上看得到的項目,類似傳統的 RecyclerView
。
// /ui/components/TransactionList.kt
package com.yourcompany.cashflow.ui.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.yourcompany.cashflow.data.model.TransactionWithDetails
@Composable
fun TransactionList(transactions: List<TransactionWithDetails>) {
if (transactions.isEmpty()) {
// 如果列表是空的,顯示提示訊息
Column(
modifier = Modifier.fillMaxSize().padding(16.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text("目前沒有任何交易紀錄。")
}
} else {
// 如果有資料,使用 LazyColumn 顯示列表
LazyColumn(
modifier = Modifier.fillMaxSize()
) {
items(transactions) { transactionDetail ->
TransactionItem(transactionDetails = transactionDetail)
}
}
}
}
3. 建立 HomeScreen.kt
(主畫面) 並連接 ViewModel
這是我們的主畫面,負責從 ViewModel
獲取數據,並把它傳給 TransactionList
。
在 ui
套件下建立 screens
子套件,並在裡面建立 HomeScreen.kt
。
// /ui/screens/HomeScreen.kt
package com.yourcompany.cashflow.ui.screens
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.yourcompany.cashflow.ui.components.TransactionList
import com.yourcompany.cashflow.ui.viewmodel.TransactionViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun HomeScreen(
viewModel: TransactionViewModel = hiltViewModel() // 透過 Hilt 取得 ViewModel 實例
) {
// 這是關鍵!
// collectAsStateWithLifecycle 會安全地從 Flow 收集數據,並將其轉換成 Compose 的 State。
// 當資料庫有變動,Flow 會發出新資料,這個 state 就會更新,Compose 會自動重繪 UI。
val transactions by viewModel.allTransactions.collectAsStateWithLifecycle(initialValue = emptyList())
Scaffold(
topBar = {
TopAppBar(title = { Text("錢流 - 交易紀錄") })
},
// floatingActionButton = {
// // 我們下一步會在這裡加上新增按鈕
// }
) { innerPadding ->
TransactionList(
transactions = transactions,
modifier = Modifier.padding(innerPadding) // 套用 Scaffold 的 padding
)
}
}
4. 在 MainActivity
中載入 UI
最後一步,修改 MainActivity.kt
,讓它顯示我們的 HomeScreen
。
// MainActivity.kt
package com.yourcompany.cashflow
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.ui.Modifier
import com.yourcompany.cashflow.ui.screens.HomeScreen
import com.yourcompany.cashflow.ui.theme.CashFlowTheme
import dagger.hilt.android.AndroidEntryPoint
@AndroidEntryPoint // 告訴 Hilt 這個 Activity 也需要注入
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
CashFlowTheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
HomeScreen() // 載入我們的主畫面
}
}
}
}
}
執行你的 App!
現在,執行你的 App,你應該能看到一個帶有標題列的畫面。如果你的資料庫裡還沒有任何資料,它會顯示「目前沒有任何交易紀錄。」。
下一步,也是最重要的一步,就是讓這個 App 變得可以互動:
我們將會在 HomeScreen 上加上一個「新增」按鈕 (Floating Action Button),點擊它之後,會跳出一個畫面讓我們可以輸入新的交易資料,並將其儲存到資料庫。當儲存成功後,你會親眼見證,主畫面的列表會自動更新!
準備好讓我們的 App 動起來了嗎?