mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-09 16:14:40 +00:00
feat(calendars): RSVP dedupe + collection editing with event picker
RSVP (item 4): - CalendarRsvpRow now uses a deterministic d-tag of the form 'rsvp:<kind>:<pubkey>:<dtag>' derived from the target appointment's address. Each tap replaces the user's single addressable RSVP for that event rather than appending another, eliminating the previous footgun where rapid taps spammed relays with parallel kind-31925 events. - Buttons now reflect the current RSVP status reactively: the matching status renders as filled-tonal with the status colour; the others render outlined. Reads `LocalCache.getOrCreateAddressableNote()` and observes the metadata flow so the row updates as soon as the new event lands in cache (own broadcast or relay echo). Collections (item 5): - Lifted NewCalendarCollectionScreen onto NewCalendarCollectionViewModel with title/description/selected-event state. Editing mode pre-populates from an existing kind-31924 by dTag (already supported by Route.NewCalendarCollection but previously unused), preserves the d-tag so the publish replaces the addressable, and seeds the selected-event list from the existing `a` tags. - Added a multi-select picker that lists the user's own appointments (kinds 31922/31923 authored by `account.userProfile()`), sorted upcoming-first. Toggling a row adds or removes its address from the outgoing `a` tag list. - Wired Route.NewCalendarCollection.dTag through to the screen so the edit flow becomes reachable from anywhere that has the calendar's dTag (the event-detail screen will plug into this in a follow-up). https://claude.ai/code/session_01CbyrA2GdM4EQh8T6nsst6U
This commit is contained in:
@@ -259,7 +259,7 @@ fun BuildNavigation(
|
||||
composableFromEnd<Route.Calendars> { CalendarsScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.CalendarCollections> { CalendarCollectionsScreen(accountViewModel, nav) }
|
||||
composableFromBottomArgs<Route.NewCalendarEvent> { NewCalendarEventScreen(nav, accountViewModel) }
|
||||
composableFromBottomArgs<Route.NewCalendarCollection> { NewCalendarCollectionScreen(nav, accountViewModel) }
|
||||
composableFromBottomArgs<Route.NewCalendarCollection> { NewCalendarCollectionScreen(nav, accountViewModel, it.dTag) }
|
||||
composableFromEnd<Route.Products> { ProductsScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.Shorts> { ShortsScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.PublicChats> { PublicChatsScreen(accountViewModel, nav) }
|
||||
|
||||
+102
-22
@@ -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:<pubkey>:<dtag>`).
|
||||
*/
|
||||
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,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
+105
-29
@@ -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<String?>(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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+162
@@ -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<Address>()
|
||||
val availableAppointments = mutableStateOf<List<OwnedAppointmentSummary>>(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<OwnedAppointmentSummary> {
|
||||
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 },
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1922,6 +1922,9 @@
|
||||
<string name="new_picture">New Picture</string>
|
||||
<string name="new_calendar_event">New Calendar Event</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>
|
||||
<string name="calendar_collection_no_events_yet">You haven\'t created any calendar events yet.</string>
|
||||
|
||||
<string name="calendar_view_feed">Feed</string>
|
||||
<string name="calendar_view_month">Month</string>
|
||||
|
||||
Reference in New Issue
Block a user