enhancement: show relative days

This commit is contained in:
2026-06-12 19:05:28 +02:00
parent 8c84eca8eb
commit 201242c797
11 changed files with 99 additions and 41 deletions
@@ -55,6 +55,8 @@ val EnHomeStrings = AppStrings(
details = "Details", details = "Details",
enterValidAmount = "Enter a valid amount", enterValidAmount = "Enter a valid amount",
satsUnit = { sats -> "$sats sats" }, satsUnit = { sats -> "$sats sats" },
today = "today",
yesterday = "yesterday",
// ── SendScreen — general ────────────────────────────────────────────────── // ── SendScreen — general ──────────────────────────────────────────────────
send = "Send", send = "Send",
@@ -55,6 +55,8 @@ val EsHomeStrings = AppStrings(
details = "Detalles", details = "Detalles",
enterValidAmount = "Introduce una cantidad válida", enterValidAmount = "Introduce una cantidad válida",
satsUnit = { sats -> "$sats sats" }, satsUnit = { sats -> "$sats sats" },
today = "hoy",
yesterday = "ayer",
// ── SendScreen — general ────────────────────────────────────────────────── // ── SendScreen — general ──────────────────────────────────────────────────
send = "Enviar", send = "Enviar",
@@ -54,6 +54,8 @@ val EuStrings = AppStrings(
details = "Xehetasunak", details = "Xehetasunak",
enterValidAmount = "Sartu zenbateko baliogarri bat", enterValidAmount = "Sartu zenbateko baliogarri bat",
satsUnit = { sats -> "$sats sat" }, satsUnit = { sats -> "$sats sat" },
today = "gaur",
yesterday = "atzo",
// ── SendScreen — general ────────────────────────────────────────────────── // ── SendScreen — general ──────────────────────────────────────────────────
send = "Bidali", send = "Bidali",
@@ -63,6 +63,8 @@ data class AppStrings(
val details : String, val details : String,
val enterValidAmount : String, val enterValidAmount : String,
val satsUnit : (sats: Long) -> String, val satsUnit : (sats: Long) -> String,
val today : String,
val yesterday : String,
// ── SendScreen — general ────────────────────────────────────────────────── // ── SendScreen — general ──────────────────────────────────────────────────
val send : String, val send : String,
@@ -220,7 +220,7 @@ internal fun FilterBottomSheet(
) { ) {
Text( Text(
currentFilter.minCreatedAt currentFilter.minCreatedAt
?.let { formatEpochShort(it) } ?.let { formatEpochShort(it, strings.today, strings.yesterday) }
?: strings.history.filterDateFrom_label // ← "From" ?: strings.history.filterDateFrom_label // ← "From"
) )
} }
@@ -231,7 +231,7 @@ internal fun FilterBottomSheet(
) { ) {
Text( Text(
currentFilter.maxCreatedAt currentFilter.maxCreatedAt
?.let { formatEpochShort(it) } ?.let { formatEpochShort(it, strings.today, strings.yesterday) }
?: strings.history.filterDateTo_label // ← "To" ?: strings.history.filterDateTo_label // ← "To"
) )
} }
@@ -464,13 +464,13 @@ private fun ActiveFilterChips(
null -> when { null -> when {
minCreatedAt != null && maxCreatedAt != null -> minCreatedAt != null && maxCreatedAt != null ->
strings.history.filterDateRange( // ← "XY" strings.history.filterDateRange( // ← "XY"
formatEpochShort(minCreatedAt), formatEpochShort(minCreatedAt, strings.today, strings.yesterday),
formatEpochShort(maxCreatedAt) formatEpochShort(maxCreatedAt, strings.today, strings.yesterday)
) )
minCreatedAt != null -> minCreatedAt != null ->
strings.history.filterDateFrom(formatEpochShort(minCreatedAt)) // ← "From X" strings.history.filterDateFrom(formatEpochShort(minCreatedAt, strings.today, strings.yesterday)) // ← "From X"
else -> else ->
strings.history.filterDateUntil(formatEpochShort(maxCreatedAt!!)) // ← "Until X" strings.history.filterDateUntil(formatEpochShort(maxCreatedAt!!, strings.today, strings.yesterday)) // ← "Until X"
} }
} }
InputChip( InputChip(
@@ -233,7 +233,11 @@ private fun PaymentDetailContent(
// ── Basic info ─────────────────────────────────────────────────────── // ── Basic info ───────────────────────────────────────────────────────
item { item {
DetailSection(title = strings.history.detailSectionDetails) { // ← "Details" DetailSection(title = strings.history.detailSectionDetails) { // ← "Details"
DetailRow(strings.history.detailRowDate, formatTimestamp(payment.createdAt, payment.time)) // ← "Date" DetailRow(strings.history.detailRowDate, formatTimestamp( // ← "Date"
payment.createdAt,
payment.time,
strings.today,
strings.yesterday))
DetailRow( DetailRow(
strings.history.detailRowMemo, // ← "Memo" strings.history.detailRowMemo, // ← "Memo"
payment.memo?.takeIf { it.isNotBlank() } ?: strings.history.detailRowMemoEmpty // ← "—" payment.memo?.takeIf { it.isNotBlank() } ?: strings.history.detailRowMemoEmpty // ← "—"
@@ -78,7 +78,7 @@ internal fun PaymentRow(
) )
Spacer(Modifier.height(2.dp)) Spacer(Modifier.height(2.dp))
val formattedTime = remember(payment.createdAt ?: payment.time) { val formattedTime = remember(payment.createdAt ?: payment.time) {
formatTimestamp(payment.createdAt, payment.time) formatTimestamp(payment.createdAt, payment.time, strings.today, strings.yesterday)
} }
Text( Text(
text = formattedTime, text = formattedTime,
@@ -14,8 +14,9 @@ import androidx.compose.ui.Modifier
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
import com.bitcointxoko.gudariwallet.api.NwcGetResponse import com.bitcointxoko.gudariwallet.api.NwcGetResponse
import com.bitcointxoko.gudariwallet.strings
import com.bitcointxoko.gudariwallet.ui.theme.semanticColors import com.bitcointxoko.gudariwallet.ui.theme.semanticColors
import com.bitcointxoko.gudariwallet.util.formatEpoch import com.bitcointxoko.gudariwallet.util.formatEpochShort
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.time.Instant import java.time.Instant
@@ -35,7 +36,7 @@ internal fun NwcConnectionRow(
val dismissState = rememberSwipeToDismissBoxState( val dismissState = rememberSwipeToDismissBoxState(
confirmValueChange = { confirmValueChange = {
// Always reject — we never want the row to stay dismissed. // Always reject — we never want the row to stay dismissed.
// The dialog is triggered via LaunchedEffect on targetValue below. // The dialogue is triggered via LaunchedEffect on targetValue below.
false false
}, },
positionalThreshold = { totalDistance -> totalDistance * 0.4f } positionalThreshold = { totalDistance -> totalDistance * 0.4f }
@@ -47,7 +48,7 @@ internal fun NwcConnectionRow(
var dialogTriggered by remember { mutableStateOf(false) } var dialogTriggered by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
// Fires when the swipe crosses the threshold — trigger confirm dialog // Fires when the swipe crosses the threshold — trigger confirm dialogue
LaunchedEffect(dismissState.targetValue) { LaunchedEffect(dismissState.targetValue) {
if (dismissState.targetValue == SwipeToDismissBoxValue.EndToStart if (dismissState.targetValue == SwipeToDismissBoxValue.EndToStart
&& !isDeleting && !isDeleting
@@ -122,7 +123,7 @@ internal fun NwcConnectionRow(
Spacer(Modifier.height(2.dp)) Spacer(Modifier.height(2.dp))
Text( Text(
text = if (key.last_used <= 0L) "Never used" text = if (key.last_used <= 0L) "Never used"
else "Last used ${formatEpoch(key.last_used)}", else "Last used ${formatEpochShort(key.last_used, strings.today, strings.yesterday)}",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
@@ -92,8 +92,6 @@ fun ConnectionDetailScreen(
onRevoke : (() -> Unit)? = null // optional show Revoke button when provided onRevoke : (() -> Unit)? = null // optional show Revoke button when provided
) { ) {
val strings = LocalAppStrings.current val strings = LocalAppStrings.current
val clipboard = LocalClipboard.current
val scope = rememberCoroutineScope()
Scaffold( Scaffold(
topBar = { topBar = {
@@ -218,13 +216,13 @@ private fun ConnectionDetailContent(
DetailRow( DetailRow(
label = strings.nwc.connectionRowCreated, // ← "Connected" label = strings.nwc.connectionRowCreated, // ← "Connected"
value = formatEpoch(connection.createdAt) value = formatEpoch(connection.createdAt, strings.today, strings.yesterday)
) )
DetailRow( DetailRow(
label = strings.nwc.connectionRowLastUsed, // ← "Last used" label = strings.nwc.connectionRowLastUsed, // ← "Last used"
value = if (connection.lastUsed > 0) value = if (connection.lastUsed > 0)
formatEpoch(connection.lastUsed) formatEpoch(connection.lastUsed, strings.today, strings.yesterday)
else else
strings.nwc.connectionNeverUsed // ← "Never" strings.nwc.connectionNeverUsed // ← "Never"
) )
@@ -234,9 +232,13 @@ private fun ConnectionDetailContent(
value = when { value = when {
connection.expiresAt <= 0 -> strings.nwc.connectionNoExpiry // ← "Never" connection.expiresAt <= 0 -> strings.nwc.connectionNoExpiry // ← "Never"
isExpired -> strings.nwc.connectionExpired( // ← "Expired · 12 Jan 2025" isExpired -> strings.nwc.connectionExpired( // ← "Expired · 12 Jan 2025"
formatEpoch(connection.expiresAt) formatEpoch(connection.expiresAt, strings.today, strings.yesterday)
)
else -> formatEpoch(
connection.expiresAt,
strings.today,
strings.yesterday
) )
else -> formatEpoch(connection.expiresAt)
}, },
// valueColor = if (isExpired) MaterialTheme.colorScheme.error else null // valueColor = if (isExpired) MaterialTheme.colorScheme.error else null
) )
@@ -383,7 +385,7 @@ private fun BudgetCard(budget: BudgetData) {
// Created at // Created at
DetailRow( DetailRow(
label = strings.nwc.connectionRowCreated, // ← "Created" label = strings.nwc.connectionRowCreated, // ← "Created"
value = formatEpoch(budget.createdAt) value = formatEpoch(budget.createdAt, strings.today, strings.yesterday)
) )
} }
} }
@@ -415,7 +417,7 @@ private fun PermissionRow(permission: ConnectionPermission, strings: AppStrings)
) )
} }
Text( Text(
text = permission.label(strings), // already localised text = permission.label(strings), // already localized
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
) )
@@ -2,9 +2,11 @@ package com.bitcointxoko.gudariwallet.util
import timber.log.Timber import timber.log.Timber
import java.time.Instant import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId import java.time.ZoneId
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle import java.time.format.FormatStyle
import java.util.Locale
// ── Epoch formatters ────────────────────────────────────────────────────────── // ── Epoch formatters ──────────────────────────────────────────────────────────
// //
@@ -13,45 +15,86 @@ import java.time.format.FormatStyle
// class-load time and become stale after a timezone/locale change). // class-load time and become stale after a timezone/locale change).
/** /**
* "dd MMM" — e.g. "09 Jun" * Returns "Today", "Yesterday", or null if neither applies.
* Suitable for compact list labels. * Extracted so all three formatters share the same logic without duplication.
*/ */
fun formatEpochShort(epochSeconds: Long): String = enum class RelativeDay { TODAY, YESTERDAY }
DateTimeFormatter.ofPattern("dd MMM") private fun relativeDay(instant: Instant, zone: ZoneId = ZoneId.systemDefault()): RelativeDay? {
.withZone(ZoneId.systemDefault()) val date = instant.atZone(zone).toLocalDate()
.format(Instant.ofEpochSecond(epochSeconds)) val today = LocalDate.now(zone)
return when (date) {
today -> RelativeDay.TODAY
today.minusDays(1) -> RelativeDay.YESTERDAY
else -> null
}
}
/** /**
* "dd MMM yyyy, HH:mm" — e.g. "09 Jun 2025, 14:32" * "Today" / "Yesterday" / "dd MMM" — e.g. "Today", "Yesterday", "09 Jun"
* Suitable for compact list labels.
*/
fun formatEpochShort(epochSeconds: Long, todayLabel: String, yesterdayLabel: String): String {
val instant = Instant.ofEpochSecond(epochSeconds)
val zone = ZoneId.systemDefault()
return when (relativeDay(instant, zone)) {
RelativeDay.TODAY -> todayLabel
RelativeDay.YESTERDAY -> yesterdayLabel
null -> DateTimeFormatter.ofPattern("dd MMM").withZone(zone).format(instant)
}
}
/**
* "Today, HH:mm" / "Yesterday, HH:mm" / "dd MMM yyyy, HH:mm" — e.g. "Today, 14:32"
* Accepts either a numeric epoch-seconds value or an ISO offset-date-time string * Accepts either a numeric epoch-seconds value or an ISO offset-date-time string
* as [rawTime] fallback (for payments not yet persisted through Room). * as [rawTime] fallback (for payments not yet persisted through Room).
*/ */
fun formatTimestamp(createdAt: Long?, rawTime: String): String { fun formatTimestamp(createdAt: Long?, rawTime: String, todayLabel: String, yesterdayLabel: String): String {
val formatter = DateTimeFormatter val zone = ZoneId.systemDefault()
.ofPattern("dd MMM yyyy, HH:mm") val timeFmt = DateTimeFormatter.ofPattern("HH:mm").withZone(zone)
.withZone(ZoneId.systemDefault()) val fullFmt = DateTimeFormatter.ofPattern("dd MMM yyyy, HH:mm").withZone(zone)
if (createdAt != null && createdAt > 1_000_000_000L) {
return formatter.format(Instant.ofEpochSecond(createdAt)) fun format(instant: Instant): String {
return when (relativeDay(instant, zone)) {
RelativeDay.TODAY -> "$todayLabel, ${timeFmt.format(instant)}"
RelativeDay.YESTERDAY -> "$yesterdayLabel, ${timeFmt.format(instant)}"
null -> fullFmt.format(instant)
}
} }
if (createdAt != null && createdAt > 1_000_000_000L) return format(Instant.ofEpochSecond(createdAt))
return runCatching { return runCatching {
formatter.format(Instant.ofEpochSecond(rawTime.toLong())) format(Instant.ofEpochSecond(rawTime.toLong()))
}.recoverCatching { }.recoverCatching {
formatter.format(java.time.OffsetDateTime.parse(rawTime)) format(java.time.OffsetDateTime.parse(rawTime).toInstant())
}.getOrDefault(rawTime) }.getOrDefault(rawTime)
} }
/** /**
* Localized SHORT date-time — e.g. "6/9/25, 2:32 PM" (locale-dependent). * "Today, 2:32 PM" / "Yesterday, 2:32 PM" / localized SHORT date-time (locale-dependent).
* Returns "—" for out-of-range epoch values; callers should handle sentinel * Returns "—" for out-of-range epoch values; callers should handle sentinel
* values (e.g. 0L = "never expires") before calling this. * values (e.g. 0L = "never expires") before calling this.
*/ */
fun formatEpoch(epochSeconds: Long): String = fun formatEpoch(epochSeconds: Long, todayLabel: String, yesterdayLabel: String): String =
try { try {
DateTimeFormatter val zone = ZoneId.systemDefault()
.ofLocalizedDateTime(FormatStyle.SHORT) val instant = Instant.ofEpochSecond(epochSeconds)
.withZone(ZoneId.systemDefault()) val raw: String = when (relativeDay(instant, zone)) {
.format(Instant.ofEpochSecond(epochSeconds)) RelativeDay.TODAY -> "$todayLabel, ${
DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT)
.withZone(zone).format(instant)
}"
RelativeDay.YESTERDAY -> "$yesterdayLabel, ${
DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT)
.withZone(zone).format(instant)
}"
null -> DateTimeFormatter
.ofLocalizedDateTime(FormatStyle.SHORT)
.withZone(zone)
.format(instant)
}
raw.lowercase().replaceFirstChar { it.titlecase(Locale.getDefault()) }
} catch (e: Exception) { } catch (e: Exception) {
Timber.tag("DateTimeUtils").w(e, "formatEpoch: could not format epochSeconds=$epochSeconds") Timber.tag("DateTimeUtils").w(e, "formatEpoch: could not format epochSeconds=$epochSeconds")
"" ""
} }