mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 11:18:24 +00:00
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:
+4
-5
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
+5
-3
@@ -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 -> "—"
|
||||
}
|
||||
|
||||
|
||||
+4
-3
@@ -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) }
|
||||
|
||||
+3
@@ -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
|
||||
|
||||
-146
@@ -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)
|
||||
}
|
||||
+3
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+5
-3
@@ -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,
|
||||
|
||||
+3
-2
@@ -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,
|
||||
)
|
||||
|
||||
+2
-2
@@ -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 },
|
||||
|
||||
+3
@@ -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
|
||||
}
|
||||
|
||||
+98
@@ -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)
|
||||
Reference in New Issue
Block a user