diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/LocalizedDateTimeFormat.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/LocalizedDateTimeFormat.kt new file mode 100644 index 0000000000..70ecc2dc71 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/LocalizedDateTimeFormat.kt @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.note + +import android.content.Context +import android.text.format.DateFormat +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +/** + * Date/time formatters that respect the user's Android system settings: + * - Date order (dd/mm/yyyy, mm/dd/yyyy, yyyy-mm-dd…) is derived from the + * active [Locale] via [DateFormat.getBestDateTimePattern]. + * - 12-hour vs 24-hour clock follows the system setting via + * [DateFormat.is24HourFormat] (which is the user's manual override on + * top of the locale default). + * + * All formatters here use Unicode LDML skeletons. `j` and `jm` are intentionally + * avoided — `j` only picks 12/24 hour from the *locale*, not the user override. + * We pick the time skeleton explicitly based on [DateFormat.is24HourFormat]. + */ + +private fun timeSkeleton(context: Context): String = if (DateFormat.is24HourFormat(context)) "Hm" else "hma" + +private fun bestPattern( + context: Context, + skeletonBase: String, + includeTime: Boolean, +): SimpleDateFormat { + val locale = Locale.getDefault() + val skeleton = if (includeTime) skeletonBase + timeSkeleton(context) else skeletonBase + return SimpleDateFormat(DateFormat.getBestDateTimePattern(locale, skeleton), locale) +} + +/** Locale-aware month + day + short time (e.g. "May 28, 14:32" / "28 May, 2:32 PM"). */ +fun formatMonthDayTime( + epochSeconds: Long, + context: Context, +): String = bestPattern(context, "MMMd", includeTime = true).format(Date(epochSeconds * 1000L)) + +/** Locale-aware medium date (e.g. "May 28, 2026" / "28 May 2026" / "28.05.2026"). */ +fun formatMediumDate( + epochSeconds: Long, + context: Context, +): String = DateFormat.getMediumDateFormat(context).format(Date(epochSeconds * 1000L)) + +/** Locale-aware medium date + short time, respecting the system 12/24-hour setting. */ +fun formatMediumDateTime( + epochSeconds: Long, + context: Context, +): String { + val date = DateFormat.getMediumDateFormat(context).format(Date(epochSeconds * 1000L)) + val time = DateFormat.getTimeFormat(context).format(Date(epochSeconds * 1000L)) + return "$date $time" +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayCompose.kt index 26e539624a..18ef92c89d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayCompose.kt @@ -50,9 +50,6 @@ import com.vitorpamplona.amethyst.ui.theme.Size5dp import com.vitorpamplona.amethyst.ui.theme.StdPadding import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl -import java.time.Instant -import java.time.ZoneId -import java.time.format.DateTimeFormatter @Composable fun RelayCompose( @@ -145,9 +142,3 @@ fun RemoveRelayButton(onClick: () -> Unit) { Text(text = stringRes(R.string.remove)) } } - -fun formattedDateTime(timestamp: Long): String = - Instant - .ofEpochSecond(timestamp) - .atZone(ZoneId.systemDefault()) - .format(DateTimeFormatter.ofPattern("MMM d, uuuu hh:mm a")) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/TimeAgoFormatter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/TimeAgoFormatter.kt index 457d144f44..6e3305263f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/TimeAgoFormatter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/TimeAgoFormatter.kt @@ -32,25 +32,47 @@ import java.util.Date import java.util.Locale import kotlin.math.round -private const val YEAR_DATE_FORMAT = "MMM dd, yyyy" -private const val MONTH_DATE_FORMAT = "MMM dd" +// Skeletons follow Unicode LDML — DateFormat.getBestDateTimePattern picks the +// correct locale-specific ordering (e.g. "MMM d, y" in en-US vs "d MMM y" in en-GB). +private const val YEAR_SKELETON = "yMMMd" +private const val MONTH_SKELETON = "MMMd" +private const val YEAR_NO_DAY_SKELETON = "yMMM" -private const val YEAR_NO_DAY_DATE_FORMAT = "MMM yyyy" -private const val MONTH_NO_DAY_DATE_FORMAT = "MMM dd" +/** + * Per-thread cached [SimpleDateFormat] keyed off the current default [Locale]. + * + * `SimpleDateFormat` is mutable and not thread-safe, and these formatters are + * read from both the UI thread (composition) and background coroutines + * (e.g. `LocalCache.justVerify` logging failed event verifications). A bare + * `var` shared across threads would race on the formatter's internal Calendar. + * Using `ThreadLocal` gives each thread its own instance — no locks, no + * allocation per call, and we rebuild lazily on locale change. + */ +private class LocaleAwareFormatter( + private val skeleton: String, +) { + private val cache = ThreadLocal>() -var locale: Locale = Locale.getDefault() -var yearFormatter = SimpleDateFormat(YEAR_DATE_FORMAT, locale) -var monthFormatter = SimpleDateFormat(MONTH_DATE_FORMAT, locale) + fun get(): SimpleDateFormat { + val current = Locale.getDefault() + val cached = cache.get() + if (cached != null && cached.first == current) return cached.second + val fresh = SimpleDateFormat(DateFormat.getBestDateTimePattern(current, skeleton), current) + cache.set(current to fresh) + return fresh + } +} -var yearNoDayFormatter = SimpleDateFormat(YEAR_NO_DAY_DATE_FORMAT, locale) -var monthNoDayFormatter = SimpleDateFormat(MONTH_NO_DAY_DATE_FORMAT, locale) +private val yearFormatter = LocaleAwareFormatter(YEAR_SKELETON) +private val monthFormatter = LocaleAwareFormatter(MONTH_SKELETON) +private val yearNoDayFormatter = LocaleAwareFormatter(YEAR_NO_DAY_SKELETON) /** * Formats a Unix timestamp (seconds) as an absolute date/time string, picking the * granularity from how far away the timestamp is: - * - same day → time only (e.g. "14:32" / "2:32 PM", locale-aware) - * - same year → "MMM dd, HH:mm" - * - older → "MMM dd, yyyy" + * - same day → time only (locale + system 12/24-hr aware via [DateFormat.getTimeFormat]) + * - same year → "Jan 5, 14:32" / "5 Jan 14:32" / "Jan 5, 2:32 PM" (locale + system aware) + * - older → "Jan 5, 2024" / "5 Jan 2024" (locale aware) * * Used by [com.vitorpamplona.amethyst.ui.note.elements.TimeAgo] when the user * taps the relative timestamp to reveal the absolute one. @@ -63,12 +85,6 @@ fun timeAbsolute( if (time == null) return " " if (time == 0L) return prefix + stringRes(context, R.string.never) - if (locale != Locale.getDefault()) { - locale = Locale.getDefault() - yearFormatter = SimpleDateFormat(YEAR_DATE_FORMAT, locale) - monthFormatter = SimpleDateFormat(MONTH_DATE_FORMAT, locale) - } - val timeMs = time * 1000 val now = Calendar.getInstance() val then = Calendar.getInstance().apply { timeInMillis = timeMs } @@ -80,8 +96,8 @@ fun timeAbsolute( return when { sameDay -> prefix + timeOfDay - sameYear -> prefix + monthFormatter.format(timeMs) + ", " + timeOfDay - else -> prefix + yearFormatter.format(timeMs) + sameYear -> prefix + monthFormatter.get().format(timeMs) + ", " + timeOfDay + else -> prefix + yearFormatter.get().format(timeMs) } } @@ -106,26 +122,11 @@ fun timeAgo( return when { timeDifference > TimeUtils.ONE_YEAR -> { - // Dec 12, 2022 - - if (locale != Locale.getDefault()) { - locale = Locale.getDefault() - yearFormatter = SimpleDateFormat(YEAR_DATE_FORMAT, locale) - monthFormatter = SimpleDateFormat(MONTH_DATE_FORMAT, locale) - } - - prefix + yearFormatter.format(time * 1000) + prefix + yearFormatter.get().format(time * 1000) } timeDifference > TimeUtils.ONE_MONTH -> { - // Dec 12 - if (locale != Locale.getDefault()) { - locale = Locale.getDefault() - yearFormatter = SimpleDateFormat(YEAR_DATE_FORMAT, locale) - monthFormatter = SimpleDateFormat(MONTH_DATE_FORMAT, locale) - } - - prefix + monthFormatter.format(time * 1000) + prefix + monthFormatter.get().format(time * 1000) } timeDifference > TimeUtils.ONE_DAY -> { @@ -157,26 +158,11 @@ fun timeAgoNoDot( return when { timeDifference > TimeUtils.ONE_YEAR -> { - // Dec 12, 2022 - - if (locale != Locale.getDefault()) { - locale = Locale.getDefault() - yearFormatter = SimpleDateFormat(YEAR_DATE_FORMAT, locale) - monthFormatter = SimpleDateFormat(MONTH_DATE_FORMAT, locale) - } - - yearFormatter.format(time * 1000) + yearFormatter.get().format(time * 1000) } timeDifference > TimeUtils.ONE_MONTH -> { - // Dec 12 - if (locale != Locale.getDefault()) { - locale = Locale.getDefault() - yearFormatter = SimpleDateFormat(YEAR_DATE_FORMAT, locale) - monthFormatter = SimpleDateFormat(MONTH_DATE_FORMAT, locale) - } - - monthFormatter.format(time * 1000) + monthFormatter.get().format(time * 1000) } timeDifference > TimeUtils.ONE_DAY -> { @@ -208,26 +194,11 @@ fun timeAgoNoDotNoDay( return when { timeDifference > TimeUtils.ONE_YEAR -> { - // Dec 12, 2022 - - if (locale != Locale.getDefault()) { - locale = Locale.getDefault() - yearNoDayFormatter = SimpleDateFormat(YEAR_NO_DAY_DATE_FORMAT, locale) - monthNoDayFormatter = SimpleDateFormat(MONTH_NO_DAY_DATE_FORMAT, locale) - } - - yearNoDayFormatter.format(time * 1000) + yearNoDayFormatter.get().format(time * 1000) } timeDifference > TimeUtils.ONE_MONTH -> { - // Dec 12 - if (locale != Locale.getDefault()) { - locale = Locale.getDefault() - yearNoDayFormatter = SimpleDateFormat(YEAR_NO_DAY_DATE_FORMAT, locale) - monthNoDayFormatter = SimpleDateFormat(MONTH_NO_DAY_DATE_FORMAT, locale) - } - - monthNoDayFormatter.format(time * 1000) + monthFormatter.get().format(time * 1000) } timeDifference > TimeUtils.ONE_DAY -> { @@ -259,26 +230,11 @@ fun timeAheadNoDot( return when { timeDifference > TimeUtils.ONE_YEAR -> { - // Dec 12, 2022 - - if (locale != Locale.getDefault()) { - locale = Locale.getDefault() - yearFormatter = SimpleDateFormat(YEAR_DATE_FORMAT, locale) - monthFormatter = SimpleDateFormat(MONTH_DATE_FORMAT, locale) - } - - yearFormatter.format(time * 1000) + yearFormatter.get().format(time * 1000) } timeDifference > TimeUtils.ONE_MONTH -> { - // Dec 12 - if (locale != Locale.getDefault()) { - locale = Locale.getDefault() - yearFormatter = SimpleDateFormat(YEAR_DATE_FORMAT, locale) - monthFormatter = SimpleDateFormat(MONTH_DATE_FORMAT, locale) - } - - monthFormatter.format(time * 1000) + monthFormatter.get().format(time * 1000) } timeDifference > TimeUtils.ONE_DAY -> { @@ -310,24 +266,9 @@ fun dateFormatter( val timeDifference = TimeUtils.now() - time return if (timeDifference > TimeUtils.ONE_YEAR) { - // Dec 12, 2022 - - if (locale != Locale.getDefault()) { - locale = Locale.getDefault() - yearFormatter = SimpleDateFormat(YEAR_DATE_FORMAT, locale) - monthFormatter = SimpleDateFormat(MONTH_DATE_FORMAT, locale) - } - - yearFormatter.format(time * 1000) + yearFormatter.get().format(time * 1000) } else if (timeDifference > TimeUtils.ONE_DAY) { - // Dec 12 - if (locale != Locale.getDefault()) { - locale = Locale.getDefault() - yearFormatter = SimpleDateFormat(YEAR_DATE_FORMAT, locale) - monthFormatter = SimpleDateFormat(MONTH_DATE_FORMAT, locale) - } - - monthFormatter.format(time * 1000) + monthFormatter.get().format(time * 1000) } else { today } @@ -409,12 +350,7 @@ fun lastSeenSentence( } } - if (locale != Locale.getDefault()) { - locale = Locale.getDefault() - yearFormatter = SimpleDateFormat(YEAR_DATE_FORMAT, locale) - monthFormatter = SimpleDateFormat(MONTH_DATE_FORMAT, locale) - } - val dateText = yearFormatter.format(time * 1000) + val dateText = yearFormatter.get().format(time * 1000) return stringRes(context, R.string.last_seen_on_date, dateText, durationText) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/expiration/ExpirationDatePicker.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/expiration/ExpirationDatePicker.kt index 7c779d1f36..4bab7562ac 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/expiration/ExpirationDatePicker.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/expiration/ExpirationDatePicker.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.note.creators.expiration +import android.text.format.DateFormat import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -83,15 +84,14 @@ fun ExpirationDatePicker(model: IExpiration) { }, ) + val context = LocalContext.current val timePickerState = rememberTimePickerState( initialHour = currentTime.hour, initialMinute = currentTime.minute, - is24Hour = false, + is24Hour = DateFormat.is24HourFormat(context), ) - val context = LocalContext.current - Column(Modifier.fillMaxWidth()) { Row( verticalAlignment = Alignment.CenterVertically, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/polls/PollDeadlinePicker.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/polls/PollDeadlinePicker.kt index 8e08a138e4..ee67d55a78 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/polls/PollDeadlinePicker.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/polls/PollDeadlinePicker.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.ui.note.creators.polls import android.annotation.SuppressLint +import android.text.format.DateFormat import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth @@ -81,15 +82,14 @@ fun PollDeadlinePicker(model: ShortNotePostViewModel) { } }, ) + val context = LocalContext.current val timePickerState = rememberTimePickerState( initialHour = currentTime.hour, initialMinute = currentTime.minute, - is24Hour = false, // Set to true if you prefer military time + is24Hour = DateFormat.is24HourFormat(context), ) - val context = LocalContext.current - OutlinedCard( onClick = { showDatePicker = true }, modifier = Modifier.fillMaxWidth(), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zappolls/ZapPollDeadlinePicker.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zappolls/ZapPollDeadlinePicker.kt index e2c981f9e8..41cb41b86a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zappolls/ZapPollDeadlinePicker.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zappolls/ZapPollDeadlinePicker.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.ui.note.creators.zappolls import android.annotation.SuppressLint +import android.text.format.DateFormat import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth @@ -81,15 +82,14 @@ fun ZapPollDeadlinePicker(model: ShortNotePostViewModel) { } }, ) + val context = LocalContext.current val timePickerState = rememberTimePickerState( initialHour = currentTime.hour, initialMinute = currentTime.minute, - is24Hour = false, // Set to true if you prefer military time + is24Hour = DateFormat.is24HourFormat(context), ) - val context = LocalContext.current - OutlinedCard( onClick = { showDatePicker = true }, modifier = Modifier.fillMaxWidth(), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Attestation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Attestation.kt index a5bafcb6af..26579bcf56 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Attestation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Attestation.kt @@ -44,6 +44,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -63,6 +64,7 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote import com.vitorpamplona.amethyst.ui.note.NoteCompose import com.vitorpamplona.amethyst.ui.note.UserCompose +import com.vitorpamplona.amethyst.ui.note.formatMediumDate import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel @@ -76,9 +78,6 @@ import com.vitorpamplona.quartz.experimental.attestations.attestation.tags.Attes import com.vitorpamplona.quartz.experimental.attestations.proficiency.AttestorProficiencyEvent import com.vitorpamplona.quartz.experimental.attestations.recommendation.AttestorRecommendationEvent import com.vitorpamplona.quartz.experimental.attestations.request.AttestationRequestEvent -import java.text.SimpleDateFormat -import java.util.Date -import java.util.Locale @Preview @Composable @@ -188,18 +187,19 @@ fun RenderAttestation( } if (validFrom != null || validTo != null) { + val context = LocalContext.current Spacer(modifier = DoubleVertSpacer) Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { validFrom?.let { Text( - text = stringRes(R.string.attestation_valid_from, formatTimestamp(it)), + text = stringRes(R.string.attestation_valid_from, formatMediumDate(it, context)), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } validTo?.let { Text( - text = stringRes(R.string.attestation_valid_to, formatTimestamp(it)), + text = stringRes(R.string.attestation_valid_to, formatMediumDate(it, context)), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -601,8 +601,3 @@ private fun attestationStatusLabel(status: AttestationStatus?): String = status == AttestationStatus.VERIFYING -> stringRes(R.string.attestation_status_verifying) else -> stringRes(R.string.attestation) } - -private fun formatTimestamp(timestamp: Long): String { - val sdf = SimpleDateFormat("MMM dd, yyyy", Locale.getDefault()) - return sdf.format(Date(timestamp * 1000)) -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarDayView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarDayView.kt index a937a04db2..6cd1e7fa29 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarDayView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarDayView.kt @@ -46,6 +46,7 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -164,6 +165,7 @@ private fun DayRow( } val startSeconds = view.startSeconds + val context = LocalContext.current val timeLabel = when { view.isAllDay -> stringRes(R.string.calendar_all_day) @@ -172,7 +174,7 @@ private fun DayRow( // misleading on day 2 since the event has been ongoing overnight. Show a // continuation marker so the user reads it as "still happening". stringRes(R.string.calendar_continues) - startSeconds != null -> formatTimeOfDay(startSeconds) + startSeconds != null -> formatTimeOfDay(startSeconds, context) else -> "—" } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarEventListCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarEventListCard.kt index e3f0599e8e..8af8a0269e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarEventListCard.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarEventListCard.kt @@ -72,8 +72,8 @@ fun CalendarEventListCard( modifier: Modifier = Modifier, ) { val view = note.appointmentView() ?: return - val range = remember(note.idHex) { formatCalendarRange(note) } val context = LocalContext.current + val range = remember(note.idHex) { formatCalendarRange(note, context) } val relative = remember(note.idHex, view.startSeconds) { relativeTimeLabel(context, view, TimeUtils.now()) } val event = note.event ?: return val detailRoute = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarTimeFormat.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarTimeFormat.kt index f24a7cd20a..2738041aa9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarTimeFormat.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarTimeFormat.kt @@ -20,6 +20,8 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars +import android.content.Context +import android.text.format.DateFormat import com.vitorpamplona.amethyst.commons.model.nip52Calendar.appointmentView import com.vitorpamplona.amethyst.model.Note import java.text.SimpleDateFormat @@ -27,34 +29,73 @@ import java.util.Calendar import java.util.Date import java.util.Locale -private val DayMonthFormat = SimpleDateFormat("EEE, MMM d", Locale.getDefault()) -private val FullDateFormat = SimpleDateFormat("EEEE, MMMM d, yyyy", Locale.getDefault()) -private val MonthYearFormat = SimpleDateFormat("MMMM yyyy", Locale.getDefault()) -private val TimeFormat = SimpleDateFormat("h:mm a", Locale.getDefault()) -private val WeekdayShortFormat = SimpleDateFormat("EEE", Locale.getDefault()) +// Skeletons follow Unicode LDML — getBestDateTimePattern picks locale ordering. +private const val DAY_MONTH_SKELETON = "EEEMMMd" // "Mon, May 28" / "Mon 28 May" +private const val FULL_DATE_SKELETON = "EEEEMMMMdy" // "Monday, May 28, 2026" / "Monday, 28 May 2026" +private const val MONTH_YEAR_SKELETON = "MMMMy" // "May 2026" / "Mai 2026" +private const val WEEKDAY_SHORT_SKELETON = "EEE" // "Mon" — order doesn't matter -fun formatCalendarRange(note: Note): String? { +/** + * Per-thread cached [SimpleDateFormat] keyed off the current default [Locale]. + * + * Cached because these run per cell in the calendar grid (≈42 cells per month, + * 7 per weekday header) — allocating a fresh SimpleDateFormat each call + * showed up as scroll-time GC churn. Per-thread because SimpleDateFormat + * isn't thread-safe; calendars today are read on the UI thread but future + * callers could differ. ThreadLocal gives both — no locks, no contention, + * lazy rebuild on locale change. + */ +private class LocaleAwareFormatter( + private val skeleton: String, +) { + private val cache = ThreadLocal>() + + fun get(): SimpleDateFormat { + val current = Locale.getDefault() + val cached = cache.get() + if (cached != null && cached.first == current) return cached.second + val fresh = SimpleDateFormat(DateFormat.getBestDateTimePattern(current, skeleton), current) + cache.set(current to fresh) + return fresh + } +} + +private val dayMonthFormat = LocaleAwareFormatter(DAY_MONTH_SKELETON) +private val fullDateFormat = LocaleAwareFormatter(FULL_DATE_SKELETON) +private val monthYearFormat = LocaleAwareFormatter(MONTH_YEAR_SKELETON) +private val weekdayShortFormat = LocaleAwareFormatter(WEEKDAY_SHORT_SKELETON) + +// Time format respects the user's Android 12/24-hour system setting. +private fun timeFormat(context: Context): java.text.DateFormat = DateFormat.getTimeFormat(context) + +fun formatCalendarRange( + note: Note, + context: Context, +): String? { val view = note.appointmentView() ?: return null val start = view.startSeconds ?: return null return if (view.isAllDay) { formatDateRange(start, view.endSeconds) } else { - formatTimeRange(start, view.endSeconds) + formatTimeRange(start, view.endSeconds, context) } } private fun formatTimeRange( start: Long, end: Long?, + context: Context, ): String { + val dayMonth = dayMonthFormat.get() + val time = timeFormat(context) val startMs = start * 1000 - val startStr = "${DayMonthFormat.format(Date(startMs))} · ${TimeFormat.format(Date(startMs))}" + val startStr = "${dayMonth.format(Date(startMs))} · ${time.format(Date(startMs))}" if (end == null || end == start) return startStr val endMs = end * 1000 return if (isSameDay(startMs, endMs)) { - "$startStr – ${TimeFormat.format(Date(endMs))}" + "$startStr – ${time.format(Date(endMs))}" } else { - "$startStr – ${DayMonthFormat.format(Date(endMs))} · ${TimeFormat.format(Date(endMs))}" + "$startStr – ${dayMonth.format(Date(endMs))} · ${time.format(Date(endMs))}" } } @@ -62,12 +103,13 @@ private fun formatDateRange( start: Long, end: Long?, ): String { - val startStr = DayMonthFormat.format(Date(start * 1000)) + val dayMonth = dayMonthFormat.get() + val startStr = dayMonth.format(Date(start * 1000)) if (end == null || end == start) return startStr - return "$startStr – ${DayMonthFormat.format(Date(end * 1000))}" + return "$startStr – ${dayMonth.format(Date(end * 1000))}" } -fun formatLongDate(unixSeconds: Long): String = FullDateFormat.format(Date(unixSeconds * 1000)) +fun formatLongDate(unixSeconds: Long): String = fullDateFormat.get().format(Date(unixSeconds * 1000)) fun formatMonthYear( year: Int, @@ -76,10 +118,13 @@ fun formatMonthYear( val cal = Calendar.getInstance() cal.clear() cal.set(year, monthZeroBased, 1) - return MonthYearFormat.format(cal.time) + return monthYearFormat.get().format(cal.time) } -fun formatTimeOfDay(unixSeconds: Long): String = TimeFormat.format(Date(unixSeconds * 1000)) +fun formatTimeOfDay( + unixSeconds: Long, + context: Context, +): String = timeFormat(context).format(Date(unixSeconds * 1000)) fun formatShortWeekday(weekdayZeroBased: Int): String { val cal = Calendar.getInstance() @@ -87,7 +132,7 @@ fun formatShortWeekday(weekdayZeroBased: Int): String { cal.firstDayOfWeek = Calendar.SUNDAY cal.set(Calendar.DAY_OF_WEEK, Calendar.SUNDAY) cal.add(Calendar.DAY_OF_YEAR, weekdayZeroBased) - return WeekdayShortFormat.format(cal.time) + return weekdayShortFormat.get().format(cal.time) } private fun isSameDay( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/create/CalendarDateTimePickerButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/create/CalendarDateTimePickerButton.kt index c9c6f48cde..b90bac387e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/create/CalendarDateTimePickerButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/create/CalendarDateTimePickerButton.kt @@ -38,14 +38,15 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.stringRes import java.text.DateFormat -import java.text.SimpleDateFormat import java.time.Instant import java.time.ZoneId import java.time.ZoneOffset import java.util.Date +import android.text.format.DateFormat as AndroidDateFormat /** * Tap-to-edit button that opens a Material3 DatePicker (and, when [includeTime] is true, @@ -68,6 +69,7 @@ fun CalendarDateTimePickerButton( var showTime by remember { mutableStateOf(false) } val locale = LocalConfiguration.current.locales[0] + val context = LocalContext.current val pretty = if (unixSeconds <= 0L) { placeholder @@ -76,7 +78,7 @@ fun CalendarDateTimePickerButton( .getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT, locale) .format(Date(unixSeconds * 1000)) } else { - SimpleDateFormat("EEEE, MMMM d, yyyy", locale).format(Date(unixSeconds * 1000)) + DateFormat.getDateInstance(DateFormat.FULL, locale).format(Date(unixSeconds * 1000)) } val initialMillis = if (unixSeconds > 0L) unixSeconds * 1000L else System.currentTimeMillis() @@ -94,7 +96,7 @@ fun CalendarDateTimePickerButton( rememberTimePickerState( initialHour = initialLocal.hour, initialMinute = initialLocal.minute, - is24Hour = false, + is24Hour = AndroidDateFormat.is24HourFormat(context), ) fun reset() { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/detail/CalendarEventDetailScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/detail/CalendarEventDetailScreen.kt index e7de2dd865..7d68d70882 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/detail/CalendarEventDetailScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/detail/CalendarEventDetailScreen.kt @@ -318,14 +318,14 @@ private fun EventBody( fontWeight = FontWeight.Bold, ) } - formatCalendarRange(note)?.let { range -> + val context = LocalContext.current + formatCalendarRange(note, context)?.let { range -> Text( text = range, style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.primary, ) } - val context = LocalContext.current val relative = remember(note.idHex, view.startSeconds) { relativeTimeLabel(context, view, TimeUtils.now()) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/create/CreateNestSheet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/create/CreateNestSheet.kt index 35df6bc622..532d72d777 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/create/CreateNestSheet.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/create/CreateNestSheet.kt @@ -63,6 +63,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.activity.NestBri import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.placeholderText import kotlinx.coroutines.launch +import android.text.format.DateFormat as AndroidDateFormat /** * Bottom sheet that lets the logged-in user start a new NIP-53 kind 30312 @@ -248,6 +249,8 @@ private fun ScheduleStartPicker( var showDate by remember { mutableStateOf(false) } var showTime by remember { mutableStateOf(false) } + val context = LocalContext.current + val pretty = if (unixSeconds <= 0L) { stringRes(R.string.nest_create_when) @@ -277,7 +280,7 @@ private fun ScheduleStartPicker( androidx.compose.material3.rememberTimePickerState( initialHour = initialLocal.hour, initialMinute = initialLocal.minute, - is24Hour = false, + is24Hour = AndroidDateFormat.is24HourFormat(context), ) // On dismiss (Cancel or back-press), restore the picker states diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/eventsync/EventSyncScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/eventsync/EventSyncScreen.kt index a83dea75f5..6971c82f94 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/eventsync/EventSyncScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/eventsync/EventSyncScreen.kt @@ -69,15 +69,13 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton +import com.vitorpamplona.amethyst.ui.note.formatMediumDate import com.vitorpamplona.amethyst.ui.note.timeAgoNoDotNoDay import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl -import java.text.SimpleDateFormat -import java.util.Date -import java.util.Locale @Composable fun EventSyncScreen( @@ -750,6 +748,8 @@ private fun DateRangeFilterCard( LaunchedEffect(effectiveSince) { onSinceChanged(effectiveSince) } LaunchedEffect(effectiveUntil) { onUntilChanged(effectiveUntil) } + val context = LocalContext.current + Card( modifier = Modifier.fillMaxWidth(), @@ -775,7 +775,7 @@ private fun DateRangeFilterCard( if (effectiveSince == null) { stringRes(R.string.event_sync_date_filter_all_time) } else { - stringRes(R.string.event_sync_date_filter_since) + " " + formatEpochDate(sinceEpoch) + stringRes(R.string.event_sync_date_filter_since) + " " + formatMediumDate(sinceEpoch, context) }, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.primary, @@ -785,7 +785,7 @@ private fun DateRangeFilterCard( if (effectiveUntil == null) { stringRes(R.string.event_sync_date_filter_now) } else { - stringRes(R.string.event_sync_date_filter_until) + " " + formatEpochDate(untilEpoch) + stringRes(R.string.event_sync_date_filter_until) + " " + formatMediumDate(untilEpoch, context) }, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.primary, @@ -810,11 +810,6 @@ private fun DateRangeFilterCard( } } -private fun formatEpochDate(epochSecs: Long): String { - val sdf = SimpleDateFormat("MMM d, yyyy", Locale.getDefault()) - return sdf.format(Date(epochSecs * 1000)) -} - // ------------------------------------------------------------------------- // Helpers // ------------------------------------------------------------------------- diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/vanish/RequestToVanishScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/vanish/RequestToVanishScreen.kt index c63a49f64c..7adc705e40 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/vanish/RequestToVanishScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/vanish/RequestToVanishScreen.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.vanish +import android.text.format.DateFormat import androidx.compose.foundation.border import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -58,6 +59,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview @@ -72,6 +74,7 @@ import com.vitorpamplona.amethyst.ui.components.TitleExplainer import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton +import com.vitorpamplona.amethyst.ui.note.formatMediumDateTime import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoDialog @@ -85,12 +88,9 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.collections.immutable.toImmutableList -import java.text.SimpleDateFormat import java.time.Instant import java.time.ZoneId import java.time.ZoneOffset -import java.util.Date -import java.util.Locale @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -131,11 +131,12 @@ fun RequestToVanishScreen( val currentTime = Instant.ofEpochMilli(vanishDate * 1000).atZone(ZoneId.systemDefault()).toLocalDateTime() + val context = LocalContext.current val timePickerState = rememberTimePickerState( initialHour = currentTime.hour, initialMinute = currentTime.minute, - is24Hour = false, + is24Hour = DateFormat.is24HourFormat(context), ) val relayOptions = @@ -288,7 +289,7 @@ fun RequestToVanishScreen( ) Spacer(Modifier.width(12.dp)) Text( - text = formatTimestamp(vanishDate), + text = formatMediumDateTime(vanishDate, context), style = MaterialTheme.typography.bodyLarge, ) } @@ -461,11 +462,6 @@ private fun ConfirmVanishDialog( ) } -private fun formatTimestamp(epochSeconds: Long): String { - val sdf = SimpleDateFormat("MMM dd, yyyy hh:mm a", Locale.getDefault()) - return sdf.format(Date(epochSeconds * 1000)) -} - @Preview @Composable fun RequestToVanishScreenPreview() { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/vanish/VanishEventsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/vanish/VanishEventsScreen.kt index 69f9549c7d..6909ecb491 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/vanish/VanishEventsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/vanish/VanishEventsScreen.kt @@ -47,6 +47,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -58,15 +59,13 @@ import com.vitorpamplona.amethyst.model.nip62Vanish.ComplianceStatus import com.vitorpamplona.amethyst.model.nip62Vanish.VanishEventItem import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton +import com.vitorpamplona.amethyst.ui.note.formatMediumDateTime import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl import kotlinx.coroutines.launch -import java.text.SimpleDateFormat -import java.util.Date -import java.util.Locale @Composable fun VanishEventsScreen( @@ -170,6 +169,7 @@ private fun VanishEventCard( containerColor = MaterialTheme.colorScheme.surfaceContainerLow, ), ) { + val context = LocalContext.current Column(modifier = Modifier.padding(16.dp)) { Row( modifier = Modifier.fillMaxWidth(), @@ -182,7 +182,7 @@ private fun VanishEventCard( color = MaterialTheme.colorScheme.onSurfaceVariant, ) Text( - text = formatTimestamp(item.event.createdAt), + text = formatMediumDateTime(item.event.createdAt, context), style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold, ) @@ -355,8 +355,3 @@ private fun RelayComplianceRow( } } } - -private fun formatTimestamp(epochSeconds: Long): String { - val sdf = SimpleDateFormat("MMM dd, yyyy hh:mm a", Locale.getDefault()) - return sdf.format(Date(epochSeconds * 1000)) -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NamecoinSettingsSection.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NamecoinSettingsSection.kt index cf49f27d32..0d3f1d9f27 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NamecoinSettingsSection.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NamecoinSettingsSection.kt @@ -60,6 +60,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontFamily @@ -72,6 +73,7 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.model.nip05DnsIdentifiers.namecoin.NamecoinSettings +import com.vitorpamplona.amethyst.ui.note.formatMediumDateTime import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.DEFAULT_ELECTRUMX_SERVERS import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.ElectrumxServer import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinBackend @@ -79,8 +81,6 @@ import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinCoreRpcConf import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.RpcProbeResult import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.ServerTestResult import kotlinx.coroutines.launch -import java.text.SimpleDateFormat -import java.util.Date import java.util.Locale /** @@ -497,10 +497,10 @@ private fun DiagnosticCard( // Last test timestamp if (lastTestTimestamp != null) { + val context = LocalContext.current val formatted = - remember(lastTestTimestamp) { - SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()) - .format(Date(lastTestTimestamp)) + remember(lastTestTimestamp, context) { + formatMediumDateTime(lastTestTimestamp / 1000L, context) } val successCount = testResults.count { it.success } val totalCount = testResults.size diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/OnchainTransactionsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/OnchainTransactionsScreen.kt index 72181d1936..59f45d0802 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/OnchainTransactionsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/OnchainTransactionsScreen.kt @@ -51,6 +51,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.platform.UriHandler import androidx.compose.ui.text.font.FontFamily @@ -65,15 +66,13 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.note.UserPicture import com.vitorpamplona.amethyst.ui.note.UsernameDisplay +import com.vitorpamplona.amethyst.ui.note.formatMonthDayTime import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.datasource.OnchainZapsFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.bitcoinColor import java.text.NumberFormat -import java.text.SimpleDateFormat -import java.util.Date -import java.util.Locale import kotlin.math.absoluteValue @OptIn(ExperimentalMaterial3Api::class) @@ -299,14 +298,11 @@ private fun OnchainTransactionItem( (if (isIncoming) "+" else "-") + fmt.format(amountSats) } + val context = LocalContext.current val dateText = - remember(view.tx.blockTime, view.tx.confirmations) { + remember(view.tx.blockTime, view.tx.confirmations, context) { val ts = view.tx.blockTime - if (ts != null) { - SimpleDateFormat("MMM d, HH:mm", Locale.getDefault()).format(Date(ts * 1000L)) - } else { - "" - } + if (ts != null) formatMonthDayTime(ts, context) else "" } val counterpartyPubkeyHex = view.counterpartyPubkeyHex() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletTransactionsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletTransactionsScreen.kt index d662e3c6d4..4e0b8be67d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletTransactionsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletTransactionsScreen.kt @@ -50,6 +50,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -60,15 +61,13 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.UserPicture import com.vitorpamplona.amethyst.ui.note.UsernameDisplay +import com.vitorpamplona.amethyst.ui.note.formatMonthDayTime import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcTransaction import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcTransactionType import java.text.NumberFormat -import java.text.SimpleDateFormat -import java.util.Date -import java.util.Locale @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -238,12 +237,10 @@ private fun TransactionItem( (if (isIncoming) "+" else "-") + fmt.format(amountSats) } + val context = LocalContext.current val dateText = - remember(tx.created_at) { - tx.created_at?.let { - val sdf = SimpleDateFormat("MMM d, HH:mm", Locale.getDefault()) - sdf.format(Date(it * 1000L)) - } ?: "" + remember(tx.created_at, context) { + tx.created_at?.let { formatMonthDayTime(it, context) } ?: "" } val parsed = remember(tx.metadata) { tx.parsedMetadata() } diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/TimeAgoFormatter.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/TimeAgoFormatter.kt index 3234edb759..43f6d5270c 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/TimeAgoFormatter.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/TimeAgoFormatter.kt @@ -27,23 +27,39 @@ import java.util.Calendar import java.util.Date import java.util.Locale -private const val YEAR_DATE_FORMAT = "MMM dd, yyyy" -private const val MONTH_DATE_FORMAT = "MMM dd" +// Month + day without year — month name is textual so order is unambiguous across locales. +private const val MONTH_DATE_FORMAT = "MMM d" -private var locale = Locale.getDefault() -private var yearFormatter = SimpleDateFormat(YEAR_DATE_FORMAT, locale) -private var monthFormatter = SimpleDateFormat(MONTH_DATE_FORMAT, locale) -private var timeOnlyFormatter = DateFormat.getTimeInstance(DateFormat.SHORT, locale) +/** + * Per-thread cached [DateFormat] keyed off the current default [Locale]. + * + * `DateFormat`/`SimpleDateFormat` are mutable and not thread-safe. These + * formatters can be read from UI threads (composition) and from background + * coroutines. `ThreadLocal` gives each thread its own instance — no locks, + * no allocation per call, and we rebuild lazily on locale change. + */ +private class LocaleAwareFormatter( + private val build: (Locale) -> DateFormat, +) { + private val cache = ThreadLocal>() -private fun updateFormattersIfNeeded() { - if (locale != Locale.getDefault()) { - locale = Locale.getDefault() - yearFormatter = SimpleDateFormat(YEAR_DATE_FORMAT, locale) - monthFormatter = SimpleDateFormat(MONTH_DATE_FORMAT, locale) - timeOnlyFormatter = DateFormat.getTimeInstance(DateFormat.SHORT, locale) + fun get(): DateFormat { + val current = Locale.getDefault() + val cached = cache.get() + if (cached != null && cached.first == current) return cached.second + val fresh = build(current) + cache.set(current to fresh) + return fresh } } +// Locale-aware: en-US "May 28, 2026" · en-GB "28 May 2026" · de-DE "28.05.2026" · ja-JP "2026/05/28" +private val yearFormatter = LocaleAwareFormatter { DateFormat.getDateInstance(DateFormat.MEDIUM, it) } +private val monthFormatter = LocaleAwareFormatter { SimpleDateFormat(MONTH_DATE_FORMAT, it) } + +// Locale-aware: en-US "2:32 PM" · en-GB "14:32" · de-DE "14:32" +private val timeOnlyFormatter = LocaleAwareFormatter { DateFormat.getTimeInstance(DateFormat.SHORT, it) } + /** * Formats a Unix timestamp (seconds) as a human-readable time ago string. * Returns strings like " • 5m", " • 2h", " • Dec 12" @@ -64,13 +80,11 @@ fun timeAgo( return when { timeDifference > TimeUtils.ONE_YEAR -> { - updateFormattersIfNeeded() - prefix + yearFormatter.format(time * 1000) + prefix + yearFormatter.get().format(time * 1000) } timeDifference > TimeUtils.ONE_MONTH -> { - updateFormattersIfNeeded() - prefix + monthFormatter.format(time * 1000) + prefix + monthFormatter.get().format(time * 1000) } timeDifference > TimeUtils.ONE_DAY -> { @@ -131,13 +145,11 @@ fun dateFormatter( return when { timeDifference > TimeUtils.ONE_YEAR -> { - updateFormattersIfNeeded() - yearFormatter.format(time * 1000) + yearFormatter.get().format(time * 1000) } timeDifference > TimeUtils.ONE_DAY -> { - updateFormattersIfNeeded() - monthFormatter.format(time * 1000) + monthFormatter.get().format(time * 1000) } else -> { @@ -154,9 +166,9 @@ fun Long.toTimeAgo(withDot: Boolean = true): String = timeAgo(this, withDot) /** * Formats a Unix timestamp (seconds) as an absolute date/time string. Granularity * depends on how far in the past the timestamp is: - * - same day → time only (e.g. "14:32"), locale-aware - * - same year → "MMM dd, HH:mm" - * - older → "MMM dd, yyyy" + * - same day → locale-aware short time (e.g. "14:32" / "2:32 PM") + * - same year → month + day + short time (e.g. "May 28, 2:32 PM") + * - older → locale-aware medium date (e.g. "May 28, 2026" / "28 May 2026" / "28.05.2026") */ fun timeAbsolute( time: Long?, @@ -167,8 +179,6 @@ fun timeAbsolute( val prefix = if (withDot) " • " else "" if (time == 0L) return prefix + never - updateFormattersIfNeeded() - val timeMs = time * 1000 val now = Calendar.getInstance() val then = Calendar.getInstance().apply { timeInMillis = timeMs } @@ -176,12 +186,12 @@ fun timeAbsolute( val sameYear = now.get(Calendar.YEAR) == then.get(Calendar.YEAR) val sameDay = sameYear && now.get(Calendar.DAY_OF_YEAR) == then.get(Calendar.DAY_OF_YEAR) - val timeOfDay = timeOnlyFormatter.format(Date(timeMs)) + val timeOfDay = timeOnlyFormatter.get().format(Date(timeMs)) return when { sameDay -> prefix + timeOfDay - sameYear -> prefix + monthFormatter.format(timeMs) + ", " + timeOfDay - else -> prefix + yearFormatter.format(timeMs) + sameYear -> prefix + monthFormatter.get().format(timeMs) + ", " + timeOfDay + else -> prefix + yearFormatter.get().format(timeMs) } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ArticleReaderScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ArticleReaderScreen.kt index 2b2249b92d..32e0479e63 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ArticleReaderScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ArticleReaderScreen.kt @@ -97,12 +97,12 @@ import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import kotlinx.coroutines.launch -import java.time.Instant -import java.time.ZoneId -import java.time.format.DateTimeFormatter +import java.text.DateFormat +import java.util.Date import java.util.Locale -private val articleDateFormat = DateTimeFormatter.ofPattern("MMM d, yyyy", Locale.getDefault()) +// Locale-aware: en-US "May 28, 2026" · en-GB "28 May 2026" · de-DE "28.05.2026" · ja-JP "2026/05/28" +private val articleDateFormat = DateFormat.getDateInstance(DateFormat.MEDIUM, Locale.getDefault()) /** * Parses a NIP-23 address tag in the format "30023:pubkey:d-tag". @@ -352,11 +352,7 @@ fun ArticleReaderScreen( val publishedAt = article?.let { art -> val ts = art.publishedAt() ?: art.createdAt - Instant - .ofEpochSecond(ts) - .atZone(ZoneId.systemDefault()) - .toLocalDate() - .format(articleDateFormat) + articleDateFormat.format(Date(ts * 1000L)) } // Author info from local cache diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadsScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadsScreen.kt index 2d5d569a22..96a44b0cee 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadsScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadsScreen.kt @@ -69,19 +69,14 @@ import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect -import java.time.Instant -import java.time.ZoneId -import java.time.format.DateTimeFormatter +import java.text.DateFormat +import java.util.Date import java.util.Locale -private val dateFormat = DateTimeFormatter.ofPattern("MMM d, yyyy", Locale.getDefault()) +// Locale-aware: en-US "May 28, 2026" · en-GB "28 May 2026" · de-DE "28.05.2026" · ja-JP "2026/05/28" +private val dateFormat = DateFormat.getDateInstance(DateFormat.MEDIUM, Locale.getDefault()) -private fun formatDate(timestamp: Long): String = - Instant - .ofEpochSecond(timestamp) - .atZone(ZoneId.systemDefault()) - .toLocalDate() - .format(dateFormat) +private fun formatDate(timestamp: Long): String = dateFormat.format(Date(timestamp * 1000L)) /** * Card displaying long-form content (NIP-23) with title, summary, and image. diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/highlights/MyHighlightsScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/highlights/MyHighlightsScreen.kt index 7cae7fa2eb..ec538790d4 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/highlights/MyHighlightsScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/highlights/MyHighlightsScreen.kt @@ -59,9 +59,9 @@ import com.vitorpamplona.amethyst.commons.model.highlights.HighlightData import com.vitorpamplona.amethyst.commons.ui.components.EmptyState import com.vitorpamplona.amethyst.desktop.service.highlights.DesktopHighlightStore import kotlinx.coroutines.launch -import java.time.Instant -import java.time.ZoneId -import java.time.format.DateTimeFormatter +import java.text.DateFormat +import java.util.Date +import java.util.Locale @Composable fun MyHighlightsScreen( @@ -238,8 +238,8 @@ private fun HighlightCard( } } -private fun formatTimestamp(epochSeconds: Long): String = - Instant - .ofEpochSecond(epochSeconds) - .atZone(ZoneId.systemDefault()) - .format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")) +// Locale-aware date+time, e.g. en-US "May 28, 2026 2:32 PM" · de-DE "28.05.2026 14:32". +private val dateTimeFormat = + DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT, Locale.getDefault()) + +private fun formatTimestamp(epochSeconds: Long): String = dateTimeFormat.format(Date(epochSeconds * 1000L))