feat: add filtering by amountSat

This commit is contained in:
2026-06-02 23:32:09 +02:00
parent 5d118cd396
commit 05562f847d
2 changed files with 178 additions and 33 deletions
@@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.ArrowDownward import androidx.compose.material.icons.filled.ArrowDownward
@@ -26,6 +27,7 @@ import androidx.compose.ui.platform.ClipEntry
import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -56,7 +58,10 @@ fun HistoryScreen(
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val isFiltered = filter.direction != DirectionFilter.ALL val isFiltered = filter.direction != DirectionFilter.ALL
|| filter.status != StatusFilter.ALL || filter.statuses.isNotEmpty()
|| filter.types.isNotEmpty()
|| filter.minAmountSat != null
|| filter.maxAmountSat != null
Scaffold( Scaffold(
topBar = { topBar = {
@@ -95,11 +100,12 @@ fun HistoryScreen(
) { ) {
// ── Active filter chips ─────────────────────────────────────────── // ── Active filter chips ───────────────────────────────────────────
if (isFiltered) { if (isFiltered) {
Row( FlowRow(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp), .padding(horizontal = 16.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp) horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) { ) {
if (filter.direction != DirectionFilter.ALL) { if (filter.direction != DirectionFilter.ALL) {
InputChip( InputChip(
@@ -122,24 +128,59 @@ fun HistoryScreen(
} }
) )
} }
if (filter.status != StatusFilter.ALL) { filter.statuses.forEach { s ->
InputChip( InputChip(
selected = true, selected = true,
onClick = { vm.setStatusFilter(StatusFilter.ALL) }, onClick = { vm.toggleStatusFilter(s) },
label = { label = {
Text( Text(
when (filter.status) { when (s) {
StatusFilter.COMPLETED -> "Completed" StatusFilter.COMPLETED -> "Completed"
StatusFilter.PENDING -> "Pending" StatusFilter.PENDING -> "Pending"
StatusFilter.FAILED -> "Failed" StatusFilter.FAILED -> "Failed"
StatusFilter.ALL -> ""
} }
) )
}, },
trailingIcon = { trailingIcon = {
Icon( Icon(
imageVector = Icons.Default.Close, imageVector = Icons.Default.Close,
contentDescription = "Clear status filter", contentDescription = "Remove status filter",
modifier = Modifier.size(16.dp)
)
}
)
}
filter.types.forEach { type ->
InputChip(
selected = true,
onClick = { vm.toggleTypeFilter(type) },
label = { Text(type.uppercase()) },
trailingIcon = {
Icon(
imageVector = Icons.Default.Close,
contentDescription = "Remove type filter",
modifier = Modifier.size(16.dp)
)
}
)
}
if (filter.minAmountSat != null || filter.maxAmountSat != null) {
val label = when {
filter.minAmountSat != null && filter.maxAmountSat != null ->
"${filter.minAmountSat}${filter.maxAmountSat} sats"
filter.minAmountSat != null ->
"${filter.minAmountSat} sats"
else ->
"${filter.maxAmountSat} sats"
}
InputChip(
selected = true,
onClick = { vm.setAmountFilter(null, null) },
label = { Text(label) },
trailingIcon = {
Icon(
imageVector = Icons.Default.Close,
contentDescription = "Clear amount filter",
modifier = Modifier.size(16.dp) modifier = Modifier.size(16.dp)
) )
} }
@@ -236,21 +277,21 @@ fun HistoryScreen(
} }
} }
} }
val availableTypes by vm.availableTypes.collectAsState()
// ── Filter bottom sheet ─────────────────────────────────────────────────── // ── Filter bottom sheet ───────────────────────────────────────────────────
if (filterSheetVisible) { if (filterSheetVisible) {
FilterBottomSheet( FilterBottomSheet(
currentFilter = filter, currentFilter = filter,
availableTypes = availableTypes,
sheetState = sheetState, sheetState = sheetState,
onDismiss = dismissFilterSheet, onDismiss = dismissFilterSheet,
onDirectionSelected = { direction -> onDirectionSelected = { direction ->
vm.setDirectionFilter(direction) vm.setDirectionFilter(direction)
dismissFilterSheet() dismissFilterSheet()
}, },
onStatusSelected = { status -> onStatusToggled = { vm.toggleStatusFilter(it) },
vm.setStatusFilter(status) onTypeToggled = { vm.toggleTypeFilter(it) },
dismissFilterSheet() onAmountFilterSet = { min, max -> vm.setAmountFilter(min, max) }
}
) )
} }
} }
@@ -261,10 +302,13 @@ fun HistoryScreen(
@Composable @Composable
private fun FilterBottomSheet( private fun FilterBottomSheet(
currentFilter : PaymentFilter, currentFilter : PaymentFilter,
availableTypes : List<String>,
sheetState : SheetState, sheetState : SheetState,
onDismiss : () -> Unit, onDismiss : () -> Unit,
onDirectionSelected: (DirectionFilter) -> Unit, onDirectionSelected: (DirectionFilter) -> Unit,
onStatusSelected : (StatusFilter) -> Unit onStatusToggled : (StatusFilter) -> Unit,
onTypeToggled : (String) -> Unit,
onAmountFilterSet : (min: Long?, max: Long?) -> Unit
) { ) {
ModalBottomSheet( ModalBottomSheet(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
@@ -313,18 +357,77 @@ private fun FilterBottomSheet(
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(12.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
StatusFilter.entries.forEach { option -> StatusFilter.entries.forEach { option ->
val label = when (option) { FilterChip(
StatusFilter.ALL -> "All" selected = option in currentFilter.statuses,
onClick = { onStatusToggled(option) },
label = {
Text(
when (option) {
StatusFilter.COMPLETED -> "Completed" StatusFilter.COMPLETED -> "Completed"
StatusFilter.PENDING -> "Pending" StatusFilter.PENDING -> "Pending"
StatusFilter.FAILED -> "Failed" StatusFilter.FAILED -> "Failed"
} }
FilterChip(
selected = currentFilter.status == option,
onClick = { onStatusSelected(option) },
label = { Text(label) }
) )
} }
)
}
}
if (availableTypes.isNotEmpty()) {
Spacer(Modifier.height(20.dp))
Text("Type", style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant)
Spacer(Modifier.height(12.dp))
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
availableTypes.forEach { type ->
FilterChip(
selected = type in currentFilter.types,
onClick = { onTypeToggled(type) },
label = { Text(type.uppercase()) }
)
}
}
}
Spacer(Modifier.height(20.dp))
// ── Amount (sats) ─────────────────────────────────────────────────────
Text(
text = "Amount (sats)",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(Modifier.height(12.dp))
// Local draft state — committed on focus-loss / field change
var minText by rememberSaveable { mutableStateOf(currentFilter.minAmountSat?.toString() ?: "") }
var maxText by rememberSaveable { mutableStateOf(currentFilter.maxAmountSat?.toString() ?: "") }
val commitAmount = {
val min = minText.trim().toLongOrNull()
val max = maxText.trim().toLongOrNull()
onAmountFilterSet(min, max)
}
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
OutlinedTextField(
value = minText,
onValueChange = { minText = it.filter(Char::isDigit); commitAmount() },
label = { Text("Min") },
suffix = { Text("sats") },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.weight(1f)
)
Text("", style = MaterialTheme.typography.bodyLarge)
OutlinedTextField(
value = maxText,
onValueChange = { maxText = it.filter(Char::isDigit); commitAmount() },
label = { Text("Max") },
suffix = { Text("sats") },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.weight(1f)
)
} }
} }
} }
@@ -15,6 +15,7 @@ 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.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -23,12 +24,15 @@ import kotlinx.coroutines.launch
data class PaymentFilter( data class PaymentFilter(
val direction: DirectionFilter = DirectionFilter.ALL, val direction: DirectionFilter = DirectionFilter.ALL,
val status: StatusFilter = StatusFilter.ALL val statuses: Set<StatusFilter> = emptySet(),
val types: Set<String> = emptySet(),
val minAmountSat : Long? = null,
val maxAmountSat : Long? = null
) )
enum class DirectionFilter { ALL, OUTGOING, INCOMING } enum class DirectionFilter { ALL, OUTGOING, INCOMING }
enum class StatusFilter { ALL, COMPLETED, PENDING, FAILED } enum class StatusFilter { COMPLETED, PENDING, FAILED }
// ── ViewModel ───────────────────────────────────────────────────────────────── // ── ViewModel ─────────────────────────────────────────────────────────────────
@@ -52,9 +56,31 @@ class HistoryViewModel(
fun setDirectionFilter(direction: DirectionFilter) { fun setDirectionFilter(direction: DirectionFilter) {
_filter.update { it.copy(direction = direction) } _filter.update { it.copy(direction = direction) }
} }
fun setStatusFilter(status: StatusFilter) { fun toggleStatusFilter(status: StatusFilter) {
_filter.update { it.copy(status = status) } _filter.update { f ->
val updated = if (status in f.statuses) f.statuses - status else f.statuses + status
f.copy(statuses = updated)
} }
}
fun toggleTypeFilter(type: String) {
_filter.update { f ->
val updated = if (type in f.types) f.types - type else f.types + type
f.copy(types = updated)
}
}
fun setAmountFilter(min: Long?, max: Long?) {
_filter.update { it.copy(minAmountSat = min, maxAmountSat = max) }
}
fun clearStatusFilter() { _filter.update { it.copy(statuses = emptySet()) } }
fun clearTypeFilter() { _filter.update { it.copy(types = emptySet()) } }
/** Distinct non-null tags present in the currently loaded list. */
val availableTypes: StateFlow<List<String>> = _state
.map { s ->
if (s !is HistoryState.Success) emptyList()
else s.payments.mapNotNull { it.extra?.tag }.distinct().sorted()
}
.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList())
/** The list shown in the UI — raw state with filter applied. */ /** The list shown in the UI — raw state with filter applied. */
@@ -69,11 +95,27 @@ class HistoryViewModel(
} }
} }
.let { list -> .let { list ->
when (f.status) { if (f.statuses.isEmpty()) list
StatusFilter.ALL -> list else list.filter { payment ->
StatusFilter.COMPLETED -> list.filter { isCompletedStatus(it.status) } f.statuses.any { s ->
StatusFilter.PENDING -> list.filter { isPendingStatus(it.status) } when (s) {
StatusFilter.FAILED -> list.filter { isFailedStatus(it.status) } StatusFilter.COMPLETED -> isCompletedStatus(payment.status)
StatusFilter.PENDING -> isPendingStatus(payment.status)
StatusFilter.FAILED -> isFailedStatus(payment.status)
}
}
}
}
.let { list ->
if (f.types.isEmpty()) list
else list.filter { it.extra?.tag in f.types }
}
.let { list ->
if (f.minAmountSat == null && f.maxAmountSat == null) list
else list.filter { payment ->
val amt = payment.amountSat
(f.minAmountSat == null || amt >= f.minAmountSat) &&
(f.maxAmountSat == null || amt <= f.maxAmountSat)
} }
} }
s.copy(payments = filtered) s.copy(payments = filtered)
@@ -94,7 +136,7 @@ class HistoryViewModel(
Log.d(TAG, "PAYMENTS [INIT ] Showing ${cached.size} cached payments immediately") Log.d(TAG, "PAYMENTS [INIT ] Showing ${cached.size} cached payments immediately")
currentOffset = cached.size currentOffset = cached.size
_state.value = HistoryState.Success( _state.value = HistoryState.Success(
payments = cached, payments = cached.distinctBy { it.paymentHash },
canLoadMore = cached.size >= WalletConstants.PAYMENTS_PAGE_SIZE, canLoadMore = cached.size >= WalletConstants.PAYMENTS_PAGE_SIZE,
isRefreshing = true isRefreshing = true
) )