feat: make search backed by room
This commit is contained in:
@@ -8,6 +8,8 @@ import com.bitcointxoko.gudariwallet.data.db.AppDatabase
|
|||||||
import com.bitcointxoko.gudariwallet.data.db.toDomain
|
import com.bitcointxoko.gudariwallet.data.db.toDomain
|
||||||
import com.bitcointxoko.gudariwallet.data.db.toEntity
|
import com.bitcointxoko.gudariwallet.data.db.toEntity
|
||||||
import com.bitcointxoko.gudariwallet.util.WalletConstants
|
import com.bitcointxoko.gudariwallet.util.WalletConstants
|
||||||
|
import com.bitcointxoko.gudariwallet.ui.PaymentFilter
|
||||||
|
import com.bitcointxoko.gudariwallet.ui.StatusFilter
|
||||||
|
|
||||||
private const val TAG = "PaymentCacheRepository"
|
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> {
|
suspend fun loadCachedPage(offset: Int, limit: Int): List<PaymentRecord> {
|
||||||
return dao.getPage(limit = limit, offset = offset)
|
return dao.getPage(limit = limit, offset = offset)
|
||||||
.map { it.toDomain() }
|
.map { it.toDomain() }
|
||||||
@@ -54,11 +53,7 @@ class PaymentCacheRepository(context: Context) {
|
|||||||
/** Total number of payment rows in the DB. */
|
/** Total number of payment rows in the DB. */
|
||||||
suspend fun countCached(): Int = dao.count()
|
suspend fun countCached(): Int = dao.count()
|
||||||
|
|
||||||
/**
|
/** Merge a fetched page into the DB. Uses INSERT OR REPLACE — never deletes existing rows. */
|
||||||
* 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.
|
|
||||||
*/
|
|
||||||
suspend fun mergePayments(payments: List<PaymentRecord>) {
|
suspend fun mergePayments(payments: List<PaymentRecord>) {
|
||||||
if (payments.isEmpty()) return
|
if (payments.isEmpty()) return
|
||||||
val now = System.currentTimeMillis()
|
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 ────────────────────────────────────────────────────────
|
// ── Payment detail ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** Returns a cached detail response, or null if not yet fetched this session. */
|
/** 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}")
|
Log.d(TAG, "PAYMENTS [UPSERT ] ${payment.checkingId}")
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Check the DB for a stored payment record and return it as a detail response.
|
/** Check the DB for a stored payment record and return it as a detail response. */
|
||||||
* Returns null if not found. Never hits the network. */
|
|
||||||
suspend fun getDetailFromDb(checkingId: String): PaymentDetailResponse? {
|
suspend fun getDetailFromDb(checkingId: String): PaymentDetailResponse? {
|
||||||
val entity = dao.getByCheckingId(checkingId) ?: return null
|
val entity = dao.getByCheckingId(checkingId) ?: return null
|
||||||
Log.d(TAG, "PAYMENTS [DETAIL DB ] $checkingId — found in Room")
|
Log.d(TAG, "PAYMENTS [DETAIL DB ] $checkingId — found in Room")
|
||||||
|
|||||||
@@ -35,4 +35,43 @@ interface PaymentDao {
|
|||||||
|
|
||||||
@Query("DELETE FROM payment_records")
|
@Query("DELETE FROM payment_records")
|
||||||
suspend fun deleteAll()
|
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>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,11 +10,13 @@ import com.bitcointxoko.gudariwallet.data.PaymentCacheRepository
|
|||||||
import com.bitcointxoko.gudariwallet.data.WalletRepository
|
import com.bitcointxoko.gudariwallet.data.WalletRepository
|
||||||
import com.bitcointxoko.gudariwallet.service.WalletNotificationService
|
import com.bitcointxoko.gudariwallet.service.WalletNotificationService
|
||||||
import com.bitcointxoko.gudariwallet.util.WalletConstants
|
import com.bitcointxoko.gudariwallet.util.WalletConstants
|
||||||
|
import kotlinx.coroutines.FlowPreview
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.debounce
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
@@ -40,7 +42,7 @@ enum class DatePreset { THIS_WEEK, THIS_MONTH, THIS_YEAR }
|
|||||||
|
|
||||||
|
|
||||||
// ── ViewModel ─────────────────────────────────────────────────────────────────
|
// ── ViewModel ─────────────────────────────────────────────────────────────────
|
||||||
|
@OptIn(FlowPreview::class)
|
||||||
class HistoryViewModel(
|
class HistoryViewModel(
|
||||||
private val repo : WalletRepository,
|
private val repo : WalletRepository,
|
||||||
private val aliasRepo : NodeAliasRepository,
|
private val aliasRepo : NodeAliasRepository,
|
||||||
@@ -122,11 +124,121 @@ class HistoryViewModel(
|
|||||||
}
|
}
|
||||||
.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList())
|
.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList())
|
||||||
|
|
||||||
|
// ── Room-backed query results ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Holds the result of the last Room query, or null when no query/filter is
|
||||||
|
* active (i.e. we're on the fast in-memory path).
|
||||||
|
*/
|
||||||
|
private val _roomResults = MutableStateFlow<List<PaymentRecord>?>(null)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true when at least one SQL-capable condition is active.
|
||||||
|
* Direction and type are excluded — they're always handled in-memory.
|
||||||
|
*/
|
||||||
|
private fun PaymentFilter.needsRoomQuery(): Boolean =
|
||||||
|
statuses.isNotEmpty() ||
|
||||||
|
minAmountSat != null || maxAmountSat != null ||
|
||||||
|
minCreatedAt != null || maxCreatedAt != null
|
||||||
|
|
||||||
|
init {
|
||||||
|
// Watch search query (debounced) + filter for SQL-capable conditions.
|
||||||
|
// Whenever either changes and a Room query is needed, fire queryAll().
|
||||||
|
// When neither is active, clear _roomResults to fall back to in-memory.
|
||||||
|
viewModelScope.launch {
|
||||||
|
combine(
|
||||||
|
_searchQuery.debounce(300),
|
||||||
|
_filter
|
||||||
|
) { q, f -> q to f }
|
||||||
|
.collect { (q, f) ->
|
||||||
|
if (q.isBlank() && !f.needsRoomQuery()) {
|
||||||
|
Log.d(TAG, "SEARCH/FILTER [IN-MEMORY] q=blank filter=default → using cached _state")
|
||||||
|
_roomResults.value = null // back to in-memory path
|
||||||
|
} else {
|
||||||
|
Log.d(TAG, "SEARCH/FILTER [ROOM ] q=\"$q\" statuses=${f.statuses} " +
|
||||||
|
"dir=${f.direction} types=${f.types} " +
|
||||||
|
"amt=${f.minAmountSat}–${f.maxAmountSat} " +
|
||||||
|
"date=${f.minCreatedAt}–${f.maxCreatedAt}")
|
||||||
|
val results = paymentCache.queryAll(q, f)
|
||||||
|
_roomResults.value = results
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Existing init block below — unchanged
|
||||||
|
viewModelScope.launch {
|
||||||
|
val cached = paymentCache.loadCachedPage(
|
||||||
|
offset = 0,
|
||||||
|
limit = WalletConstants.PAYMENTS_PAGE_SIZE
|
||||||
|
)
|
||||||
|
if (cached.isNotEmpty()) {
|
||||||
|
Log.d(TAG, "PAYMENTS [INIT ] Showing ${cached.size} cached payments immediately")
|
||||||
|
currentOffset = cached.size
|
||||||
|
_state.value = HistoryState.Success(
|
||||||
|
payments = cached.distinctBy { it.paymentHash },
|
||||||
|
canLoadMore = cached.size >= WalletConstants.PAYMENTS_PAGE_SIZE,
|
||||||
|
isRefreshing = true
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Log.d(TAG, "PAYMENTS [INIT ] No cache — cold load")
|
||||||
|
}
|
||||||
|
syncNewPayments()
|
||||||
|
}
|
||||||
|
viewModelScope.launch {
|
||||||
|
WalletNotificationService.paymentEvents.collect { event ->
|
||||||
|
Log.d(TAG, "PAYMENTS [WS EVENT ] incoming event ${event.paymentHash} — refreshing list")
|
||||||
|
val current = _state.value
|
||||||
|
if (current is HistoryState.Success) {
|
||||||
|
_state.value = current.copy(isRefreshing = true)
|
||||||
|
}
|
||||||
|
syncNewPayments()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── filteredState ─────────────────────────────────────────────────────────
|
||||||
|
/**
|
||||||
|
* The list shown in the UI.
|
||||||
|
*
|
||||||
|
* Two paths:
|
||||||
|
* A) _roomResults == null → in-memory path (no search, no SQL-capable filter active)
|
||||||
|
* Fast, zero DB overhead, identical to the old behaviour.
|
||||||
|
*
|
||||||
|
* B) _roomResults != null → Room path (search or SQL-capable filter active)
|
||||||
|
* Base list comes from Room (full table, already filtered by SQL).
|
||||||
|
* Direction + type filters are then applied in-memory on top.
|
||||||
|
* canLoadMore is forced false — Room already returned everything.
|
||||||
|
*/
|
||||||
|
val filteredState: StateFlow<HistoryState> = combine(
|
||||||
|
_state, _filter, _searchQuery, _roomResults
|
||||||
|
) { s, f, _, roomResults ->
|
||||||
|
|
||||||
/** The list shown in the UI — raw state with filter applied. */
|
|
||||||
val filteredState: StateFlow<HistoryState> = combine(_state, _filter, _searchQuery) { s, f, q ->
|
|
||||||
if (s !is HistoryState.Success) return@combine s
|
if (s !is HistoryState.Success) return@combine s
|
||||||
val filtered = s.payments
|
|
||||||
|
// ── Path A: pure in-memory (search blank, no SQL-capable filter) ──────
|
||||||
|
if (roomResults == null) {
|
||||||
|
Log.d(TAG, "SEARCH/FILTER [IN-MEMORY] filteredState: base=${s.payments.size} payments")
|
||||||
|
val filtered = s.payments
|
||||||
|
.distinctBy { it.paymentHash }
|
||||||
|
.let { list ->
|
||||||
|
when (f.direction) {
|
||||||
|
DirectionFilter.ALL -> list
|
||||||
|
DirectionFilter.OUTGOING -> list.filter { it.isOutgoing }
|
||||||
|
DirectionFilter.INCOMING -> list.filter { !it.isOutgoing }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.let { list ->
|
||||||
|
if (f.types.isEmpty()) list
|
||||||
|
else list.filter { it.extra?.tag in f.types }
|
||||||
|
}
|
||||||
|
Log.d(TAG, "SEARCH/FILTER [IN-MEMORY] filteredState: after filters → ${filtered.size} shown")
|
||||||
|
return@combine s.copy(payments = filtered)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Path B: Room results — apply direction + type in-memory ───────────
|
||||||
|
Log.d(TAG, "SEARCH/FILTER [ROOM ] filteredState: base=${roomResults.size} from Room")
|
||||||
|
val filtered = roomResults
|
||||||
|
.distinctBy { it.paymentHash }
|
||||||
.let { list ->
|
.let { list ->
|
||||||
when (f.direction) {
|
when (f.direction) {
|
||||||
DirectionFilter.ALL -> list
|
DirectionFilter.ALL -> list
|
||||||
@@ -134,56 +246,17 @@ class HistoryViewModel(
|
|||||||
DirectionFilter.INCOMING -> list.filter { !it.isOutgoing }
|
DirectionFilter.INCOMING -> list.filter { !it.isOutgoing }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.let { list ->
|
|
||||||
if (f.statuses.isEmpty()) list
|
|
||||||
else list.filter { payment ->
|
|
||||||
f.statuses.any { s ->
|
|
||||||
when (s) {
|
|
||||||
StatusFilter.COMPLETED -> isCompletedStatus(payment.status)
|
|
||||||
StatusFilter.PENDING -> isPendingStatus(payment.status)
|
|
||||||
StatusFilter.FAILED -> isFailedStatus(payment.status)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.let { list ->
|
.let { list ->
|
||||||
if (f.types.isEmpty()) list
|
if (f.types.isEmpty()) list
|
||||||
else list.filter { it.extra?.tag in f.types }
|
else list.filter { it.extra?.tag in f.types }
|
||||||
}
|
}
|
||||||
.let { list ->
|
|
||||||
if (f.minAmountSat == null && f.maxAmountSat == null) list
|
// canLoadMore = false: Room returned the full matching set already
|
||||||
else list.filter { payment ->
|
Log.d(TAG, "SEARCH/FILTER [ROOM ] filteredState: after dir+type → ${filtered.size} shown")
|
||||||
val amt = payment.amountSat
|
s.copy(payments = filtered, canLoadMore = false)
|
||||||
(f.minAmountSat == null || amt >= f.minAmountSat) &&
|
|
||||||
(f.maxAmountSat == null || amt <= f.maxAmountSat)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.let { list ->
|
|
||||||
if (f.minCreatedAt == null && f.maxCreatedAt == null) list
|
|
||||||
else list.filter { payment ->
|
|
||||||
val epoch = payment.createdAt
|
|
||||||
?: payment.time.toLongOrNull()
|
|
||||||
?: return@filter false
|
|
||||||
(f.minCreatedAt == null || epoch >= f.minCreatedAt) &&
|
|
||||||
(f.maxCreatedAt == null || epoch <= f.maxCreatedAt)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.let { list ->
|
|
||||||
// ── Search (last stage, applied after all filters) ─────────
|
|
||||||
val query = q.lowercase()
|
|
||||||
if (query.isBlank()) list
|
|
||||||
else list.filter { payment ->
|
|
||||||
payment.bolt11?.lowercase()?.contains(query) == true ||
|
|
||||||
payment.paymentHash.lowercase().contains(query) ||
|
|
||||||
payment.memo?.lowercase()?.contains(query) == true ||
|
|
||||||
payment.preimage?.lowercase()?.contains(query) == true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
s.copy(payments = filtered)
|
|
||||||
}.stateIn(viewModelScope, SharingStarted.Eagerly, HistoryState.Loading)
|
}.stateIn(viewModelScope, SharingStarted.Eagerly, HistoryState.Loading)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
private var currentOffset = 0
|
private var currentOffset = 0
|
||||||
private var loadJob: Job? = null
|
private var loadJob: Job? = null
|
||||||
|
|
||||||
@@ -298,6 +371,9 @@ class HistoryViewModel(
|
|||||||
|
|
||||||
fun loadMore() {
|
fun loadMore() {
|
||||||
if (loadJob?.isActive == true) return
|
if (loadJob?.isActive == true) return
|
||||||
|
// Guard against Room path — filteredState already returns canLoadMore=false
|
||||||
|
val filtered = filteredState.value
|
||||||
|
if (filtered !is HistoryState.Success || !filtered.canLoadMore) return
|
||||||
val current = _state.value
|
val current = _state.value
|
||||||
if (current !is HistoryState.Success || !current.canLoadMore) return
|
if (current !is HistoryState.Success || !current.canLoadMore) return
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user