mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +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.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.vitorpamplona.amethyst.commons.model.AddressableNote
|
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.CalendarRsvpCard
|
||||||
import com.vitorpamplona.amethyst.commons.ui.note.LoadAddressableNote
|
import com.vitorpamplona.amethyst.commons.ui.note.LoadAddressableNote
|
||||||
import com.vitorpamplona.amethyst.commons.ui.note.WatchNoteEvent
|
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.ui.theme.ThemeComparisonColumn
|
||||||
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
|
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
|
||||||
import com.vitorpamplona.amethyst.commons.viewmodels.mockAccountViewModel
|
import com.vitorpamplona.amethyst.commons.viewmodels.mockAccountViewModel
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.CalendarAppointmentLines
|
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.CalendarDateBadge
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.detailRouteFor
|
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.amethyst.ui.screen.loggedIn.calendars.rememberRelativeTimeLabel
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||||
@@ -133,10 +133,9 @@ private fun RsvpWithAppointment(
|
|||||||
CalendarRsvpCard(event)
|
CalendarRsvpCard(event)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
val timeFormatter = rememberTimeOfDayFormatter()
|
||||||
val context = LocalContext.current
|
|
||||||
val appointmentEvent = appointment.event
|
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 relative = rememberRelativeTimeLabel(view, appointmentEvent?.id)
|
||||||
val route = remember(appointment) { detailRouteFor(appointment) }
|
val route = remember(appointment) { detailRouteFor(appointment) }
|
||||||
// Past a week the relative formatter falls back to a plain date ("October 31"), which the
|
// 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.service.ai.AltTextSuggester
|
||||||
import com.vitorpamplona.amethyst.commons.tor.TorSettingsFlow
|
import com.vitorpamplona.amethyst.commons.tor.TorSettingsFlow
|
||||||
import com.vitorpamplona.amethyst.service.ai.MLKitImageLabelService
|
import com.vitorpamplona.amethyst.service.ai.MLKitImageLabelService
|
||||||
|
import com.vitorpamplona.amethyst.service.location.CachedReversedGeoLocations
|
||||||
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinNameResolver
|
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinNameResolver
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
@@ -67,6 +68,8 @@ object AndroidAppServices : AppServices {
|
|||||||
|
|
||||||
override val deviceLocation: DeviceLocation get() = Amethyst.instance.locationManager
|
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)
|
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.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
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.resources.calendar_nav_previous_day
|
||||||
import com.vitorpamplona.amethyst.commons.ui.layouts.rememberFeedContentPadding
|
import com.vitorpamplona.amethyst.commons.ui.layouts.rememberFeedContentPadding
|
||||||
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
|
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.stringRes
|
||||||
import com.vitorpamplona.amethyst.commons.ui.theme.FeedPadding
|
import com.vitorpamplona.amethyst.commons.ui.theme.FeedPadding
|
||||||
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
|
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
|
||||||
@@ -161,7 +163,7 @@ private fun DayRow(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val startSeconds = view.startSeconds
|
val startSeconds = view.startSeconds
|
||||||
val context = LocalContext.current
|
val timeFormatter = rememberTimeOfDayFormatter()
|
||||||
val timeLabel =
|
val timeLabel =
|
||||||
when {
|
when {
|
||||||
view.isAllDay -> stringRes(Res.string.calendar_all_day)
|
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
|
// misleading on day 2 since the event has been ongoing overnight. Show a
|
||||||
// continuation marker so the user reads it as "still happening".
|
// continuation marker so the user reads it as "still happening".
|
||||||
stringRes(Res.string.calendar_continues)
|
stringRes(Res.string.calendar_continues)
|
||||||
startSeconds != null -> formatTimeOfDay(startSeconds, context)
|
startSeconds != null -> formatTimeOfDay(startSeconds, timeFormatter)
|
||||||
else -> "—"
|
else -> "—"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+4
-3
@@ -40,7 +40,6 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.platform.LocalLocale
|
import androidx.compose.ui.platform.LocalLocale
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
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.model.nip52Calendar.appointmentView
|
||||||
import com.vitorpamplona.amethyst.commons.ui.components.MyAsyncImage
|
import com.vitorpamplona.amethyst.commons.ui.components.MyAsyncImage
|
||||||
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
|
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.ui.screen.loggedIn.video.UserCardHeader
|
||||||
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
|
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent
|
import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent
|
||||||
@@ -80,8 +81,8 @@ fun CalendarEventListCard(
|
|||||||
footer: (@Composable () -> Unit)? = null,
|
footer: (@Composable () -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
val view = note.appointmentView() ?: return
|
val view = note.appointmentView() ?: return
|
||||||
val context = LocalContext.current
|
val timeFormatter = rememberTimeOfDayFormatter()
|
||||||
val range = remember(note.idHex) { formatCalendarRange(note, context) }
|
val range = remember(note.idHex) { formatCalendarRange(note, timeFormatter) }
|
||||||
val relative = rememberRelativeTimeLabel(view, note.idHex)
|
val relative = rememberRelativeTimeLabel(view, note.idHex)
|
||||||
val event = note.event ?: return
|
val event = note.event ?: return
|
||||||
val detailRoute = remember(event.id) { detailRouteFor(note) }
|
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.resources.calendar_nav_previous_month
|
||||||
import com.vitorpamplona.amethyst.commons.ui.layouts.rememberFeedContentPadding
|
import com.vitorpamplona.amethyst.commons.ui.layouts.rememberFeedContentPadding
|
||||||
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
|
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.stringRes
|
||||||
import com.vitorpamplona.amethyst.commons.ui.theme.FeedPadding
|
import com.vitorpamplona.amethyst.commons.ui.theme.FeedPadding
|
||||||
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
|
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.resources.calendar_nav_previous_week
|
||||||
import com.vitorpamplona.amethyst.commons.ui.layouts.rememberFeedContentPadding
|
import com.vitorpamplona.amethyst.commons.ui.layouts.rememberFeedContentPadding
|
||||||
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
|
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.stringRes
|
||||||
import com.vitorpamplona.amethyst.commons.ui.theme.FeedPadding
|
import com.vitorpamplona.amethyst.commons.ui.theme.FeedPadding
|
||||||
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
|
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.insets.imePaddingSafe
|
||||||
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
|
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
|
||||||
import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.SavingTopBar
|
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.ui.stringRes
|
||||||
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
|
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.formatLongDate
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@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.LoadUser
|
||||||
import com.vitorpamplona.amethyst.commons.ui.note.UsernameDisplay
|
import com.vitorpamplona.amethyst.commons.ui.note.UsernameDisplay
|
||||||
import com.vitorpamplona.amethyst.commons.ui.note.platform.ReactionsRow
|
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.note.types.CalendarRsvpRow
|
||||||
import com.vitorpamplona.amethyst.commons.ui.pluralStringRes
|
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.stringRes
|
||||||
import com.vitorpamplona.amethyst.commons.ui.theme.Size30dp
|
import com.vitorpamplona.amethyst.commons.ui.theme.Size30dp
|
||||||
import com.vitorpamplona.amethyst.commons.ui.theme.Size35dp
|
import com.vitorpamplona.amethyst.commons.ui.theme.Size35dp
|
||||||
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
|
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.addToPhoneCalendar
|
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.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.rememberRelativeTimeLabel
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.shareIcs
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.shareIcs
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||||
@@ -347,7 +348,8 @@ private fun EventBody(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
formatCalendarRange(note, context)?.let { range ->
|
val timeFormatter = rememberTimeOfDayFormatter()
|
||||||
|
formatCalendarRange(note, timeFormatter)?.let { range ->
|
||||||
Text(
|
Text(
|
||||||
text = range,
|
text = range,
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
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.zapraiser.ZapRaiserRequest
|
||||||
import com.vitorpamplona.amethyst.commons.ui.note.creators.zapsplits.ForwardZapTo
|
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.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.screen.loggedIn.settings.SettingsRow
|
||||||
import com.vitorpamplona.amethyst.commons.ui.stringRes
|
import com.vitorpamplona.amethyst.commons.ui.stringRes
|
||||||
import com.vitorpamplona.amethyst.commons.ui.text.MentionPreservingInputTransformation
|
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.SuggestionListDefaultHeightPage
|
||||||
import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText
|
import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText
|
||||||
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
|
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.amethyst.ui.screen.loggedIn.home.ObserveInboxRelayListAndDisplayIfNotFound
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||||
import kotlinx.collections.immutable.persistentListOf
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
@@ -387,12 +387,13 @@ private fun MarkdownPostScreenBody(
|
|||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(Size10dp),
|
.padding(Size10dp),
|
||||||
) {
|
) {
|
||||||
RenderContentAsMarkdown(
|
LocalRichTextPlatform.current.Markdown(
|
||||||
content = postViewModel.message.text.toString(),
|
content = postViewModel.message.text.toString(),
|
||||||
tags = EmptyTagList,
|
tags = EmptyTagList,
|
||||||
canPreview = true,
|
canPreview = true,
|
||||||
quotesLeft = 1,
|
quotesLeft = 1,
|
||||||
backgroundColor = backgroundColor,
|
backgroundColor = backgroundColor,
|
||||||
|
callbackUri = null,
|
||||||
accountViewModel = accountViewModel,
|
accountViewModel = accountViewModel,
|
||||||
nav = nav,
|
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.StdTopPadding
|
||||||
import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText
|
import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText
|
||||||
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
|
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.amethyst.service.relayClient.searchCommand.TextSearchDataSourceSubscription
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
|
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
|
||||||
import kotlinx.collections.immutable.toImmutableList
|
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
|
// fields rather than search terms. The value stays the plain text, so a query can still
|
||||||
// be copied out and pasted back.
|
// be copied out and pasted back.
|
||||||
val fieldState = remember { SearchFieldState(searchBarViewModel.searchValue) }
|
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
|
// 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.
|
// 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] },
|
groupName = { groupNames[it] },
|
||||||
// The same reverse-geocode cache the feed spinner and thread view already read
|
// The same reverse-geocode cache the feed spinner and thread view already read
|
||||||
// through LoadCityName; null until it resolves, which leaves the geohash showing.
|
// 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,
|
groups = groupCandidates,
|
||||||
onPeopleQuery = { userSuggestions.processCurrentWord(it) },
|
onPeopleQuery = { userSuggestions.processCurrentWord(it) },
|
||||||
onGroupQuery = { partial -> groupQuery = partial },
|
onGroupQuery = { partial -> groupQuery = partial },
|
||||||
|
|||||||
+3
@@ -70,6 +70,9 @@ interface AppServices {
|
|||||||
/** The device's position as geohashes. */
|
/** The device's position as geohashes. */
|
||||||
val deviceLocation: DeviceLocation get() = DeviceLocation.None
|
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. */
|
/** A new on-device alt-text suggester for image uploads, or null where the platform has none. */
|
||||||
fun createAltTextSuggester(): AltTextSuggester? = null
|
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