From 432cd0e478e3641677be733f89f431ed252deb8a Mon Sep 17 00:00:00 2001 From: rasputin Date: Wed, 3 Jun 2026 21:06:10 +0200 Subject: [PATCH] refactor: extract HistoryViewModel into separate components --- .../ui/history/PaymentDetailDelegate.kt | 60 ++++++ .../ui/history/PaymentEnricher.kt | 42 ++++ .../gudariwallet/ui/history/PaymentFilter.kt | 62 ++++++ .../ui/history/PaymentSyncManager.kt | 195 ++++++++++++++++++ 4 files changed, 359 insertions(+) create mode 100644 app/src/main/java/com/bitcointxoko/gudariwallet/ui/history/PaymentDetailDelegate.kt create mode 100644 app/src/main/java/com/bitcointxoko/gudariwallet/ui/history/PaymentEnricher.kt create mode 100644 app/src/main/java/com/bitcointxoko/gudariwallet/ui/history/PaymentFilter.kt create mode 100644 app/src/main/java/com/bitcointxoko/gudariwallet/ui/history/PaymentSyncManager.kt diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/history/PaymentDetailDelegate.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/history/PaymentDetailDelegate.kt new file mode 100644 index 0000000..a406235 --- /dev/null +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/history/PaymentDetailDelegate.kt @@ -0,0 +1,60 @@ +package com.bitcointxoko.gudariwallet.ui.history + +import android.util.Log +import com.bitcointxoko.gudariwallet.api.PaymentRecord +import com.bitcointxoko.gudariwallet.data.PaymentCacheRepository +import com.bitcointxoko.gudariwallet.data.WalletRepository +import com.bitcointxoko.gudariwallet.ui.DetailState +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch + +private const val TAG = "PaymentDetailDelegate" + +class PaymentDetailDelegate( + private val repo : WalletRepository, + private val paymentCache : PaymentCacheRepository, + private val scope : CoroutineScope +) { + private val _state = MutableStateFlow(DetailState.Idle) + val state: StateFlow = _state + + fun load(checkingId: String, record: PaymentRecord? = null) { + paymentCache.getCachedDetail(checkingId)?.let { + Log.d(TAG, "DETAIL [HIT] $checkingId — instant from cache") + _state.value = DetailState.Success(it) + return + } + _state.value = if (record != null) { + Log.d(TAG, "DETAIL [PAR] $checkingId — partial from PaymentRecord") + DetailState.Partial(record) + } else { + Log.d(TAG, "DETAIL [LOD] $checkingId — no record, showing spinner") + DetailState.Loading + } + scope.launch { + val fromDb = paymentCache.getDetailFromDb(checkingId) + if (fromDb != null) { + paymentCache.saveDetail(checkingId, fromDb) + _state.value = DetailState.Success(fromDb) + Log.d(TAG, "DETAIL [DB] $checkingId — served from Room") + return@launch + } + runCatching { repo.getPaymentDetail(checkingId) } + .onSuccess { detail -> + Log.d(TAG, "DETAIL [NET] $checkingId — fetched from network") + paymentCache.saveDetail(checkingId, detail) + _state.value = DetailState.Success(detail) + } + .onFailure { e -> + Log.w(TAG, "DETAIL [ERR] $checkingId — ${e.message}") + if (_state.value !is DetailState.Partial) { + _state.value = DetailState.Error(e.message ?: "Failed to load payment") + } + } + } + } + + fun clear() { _state.value = DetailState.Idle } +} diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/history/PaymentEnricher.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/history/PaymentEnricher.kt new file mode 100644 index 0000000..52234cc --- /dev/null +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/history/PaymentEnricher.kt @@ -0,0 +1,42 @@ +package com.bitcointxoko.gudariwallet.ui.history + +import android.util.Log +import com.bitcointxoko.gudariwallet.api.PaymentRecord +import com.bitcointxoko.gudariwallet.data.NodeAliasRepository +import com.bitcointxoko.gudariwallet.data.PaymentCacheRepository +import com.bitcointxoko.gudariwallet.data.WalletRepository +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +private const val TAG = "PaymentEnricher" + +class PaymentEnricher( + private val repo : WalletRepository, + private val aliasRepo : NodeAliasRepository, + private val paymentCache : PaymentCacheRepository, + private val scope : CoroutineScope, + private val onEnriched : (paymentHash: String, pubkey: String, alias: String?) -> Unit +) { + fun enrich(payments: List) { + val candidates = payments.filter { it.isOutgoing } + if (candidates.isEmpty()) return + val toEnrich = candidates.filter { !it.bolt11.isNullOrBlank() && it.pubkey == null } + Log.d(TAG, "ENRICH [START ] ${toEnrich.size} payments to enrich " + + "(${candidates.size - toEnrich.size} already enriched or no bolt11)") + if (toEnrich.isEmpty()) return + + scope.launch { + for (payment in toEnrich) { + val pubkey = runCatching { + repo.decodeBolt11(payment.bolt11!!).payee + }.getOrNull() ?: continue + val alias = runCatching { + aliasRepo.resolve(pubkey) + }.getOrNull() + paymentCache.updatePubkeyAlias(payment.paymentHash, pubkey, alias) + onEnriched(payment.paymentHash, pubkey, alias) + } + } + } +} + diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/history/PaymentFilter.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/history/PaymentFilter.kt new file mode 100644 index 0000000..f6c2d0d --- /dev/null +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/history/PaymentFilter.kt @@ -0,0 +1,62 @@ +package com.bitcointxoko.gudariwallet.ui.history + +import com.bitcointxoko.gudariwallet.api.PaymentRecord +import java.time.DayOfWeek +import java.time.ZonedDateTime +import java.time.temporal.ChronoUnit + +data class PaymentFilter( + val searchQuery : String = "", + val direction : DirectionFilter = DirectionFilter.ALL, + val statuses : Set = emptySet(), + val types : Set = emptySet(), + val minAmountSat : Long? = null, + val maxAmountSat : Long? = null, + val minCreatedAt : Long? = null, + val maxCreatedAt : Long? = null, + val datePreset : DatePreset? = null +) + +enum class DirectionFilter { ALL, OUTGOING, INCOMING } +enum class StatusFilter { COMPLETED, PENDING, FAILED } +enum class DatePreset { THIS_WEEK, THIS_MONTH, THIS_YEAR } + +val PaymentFilter.isActive: Boolean + get() = direction != DirectionFilter.ALL + || statuses.isNotEmpty() + || types.isNotEmpty() + || minAmountSat != null + || maxAmountSat != null + || minCreatedAt != null + || maxCreatedAt != null + +fun PaymentFilter.needsRoomQuery(): Boolean = + searchQuery.isNotBlank() || + statuses.isNotEmpty() || + minAmountSat != null || maxAmountSat != null || + minCreatedAt != null || maxCreatedAt != null + +/** Resolves a [DatePreset] to a (epochSecondMin, epochSecondMax) pair. */ +fun DatePreset.toEpochSecondRange(): Pair { + val now = ZonedDateTime.now() + val start = when (this) { + DatePreset.THIS_WEEK -> now.with(DayOfWeek.MONDAY).truncatedTo(ChronoUnit.DAYS) + DatePreset.THIS_MONTH -> now.withDayOfMonth(1).truncatedTo(ChronoUnit.DAYS) + DatePreset.THIS_YEAR -> now.withDayOfYear(1).truncatedTo(ChronoUnit.DAYS) + } + return start.toEpochSecond() to now.toEpochSecond() +} + +fun List.applyInMemoryFilters(f: PaymentFilter): List = + distinctBy { it.paymentHash } + .let { list -> + when (f.direction) { + DirectionFilter.ALL -> list + DirectionFilter.OUTGOING -> list.filter { it.isOutgoing } + DirectionFilter.INCOMING -> list.filter { !it.isOutgoing } + } + } + .let { list -> + if (f.types.isEmpty()) list + else list.filter { it.extra?.tag in f.types } + } diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/history/PaymentSyncManager.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/history/PaymentSyncManager.kt new file mode 100644 index 0000000..c279491 --- /dev/null +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/history/PaymentSyncManager.kt @@ -0,0 +1,195 @@ +package com.bitcointxoko.gudariwallet.ui.history + +import android.util.Log +import com.bitcointxoko.gudariwallet.api.PaymentRecord +import com.bitcointxoko.gudariwallet.data.PaymentCacheRepository +import com.bitcointxoko.gudariwallet.data.WalletRepository +import com.bitcointxoko.gudariwallet.ui.HistoryState +import com.bitcointxoko.gudariwallet.util.WalletConstants +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch + +class PaymentSyncManager( + private val repo : WalletRepository, + private val paymentCache : PaymentCacheRepository, + private val scope : CoroutineScope, + private val onNewPayments: (List) -> Unit +) { + private val TAG = "PaymentSyncManager" + + val mutableState = MutableStateFlow(HistoryState.Loading) + val state: StateFlow = mutableState + + var currentOffset = 0 + private set + + private var loadJob: Job? = null + + // ── Init ────────────────────────────────────────────────────────────────── + + fun init() { + scope.launch { + val cached = paymentCache.loadCachedPage( + offset = 0, + limit = WalletConstants.PAYMENTS_PAGE_SIZE + ) + if (cached.isNotEmpty()) { + Log.d(TAG, "PAYMENTS [INIT ] Showing ${cached.size} cached payments immediately") + currentOffset = cached.size + mutableState.value = HistoryState.Success( + payments = cached.distinctBy { it.paymentHash }, + canLoadMore = cached.size >= WalletConstants.PAYMENTS_PAGE_SIZE, + isRefreshing = true + ) + } else { + Log.d(TAG, "PAYMENTS [INIT ] No cache — cold load") + } + syncNewPayments() + } + } + + // ── Refresh ─────────────────────────────────────────────────────────────── + + fun refresh() { + loadJob?.cancel() + currentOffset = 0 + val current = mutableState.value + mutableState.value = if (current is HistoryState.Success) + current.copy(isRefreshing = true) + else + HistoryState.Loading + scope.launch { syncNewPayments() } + } + + // ── Load more ───────────────────────────────────────────────────────────── + + fun loadMore(filteredCanLoadMore: () -> Boolean) { + if (loadJob?.isActive == true) return + if (!filteredCanLoadMore()) return + val current = mutableState.value + if (current !is HistoryState.Success || !current.canLoadMore) return + + loadJob = scope.launch { + val fromDb = paymentCache.loadCachedPage( + offset = currentOffset, + limit = WalletConstants.PAYMENTS_PAGE_SIZE + ) + if (fromDb.size == WalletConstants.PAYMENTS_PAGE_SIZE) { + Log.d(TAG, "PAYMENTS [MORE DB ] offset=$currentOffset — served ${fromDb.size} rows from Room") + val deduplicated = (current.payments + fromDb).distinctBy { it.paymentHash } + currentOffset += fromDb.size + val newState = HistoryState.Success( + payments = deduplicated, + canLoadMore = true, + isRefreshing = false + ) + mutableState.value = newState + onNewPayments(newState.payments) + return@launch + } + + Log.d(TAG, "PAYMENTS [MORE NET ] offset=$currentOffset — DB has ${fromDb.size}, fetching from network") + runCatching { + repo.getPayments(offset = currentOffset, limit = WalletConstants.PAYMENTS_PAGE_SIZE) + }.onSuccess { newPage -> + paymentCache.mergePayments(newPage) + val deduplicated = (current.payments + newPage).distinctBy { it.paymentHash } + currentOffset += newPage.size + val newState = HistoryState.Success( + payments = deduplicated, + canLoadMore = newPage.size >= WalletConstants.PAYMENTS_PAGE_SIZE, + isRefreshing = false + ) + mutableState.value = newState + onNewPayments(newState.payments) + }.onFailure { e -> + Log.w(TAG, "PAYMENTS [MORE ERR ] ${e.message}") + mutableState.value = current.copy(isRefreshing = false) + } + } + } + + // ── Sync loop ───────────────────────────────────────────────────────────── + + suspend fun syncNewPayments(maxPayments: Int = WalletConstants.PAYMENTS_SYNC_CHECKPOINT) { + var syncOffset = 0 + var totalNew = 0 + var pagesChecked = 0 + + Log.d(TAG, "PAYMENTS [SYNC START] scanning for new payments (cap=$maxPayments)") + + while (true) { + if (totalNew >= maxPayments) { + Log.d(TAG, "PAYMENTS [SYNC CAP ] reached $maxPayments payment limit — stopping early") + break + } + + val page = runCatching { + repo.getPayments(offset = syncOffset, limit = WalletConstants.PAYMENTS_PAGE_SIZE) + }.getOrElse { e -> + val current = mutableState.value + if (current is HistoryState.Success) { + Log.w(TAG, "PAYMENTS [SYNC ERR ] ${e.message} — keeping cached list") + mutableState.value = current.copy(isRefreshing = false) + } else { + Log.w(TAG, "PAYMENTS [SYNC ERR ] ${e.message} — no cache to fall back on") + mutableState.value = HistoryState.Error(e.message ?: "Failed to load payments") + } + return + } + + pagesChecked++ + + val overlapIndex = page.indexOfFirst { paymentCache.containsPayment(it.checkingId) } + val newPayments = if (overlapIndex == -1) page else page.subList(0, overlapIndex) + val allowedNew = maxPayments - totalNew + val toMerge = if (newPayments.size > allowedNew) newPayments.subList(0, allowedNew) else newPayments + + if (toMerge.isNotEmpty()) { + paymentCache.mergePayments(toMerge) + totalNew += toMerge.size + val withTimestamp = toMerge.count { it.time.toLongOrNull() != null } + Log.d(TAG, "PAYMENTS [SYNC PAGE ] page $pagesChecked — ${toMerge.size} new payments merged ($withTimestamp with numeric timestamp)") + } + + val reachedOverlap = overlapIndex != -1 + val reachedEnd = page.size < WalletConstants.PAYMENTS_PAGE_SIZE + + if (reachedOverlap || reachedEnd) { + Log.d(TAG, "PAYMENTS [SYNC DONE ] $totalNew new payment(s) across $pagesChecked page(s) — " + + if (reachedOverlap) "stopped at known record" else "reached end of history") + break + } + + syncOffset += page.size + } + + val refreshed = paymentCache.loadCachedPage( + offset = 0, + limit = currentOffset.coerceAtLeast(WalletConstants.PAYMENTS_PAGE_SIZE) + ) + val merged = (refreshed + ((mutableState.value as? HistoryState.Success)?.payments ?: emptyList())) + .distinctBy { it.paymentHash } + val newState = HistoryState.Success( + payments = merged, + canLoadMore = refreshed.size >= currentOffset.coerceAtLeast(WalletConstants.PAYMENTS_PAGE_SIZE), + isRefreshing = false + ) + mutableState.value = newState + onNewPayments(newState.payments) + } + + fun updateEnrichedPayment(paymentHash: String, pubkey: String, alias: String?) { + val current = mutableState.value + if (current is HistoryState.Success) { + mutableState.value = current.copy( + payments = current.payments.map { p -> + if (p.paymentHash == paymentHash) p.copy(pubkey = pubkey, alias = alias) else p + } + ) + } + } +}