mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-12 01:07:46 +00:00
feat(calendars): reactions/zaps row, multi-day events, swipe nav, prefetch
- ReactionsRow on the detail screen so zap/like/repost/reply use the same shared component every other note surface uses (no calendar-specific reinvention) - Multi-day events now appear on every day they cover in month/week/day grids (groupByDayKeyExpanded), with "Day X of Y" / "Continues" markers on continuation rows - Horizontal swipe gestures on month/week/day views via a shared calendarSwipeNavigation modifier - Deep-link prefetch: notification taps on a calendar naddr route directly to CalendarEventDetail (skipping EventRedirect) and the detail screen issues a per-event relay subscription via observeNote - "Happening now · ends in X" relative-time label for ongoing events - Richer empty-state copy with a shared CalendarEmptyState composable (feed / day / week / collections each get a title + actionable hint) - Cleanup: hoist inline fully-qualified imports across the detail screen
This commit is contained in:
@@ -49,6 +49,8 @@ import com.vitorpamplona.quartz.nip19Bech32.entities.NNote
|
||||
import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile
|
||||
import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
|
||||
import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent
|
||||
import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import com.vitorpamplona.quartz.utils.UriParser
|
||||
import kotlinx.coroutines.CancellationException
|
||||
@@ -192,7 +194,7 @@ fun uriToRoute(
|
||||
routeFor(
|
||||
note = LocalCache.getOrCreateAddressableNote(nip19.address()),
|
||||
loggedIn = account,
|
||||
) ?: Route.EventRedirect(nip19.aTag())
|
||||
) ?: calendarDirectRoute(nip19) ?: Route.EventRedirect(nip19.aTag())
|
||||
}
|
||||
|
||||
is NEmbed -> {
|
||||
@@ -254,3 +256,19 @@ fun uriToRoute(
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Direct route for an `naddr` whose event hasn't arrived in [LocalCache] yet. When a notification
|
||||
* is tapped (or a `nostr:naddr…` deep link arrives) for a calendar appointment we know is kind
|
||||
* 31922/31923, route straight to the dedicated detail screen instead of bouncing through
|
||||
* [Route.EventRedirect]. The detail screen issues its own per-event subscription, so the user
|
||||
* sees the calendar-specific loading placeholder while the event arrives, not the generic
|
||||
* redirect screen.
|
||||
*/
|
||||
private fun calendarDirectRoute(nip19: NAddress): Route? =
|
||||
when (nip19.kind) {
|
||||
CalendarTimeSlotEvent.KIND,
|
||||
CalendarDateSlotEvent.KIND,
|
||||
-> Route.CalendarEventDetail(nip19.kind, nip19.author, nip19.dTag)
|
||||
else -> null
|
||||
}
|
||||
|
||||
+4
-10
@@ -102,16 +102,10 @@ private fun CollectionsBody(
|
||||
|
||||
@Composable
|
||||
private fun EmptyCollections() {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize().padding(32.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = stringRes(R.string.calendar_empty_collections),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
CalendarEmptyState(
|
||||
title = stringRes(R.string.calendar_empty_collections_title),
|
||||
subtitle = stringRes(R.string.calendar_empty_collections_subtitle),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
+48
-15
@@ -58,7 +58,8 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.dal.appointmentView
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.dal.groupByDayKey
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.dal.calendarLocalDayKeyRange
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.dal.groupByDayKeyExpanded
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import java.time.LocalDate
|
||||
import java.time.ZoneId
|
||||
@@ -87,10 +88,19 @@ fun CalendarDayView(
|
||||
var visibleEpochDay by rememberSaveable { mutableStateOf(today.toEpochDay()) }
|
||||
val visibleDate = LocalDate.ofEpochDay(visibleEpochDay)
|
||||
|
||||
val byDay by remember(notes) { derivedStateOf { groupByDayKey(notes) } }
|
||||
val byDay by remember(notes) { derivedStateOf { groupByDayKeyExpanded(notes) } }
|
||||
val dayEvents = byDay[visibleDate.toEpochDay()].orEmpty()
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.calendarSwipeNavigation(
|
||||
key = visibleEpochDay,
|
||||
onSwipeLeft = { visibleEpochDay = visibleDate.plusDays(1).toEpochDay() },
|
||||
onSwipeRight = { visibleEpochDay = visibleDate.minusDays(1).toEpochDay() },
|
||||
),
|
||||
) {
|
||||
CalendarNavigationHeader(
|
||||
title = formatLongDate(visibleDate.atStartOfDay(ZoneId.systemDefault()).toEpochSecond()),
|
||||
prevContentDescription = stringRes(R.string.calendar_nav_previous_day),
|
||||
@@ -101,26 +111,21 @@ fun CalendarDayView(
|
||||
)
|
||||
|
||||
if (dayEvents.isEmpty()) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize().padding(32.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = stringRes(R.string.calendar_no_events_today),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
CalendarEmptyState(
|
||||
title = stringRes(R.string.calendar_empty_day_title),
|
||||
subtitle = stringRes(R.string.calendar_empty_day_subtitle),
|
||||
)
|
||||
return@Column
|
||||
}
|
||||
|
||||
DayTimeline(dayEvents, nav)
|
||||
DayTimeline(dayEvents, visibleEpochDay, nav)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DayTimeline(
|
||||
dayEvents: List<Note>,
|
||||
visibleEpochDay: Long,
|
||||
nav: INav,
|
||||
) {
|
||||
val sorted =
|
||||
@@ -131,7 +136,11 @@ private fun DayTimeline(
|
||||
|
||||
LazyColumn(modifier = Modifier.fillMaxSize().padding(horizontal = 12.dp)) {
|
||||
items(sorted, key = { it.idHex }) { note ->
|
||||
DayRow(note = note, onClick = { nav.nav(Route.Note(note.idHex)) })
|
||||
DayRow(
|
||||
note = note,
|
||||
visibleEpochDay = visibleEpochDay,
|
||||
onClick = { nav.nav(Route.Note(note.idHex)) },
|
||||
)
|
||||
HorizontalDivider()
|
||||
}
|
||||
}
|
||||
@@ -140,13 +149,29 @@ private fun DayTimeline(
|
||||
@Composable
|
||||
private fun DayRow(
|
||||
note: Note,
|
||||
visibleEpochDay: Long,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val view = note.appointmentView() ?: return
|
||||
val range = note.calendarLocalDayKeyRange()
|
||||
// Position within a multi-day event: today is "Day 2 of 4". Renders below the time label so
|
||||
// a continuation day on a 3-day conference reads as "9:00 AM / Day 2 of 3" rather than
|
||||
// looking like a fresh event.
|
||||
val dayOfTotal =
|
||||
if (range != null && range.last > range.first) {
|
||||
(visibleEpochDay - range.first + 1).toInt() to (range.last - range.first + 1).toInt()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
val timeLabel =
|
||||
when {
|
||||
view.isAllDay -> stringRes(R.string.calendar_all_day)
|
||||
view.startSeconds != null && visibleEpochDay > (range?.first ?: visibleEpochDay) ->
|
||||
// Continuation day of a multi-day timed event — the "9:00 AM" of day 1 is
|
||||
// misleading on day 2 since the event has been ongoing overnight. Show a
|
||||
// continuation marker so the user reads it as "still happening".
|
||||
stringRes(R.string.calendar_continues)
|
||||
view.startSeconds != null -> formatTimeOfDay(view.startSeconds)
|
||||
else -> "—"
|
||||
}
|
||||
@@ -186,6 +211,14 @@ private fun DayRow(
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
dayOfTotal?.let { (day, total) ->
|
||||
Text(
|
||||
text = stringRes(R.string.calendar_day_of_total, day, total),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
}
|
||||
view.location?.let {
|
||||
Text(
|
||||
text = it,
|
||||
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
/*
|
||||
* 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 androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/**
|
||||
* Shared empty-state layout used across the calendar surfaces (feed, day, week, collections).
|
||||
* Title is the headline; subtitle gives the user one concrete next step ("tap + to create one").
|
||||
* Keeping all calendar empty states uniform avoids the previous one-line walls of text that
|
||||
* gave the user no guidance about what to do next.
|
||||
*/
|
||||
@Composable
|
||||
fun CalendarEmptyState(
|
||||
title: String,
|
||||
subtitle: String,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize().padding(32.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+4
-10
@@ -125,16 +125,10 @@ private fun SectionHeader(text: String) {
|
||||
|
||||
@Composable
|
||||
private fun CalendarFeedEmpty() {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize().padding(32.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = stringRes(R.string.calendar_empty_feed),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
CalendarEmptyState(
|
||||
title = stringRes(R.string.calendar_empty_feed_title),
|
||||
subtitle = stringRes(R.string.calendar_empty_feed_subtitle),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
+18
-3
@@ -59,7 +59,7 @@ import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.dal.groupByDayKey
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.dal.groupByDayKeyExpanded
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import java.time.LocalDate
|
||||
import java.time.YearMonth
|
||||
@@ -93,11 +93,26 @@ fun CalendarMonthView(
|
||||
visibleMonthValue = ym.monthValue
|
||||
}
|
||||
|
||||
val eventsByDay by remember(notes) { derivedStateOf { groupByDayKey(notes) } }
|
||||
val eventsByDay by remember(notes) { derivedStateOf { groupByDayKeyExpanded(notes) } }
|
||||
|
||||
var selectedDayKey by rememberSaveable { mutableStateOf<Long?>(null) }
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.calendarSwipeNavigation(
|
||||
key = visibleYear to visibleMonthValue,
|
||||
onSwipeLeft = {
|
||||
setVisibleMonth(visibleMonth.plusMonths(1))
|
||||
selectedDayKey = null
|
||||
},
|
||||
onSwipeRight = {
|
||||
setVisibleMonth(visibleMonth.minusMonths(1))
|
||||
selectedDayKey = null
|
||||
},
|
||||
),
|
||||
) {
|
||||
CalendarNavigationHeader(
|
||||
title = formatMonthYear(visibleMonth.year, visibleMonth.monthValue - 1),
|
||||
prevContentDescription = stringRes(R.string.calendar_nav_previous_month),
|
||||
|
||||
+17
-6
@@ -26,14 +26,18 @@ import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.dal.CalendarAppointmentView
|
||||
|
||||
/**
|
||||
* Localised "starts in 2 hours" / "started 5 minutes ago" / "ongoing" label for an appointment.
|
||||
* Returns null when the event has no parseable start (in which case there's nothing to anchor
|
||||
* a relative phrase to).
|
||||
* Localised "starts in 2 hours" / "started 5 minutes ago" / "Happening now · ends in 2 hours"
|
||||
* label for an appointment. Returns null when the event has no parseable start (nothing to
|
||||
* anchor a relative phrase to).
|
||||
*
|
||||
* Uses [DateUtils.getRelativeTimeSpanString] for the underlying minute/hour/day phrasing — that
|
||||
* helper is locale-aware and ages from "just now" through "in N days" to absolute date for
|
||||
* far-out events. For all-day events we extend the resolution to DAY so we get "tomorrow",
|
||||
* "in 3 days" instead of an hour-precision phrase that would lie about the start moment.
|
||||
*
|
||||
* Ongoing events (start ≤ now ≤ end) get a composite "Happening now · ends in X" so a user
|
||||
* mid-event sees how much time is left rather than the misleading "started X minutes ago" that
|
||||
* DateUtils would produce on its own.
|
||||
*/
|
||||
fun relativeTimeLabel(
|
||||
context: Context,
|
||||
@@ -43,10 +47,17 @@ fun relativeTimeLabel(
|
||||
val start = view.startSeconds ?: return null
|
||||
val end = view.endSeconds
|
||||
|
||||
// If the event is happening right now (start ≤ now ≤ end), prefer an explicit "ongoing"
|
||||
// label over the misleading "started X minutes ago" that DateUtils would produce.
|
||||
if (end != null && start <= nowSeconds && nowSeconds <= end) {
|
||||
return context.getString(R.string.calendar_relative_ongoing)
|
||||
val ongoing = context.getString(R.string.calendar_relative_ongoing)
|
||||
val endsIn =
|
||||
DateUtils
|
||||
.getRelativeTimeSpanString(
|
||||
end * 1000L,
|
||||
nowSeconds * 1000L,
|
||||
if (view.isAllDay) DateUtils.DAY_IN_MILLIS else DateUtils.MINUTE_IN_MILLIS,
|
||||
DateUtils.FORMAT_ABBREV_RELATIVE,
|
||||
).toString()
|
||||
return context.getString(R.string.calendar_relative_ongoing_with_end, ongoing, endsIn)
|
||||
}
|
||||
|
||||
val minResolution =
|
||||
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
/*
|
||||
* 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 androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
|
||||
/**
|
||||
* Swipe-to-navigate gesture for calendar surfaces. Horizontal drag past the threshold fires
|
||||
* [onSwipeLeft] (next period) or [onSwipeRight] (previous period). The threshold is in pixels
|
||||
* — we accumulate raw drag deltas because [detectHorizontalDragGestures]' final-velocity callback
|
||||
* isn't surfaced here; a positional threshold gives the user predictable, latch-style behaviour
|
||||
* comparable to the previous/next arrows in [CalendarNavigationHeader].
|
||||
*
|
||||
* The `key` lets a host that swaps state (week → next week, day → next day) restart the gesture
|
||||
* detector so a long sequence of partial drags doesn't accumulate across navigations.
|
||||
*/
|
||||
fun Modifier.calendarSwipeNavigation(
|
||||
key: Any?,
|
||||
onSwipeLeft: () -> Unit,
|
||||
onSwipeRight: () -> Unit,
|
||||
thresholdPx: Float = 120f,
|
||||
): Modifier =
|
||||
this.pointerInput(key) {
|
||||
var totalDrag = 0f
|
||||
detectHorizontalDragGestures(
|
||||
onDragStart = { totalDrag = 0f },
|
||||
onDragEnd = {
|
||||
if (totalDrag <= -thresholdPx) {
|
||||
onSwipeLeft()
|
||||
} else if (totalDrag >= thresholdPx) {
|
||||
onSwipeRight()
|
||||
}
|
||||
totalDrag = 0f
|
||||
},
|
||||
onDragCancel = { totalDrag = 0f },
|
||||
) { _, dragAmount ->
|
||||
totalDrag += dragAmount
|
||||
}
|
||||
}
|
||||
+22
-14
@@ -23,7 +23,6 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
@@ -55,7 +54,7 @@ import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.dal.groupByDayKey
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.dal.groupByDayKeyExpanded
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import java.time.LocalDate
|
||||
import java.time.ZoneId
|
||||
@@ -87,9 +86,24 @@ fun CalendarWeekView(
|
||||
|
||||
var selectedDayIndex by rememberSaveable { mutableStateOf(0) }
|
||||
|
||||
val eventsByDay by remember(notes) { derivedStateOf { groupByDayKey(notes) } }
|
||||
val eventsByDay by remember(notes) { derivedStateOf { groupByDayKeyExpanded(notes) } }
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.calendarSwipeNavigation(
|
||||
key = weekStartEpochDay,
|
||||
onSwipeLeft = {
|
||||
weekStartEpochDay = weekStart.plusWeeks(1).toEpochDay()
|
||||
selectedDayIndex = 0
|
||||
},
|
||||
onSwipeRight = {
|
||||
weekStartEpochDay = weekStart.minusWeeks(1).toEpochDay()
|
||||
selectedDayIndex = 0
|
||||
},
|
||||
),
|
||||
) {
|
||||
CalendarNavigationHeader(
|
||||
title = formatMonthYear(weekStart.year, weekStart.monthValue - 1),
|
||||
prevContentDescription = stringRes(R.string.calendar_nav_previous_week),
|
||||
@@ -124,16 +138,10 @@ fun CalendarWeekView(
|
||||
DaySummaryHeader(selectedDate)
|
||||
|
||||
if (dayNotes.isEmpty()) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize().padding(32.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = stringRes(R.string.calendar_no_events),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
CalendarEmptyState(
|
||||
title = stringRes(R.string.calendar_empty_week_title),
|
||||
subtitle = stringRes(R.string.calendar_empty_week_subtitle),
|
||||
)
|
||||
} else {
|
||||
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||
items(dayNotes, key = { it.idHex }) { note ->
|
||||
|
||||
+42
@@ -102,6 +102,48 @@ fun groupByDayKey(notes: List<Note>): Map<Long, List<Note>> {
|
||||
return map
|
||||
}
|
||||
|
||||
/**
|
||||
* Inclusive `[start, end]` range of day-keys an appointment covers. A single-day event yields
|
||||
* one key; a multi-day event yields every day from start through end. Returns null when the
|
||||
* note isn't a calendar appointment or has no parseable start.
|
||||
*
|
||||
* Capped at 366 days so a malformed event with a far-future end can't blow up month-view memory.
|
||||
*/
|
||||
fun Note.calendarLocalDayKeyRange(): LongRange? {
|
||||
val startKey = calendarLocalDayKey() ?: return null
|
||||
val endKey =
|
||||
when (val e = event) {
|
||||
is CalendarTimeSlotEvent ->
|
||||
e.end()?.let {
|
||||
Instant
|
||||
.ofEpochSecond(it)
|
||||
.atZone(ZoneId.systemDefault())
|
||||
.toLocalDate()
|
||||
.toEpochDay()
|
||||
} ?: startKey
|
||||
is CalendarDateSlotEvent -> parseIsoDate(e.end())?.toEpochDay() ?: startKey
|
||||
else -> startKey
|
||||
}
|
||||
val safeEnd = endKey.coerceAtLeast(startKey).coerceAtMost(startKey + 366)
|
||||
return startKey..safeEnd
|
||||
}
|
||||
|
||||
/**
|
||||
* Like [groupByDayKey] but a multi-day appointment lands in every day it covers (not just the
|
||||
* start day). Used by month/week/day views so a 3-day conference shows on all three rows; the
|
||||
* upcoming/past list view still uses [groupByDayKey] semantics via its own ordering.
|
||||
*/
|
||||
fun groupByDayKeyExpanded(notes: List<Note>): Map<Long, List<Note>> {
|
||||
val map = mutableMapOf<Long, MutableList<Note>>()
|
||||
notes.forEach { note ->
|
||||
val range = note.calendarLocalDayKeyRange() ?: return@forEach
|
||||
for (key in range) {
|
||||
map.getOrPut(key) { mutableListOf() }.add(note)
|
||||
}
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort by: upcoming events ascending (closest first), then past events descending (most-recent
|
||||
* first). [nowSeconds] is captured once per sort so the comparator stays transitive across the
|
||||
|
||||
+87
-83
@@ -20,6 +20,8 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.detail
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -43,39 +45,52 @@ import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
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.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote
|
||||
import com.vitorpamplona.amethyst.ui.components.MyAsyncImage
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture
|
||||
import com.vitorpamplona.amethyst.ui.note.ReactionsRow
|
||||
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
|
||||
import com.vitorpamplona.amethyst.ui.note.types.CalendarRsvpRow
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.dal.IcsExport
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.dal.appointmentView
|
||||
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.relativeTimeLabel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.shareIcs
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size30dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size35dp
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
|
||||
import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress
|
||||
import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent
|
||||
import com.vitorpamplona.quartz.nip52Calendar.appt.tags.RSVPStatusTag
|
||||
import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent
|
||||
import com.vitorpamplona.quartz.nip52Calendar.calendar.CalendarEvent
|
||||
import com.vitorpamplona.quartz.nip52Calendar.rsvp.CalendarRSVPEvent
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
|
||||
/**
|
||||
* Dedicated detail screen for a NIP-52 calendar appointment (kind 31922 or 31923). Renders the
|
||||
@@ -102,10 +117,11 @@ fun CalendarEventDetailScreen(
|
||||
|
||||
val targetAddress = remember(kind, pubKeyHex, dTag) { Address(kind, pubKeyHex, dTag) }
|
||||
val targetNote = remember(targetAddress) { LocalCache.getOrCreateAddressableNote(targetAddress) }
|
||||
val noteState by targetNote
|
||||
.flow()
|
||||
.metadata.stateFlow
|
||||
.collectAsStateWithLifecycle()
|
||||
// [observeNote] issues a per-event relay subscription on top of the LocalCache flow. This
|
||||
// is the prefetch path for deep links (notification tap, `nostr:naddr…` from another app):
|
||||
// landing on the screen without the event cached now triggers a targeted relay fetch instead
|
||||
// of waiting for the broader calendars feed to happen to include it.
|
||||
val noteState by observeNote(targetNote, accountViewModel)
|
||||
val event = noteState.note.event
|
||||
|
||||
val isOwnEvent = event?.pubKey == accountViewModel.userProfile().pubkeyHex
|
||||
@@ -130,7 +146,7 @@ fun CalendarEventDetailScreen(
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
val context = androidx.compose.ui.platform.LocalContext.current
|
||||
val context = LocalContext.current
|
||||
// Two share modes:
|
||||
// - .ics for non-nostr calendar apps (Google Calendar / iOS / Outlook)
|
||||
// - nostr:naddr… link for sharing inside the nostr ecosystem (in DMs,
|
||||
@@ -138,19 +154,9 @@ fun CalendarEventDetailScreen(
|
||||
// cleaner, but two icons keep both actions one tap away.
|
||||
if (event != null) {
|
||||
IconButton(onClick = {
|
||||
val ics =
|
||||
com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.dal.IcsExport
|
||||
.appointmentToIcs(
|
||||
event,
|
||||
targetAddress,
|
||||
com.vitorpamplona.quartz.utils.TimeUtils
|
||||
.now(),
|
||||
)
|
||||
val filename =
|
||||
com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.dal.IcsExport
|
||||
.appointmentFilename(event, targetAddress)
|
||||
com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars
|
||||
.shareIcs(context, filename, ics)
|
||||
val ics = IcsExport.appointmentToIcs(event, targetAddress, TimeUtils.now())
|
||||
val filename = IcsExport.appointmentFilename(event, targetAddress)
|
||||
shareIcs(context, filename, ics)
|
||||
}) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Share,
|
||||
@@ -162,22 +168,20 @@ fun CalendarEventDetailScreen(
|
||||
val shareTitle = stringRes(R.string.calendar_share_nostr_title)
|
||||
IconButton(onClick = {
|
||||
val naddr =
|
||||
com.vitorpamplona.quartz.nip19Bech32.entities.NAddress
|
||||
.create(
|
||||
targetAddress.kind,
|
||||
targetAddress.pubKeyHex,
|
||||
targetAddress.dTag,
|
||||
null,
|
||||
)
|
||||
NAddress.create(
|
||||
targetAddress.kind,
|
||||
targetAddress.pubKeyHex,
|
||||
targetAddress.dTag,
|
||||
null,
|
||||
)
|
||||
val intent =
|
||||
android.content
|
||||
.Intent(android.content.Intent.ACTION_SEND)
|
||||
Intent(Intent.ACTION_SEND)
|
||||
.setType("text/plain")
|
||||
.putExtra(android.content.Intent.EXTRA_TEXT, "nostr:$naddr")
|
||||
.putExtra(Intent.EXTRA_TEXT, "nostr:$naddr")
|
||||
context.startActivity(
|
||||
android.content.Intent
|
||||
Intent
|
||||
.createChooser(intent, shareTitle)
|
||||
.addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK),
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
|
||||
)
|
||||
}) {
|
||||
Icon(
|
||||
@@ -288,15 +292,10 @@ private fun EventBody(
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
val context = androidx.compose.ui.platform.LocalContext.current
|
||||
val context = LocalContext.current
|
||||
val relative =
|
||||
remember(note.idHex, view.startSeconds) {
|
||||
relativeTimeLabel(
|
||||
context,
|
||||
view,
|
||||
com.vitorpamplona.quartz.utils.TimeUtils
|
||||
.now(),
|
||||
)
|
||||
relativeTimeLabel(context, view, TimeUtils.now())
|
||||
}
|
||||
relative?.let {
|
||||
Text(
|
||||
@@ -315,6 +314,18 @@ private fun EventBody(
|
||||
}
|
||||
}
|
||||
|
||||
// Standard social actions (zap, reactions/likes, repost, reply count → thread/comments).
|
||||
// Uses the shared [ReactionsRow] so the affordances look and behave the same as every other
|
||||
// note-detail surface in the app — no calendar-specific reinvention.
|
||||
ReactionsRow(
|
||||
baseNote = note,
|
||||
showReactionDetail = true,
|
||||
addPadding = true,
|
||||
editState = null,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
CalendarRsvpRow(
|
||||
@@ -359,7 +370,7 @@ private fun HeroImage(
|
||||
|
||||
@Composable
|
||||
private fun LocationRow(location: String) {
|
||||
val context = androidx.compose.ui.platform.LocalContext.current
|
||||
val context = LocalContext.current
|
||||
// The whole row is the affordance — a single click target with a trailing chevron makes the
|
||||
// action discoverable without the dead-button look the previous nested TextButton produced.
|
||||
Row(
|
||||
@@ -373,9 +384,8 @@ private fun LocationRow(location: String) {
|
||||
// the ActivityNotFoundException — we don't have anywhere useful to fall
|
||||
// back to.
|
||||
context.startActivity(
|
||||
android.content
|
||||
.Intent(android.content.Intent.ACTION_VIEW, "geo:0,0?q=${android.net.Uri.encode(location)}".let(android.net.Uri::parse))
|
||||
.addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK),
|
||||
Intent(Intent.ACTION_VIEW, Uri.parse("geo:0,0?q=${Uri.encode(location)}"))
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
|
||||
)
|
||||
}
|
||||
}.padding(vertical = 4.dp),
|
||||
@@ -505,9 +515,9 @@ private fun InCalendarsSection(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
com.vitorpamplona.amethyst.ui.note.ClickableUserPicture(
|
||||
ClickableUserPicture(
|
||||
baseUserHex = calendar.pubKey,
|
||||
size = com.vitorpamplona.amethyst.ui.theme.Size30dp,
|
||||
size = Size30dp,
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
Text(
|
||||
@@ -534,45 +544,39 @@ private fun UserRow(
|
||||
nav: INav,
|
||||
trailing: (@Composable () -> Unit)?,
|
||||
) {
|
||||
com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms
|
||||
.LoadUser(baseUserHex = pubKey, accountViewModel = accountViewModel) { user ->
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
com.vitorpamplona.amethyst.ui.note.ClickableUserPicture(
|
||||
baseUserHex = pubKey,
|
||||
size = com.vitorpamplona.amethyst.ui.theme.Size35dp,
|
||||
LoadUser(baseUserHex = pubKey, accountViewModel = accountViewModel) { user ->
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
ClickableUserPicture(
|
||||
baseUserHex = pubKey,
|
||||
size = Size35dp,
|
||||
accountViewModel = accountViewModel,
|
||||
onClick = { nav.nav(Route.Profile(pubKey)) },
|
||||
)
|
||||
if (user != null) {
|
||||
UsernameDisplay(
|
||||
baseUser = user,
|
||||
weight = Modifier.weight(1f),
|
||||
accountViewModel = accountViewModel,
|
||||
onClick = {
|
||||
nav.nav(
|
||||
com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
.Profile(pubKey),
|
||||
)
|
||||
},
|
||||
)
|
||||
if (user != null) {
|
||||
com.vitorpamplona.amethyst.ui.note.UsernameDisplay(
|
||||
baseUser = user,
|
||||
weight = Modifier.weight(1f),
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
} else {
|
||||
// LoadUser is still resolving — show the npub-style fallback so the row
|
||||
// doesn't visibly collapse.
|
||||
Text(
|
||||
text = formatPubKeyShort(pubKey),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.weight(1f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
trailing?.invoke()
|
||||
} else {
|
||||
// LoadUser is still resolving — show the npub-style fallback so the row
|
||||
// doesn't visibly collapse.
|
||||
Text(
|
||||
text = formatPubKeyShort(pubKey),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.weight(1f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
trailing?.invoke()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -614,16 +618,16 @@ private fun formatPubKeyShort(pubKey: String): String = if (pubKey.length <= 16)
|
||||
* refresh. The scan is O(addressables) which is bounded by the relay subscription.
|
||||
*/
|
||||
@Composable
|
||||
private fun rememberRsvpsFor(targetAddress: Address): androidx.compose.runtime.State<List<CalendarRSVPEvent>> =
|
||||
androidx.compose.runtime.produceState(initialValue = findRsvpsFor(targetAddress), targetAddress) {
|
||||
private fun rememberRsvpsFor(targetAddress: Address): State<List<CalendarRSVPEvent>> =
|
||||
produceState(initialValue = findRsvpsFor(targetAddress), targetAddress) {
|
||||
LocalCache.live.newEventBundles.collect {
|
||||
value = findRsvpsFor(targetAddress)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun rememberCalendarsContaining(targetAddress: Address): androidx.compose.runtime.State<List<CalendarEvent>> =
|
||||
androidx.compose.runtime.produceState(initialValue = findCalendarsContaining(targetAddress), targetAddress) {
|
||||
private fun rememberCalendarsContaining(targetAddress: Address): State<List<CalendarEvent>> =
|
||||
produceState(initialValue = findCalendarsContaining(targetAddress), targetAddress) {
|
||||
LocalCache.live.newEventBundles.collect {
|
||||
value = findCalendarsContaining(targetAddress)
|
||||
}
|
||||
|
||||
@@ -1937,6 +1937,14 @@
|
||||
<string name="calendar_section_past">Past</string>
|
||||
<string name="calendar_empty_feed">No upcoming or past calendar events from your selected feed yet.</string>
|
||||
<string name="calendar_empty_collections">No calendar collections yet.</string>
|
||||
<string name="calendar_empty_feed_title">Your calendar is empty</string>
|
||||
<string name="calendar_empty_feed_subtitle">Events shared by people you follow appear here. Tap the + button to create your own.</string>
|
||||
<string name="calendar_empty_collections_title">No collections yet</string>
|
||||
<string name="calendar_empty_collections_subtitle">Group events together — a meetup series, a conference track, your team\'s roadmap. Tap + to create one.</string>
|
||||
<string name="calendar_empty_day_title">Nothing scheduled</string>
|
||||
<string name="calendar_empty_day_subtitle">No events on this day. Tap + to add one.</string>
|
||||
<string name="calendar_empty_week_title">Nothing this week</string>
|
||||
<string name="calendar_empty_week_subtitle">No events fall in this week.</string>
|
||||
|
||||
<string name="calendar_event_title">Title</string>
|
||||
<string name="calendar_event_summary">Summary</string>
|
||||
@@ -1958,6 +1966,8 @@
|
||||
<string name="calendar_nav_next_day">Next day</string>
|
||||
<string name="calendar_no_events_today">No events on this day</string>
|
||||
<string name="calendar_no_events">No events</string>
|
||||
<string name="calendar_continues">Continues</string>
|
||||
<string name="calendar_day_of_total">Day %1$d of %2$d</string>
|
||||
<string name="calendar_untitled">(untitled)</string>
|
||||
<string name="calendar_all_day">All-day</string>
|
||||
<string name="calendar_rsvp_going_prefixed">✓ Going</string>
|
||||
@@ -1979,6 +1989,7 @@
|
||||
<string name="calendar_event_in_no_calendars">Not part of any calendar yet.</string>
|
||||
<string name="calendar_event_loading">Loading event…</string>
|
||||
<string name="calendar_relative_ongoing">Happening now</string>
|
||||
<string name="calendar_relative_ongoing_with_end">%1$s · ends %2$s</string>
|
||||
<string name="calendar_export_share_title">Share calendar event</string>
|
||||
<string name="calendar_export_event">Export to calendar (.ics)</string>
|
||||
<string name="calendar_reminder_channel_id">calendar_reminders</string>
|
||||
|
||||
+43
@@ -21,7 +21,9 @@
|
||||
package com.vitorpamplona.amethyst.calendar
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.dal.calendarLocalDayKeyRange
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.dal.groupByDayKey
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.dal.groupByDayKeyExpanded
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.partitionUpcomingPast
|
||||
import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent
|
||||
import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent
|
||||
@@ -142,6 +144,47 @@ class CalendarFeedGroupingTest {
|
||||
assertTrue(grouped.containsKey(expectedKey))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun groupByDayKeyExpanded_singleDayEvent_landsOnlyOnStartDay() {
|
||||
// Sanity: an event with no end (or end == start) doesn't multiply itself.
|
||||
val note = dateSlotNote(id = "d", start = "2025-01-15")
|
||||
val grouped = groupByDayKeyExpanded(listOf(note))
|
||||
val key = LocalDate.of(2025, 1, 15).toEpochDay()
|
||||
assertEquals(1, grouped.size)
|
||||
assertEquals(1, grouped[key]?.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun groupByDayKeyExpanded_multiDayDateSlot_landsOnEveryDay() {
|
||||
// A 3-day date-slot event should appear in each of Jan 15, 16, 17.
|
||||
val note = dateSlotNote(id = "d", start = "2025-01-15", end = "2025-01-17")
|
||||
val grouped = groupByDayKeyExpanded(listOf(note))
|
||||
val keys = listOf(15, 16, 17).map { LocalDate.of(2025, 1, it).toEpochDay() }
|
||||
assertEquals(3, grouped.size)
|
||||
for (k in keys) assertEquals(1, grouped[k]?.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun groupByDayKeyExpanded_multiDayTimeSlot_landsOnEveryDayCovered() {
|
||||
// Spans ~36 hours from 12:00 UTC Jan 15 to 00:00 UTC Jan 17. Whether that crosses 2 or 3
|
||||
// local days depends on the runner zone; we just assert it covers more than one day.
|
||||
val note = timeSlotNote(id = "t", startSeconds = 1736942400L, endSeconds = 1736942400L + 36 * 3600L)
|
||||
val grouped = groupByDayKeyExpanded(listOf(note))
|
||||
assertTrue("expected multi-day event to land on >1 day", grouped.size >= 2)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun calendarLocalDayKeyRange_isCappedAt366Days() {
|
||||
// Defence: a malformed event with end years in the future shouldn't expand to thousands
|
||||
// of day-keys and blow up the month grid.
|
||||
val absurdStart = 1736942400L
|
||||
val absurdEnd = absurdStart + 365L * 86400L * 10 // 10 years
|
||||
val note = timeSlotNote(id = "rogue", startSeconds = absurdStart, endSeconds = absurdEnd)
|
||||
val range = note.calendarLocalDayKeyRange()
|
||||
assertNotNull(range)
|
||||
assertTrue((range!!.last - range.first) <= 366)
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
private fun timeSlotNote(
|
||||
|
||||
Reference in New Issue
Block a user