From 57d718caf6cfa46c6151ce6eb7e3c8a91edb3544 Mon Sep 17 00:00:00 2001 From: rasputin Date: Wed, 3 Jun 2026 18:34:12 +0200 Subject: [PATCH] refactor: combine filter and search states --- .../gudariwallet/ui/HistoryScreen.kt | 13 ++- .../gudariwallet/ui/HistoryViewModel.kt | 99 ++++++++----------- 2 files changed, 47 insertions(+), 65 deletions(-) diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/HistoryScreen.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/HistoryScreen.kt index 9a251b8..6889eaf 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/HistoryScreen.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/HistoryScreen.kt @@ -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, diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/HistoryViewModel.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/HistoryViewModel.kt index e2d300e..14e8aa7 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/HistoryViewModel.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/HistoryViewModel.kt @@ -25,16 +25,26 @@ import kotlinx.coroutines.launch // ── Filter model ────────────────────────────────────────────────────────────── data class PaymentFilter( - val direction: DirectionFilter = DirectionFilter.ALL, - val statuses: Set = emptySet(), - val types: Set = emptySet(), + val searchQuery : String = "", + val direction : DirectionFilter = DirectionFilter.ALL, + val statuses : Set = emptySet(), + val types : Set = 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.Loading) val state: StateFlow = _state - // ── Search state ─────────────────────────────────────────────────────── - - private val _searchQuery = MutableStateFlow("") - val searchQuery: StateFlow = _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 = _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?>(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() || + 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 = 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)