refactor: combine filter and search states
This commit is contained in:
@@ -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 searchQuery : String = "",
|
||||||
val direction : DirectionFilter = DirectionFilter.ALL,
|
val direction : DirectionFilter = DirectionFilter.ALL,
|
||||||
val statuses : Set<StatusFilter> = emptySet(),
|
val statuses : Set<StatusFilter> = emptySet(),
|
||||||
val types : Set<String> = 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 =
|
||||||
|
searchQuery.isNotBlank() ||
|
||||||
statuses.isNotEmpty() ||
|
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)
|
||||||
|
|||||||
Reference in New Issue
Block a user