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) + }