refactor: extract HistoryViewModel into separate components
This commit is contained in:
@@ -8,8 +8,8 @@ import com.bitcointxoko.gudariwallet.data.db.AppDatabase
|
|||||||
import com.bitcointxoko.gudariwallet.data.db.toDomain
|
import com.bitcointxoko.gudariwallet.data.db.toDomain
|
||||||
import com.bitcointxoko.gudariwallet.data.db.toEntity
|
import com.bitcointxoko.gudariwallet.data.db.toEntity
|
||||||
import com.bitcointxoko.gudariwallet.util.WalletConstants
|
import com.bitcointxoko.gudariwallet.util.WalletConstants
|
||||||
import com.bitcointxoko.gudariwallet.ui.PaymentFilter
|
import com.bitcointxoko.gudariwallet.ui.history.PaymentFilter
|
||||||
import com.bitcointxoko.gudariwallet.ui.StatusFilter
|
import com.bitcointxoko.gudariwallet.ui.history.StatusFilter
|
||||||
|
|
||||||
private const val TAG = "PaymentCacheRepository"
|
private const val TAG = "PaymentCacheRepository"
|
||||||
|
|
||||||
|
|||||||
@@ -1,440 +0,0 @@
|
|||||||
package com.bitcointxoko.gudariwallet.ui
|
|
||||||
|
|
||||||
import android.util.Log
|
|
||||||
import androidx.lifecycle.ViewModel
|
|
||||||
import androidx.lifecycle.ViewModelProvider
|
|
||||||
import androidx.lifecycle.viewModelScope
|
|
||||||
import com.bitcointxoko.gudariwallet.data.NodeAliasRepository
|
|
||||||
import com.bitcointxoko.gudariwallet.api.PaymentRecord
|
|
||||||
import com.bitcointxoko.gudariwallet.data.PaymentCacheRepository
|
|
||||||
import com.bitcointxoko.gudariwallet.data.WalletRepository
|
|
||||||
import com.bitcointxoko.gudariwallet.service.WalletNotificationService
|
|
||||||
import com.bitcointxoko.gudariwallet.util.WalletConstants
|
|
||||||
import kotlinx.coroutines.FlowPreview
|
|
||||||
import kotlinx.coroutines.Job
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
import kotlinx.coroutines.flow.combine
|
|
||||||
import kotlinx.coroutines.flow.debounce
|
|
||||||
import kotlinx.coroutines.flow.map
|
|
||||||
import kotlinx.coroutines.flow.stateIn
|
|
||||||
import kotlinx.coroutines.flow.update
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
|
|
||||||
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
|
|
||||||
)
|
|
||||||
|
|
||||||
val PaymentFilter.isActive: Boolean
|
|
||||||
get() = direction != DirectionFilter.ALL
|
|
||||||
|| statuses.isNotEmpty()
|
|
||||||
|| types.isNotEmpty()
|
|
||||||
|| minAmountSat != null
|
|
||||||
|| maxAmountSat != null
|
|
||||||
|| minCreatedAt != null
|
|
||||||
|| maxCreatedAt != null
|
|
||||||
|
|
||||||
enum class DirectionFilter { ALL, OUTGOING, INCOMING }
|
|
||||||
enum class StatusFilter { COMPLETED, PENDING, FAILED }
|
|
||||||
enum class DatePreset { THIS_WEEK, THIS_MONTH, THIS_YEAR }
|
|
||||||
|
|
||||||
@OptIn(FlowPreview::class)
|
|
||||||
class HistoryViewModel(
|
|
||||||
private val repo : WalletRepository,
|
|
||||||
private val aliasRepo : NodeAliasRepository,
|
|
||||||
private val paymentCache : PaymentCacheRepository,
|
|
||||||
val fiatCurrency : StateFlow<String?>,
|
|
||||||
val fiatSatsPerUnit : StateFlow<Double?>
|
|
||||||
) : ViewModel() {
|
|
||||||
|
|
||||||
private val _state = MutableStateFlow<HistoryState>(HistoryState.Loading)
|
|
||||||
val state: StateFlow<HistoryState> = _state
|
|
||||||
|
|
||||||
// ── Filter state (search is just another filter field) ────────────────────
|
|
||||||
private val _filter = MutableStateFlow(PaymentFilter())
|
|
||||||
val filter: StateFlow<PaymentFilter> = _filter
|
|
||||||
|
|
||||||
fun setSearchQuery(q: String) { _filter.update { it.copy(searchQuery = q.trim()) } }
|
|
||||||
fun clearSearch() { _filter.update { it.copy(searchQuery = "") } }
|
|
||||||
|
|
||||||
fun setDirectionFilter(direction: DirectionFilter) {
|
|
||||||
_filter.update { it.copy(direction = direction) }
|
|
||||||
}
|
|
||||||
fun toggleStatusFilter(status: StatusFilter) {
|
|
||||||
_filter.update { f ->
|
|
||||||
val updated = if (status in f.statuses) f.statuses - status else f.statuses + status
|
|
||||||
f.copy(statuses = updated)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fun toggleTypeFilter(type: String) {
|
|
||||||
_filter.update { f ->
|
|
||||||
val updated = if (type in f.types) f.types - type else f.types + type
|
|
||||||
f.copy(types = updated)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fun setAmountFilter(min: Long?, max: Long?) {
|
|
||||||
_filter.update { it.copy(minAmountSat = min, maxAmountSat = max) }
|
|
||||||
}
|
|
||||||
fun setDateFilter(min: Long?, max: Long?, preset: DatePreset? = null) {
|
|
||||||
_filter.update { it.copy(minCreatedAt = min, maxCreatedAt = max, datePreset = preset) }
|
|
||||||
}
|
|
||||||
fun applyDatePreset(preset: DatePreset) {
|
|
||||||
val now = java.time.ZonedDateTime.now()
|
|
||||||
val (min, max) = when (preset) {
|
|
||||||
DatePreset.THIS_WEEK -> {
|
|
||||||
val start = now.with(java.time.DayOfWeek.MONDAY)
|
|
||||||
.truncatedTo(java.time.temporal.ChronoUnit.DAYS)
|
|
||||||
start.toEpochSecond() to now.toEpochSecond()
|
|
||||||
}
|
|
||||||
DatePreset.THIS_MONTH -> {
|
|
||||||
val start = now.withDayOfMonth(1)
|
|
||||||
.truncatedTo(java.time.temporal.ChronoUnit.DAYS)
|
|
||||||
start.toEpochSecond() to now.toEpochSecond()
|
|
||||||
}
|
|
||||||
DatePreset.THIS_YEAR -> {
|
|
||||||
val start = now.withDayOfYear(1)
|
|
||||||
.truncatedTo(java.time.temporal.ChronoUnit.DAYS)
|
|
||||||
start.toEpochSecond() to now.toEpochSecond()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setDateFilter(min, max, preset)
|
|
||||||
}
|
|
||||||
fun clearDateFilter() {
|
|
||||||
_filter.update { it.copy(minCreatedAt = null, maxCreatedAt = null, datePreset = null) }
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Distinct non-null tags present in the currently loaded list. */
|
|
||||||
val availableTypes: StateFlow<List<String>> = _state
|
|
||||||
.map { s ->
|
|
||||||
if (s !is HistoryState.Success) emptyList()
|
|
||||||
else s.payments.mapNotNull { it.extra?.tag }.distinct().sorted()
|
|
||||||
}
|
|
||||||
.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList())
|
|
||||||
|
|
||||||
// ── Room-backed query results ─────────────────────────────────────────────
|
|
||||||
|
|
||||||
private val _roomResults = MutableStateFlow<List<PaymentRecord>?>(null)
|
|
||||||
|
|
||||||
private fun PaymentFilter.needsRoomQuery(): Boolean =
|
|
||||||
searchQuery.isNotBlank() ||
|
|
||||||
statuses.isNotEmpty() ||
|
|
||||||
minAmountSat != null || maxAmountSat != null ||
|
|
||||||
minCreatedAt != null || maxCreatedAt != null
|
|
||||||
|
|
||||||
init {
|
|
||||||
// Debounce only the search query field; other filter changes are instant.
|
|
||||||
// We do this by splitting the flow: debounce a search-only signal, then
|
|
||||||
// re-combine with the rest of the filter so non-search changes are fast.
|
|
||||||
viewModelScope.launch {
|
|
||||||
combine(
|
|
||||||
_filter.map { it.searchQuery }.debounce(300),
|
|
||||||
_filter
|
|
||||||
) { _, f -> f }
|
|
||||||
.collect { f ->
|
|
||||||
if (!f.needsRoomQuery()) {
|
|
||||||
Log.d(TAG, "FILTER [IN-MEMORY] filter=default → using cached _state")
|
|
||||||
_roomResults.value = null
|
|
||||||
} else {
|
|
||||||
Log.d(TAG, "FILTER [ROOM ] q=\"${f.searchQuery}\" " +
|
|
||||||
"statuses=${f.statuses} dir=${f.direction} types=${f.types} " +
|
|
||||||
"amt=${f.minAmountSat}–${f.maxAmountSat} " +
|
|
||||||
"date=${f.minCreatedAt}–${f.maxCreatedAt}")
|
|
||||||
_roomResults.value = paymentCache.queryAll(f.searchQuery, f)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
viewModelScope.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
|
|
||||||
_state.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()
|
|
||||||
}
|
|
||||||
viewModelScope.launch {
|
|
||||||
WalletNotificationService.paymentEvents.collect { event ->
|
|
||||||
Log.d(TAG, "PAYMENTS [WS EVENT ] incoming event ${event.paymentHash} — refreshing list")
|
|
||||||
val current = _state.value
|
|
||||||
if (current is HistoryState.Success) {
|
|
||||||
_state.value = current.copy(isRefreshing = true)
|
|
||||||
}
|
|
||||||
syncNewPayments()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── filteredState ─────────────────────────────────────────────────────────
|
|
||||||
val filteredState = combine(_state, _filter, _roomResults) { s, f, roomResults ->
|
|
||||||
if (s !is HistoryState.Success) return@combine s
|
|
||||||
if (roomResults == null) {
|
|
||||||
s.copy(payments = s.payments.applyInMemoryFilters(f))
|
|
||||||
} else {
|
|
||||||
s.copy(payments = roomResults.applyInMemoryFilters(f), canLoadMore = false)
|
|
||||||
}
|
|
||||||
}.stateIn(viewModelScope, SharingStarted.Eagerly, HistoryState.Loading)
|
|
||||||
|
|
||||||
private var currentOffset = 0
|
|
||||||
private var loadJob: Job? = null
|
|
||||||
|
|
||||||
private fun List<PaymentRecord>.applyInMemoryFilters(f: PaymentFilter) =
|
|
||||||
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 }
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
private 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 = _state.value
|
|
||||||
if (current is HistoryState.Success) {
|
|
||||||
Log.w(TAG, "PAYMENTS [SYNC ERR ] ${e.message} — keeping cached list")
|
|
||||||
_state.value = current.copy(isRefreshing = false)
|
|
||||||
} else {
|
|
||||||
Log.w(TAG, "PAYMENTS [SYNC ERR ] ${e.message} — no cache to fall back on")
|
|
||||||
_state.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 + ((_state.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
|
|
||||||
)
|
|
||||||
_state.value = newState
|
|
||||||
enrichPayments(newState.payments)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun refresh() {
|
|
||||||
loadJob?.cancel()
|
|
||||||
currentOffset = 0
|
|
||||||
val current = _state.value
|
|
||||||
if (current is HistoryState.Success) {
|
|
||||||
_state.value = current.copy(isRefreshing = true)
|
|
||||||
} else {
|
|
||||||
_state.value = HistoryState.Loading
|
|
||||||
}
|
|
||||||
viewModelScope.launch { syncNewPayments() }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun loadMore() {
|
|
||||||
if (loadJob?.isActive == true) return
|
|
||||||
// Guard against Room path — filteredState already returns canLoadMore=false
|
|
||||||
val filtered = filteredState.value
|
|
||||||
if (filtered !is HistoryState.Success || !filtered.canLoadMore) return
|
|
||||||
val current = _state.value
|
|
||||||
if (current !is HistoryState.Success || !current.canLoadMore) return
|
|
||||||
|
|
||||||
loadJob = viewModelScope.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
|
|
||||||
)
|
|
||||||
_state.value = newState
|
|
||||||
enrichPayments(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
|
|
||||||
)
|
|
||||||
_state.value = newState
|
|
||||||
enrichPayments(newState.payments)
|
|
||||||
}.onFailure { e ->
|
|
||||||
Log.w(TAG, "PAYMENTS [MORE ERR ] ${e.message}")
|
|
||||||
_state.value = current.copy(isRefreshing = false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Enrichment (pubkey + alias) ────────────────────────────────────────────
|
|
||||||
private fun enrichPayments(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)")
|
|
||||||
viewModelScope.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)
|
|
||||||
val current = _state.value
|
|
||||||
if (current is HistoryState.Success) {
|
|
||||||
_state.value = current.copy(
|
|
||||||
payments = current.payments.map { p ->
|
|
||||||
if (p.paymentHash == payment.paymentHash)
|
|
||||||
p.copy(pubkey = pubkey, alias = alias)
|
|
||||||
else p
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Payment detail ────────────────────────────────────────────────────────
|
|
||||||
private val _detailState = MutableStateFlow<DetailState>(DetailState.Idle)
|
|
||||||
val detailState: StateFlow<DetailState> = _detailState
|
|
||||||
|
|
||||||
fun loadDetail(checkingId: String, record: PaymentRecord? = null) {
|
|
||||||
paymentCache.getCachedDetail(checkingId)?.let {
|
|
||||||
Log.d(TAG, "PAYMENTS [DETAIL HIT] $checkingId — instant from cache")
|
|
||||||
_detailState.value = DetailState.Success(it)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (record != null) {
|
|
||||||
Log.d(TAG, "PAYMENTS [DETAIL PAR] $checkingId — showing partial from PaymentRecord")
|
|
||||||
_detailState.value = DetailState.Partial(record)
|
|
||||||
} else {
|
|
||||||
Log.d(TAG, "PAYMENTS [DETAIL LOD] $checkingId — no record, showing spinner")
|
|
||||||
_detailState.value = DetailState.Loading
|
|
||||||
}
|
|
||||||
viewModelScope.launch {
|
|
||||||
val fromDb = paymentCache.getDetailFromDb(checkingId)
|
|
||||||
if (fromDb != null) {
|
|
||||||
paymentCache.saveDetail(checkingId, fromDb)
|
|
||||||
_detailState.value = DetailState.Success(fromDb)
|
|
||||||
Log.d(TAG, "PAYMENTS [DETAIL DB ] $checkingId — served from Room, skipping network")
|
|
||||||
return@launch
|
|
||||||
}
|
|
||||||
runCatching {
|
|
||||||
repo.getPaymentDetail(checkingId)
|
|
||||||
}.onSuccess { detail ->
|
|
||||||
Log.d(TAG, "PAYMENTS [DETAIL NET] $checkingId — fetched from network")
|
|
||||||
paymentCache.saveDetail(checkingId, detail)
|
|
||||||
_detailState.value = DetailState.Success(detail)
|
|
||||||
}.onFailure { e ->
|
|
||||||
Log.w(TAG, "PAYMENTS [DETAIL ERR] $checkingId — ${e.message}")
|
|
||||||
if (_detailState.value !is DetailState.Partial) {
|
|
||||||
_detailState.value = DetailState.Error(e.message ?: "Failed to load payment")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun clearDetail() { _detailState.value = DetailState.Idle }
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
private const val TAG = "HistoryViewModel"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Factory ───────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
class HistoryViewModelFactory(
|
|
||||||
private val repo : WalletRepository,
|
|
||||||
private val aliasRepo : NodeAliasRepository,
|
|
||||||
private val paymentCache : PaymentCacheRepository,
|
|
||||||
private val fiatCurrency : StateFlow<String?>,
|
|
||||||
private val fiatSatsPerUnit: StateFlow<Double?>
|
|
||||||
) : ViewModelProvider.Factory {
|
|
||||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
|
||||||
@Suppress("UNCHECKED_CAST")
|
|
||||||
return HistoryViewModel(
|
|
||||||
repo = repo,
|
|
||||||
aliasRepo = aliasRepo,
|
|
||||||
paymentCache = paymentCache,
|
|
||||||
fiatCurrency = fiatCurrency,
|
|
||||||
fiatSatsPerUnit = fiatSatsPerUnit
|
|
||||||
) as T
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -54,6 +54,8 @@ import androidx.navigation.compose.rememberNavController
|
|||||||
import androidx.navigation.navArgument
|
import androidx.navigation.navArgument
|
||||||
import com.bitcointxoko.gudariwallet.MainActivity
|
import com.bitcointxoko.gudariwallet.MainActivity
|
||||||
import com.bitcointxoko.gudariwallet.ui.history.HistoryScreen
|
import com.bitcointxoko.gudariwallet.ui.history.HistoryScreen
|
||||||
|
import com.bitcointxoko.gudariwallet.ui.history.HistoryViewModel
|
||||||
|
import com.bitcointxoko.gudariwallet.ui.history.HistoryViewModelFactory
|
||||||
import com.bitcointxoko.gudariwallet.ui.history.PaymentDetailScreen
|
import com.bitcointxoko.gudariwallet.ui.history.PaymentDetailScreen
|
||||||
import com.bitcointxoko.gudariwallet.ui.receive.ReceiveScreen
|
import com.bitcointxoko.gudariwallet.ui.receive.ReceiveScreen
|
||||||
import com.bitcointxoko.gudariwallet.ui.send.SendScreen
|
import com.bitcointxoko.gudariwallet.ui.send.SendScreen
|
||||||
|
|||||||
@@ -31,10 +31,10 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.bitcointxoko.gudariwallet.ui.DatePreset
|
import com.bitcointxoko.gudariwallet.ui.history.DatePreset
|
||||||
import com.bitcointxoko.gudariwallet.ui.DirectionFilter
|
import com.bitcointxoko.gudariwallet.ui.history.DirectionFilter
|
||||||
import com.bitcointxoko.gudariwallet.ui.PaymentFilter
|
import com.bitcointxoko.gudariwallet.ui.history.PaymentFilter
|
||||||
import com.bitcointxoko.gudariwallet.ui.StatusFilter
|
import com.bitcointxoko.gudariwallet.ui.history.StatusFilter
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
|
|||||||
@@ -57,13 +57,13 @@ import androidx.compose.ui.graphics.Color
|
|||||||
import androidx.compose.ui.platform.LocalClipboard
|
import androidx.compose.ui.platform.LocalClipboard
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.bitcointxoko.gudariwallet.api.PaymentRecord
|
import com.bitcointxoko.gudariwallet.api.PaymentRecord
|
||||||
import com.bitcointxoko.gudariwallet.ui.DatePreset
|
import com.bitcointxoko.gudariwallet.ui.history.DatePreset
|
||||||
import com.bitcointxoko.gudariwallet.ui.DirectionFilter
|
import com.bitcointxoko.gudariwallet.ui.history.DirectionFilter
|
||||||
import com.bitcointxoko.gudariwallet.ui.HistoryState
|
import com.bitcointxoko.gudariwallet.ui.HistoryState
|
||||||
import com.bitcointxoko.gudariwallet.ui.HistoryViewModel
|
import com.bitcointxoko.gudariwallet.ui.history.HistoryViewModel
|
||||||
import com.bitcointxoko.gudariwallet.ui.PaymentFilter
|
import com.bitcointxoko.gudariwallet.ui.history.PaymentFilter
|
||||||
import com.bitcointxoko.gudariwallet.ui.StatusFilter
|
import com.bitcointxoko.gudariwallet.ui.history.StatusFilter
|
||||||
import com.bitcointxoko.gudariwallet.ui.isActive
|
import com.bitcointxoko.gudariwallet.ui.history.isActive
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
// ── List screen ──────────────────────────────────────────────────────────────
|
// ── List screen ──────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -0,0 +1,201 @@
|
|||||||
|
package com.bitcointxoko.gudariwallet.ui.history
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.ViewModelProvider
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
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 com.bitcointxoko.gudariwallet.service.WalletNotificationService
|
||||||
|
import com.bitcointxoko.gudariwallet.ui.DetailState
|
||||||
|
import com.bitcointxoko.gudariwallet.ui.HistoryState
|
||||||
|
import kotlinx.coroutines.FlowPreview
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.debounce
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
@OptIn(FlowPreview::class)
|
||||||
|
class HistoryViewModel(
|
||||||
|
private val repo : WalletRepository,
|
||||||
|
private val aliasRepo : NodeAliasRepository,
|
||||||
|
private val paymentCache : PaymentCacheRepository,
|
||||||
|
val fiatCurrency : StateFlow<String?>,
|
||||||
|
val fiatSatsPerUnit : StateFlow<Double?>
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
// ── Sync manager (owns _state, currentOffset, loadJob, sync loop) ─────────
|
||||||
|
private val syncManager: PaymentSyncManager = PaymentSyncManager(
|
||||||
|
repo = repo,
|
||||||
|
paymentCache = paymentCache,
|
||||||
|
scope = viewModelScope,
|
||||||
|
onNewPayments = { payments -> enricher.enrich(payments) }
|
||||||
|
)
|
||||||
|
|
||||||
|
private val enricher: PaymentEnricher = PaymentEnricher(
|
||||||
|
repo = repo,
|
||||||
|
aliasRepo = aliasRepo,
|
||||||
|
paymentCache = paymentCache,
|
||||||
|
scope = viewModelScope,
|
||||||
|
onEnriched = { paymentHash, pubkey, alias ->
|
||||||
|
syncManager.updateEnrichedPayment(paymentHash, pubkey, alias)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
val state: StateFlow<HistoryState> = syncManager.state
|
||||||
|
|
||||||
|
fun refresh() = syncManager.refresh()
|
||||||
|
fun loadMore() = syncManager.loadMore(
|
||||||
|
filteredCanLoadMore = { (filteredState.value as? HistoryState.Success)?.canLoadMore == true }
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Detail delegate ───────────────────────────────────────────────────────
|
||||||
|
private val detailDelegate = PaymentDetailDelegate(
|
||||||
|
repo = repo,
|
||||||
|
paymentCache = paymentCache,
|
||||||
|
scope = viewModelScope
|
||||||
|
)
|
||||||
|
val detailState: StateFlow<DetailState> = detailDelegate.state
|
||||||
|
|
||||||
|
fun loadDetail(checkingId: String, record: PaymentRecord? = null) =
|
||||||
|
detailDelegate.load(checkingId, record)
|
||||||
|
|
||||||
|
fun clearDetail() = detailDelegate.clear()
|
||||||
|
|
||||||
|
// ── Filter state ──────────────────────────────────────────────────────────
|
||||||
|
private val _filter = MutableStateFlow(PaymentFilter())
|
||||||
|
val filter: StateFlow<PaymentFilter> = _filter
|
||||||
|
|
||||||
|
fun setSearchQuery(q: String) { _filter.update { it.copy(searchQuery = q.trim()) } }
|
||||||
|
fun clearSearch() { _filter.update { it.copy(searchQuery = "") } }
|
||||||
|
|
||||||
|
fun setDirectionFilter(direction: DirectionFilter) {
|
||||||
|
_filter.update { it.copy(direction = direction) }
|
||||||
|
}
|
||||||
|
fun toggleStatusFilter(status: StatusFilter) {
|
||||||
|
_filter.update { f ->
|
||||||
|
val updated = if (status in f.statuses) f.statuses - status else f.statuses + status
|
||||||
|
f.copy(statuses = updated)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fun toggleTypeFilter(type: String) {
|
||||||
|
_filter.update { f ->
|
||||||
|
val updated = if (type in f.types) f.types - type else f.types + type
|
||||||
|
f.copy(types = updated)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fun setAmountFilter(min: Long?, max: Long?) {
|
||||||
|
_filter.update { it.copy(minAmountSat = min, maxAmountSat = max) }
|
||||||
|
}
|
||||||
|
fun setDateFilter(min: Long?, max: Long?, preset: DatePreset? = null) {
|
||||||
|
_filter.update { it.copy(minCreatedAt = min, maxCreatedAt = max, datePreset = preset) }
|
||||||
|
}
|
||||||
|
fun applyDatePreset(preset: DatePreset) {
|
||||||
|
val (min, max) = preset.toEpochSecondRange() // ← delegate to PaymentFilter.kt
|
||||||
|
setDateFilter(min, max, preset)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearDateFilter() {
|
||||||
|
_filter.update { it.copy(minCreatedAt = null, maxCreatedAt = null, datePreset = null) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Room-backed query results ──────────────────────────────────────────────
|
||||||
|
private val _roomResults = MutableStateFlow<List<PaymentRecord>?>(null)
|
||||||
|
|
||||||
|
// ── Derived state ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Distinct non-null tags present in the currently loaded list. */
|
||||||
|
val availableTypes: StateFlow<List<String>> = syncManager.state
|
||||||
|
.map { s ->
|
||||||
|
if (s !is HistoryState.Success) emptyList()
|
||||||
|
else s.payments.mapNotNull { it.extra?.tag }.distinct().sorted()
|
||||||
|
}
|
||||||
|
.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList())
|
||||||
|
|
||||||
|
val filteredState = combine(syncManager.state, _filter, _roomResults) { s, f, roomResults ->
|
||||||
|
if (s !is HistoryState.Success) return@combine s
|
||||||
|
if (roomResults == null) {
|
||||||
|
s.copy(payments = s.payments.applyInMemoryFilters(f))
|
||||||
|
} else {
|
||||||
|
s.copy(payments = roomResults.applyInMemoryFilters(f), canLoadMore = false)
|
||||||
|
}
|
||||||
|
}.stateIn(viewModelScope, SharingStarted.Eagerly, HistoryState.Loading)
|
||||||
|
|
||||||
|
// ── In-memory filter logic ────────────────────────────────────────────────
|
||||||
|
private fun List<PaymentRecord>.applyInMemoryFilters(f: PaymentFilter) =
|
||||||
|
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 }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Init ──────────────────────────────────────────────────────────────────
|
||||||
|
init {
|
||||||
|
syncManager.init()
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
combine(
|
||||||
|
_filter.map { it.searchQuery }.debounce(300),
|
||||||
|
_filter
|
||||||
|
) { _, f -> f }
|
||||||
|
.collect { f ->
|
||||||
|
if (!f.needsRoomQuery()) {
|
||||||
|
Log.d(TAG, "FILTER [IN-MEMORY] filter=default → using cached state")
|
||||||
|
_roomResults.value = null
|
||||||
|
} else {
|
||||||
|
Log.d(TAG, "FILTER [ROOM ] q=\"${f.searchQuery}\" " +
|
||||||
|
"statuses=${f.statuses} dir=${f.direction} types=${f.types} " +
|
||||||
|
"amt=${f.minAmountSat}–${f.maxAmountSat} " +
|
||||||
|
"date=${f.minCreatedAt}–${f.maxCreatedAt}")
|
||||||
|
_roomResults.value = paymentCache.queryAll(f.searchQuery, f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
WalletNotificationService.paymentEvents.collect { event ->
|
||||||
|
Log.d(TAG, "PAYMENTS [WS EVENT ] incoming event ${event.paymentHash} — refreshing list")
|
||||||
|
syncManager.refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "HistoryViewModel"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Factory ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class HistoryViewModelFactory(
|
||||||
|
private val repo : WalletRepository,
|
||||||
|
private val aliasRepo : NodeAliasRepository,
|
||||||
|
private val paymentCache : PaymentCacheRepository,
|
||||||
|
private val fiatCurrency : StateFlow<String?>,
|
||||||
|
private val fiatSatsPerUnit: StateFlow<Double?>
|
||||||
|
) : ViewModelProvider.Factory {
|
||||||
|
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
return HistoryViewModel(
|
||||||
|
repo = repo,
|
||||||
|
aliasRepo = aliasRepo,
|
||||||
|
paymentCache = paymentCache,
|
||||||
|
fiatCurrency = fiatCurrency,
|
||||||
|
fiatSatsPerUnit = fiatSatsPerUnit
|
||||||
|
) as T
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -44,7 +44,7 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
import com.bitcointxoko.gudariwallet.api.PaymentRecord
|
import com.bitcointxoko.gudariwallet.api.PaymentRecord
|
||||||
import com.bitcointxoko.gudariwallet.ui.DetailState
|
import com.bitcointxoko.gudariwallet.ui.DetailState
|
||||||
import com.bitcointxoko.gudariwallet.ui.HistoryViewModel
|
import com.bitcointxoko.gudariwallet.ui.history.HistoryViewModel
|
||||||
import com.bitcointxoko.gudariwallet.util.feePpm
|
import com.bitcointxoko.gudariwallet.util.feePpm
|
||||||
import com.bitcointxoko.gudariwallet.util.formatFiatForSats
|
import com.bitcointxoko.gudariwallet.util.formatFiatForSats
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|||||||
Reference in New Issue
Block a user