From e901364e87bcb1008d8a4ad25f931e2edbb565ce Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 19 May 2026 16:15:33 +0000 Subject: [PATCH] feat(calendars): RSVP dedupe + collection editing with event picker RSVP (item 4): - CalendarRsvpRow now uses a deterministic d-tag of the form 'rsvp:::' 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 --- .../amethyst/ui/navigation/AppNavigation.kt | 2 +- .../amethyst/ui/note/types/CalendarRsvpRow.kt | 124 +++++++++++--- .../create/NewCalendarCollectionScreen.kt | 134 +++++++++++---- .../create/NewCalendarCollectionViewModel.kt | 162 ++++++++++++++++++ amethyst/src/main/res/values/strings.xml | 3 + 5 files changed, 373 insertions(+), 52 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/create/NewCalendarCollectionViewModel.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 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