feat(calendars): modernize UI to match the rest of the app

Visual: every calendar surface now uses the same avatar + display-name
pattern as the picture feed / shorts / video cards — UserCardHeader at
the top of each list card, ClickableUserPicture + UsernameDisplay in
the detail screen's people sections. Truncated pubkeys (`pub…XX`) only
appear as a fallback while LoadUser resolves a real metadata record.

Cards:
- CalendarEventListCard now starts with UserCardHeader (avatar, name,
  time-ago, more-options) above the date badge + body. Layout matches
  PictureCardCompose / VideoCardCompose so the calendar feed reads as
  part of the same product, not a tacked-on tab.
- CalendarCollectionCard same treatment.

Detail screen:
- ParticipantsSection now renders a 35dp avatar + display name per
  p-tag, tappable to the user's profile.
- RsvpsSection renders the RSVP author the same way with the status
  badge as a trailing element (Going / Maybe / Can't go in the matching
  scheme colour).
- InCalendarsSection renders each calendar's author avatar alongside
  the calendar title — clicking the row still navigates to that
  calendar's detail.
- RSVPs + calendars sections are now reactive: produceState collects
  LocalCache.live.newEventBundles and re-runs the scan, so RSVPs that
  arrive from relays while the screen is open appear without leaving
  and returning.

Notifications:
- Tap the notification → opens the calendar event detail. The reminder
  PendingIntent now carries the `nostr:naddr…` URI as ACTION_VIEW data;
  AppNavigation's existing uriToRoute pipeline resolves it via NAddress
  → RouteMaker, where new branches map CalendarTimeSlotEvent /
  CalendarDateSlotEvent to Route.CalendarEventDetail. Previously the
  tap just opened the home screen.
- CalendarReminderStore now keys on (eventId, startSeconds). If the
  author updates the appointment with a new start time, the stored
  value won't match and the worker fires a fresh reminder for the new
  time — previously a moved meeting would be silently skipped because
  the eventId already had a record.

