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:
Claude
2026-05-19 21:58:34 +00:00
parent eeda5810ae
commit 971e9e4846
13 changed files with 445 additions and 142 deletions
@@ -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
}
@@ -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
@@ -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,
@@ -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,
)
}
}
}
@@ -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
@@ -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),
@@ -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 =
@@ -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
}
}
@@ -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 ->
@@ -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
@@ -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)
}
+11
View File
@@ -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>
@@ -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(