diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/data/ContactRepository.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/data/ContactRepository.kt index 3fadb87..7869393 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/data/ContactRepository.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/data/ContactRepository.kt @@ -140,4 +140,10 @@ class ContactRepository(app: Application) { fun observeAllPaymentContactNames(): Flow> = dao.observePaymentContactNames() .map { list -> list.associate { it.checkingId to it.contactName } } + + suspend fun findCheckingIdsByContactName(query: String): List = + dao.findCheckingIdsByContactName(query) + + suspend fun findCheckingIdsByContactIds(contactIds: List): List = + dao.findCheckingIdsByContactIds(contactIds) } 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 9e72464..ac1843e 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/data/PaymentCacheRepository.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/data/PaymentCacheRepository.kt @@ -159,4 +159,10 @@ class PaymentCacheRepository(context: Context) { dao.deleteAll() Timber.d("PAYMENTS [CLEARED ] cache wiped") } + + // Fetches specific payments by checkingId (used after a contact lookup) + suspend fun queryByCheckingIds(checkingIds: List): List { + if (checkingIds.isEmpty()) return emptyList() + return dao.getByCheckingIds(checkingIds).map { it.toDomain() } + } } diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/data/db/ContactDao.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/data/db/ContactDao.kt index d6a167f..36b27d9 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/data/db/ContactDao.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/data/db/ContactDao.kt @@ -108,4 +108,19 @@ interface ContactDao { INNER JOIN contacts c ON c.id = tcl.contactId """) fun observePaymentContactNames(): Flow> + + // For text search matching contact names — returns checkingIds of matched payments + @Query(""" + SELECT tcl.checkingId FROM tx_contact_links tcl + INNER JOIN contacts c ON c.id = tcl.contactId + WHERE COALESCE(c.localAlias, c.displayName, c.name) LIKE '%' || :query || '%' +""") + suspend fun findCheckingIdsByContactName(query: String): List + + // For filter-by-contact — returns checkingIds for specific contact IDs + @Query(""" + SELECT DISTINCT checkingId FROM tx_contact_links + WHERE contactId IN (:contactIds) +""") + suspend fun findCheckingIdsByContactIds(contactIds: List): List } diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/data/db/PaymentDao.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/data/db/PaymentDao.kt index 227d064..231d516 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/data/db/PaymentDao.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/data/db/PaymentDao.kt @@ -32,6 +32,9 @@ interface PaymentDao { @Query("SELECT * FROM payment_records WHERE checkingId = :checkingId LIMIT 1") suspend fun getByCheckingId(checkingId: String): PaymentRecordEntity? + @Query("SELECT * FROM payment_records WHERE checkingId IN (:checkingIds) ORDER BY createdAt DESC") + suspend fun getByCheckingIds(checkingIds: List): List + @Query("DELETE FROM payment_records") suspend fun deleteAll() 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 cf6d1d6..c79ac1c 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 @@ -119,6 +119,23 @@ class HistoryViewModel( ?.payments?.firstOrNull { it.checkingId == checkingId } } + // Maps contactId → display name (for filter chips) + val contactDisplayNames: StateFlow> = + contactRepo.observeAllContacts() + .map { contacts -> + contacts.associate { c -> + c.contact.id to (c.contact.localAlias + ?: c.contact.displayName + ?: c.contact.name + ?: "Unknown") + } + } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = emptyMap() + ) + // ── Room-backed query results ────────────────────────────────────────────── private val _roomResults = MutableStateFlow?>(null) @@ -215,6 +232,18 @@ class HistoryViewModel( } } + 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 { syncManager.init() @@ -229,9 +258,55 @@ class HistoryViewModel( Timber.d("FILTER [IN-MEMORY] filter=default → using cached state") _roomResults.value = null } else { - Timber.d("%snull", "FILTER [ROOM ] q=\"${f.searchQuery}\" " + - "statuses=${f.statuses} dir=${f.direction} types=${f.types} ") - _roomResults.value = paymentCache.queryAll(f.searchQuery, f) + // ── Room query path ─────────────────────────────────── + val baseResults = paymentCache.queryAll(f.searchQuery, f) + .toMutableList() + + // ── Supplemental contact search ─────────────────────── + // 1. Text query may match contact names not in payment_records + if (f.searchQuery.isNotBlank()) { + val contactCheckingIds = contactRepo + .findCheckingIdsByContactName(f.searchQuery) + if (contactCheckingIds.isNotEmpty()) { + val contactPayments = paymentCache + .queryByCheckingIds(contactCheckingIds) + // Merge without duplicates + val existingIds = baseResults.map { it.checkingId }.toSet() + baseResults += contactPayments + .filter { it.checkingId !in existingIds } + } + } + + // 2. Explicit contact ID filter + if (f.contactIds.isNotEmpty()) { + val contactCheckingIds = contactRepo + .findCheckingIdsByContactIds(f.contactIds.toList()) + if (contactCheckingIds.isNotEmpty()) { + // Intersect: only keep base results that belong + // to the selected contacts, OR fetch them directly + val existingIds = baseResults.map { it.checkingId }.toSet() + val missing = contactCheckingIds.filter { it !in existingIds } + if (missing.isNotEmpty()) { + baseResults += paymentCache.queryByCheckingIds(missing) + } + // Now narrow: remove payments NOT linked to selected contacts + val contactCheckingIdSet = contactCheckingIds.toSet() + _roomResults.value = baseResults + .filter { it.checkingId in contactCheckingIdSet } + .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 + } + } + + _roomResults.value = baseResults.also { + Timber.d("FILTER [ROOM] q=\"${f.searchQuery}\" → ${it.size} results (${it.size - (paymentCache.queryAll(f.searchQuery, f).size)} from contacts)") + } } } } diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/history/PaymentFilter.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/history/PaymentFilter.kt index f6c2d0d..1cc0c6e 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/history/PaymentFilter.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/history/PaymentFilter.kt @@ -14,7 +14,8 @@ data class PaymentFilter( val maxAmountSat : Long? = null, val minCreatedAt : Long? = null, val maxCreatedAt : Long? = null, - val datePreset : DatePreset? = null + val datePreset : DatePreset? = null, + val contactIds : Set = emptySet() ) enum class DirectionFilter { ALL, OUTGOING, INCOMING } @@ -33,8 +34,11 @@ val PaymentFilter.isActive: Boolean fun PaymentFilter.needsRoomQuery(): Boolean = searchQuery.isNotBlank() || statuses.isNotEmpty() || - minAmountSat != null || maxAmountSat != null || - minCreatedAt != null || maxCreatedAt != null + minAmountSat != null || + maxAmountSat != null || + minCreatedAt != null || + maxCreatedAt != null || + contactIds.isNotEmpty() /** Resolves a [DatePreset] to a (epochSecondMin, epochSecondMax) pair. */ fun DatePreset.toEpochSecondRange(): Pair {