diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/CalendarRsvpRender.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/CalendarRsvpRender.kt
index fe7acc5a57..f47b1ce812 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/CalendarRsvpRender.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/CalendarRsvpRender.kt
@@ -20,21 +20,43 @@
*/
package com.vitorpamplona.amethyst.ui.note.types
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.aspectRatio
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
+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.unit.dp
+import com.vitorpamplona.amethyst.commons.model.AddressableNote
import com.vitorpamplona.amethyst.commons.model.Note
+import com.vitorpamplona.amethyst.commons.model.nip52Calendar.appointmentView
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.note.CalendarRsvpCard
+import com.vitorpamplona.amethyst.ui.components.MyAsyncImage
import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote
import com.vitorpamplona.amethyst.ui.note.WatchNoteEvent
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
-import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.CalendarEventListCard
+import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.CalendarAppointmentLines
+import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.CalendarDateBadge
+import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.detailRouteFor
+import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.formatCalendarRange
+import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.rememberRelativeTimeLabel
import com.vitorpamplona.quartz.nip52Calendar.rsvp.CalendarRSVPEvent
/**
* Entry for a NIP-52 calendar RSVP: decodes the [Note], makes sure the appointment it answers is
- * in the cache, and renders the shared commons [CalendarRsvpCard] with that appointment inside it
- * — "going" is only interesting next to *what* — falling back to the bare address until it loads.
+ * in the cache, and renders the shared commons [CalendarRsvpCard] with that appointment merged
+ * into its frame — "going" is only interesting next to *what*. Until the appointment loads the
+ * card shows its status strip over a "loading" line.
*
* [LoadAddressableNote] creates the [com.vitorpamplona.amethyst.commons.model.AddressableNote] in
* `LocalCache` — a shell with a null event if we have never seen the appointment — and
@@ -74,14 +96,69 @@ fun RenderCalendarRSVPEvent(
// keep asking relays for the appointment's reactions and zaps on every RSVP in the feed.
WatchNoteEvent(
baseNote = appointment,
- onNoteEventFound = {
- CalendarRsvpCard(event) {
- CalendarEventListCard(appointment, accountViewModel, nav)
- }
- },
+ onNoteEventFound = { RsvpWithAppointment(event, appointment, accountViewModel, nav) },
onBlank = { CalendarRsvpCard(event) },
accountViewModel = accountViewModel,
)
}
}
}
+
+/**
+ * The appointment drawn *inside* the RSVP's frame, under its status strip: cover image full
+ * width, then the same date badge and text lines the calendar list uses. No author header — the
+ * post already says who answered — and no card of its own, so there is one frame, not two.
+ * "In 3 days" moves up into the strip, next to the answer it qualifies.
+ */
+@Composable
+private fun RsvpWithAppointment(
+ event: CalendarRSVPEvent,
+ appointment: AddressableNote,
+ accountViewModel: AccountViewModel,
+ nav: INav,
+) {
+ val view = appointment.appointmentView()
+ if (view == null) {
+ CalendarRsvpCard(event)
+ return
+ }
+
+ val context = LocalContext.current
+ val appointmentEvent = appointment.event
+ val range = remember(appointmentEvent) { formatCalendarRange(appointment, context) }
+ val relative = rememberRelativeTimeLabel(view, appointmentEvent?.id)
+ val route = remember(appointment) { detailRouteFor(appointment) }
+
+ CalendarRsvpCard(
+ event = event,
+ statusDetail = relative,
+ onClick = { nav.nav(route) },
+ ) {
+ val image = view.image
+ if (!image.isNullOrBlank()) {
+ MyAsyncImage(
+ imageUrl = image,
+ contentDescription = view.title,
+ contentScale = ContentScale.Crop,
+ mainImageModifier = CoverImageModifier,
+ loadedImageModifier = Modifier,
+ accountViewModel = accountViewModel,
+ onLoadingBackground = { Box(CoverImageModifier) },
+ onError = null,
+ )
+ }
+
+ Row(
+ modifier = Modifier.padding(12.dp),
+ verticalAlignment = Alignment.Top,
+ ) {
+ CalendarDateBadge(view.startSeconds)
+ Spacer(modifier = Modifier.size(12.dp))
+ Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
+ CalendarAppointmentLines(view, range, relative = null)
+ }
+ }
+ }
+}
+
+private val CoverImageModifier = Modifier.fillMaxWidth().aspectRatio(2f)
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarEventListCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarEventListCard.kt
index 00a345c829..16366ee7b2 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarEventListCard.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarEventListCard.kt
@@ -49,6 +49,7 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.navigation.Route
+import com.vitorpamplona.amethyst.commons.model.nip52Calendar.CalendarAppointmentView
import com.vitorpamplona.amethyst.commons.model.nip52Calendar.appointmentView
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.components.MyAsyncImage
@@ -120,51 +121,7 @@ fun CalendarEventListCard(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
- view.title?.let {
- Text(
- text = it,
- style = MaterialTheme.typography.titleMedium,
- fontWeight = FontWeight.SemiBold,
- maxLines = 2,
- overflow = TextOverflow.Ellipsis,
- )
- }
- range?.let {
- Text(
- text = it,
- style = MaterialTheme.typography.bodyMedium,
- color = MaterialTheme.colorScheme.primary,
- maxLines = 2,
- overflow = TextOverflow.Ellipsis,
- )
- }
- relative?.let {
- Text(
- text = it,
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
- )
- }
- view.location?.let {
- Row(verticalAlignment = Alignment.CenterVertically) {
- Icon(
- symbol = MaterialSymbols.LocationOn,
- contentDescription = null,
- modifier = Modifier.size(14.dp),
- tint = MaterialTheme.colorScheme.onSurfaceVariant,
- )
- Spacer(modifier = Modifier.size(4.dp))
- Text(
- text = it,
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
- )
- }
- }
+ CalendarAppointmentLines(view, range, relative)
val image = view.image
val summary = view.summary
if (!image.isNullOrBlank()) {
@@ -196,8 +153,66 @@ fun CalendarEventListCard(
}
}
+/**
+ * Title, time range, relative time and location of an appointment — the text half of every
+ * calendar row. Shared by the calendar list card and the feed's RSVP card so the two cannot
+ * disagree about how an event reads. Pass a null [relative] where the caller shows it elsewhere.
+ */
@Composable
-private fun CalendarDateBadge(startSeconds: Long?) {
+internal fun CalendarAppointmentLines(
+ view: CalendarAppointmentView,
+ range: String?,
+ relative: String?,
+) {
+ view.title?.let {
+ Text(
+ text = it,
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.SemiBold,
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+ range?.let {
+ Text(
+ text = it,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.primary,
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+ relative?.let {
+ Text(
+ text = it,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+ view.location?.let {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Icon(
+ symbol = MaterialSymbols.LocationOn,
+ contentDescription = null,
+ modifier = Modifier.size(14.dp),
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ Spacer(modifier = Modifier.size(4.dp))
+ Text(
+ text = it,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+ }
+}
+
+@Composable
+internal fun CalendarDateBadge(startSeconds: Long?) {
if (startSeconds == null) {
Box(
modifier = Modifier.size(width = 52.dp, height = 60.dp),
diff --git a/commonsUI/src/commonMain/composeResources/values/strings.xml b/commonsUI/src/commonMain/composeResources/values/strings.xml
index 2392d23c21..ceae1383d3 100644
--- a/commonsUI/src/commonMain/composeResources/values/strings.xml
+++ b/commonsUI/src/commonMain/composeResources/values/strings.xml
@@ -340,6 +340,7 @@
Location
Going
Maybe
+ Loading event…
Can't go
- %1$d event
diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/CalendarRsvpCard.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/CalendarRsvpCard.kt
index cb917ceb22..f0f066ce28 100644
--- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/CalendarRsvpCard.kt
+++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/CalendarRsvpCard.kt
@@ -20,7 +20,10 @@
*/
package com.vitorpamplona.amethyst.commons.ui.note
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
@@ -34,97 +37,135 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
+import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.calendar_rsvp_going
+import com.vitorpamplona.amethyst.commons.resources.calendar_rsvp_loading_event
import com.vitorpamplona.amethyst.commons.resources.calendar_rsvp_maybe
import com.vitorpamplona.amethyst.commons.resources.calendar_rsvp_not_going
-import com.vitorpamplona.amethyst.commons.ui.theme.StdVertSpacer
import com.vitorpamplona.amethyst.commons.ui.theme.replyModifier
import com.vitorpamplona.quartz.nip52Calendar.appt.tags.RSVPStatusTag
import com.vitorpamplona.quartz.nip52Calendar.rsvp.CalendarRSVPEvent
import org.jetbrains.compose.resources.stringResource
/**
- * Card for a NIP-52 calendar RSVP (kind 31925): the going/maybe/not-going status, note, and target.
+ * A NIP-52 calendar RSVP (kind 31925) as one card: the author's note as post text, then a single
+ * quote-style frame whose tinted strip says how they answered and whose body is the appointment
+ * they answered.
*
- * [target] draws the appointment being answered once the front end has it; until then (or when
- * it is null) the card falls back to printing the appointment's address.
+ * The status lives *inside* the appointment's frame on purpose. It is a property of "Alice → this
+ * event", so a header line floating above a separately framed event read as two unrelated posts;
+ * and the frame is the feed's plain quote border, not a raised card, so the RSVP never nests a
+ * box inside a box.
+ *
+ * [appointment] draws the event once the front end has it (no header of its own — the post's
+ * author is who answered, and a second avatar row made it read like a quote of a quote). Until
+ * then the strip stands over a muted "loading" line. [statusDetail] is the strip's trailing text,
+ * e.g. "in 3 days". [onClick] makes the whole frame open the event.
*/
@Composable
fun CalendarRsvpCard(
event: CalendarRSVPEvent,
- target: (@Composable () -> Unit)? = null,
+ statusDetail: String? = null,
+ onClick: (() -> Unit)? = null,
+ appointment: (@Composable ColumnScope.() -> Unit)? = null,
) {
val status = remember(event) { event.status() }
- val targetAddress = remember(event) { event.calendarEventAddress() }
- val freebusy = remember(event) { event.freebusy() }
- val statusLabel =
- when (status) {
- RSVPStatusTag.STATUS.ACCEPTED -> stringResource(Res.string.calendar_rsvp_going)
- RSVPStatusTag.STATUS.TENTATIVE -> stringResource(Res.string.calendar_rsvp_maybe)
- RSVPStatusTag.STATUS.DECLINED -> stringResource(Res.string.calendar_rsvp_not_going)
- null -> "—"
- }
-
- val statusColor =
- when (status) {
- RSVPStatusTag.STATUS.ACCEPTED -> MaterialTheme.colorScheme.primary
- RSVPStatusTag.STATUS.TENTATIVE -> MaterialTheme.colorScheme.tertiary
- RSVPStatusTag.STATUS.DECLINED -> MaterialTheme.colorScheme.error
- null -> Color.Gray
- }
-
- Column(MaterialTheme.colorScheme.replyModifier) {
- Row(
- modifier = Modifier.fillMaxWidth().padding(start = 10.dp, end = 10.dp, top = 10.dp),
- verticalAlignment = Alignment.CenterVertically,
- ) {
- Icon(
- symbol = MaterialSymbols.CalendarMonth,
- contentDescription = null,
- modifier = Modifier.size(20.dp),
- tint = statusColor,
- )
- Spacer(modifier = Modifier.size(8.dp))
- Text(
- text = statusLabel,
- style = MaterialTheme.typography.titleMedium,
- fontWeight = FontWeight.SemiBold,
- color = statusColor,
- )
+ val frame =
+ if (onClick != null) {
+ MaterialTheme.colorScheme.replyModifier.clickable(onClick = onClick)
+ } else {
+ MaterialTheme.colorScheme.replyModifier
}
+ Column {
if (event.content.isNotBlank()) {
- Spacer(modifier = StdVertSpacer)
Text(
text = event.content,
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- modifier = Modifier.padding(start = 10.dp, end = 10.dp),
+ style = MaterialTheme.typography.bodyLarge,
+ modifier = Modifier.padding(bottom = 4.dp),
)
}
- if (target != null) {
- Spacer(modifier = StdVertSpacer)
- target()
- } else {
- targetAddress?.let { addr ->
- Spacer(modifier = StdVertSpacer)
+ Column(frame) {
+ RsvpStatusStrip(status, statusDetail)
+
+ if (appointment != null) {
+ appointment()
+ } else {
Text(
- text = "→ ${addr.toValue()}",
- style = MaterialTheme.typography.labelSmall,
+ text = stringResource(Res.string.calendar_rsvp_loading_event),
+ style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
- modifier = Modifier.padding(start = 10.dp, end = 10.dp, bottom = 12.dp),
+ modifier = Modifier.padding(12.dp),
)
}
}
+ }
+}
- if (freebusy != null) {
- Spacer(modifier = StdVertSpacer)
+@Composable
+private fun RsvpStatusStrip(
+ status: RSVPStatusTag.STATUS?,
+ detail: String?,
+) {
+ val (label, color, symbol) = rsvpStatusLook(status)
+
+ Row(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .background(color.copy(alpha = 0.12f))
+ .padding(horizontal = 12.dp, vertical = 8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Icon(
+ symbol = symbol,
+ contentDescription = null,
+ modifier = Modifier.size(18.dp),
+ tint = color,
+ )
+ Spacer(modifier = Modifier.size(6.dp))
+ Text(
+ text = label,
+ style = MaterialTheme.typography.labelLarge,
+ fontWeight = FontWeight.SemiBold,
+ color = color,
+ modifier = Modifier.weight(1f),
+ maxLines = 1,
+ )
+ if (detail != null) {
+ Text(
+ text = detail,
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
}
}
}
+
+private data class RsvpStatusLook(
+ val label: String,
+ val color: Color,
+ val symbol: MaterialSymbol,
+)
+
+@Composable
+private fun rsvpStatusLook(status: RSVPStatusTag.STATUS?): RsvpStatusLook =
+ when (status) {
+ RSVPStatusTag.STATUS.ACCEPTED ->
+ RsvpStatusLook(stringResource(Res.string.calendar_rsvp_going), MaterialTheme.colorScheme.primary, MaterialSymbols.CheckCircle)
+ RSVPStatusTag.STATUS.TENTATIVE ->
+ RsvpStatusLook(stringResource(Res.string.calendar_rsvp_maybe), MaterialTheme.colorScheme.tertiary, MaterialSymbols.Schedule)
+ RSVPStatusTag.STATUS.DECLINED ->
+ RsvpStatusLook(stringResource(Res.string.calendar_rsvp_not_going), MaterialTheme.colorScheme.error, MaterialSymbols.Cancel)
+ null ->
+ RsvpStatusLook("—", Color.Gray, MaterialSymbols.CalendarMonth)
+ }