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.ClipData
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.util.Log
|
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
@@ -63,6 +62,8 @@ fun HistoryScreen(
|
|||||||
|| filter.types.isNotEmpty()
|
|| filter.types.isNotEmpty()
|
||||||
|| filter.minAmountSat != null
|
|| filter.minAmountSat != null
|
||||||
|| filter.maxAmountSat != null
|
|| filter.maxAmountSat != null
|
||||||
|
|| filter.minCreatedAt != null
|
||||||
|
|| filter.maxCreatedAt != null
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
topBar = {
|
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) },
|
onStatusToggled = { vm.toggleStatusFilter(it) },
|
||||||
onTypeToggled = { vm.toggleTypeFilter(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,
|
onDirectionSelected: (DirectionFilter) -> Unit,
|
||||||
onStatusToggled : (StatusFilter) -> Unit,
|
onStatusToggled : (StatusFilter) -> Unit,
|
||||||
onTypeToggled : (String) -> 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(
|
ModalBottomSheet(
|
||||||
onDismissRequest = onDismiss,
|
onDismissRequest = onDismiss,
|
||||||
@@ -430,6 +466,96 @@ private fun FilterBottomSheet(
|
|||||||
modifier = Modifier.weight(1f)
|
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 ──────────────────────────────────────────────────────────────────
|
// ── 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 {
|
private fun formatTimestamp(createdAt: Long?, rawTime: String): String {
|
||||||
Log.d("TimestampDebug", "createdAt=$createdAt rawTime=$rawTime")
|
|
||||||
val formatter = java.time.format.DateTimeFormatter
|
val formatter = java.time.format.DateTimeFormatter
|
||||||
.ofPattern("dd MMM yyyy, HH:mm")
|
.ofPattern("dd MMM yyyy, HH:mm")
|
||||||
.withZone(java.time.ZoneId.systemDefault())
|
.withZone(java.time.ZoneId.systemDefault())
|
||||||
|
|||||||
@@ -27,12 +27,16 @@ data class PaymentFilter(
|
|||||||
val statuses: Set<StatusFilter> = emptySet(),
|
val statuses: Set<StatusFilter> = emptySet(),
|
||||||
val types: Set<String> = emptySet(),
|
val types: Set<String> = emptySet(),
|
||||||
val minAmountSat : Long? = null,
|
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 DirectionFilter { ALL, OUTGOING, INCOMING }
|
||||||
enum class StatusFilter { COMPLETED, PENDING, FAILED }
|
enum class StatusFilter { COMPLETED, PENDING, FAILED }
|
||||||
|
enum class DatePreset { THIS_WEEK, THIS_MONTH, THIS_YEAR }
|
||||||
|
|
||||||
|
|
||||||
// ── ViewModel ─────────────────────────────────────────────────────────────────
|
// ── ViewModel ─────────────────────────────────────────────────────────────────
|
||||||
@@ -74,6 +78,34 @@ class HistoryViewModel(
|
|||||||
fun clearStatusFilter() { _filter.update { it.copy(statuses = emptySet()) } }
|
fun clearStatusFilter() { _filter.update { it.copy(statuses = emptySet()) } }
|
||||||
fun clearTypeFilter() { _filter.update { it.copy(types = 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. */
|
/** Distinct non-null tags present in the currently loaded list. */
|
||||||
val availableTypes: StateFlow<List<String>> = _state
|
val availableTypes: StateFlow<List<String>> = _state
|
||||||
.map { s ->
|
.map { s ->
|
||||||
@@ -118,6 +150,16 @@ class HistoryViewModel(
|
|||||||
(f.maxAmountSat == null || amt <= f.maxAmountSat)
|
(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)
|
s.copy(payments = filtered)
|
||||||
}.stateIn(viewModelScope, SharingStarted.Eagerly, HistoryState.Loading)
|
}.stateIn(viewModelScope, SharingStarted.Eagerly, HistoryState.Loading)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user