diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/i18n/EnStrings.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/i18n/EnStrings.kt index 7a0576b..1abce6b 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/i18n/EnStrings.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/i18n/EnStrings.kt @@ -55,6 +55,8 @@ val EnHomeStrings = AppStrings( details = "Details", enterValidAmount = "Enter a valid amount", satsUnit = { sats -> "$sats sats" }, + today = "today", + yesterday = "yesterday", // ── SendScreen — general ────────────────────────────────────────────────── send = "Send", diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/i18n/EsStrings.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/i18n/EsStrings.kt index 97976eb..c4491f5 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/i18n/EsStrings.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/i18n/EsStrings.kt @@ -55,6 +55,8 @@ val EsHomeStrings = AppStrings( details = "Detalles", enterValidAmount = "Introduce una cantidad válida", satsUnit = { sats -> "$sats sats" }, + today = "hoy", + yesterday = "ayer", // ── SendScreen — general ────────────────────────────────────────────────── send = "Enviar", diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/i18n/EuStrings.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/i18n/EuStrings.kt index 475c2b5..81e06f2 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/i18n/EuStrings.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/i18n/EuStrings.kt @@ -54,6 +54,8 @@ val EuStrings = AppStrings( details = "Xehetasunak", enterValidAmount = "Sartu zenbateko baliogarri bat", satsUnit = { sats -> "$sats sat" }, + today = "gaur", + yesterday = "atzo", // ── SendScreen — general ────────────────────────────────────────────────── send = "Bidali", diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/i18n/Strings.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/i18n/Strings.kt index 0e48011..514b930 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/i18n/Strings.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/i18n/Strings.kt @@ -63,6 +63,8 @@ data class AppStrings( val details : String, val enterValidAmount : String, val satsUnit : (sats: Long) -> String, + val today : String, + val yesterday : String, // ── SendScreen — general ────────────────────────────────────────────────── val send : String, diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/history/FilterBottomSheet.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/history/FilterBottomSheet.kt index 8301cb7..e30fae3 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/history/FilterBottomSheet.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/history/FilterBottomSheet.kt @@ -220,7 +220,7 @@ internal fun FilterBottomSheet( ) { Text( currentFilter.minCreatedAt - ?.let { formatEpochShort(it) } + ?.let { formatEpochShort(it, strings.today, strings.yesterday) } ?: strings.history.filterDateFrom_label // ← "From" ) } @@ -231,7 +231,7 @@ internal fun FilterBottomSheet( ) { Text( currentFilter.maxCreatedAt - ?.let { formatEpochShort(it) } + ?.let { formatEpochShort(it, strings.today, strings.yesterday) } ?: strings.history.filterDateTo_label // ← "To" ) } diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/history/HistoryScreen.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/history/HistoryScreen.kt index 14d6650..83f98ce 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/history/HistoryScreen.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/history/HistoryScreen.kt @@ -464,13 +464,13 @@ private fun ActiveFilterChips( null -> when { minCreatedAt != null && maxCreatedAt != null -> strings.history.filterDateRange( // ← "X–Y" - formatEpochShort(minCreatedAt), - formatEpochShort(maxCreatedAt) + formatEpochShort(minCreatedAt, strings.today, strings.yesterday), + formatEpochShort(maxCreatedAt, strings.today, strings.yesterday) ) minCreatedAt != null -> - strings.history.filterDateFrom(formatEpochShort(minCreatedAt)) // ← "From X" + strings.history.filterDateFrom(formatEpochShort(minCreatedAt, strings.today, strings.yesterday)) // ← "From X" else -> - strings.history.filterDateUntil(formatEpochShort(maxCreatedAt!!)) // ← "Until X" + strings.history.filterDateUntil(formatEpochShort(maxCreatedAt!!, strings.today, strings.yesterday)) // ← "Until X" } } InputChip( diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/history/PaymentDetailScreen.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/history/PaymentDetailScreen.kt index f0e06bd..7c01f65 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/history/PaymentDetailScreen.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/history/PaymentDetailScreen.kt @@ -233,7 +233,11 @@ private fun PaymentDetailContent( // ── Basic info ─────────────────────────────────────────────────────── item { 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( strings.history.detailRowMemo, // ← "Memo" payment.memo?.takeIf { it.isNotBlank() } ?: strings.history.detailRowMemoEmpty // ← "—" diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/history/PaymentRow.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/history/PaymentRow.kt index fbcbd29..e712df2 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/history/PaymentRow.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/history/PaymentRow.kt @@ -78,7 +78,7 @@ internal fun PaymentRow( ) Spacer(Modifier.height(2.dp)) val formattedTime = remember(payment.createdAt ?: payment.time) { - formatTimestamp(payment.createdAt, payment.time) + formatTimestamp(payment.createdAt, payment.time, strings.today, strings.yesterday) } Text( text = formattedTime, diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/nwc/NwcConnectionRow.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/nwc/NwcConnectionRow.kt index 6fb59d9..19b809a 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/nwc/NwcConnectionRow.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/nwc/NwcConnectionRow.kt @@ -14,8 +14,9 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.bitcointxoko.gudariwallet.api.NwcGetResponse +import com.bitcointxoko.gudariwallet.strings import com.bitcointxoko.gudariwallet.ui.theme.semanticColors -import com.bitcointxoko.gudariwallet.util.formatEpoch +import com.bitcointxoko.gudariwallet.util.formatEpochShort import kotlinx.coroutines.launch import java.time.Instant @@ -35,7 +36,7 @@ internal fun NwcConnectionRow( val dismissState = rememberSwipeToDismissBoxState( confirmValueChange = { // 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 }, positionalThreshold = { totalDistance -> totalDistance * 0.4f } @@ -47,7 +48,7 @@ internal fun NwcConnectionRow( var dialogTriggered by remember { mutableStateOf(false) } 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) { if (dismissState.targetValue == SwipeToDismissBoxValue.EndToStart && !isDeleting @@ -122,7 +123,7 @@ internal fun NwcConnectionRow( Spacer(Modifier.height(2.dp)) Text( 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, color = MaterialTheme.colorScheme.onSurfaceVariant ) diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/nwc/NwcDetailScreen.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/nwc/NwcDetailScreen.kt index c8f3862..ec7edd1 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/nwc/NwcDetailScreen.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/nwc/NwcDetailScreen.kt @@ -92,8 +92,6 @@ fun ConnectionDetailScreen( onRevoke : (() -> Unit)? = null // optional – show Revoke button when provided ) { val strings = LocalAppStrings.current - val clipboard = LocalClipboard.current - val scope = rememberCoroutineScope() Scaffold( topBar = { @@ -218,13 +216,13 @@ private fun ConnectionDetailContent( DetailRow( label = strings.nwc.connectionRowCreated, // ← "Connected" - value = formatEpoch(connection.createdAt) + value = formatEpoch(connection.createdAt, strings.today, strings.yesterday) ) DetailRow( label = strings.nwc.connectionRowLastUsed, // ← "Last used" value = if (connection.lastUsed > 0) - formatEpoch(connection.lastUsed) + formatEpoch(connection.lastUsed, strings.today, strings.yesterday) else strings.nwc.connectionNeverUsed // ← "Never" ) @@ -234,9 +232,13 @@ private fun ConnectionDetailContent( value = when { connection.expiresAt <= 0 -> strings.nwc.connectionNoExpiry // ← "Never" 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 ) @@ -383,7 +385,7 @@ private fun BudgetCard(budget: BudgetData) { // Created at DetailRow( 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 = permission.label(strings), // already localised + text = permission.label(strings), // already localized style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f) ) diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/util/DateTimeUtils.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/util/DateTimeUtils.kt index be23f63..cffb53c 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/util/DateTimeUtils.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/util/DateTimeUtils.kt @@ -2,9 +2,11 @@ package com.bitcointxoko.gudariwallet.util import timber.log.Timber import java.time.Instant +import java.time.LocalDate import java.time.ZoneId import java.time.format.DateTimeFormatter import java.time.format.FormatStyle +import java.util.Locale // ── Epoch formatters ────────────────────────────────────────────────────────── // @@ -13,45 +15,86 @@ import java.time.format.FormatStyle // class-load time and become stale after a timezone/locale change). /** - * "dd MMM" — e.g. "09 Jun" - * Suitable for compact list labels. + * Returns "Today", "Yesterday", or null if neither applies. + * Extracted so all three formatters share the same logic without duplication. */ -fun formatEpochShort(epochSeconds: Long): String = - DateTimeFormatter.ofPattern("dd MMM") - .withZone(ZoneId.systemDefault()) - .format(Instant.ofEpochSecond(epochSeconds)) +enum class RelativeDay { TODAY, YESTERDAY } +private fun relativeDay(instant: Instant, zone: ZoneId = ZoneId.systemDefault()): RelativeDay? { + val date = instant.atZone(zone).toLocalDate() + 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 * as [rawTime] fallback (for payments not yet persisted through Room). */ -fun formatTimestamp(createdAt: Long?, rawTime: String): String { - val formatter = DateTimeFormatter - .ofPattern("dd MMM yyyy, HH:mm") - .withZone(ZoneId.systemDefault()) - if (createdAt != null && createdAt > 1_000_000_000L) { - return formatter.format(Instant.ofEpochSecond(createdAt)) +fun formatTimestamp(createdAt: Long?, rawTime: String, todayLabel: String, yesterdayLabel: String): String { + val zone = ZoneId.systemDefault() + val timeFmt = DateTimeFormatter.ofPattern("HH:mm").withZone(zone) + val fullFmt = DateTimeFormatter.ofPattern("dd MMM yyyy, HH:mm").withZone(zone) + + 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 { - formatter.format(Instant.ofEpochSecond(rawTime.toLong())) + format(Instant.ofEpochSecond(rawTime.toLong())) }.recoverCatching { - formatter.format(java.time.OffsetDateTime.parse(rawTime)) + format(java.time.OffsetDateTime.parse(rawTime).toInstant()) }.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 * values (e.g. 0L = "never expires") before calling this. */ -fun formatEpoch(epochSeconds: Long): String = +fun formatEpoch(epochSeconds: Long, todayLabel: String, yesterdayLabel: String): String = try { - DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - .withZone(ZoneId.systemDefault()) - .format(Instant.ofEpochSecond(epochSeconds)) + 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) + .withZone(zone) + .format(instant) + } + raw.lowercase().replaceFirstChar { it.titlecase(Locale.getDefault()) } } catch (e: Exception) { Timber.tag("DateTimeUtils").w(e, "formatEpoch: could not format epochSeconds=$epochSeconds") "—" } +