From 3c5539d2cb318410e14effb82d30013645e89882 Mon Sep 17 00:00:00 2001 From: rasputin Date: Sun, 14 Jun 2026 14:55:55 +0200 Subject: [PATCH] wire up historyviewmodel --- .../ui/history/HistoryViewModel.kt | 359 ++++++++++-------- 1 file changed, 205 insertions(+), 154 deletions(-) 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 index 446c12a..708a082 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/history/HistoryViewModel.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/history/HistoryViewModel.kt @@ -8,10 +8,13 @@ import com.bitcointxoko.gudariwallet.api.PaymentRecord import com.bitcointxoko.gudariwallet.data.ContactRepository import com.bitcointxoko.gudariwallet.data.NodeAliasRepository import com.bitcointxoko.gudariwallet.data.PaymentCacheRepository +import com.bitcointxoko.gudariwallet.data.PaymentFeedRepository import com.bitcointxoko.gudariwallet.data.WalletRepository import com.bitcointxoko.gudariwallet.data.db.ContactEntity import com.bitcointxoko.gudariwallet.data.db.ContactWithAddresses import com.bitcointxoko.gudariwallet.data.db.PaymentAddressType +import com.bitcointxoko.gudariwallet.data.model.PaymentFeedItem +import com.bitcointxoko.gudariwallet.data.model.toPaymentFeedItem import com.bitcointxoko.gudariwallet.service.WalletNotificationService import com.bitcointxoko.gudariwallet.ui.DetailState import com.bitcointxoko.gudariwallet.ui.HistoryState @@ -31,24 +34,31 @@ private const val TAG = "HistoryViewModel" @OptIn(FlowPreview::class) class HistoryViewModel( - private val repo : WalletRepository, - private val aliasRepo : NodeAliasRepository, - private val paymentCache : PaymentCacheRepository, - private val contactRepo : ContactRepository, - val fiatCurrency : StateFlow, - val fiatSatsPerUnit : StateFlow + private val repo : WalletRepository, + private val aliasRepo : NodeAliasRepository, + private val paymentCache : PaymentCacheRepository, + private val contactRepo : ContactRepository, + private val feedRepository : PaymentFeedRepository, + val fiatCurrency : StateFlow, + val fiatSatsPerUnit : StateFlow ) : ViewModel() { - // ── Sync manager (owns _state, currentOffset, loadJob, sync loop) ───────── + + // ── Sync manager ────────────────────────────────────────────────────────── + // Owns the websocket/sync loop, pagination, and optimistic inserts. + // Still operates on PaymentRecord internally — untouched by this refactor. private val syncManager: PaymentSyncManager = PaymentSyncManager( - repo = repo, - paymentCache = paymentCache, - scope = viewModelScope, + repo = repo, + paymentCache = paymentCache, + scope = viewModelScope, onNewPayments = { payments -> enricher.enrich(payments) } ) + // ── Optimistic insert (called after a successful send) ───────────────────── - fun primePayment(record: PaymentRecord) { - syncManager.primePayment(record) - } + fun primePayment(record: PaymentRecord) { syncManager.primePayment(record) } + + // ── Enricher ────────────────────────────────────────────────────────────── + // Resolves pubkey/alias post-send and writes back into NodeAliasCache, + // which feeds into the feedRepository.observeFeedItems() combine. private val enricher: PaymentEnricher = PaymentEnricher( repo = repo, aliasRepo = aliasRepo, @@ -58,36 +68,40 @@ class HistoryViewModel( syncManager.updateEnrichedPayment(paymentHash, pubkey, alias) } ) + + // ── Sync status ─────────────────────────────────────────────────────────── + // Exposes Loading / Error / Success shell (isRefreshing, canLoadMore). + // Does NOT carry payment data — see feedItems below. val state: StateFlow = syncManager.state + fun refresh() = syncManager.refresh() fun loadMore() = syncManager.loadMore( - filteredCanLoadMore = { (filteredState.value as? HistoryState.Success)?.canLoadMore == true } + filteredCanLoadMore = { + (state.value as? HistoryState.Success)?.canLoadMore == true + } ) - // ── Detail delegate ─────────────────────────────────────────────────────── + // ── 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 loadDetail(paymentHash: String, record: PaymentRecord? = null) = + detailDelegate.load(paymentHash, record) fun clearDetail() = detailDelegate.clear() - // ── Filter state ────────────────────────────────────────────────────────── + // ── 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) { + fun setSearchQuery(q: String) { _filter.update { it.copy(searchQuery = q.trim()) } } + fun clearSearch() { _filter.update { it.copy(searchQuery = "") } } + fun setDirectionFilter(d: DirectionFilter) { _filter.update { it.copy(direction = d) } } + fun toggleStatusFilter(s: StatusFilter) { _filter.update { f -> - val updated = if (status in f.statuses) f.statuses - status else f.statuses + status + val updated = if (s in f.statuses) f.statuses - s else f.statuses + s f.copy(statuses = updated) } } @@ -104,55 +118,55 @@ class HistoryViewModel( _filter.update { it.copy(minCreatedAt = min, maxCreatedAt = max, datePreset = preset) } } fun applyDatePreset(preset: DatePreset) { - val (min, max) = preset.toEpochSecondRange() // ← delegate to PaymentFilter.kt + val (min, max) = preset.toEpochSecondRange() setDateFilter(min, max, preset) } fun clearDateFilter() { _filter.update { it.copy(minCreatedAt = null, maxCreatedAt = null, datePreset = null) } } - - fun findPayment(checkingId: String): PaymentRecord? { - val fromFiltered = (filteredState.value as? HistoryState.Success) - ?.payments?.firstOrNull { it.checkingId == checkingId } - if (fromFiltered != null) return fromFiltered - return (state.value as? HistoryState.Success) - ?.payments?.firstOrNull { it.checkingId == checkingId } + fun toggleContactFilter(contactId: String) { + _filter.update { f -> + val updated = if (contactId in f.contactIds) + f.contactIds - contactId else f.contactIds + contactId + f.copy(contactIds = updated) + } } - + fun clearContactFilter() { _filter.update { it.copy(contactIds = emptySet()) } } // ── Room-backed query results ────────────────────────────────────────────── - private val _roomResults = MutableStateFlow?>(null) + // Null = use the live feed stream. + // Non-null = a search / contact-ID Room query has run and its results + // override the live stream until the filter is cleared. + private val _roomResults = MutableStateFlow?>(null) - // ── Derived state ───────────────────────────────────────────────────────── + // ── Feed items — single source of truth for the UI list ─────────────────── + // Emits a freshly filtered List whenever any upstream + // source changes: payments DB, contact links, alias cache, fiat rate, + // active filter, or Room query override. + val feedItems: StateFlow> = combine( + feedRepository.observeFeedItems(), + _filter, + _roomResults + ) { feed, f, roomResults -> + val base = roomResults ?: feed + base.applyInMemoryFilters(f) + }.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) - /** 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() - } + // ── Available type filter chips ──────────────────────────────────────────── + val availableTypes: StateFlow> = feedItems + .map { items -> items.mapNotNull { it.tag }.distinct().sorted() } .stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) - val filteredState = combine( - syncManager.state, - _filter, _roomResults, - contactRepo.observeAllPaymentContactNames() - ) { s, f, roomResults, contactNames -> - Timber.d("CONTACT [COMBINE ] contactNames has ${contactNames.size} entries") - if (s !is HistoryState.Success) return@combine s - val payments = if (roomResults == null) { - s.payments.applyInMemoryFilters(f) - } else { - roomResults.applyInMemoryFilters(f) - } - s.copy( - payments = payments, - canLoadMore = roomResults == null && s.canLoadMore - ) - }.stateIn(viewModelScope, SharingStarted.Eagerly, HistoryState.Loading) + // ── Find a single item by paymentHash ───────────────────────────────────── + // paymentHash supersedes checkingId as the stable unique identifier. + // Used by PaymentDetailScreen to retrieve a feed item without a DB round-trip. + fun findPayment(paymentHash: String): PaymentFeedItem? = + feedItems.value.firstOrNull { it.paymentHash == paymentHash } - // ── In-memory filter logic ──────────────────────────────────────────────── - private fun List.applyInMemoryFilters(f: PaymentFilter) = + // ── In-memory filter logic ───────────────────────────────────────────────── + // All filter keys map directly to PaymentFeedItem fields — no extra + // resolution needed (contact names, alias, fiat are already embedded). + private fun List.applyInMemoryFilters(f: PaymentFilter): List = distinctBy { it.paymentHash } .let { list -> when (f.direction) { @@ -161,22 +175,53 @@ class HistoryViewModel( DirectionFilter.INCOMING -> list.filter { !it.isOutgoing } } } + .let { list -> + if (f.statuses.isEmpty()) list + else list.filter { item -> + f.statuses.any { status -> + when (status) { + StatusFilter.PENDING -> item.isPending + StatusFilter.FAILED -> item.isFailed + StatusFilter.COMPLETED -> !item.isPending && !item.isFailed + } + } + } + } .let { list -> if (f.types.isEmpty()) list - else list.filter { it.extra?.tag in f.types } + else list.filter { it.tag in f.types } + } + .let { list -> + val min = f.minAmountSat; val max = f.maxAmountSat + if (min == null && max == null) list + else list.filter { item -> + (min == null || item.amountSat >= min) && + (max == null || item.amountSat <= max) + } + } + .let { list -> + val min = f.minCreatedAt; val max = f.maxCreatedAt + if (min == null && max == null) list + else list.filter { item -> + val ts = item.createdAt ?: item.time + ts != null && + (min == null || ts >= min) && + (max == null || ts <= max) + } + } + .let { list -> + val q = f.searchQuery + if (q.isBlank()) list + else list.filter { item -> + item.memo?.contains(q, ignoreCase = true) == true || + item.contactName?.contains(q, ignoreCase = true) == true || + item.paymentHash.contains(q, ignoreCase = true) + } } - // ── Contact name map ───────────────────────────────────────────────────── - // Maps checkingId → display name for any payment that has a linked contact. - // Rebuilt whenever the underlying tx_contact_links or contacts tables change. - val contactNames: StateFlow> = - contactRepo.observeAllPaymentContactNames() - .stateIn( - scope = viewModelScope, - started = SharingStarted.Eagerly, - initialValue = emptyMap() - ) - // Maps contactId → display name (for filter chips) + // ── Contact display names (for filter chips) ─────────────────────────────── + // Used to render contact names in active filter chips on the UI. + // Payment row contact names are embedded in PaymentFeedItem.contactName. val contactDisplayNames: StateFlow> = contactRepo.observeAllContacts() .map { contacts -> @@ -188,40 +233,36 @@ class HistoryViewModel( } } .stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(5_000), + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), initialValue = emptyMap() ) - // ── Contact assignment ─────────────────────────────────────────────────── - + // ── Per-payment contact queries ──────────────────────────────────────────── fun contactsForPayment(paymentHash: String): StateFlow> = contactRepo.observeContactsForPayment(paymentHash) .stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(5_000), + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), initialValue = emptyList() ) fun allContacts(): StateFlow> = contactRepo.observeAllContacts() .stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(5_000), + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), initialValue = emptyList() ) + // ── Contact assignment ───────────────────────────────────────────────────── fun assignContact(paymentHash: String, contactId: String) { - Timber.d("CONTACT [ASSIGN ] paymentHash=$paymentHash contactId=$contactId") - viewModelScope.launch { - contactRepo.linkPaymentToContact(paymentHash, contactId) - } + Timber.tag(TAG).d("CONTACT [ASSIGN ] paymentHash=$paymentHash contactId=$contactId") + viewModelScope.launch { contactRepo.linkPaymentToContact(paymentHash, contactId) } } fun unassignContact(paymentHash: String, contactId: String) { - viewModelScope.launch { - contactRepo.unlinkPaymentFromContact(paymentHash, contactId) - } + viewModelScope.launch { contactRepo.unlinkPaymentFromContact(paymentHash, contactId) } } fun saveContactAndAssign( @@ -230,36 +271,28 @@ class HistoryViewModel( addressType : PaymentAddressType?, address : String? ) { - Timber.d("CONTACT [CREATE+ASSIGN] paymentHash=$paymentHash name=$name") + Timber.tag(TAG).d("CONTACT [CREATE+ASSIGN] paymentHash=$paymentHash name=$name") viewModelScope.launch { val lnAddress = address?.takeIf { addressType == PaymentAddressType.LIGHTNING_ADDRESS } val lnUrl = address?.takeIf { addressType == PaymentAddressType.LNURL } - val contact = contactRepo.createContact( + val contact = contactRepo.createContact( localAlias = name, lnAddress = lnAddress, lnUrl = lnUrl ) - Timber.d("CONTACT [CREATE+ASSIGN] created contactId=${contact.id} → linking to paymentHash=$paymentHash") + Timber.tag(TAG).d("CONTACT [CREATE+ASSIGN] created contactId=${contact.id} → linking") contactRepo.linkPaymentToContact(paymentHash, contact.id) } } - fun toggleContactFilter(contactId: String) { - _filter.update { f -> - val updated = if (contactId in f.contactIds) - f.contactIds - contactId else f.contactIds + contactId - f.copy(contactIds = updated) - } - } - fun clearContactFilter() { - _filter.update { it.copy(contactIds = emptySet()) } - } - - - // ── Init ────────────────────────────────────────────────────────────────── + // ── Init ─────────────────────────────────────────────────────────────────── init { syncManager.init() + // ── Room query path ──────────────────────────────────────────────────── + // Activated when the filter requires DB-level text search or contact-ID + // lookup. Results are stored in _roomResults and override the live feed + // stream in feedItems. Cleared back to null when no Room query is needed. viewModelScope.launch { combine( _filter.map { it.searchQuery }.debounce(300.milliseconds), @@ -267,84 +300,102 @@ class HistoryViewModel( ) { _, f -> f } .collect { f -> if (!f.needsRoomQuery()) { - Timber.d("FILTER [IN-MEMORY] filter=default → using cached state") + Timber.tag(TAG).d("FILTER [IN-MEMORY] using live feed") _roomResults.value = null - } else { - // ── Room query path ─────────────────────────────────── - val baseResults = paymentCache.queryAll(f.searchQuery, f) - .toMutableList() + return@collect + } - // ── Supplemental contact search ─────────────────────── - // 1. Text query may match contact names not in payment_records - if (f.searchQuery.isNotBlank()) { - val contactPaymentHashes = contactRepo - .findPaymentHashesByContactName(f.searchQuery) - if (contactPaymentHashes.isNotEmpty()) { - val contactPayments = paymentCache - .queryByPaymentHashes(contactPaymentHashes, f) - // Merge without duplicates - val existingIds = baseResults.map { it.checkingId }.toSet() - baseResults += contactPayments - .filter { it.checkingId !in existingIds } - } + // ── Explicit contact-ID filter (highest priority) ────────── + if (f.contactIds.isNotEmpty()) { + val contactHashes = contactRepo + .findPaymentHashesByContactIds(f.contactIds.toList()) + _roomResults.value = if (contactHashes.isNotEmpty()) { + paymentCache.queryByPaymentHashes(contactHashes, f) + .map { it.toFeedItem() } + .also { + Timber.tag(TAG).d( + "FILTER [ROOM+CONTACT] contacts=${f.contactIds} → ${it.size} results" + ) + } + } else { + emptyList() } + return@collect + } - // 2. Explicit contact ID filter - if (f.contactIds.isNotEmpty()) { - val contactPaymentHashes = contactRepo - .findPaymentHashesByContactIds(f.contactIds.toList()) - if (contactPaymentHashes.isNotEmpty()) { - // Fetch ALL payments for these contacts, WITH the full filter applied - val contactFiltered = paymentCache.queryByPaymentHashes( - paymentHashes = contactPaymentHashes, - filter = f - ) - _roomResults.value = contactFiltered - .also { - Timber.d("FILTER [ROOM+CONTACT] contacts=${f.contactIds} → ${it.size} results") - } - return@collect - } else { - // Selected contacts have no payments — return empty - _roomResults.value = emptyList() - return@collect - } - } + // ── Text search ──────────────────────────────────────────── + val baseRecords = paymentCache.queryAll(f.searchQuery, f).toMutableList() - _roomResults.value = baseResults.also { - Timber.d("FILTER [ROOM] q=\"${f.searchQuery}\" → ${it.size} results") + // Supplement: query may match contact names not stored in payment_records + if (f.searchQuery.isNotBlank()) { + val contactHashes = contactRepo + .findPaymentHashesByContactName(f.searchQuery) + if (contactHashes.isNotEmpty()) { + val existingIds = baseRecords.map { it.checkingId }.toSet() + baseRecords += paymentCache + .queryByPaymentHashes(contactHashes, f) + .filter { it.checkingId !in existingIds } } } + + _roomResults.value = baseRecords + .map { it.toFeedItem() } + .also { + Timber.tag(TAG).d( + "FILTER [ROOM] q=\"${f.searchQuery}\" → ${it.size} results" + ) + } } } + + // ── WebSocket payment event listener ─────────────────────────────────── viewModelScope.launch { WalletNotificationService.paymentEvents.collect { event -> - Timber.d("PAYMENTS [WS EVENT ] incoming event ${event.paymentHash} — refreshing list") + Timber.tag(TAG).d("PAYMENTS [WS EVENT] ${event.paymentHash} — refreshing") syncManager.refresh() } } } + + // ── Private helper ───────────────────────────────────────────────────────── + // Converts a Room query PaymentRecord into a PaymentFeedItem for _roomResults. + // Contact links, alias, and fiat are intentionally left empty/null here — + // applyInMemoryFilters only needs paymentHash, amountSat, tag, memo, and + // timestamps. Full enrichment is provided by feedRepository.observeFeedItems() + // for all items that pass through the live feed path. + private fun PaymentRecord.toFeedItem(): PaymentFeedItem = + toPaymentFeedItem( + linkedContacts = emptyList(), + resolvedAlias = null, + fiatCurrency = fiatCurrency.value, + fiatSatsPerUnit = fiatSatsPerUnit.value + ) } -// ── Factory ─────────────────────────────────────────────────────────────────── +// ── Factory ──────────────────────────────────────────────────────────────────── class HistoryViewModelFactory( - private val repo : WalletRepository, - private val aliasRepo : NodeAliasRepository, - private val paymentCache : PaymentCacheRepository, + private val repo : WalletRepository, + private val aliasRepo : NodeAliasRepository, + private val paymentCache : PaymentCacheRepository, private val contactRepo : ContactRepository, - private val fiatCurrency : StateFlow, - private val fiatSatsPerUnit: StateFlow + private val feedRepository : PaymentFeedRepository, + private val fiatCurrency : StateFlow, + private val fiatSatsPerUnit : StateFlow ) : ViewModelProvider.Factory { override fun create(modelClass: Class): T { + require(modelClass.isAssignableFrom(HistoryViewModel::class.java)) { + "Unknown ViewModel class: ${modelClass.name}" + } @Suppress("UNCHECKED_CAST") return HistoryViewModel( repo = repo, aliasRepo = aliasRepo, paymentCache = paymentCache, contactRepo = contactRepo, + feedRepository = feedRepository, fiatCurrency = fiatCurrency, fiatSatsPerUnit = fiatSatsPerUnit ) as T } -} \ No newline at end of file +}