mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-08 23:54:39 +00:00
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:
+11
-4
@@ -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 =
|
||||
|
||||
+10
-1
@@ -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,
|
||||
|
||||
+8
-2
@@ -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)
|
||||
|
||||
+9
-2
@@ -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(
|
||||
|
||||
+7
-1
@@ -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)
|
||||
|
||||
+136
-41
@@ -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 ->
|
||||
|
||||
Reference in New Issue
Block a user