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