feat(calendars): edit existing appointments

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
This commit is contained in:
Claude
2026-05-19 21:58:32 +00:00
parent 390d750638
commit 18512e11ff
7 changed files with 310 additions and 29 deletions
@@ -263,6 +263,9 @@ fun BuildNavigation(
CalendarEventDetailScreen(it.kind, it.pubKeyHex, it.dTag, accountViewModel, nav)
}
composableFromBottomArgs<Route.NewCalendarEvent> { NewCalendarEventScreen(nav, accountViewModel) }
composableFromBottomArgs<Route.EditCalendarEvent> {
NewCalendarEventScreen(nav, accountViewModel, editKind = it.kind, editPubKeyHex = it.pubKeyHex, editDTag = it.dTag)
}
composableFromBottomArgs<Route.NewCalendarCollection> { NewCalendarCollectionScreen(nav, accountViewModel, it.dTag) }
composableFromEnd<Route.Products> { ProductsScreen(accountViewModel, nav) }
composableFromEnd<Route.Shorts> { ShortsScreen(accountViewModel, nav) }
@@ -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,
@@ -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,
)
}
}
@@ -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)
}
},
)
}
@@ -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 ->
+3 -1
View File
@@ -1921,6 +1921,8 @@
<string name="new_regular_poll">New Regular Poll</string>
<string name="new_picture">New Picture</string>
<string name="new_calendar_event">New Calendar Event</string>
<string name="edit_calendar_event">Edit Calendar Event</string>
<string name="calendar_event_all_day_locked">Locked while editing — changing this would create a new event instead.</string>
<string name="new_calendar_collection">New Calendar</string>
<string name="edit_calendar_collection">Edit Calendar</string>
<string name="calendar_collection_events_section">Events in this calendar (%1$d)</string>
@@ -1959,7 +1961,7 @@
<string name="calendar_untitled">(untitled)</string>
<string name="calendar_all_day">All-day</string>
<string name="calendar_rsvp_going_prefixed">✓ Going</string>
<string name="calendar_rsvp_maybe_prefixed">? Maybe</string>
<string name="calendar_rsvp_maybe_prefixed">\? Maybe</string>
<string name="calendar_rsvp_not_going_prefixed">✗ Can\'t go</string>
<string name="calendar_collection_title">Title</string>
@@ -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<String>(), event.hashtags())
}
}