diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index 91abdca7b7..03255ee307 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -259,7 +259,7 @@ fun BuildNavigation( composableFromEnd { CalendarsScreen(accountViewModel, nav) } composableFromEnd { CalendarCollectionsScreen(accountViewModel, nav) } composableFromBottomArgs { NewCalendarEventScreen(nav, accountViewModel) } - composableFromBottomArgs { NewCalendarCollectionScreen(nav, accountViewModel) } + composableFromBottomArgs { NewCalendarCollectionScreen(nav, accountViewModel, it.dTag) } composableFromEnd { ProductsScreen(accountViewModel, nav) } composableFromEnd { ShortsScreen(accountViewModel, nav) } composableFromEnd { PublicChatsScreen(accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/CalendarRsvpRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/CalendarRsvpRow.kt index 69cadfc3c5..a14098a7f2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/CalendarRsvpRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/CalendarRsvpRow.kt @@ -30,21 +30,29 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag import com.vitorpamplona.quartz.nip01Core.tags.people.PTag import com.vitorpamplona.quartz.nip52Calendar.appt.tags.RSVPStatusTag import com.vitorpamplona.quartz.nip52Calendar.rsvp.CalendarRSVPEvent /** - * Renders a 3-button RSVP row (Going / Maybe / Can't go) below a NIP-52 calendar event. - * Tapping a button publishes a new kind 31925 with a random `d` tag — multiple taps create - * multiple RSVPs, which is consistent with how the NIP describes "responses". + * Renders a 3-button RSVP row (Going / Maybe / Can't go) below a NIP-52 calendar appointment. + * + * Uses a deterministic d-tag derived from the target appointment's address so the user has + * exactly one RSVP per event from this client — tapping again replaces it rather than appending + * another addressable. The button matching the current status renders as filled-tonal; the + * others render outlined. */ @Composable fun CalendarRsvpRow( @@ -54,46 +62,117 @@ fun CalendarRsvpRow( eventId: String, accountViewModel: AccountViewModel, ) { + val myPubKey = accountViewModel.userProfile().pubkeyHex + val targetAddress = remember(eventKind, eventPubKey, eventDTag) { Address(eventKind, eventPubKey, eventDTag) } + val myRsvpAddress = remember(targetAddress, myPubKey) { rsvpAddressFor(myPubKey, targetAddress) } + + val myRsvpNote = remember(myRsvpAddress) { LocalCache.getOrCreateAddressableNote(myRsvpAddress) } + val myRsvpState by myRsvpNote + .flow() + .metadata.stateFlow + .collectAsStateWithLifecycle() + + val currentStatus = (myRsvpState.note.event as? CalendarRSVPEvent)?.status() + + val onTap: (RSVPStatusTag.STATUS) -> Unit = { newStatus -> + sendRsvp( + accountViewModel = accountViewModel, + targetAddress = targetAddress, + eventId = eventId, + myPubKey = myPubKey, + status = newStatus, + ) + } + Row( modifier = Modifier.fillMaxWidth().padding(start = 10.dp, end = 10.dp, top = 10.dp, bottom = 12.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), ) { - FilledTonalButton( - onClick = { sendRsvp(accountViewModel, eventKind, eventPubKey, eventDTag, eventId, RSVPStatusTag.STATUS.ACCEPTED) }, + RsvpButton( + label = stringRes(R.string.calendar_rsvp_going), + status = RSVPStatusTag.STATUS.ACCEPTED, + currentStatus = currentStatus, modifier = Modifier.weight(1f), + onClick = onTap, + ) + RsvpButton( + label = stringRes(R.string.calendar_rsvp_maybe), + status = RSVPStatusTag.STATUS.TENTATIVE, + currentStatus = currentStatus, + modifier = Modifier.weight(1f), + onClick = onTap, + ) + RsvpButton( + label = stringRes(R.string.calendar_rsvp_not_going), + status = RSVPStatusTag.STATUS.DECLINED, + currentStatus = currentStatus, + modifier = Modifier.weight(1f), + onClick = onTap, + ) + } +} + +@Composable +private fun RsvpButton( + label: String, + status: RSVPStatusTag.STATUS, + currentStatus: RSVPStatusTag.STATUS?, + modifier: Modifier, + onClick: (RSVPStatusTag.STATUS) -> Unit, +) { + val selected = status == currentStatus + if (selected) { + FilledTonalButton( + onClick = { onClick(status) }, + modifier = modifier, colors = ButtonDefaults.filledTonalButtonColors( - containerColor = MaterialTheme.colorScheme.primaryContainer, + containerColor = colorFor(status), + contentColor = Color.White, ), ) { - Text(text = stringRes(R.string.calendar_rsvp_going)) + Text(text = label) } + } else { OutlinedButton( - onClick = { sendRsvp(accountViewModel, eventKind, eventPubKey, eventDTag, eventId, RSVPStatusTag.STATUS.TENTATIVE) }, - modifier = Modifier.weight(1f), + onClick = { onClick(status) }, + modifier = modifier, ) { - Text(text = stringRes(R.string.calendar_rsvp_maybe)) - } - OutlinedButton( - onClick = { sendRsvp(accountViewModel, eventKind, eventPubKey, eventDTag, eventId, RSVPStatusTag.STATUS.DECLINED) }, - modifier = Modifier.weight(1f), - ) { - Text(text = stringRes(R.string.calendar_rsvp_not_going)) + Text(text = label) } } } +@Composable +private fun colorFor(status: RSVPStatusTag.STATUS) = + when (status) { + RSVPStatusTag.STATUS.ACCEPTED -> MaterialTheme.colorScheme.primary + RSVPStatusTag.STATUS.TENTATIVE -> MaterialTheme.colorScheme.tertiary + RSVPStatusTag.STATUS.DECLINED -> MaterialTheme.colorScheme.error + } + +/** + * Deterministic per-target d-tag so each user's RSVP for a given event is a single addressable. + * The format mirrors the a-tag coordinate so it's debuggable (`rsvp:31923::`). + */ +fun rsvpDTagFor(targetAddress: Address): String = "rsvp:${targetAddress.kind}:${targetAddress.pubKeyHex}:${targetAddress.dTag}" + +fun rsvpAddressFor( + myPubKey: String, + targetAddress: Address, +): Address = Address(CalendarRSVPEvent.KIND, myPubKey, rsvpDTagFor(targetAddress)) + private fun sendRsvp( accountViewModel: AccountViewModel, - eventKind: Int, - eventPubKey: String, - eventDTag: String, + targetAddress: Address, eventId: String, + myPubKey: String, status: RSVPStatusTag.STATUS, ) { - val noteRelays = LocalCache.getNoteIfExists(eventId)?.relays?.firstOrNull() - val aTag = ATag(eventKind, eventPubKey, eventDTag, noteRelays) - val pTag = PTag(eventPubKey) + val relayHint = LocalCache.getNoteIfExists(eventId)?.relays?.firstOrNull() + val aTag = ATag(targetAddress, relayHint) + val pTag = PTag(targetAddress.pubKeyHex) + val dTag = rsvpDTagFor(targetAddress) accountViewModel.launchSigner { accountViewModel.account.signAndComputeBroadcast( @@ -101,6 +180,7 @@ private fun sendRsvp( calendarEventAddress = aTag, status = status, calendarEventAuthor = pTag, + dTag = dTag, ), ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/create/NewCalendarCollectionScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/create/NewCalendarCollectionScreen.kt index 84b19c599d..b71d0de340 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/create/NewCalendarCollectionScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/create/NewCalendarCollectionScreen.kt @@ -20,8 +20,10 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.create +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.imePadding @@ -29,54 +31,50 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Checkbox import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.topbars.SavingTopBar import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.formatLongDate import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.quartz.nip52Calendar.calendar.CalendarEvent @OptIn(ExperimentalMaterial3Api::class) @Composable fun NewCalendarCollectionScreen( nav: INav, accountViewModel: AccountViewModel, + editDTag: String? = null, ) { - var title by rememberSaveable { mutableStateOf("") } - var description by rememberSaveable { mutableStateOf("") } - var errorMessage by rememberSaveable { mutableStateOf(null) } + val vm: NewCalendarCollectionViewModel = viewModel() + vm.init(accountViewModel, editDTag) Scaffold( topBar = { SavingTopBar( - titleRes = R.string.new_calendar_collection, + titleRes = if (editDTag == null) R.string.new_calendar_collection else R.string.edit_calendar_collection, onCancel = { nav.popBack() }, onPost = { - if (title.isBlank()) { - errorMessage = "title-required" - return@SavingTopBar - } accountViewModel.launchSigner { - accountViewModel.account.signAndComputeBroadcast( - CalendarEvent.build( - title = title.trim(), - content = description.trim(), - ), - ) - nav.popBack() + if (vm.publish()) { + nav.popBack() + } } }, ) @@ -96,34 +94,112 @@ fun NewCalendarCollectionScreen( verticalArrangement = Arrangement.spacedBy(12.dp), ) { OutlinedTextField( - value = title, - onValueChange = { - title = it - errorMessage = null - }, + value = vm.title.value, + onValueChange = { vm.title.value = it }, label = { Text(stringRes(R.string.calendar_collection_title)) }, modifier = Modifier.fillMaxWidth(), singleLine = true, keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Sentences), - isError = errorMessage == "title-required", + isError = !vm.isValid(), ) OutlinedTextField( - value = description, - onValueChange = { description = it }, + value = vm.description.value, + onValueChange = { vm.description.value = it }, label = { Text(stringRes(R.string.calendar_collection_description)) }, modifier = Modifier.fillMaxWidth(), - minLines = 5, + minLines = 4, keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Sentences), ) - if (errorMessage != null) { + if (!vm.isValid()) { Text( text = stringRes(R.string.calendar_collection_invalid), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error, ) } + + AppointmentPickerSection(vm) + } + } +} + +@Composable +private fun AppointmentPickerSection(vm: NewCalendarCollectionViewModel) { + val available by vm.availableAppointments + val selectedCount = vm.selectedAddresses.size + + Text( + text = stringRes(R.string.calendar_collection_events_section, selectedCount), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(top = 8.dp), + ) + + if (available.isEmpty()) { + Text( + text = stringRes(R.string.calendar_collection_no_events_yet), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(vertical = 8.dp), + ) + return + } + + available.forEach { summary -> + // Snapshot selection state without subscribing to the list itself (we only need to + // re-render the affected row on toggle). + val isSelected = vm.selectedAddresses.contains(summary.address) + AppointmentPickerRow( + summary = summary, + isSelected = isSelected, + onToggle = { vm.toggle(summary.address) }, + ) + HorizontalDivider() + } +} + +@Composable +private fun AppointmentPickerRow( + summary: OwnedAppointmentSummary, + isSelected: Boolean, + onToggle: () -> Unit, +) { + val whenLabel = + remember(summary.address, summary.startSeconds, summary.isAllDay) { + when { + summary.isAllDay -> "All-day" + summary.startSeconds != null -> formatLongDate(summary.startSeconds) + else -> "—" + } + } + + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable(onClick = onToggle) + .padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Checkbox(checked = isSelected, onCheckedChange = { onToggle() }) + Column( + modifier = Modifier.padding(start = 4.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text( + text = summary.title, + style = MaterialTheme.typography.bodyLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = whenLabel, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/create/NewCalendarCollectionViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/create/NewCalendarCollectionViewModel.kt new file mode 100644 index 0000000000..b80b124c3d --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/create/NewCalendarCollectionViewModel.kt @@ -0,0 +1,162 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.create + +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.lifecycle.ViewModel +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag +import com.vitorpamplona.quartz.nip01Core.tags.aTag.aTags +import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent +import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent +import com.vitorpamplona.quartz.nip52Calendar.calendar.CalendarEvent +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * Lightweight projection of a calendar appointment authored by the current user, used to power + * the multi-select picker on the collection editor. + */ +@Immutable +data class OwnedAppointmentSummary( + val address: Address, + val title: String, + val startSeconds: Long?, + val isAllDay: Boolean, +) + +class NewCalendarCollectionViewModel : ViewModel() { + private lateinit var account: Account + + val title = mutableStateOf("") + val description = mutableStateOf("") + val isPublishing = mutableStateOf(false) + + /** Stable d-tag for the addressable: random for create, preserved when editing. */ + private var dTag: String? = null + + val selectedAddresses = mutableStateListOf
() + val availableAppointments = mutableStateOf>(emptyList()) + + fun init( + accountViewModel: AccountViewModel, + editDTag: String?, + ) { + if (::account.isInitialized) return // idempotent across recompositions + this.account = accountViewModel.account + dTag = editDTag + + editDTag?.let { existingDTag -> + val existingAddress = Address(CalendarEvent.KIND, account.userProfile().pubkeyHex, existingDTag) + val existingNote = LocalCache.addressables.get(existingAddress) + (existingNote?.event as? CalendarEvent)?.let { existing -> + title.value = existing.title().orEmpty() + description.value = existing.content + selectedAddresses.addAll(existing.calendarEventAddresses()) + } + } + + availableAppointments.value = loadOwnedAppointments() + } + + fun toggle(address: Address) { + if (selectedAddresses.remove(address)) return + selectedAddresses.add(address) + } + + fun isValid(): Boolean = title.value.isNotBlank() + + suspend fun publish(): Boolean { + if (!isValid()) return false + isPublishing.value = true + try { + val effectiveDTag = dTag + val selected = selectedAddresses.toList() + val parsedTitle = title.value.trim() + val parsedDescription = description.value.trim() + + account.signAndComputeBroadcast( + if (effectiveDTag != null) { + CalendarEvent.build( + title = parsedTitle, + content = parsedDescription, + dTag = effectiveDTag, + ) { + if (selected.isNotEmpty()) aTags(selected.map { ATag(it) }) + } + } else { + CalendarEvent.build( + title = parsedTitle, + content = parsedDescription, + ) { + if (selected.isNotEmpty()) aTags(selected.map { ATag(it) }) + } + }, + ) + return true + } finally { + isPublishing.value = false + } + } + + private fun loadOwnedAppointments(): List { + val mePubKey = account.userProfile().pubkeyHex + val results = + LocalCache.notes + .filterIntoSet { _, note -> + val e = note.event + (e is CalendarTimeSlotEvent || e is CalendarDateSlotEvent) && e.pubKey == mePubKey + }.mapNotNull { note -> + when (val e = note.event) { + is CalendarTimeSlotEvent -> + OwnedAppointmentSummary( + address = e.address(), + title = e.title().orEmpty().ifBlank { "(untitled)" }, + startSeconds = e.start(), + isAllDay = false, + ) + is CalendarDateSlotEvent -> + OwnedAppointmentSummary( + address = e.address(), + title = e.title().orEmpty().ifBlank { "(untitled)" }, + // Date-only events don't have an instant; null sorts last in the + // upcoming-first comparator below. + startSeconds = null, + isAllDay = true, + ) + else -> null + } + } + // Upcoming events first (closest start), then date-only/past — same intent as the + // main feed's UpcomingFirst ordering, simplified for the picker context. + val now = TimeUtils.now() + return results.sortedWith( + compareBy( + { if (it.startSeconds == null || it.startSeconds >= now) 0 else 1 }, + { it.startSeconds ?: Long.MAX_VALUE }, + ), + ) + } +} diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index ba8b05f482..1b797956bc 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1922,6 +1922,9 @@ New Picture New Calendar Event New Calendar + Edit Calendar + Events in this calendar (%1$d) + You haven\'t created any calendar events yet. Feed Month