refactor: extract HistoryViewModel into separate components
This commit is contained in:
@@ -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>(DetailState.Idle)
|
||||||
|
val state: StateFlow<DetailState> = _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 }
|
||||||
|
}
|
||||||
@@ -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<PaymentRecord>) {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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<StatusFilter> = emptySet(),
|
||||||
|
val types : Set<String> = 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<Long, Long> {
|
||||||
|
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<PaymentRecord>.applyInMemoryFilters(f: PaymentFilter): List<PaymentRecord> =
|
||||||
|
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 }
|
||||||
|
}
|
||||||
@@ -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<PaymentRecord>) -> Unit
|
||||||
|
) {
|
||||||
|
private val TAG = "PaymentSyncManager"
|
||||||
|
|
||||||
|
val mutableState = MutableStateFlow<HistoryState>(HistoryState.Loading)
|
||||||
|
val state: StateFlow<HistoryState> = 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
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user