feat: search payments by contact
This commit is contained in:
@@ -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> {
|
||||||
|
|||||||
Reference in New Issue
Block a user