From 05562f847d12333f11bbccd4531a6ff15c077ccc Mon Sep 17 00:00:00 2001 From: rasputin Date: Tue, 2 Jun 2026 23:32:09 +0200 Subject: [PATCH] feat: add filtering by amountSat --- .../gudariwallet/ui/HistoryScreen.kt | 149 +++++++++++++++--- .../gudariwallet/ui/HistoryViewModel.kt | 62 ++++++-- 2 files changed, 178 insertions(+), 33 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 a07c49a..4198e4c 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/HistoryScreen.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/HistoryScreen.kt @@ -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, 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) + ) + } } } } 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 6ecf3e0..145f5b8 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/HistoryViewModel.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/HistoryViewModel.kt @@ -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 = emptySet(), + val types: Set = 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> = _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 )