mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-08 23:54:39 +00:00
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:
@@ -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,
|
||||
|
||||
+25
-6
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+113
-22
@@ -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)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
+24
@@ -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 ->
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user