feat: add filtering by createdAt
This commit is contained in:
@@ -2,7 +2,6 @@ package com.bitcointxoko.gudariwallet.ui
|
||||
|
||||
import android.content.ClipData
|
||||
import android.content.Intent
|
||||
import android.util.Log
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
@@ -63,6 +62,8 @@ fun HistoryScreen(
|
||||
|| filter.types.isNotEmpty()
|
||||
|| filter.minAmountSat != null
|
||||
|| filter.maxAmountSat != null
|
||||
|| filter.minCreatedAt != null
|
||||
|| filter.maxCreatedAt != null
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
@@ -187,6 +188,34 @@ fun HistoryScreen(
|
||||
}
|
||||
)
|
||||
}
|
||||
val minCreatedAt = filter.minCreatedAt
|
||||
val maxCreatedAt = filter.maxCreatedAt
|
||||
if (minCreatedAt != null || maxCreatedAt != null) {
|
||||
val label = when (filter.datePreset) {
|
||||
DatePreset.THIS_WEEK -> "This week"
|
||||
DatePreset.THIS_MONTH -> "This month"
|
||||
DatePreset.THIS_YEAR -> "This year"
|
||||
null -> when {
|
||||
minCreatedAt != null && maxCreatedAt != null ->
|
||||
"${formatEpochShort(minCreatedAt)}–${formatEpochShort(maxCreatedAt)}"
|
||||
minCreatedAt != null ->
|
||||
"From ${formatEpochShort(minCreatedAt)}"
|
||||
else ->
|
||||
"Until ${formatEpochShort(maxCreatedAt!!)}"}
|
||||
}
|
||||
InputChip(
|
||||
selected = true,
|
||||
onClick = { vm.clearDateFilter() },
|
||||
label = { Text(label) },
|
||||
trailingIcon = {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Close,
|
||||
contentDescription = "Clear date filter",
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -292,7 +321,11 @@ fun HistoryScreen(
|
||||
},
|
||||
onStatusToggled = { vm.toggleStatusFilter(it) },
|
||||
onTypeToggled = { vm.toggleTypeFilter(it) },
|
||||
onAmountFilterSet = { min, max -> vm.setAmountFilter(min, max) }
|
||||
onAmountFilterSet = { min, max -> vm.setAmountFilter(min, max) },
|
||||
onDatePresetSelected = { vm.applyDatePreset(it) },
|
||||
onDateFilterSet = { min, max -> vm.setDateFilter(min, max, preset = null) },
|
||||
onDateClearRequested = { vm.clearDateFilter() }
|
||||
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -309,7 +342,10 @@ private fun FilterBottomSheet(
|
||||
onDirectionSelected: (DirectionFilter) -> Unit,
|
||||
onStatusToggled : (StatusFilter) -> Unit,
|
||||
onTypeToggled : (String) -> Unit,
|
||||
onAmountFilterSet : (min: Long?, max: Long?) -> Unit
|
||||
onAmountFilterSet : (min: Long?, max: Long?) -> Unit,
|
||||
onDatePresetSelected: (DatePreset) -> Unit,
|
||||
onDateFilterSet : (min: Long?, max: Long?) -> Unit,
|
||||
onDateClearRequested: () -> Unit
|
||||
) {
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
@@ -430,6 +466,96 @@ private fun FilterBottomSheet(
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(20.dp))
|
||||
// ── Date ─────────────────────────────────────────────────────────
|
||||
Text(
|
||||
text = "Date",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
// Preset chips
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
DatePreset.entries.forEach { preset ->
|
||||
val label = when (preset) {
|
||||
DatePreset.THIS_WEEK -> "This week"
|
||||
DatePreset.THIS_MONTH -> "This month"
|
||||
DatePreset.THIS_YEAR -> "This year"
|
||||
}
|
||||
FilterChip(
|
||||
selected = currentFilter.datePreset == preset,
|
||||
onClick = {
|
||||
if (currentFilter.datePreset == preset) onDateClearRequested()
|
||||
else onDatePresetSelected(preset)
|
||||
},
|
||||
label = { Text(label) }
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
// Manual From / To date pickers
|
||||
var showFromPicker by remember { mutableStateOf(false) }
|
||||
var showToPicker by remember { mutableStateOf(false) }
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
OutlinedButton(
|
||||
onClick = { showFromPicker = true },
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Text(currentFilter.minCreatedAt?.let { formatEpochShort(it) } ?: "From")
|
||||
}
|
||||
Text("–", style = MaterialTheme.typography.bodyLarge)
|
||||
OutlinedButton(
|
||||
onClick = { showToPicker = true },
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Text(currentFilter.maxCreatedAt?.let { formatEpochShort(it) } ?: "To")
|
||||
}
|
||||
}
|
||||
|
||||
if (showFromPicker) {
|
||||
val pickerState = rememberDatePickerState(
|
||||
initialSelectedDateMillis = currentFilter.minCreatedAt?.let { it * 1000 }
|
||||
)
|
||||
DatePickerDialog(
|
||||
onDismissRequest = { showFromPicker = false },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
val epochSec = pickerState.selectedDateMillis?.div(1000)
|
||||
onDateFilterSet(epochSec, currentFilter.maxCreatedAt)
|
||||
showFromPicker = false
|
||||
}) { Text("OK") }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showFromPicker = false }) { Text("Cancel") }
|
||||
}
|
||||
) { DatePicker(state = pickerState) }
|
||||
}
|
||||
|
||||
if (showToPicker) {
|
||||
val pickerState = rememberDatePickerState(
|
||||
initialSelectedDateMillis = currentFilter.maxCreatedAt?.let { it * 1000 }
|
||||
)
|
||||
DatePickerDialog(
|
||||
onDismissRequest = { showToPicker = false },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
// +86399 to make "To" date inclusive (end of day)
|
||||
val epochSec = pickerState.selectedDateMillis?.div(1000)?.let { it + 86399L }
|
||||
onDateFilterSet(currentFilter.minCreatedAt, epochSec)
|
||||
showToPicker = false
|
||||
}) { Text("OK") }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showToPicker = false }) { Text("Cancel") }
|
||||
}
|
||||
) { DatePicker(state = pickerState) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -901,8 +1027,11 @@ private fun StatusChip(status: String) {
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
private fun formatEpochShort(epoch: Long): String =
|
||||
java.time.format.DateTimeFormatter.ofPattern("dd MMM")
|
||||
.withZone(java.time.ZoneId.systemDefault())
|
||||
.format(java.time.Instant.ofEpochSecond(epoch))
|
||||
private fun formatTimestamp(createdAt: Long?, rawTime: String): String {
|
||||
Log.d("TimestampDebug", "createdAt=$createdAt rawTime=$rawTime")
|
||||
val formatter = java.time.format.DateTimeFormatter
|
||||
.ofPattern("dd MMM yyyy, HH:mm")
|
||||
.withZone(java.time.ZoneId.systemDefault())
|
||||
|
||||
@@ -27,12 +27,16 @@ data class PaymentFilter(
|
||||
val statuses: Set<StatusFilter> = emptySet(),
|
||||
val types: Set<String> = emptySet(),
|
||||
val minAmountSat : Long? = null,
|
||||
val maxAmountSat : Long? = null
|
||||
val maxAmountSat : Long? = null,
|
||||
val minCreatedAt : Long? = null, // epoch seconds, inclusive
|
||||
val maxCreatedAt : Long? = null, // epoch seconds, inclusive
|
||||
val datePreset : DatePreset? = null // which preset chip is active
|
||||
)
|
||||
|
||||
|
||||
enum class DirectionFilter { ALL, OUTGOING, INCOMING }
|
||||
enum class StatusFilter { COMPLETED, PENDING, FAILED }
|
||||
enum class DatePreset { THIS_WEEK, THIS_MONTH, THIS_YEAR }
|
||||
|
||||
|
||||
// ── ViewModel ─────────────────────────────────────────────────────────────────
|
||||
@@ -74,6 +78,34 @@ class HistoryViewModel(
|
||||
fun clearStatusFilter() { _filter.update { it.copy(statuses = emptySet()) } }
|
||||
fun clearTypeFilter() { _filter.update { it.copy(types = emptySet()) } }
|
||||
|
||||
fun setDateFilter(min: Long?, max: Long?, preset: DatePreset? = null) {
|
||||
_filter.update { it.copy(minCreatedAt = min, maxCreatedAt = max, datePreset = preset) }
|
||||
}
|
||||
fun applyDatePreset(preset: DatePreset) {
|
||||
val now = java.time.ZonedDateTime.now()
|
||||
val (min, max) = when (preset) {
|
||||
DatePreset.THIS_WEEK -> {
|
||||
val start = now.with(java.time.DayOfWeek.MONDAY)
|
||||
.truncatedTo(java.time.temporal.ChronoUnit.DAYS)
|
||||
start.toEpochSecond() to now.toEpochSecond()
|
||||
}
|
||||
DatePreset.THIS_MONTH -> {
|
||||
val start = now.withDayOfMonth(1)
|
||||
.truncatedTo(java.time.temporal.ChronoUnit.DAYS)
|
||||
start.toEpochSecond() to now.toEpochSecond()
|
||||
}
|
||||
DatePreset.THIS_YEAR -> {
|
||||
val start = now.withDayOfYear(1)
|
||||
.truncatedTo(java.time.temporal.ChronoUnit.DAYS)
|
||||
start.toEpochSecond() to now.toEpochSecond()
|
||||
}
|
||||
}
|
||||
setDateFilter(min, max, preset)
|
||||
}
|
||||
fun clearDateFilter() {
|
||||
_filter.update { it.copy(minCreatedAt = null, maxCreatedAt = null, datePreset = null) }
|
||||
}
|
||||
|
||||
/** Distinct non-null tags present in the currently loaded list. */
|
||||
val availableTypes: StateFlow<List<String>> = _state
|
||||
.map { s ->
|
||||
@@ -118,6 +150,16 @@ class HistoryViewModel(
|
||||
(f.maxAmountSat == null || amt <= f.maxAmountSat)
|
||||
}
|
||||
}
|
||||
.let { list ->
|
||||
if (f.minCreatedAt == null && f.maxCreatedAt == null) list
|
||||
else list.filter { payment ->
|
||||
val epoch = payment.createdAt
|
||||
?: payment.time.toLongOrNull() // try the raw string field
|
||||
?: return@filter false // no usable timestamp → exclude
|
||||
(f.minCreatedAt == null || epoch >= f.minCreatedAt) &&
|
||||
(f.maxCreatedAt == null || epoch <= f.maxCreatedAt)
|
||||
}
|
||||
}
|
||||
s.copy(payments = filtered)
|
||||
}.stateIn(viewModelScope, SharingStarted.Eagerly, HistoryState.Loading)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user