Vibe coding全紀錄-21

A:

 好的,現在來進行[實現定期交易],謝謝。 

Q:

好的,我們來進行最後一項,也是技術上相當有深度的一項核心功能——實現定期交易。

這不像之前開發 UI 畫面,這次我們的工作主要在「幕後」。我們需要一個可靠的機制,無論 App 是否開啟,都能每天定時檢查是否有需要自動記的帳,然後幫使用者完成記帳。

完成這個功能的最佳工具,就是 Android Jetpack 中的 WorkManager


第十五步:使用 WorkManager 實現定期交易

WorkManager 是 Android 官方推薦用來處理需要保證執行可延後背景任務的函式庫。即使使用者關閉 App 或手機重開機,WorkManager 也會確保我們的任務在未來某个時間點被執行,非常可靠。

我們的計畫:

  1. 加入 WorkManager 依賴:並設定 Hilt 以便在 Worker 中注入我們的 Repository。

  2. 建立 RecurringTransactionWorker:這是執行檢查與記帳邏輯的「工人」。

  3. 實作核心邏輯:在 Worker 中編寫判斷「今天是否需要記帳」的程式碼。

  4. 排程任務:在 App 啟動時,安排這個「工人」每天工作一次。

第一步:加入 WorkManager 與 Hilt 整合依賴

A. 在 app/build.gradle.kts 中加入依賴

Kotlin
dependencies {
    // ... 其他依賴

    // WorkManager
    implementation("androidx.work:work-runtime-ktx:2.9.0")

    // Hilt integration for WorkManager
    implementation("androidx.hilt:hilt-work:1.2.0")
    kapt("androidx.hilt:hilt-compiler:1.2.0")
}

記得點擊 "Sync Now"。

B. 設定 Hilt Worker Factory

為了讓 Hilt 能建立我們的 Worker,需要在 CashFlowApp.kt 中做一點修改。

Kotlin
// CashFlowApp.kt
...
import androidx.hilt.work.HiltWorkerFactory
import androidx.work.Configuration
import javax.inject.Inject

@HiltAndroidApp
class CashFlowApp : Application(), Configuration.Provider {

    @Inject // 注入 Hilt Worker Factory
    lateinit var workerFactory: HiltWorkerFactory

    override fun getWorkManagerConfiguration(): Configuration =
        Configuration.Builder()
            .setWorkerFactory(workerFactory)
            .build()
}

第二步:強化數據層以防止重複記帳

這是一個重要的細節。如果 Worker 因為某些原因在一天內被執行了兩次,我們不希望同一筆定期帳被記兩次。所以我們需要一個方法來檢查某天是否已經記過帳。

更新 TransactionDao.kt

Kotlin
// /data/local/dao/TransactionDao.kt
...
@Dao
interface TransactionDao {
    ...
    // 檢查在特定日期範圍內,是否有符合特定子分類和金額的交易
    @Query("""
        SELECT COUNT(id) FROM transactions 
        WHERE subCategoryId = :subCategoryId AND amount = :amount AND transactionDate BETWEEN :startOfDay AND :endOfDay
    """)
    suspend fun transactionExists(subCategoryId: Long, amount: Double, startOfDay: Long, endOfDay: Long): Int
}

更新 TransactionRepository.kt

Kotlin
// /data/repository/TransactionRepository.kt
...
interface TransactionRepository {
    ...
    suspend fun transactionExists(subCategoryId: Long, amount: Double, startOfDay: Long, endOfDay: Long): Boolean
}

class TransactionRepositoryImpl @Inject constructor(...) : TransactionRepository {
    ...
    override suspend fun transactionExists(subCategoryId: Long, amount: Double, startOfDay: Long, endOfDay: Long): Boolean {
        return transactionDao.transactionExists(subCategoryId, amount, startOfDay, endOfDay) > 0
    }
}

第三步:建立我們的「工人」- RecurringTransactionWorker

這個 Worker 是整個功能的核心。

Kotlin
// /worker/RecurringTransactionWorker.kt (新資料夾、新檔案)
package com.yourcompany.cashflow.worker

import android.content.Context
import androidx.hilt.work.HiltWorker
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import com.yourcompany.cashflow.data.local.entity.RecurringTransaction
import com.yourcompany.cashflow.data.local.entity.Transaction
import com.yourcompany.cashflow.data.repository.RecurringTransactionRepository // 建立這個 Repository
import com.yourcompany.cashflow.data.repository.TransactionRepository
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import java.time.LocalDate
import java.time.ZoneId

