From 18512e11ffa0549cddf9612111275c5ac71012ab Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 19 May 2026 17:48:06 +0000 Subject: [PATCH] feat(calendars): edit existing appointments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an edit flow for kind-31922/31923 appointments authored by the current account. The detail screen surfaces a pencil icon in the top bar when isOwnEvent is true; tapping it navigates to the new Route.EditCalendarEvent(kind, pubKeyHex, dTag), which routes to the existing NewCalendarEventScreen in edit mode. ViewModel: - NewCalendarEventViewModel.loadForEdit() pre-populates all fields (title, summary, location, image, hashtags, start/end seconds) from the cached event. It's idempotent across recompositions and a no-op if the address isn't in LocalCache yet. - publish() now preserves the addressable's d-tag and kind in edit mode so the broadcast replaces the original rather than minting a new event. - The all-day toggle is disabled while editing — switching kinds mid- edit would leave a stale event under the original kind/d-tag combination. The UI shows an explanatory subtitle. Also escapes the leading `?` in calendar_rsvp_maybe_prefixed (aapt was parsing it as a theme-attribute reference and refusing to link). Tests: - New CalendarEditLoadTest covers the round-trip parsing from packed tags back to the fields the VM reads — time-slot, date-slot, and an empty-optional-tags variant. https://claude.ai/code/session_01CbyrA2GdM4EQh8T6nsst6U --- .../amethyst/ui/navigation/AppNavigation.kt | 3 + .../amethyst/ui/navigation/routes/Routes.kt | 13 ++ .../create/NewCalendarEventScreen.kt | 31 +++- .../create/NewCalendarEventViewModel.kt | 135 +++++++++++++++--- .../detail/CalendarEventDetailScreen.kt | 24 ++++ amethyst/src/main/res/values/strings.xml | 4 +- .../amethyst/calendar/CalendarEditLoadTest.kt | 129 +++++++++++++++++ 7 files changed, 310 insertions(+), 29 deletions(-) create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/calendar/CalendarEditLoadTest.kt 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 f977e984ca..4d2adba44d 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 @@ -263,6 +263,9 @@ fun BuildNavigation( CalendarEventDetailScreen(it.kind, it.pubKeyHex, it.dTag, accountViewModel, nav) } composableFromBottomArgs { NewCalendarEventScreen(nav, accountViewModel) } + composableFromBottomArgs { + NewCalendarEventScreen(nav, accountViewModel, editKind = it.kind, editPubKeyHex = it.pubKeyHex, editDTag = it.dTag) + } composableFromBottomArgs { NewCalendarCollectionScreen(nav, accountViewModel, it.dTag) } composableFromEnd { ProductsScreen(accountViewModel, nav) } composableFromEnd { ShortsScreen(accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index dc6ea968ff..752ad062bd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -90,6 +90,19 @@ sealed class Route { val draft: String? = null, ) : Route() + @Serializable + data class EditCalendarEvent( + val kind: Int, + val pubKeyHex: HexKey, + val dTag: String, + ) : Route() { + constructor(address: Address) : this( + kind = address.kind, + pubKeyHex = address.pubKeyHex, + dTag = address.dTag, + ) + } + @Serializable data class NewCalendarCollection( val dTag: String? = null, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/create/NewCalendarEventScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/create/NewCalendarEventScreen.kt index 4286f7b244..73ab3e89e5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/create/NewCalendarEventScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/create/NewCalendarEventScreen.kt @@ -57,14 +57,21 @@ import com.vitorpamplona.amethyst.ui.stringRes fun NewCalendarEventScreen( nav: INav, accountViewModel: AccountViewModel, + editKind: Int? = null, + editPubKeyHex: String? = null, + editDTag: String? = null, ) { val vm: NewCalendarEventViewModel = viewModel() vm.init(accountViewModel) + if (editKind != null && editPubKeyHex != null && editDTag != null) { + // loadForEdit is idempotent across recompositions; safe to call from the composable body. + vm.loadForEdit(accountViewModel, editKind, editPubKeyHex, editDTag) + } Scaffold( topBar = { SavingTopBar( - titleRes = R.string.new_calendar_event, + titleRes = if (vm.isEditing) R.string.edit_calendar_event else R.string.new_calendar_event, onCancel = { nav.popBack() }, onPost = { accountViewModel.launchSigner { @@ -175,14 +182,26 @@ private fun AllDayToggleRow(vm: NewCalendarEventViewModel) { modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, ) { - Text( - text = stringRes(R.string.calendar_event_all_day), - style = MaterialTheme.typography.titleSmall, - modifier = Modifier.weight(1f), - ) + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringRes(R.string.calendar_event_all_day), + style = MaterialTheme.typography.titleSmall, + ) + if (vm.isEditing) { + // Toggling all-day mid-edit would mean a different event kind (31922 vs 31923) + // and a different addressable, leaving the original event live as a stale copy. + // The user can delete the appointment and re-create if they want to change kind. + Text( + text = stringRes(R.string.calendar_event_all_day_locked), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } Switch( checked = vm.isAllDay.value, onCheckedChange = { vm.isAllDay.value = it }, + enabled = !vm.isEditing, ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/create/NewCalendarEventViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/create/NewCalendarEventViewModel.kt index 6853e65128..de22b67cdf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/create/NewCalendarEventViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/create/NewCalendarEventViewModel.kt @@ -23,7 +23,10 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.create 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.amethyst.ui.screen.loggedIn.calendars.dal.parseIsoDateToUnixSeconds +import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent @@ -54,10 +57,65 @@ class NewCalendarEventViewModel : ViewModel() { val isPublishing = mutableStateOf(false) + /** + * When non-null, [publish] preserves this d-tag and kind so the broadcast replaces an + * existing addressable appointment instead of minting a new one. The UI also locks the + * all-day toggle in edit mode — switching kinds mid-edit would leave a stale event under + * the original kind/d-tag combination. + */ + private var editAddress: Address? = null + + val isEditing: Boolean + get() = editAddress != null + fun init(accountViewModel: AccountViewModel) { + if (::account.isInitialized) return this.account = accountViewModel.account } + /** + * Pre-populate from an existing appointment for edit mode. Idempotent: a recomposition that + * calls this again is a no-op. Only the author of the appointment should reach this path — + * the screen guards via UI affordance, but [publish] will also produce an unsigned event if + * the current account doesn't own the address. + */ + fun loadForEdit( + accountViewModel: AccountViewModel, + kind: Int, + pubKeyHex: String, + dTag: String, + ) { + init(accountViewModel) + if (editAddress != null) return // already loaded + + val address = Address(kind, pubKeyHex, dTag) + val existing = LocalCache.addressables.get(address)?.event ?: return + editAddress = address + + when (existing) { + is CalendarTimeSlotEvent -> { + isAllDay.value = false + title.value = existing.title().orEmpty() + summary.value = existing.summary().orEmpty().ifBlank { existing.content } + location.value = existing.location().orEmpty() + imageUrl.value = existing.image().orEmpty() + hashtags.value = existing.hashtags().joinToString(", ") + startSeconds.value = existing.start() ?: 0L + endSeconds.value = existing.end() ?: 0L + } + is CalendarDateSlotEvent -> { + isAllDay.value = true + title.value = existing.title().orEmpty() + summary.value = existing.summary().orEmpty().ifBlank { existing.content } + location.value = existing.location().orEmpty() + imageUrl.value = existing.image().orEmpty() + hashtags.value = existing.hashtags().joinToString(", ") + startSeconds.value = parseIsoDateToUnixSeconds(existing.start()) ?: 0L + endSeconds.value = parseIsoDateToUnixSeconds(existing.end()) ?: 0L + } + } + } + fun isValid(): Boolean = title.value.isNotBlank() && startSeconds.value > 0L fun isEndAfterStart(): Boolean = endSeconds.value == 0L || endSeconds.value >= startSeconds.value @@ -75,35 +133,68 @@ class NewCalendarEventViewModel : ViewModel() { val parsedImage = imageUrl.value.trim().takeIf { it.isNotBlank() } val parsedLocation = location.value.trim().takeIf { it.isNotBlank() } val tzId = TimeZone.getDefault().id + val targetDTag = editAddress?.dTag if (isAllDay.value) { account.signAndComputeBroadcast( - CalendarDateSlotEvent.build( - title = title.value.trim(), - start = toIsoDate(startSeconds.value), - end = endSeconds.value.takeIf { it > 0L }?.let { toIsoDate(it) }, - content = parsedSummary.orEmpty(), - ) { - parsedSummary?.let { daySummary(it) } - parsedImage?.let { dayImage(it) } - parsedLocation?.let { dayLocations(listOf(it)) } - if (parsedHashtags.isNotEmpty()) hashtags(parsedHashtags) + if (targetDTag != null) { + CalendarDateSlotEvent.build( + title = title.value.trim(), + start = toIsoDate(startSeconds.value), + end = endSeconds.value.takeIf { it > 0L }?.let { toIsoDate(it) }, + content = parsedSummary.orEmpty(), + dTag = targetDTag, + ) { + parsedSummary?.let { daySummary(it) } + parsedImage?.let { dayImage(it) } + parsedLocation?.let { dayLocations(listOf(it)) } + if (parsedHashtags.isNotEmpty()) hashtags(parsedHashtags) + } + } else { + CalendarDateSlotEvent.build( + title = title.value.trim(), + start = toIsoDate(startSeconds.value), + end = endSeconds.value.takeIf { it > 0L }?.let { toIsoDate(it) }, + content = parsedSummary.orEmpty(), + ) { + parsedSummary?.let { daySummary(it) } + parsedImage?.let { dayImage(it) } + parsedLocation?.let { dayLocations(listOf(it)) } + if (parsedHashtags.isNotEmpty()) hashtags(parsedHashtags) + } }, ) } else { account.signAndComputeBroadcast( - CalendarTimeSlotEvent.build( - title = title.value.trim(), - start = startSeconds.value, - end = endSeconds.value.takeIf { it > 0L }, - startTzId = tzId, - endTzId = tzId, - content = parsedSummary.orEmpty(), - ) { - parsedSummary?.let { timeSummary(it) } - parsedImage?.let { timeImage(it) } - parsedLocation?.let { timeLocations(listOf(it)) } - if (parsedHashtags.isNotEmpty()) hashtags(parsedHashtags) + if (targetDTag != null) { + CalendarTimeSlotEvent.build( + title = title.value.trim(), + start = startSeconds.value, + end = endSeconds.value.takeIf { it > 0L }, + startTzId = tzId, + endTzId = tzId, + content = parsedSummary.orEmpty(), + dTag = targetDTag, + ) { + parsedSummary?.let { timeSummary(it) } + parsedImage?.let { timeImage(it) } + parsedLocation?.let { timeLocations(listOf(it)) } + if (parsedHashtags.isNotEmpty()) hashtags(parsedHashtags) + } + } else { + CalendarTimeSlotEvent.build( + title = title.value.trim(), + start = startSeconds.value, + end = endSeconds.value.takeIf { it > 0L }, + startTzId = tzId, + endTzId = tzId, + content = parsedSummary.orEmpty(), + ) { + parsedSummary?.let { timeSummary(it) } + parsedImage?.let { timeImage(it) } + parsedLocation?.let { timeLocations(listOf(it)) } + if (parsedHashtags.isNotEmpty()) hashtags(parsedHashtags) + } }, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/detail/CalendarEventDetailScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/detail/CalendarEventDetailScreen.kt index 8da19947a3..62228938a0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/detail/CalendarEventDetailScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/detail/CalendarEventDetailScreen.kt @@ -106,6 +106,8 @@ fun CalendarEventDetailScreen( .collectAsStateWithLifecycle() val event = noteState.note.event + val isOwnEvent = event?.pubKey == accountViewModel.userProfile().pubkeyHex + Scaffold( topBar = { TopAppBar( @@ -125,6 +127,28 @@ fun CalendarEventDetailScreen( ) } }, + actions = { + // The Edit affordance is only meaningful when the current account is the + // author — relays will reject a signed-by-stranger replacement. + if (isOwnEvent && event != null) { + IconButton(onClick = { + nav.nav( + Route.EditCalendarEvent( + kind = event.kind, + pubKeyHex = event.pubKey, + dTag = targetAddress.dTag, + ), + ) + }) { + Icon( + symbol = MaterialSymbols.Edit, + contentDescription = stringRes(R.string.edit_calendar_event), + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onSurface, + ) + } + } + }, ) }, ) { pad -> diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 4f28a92498..64fa1398c7 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1921,6 +1921,8 @@ New Regular Poll New Picture New Calendar Event + Edit Calendar Event + Locked while editing — changing this would create a new event instead. New Calendar Edit Calendar Events in this calendar (%1$d) @@ -1959,7 +1961,7 @@ (untitled) All-day ✓ Going - ? Maybe + \? Maybe ✗ Can\'t go Title diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/calendar/CalendarEditLoadTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/calendar/CalendarEditLoadTest.kt new file mode 100644 index 0000000000..32ffdb4dde --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/calendar/CalendarEditLoadTest.kt @@ -0,0 +1,129 @@ +/* + * 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.calendar + +import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags +import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent +import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * Smoke tests for the parsing path used by [NewCalendarEventViewModel.loadForEdit]. The VM + * itself can't be instantiated in a JVM test without the Account graph, but the per-field + * extraction logic delegates to Quartz accessors that are pure functions on the parsed event — + * exercising those here proves the round-trip from on-the-wire tags back to populated form + * fields. + */ +class CalendarEditLoadTest { + @Test + fun timeSlot_roundTripsAllFields() { + val event = + CalendarTimeSlotEvent( + id = "id", + pubKey = "pub", + createdAt = 0L, + tags = + arrayOf( + arrayOf("d", "d-tag"), + arrayOf("title", "Bitcoin meetup"), + arrayOf("start", "1775671200"), + arrayOf("end", "1775674800"), + arrayOf("start_tzid", "Europe/Oslo"), + arrayOf("summary", "An evening of stacking"), + arrayOf("image", "https://example.com/img.png"), + arrayOf("location", "Storgata 8"), + arrayOf("t", "bitcoin"), + arrayOf("t", "meetup"), + ), + content = "Body", + sig = "sig", + ) + + assertEquals("Bitcoin meetup", event.title()) + assertEquals(1775671200L, event.start()) + assertEquals(1775674800L, event.end()) + assertEquals("Europe/Oslo", event.startTzId()) + assertEquals("An evening of stacking", event.summary()) + assertEquals("https://example.com/img.png", event.image()) + assertEquals("Storgata 8", event.location()) + assertEquals(listOf("bitcoin", "meetup"), event.hashtags()) + } + + @Test + fun dateSlot_roundTripsAllFields() { + val event = + CalendarDateSlotEvent( + id = "id", + pubKey = "pub", + createdAt = 0L, + tags = + arrayOf( + arrayOf("d", "d-tag"), + arrayOf("title", "Conference"), + arrayOf("start", "2025-01-15"), + arrayOf("end", "2025-01-17"), + arrayOf("summary", "Three day affair"), + arrayOf("image", "https://example.com/banner.png"), + arrayOf("location", "Lisbon"), + arrayOf("t", "tech"), + ), + content = "Body", + sig = "sig", + ) + + assertEquals("Conference", event.title()) + assertEquals("2025-01-15", event.start()) + assertEquals("2025-01-17", event.end()) + assertEquals("Three day affair", event.summary()) + assertEquals("https://example.com/banner.png", event.image()) + assertEquals("Lisbon", event.location()) + assertEquals(listOf("tech"), event.hashtags()) + } + + @Test + fun emptyFields_returnSafeDefaults() { + // An event with no optional tags should parse without exceptions; the VM substitutes + // empty-string defaults in those cases. + val event = + CalendarTimeSlotEvent( + id = "id", + pubKey = "pub", + createdAt = 0L, + tags = + arrayOf( + arrayOf("d", "d-tag"), + arrayOf("title", "Bare"), + arrayOf("start", "1000000"), + ), + content = "", + sig = "sig", + ) + + assertEquals("Bare", event.title()) + assertEquals(1000000L, event.start()) + assertEquals(null, event.end()) + assertEquals(null, event.summary()) + assertEquals(null, event.image()) + assertEquals(null, event.location()) + assertEquals(emptyList(), event.hashtags()) + } +}