refactor: shared calendar date formatting, markdown preview slot and cached place names

- CalendarTimeFormat moves to commonsUI on DateSkeletonFormatter, LocalClock
  and calendarYearAndDay; range and time-of-day formatting take the
  composable's rememberTimeOfDayFormatter instead of a Context.
- LongFormPostScreen previews through LocalRichTextPlatform.Markdown.
- AppServices.cachedPlaceName replaces SearchScreen's direct read of the
  reverse-geocoding cache.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S7FuNBSKiyVecARSoE4B9P
This commit is contained in:
Claude
2026-09-30 04:03:27 +00:00
parent 05bbe21f87
commit a6e6e8d290
13 changed files with 134 additions and 165 deletions
@@ -34,7 +34,6 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.model.AddressableNote
@@ -47,13 +46,14 @@ import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.note.CalendarRsvpCard
import com.vitorpamplona.amethyst.commons.ui.note.LoadAddressableNote
import com.vitorpamplona.amethyst.commons.ui.note.WatchNoteEvent
import com.vitorpamplona.amethyst.commons.ui.note.rememberTimeOfDayFormatter
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.calendars.formatCalendarRange
import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.commons.viewmodels.mockAccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.CalendarAppointmentLines
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.CalendarDateBadge
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.detailRouteFor
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.formatCalendarRange
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.rememberRelativeTimeLabel
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.Event
@@ -133,10 +133,9 @@ private fun RsvpWithAppointment(
CalendarRsvpCard(event)
return
}
val context = LocalContext.current
val timeFormatter = rememberTimeOfDayFormatter()
val appointmentEvent = appointment.event
val range = remember(appointmentEvent) { formatCalendarRange(appointment, context) }
val range = remember(appointmentEvent) { formatCalendarRange(appointment, timeFormatter) }
val relative = rememberRelativeTimeLabel(view, appointmentEvent?.id)
val route = remember(appointment) { detailRouteFor(appointment) }
// Past a week the relative formatter falls back to a plain date ("October 31"), which the
@@ -33,6 +33,7 @@ import com.vitorpamplona.amethyst.commons.service.BlossomServerFinder
import com.vitorpamplona.amethyst.commons.service.ai.AltTextSuggester
import com.vitorpamplona.amethyst.commons.tor.TorSettingsFlow
import com.vitorpamplona.amethyst.service.ai.MLKitImageLabelService
import com.vitorpamplona.amethyst.service.location.CachedReversedGeoLocations
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinNameResolver
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
@@ -67,6 +68,8 @@ object AndroidAppServices : AppServices {
override val deviceLocation: DeviceLocation get() = Amethyst.instance.locationManager
override fun cachedPlaceName(geohash: String): String? = CachedReversedGeoLocations.cached(geohash)
override fun createAltTextSuggester(): AltTextSuggester = MLKitImageLabelService(Amethyst.instance.appContext)
}
@@ -43,7 +43,6 @@ 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.TextOverflow
import androidx.compose.ui.unit.dp
@@ -64,6 +63,9 @@ import com.vitorpamplona.amethyst.commons.resources.calendar_nav_next_day
import com.vitorpamplona.amethyst.commons.resources.calendar_nav_previous_day
import com.vitorpamplona.amethyst.commons.ui.layouts.rememberFeedContentPadding
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.note.rememberTimeOfDayFormatter
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.calendars.formatLongDate
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.calendars.formatTimeOfDay
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.FeedPadding
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
@@ -161,7 +163,7 @@ private fun DayRow(
}
val startSeconds = view.startSeconds
val context = LocalContext.current
val timeFormatter = rememberTimeOfDayFormatter()
val timeLabel =
when {
view.isAllDay -> stringRes(Res.string.calendar_all_day)
@@ -170,7 +172,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(Res.string.calendar_continues)
startSeconds != null -> formatTimeOfDay(startSeconds, context)
startSeconds != null -> formatTimeOfDay(startSeconds, timeFormatter)
else -> "—"
}
@@ -40,7 +40,6 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLocale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
@@ -53,6 +52,8 @@ import com.vitorpamplona.amethyst.commons.model.nip52Calendar.CalendarAppointmen
import com.vitorpamplona.amethyst.commons.model.nip52Calendar.appointmentView
import com.vitorpamplona.amethyst.commons.ui.components.MyAsyncImage
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.note.rememberTimeOfDayFormatter
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.calendars.formatCalendarRange
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.video.UserCardHeader
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent
@@ -80,8 +81,8 @@ fun CalendarEventListCard(
footer: (@Composable () -> Unit)? = null,
) {
val view = note.appointmentView() ?: return
val context = LocalContext.current
val range = remember(note.idHex) { formatCalendarRange(note, context) }
val timeFormatter = rememberTimeOfDayFormatter()
val range = remember(note.idHex) { formatCalendarRange(note, timeFormatter) }
val relative = rememberRelativeTimeLabel(view, note.idHex)
val event = note.event ?: return
val detailRoute = remember(event.id) { detailRouteFor(note) }
@@ -61,6 +61,9 @@ import com.vitorpamplona.amethyst.commons.resources.calendar_nav_next_month
import com.vitorpamplona.amethyst.commons.resources.calendar_nav_previous_month
import com.vitorpamplona.amethyst.commons.ui.layouts.rememberFeedContentPadding
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.calendars.formatLongDate
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.calendars.formatMonthYear
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.calendars.formatShortWeekday
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.FeedPadding
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
@@ -1,146 +0,0 @@
/*
* 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.screen.loggedIn.calendars
import android.content.Context
import android.text.format.DateFormat
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.nip52Calendar.appointmentView
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Date
import java.util.Locale
// 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
/**
* 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, 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 = "${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 – ${time.format(Date(endMs))}"
} else {
"$startStr – ${dayMonth.format(Date(endMs))} · ${time.format(Date(endMs))}"
}
}
private fun formatDateRange(
start: Long,
end: Long?,
): String {
val dayMonth = dayMonthFormat.get()
val startStr = dayMonth.format(Date(start * 1000))
if (end == null || end == start) return startStr
return "$startStr – ${dayMonth.format(Date(end * 1000))}"
}
fun formatLongDate(unixSeconds: Long): String = fullDateFormat.get().format(Date(unixSeconds * 1000))
fun formatMonthYear(
year: Int,
monthZeroBased: Int,
): String {
val cal = Calendar.getInstance()
cal.clear()
cal.set(year, monthZeroBased, 1)
return monthYearFormat.get().format(cal.time)
}
fun formatTimeOfDay(
unixSeconds: Long,
context: Context,
): String = timeFormat(context).format(Date(unixSeconds * 1000))
fun formatShortWeekday(weekdayZeroBased: Int): String {
val cal = Calendar.getInstance()
cal.clear()
cal.firstDayOfWeek = Calendar.SUNDAY
cal.set(Calendar.DAY_OF_WEEK, Calendar.SUNDAY)
cal.add(Calendar.DAY_OF_YEAR, weekdayZeroBased)
return weekdayShortFormat.get().format(cal.time)
}
private fun isSameDay(
aMs: Long,
bMs: Long,
): Boolean {
val ca = Calendar.getInstance().apply { timeInMillis = aMs }
val cb = Calendar.getInstance().apply { timeInMillis = bMs }
return ca.get(Calendar.YEAR) == cb.get(Calendar.YEAR) &&
ca.get(Calendar.DAY_OF_YEAR) == cb.get(Calendar.DAY_OF_YEAR)
}
@@ -61,6 +61,9 @@ import com.vitorpamplona.amethyst.commons.resources.calendar_nav_next_week
import com.vitorpamplona.amethyst.commons.resources.calendar_nav_previous_week
import com.vitorpamplona.amethyst.commons.ui.layouts.rememberFeedContentPadding
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.calendars.formatLongDate
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.calendars.formatMonthYear
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.calendars.formatShortWeekday
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.FeedPadding
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
@@ -71,9 +71,9 @@ import com.vitorpamplona.amethyst.commons.resources.new_calendar_collection
import com.vitorpamplona.amethyst.commons.ui.insets.imePaddingSafe
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.SavingTopBar
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.calendars.formatLongDate
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.formatLongDate
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -97,16 +97,17 @@ import com.vitorpamplona.amethyst.commons.ui.note.ClickableUserPicture
import com.vitorpamplona.amethyst.commons.ui.note.LoadUser
import com.vitorpamplona.amethyst.commons.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.commons.ui.note.platform.ReactionsRow
import com.vitorpamplona.amethyst.commons.ui.note.rememberTimeOfDayFormatter
import com.vitorpamplona.amethyst.commons.ui.note.types.CalendarRsvpRow
import com.vitorpamplona.amethyst.commons.ui.pluralStringRes
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.calendars.formatCalendarRange
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.calendars.formatLongDate
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.Size30dp
import com.vitorpamplona.amethyst.commons.ui.theme.Size35dp
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.addToPhoneCalendar
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.datasource.CalendarsFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.formatCalendarRange
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.formatLongDate
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.rememberRelativeTimeLabel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.shareIcs
import com.vitorpamplona.quartz.nip01Core.core.Address
@@ -347,7 +348,8 @@ private fun EventBody(
)
}
val context = LocalContext.current
formatCalendarRange(note, context)?.let { range ->
val timeFormatter = rememberTimeOfDayFormatter()
formatCalendarRange(note, timeFormatter)?.let { range ->
Text(
text = range,
style = MaterialTheme.typography.bodyLarge,
@@ -130,6 +130,7 @@ import com.vitorpamplona.amethyst.commons.ui.note.creators.zapraiser.AddZapraise
import com.vitorpamplona.amethyst.commons.ui.note.creators.zapraiser.ZapRaiserRequest
import com.vitorpamplona.amethyst.commons.ui.note.creators.zapsplits.ForwardZapTo
import com.vitorpamplona.amethyst.commons.ui.note.creators.zapsplits.ForwardZapToButton
import com.vitorpamplona.amethyst.commons.ui.richtext.LocalRichTextPlatform
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.settings.SettingsRow
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.text.MentionPreservingInputTransformation
@@ -139,7 +140,6 @@ import com.vitorpamplona.amethyst.commons.ui.theme.Size5dp
import com.vitorpamplona.amethyst.commons.ui.theme.SuggestionListDefaultHeightPage
import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.ui.components.markdown.RenderContentAsMarkdown
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.ObserveInboxRelayListAndDisplayIfNotFound
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import kotlinx.collections.immutable.persistentListOf
@@ -387,12 +387,13 @@ private fun MarkdownPostScreenBody(
.fillMaxWidth()
.padding(Size10dp),
) {
RenderContentAsMarkdown(
LocalRichTextPlatform.current.Markdown(
content = postViewModel.message.text.toString(),
tags = EmptyTagList,
canPreview = true,
quotesLeft = 1,
backgroundColor = backgroundColor,
callbackUri = null,
accountViewModel = accountViewModel,
nav = nav,
)
@@ -146,7 +146,6 @@ import com.vitorpamplona.amethyst.commons.ui.theme.Size5dp
import com.vitorpamplona.amethyst.commons.ui.theme.StdTopPadding
import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.service.location.CachedReversedGeoLocations
import com.vitorpamplona.amethyst.service.relayClient.searchCommand.TextSearchDataSourceSubscription
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
import kotlinx.collections.immutable.toImmutableList
@@ -584,6 +583,7 @@ private fun SearchTextField(
// fields rather than search terms. The value stays the plain text, so a query can still
// be copied out and pasted back.
val fieldState = remember { SearchFieldState(searchBarViewModel.searchValue) }
val appServices = LocalAppServices.current
// Reported up rather than read down: the scaffold that has to stop moving is composed
// above this field, and it is the only thing that can keep a picker's scroll off the bars.
@@ -650,7 +650,7 @@ private fun SearchTextField(
groupName = { groupNames[it] },
// The same reverse-geocode cache the feed spinner and thread view already read
// through LoadCityName; null until it resolves, which leaves the geohash showing.
scopeName = { field, value -> if (field == "geo") CachedReversedGeoLocations.cached(value) else null },
scopeName = { field, value -> if (field == "geo") appServices.cachedPlaceName(value) else null },
groups = groupCandidates,
onPeopleQuery = { userSuggestions.processCurrentWord(it) },
onGroupQuery = { partial -> groupQuery = partial },
@@ -70,6 +70,9 @@ interface AppServices {
/** The device's position as geohashes. */
val deviceLocation: DeviceLocation get() = DeviceLocation.None
/** The place name already reverse-geocoded for [geohash], or null when none is cached yet. */
fun cachedPlaceName(geohash: String): String? = null
/** A new on-device alt-text suggester for image uploads, or null where the platform has none. */
fun createAltTextSuggester(): AltTextSuggester? = null
}
@@ -0,0 +1,98 @@
/*
* 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.commons.ui.screen.loggedIn.calendars
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.nip52Calendar.appointmentView
import com.vitorpamplona.amethyst.commons.search.calendar.LocalClock
import com.vitorpamplona.amethyst.commons.search.calendar.SearchDate
import com.vitorpamplona.amethyst.commons.ui.note.DateSkeletonFormatter
import com.vitorpamplona.amethyst.commons.ui.note.calendarYearAndDay
// Skeletons follow Unicode LDML — the platform picks the locale's field order.
private val dayMonthFormat = DateSkeletonFormatter("EEEMMMd") // "Mon, May 28" / "Mon 28 May"
private val fullDateFormat = DateSkeletonFormatter("EEEEMMMMdy") // "Monday, May 28, 2026" / "Monday, 28 May 2026"
private val monthYearFormat = DateSkeletonFormatter("MMMMy") // "May 2026" / "Mai 2026"
private val weekdayShortFormat = DateSkeletonFormatter("EEE") // "Mon" — order doesn't matter
/**
* The appointment's date or time range. [formatTime] renders a time of day under the user's
* 12/24-hour setting; composables get it from `rememberTimeOfDayFormatter()`.
*/
fun formatCalendarRange(
note: Note,
formatTime: (epochMillis: Long) -> String,
): 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, formatTime)
}
}
private fun formatTimeRange(
start: Long,
end: Long?,
formatTime: (epochMillis: Long) -> String,
): String {
val startMs = start * 1000
val startStr = "${dayMonthFormat.format(startMs)} · ${formatTime(startMs)}"
if (end == null || end == start) return startStr
val endMs = end * 1000
return if (isSameDay(startMs, endMs)) {
"$startStr – ${formatTime(endMs)}"
} else {
"$startStr – ${dayMonthFormat.format(endMs)} · ${formatTime(endMs)}"
}
}
private fun formatDateRange(
start: Long,
end: Long?,
): String {
val startStr = dayMonthFormat.format(start * 1000)
if (end == null || end == start) return startStr
return "$startStr – ${dayMonthFormat.format(end * 1000)}"
}
fun formatLongDate(unixSeconds: Long): String = fullDateFormat.format(unixSeconds * 1000)
fun formatMonthYear(
year: Int,
monthZeroBased: Int,
): String = monthYearFormat.format(LocalClock.startOfDay(SearchDate(year, monthZeroBased + 1, 1)) * 1000)
fun formatTimeOfDay(
unixSeconds: Long,
formatTime: (epochMillis: Long) -> String,
): String = formatTime(unixSeconds * 1000)
/** The short name of a weekday, 0 = Sunday. */
fun formatShortWeekday(weekdayZeroBased: Int): String =
// 2026-01-04 was a Sunday; any known Sunday works.
weekdayShortFormat.format(LocalClock.startOfDay(SearchDate(2026, 1, 4 + weekdayZeroBased)) * 1000)
private fun isSameDay(
aMs: Long,
bMs: Long,
): Boolean = calendarYearAndDay(aMs) == calendarYearAndDay(bMs)