https://claude.ai/code/session_01CbyrA2GdM4EQh8T6nsst6U
This commit is contained in:
Claude
2026-05-19 21:58:33 +00:00
parent 55147b7781
commit cc5ae46f5b
7 changed files with 193 additions and 51 deletions
@@ -42,22 +42,29 @@ object CalendarReminderNotifier {
private const val REMINDER_NOT_ID_BASE = 0x80000
/**
* @param eventId the appointment's event id — used to derive a stable notification id so a
* second reminder for the same event collapses rather than stacking.
* @param title the appointment title (or a fallback string).
* @param body a short pre-formatted body, e.g. "Starts in 15 minutes".
* @param eventId the appointment's event id — used to derive a stable notification id so a
* second reminder for the same event collapses rather than stacking.
* @param title the appointment title (or a fallback string).
* @param body a short pre-formatted body, e.g. "Starts in 15 minutes".
* @param deepLink a `nostr:naddr…` URI for the calendar event. Tapping the notification
* hands this to MainActivity, which routes it via `uriToRoute` → the
* calendar detail screen (CalendarTimeSlotEvent / CalendarDateSlotEvent
* branches in RouteMaker resolve to Route.CalendarEventDetail).
*/
fun notifyReminder(
context: Context,
eventId: String,
title: String,
body: String,
deepLink: String,
) {
ensureChannel(context)
val notId = idFor(eventId)
val channelId = stringRes(context, R.string.calendar_reminder_channel_id)
val tapIntent =
Intent(context, MainActivity::class.java).apply {
action = Intent.ACTION_VIEW
data = android.net.Uri.parse(deepLink)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
}
val tapPendingIntent =
@@ -39,7 +39,16 @@ class CalendarReminderStore(
private val prefs: SharedPreferences =
context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE)
fun wasNotified(eventId: String): Boolean = prefs.contains(keyFor(eventId))
/**
* Returns true when we've previously notified for this exact event-start pairing. If the
* author updates the appointment to a new start time, the stored value won't match and
* we'll fire a fresh reminder for the new time — that's the desired behaviour: a moved
* meeting shouldn't be silently skipped.
*/
fun wasNotified(
eventId: String,
eventStartSeconds: Long,
): Boolean = prefs.getLong(keyFor(eventId), Long.MIN_VALUE) == eventStartSeconds
fun markNotified(
eventId: String,
@@ -80,7 +80,9 @@ class CalendarReminderWorker(
?: return@forEach
if (start !in now..windowEnd) return@forEach
if (store.wasNotified(eventId)) return@forEach
// Keyed on (eventId, start) so a moved appointment re-fires when the new start
// enters the lead window — the old notification stays valid in the system tray.
if (store.wasNotified(eventId, start)) return@forEach
val title = view.title ?: stringRes(applicationContext, R.string.calendar_reminder_default_title)
val minutesAway = ((start - now).coerceAtLeast(0L) / 60L).toInt()
@@ -90,7 +92,11 @@ class CalendarReminderWorker(
R.string.calendar_reminder_body,
minutesAway,
)
CalendarReminderNotifier.notifyReminder(applicationContext, eventId, title, body)
val deepLink =
"nostr:" +
com.vitorpamplona.quartz.nip19Bech32.entities.NAddress
.create(targetAddress.kind, targetAddress.pubKeyHex, targetAddress.dTag, null)
CalendarReminderNotifier.notifyReminder(applicationContext, eventId, title, body, deepLink)
store.markNotified(eventId, start)
Log.d(TAG) { "Notified $eventId (starts in ${minutesAway}m)" }
}
@@ -149,6 +149,18 @@ fun routeForInner(
Route.GitRepository(noteEvent.kind, noteEvent.pubKey, noteEvent.dTag())
}
// Calendar appointments route to their dedicated detail screen rather than the generic
// Route.Note that AddressableEvent would fall through to — without this the notification
// tap and `nostr:naddr…` deep links land on the bare note view instead of the calendar
// detail with RSVPs, participants, and the "in calendars" list.
is com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent -> {
Route.CalendarEventDetail(noteEvent.kind, noteEvent.pubKey, noteEvent.dTag())
}
is com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent -> {
Route.CalendarEventDetail(noteEvent.kind, noteEvent.pubKey, noteEvent.dTag())
}
is GiftWrapEvent -> {
noteEvent.innerEventId?.let {
routeFor(LocalCache.getOrCreateNote(it), loggedIn)
@@ -95,7 +95,7 @@ private fun CollectionsBody(
val items by loaded.feed.collectAsStateWithLifecycle()
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(items.list, key = { it.idHex }) { note ->
CalendarCollectionCard(note, nav)
CalendarCollectionCard(note, accountViewModel, nav)
}
}
}
@@ -117,6 +117,7 @@ private fun EmptyCollections() {
@Composable
fun CalendarCollectionCard(
note: Note,
accountViewModel: AccountViewModel,
nav: INav,
) {
val event = note.event as? CalendarEvent ?: return
@@ -135,8 +136,14 @@ fun CalendarCollectionCard(
colors = CardDefaults.elevatedCardColors(),
elevation = CardDefaults.elevatedCardElevation(defaultElevation = 2.dp),
) {
// Author header matches every other social card in the app.
com.vitorpamplona.amethyst.ui.screen.loggedIn.video.UserCardHeader(
baseNote = note,
accountViewModel = accountViewModel,
nav = nav,
)
Row(
modifier = Modifier.padding(14.dp),
modifier = Modifier.padding(start = 14.dp, end = 14.dp, bottom = 14.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
@@ -52,6 +52,7 @@ 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.video.UserCardHeader
import com.vitorpamplona.quartz.utils.TimeUtils
import java.time.Instant
import java.time.ZoneId
@@ -92,8 +93,13 @@ fun CalendarEventListCard(
colors = CardDefaults.elevatedCardColors(),
elevation = CardDefaults.elevatedCardElevation(defaultElevation = 2.dp),
) {
// Author header matches the picture-feed / shorts card shape: avatar + display name +
// time-ago at the top of every social card in the app. Without this, calendar cards
// looked alien next to the rest of the feed.
UserCardHeader(baseNote = note, accountViewModel = accountViewModel, nav = nav)
Row(
modifier = Modifier.padding(12.dp),
modifier = Modifier.padding(start = 12.dp, end = 12.dp, bottom = 12.dp),
verticalAlignment = Alignment.Top,
) {
CalendarDateBadge(view.startSeconds)
@@ -295,14 +295,14 @@ private fun EventBody(
if (participants.isNotEmpty()) {
HorizontalDivider()
ParticipantsSection(participants)
ParticipantsSection(participants, accountViewModel, nav)
}
HorizontalDivider()
RsvpsSection(targetAddress)
RsvpsSection(targetAddress, accountViewModel, nav)
HorizontalDivider()
InCalendarsSection(targetAddress, nav)
InCalendarsSection(targetAddress, accountViewModel, nav)
Spacer(modifier = Modifier.height(24.dp))
}
@@ -367,26 +367,30 @@ private fun LocationRow(location: String) {
}
@Composable
private fun ParticipantsSection(participants: List<PTag>) {
Column(modifier = Modifier.padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
private fun ParticipantsSection(
participants: List<PTag>,
accountViewModel: AccountViewModel,
nav: INav,
) {
Column(modifier = Modifier.padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
SectionTitle(stringRes(R.string.calendar_participants_section, participants.size))
participants.forEach { p ->
Text(
text = formatPubKeyShort(p.pubKey),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
UserRow(p.pubKey, accountViewModel, nav, trailing = null)
}
}
}
@Composable
private fun RsvpsSection(targetAddress: Address) {
val rsvps = remember(targetAddress) { findRsvpsFor(targetAddress) }
private fun RsvpsSection(
targetAddress: Address,
accountViewModel: AccountViewModel,
nav: INav,
) {
// Reactively re-scan when LocalCache emits new bundles. Without this, RSVPs that arrive
// from relays while the screen is open don't show up until the user leaves and returns.
val rsvps by rememberRsvpsFor(targetAddress)
Column(modifier = Modifier.padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Column(modifier = Modifier.padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
SectionTitle(stringRes(R.string.calendar_rsvp_section, rsvps.size))
if (rsvps.isEmpty()) {
Text(
@@ -397,18 +401,11 @@ private fun RsvpsSection(targetAddress: Address) {
return@Column
}
rsvps.forEach { rsvp ->
val statusLabel =
when (rsvp.status()) {
RSVPStatusTag.STATUS.ACCEPTED -> stringRes(R.string.calendar_rsvp_going_prefixed)
RSVPStatusTag.STATUS.TENTATIVE -> stringRes(R.string.calendar_rsvp_maybe_prefixed)
RSVPStatusTag.STATUS.DECLINED -> stringRes(R.string.calendar_rsvp_not_going_prefixed)
null -> ""
}
Text(
text = "$statusLabel · ${formatPubKeyShort(rsvp.pubKey)}",
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
UserRow(
pubKey = rsvp.pubKey,
accountViewModel = accountViewModel,
nav = nav,
trailing = { RsvpStatusBadge(rsvp.status()) },
)
}
}
@@ -417,11 +414,12 @@ private fun RsvpsSection(targetAddress: Address) {
@Composable
private fun InCalendarsSection(
targetAddress: Address,
accountViewModel: AccountViewModel,
nav: INav,
) {
val calendars = remember(targetAddress) { findCalendarsContaining(targetAddress) }
val calendars by rememberCalendarsContaining(targetAddress)
Column(modifier = Modifier.padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Column(modifier = Modifier.padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
SectionTitle(stringRes(R.string.calendar_event_in_calendars, calendars.size))
if (calendars.isEmpty()) {
Text(
@@ -432,10 +430,7 @@ private fun InCalendarsSection(
return@Column
}
calendars.forEach { calendar ->
Text(
text = calendar.title() ?: stringRes(R.string.calendar_untitled),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary,
Row(
modifier =
Modifier
.fillMaxWidth()
@@ -448,13 +443,99 @@ private fun InCalendarsSection(
),
)
}.padding(vertical = 4.dp),
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
com.vitorpamplona.amethyst.ui.note.ClickableUserPicture(
baseUserHex = calendar.pubKey,
size = com.vitorpamplona.amethyst.ui.theme.Size30dp,
accountViewModel = accountViewModel,
)
Text(
text = calendar.title() ?: stringRes(R.string.calendar_untitled),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
}
}
}
/**
* Shared social-row layout: avatar (clickable → profile), display name, optional trailing slot
* for things like RSVP badges. Matches the visual language of every other user-list across the
* app.
*/
@Composable
private fun UserRow(
pubKey: String,
accountViewModel: AccountViewModel,
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,
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()
}
}
}
@Composable
private fun RsvpStatusBadge(status: RSVPStatusTag.STATUS?) {
val (label, color) =
when (status) {
RSVPStatusTag.STATUS.ACCEPTED ->
stringRes(R.string.calendar_rsvp_going_prefixed) to MaterialTheme.colorScheme.primary
RSVPStatusTag.STATUS.TENTATIVE ->
stringRes(R.string.calendar_rsvp_maybe_prefixed) to MaterialTheme.colorScheme.tertiary
RSVPStatusTag.STATUS.DECLINED ->
stringRes(R.string.calendar_rsvp_not_going_prefixed) to MaterialTheme.colorScheme.error
null -> "" to MaterialTheme.colorScheme.onSurfaceVariant
}
Text(
text = label,
style = MaterialTheme.typography.labelMedium,
color = color,
fontWeight = FontWeight.SemiBold,
)
}
@Composable
private fun SectionTitle(text: String) {
Text(
@@ -469,9 +550,26 @@ private fun SectionTitle(text: String) {
private fun formatPubKeyShort(pubKey: String): String = if (pubKey.length <= 16) pubKey else pubKey.take(8) + "" + pubKey.takeLast(8)
/**
* Scans LocalCache for kind-31925 RSVPs that a-tag [targetAddress]. Snapshotted at call time —
* see the class kdoc for the reactivity trade-off.
* Reactive scan of [LocalCache] for kind-31925 RSVPs that a-tag [targetAddress]. Re-runs on
* every new-event bundle so RSVPs that arrive while the screen is open appear without a manual
* 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) {
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) {
LocalCache.live.newEventBundles.collect {
value = findCalendarsContaining(targetAddress)
}
}
private fun findRsvpsFor(targetAddress: Address): List<CalendarRSVPEvent> =
LocalCache.addressables
.filterIntoSet { _, note ->
@@ -480,9 +578,6 @@ private fun findRsvpsFor(targetAddress: Address): List<CalendarRSVPEvent> =
}.mapNotNull { it.event as? CalendarRSVPEvent }
.sortedByDescending { it.createdAt }
/**
* Scans LocalCache for kind-31924 calendars whose `a` tags include [targetAddress].
*/
private fun findCalendarsContaining(targetAddress: Address): List<CalendarEvent> =
LocalCache.addressables
.filterIntoSet { _, note ->