mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-11 08:47:33 +00:00
Merge pull request #3095 from vitorpamplona/claude/amazing-ptolemy-26Nek
Use locale-aware date/time formatting throughout the app
This commit is contained in:
@@ -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"
|
||||
}
|
||||
@@ -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"))
|
||||
|
||||
@@ -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<Pair<Locale, SimpleDateFormat>>()
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
+3
-3
@@ -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,
|
||||
|
||||
+3
-3
@@ -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(),
|
||||
|
||||
+3
-3
@@ -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(),
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
+3
-1
@@ -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 -> "—"
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -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 =
|
||||
|
||||
+61
-16
@@ -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<Pair<Locale, SimpleDateFormat>>()
|
||||
|
||||
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(
|
||||
|
||||
+5
-3
@@ -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() {
|
||||
|
||||
+2
-2
@@ -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())
|
||||
|
||||
+4
-1
@@ -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
|
||||
|
||||
+5
-10
@@ -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
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
+6
-10
@@ -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() {
|
||||
|
||||
+4
-9
@@ -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))
|
||||
}
|
||||
|
||||
+5
-5
@@ -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
|
||||
|
||||
+5
-9
@@ -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()
|
||||
|
||||
+5
-8
@@ -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() }
|
||||
|
||||
+38
-28
@@ -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<Pair<Locale, DateFormat>>()
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+5
-9
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
+8
-8
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user