feat: search payments by contact

This commit is contained in:
2026-06-13 14:30:17 +02:00
parent f9991622b3
commit 2bec9f3908
6 changed files with 115 additions and 6 deletions
@@ -140,4 +140,10 @@ class ContactRepository(app: Application) {
fun observeAllPaymentContactNames(): Flow<Map<String, String>> = fun observeAllPaymentContactNames(): Flow<Map<String, String>> =
dao.observePaymentContactNames() dao.observePaymentContactNames()
.map { list -> list.associate { it.checkingId to it.contactName } } .map { list -> list.associate { it.checkingId to it.contactName } }
suspend fun findCheckingIdsByContactName(query: String): List<String> =
dao.findCheckingIdsByContactName(query)
suspend fun findCheckingIdsByContactIds(contactIds: List<String>): List<String> =
dao.findCheckingIdsByContactIds(contactIds)
} }
@@ -159,4 +159,10 @@ class PaymentCacheRepository(context: Context) {
dao.deleteAll() dao.deleteAll()
Timber.d("PAYMENTS [CLEARED ] cache wiped") Timber.d("PAYMENTS [CLEARED ] cache wiped")
} }
// Fetches specific payments by checkingId (used after a contact lookup)
suspend fun queryByCheckingIds(checkingIds: List<String>): List<PaymentRecord> {
if (checkingIds.isEmpty()) return emptyList()
return dao.getByCheckingIds(checkingIds).map { it.toDomain() }
}
} }
@@ -108,4 +108,19 @@ interface ContactDao {
INNER JOIN contacts c ON c.id = tcl.contactId INNER JOIN contacts c ON c.id = tcl.contactId
""") """)
fun observePaymentContactNames(): Flow<List<PaymentContactName>> fun observePaymentContactNames(): Flow<List<PaymentContactName>>
// 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<String>
// 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<String>): List<String>
} }
@@ -32,6 +32,9 @@ interface PaymentDao {
@Query("SELECT * FROM payment_records WHERE checkingId = :checkingId LIMIT 1") @Query("SELECT * FROM payment_records WHERE checkingId = :checkingId LIMIT 1")
suspend fun getByCheckingId(checkingId: String): PaymentRecordEntity? suspend fun getByCheckingId(checkingId: String): PaymentRecordEntity?
@Query("SELECT * FROM payment_records WHERE checkingId IN (:checkingIds) ORDER BY createdAt DESC")
suspend fun getByCheckingIds(checkingIds: List<String>): List<PaymentRecordEntity>
@Query("DELETE FROM payment_records") @Query("DELETE FROM payment_records")
suspend fun deleteAll() suspend fun deleteAll()
@@ -119,6 +119,23 @@ class HistoryViewModel(
?.payments?.firstOrNull { it.checkingId == checkingId } ?.payments?.firstOrNull { it.checkingId == checkingId }
} }
// Maps contactId → display name (for filter chips)
val contactDisplayNames: StateFlow<Map<String, String>> =
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 ────────────────────────────────────────────── // ── Room-backed query results ──────────────────────────────────────────────
private val _roomResults = MutableStateFlow<List<PaymentRecord>?>(null) private val _roomResults = MutableStateFlow<List<PaymentRecord>?>(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 ──────────────────────────────────────────────────────────────────
init { init {
syncManager.init() syncManager.init()
@@ -229,9 +258,55 @@ class HistoryViewModel(
Timber.d("FILTER [IN-MEMORY] filter=default → using cached state") Timber.d("FILTER [IN-MEMORY] filter=default → using cached state")
_roomResults.value = null _roomResults.value = null
} else { } else {
Timber.d("%snull", "FILTER [ROOM ] q=\"${f.searchQuery}\" " + // ── Room query path ───────────────────────────────────
"statuses=${f.statuses} dir=${f.direction} types=${f.types} ") val baseResults = paymentCache.queryAll(f.searchQuery, f)
_roomResults.value = 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)")
}
} }
} }
} }
@@ -14,7 +14,8 @@ data class PaymentFilter(
val maxAmountSat : Long? = null, val maxAmountSat : Long? = null,
val minCreatedAt : Long? = null, val minCreatedAt : Long? = null,
val maxCreatedAt : Long? = null, val maxCreatedAt : Long? = null,
val datePreset : DatePreset? = null val datePreset : DatePreset? = null,
val contactIds : Set<String> = emptySet()
) )
enum class DirectionFilter { ALL, OUTGOING, INCOMING } enum class DirectionFilter { ALL, OUTGOING, INCOMING }
@@ -33,8 +34,11 @@ val PaymentFilter.isActive: Boolean
fun PaymentFilter.needsRoomQuery(): Boolean = fun PaymentFilter.needsRoomQuery(): Boolean =
searchQuery.isNotBlank() || searchQuery.isNotBlank() ||
statuses.isNotEmpty() || statuses.isNotEmpty() ||
minAmountSat != null || maxAmountSat != null || minAmountSat != null ||
minCreatedAt != null || maxCreatedAt != null maxAmountSat != null ||
minCreatedAt != null ||
maxCreatedAt != null ||
contactIds.isNotEmpty()
/** Resolves a [DatePreset] to a (epochSecondMin, epochSecondMax) pair. */ /** Resolves a [DatePreset] to a (epochSecondMin, epochSecondMax) pair. */
fun DatePreset.toEpochSecondRange(): Pair<Long, Long> { fun DatePreset.toEpochSecondRange(): Pair<Long, Long> {