Unify payment item #1

Merged
rasputin merged 5 commits from unify-payment-item into main 2026-06-27 11:13:27 +00:00
Showing only changes of commit 3c5539d2cb - Show all commits
@@ -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<String?>,
val fiatSatsPerUnit : StateFlow<Double?>
private val repo : WalletRepository,
private val aliasRepo : NodeAliasRepository,
private val paymentCache : PaymentCacheRepository,
private val contactRepo : ContactRepository,
private val feedRepository : PaymentFeedRepository,
val fiatCurrency : StateFlow<String?>,
val fiatSatsPerUnit : StateFlow<Double?>
) : 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<HistoryState> = 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<DetailState> = 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<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) {
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<List<PaymentRecord>?>(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<List<PaymentFeedItem>?>(null)
// ── Derived state ─────────────────────────────────────────────────────────
// ── Feed items — single source of truth for the UI list ───────────────────
// Emits a freshly filtered List<PaymentFeedItem> whenever any upstream
// source changes: payments DB, contact links, alias cache, fiat rate,
// active filter, or Room query override.
val feedItems: StateFlow<List<PaymentFeedItem>> = 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<List<String>> = 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<List<String>> = 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<PaymentRecord>.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<PaymentFeedItem>.applyInMemoryFilters(f: PaymentFilter): List<PaymentFeedItem> =
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<Map<String, String>> =
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<Map<String, String>> =
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<List<ContactEntity>> =
contactRepo.observeContactsForPayment(paymentHash)
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = emptyList()
)
fun allContacts(): StateFlow<List<ContactWithAddresses>> =
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,82 +300,100 @@ 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<String?>,
private val fiatSatsPerUnit: StateFlow<Double?>
private val feedRepository : PaymentFeedRepository,
private val fiatCurrency : StateFlow<String?>,
private val fiatSatsPerUnit : StateFlow<Double?>
) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): 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