mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-06 03:38:23 +00:00
feat(calendar): merge the RSVP card and its event into one frame
The Home feed's Going card stacked a bordered box, a status title, the comment, and a raised, shadowed event card carrying the host's own avatar header: two frames and two authors for one post. Now the note's text is the RSVP comment, followed by a single quote-style frame: a tinted status strip (Going / Maybe / Can't go, with "in 3 days" on the right), the cover image full width, and the same date badge and text lines the calendar list uses. No inner card, no second header; the whole frame opens the event. While the event loads the strip sits over a muted loading line instead of the raw a-tag address. The list card's badge and text lines are extracted so both surfaces draw an event the same way. Icons reuse existing codepoints. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BfJPuJUrcXrZqSyVevxKbk
This commit is contained in:
+85
-8
@@ -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)
|
||||
|
||||
+61
-46
@@ -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),
|
||||
|
||||
@@ -340,6 +340,7 @@
|
||||
<string name="relay_discovery_geohash">Location</string>
|
||||
<string name="calendar_rsvp_going">Going</string>
|
||||
<string name="calendar_rsvp_maybe">Maybe</string>
|
||||
<string name="calendar_rsvp_loading_event">Loading event…</string>
|
||||
<string name="calendar_rsvp_not_going">Can't go</string>
|
||||
<plurals name="calendar_collection_count">
|
||||
<item quantity="one">%1$d event</item>
|
||||
|
||||
+97
-56
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user