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.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,19 +357,78 @@ 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) {
|
|
||||||
StatusFilter.ALL -> "All"
|
|
||||||
StatusFilter.COMPLETED -> "Completed"
|
|
||||||
StatusFilter.PENDING -> "Pending"
|
|
||||||
StatusFilter.FAILED -> "Failed"
|
|
||||||
}
|
|
||||||
FilterChip(
|
FilterChip(
|
||||||
selected = currentFilter.status == option,
|
selected = option in currentFilter.statuses,
|
||||||
onClick = { onStatusSelected(option) },
|
onClick = { onStatusToggled(option) },
|
||||||
label = { Text(label) }
|
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.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
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user