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.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
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.LocalContext
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.TextOverflow
import androidx.compose.ui.unit.dp
@@ -56,7 +58,10 @@ fun HistoryScreen(
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val isFiltered = filter.direction != DirectionFilter.ALL
|| filter.status != StatusFilter.ALL
|| filter.statuses.isNotEmpty()
|| filter.types.isNotEmpty()
|| filter.minAmountSat != null
|| filter.maxAmountSat != null
Scaffold(
topBar = {
@@ -95,11 +100,12 @@ fun HistoryScreen(
) {
// ── Active filter chips ───────────────────────────────────────────
if (isFiltered) {
Row(
FlowRow(
modifier = Modifier
.fillMaxWidth()
.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) {
InputChip(
@@ -122,24 +128,59 @@ fun HistoryScreen(
}
)
}
if (filter.status != StatusFilter.ALL) {
filter.statuses.forEach { s ->
InputChip(
selected = true,
onClick = { vm.setStatusFilter(StatusFilter.ALL) },
onClick = { vm.toggleStatusFilter(s) },
label = {
Text(
when (filter.status) {
when (s) {
StatusFilter.COMPLETED -> "Completed"
StatusFilter.PENDING -> "Pending"
StatusFilter.FAILED -> "Failed"
StatusFilter.ALL -> ""
}
)
},
trailingIcon = {
Icon(
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)
)
}
@@ -236,21 +277,21 @@ fun HistoryScreen(
}
}
}
val availableTypes by vm.availableTypes.collectAsState()
// ── Filter bottom sheet ───────────────────────────────────────────────────
if (filterSheetVisible) {
FilterBottomSheet(
currentFilter = filter,
availableTypes = availableTypes,
sheetState = sheetState,
onDismiss = dismissFilterSheet,
onDirectionSelected = { direction ->
vm.setDirectionFilter(direction)
dismissFilterSheet()
},
onStatusSelected = { status ->
vm.setStatusFilter(status)
dismissFilterSheet()
}
onStatusToggled = { vm.toggleStatusFilter(it) },
onTypeToggled = { vm.toggleTypeFilter(it) },
onAmountFilterSet = { min, max -> vm.setAmountFilter(min, max) }
)
}
}
@@ -261,10 +302,13 @@ fun HistoryScreen(
@Composable
private fun FilterBottomSheet(
currentFilter : PaymentFilter,
availableTypes : List<String>,
sheetState : SheetState,
onDismiss : () -> Unit,
onDirectionSelected: (DirectionFilter) -> Unit,
onStatusSelected : (StatusFilter) -> Unit
onStatusToggled : (StatusFilter) -> Unit,
onTypeToggled : (String) -> Unit,
onAmountFilterSet : (min: Long?, max: Long?) -> Unit
) {
ModalBottomSheet(
onDismissRequest = onDismiss,
@@ -313,19 +357,78 @@ private fun FilterBottomSheet(
Spacer(Modifier.height(12.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
StatusFilter.entries.forEach { option ->
val label = when (option) {
StatusFilter.ALL -> "All"
StatusFilter.COMPLETED -> "Completed"
StatusFilter.PENDING -> "Pending"
StatusFilter.FAILED -> "Failed"
}
FilterChip(
selected = currentFilter.status == option,
onClick = { onStatusSelected(option) },
label = { Text(label) }
selected = option in currentFilter.statuses,
onClick = { onStatusToggled(option) },
label = {
Text(
when (option) {
StatusFilter.COMPLETED -> "Completed"
StatusFilter.PENDING -> "Pending"
StatusFilter.FAILED -> "Failed"
}
)
}
)
}
}
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.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
@@ -23,12 +24,15 @@ import kotlinx.coroutines.launch
data class PaymentFilter(
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 StatusFilter { ALL, COMPLETED, PENDING, FAILED }
enum class StatusFilter { COMPLETED, PENDING, FAILED }
// ── ViewModel ─────────────────────────────────────────────────────────────────
@@ -52,9 +56,31 @@ class HistoryViewModel(
fun setDirectionFilter(direction: DirectionFilter) {
_filter.update { it.copy(direction = direction) }
}
fun setStatusFilter(status: StatusFilter) {
_filter.update { it.copy(status = status) }
fun toggleStatusFilter(status: StatusFilter) {
_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. */
@@ -69,11 +95,27 @@ class HistoryViewModel(
}
}
.let { list ->
when (f.status) {
StatusFilter.ALL -> list
StatusFilter.COMPLETED -> list.filter { isCompletedStatus(it.status) }
StatusFilter.PENDING -> list.filter { isPendingStatus(it.status) }
StatusFilter.FAILED -> list.filter { isFailedStatus(it.status) }
if (f.statuses.isEmpty()) list
else list.filter { payment ->
f.statuses.any { s ->
when (s) {
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)
@@ -94,7 +136,7 @@ class HistoryViewModel(
Log.d(TAG, "PAYMENTS [INIT ] Showing ${cached.size} cached payments immediately")
currentOffset = cached.size
_state.value = HistoryState.Success(
payments = cached,
payments = cached.distinctBy { it.paymentHash },
canLoadMore = cached.size >= WalletConstants.PAYMENTS_PAGE_SIZE,
isRefreshing = true
)