feat: add filtering by amountSat
This commit is contained in:
@@ -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,18 +357,77 @@ 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"
|
||||
FilterChip(
|
||||
selected = option in currentFilter.statuses,
|
||||
onClick = { onStatusToggled(option) },
|
||||
label = {
|
||||
Text(
|
||||
when (option) {
|
||||
StatusFilter.COMPLETED -> "Completed"
|
||||
StatusFilter.PENDING -> "Pending"
|
||||
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.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
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user