From ba96bde89c26b5d3e90da18c68a29f5ccfdb9d87 Mon Sep 17 00:00:00 2001 From: rasputin Date: Wed, 3 Jun 2026 21:06:04 +0200 Subject: [PATCH] refactor: extract HistoryViewModel into separate components --- .../data/PaymentCacheRepository.kt | 4 +- .../gudariwallet/ui/HistoryViewModel.kt | 440 ------------------ .../gudariwallet/ui/WalletScreen.kt | 10 +- .../ui/history/FilterBottomSheet.kt | 8 +- .../gudariwallet/ui/history/HistoryScreen.kt | 12 +- .../ui/history/HistoryViewModel.kt | 201 ++++++++ .../ui/history/PaymentDetailScreen.kt | 2 +- 7 files changed, 220 insertions(+), 457 deletions(-) delete mode 100644 app/src/main/java/com/bitcointxoko/gudariwallet/ui/HistoryViewModel.kt create mode 100644 app/src/main/java/com/bitcointxoko/gudariwallet/ui/history/HistoryViewModel.kt diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/data/PaymentCacheRepository.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/data/PaymentCacheRepository.kt index 707e18b..46d2523 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/data/PaymentCacheRepository.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/data/PaymentCacheRepository.kt @@ -8,8 +8,8 @@ import com.bitcointxoko.gudariwallet.data.db.AppDatabase import com.bitcointxoko.gudariwallet.data.db.toDomain import com.bitcointxoko.gudariwallet.data.db.toEntity import com.bitcointxoko.gudariwallet.util.WalletConstants -import com.bitcointxoko.gudariwallet.ui.PaymentFilter -import com.bitcointxoko.gudariwallet.ui.StatusFilter +import com.bitcointxoko.gudariwallet.ui.history.PaymentFilter +import com.bitcointxoko.gudariwallet.ui.history.StatusFilter private const val TAG = "PaymentCacheRepository" diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/HistoryViewModel.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/HistoryViewModel.kt deleted file mode 100644 index 023b403..0000000 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/HistoryViewModel.kt +++ /dev/null @@ -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 = 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 -) - -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, - val fiatSatsPerUnit : StateFlow -) : ViewModel() { - - private val _state = MutableStateFlow(HistoryState.Loading) - val state: StateFlow = _state - - // ── Filter state (search is just another filter field) ──────────────────── - private val _filter = MutableStateFlow(PaymentFilter()) - val filter: StateFlow = _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> = _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?>(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.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) { - 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.Idle) - val detailState: StateFlow = _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, - private val fiatSatsPerUnit: StateFlow -) : ViewModelProvider.Factory { - override fun create(modelClass: Class): T { - @Suppress("UNCHECKED_CAST") - return HistoryViewModel( - repo = repo, - aliasRepo = aliasRepo, - paymentCache = paymentCache, - fiatCurrency = fiatCurrency, - fiatSatsPerUnit = fiatSatsPerUnit - ) as T - } -} diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletScreen.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletScreen.kt index 5d193a5..b62b51e 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletScreen.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletScreen.kt @@ -54,6 +54,8 @@ import androidx.navigation.compose.rememberNavController import androidx.navigation.navArgument import com.bitcointxoko.gudariwallet.MainActivity 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.receive.ReceiveScreen import com.bitcointxoko.gudariwallet.ui.send.SendScreen @@ -82,10 +84,10 @@ fun WalletScreen( val context = LocalContext.current val historyFactory = remember { HistoryViewModelFactory( - repo = vm.repo, - aliasRepo = vm.aliasRepo, - paymentCache = vm.paymentCache, - fiatCurrency = vm.selectedCurrency, + repo = vm.repo, + aliasRepo = vm.aliasRepo, + paymentCache = vm.paymentCache, + fiatCurrency = vm.selectedCurrency, fiatSatsPerUnit = vm.fiatSatsPerUnit ) } diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/history/FilterBottomSheet.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/history/FilterBottomSheet.kt index 4da27b7..294382a 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/history/FilterBottomSheet.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/history/FilterBottomSheet.kt @@ -31,10 +31,10 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp -import com.bitcointxoko.gudariwallet.ui.DatePreset -import com.bitcointxoko.gudariwallet.ui.DirectionFilter -import com.bitcointxoko.gudariwallet.ui.PaymentFilter -import com.bitcointxoko.gudariwallet.ui.StatusFilter +import com.bitcointxoko.gudariwallet.ui.history.DatePreset +import com.bitcointxoko.gudariwallet.ui.history.DirectionFilter +import com.bitcointxoko.gudariwallet.ui.history.PaymentFilter +import com.bitcointxoko.gudariwallet.ui.history.StatusFilter @OptIn(ExperimentalMaterial3Api::class) @Composable diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/history/HistoryScreen.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/history/HistoryScreen.kt index 7fd3238..bf7dde2 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/history/HistoryScreen.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/history/HistoryScreen.kt @@ -57,13 +57,13 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.unit.dp import com.bitcointxoko.gudariwallet.api.PaymentRecord -import com.bitcointxoko.gudariwallet.ui.DatePreset -import com.bitcointxoko.gudariwallet.ui.DirectionFilter +import com.bitcointxoko.gudariwallet.ui.history.DatePreset +import com.bitcointxoko.gudariwallet.ui.history.DirectionFilter import com.bitcointxoko.gudariwallet.ui.HistoryState -import com.bitcointxoko.gudariwallet.ui.HistoryViewModel -import com.bitcointxoko.gudariwallet.ui.PaymentFilter -import com.bitcointxoko.gudariwallet.ui.StatusFilter -import com.bitcointxoko.gudariwallet.ui.isActive +import com.bitcointxoko.gudariwallet.ui.history.HistoryViewModel +import com.bitcointxoko.gudariwallet.ui.history.PaymentFilter +import com.bitcointxoko.gudariwallet.ui.history.StatusFilter +import com.bitcointxoko.gudariwallet.ui.history.isActive import kotlinx.coroutines.launch // ── List screen ────────────────────────────────────────────────────────────── diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/history/HistoryViewModel.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/history/HistoryViewModel.kt new file mode 100644 index 0000000..7063c28 --- /dev/null +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/history/HistoryViewModel.kt @@ -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, + val fiatSatsPerUnit : StateFlow +) : 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 = 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 = 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 = _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?>(null) + + // ── Derived state ───────────────────────────────────────────────────────── + + /** Distinct non-null tags present in the currently loaded list. */ + val availableTypes: StateFlow> = 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.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, + private val fiatSatsPerUnit: StateFlow +) : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + @Suppress("UNCHECKED_CAST") + return HistoryViewModel( + repo = repo, + aliasRepo = aliasRepo, + paymentCache = paymentCache, + fiatCurrency = fiatCurrency, + fiatSatsPerUnit = fiatSatsPerUnit + ) as T + } +} diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/history/PaymentDetailScreen.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/history/PaymentDetailScreen.kt index 2633b45..0b5c40d 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/history/PaymentDetailScreen.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/history/PaymentDetailScreen.kt @@ -44,7 +44,7 @@ import androidx.compose.ui.unit.dp import androidx.core.net.toUri import com.bitcointxoko.gudariwallet.api.PaymentRecord 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.formatFiatForSats import kotlinx.coroutines.launch