diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/CalendarRsvpRender.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/CalendarRsvpRender.kt index 9acc19fe61..60428dea67 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/CalendarRsvpRender.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/CalendarRsvpRender.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/platform/AndroidAppServices.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/platform/AndroidAppServices.kt index 9b56a1e0a5..f6f1375a9e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/platform/AndroidAppServices.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/platform/AndroidAppServices.kt @@ -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) } 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 aa4f0a7981..03701e97df 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 @@ -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 -> "—" } 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 1d611b18a5..b08505e1bc 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 @@ -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) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarMonthView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarMonthView.kt index d07e649d06..dad7066cc0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarMonthView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarMonthView.kt @@ -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 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 deleted file mode 100644 index 7f026605ab..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarTimeFormat.kt +++ /dev/null @@ -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>() - - 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) -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarWeekView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarWeekView.kt index 4b504f1c73..6e048018da 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarWeekView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarWeekView.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/create/NewCalendarCollectionScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/create/NewCalendarCollectionScreen.kt index d98058b5f3..ac69a49acc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/create/NewCalendarCollectionScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/create/NewCalendarCollectionScreen.kt @@ -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 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 9915e54662..08e1939341 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 @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostScreen.kt index f28daa91bb..b3ede6e110 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostScreen.kt @@ -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, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchScreen.kt index 34f42ff0fa..f9f9250491 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchScreen.kt @@ -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 }, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/AppServices.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/AppServices.kt index 542bd89360..ec851b8f1e 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/AppServices.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/AppServices.kt @@ -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 } diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/calendars/CalendarTimeFormat.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/calendars/CalendarTimeFormat.kt new file mode 100644 index 0000000000..f8448b1788 --- /dev/null +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/calendars/CalendarTimeFormat.kt @@ -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)