feat: make search backed by room

This commit is contained in:
2026-06-03 16:33:45 +02:00
parent 0f932b0229
commit fe4b387a27
3 changed files with 206 additions and 59 deletions
@@ -8,6 +8,8 @@ import com.bitcointxoko.gudariwallet.data.db.AppDatabase
import com.bitcointxoko.gudariwallet.data.db.toDomain
import com.bitcointxoko.gudariwallet.data.db.toEntity
import com.bitcointxoko.gudariwallet.util.WalletConstants
import com.bitcointxoko.gudariwallet.ui.PaymentFilter
import com.bitcointxoko.gudariwallet.ui.StatusFilter
private const val TAG = "PaymentCacheRepository"
@@ -36,10 +38,7 @@ class PaymentCacheRepository(context: Context) {
}
}
/**
* Load a page of payments from the DB.
* Returns empty list if nothing cached yet.
*/
/** Load a page of payments from the DB. Returns empty list if nothing cached yet. */
suspend fun loadCachedPage(offset: Int, limit: Int): List<PaymentRecord> {
return dao.getPage(limit = limit, offset = offset)
.map { it.toDomain() }
@@ -54,11 +53,7 @@ class PaymentCacheRepository(context: Context) {
/** Total number of payment rows in the DB. */
suspend fun countCached(): Int = dao.count()
/**
* Merge a fetched page into the DB.
* Uses INSERT OR REPLACE — never deletes existing rows.
* Safe to call for any page, not just the first.
*/
/** Merge a fetched page into the DB. Uses INSERT OR REPLACE — never deletes existing rows. */
suspend fun mergePayments(payments: List<PaymentRecord>) {
if (payments.isEmpty()) return
val now = System.currentTimeMillis()
@@ -82,6 +77,44 @@ class PaymentCacheRepository(context: Context) {
}
}
/**
* Query the full Room table using any combination of search + filter.
* Called by HistoryViewModel whenever search or a SQL-capable filter is active.
* Direction and type filters are NOT applied here — they're handled in-memory
* in the ViewModel after this call returns.
*/
suspend fun queryAll(query: String, filter: PaymentFilter): List<PaymentRecord> {
val searchQuery = query.trim().ifBlank { null }
// Build a comma-joined status pattern, e.g. "success,complete,paid,pending,in_flight,inflight"
// null means "no status filter" — Room will skip that WHERE clause entirely.
val statusPattern: String? = if (filter.statuses.isEmpty()) null else {
filter.statuses.flatMap { s ->
when (s) {
StatusFilter.COMPLETED -> listOf("success", "complete", "paid")
StatusFilter.PENDING -> listOf("pending", "in_flight", "inflight")
StatusFilter.FAILED -> listOf("failed", "error", "expired")
}
}.joinToString(",")
}
val minAmountMsat = filter.minAmountSat?.let { it * 1000L }
val maxAmountMsat = filter.maxAmountSat?.let { it * 1000L }
return dao.queryAll(
searchQuery = searchQuery,
statusPattern = statusPattern,
minAmountMsat = minAmountMsat,
maxAmountMsat = maxAmountMsat,
minCreatedAt = filter.minCreatedAt,
maxCreatedAt = filter.maxCreatedAt
).map { it.toDomain() }.also {
Log.d(TAG, "PAYMENTS [QUERY ] q=$searchQuery status=$statusPattern " +
"amt=${filter.minAmountSat}${filter.maxAmountSat} " +
"date=${filter.minCreatedAt}${filter.maxCreatedAt}${it.size} rows")
}
}
// ── Payment detail ────────────────────────────────────────────────────────
/** Returns a cached detail response, or null if not yet fetched this session. */
@@ -103,8 +136,7 @@ class PaymentCacheRepository(context: Context) {
Log.d(TAG, "PAYMENTS [UPSERT ] ${payment.checkingId}")
}
/** Check the DB for a stored payment record and return it as a detail response.
* Returns null if not found. Never hits the network. */
/** Check the DB for a stored payment record and return it as a detail response. */
suspend fun getDetailFromDb(checkingId: String): PaymentDetailResponse? {
val entity = dao.getByCheckingId(checkingId) ?: return null
Log.d(TAG, "PAYMENTS [DETAIL DB ] $checkingId — found in Room")
@@ -35,4 +35,43 @@ interface PaymentDao {
@Query("DELETE FROM payment_records")
suspend fun deleteAll()
/**
* Full-table query used when search or filter is active.
* All parameters are optional — pass null to skip that condition.
*
* Notes:
* - status strings are passed as a comma-joined string and matched with LIKE
* because Room doesn't support passing a list to IN() without extra setup.
* We use ",success,complete,paid," style wrapping to avoid false matches.
* - amountMsat thresholds are passed in msat (sat * 1000), ABS() handles
* outgoing payments whose amountMsat is negative.
* - createdAt is epoch seconds, matching the stored column.
* - direction and type are handled in-memory post-query (no column for either).
*/
@Query("""
SELECT * FROM payment_records
WHERE
(:searchQuery IS NULL OR (
lower(memo) LIKE '%' || lower(:searchQuery) || '%' OR
lower(paymentHash) LIKE '%' || lower(:searchQuery) || '%' OR
lower(bolt11) LIKE '%' || lower(:searchQuery) || '%' OR
lower(preimage) LIKE '%' || lower(:searchQuery) || '%'
))
AND (:statusPattern IS NULL OR
',' || :statusPattern || ',' LIKE '%,' || lower(status) || ',%')
AND (:minAmountMsat IS NULL OR ABS(amountMsat) >= :minAmountMsat)
AND (:maxAmountMsat IS NULL OR ABS(amountMsat) <= :maxAmountMsat)
AND (:minCreatedAt IS NULL OR createdAt >= :minCreatedAt)
AND (:maxCreatedAt IS NULL OR createdAt <= :maxCreatedAt)
ORDER BY createdAt DESC, time DESC
""")
suspend fun queryAll(
searchQuery : String?,
statusPattern : String?, // e.g. "success,complete,paid" or null
minAmountMsat : Long?,
maxAmountMsat : Long?,
minCreatedAt : Long?,
maxCreatedAt : Long?
): List<PaymentRecordEntity>
}