refactor: combine filter and search states

This commit is contained in:
2026-06-03 18:34:12 +02:00
parent 4e58681919
commit 57d718caf6
2 changed files with 47 additions and 65 deletions
@@ -54,7 +54,6 @@ fun HistoryScreen(
) { ) {
val state by vm.filteredState.collectAsState() val state by vm.filteredState.collectAsState()
val filter by vm.filter.collectAsState() val filter by vm.filter.collectAsState()
val searchQuery by vm.searchQuery.collectAsState()
val fiatCurrency by vm.fiatCurrency.collectAsState() val fiatCurrency by vm.fiatCurrency.collectAsState()
val fiatSatsPerUnit by vm.fiatSatsPerUnit.collectAsState() val fiatSatsPerUnit by vm.fiatSatsPerUnit.collectAsState()
val listState = rememberLazyListState() val listState = rememberLazyListState()
@@ -87,7 +86,7 @@ fun HistoryScreen(
actions = { actions = {
// Search button // Search button
BadgedBox( BadgedBox(
badge = { if (searchQuery.isNotBlank()) Badge() } badge = { if (filter.searchQuery.isNotBlank()) Badge() }
) { ) {
IconButton(onClick = { IconButton(onClick = {
searchVisible = !searchVisible searchVisible = !searchVisible
@@ -143,7 +142,7 @@ fun HistoryScreen(
} }
// Text field — takes all available space // Text field — takes all available space
TextField( TextField(
value = searchQuery, value = filter.searchQuery,
onValueChange = { vm.setSearchQuery(it) }, onValueChange = { vm.setSearchQuery(it) },
placeholder = { Text("Search transactions") }, placeholder = { Text("Search transactions") },
singleLine = true, singleLine = true,
@@ -173,12 +172,12 @@ fun HistoryScreen(
// Clear button — only enabled when query is non-empty // Clear button — only enabled when query is non-empty
IconButton( IconButton(
onClick = { vm.clearSearch() }, onClick = { vm.clearSearch() },
enabled = searchQuery.isNotEmpty() enabled = filter.searchQuery.isNotEmpty()
) { ) {
Icon( Icon(
imageVector = Icons.Default.Close, imageVector = Icons.Default.Close,
contentDescription = "Clear search", contentDescription = "Clear search",
tint = if (searchQuery.isNotEmpty()) tint = if (filter.searchQuery.isNotEmpty())
LocalContentColor.current LocalContentColor.current
else else
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
@@ -188,7 +187,7 @@ fun HistoryScreen(
HorizontalDivider() HorizontalDivider()
} }
// ── Active filter chips ─────────────────────────────────────────── // ── Active filter chips ───────────────────────────────────────────
val searchActive = searchQuery.isNotBlank() val searchActive = filter.searchQuery.isNotBlank()
if (isFiltered || searchActive) { if (isFiltered || searchActive) {
FlowRow( FlowRow(
@@ -312,7 +311,7 @@ fun HistoryScreen(
vm.clearSearch() vm.clearSearch()
searchVisible = false searchVisible = false
}, },
label = { Text("search: …${searchQuery.takeLast(8)}") }, label = { Text("search: …${filter.searchQuery.takeLast(8)}") },
trailingIcon = { trailingIcon = {
Icon( Icon(
imageVector = Icons.Default.Close, imageVector = Icons.Default.Close,
@@ -25,16 +25,26 @@ import kotlinx.coroutines.launch
// ── Filter model ────────────────────────────────────────────────────────────── // ── Filter model ──────────────────────────────────────────────────────────────
data class PaymentFilter( data class PaymentFilter(
val direction: DirectionFilter = DirectionFilter.ALL, val searchQuery : String = "",
val statuses: Set<StatusFilter> = emptySet(), val direction : DirectionFilter = DirectionFilter.ALL,
val types: Set<String> = emptySet(), val statuses : Set<StatusFilter> = emptySet(),
val types : Set<String> = emptySet(),
val minAmountSat : Long? = null, val minAmountSat : Long? = null,
val maxAmountSat : Long? = null, val maxAmountSat : Long? = null,
val minCreatedAt : Long? = null, // epoch seconds, inclusive val minCreatedAt : Long? = null,
val maxCreatedAt : Long? = null, // epoch seconds, inclusive val maxCreatedAt : Long? = null,
val datePreset : DatePreset? = null // which preset chip is active val datePreset : DatePreset? = null
) )
val PaymentFilter.isActive: Boolean
get() = searchQuery.isNotBlank()
|| direction != DirectionFilter.ALL
|| statuses.isNotEmpty()
|| types.isNotEmpty()
|| minAmountSat != null
|| maxAmountSat != null
|| minCreatedAt != null
|| maxCreatedAt != null
enum class DirectionFilter { ALL, OUTGOING, INCOMING } enum class DirectionFilter { ALL, OUTGOING, INCOMING }
enum class StatusFilter { COMPLETED, PENDING, FAILED } enum class StatusFilter { COMPLETED, PENDING, FAILED }
@@ -54,19 +64,14 @@ class HistoryViewModel(
private val _state = MutableStateFlow<HistoryState>(HistoryState.Loading) private val _state = MutableStateFlow<HistoryState>(HistoryState.Loading)
val state: StateFlow<HistoryState> = _state val state: StateFlow<HistoryState> = _state
// ── Search state ─────────────────────────────────────────────────────── // ── Filter state (search is just another filter field) ────────────────────
private val _searchQuery = MutableStateFlow("")
val searchQuery: StateFlow<String> = _searchQuery
fun setSearchQuery(q: String) { _searchQuery.update { q.trim() } }
fun clearSearch() { _searchQuery.value = "" }
// ── Filter state ──────────────────────────────────────────────────────────
private val _filter = MutableStateFlow(PaymentFilter()) private val _filter = MutableStateFlow(PaymentFilter())
val filter: StateFlow<PaymentFilter> = _filter val filter: StateFlow<PaymentFilter> = _filter
fun setSearchQuery(q: String) { _filter.update { it.copy(searchQuery = q.trim()) } }
fun clearSearch() { _filter.update { it.copy(searchQuery = "") } }
fun setDirectionFilter(direction: DirectionFilter) { fun setDirectionFilter(direction: DirectionFilter) {
_filter.update { it.copy(direction = direction) } _filter.update { it.copy(direction = direction) }
} }
@@ -126,46 +131,37 @@ class HistoryViewModel(
// ── Room-backed query results ───────────────────────────────────────────── // ── 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) 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 = private fun PaymentFilter.needsRoomQuery(): Boolean =
statuses.isNotEmpty() || searchQuery.isNotBlank() ||
statuses.isNotEmpty() ||
minAmountSat != null || maxAmountSat != null || minAmountSat != null || maxAmountSat != null ||
minCreatedAt != null || maxCreatedAt != null minCreatedAt != null || maxCreatedAt != null
init { init {
// Watch search query (debounced) + filter for SQL-capable conditions. // Debounce only the search query field; other filter changes are instant.
// Whenever either changes and a Room query is needed, fire queryAll(). // We do this by splitting the flow: debounce a search-only signal, then
// When neither is active, clear _roomResults to fall back to in-memory. // re-combine with the rest of the filter so non-search changes are fast.
viewModelScope.launch { viewModelScope.launch {
combine( combine(
_searchQuery.debounce(300), _filter.map { it.searchQuery }.debounce(300),
_filter _filter
) { q, f -> q to f } ) { _, f -> f }
.collect { (q, f) -> .collect { f ->
if (q.isBlank() && !f.needsRoomQuery()) { if (!f.needsRoomQuery()) {
Log.d(TAG, "SEARCH/FILTER [IN-MEMORY] q=blank filter=default → using cached _state") Log.d(TAG, "FILTER [IN-MEMORY] filter=default → using cached _state")
_roomResults.value = null // back to in-memory path _roomResults.value = null
} else { } else {
Log.d(TAG, "SEARCH/FILTER [ROOM ] q=\"$q\" statuses=${f.statuses} " + Log.d(TAG, "FILTER [ROOM ] q=\"${f.searchQuery}\" " +
"dir=${f.direction} types=${f.types} " + "statuses=${f.statuses} dir=${f.direction} types=${f.types} " +
"amt=${f.minAmountSat}${f.maxAmountSat} " + "amt=${f.minAmountSat}${f.maxAmountSat} " +
"date=${f.minCreatedAt}${f.maxCreatedAt}") "date=${f.minCreatedAt}${f.maxCreatedAt}")
val results = paymentCache.queryAll(q, f) _roomResults.value = paymentCache.queryAll(f.searchQuery, f)
_roomResults.value = results
} }
} }
} }
// Existing init block below — unchanged
viewModelScope.launch { viewModelScope.launch {
val cached = paymentCache.loadCachedPage( val cached = paymentCache.loadCachedPage(
offset = 0, offset = 0,
@@ -197,27 +193,15 @@ class HistoryViewModel(
} }
// ── filteredState ───────────────────────────────────────────────────────── // ── 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( val filteredState: StateFlow<HistoryState> = combine(
_state, _filter, _searchQuery, _roomResults _state, _filter, _roomResults
) { s, f, _, roomResults -> ) { s, f, roomResults ->
if (s !is HistoryState.Success) return@combine s if (s !is HistoryState.Success) return@combine s
// ── Path A: pure in-memory (search blank, no SQL-capable filter) ────── // ── Path A: pure in-memory ────────────────────────────────────────────
if (roomResults == null) { if (roomResults == null) {
Log.d(TAG, "SEARCH/FILTER [IN-MEMORY] filteredState: base=${s.payments.size} payments") Log.d(TAG, "FILTER [IN-MEMORY] filteredState: base=${s.payments.size} payments")
val filtered = s.payments val filtered = s.payments
.distinctBy { it.paymentHash } .distinctBy { it.paymentHash }
.let { list -> .let { list ->
@@ -231,12 +215,12 @@ class HistoryViewModel(
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 }
} }
Log.d(TAG, "SEARCH/FILTER [IN-MEMORY] filteredState: after filters → ${filtered.size} shown") Log.d(TAG, "FILTER [IN-MEMORY] filteredState: after filters → ${filtered.size} shown")
return@combine s.copy(payments = filtered) return@combine s.copy(payments = filtered)
} }
// ── Path B: Room results — apply direction + type in-memory ─────────── // ── Path B: Room results — apply direction + type in-memory ───────────
Log.d(TAG, "SEARCH/FILTER [ROOM ] filteredState: base=${roomResults.size} from Room") Log.d(TAG, "FILTER [ROOM ] filteredState: base=${roomResults.size} from Room")
val filtered = roomResults val filtered = roomResults
.distinctBy { it.paymentHash } .distinctBy { it.paymentHash }
.let { list -> .let { list ->
@@ -251,8 +235,7 @@ class HistoryViewModel(
else list.filter { it.extra?.tag in f.types } else list.filter { it.extra?.tag in f.types }
} }
// canLoadMore = false: Room returned the full matching set already Log.d(TAG, "FILTER [ROOM ] filteredState: after dir+type → ${filtered.size} shown")
Log.d(TAG, "SEARCH/FILTER [ROOM ] filteredState: after dir+type → ${filtered.size} shown")
s.copy(payments = filtered, canLoadMore = false) s.copy(payments = filtered, canLoadMore = false)
}.stateIn(viewModelScope, SharingStarted.Eagerly, HistoryState.Loading) }.stateIn(viewModelScope, SharingStarted.Eagerly, HistoryState.Loading)