enhancement: show relative days
This commit is contained in:
@@ -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( // ← "X–Y"
|
strings.history.filterDateRange( // ← "X–Y"
|
||||||
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()
|
||||||
|
val instant = Instant.ofEpochSecond(epochSeconds)
|
||||||
|
val raw: String = when (relativeDay(instant, zone)) {
|
||||||
|
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)
|
.ofLocalizedDateTime(FormatStyle.SHORT)
|
||||||
.withZone(ZoneId.systemDefault())
|
.withZone(zone)
|
||||||
.format(Instant.ofEpochSecond(epochSeconds))
|
.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")
|
||||||
"—"
|
"—"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user