feat: add filtering by createdAt

This commit is contained in:
2026-06-03 12:10:26 +02:00
parent 5dd2edce53
commit 52da6a37ad
2 changed files with 176 additions and 5 deletions
@@ -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)