Vibe coding全紀錄-10

A:

 好的,接下來請一步一步慢慢分析進行開始建構使用者介面 (UI) ,謝謝。 

Q:

太棒了!這是最令人興奮的部分,因為我們終於要開始打造使用者能看到並互動的介面了。我們將使用 Google 推薦的現代化 UI 工具包 Jetpack Compose

Compose 的核心理念是:你只需要用程式碼描述你的 UI 應該長什麼樣子,而不需要去寫繁瑣的程式碼來手動更新它。當你的資料變化時,Compose 會自動更新畫面。

我們的第一個目標是:建立主畫面,用來顯示交易紀錄列表。


第七步:使用 Jetpack Compose 建立主畫面

我們會將這個任務分解成幾個小部分,由小到大組合起來:

  1. TransactionItem:一個獨立的 Composable,負責顯示「單一一筆」交易紀錄的樣式。

  2. TransactionList:一個 Composable,負責顯示「整個交易列表」,並處理列表為空時的狀況。

  3. HomeScreen:整個主畫面的容器,它將會持有 ViewModel,並將數據傳遞給 TransactionList

  4. MainActivity:最後,在我們的主活動中載入 HomeScreen

1. 建立 TransactionItem.kt (單筆交易卡片)

這是最小的 UI 元件。我們先把它做好,之後可以重複使用。

ui 套件下建立一個新的 components 子套件,並在裡面建立 TransactionItem.kt 檔案。

Kotlin
// /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

Kotlin
// /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

Kotlin
// /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

Kotlin
// 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 動起來了嗎?