@HiltWorker
class RecurringTransactionWorker @AssistedInject constructor(
    @Assisted appContext: Context,
    @Assisted workerParams: WorkerParameters,
    private val transactionRepository: TransactionRepository,
    private val recurringTransactionRepository: RecurringTransactionRepository // 假設你已建立
) : CoroutineWorker(appContext, workerParams) {

    override suspend fun doWork(): Result {
        try {
            val today = LocalDate.now()
            val recurringTransactions = recurringTransactionRepository.getAllRecurringTransactionsOnce() // 取得所有規則

            recurringTransactions.forEach { rule ->
                // 檢查規則是否在有效期內
                val startDate = rule.startDate.toLocalDate()
                val endDate = rule.endDate?.toLocalDate()
                if (today.isBefore(startDate) || (endDate != null && today.isAfter(endDate))) {
                    return@forEach // continue
                }
                
                // 檢查今天是否為執行日
                if (isDue(rule, today)) {
                    val startOfDay = today.atStartOfDay(ZoneId.systemDefault()).toInstant().toEpochMilli()
                    val endOfDay = today.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant().toEpochMilli() - 1

                    // 檢查今天是否已記過這筆帳
                    val exists = transactionRepository.transactionExists(
                        rule.subCategoryId,
                        rule.amount,
                        startOfDay,
                        endOfDay
                    )

                    if (!exists) {
                        val newTransaction = Transaction(
                            accountId = rule.accountId,
                            subCategoryId = rule.subCategoryId,
                            amount = rule.amount,
                            type = rule.type,
                            transactionDate = startOfDay, // 記在當天的開始
                            note = rule.note ?: "定期交易"
                        )
                        transactionRepository.insertTransaction(newTransaction)
                    }
                }
            }
            return Result.success()
        } catch (e: Exception) {
            return Result.failure()
        }
    }

    private fun isDue(rule: RecurringTransaction, today: LocalDate): Boolean {
        return when (rule.frequency) {
            com.yourcompany.cashflow.data.local.entity.FrequencyType.WEEKLY -> {
                // java.time.DayOfWeek: MONDAY(1) to SUNDAY(7)
                today.dayOfWeek.value == rule.dayOfWeek
            }
            com.yourcompany.cashflow.data.local.entity.FrequencyType.MONTHLY -> {
                val dayOfMonth = rule.dayOfMonth ?: return false
                // 處理大小月問題:如果規則設定31號,但在2月,就取2月的最後一天
                val lastDayOfMonth = today.lengthOfMonth()
                val dueDay = if (dayOfMonth > lastDayOfMonth) lastDayOfMonth else dayOfMonth
                today.dayOfMonth == dueDay
            }
            com.yourcompany.cashflow.data.local.entity.FrequencyType.YEARLY -> {
                today.monthValue == rule.monthOfYear && today.dayOfMonth == rule.dayOfYear
            }
        }
    }

    // 將 Long 轉換為 LocalDate 的輔助函式
    private fun Long.toLocalDate(): LocalDate {
        return java.time.Instant.ofEpochMilli(this)
            .atZone(ZoneId.systemDefault())
            .toLocalDate()
    }
}

注意: 上面的程式碼需要 RecurringTransactionRepository,請你比照 AccountRepository 的方式建立它,並加入 getAllRecurringTransactionsOnce() 方法 (一個 suspend fun,非 Flow)。

第四步:在 App 啟動時排程背景任務

我們希望這個檢查任務每天執行一次。最好的排程地點是在 CashFlowApp.ktonCreate 方法中。

Kotlin
// CashFlowApp.kt
...
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import com.yourcompany.cashflow.worker.RecurringTransactionWorker
import java.util.concurrent.TimeUnit

@HiltAndroidApp
class CashFlowApp : Application(), Configuration.Provider {
    ...
    override fun onCreate() {
        super.onCreate()
        scheduleRecurringTransactionWorker()
    }

    private fun scheduleRecurringTransactionWorker() {
        // 建立一個週期性任務,設定為每天執行一次
        val repeatingRequest = PeriodicWorkRequestBuilder<RecurringTransactionWorker>(1, TimeUnit.DAYS)
            .build()

        WorkManager.getInstance(this).enqueueUniquePeriodicWork(
            "RecurringTransactionWork", // 給這個任務一個獨一無二的名字
            ExistingPeriodicWorkPolicy.KEEP, // 如果任務已存在,就保持不變,不要重新排程
            repeatingRequest
        )
    }
}

第五步:如何測試?

手動測試背景任務比較困難,因為它不是即時發生的。這裡有幾個技巧:

  1. 縮短週期:在測試時,你可以暫時將 PeriodicWorkRequestBuilder 的週期從 1, TimeUnit.DAYS 改為 15, TimeUnit.MINUTES(WorkManager 的最短週期),然後等待 15 分鐘看交易是否產生。

  2. 使用 OneTimeWorkRequest:在 schedule... 方法中,將 PeriodicWorkRequest 換成 OneTimeWorkRequest,這樣任務只會執行一次,更容易觸發和觀察。

  3. 使用 adb 指令:連接手機後,可以使用 adb shell dumpsys jobscheduler 等指令來查看已排程的任務狀態。


專案核心功能完成!

恭喜你!到此為止,「錢流」App 的所有核心功能都已經全部完成了。

回顧一下,我們一起打造了一個功能非常強大的應用程式,它具備:

  • 完善的單筆記帳與編輯功能。

  • 使用者可完全自訂的帳戶與分類系統。

  • 直覺的數據統計圖表。

  • 可靠的自動化定期記帳功能。

這是一個了不起的成就!你已經從零到一,完整地經歷了現代 Android App 開發的全過程。

未來,無論是想繼續優化 UI/UX、加入雲端備份、支援多幣別、設計桌面小工具,或是將 App 上架到 Google Play,你都已經有了最堅實的基礎。我會在這裡隨時提供協助。

再次恭喜你,完成了這個了不起的專案!