Merge pull request #2994 from vitorpamplona/claude/nip52-calendar-screens-yQc3j

Add NIP-52 calendar events support with UI and reminder system
This commit is contained in:
Vitor Pamplona
2026-05-19 18:30:59 -04:00
committed by GitHub
81 changed files with 9246 additions and 2 deletions
@@ -721,6 +721,12 @@ class AppModules(
ScheduledPostWorker.schedule(appContext)
ScheduledPostWorker.scheduleCatchUp(appContext)
// Periodic scan that posts "starting soon" notifications for NIP-52 appointments the
// user has RSVP'd to as ACCEPTED. 15-minute cadence matches both the WorkManager
// periodic minimum and the lead-time window.
com.vitorpamplona.amethyst.service.calendar.CalendarReminderWorker
.schedule(appContext)
// Watch for account login and start/stop always-on notification service
applicationIOScope.launch {
sessionManager.accountContent.collectLatest { state ->
@@ -104,6 +104,7 @@ private object PrefKeys {
const val DEFAULT_DISCOVERY_FOLLOW_LIST = "defaultDiscoveryFollowList"
const val DEFAULT_POLLS_FOLLOW_LIST = "defaultPollsFollowList"
const val DEFAULT_PICTURES_FOLLOW_LIST = "defaultPicturesFollowList"
const val DEFAULT_CALENDARS_FOLLOW_LIST = "defaultCalendarsFollowList"
const val DEFAULT_PRODUCTS_FOLLOW_LIST = "defaultProductsFollowList"
const val DEFAULT_SHORTS_FOLLOW_LIST = "defaultShortsFollowList"
const val DEFAULT_PUBLIC_CHATS_FOLLOW_LIST = "defaultPublicChatsFollowList"
@@ -361,6 +362,7 @@ object LocalPreferences {
putString(PrefKeys.DEFAULT_POLLS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultPollsFollowList.value))
putString(PrefKeys.DEFAULT_PICTURES_FOLLOW_LIST, JsonMapper.toJson(settings.defaultPicturesFollowList.value))
putString(PrefKeys.DEFAULT_CALENDARS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultCalendarsFollowList.value))
putString(PrefKeys.DEFAULT_PRODUCTS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultProductsFollowList.value))
putString(PrefKeys.DEFAULT_SHORTS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultShortsFollowList.value))
putString(PrefKeys.DEFAULT_PUBLIC_CHATS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultPublicChatsFollowList.value))
@@ -641,6 +643,7 @@ object LocalPreferences {
defaultDiscoveryFollowList = MutableStateFlow(followListPrefs.discovery),
defaultPollsFollowList = MutableStateFlow(followListPrefs.polls),
defaultPicturesFollowList = MutableStateFlow(followListPrefs.pictures),
defaultCalendarsFollowList = MutableStateFlow(followListPrefs.calendars),
defaultProductsFollowList = MutableStateFlow(followListPrefs.products),
defaultShortsFollowList = MutableStateFlow(followListPrefs.shorts),
defaultPublicChatsFollowList = MutableStateFlow(followListPrefs.publicChats),
@@ -712,6 +715,7 @@ object LocalPreferences {
val discovery: TopFilter,
val polls: TopFilter,
val pictures: TopFilter,
val calendars: TopFilter,
val products: TopFilter,
val shorts: TopFilter,
val publicChats: TopFilter,
@@ -733,6 +737,7 @@ object LocalPreferences {
discovery = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_DISCOVERY_FOLLOW_LIST, null), TopFilter.Global),
polls = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_POLLS_FOLLOW_LIST, null), TopFilter.Global),
pictures = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_PICTURES_FOLLOW_LIST, null), TopFilter.Global),
calendars = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_CALENDARS_FOLLOW_LIST, null), TopFilter.Global),
products = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_PRODUCTS_FOLLOW_LIST, null), TopFilter.AroundMe),
shorts = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_SHORTS_FOLLOW_LIST, null), TopFilter.Global),
publicChats = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_PUBLIC_CHATS_FOLLOW_LIST, null), TopFilter.Global),
@@ -489,6 +489,9 @@ class Account(
val livePicturesFollowLists: StateFlow<IFeedTopNavFilter> = topNavFilterFlow(settings.defaultPicturesFollowList)
val livePicturesFollowListsPerRelay = OutboxLoaderState(livePicturesFollowLists, cache, scope).flow
val liveCalendarsFollowLists: StateFlow<IFeedTopNavFilter> = topNavFilterFlow(settings.defaultCalendarsFollowList)
val liveCalendarsFollowListsPerRelay = OutboxLoaderState(liveCalendarsFollowLists, cache, scope).flow
val liveProductsFollowLists: StateFlow<IFeedTopNavFilter> = topNavFilterFlow(settings.defaultProductsFollowList)
val liveProductsFollowListsPerRelay = OutboxLoaderState(liveProductsFollowLists, cache, scope).flow
@@ -163,6 +163,7 @@ class AccountSettings(
val defaultDiscoveryFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val defaultPollsFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val defaultPicturesFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val defaultCalendarsFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val defaultProductsFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.AroundMe),
val defaultShortsFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val defaultPublicChatsFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
@@ -529,6 +530,17 @@ class AccountSettings(
}
}
fun changeDefaultCalendarsFollowList(name: FeedDefinition) {
changeDefaultCalendarsFollowList(name.code)
}
fun changeDefaultCalendarsFollowList(name: TopFilter) {
if (defaultCalendarsFollowList.value != name) {
defaultCalendarsFollowList.tryEmit(name)
saveAccountSettings()
}
}
fun changeDefaultProductsFollowList(name: FeedDefinition) {
changeDefaultProductsFollowList(name.code)
}
@@ -0,0 +1,119 @@
/*
* 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.service.calendar
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.MainActivity
import com.vitorpamplona.amethyst.ui.stringRes
/**
* Posts user-visible "starting soon" notifications for NIP-52 appointments the user has RSVP'd
* to as ACCEPTED. Mirrors the shape of [com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostNotifier]
* so the two notification surfaces stay consistent.
*/
object CalendarReminderNotifier {
@Volatile
private var channel: NotificationChannel? = null
private const val REMINDER_NOT_ID_BASE = 0x80000
/**
* @param eventId the appointment's event id used to derive a stable notification id so a
* second reminder for the same event collapses rather than stacking.
* @param title the appointment title (or a fallback string).
* @param body a short pre-formatted body, e.g. "Starts in 15 minutes".
* @param deepLink a `nostr:naddr` URI for the calendar event. Tapping the notification
* hands this to MainActivity, which routes it via `uriToRoute` the
* calendar detail screen (CalendarTimeSlotEvent / CalendarDateSlotEvent
* branches in RouteMaker resolve to Route.CalendarEventDetail).
*/
fun notifyReminder(
context: Context,
eventId: String,
title: String,
body: String,
deepLink: String,
) {
ensureChannel(context)
val notId = idFor(eventId)
val channelId = stringRes(context, R.string.calendar_reminder_channel_id)
val tapIntent =
Intent(context, MainActivity::class.java).apply {
action = Intent.ACTION_VIEW
data = android.net.Uri.parse(deepLink)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
}
val tapPendingIntent =
PendingIntent.getActivity(
context,
notId,
tapIntent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
val nm = NotificationManagerCompat.from(context)
// POST_NOTIFICATIONS is runtime-granted on Android 13+; bail when denied so the lint
// call below doesn't flag and we don't log a misleading no-op.
if (!nm.areNotificationsEnabled()) return
val notification =
NotificationCompat
.Builder(context, channelId)
.setSmallIcon(R.drawable.amethyst)
.setContentTitle(title)
.setContentText(body)
.setStyle(NotificationCompat.BigTextStyle().bigText(body))
.setContentIntent(tapPendingIntent)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setCategory(NotificationCompat.CATEGORY_EVENT)
.setAutoCancel(true)
.setWhen(System.currentTimeMillis())
.build()
try {
nm.notify(notId, notification)
} catch (_: SecurityException) {
// Race: permission revoked between the check above and notify().
}
}
fun ensureChannel(context: Context) {
if (channel != null) return
channel =
NotificationChannel(
stringRes(context, R.string.calendar_reminder_channel_id),
stringRes(context, R.string.calendar_reminder_channel_name),
NotificationManager.IMPORTANCE_DEFAULT,
).apply {
description = stringRes(context, R.string.calendar_reminder_channel_description)
}
val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
nm.createNotificationChannel(channel!!)
}
// Distinct id per event so two reminders for the same event collapse onto one row while
// separate events render side by side.
private fun idFor(eventId: String): Int = REMINDER_NOT_ID_BASE xor eventId.hashCode()
}
@@ -0,0 +1,65 @@
/*
* 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.service.calendar
import android.content.Context
import android.content.SharedPreferences
/**
* Device-wide preferences for the calendar reminder worker.
*
* Stored at device scope (rather than per-account) because the worker that consults them runs
* globally multiplexing per-account preferences would require account-context plumbing into
* WorkManager that the rest of the app doesn't have. A user who flips between two accounts on
* the same device shares the same lead-time and enabled-state. Per-account preferences could be
* a follow-up if anyone asks.
*/
class CalendarReminderPrefs(
context: Context,
) {
private val prefs: SharedPreferences = context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE)
fun isEnabled(): Boolean = prefs.getBoolean(KEY_ENABLED, DEFAULT_ENABLED)
fun setEnabled(enabled: Boolean) {
prefs.edit().putBoolean(KEY_ENABLED, enabled).apply()
}
fun leadMinutes(): Int = prefs.getInt(KEY_LEAD_MINUTES, DEFAULT_LEAD_MINUTES)
fun setLeadMinutes(minutes: Int) {
prefs.edit().putInt(KEY_LEAD_MINUTES, minutes).apply()
}
companion object {
const val DEFAULT_LEAD_MINUTES = 15
const val DEFAULT_ENABLED = true
// Choices presented in the settings UI. Anchored to the worker cadence — lead times
// smaller than the cadence (15 min) can't be honoured reliably; 60 is the largest the
// UX shape supports without an extra "hours" picker.
val LEAD_TIME_CHOICES = listOf(5, 15, 30, 60)
private const val PREF_NAME = "amethyst_calendar_reminder_prefs"
private const val KEY_ENABLED = "enabled"
private const val KEY_LEAD_MINUTES = "lead_minutes"
}
}
@@ -0,0 +1,83 @@
/*
* 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.service.calendar
import android.content.Context
import android.content.SharedPreferences
/**
* Persistent "I've already notified for this event" set. Backed by [SharedPreferences] because
* the worker that consults it runs in the app process and the dataset is tiny ( a few hundred
* IDs at most). Without persistence, every worker run after a restart would re-notify for the
* same upcoming event until it started, since LocalCache has no memory of past reminders.
*
* Keys are event ids (the 32-byte hex from a 31922/31923 appointment). Values aren't used; only
* presence in the set matters. Entries are pruned by [forgetBefore] when the worker has just
* fired so the store doesn't grow unbounded over time.
*/
class CalendarReminderStore(
context: Context,
) {
private val prefs: SharedPreferences =
context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE)
/**
* Returns true when we've previously notified for this exact event-start pairing. If the
* author updates the appointment to a new start time, the stored value won't match and
* we'll fire a fresh reminder for the new time that's the desired behaviour: a moved
* meeting shouldn't be silently skipped.
*/
fun wasNotified(
eventId: String,
eventStartSeconds: Long,
): Boolean = prefs.getLong(keyFor(eventId), Long.MIN_VALUE) == eventStartSeconds
fun markNotified(
eventId: String,
eventStartSeconds: Long,
) {
prefs.edit().putLong(keyFor(eventId), eventStartSeconds).apply()
}
/**
* Drops any entry whose recorded event-start time is older than [cutoffSeconds]. Called
* after each worker run so the store stays bounded events that have long since ended
* can't fire a second reminder, so their entries are dead weight.
*/
fun forgetBefore(cutoffSeconds: Long) {
val editor = prefs.edit()
var changed = false
prefs.all.forEach { (key, value) ->
if (value is Long && value < cutoffSeconds) {
editor.remove(key)
changed = true
}
}
if (changed) editor.apply()
}
companion object {
private const val PREF_NAME = "amethyst_calendar_reminders"
private const val KEY_PREFIX = "notified:"
private fun keyFor(eventId: String) = KEY_PREFIX + eventId
}
}
@@ -0,0 +1,138 @@
/*
* 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.service.calendar
import android.content.Context
import androidx.work.CoroutineWorker
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.nip52Calendar.appointmentView
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent
import com.vitorpamplona.quartz.nip52Calendar.appt.tags.RSVPStatusTag
import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent
import com.vitorpamplona.quartz.nip52Calendar.rsvp.CalendarRSVPEvent
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.TimeUtils
import java.util.concurrent.TimeUnit
/**
* Periodic scan that posts "starting soon" notifications for appointments the user has RSVP'd
* to as ACCEPTED.
*
* The work is bounded scans LocalCache (which is bounded by the relay subscription) and
* consults [CalendarReminderStore] to skip events that have already been notified for. Run as
* a 15-minute periodic worker: that's the WorkManager minimum and matches the resolution of
* the reminder UI ("starts in ~15 min" is the smallest interval users perceive as "soon").
*/
class CalendarReminderWorker(
appContext: Context,
params: WorkerParameters,
) : CoroutineWorker(appContext, params) {
override suspend fun doWork(): Result {
val prefs = CalendarReminderPrefs(applicationContext)
if (!prefs.isEnabled()) {
Log.d(TAG) { "Reminders disabled; skipping scan." }
return Result.success()
}
val now = TimeUtils.now()
val windowEnd = now + prefs.leadMinutes() * 60L
val store = CalendarReminderStore(applicationContext)
// Walk every kind-31925 RSVP authored by an account on this device. We don't have a
// multi-account "all logged-in pubkeys" view here, so we accept any RSVP that's
// present in cache — the alternative (looking only at the foreground account) would
// silently break notifications for account switching during the lead window.
val acceptedRsvps =
LocalCache.addressables
.filterIntoSet { _, note ->
val e = note.event
e is CalendarRSVPEvent && e.status() == RSVPStatusTag.STATUS.ACCEPTED
}.mapNotNull { it.event as? CalendarRSVPEvent }
Log.d(TAG) { "Worker scanning ${acceptedRsvps.size} accepted RSVPs (now=$now, lead=${prefs.leadMinutes()}m)" }
acceptedRsvps.forEach { rsvp ->
val targetAddress = rsvp.calendarEventAddress() ?: return@forEach
val targetNote = LocalCache.addressables.get(targetAddress) ?: return@forEach
val view = targetNote.appointmentView() ?: return@forEach
val start = view.startSeconds ?: return@forEach
val eventId =
(targetNote.event as? CalendarTimeSlotEvent)?.id
?: (targetNote.event as? CalendarDateSlotEvent)?.id
?: return@forEach
if (start !in now..windowEnd) return@forEach
// Keyed on (eventId, start) so a moved appointment re-fires when the new start
// enters the lead window — the old notification stays valid in the system tray.
if (store.wasNotified(eventId, start)) return@forEach
val title = view.title ?: stringRes(applicationContext, R.string.calendar_reminder_default_title)
val minutesAway = ((start - now).coerceAtLeast(0L) / 60L).toInt()
val body =
stringRes(
applicationContext,
R.string.calendar_reminder_body,
minutesAway,
)
val deepLink =
"nostr:" +
com.vitorpamplona.quartz.nip19Bech32.entities.NAddress
.create(targetAddress.kind, targetAddress.pubKeyHex, targetAddress.dTag, null)
CalendarReminderNotifier.notifyReminder(applicationContext, eventId, title, body, deepLink)
store.markNotified(eventId, start)
Log.d(TAG) { "Notified $eventId (starts in ${minutesAway}m)" }
}
// Prune entries for events that ended more than a day ago — they can't fire again.
store.forgetBefore(now - PRUNE_AGE_SECONDS)
return Result.success()
}
companion object {
private const val TAG = "CalendarReminderWorker"
private const val WORK_NAME = "calendar_reminder_worker"
// Don't bother remembering "I notified for this" entries for events whose start was
// more than a day ago; they can't fire again so the entry is pure overhead.
private const val PRUNE_AGE_SECONDS = 24L * 60L * 60L
fun schedule(context: Context) {
val request =
PeriodicWorkRequestBuilder<CalendarReminderWorker>(15, TimeUnit.MINUTES)
.build()
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
WORK_NAME,
ExistingPeriodicWorkPolicy.KEEP,
request,
)
Log.d(TAG) { "schedule(): enqueueUniquePeriodicWork($WORK_NAME, 15 MIN, KEEP)" }
}
fun cancel(context: Context) {
WorkManager.getInstance(context).cancelUniqueWork(WORK_NAME)
}
}
}
@@ -55,6 +55,17 @@ abstract class PerUserAndFollowListEoseManager<T, U : Any>(
fun since(key: T) = latestEOSEs.since(user(key), list(key))
/**
* Drops the EOSE state for the (user, list) pair this key resolves to so the next subscription
* assembly will ask the relay from scratch instead of carrying over a stale `since` cursor.
* Use when the in-memory note store may have evicted previously-loaded events (e.g.
* [LargeSoftCache]'s WeakReference store under memory pressure) and a list switch needs to
* re-fetch them rather than rely on a cache that's no longer there.
*/
protected fun clearEoseFor(key: T) {
latestEOSEs.clear(user(key), list(key))
}
fun newEose(
key: T,
relay: NormalizedRelayUrl,
@@ -31,6 +31,7 @@ import com.vitorpamplona.amethyst.service.relayClient.searchCommand.SearchFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.articles.datasource.ArticlesFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.datasource.BadgesFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.profile.datasource.ProfileBadgesFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.datasource.CalendarsFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.datasource.ChatroomFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource.ChannelFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource.ChatroomListFilterAssembler
@@ -111,6 +112,7 @@ class RelaySubscriptionsCoordinator(
val polls = PollsFilterAssembler(client)
val pictures = PicturesFilterAssembler(client)
val calendars = CalendarsFilterAssembler(client)
val products = ProductsFilterAssembler(client)
val shorts = ShortsFilterAssembler(client)
val publicChats = PublicChatsFilterAssembler(client)
@@ -141,6 +143,7 @@ class RelaySubscriptionsCoordinator(
discovery,
polls,
pictures,
calendars,
products,
shorts,
publicChats,
@@ -51,6 +51,11 @@ open class EOSEByKey<U : Any>(
fun since(listCode: U) = followList[listCode]?.relayList
/** Drops the EOSE state for [listCode] so the next filter assembly starts fresh. */
fun clear(listCode: U) {
followList.remove(listCode)
}
fun newEose(
listCode: U,
relayUrl: NormalizedRelayUrl,
@@ -83,6 +88,19 @@ open class EOSEAccountKey<U : Any>(
users.remove(user)
}
/**
* Drops the EOSE state for the (user, list) pair so the next assembly will ask the relay
* from scratch. Used by feeds that need to force a re-fetch e.g. calendar / pictures
* where [LargeSoftCache]'s WeakReference store may have evicted the previously-loaded notes
* while the user was viewing a different list.
*/
fun clear(
user: User,
listCode: U,
) {
users[user]?.clear(listCode)
}
fun since(
key: User,
listCode: U,
@@ -49,6 +49,8 @@ import com.vitorpamplona.quartz.nip19Bech32.entities.NNote
import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile
import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent
import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.UriParser
import kotlinx.coroutines.CancellationException
@@ -192,7 +194,7 @@ fun uriToRoute(
routeFor(
note = LocalCache.getOrCreateAddressableNote(nip19.address()),
loggedIn = account,
) ?: Route.EventRedirect(nip19.aTag())
) ?: calendarDirectRoute(nip19) ?: Route.EventRedirect(nip19.aTag())
}
is NEmbed -> {
@@ -254,3 +256,19 @@ fun uriToRoute(
return null
}
/**
* Direct route for an `naddr` whose event hasn't arrived in [LocalCache] yet. When a notification
* is tapped (or a `nostr:naddr` deep link arrives) for a calendar appointment we know is kind
* 31922/31923, route straight to the dedicated detail screen instead of bouncing through
* [Route.EventRedirect]. The detail screen issues its own per-event subscription, so the user
* sees the calendar-specific loading placeholder while the event arrives, not the generic
* redirect screen.
*/
private fun calendarDirectRoute(nip19: NAddress): Route? =
when (nip19.kind) {
CalendarTimeSlotEvent.KIND,
CalendarDateSlotEvent.KIND,
-> Route.CalendarEventDetail(nip19.kind, nip19.author, nip19.dTag)
else -> null
}
@@ -65,6 +65,8 @@ object ScrollStateKeys {
const val BROWSE_EMOJI_SETS_SCREEN = "BrowseEmojiSetsFeed"
const val COMMUNITIES_LIST = "CommunitiesListFeed"
const val PICTURES_SCREEN = "PicturesFeed"
const val CALENDARS_SCREEN = "CalendarsFeed"
const val CALENDAR_COLLECTIONS_SCREEN = "CalendarCollectionsFeed"
const val PRODUCTS_SCREEN = "ProductsFeed"
const val SHORTS_SCREEN = "ShortsFeed"
const val PUBLIC_CHATS_SCREEN = "PublicChatsFeed"
@@ -73,6 +73,12 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.list.metadat
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.membershipManagement.ArticleBookmarkListManagementScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.membershipManagement.PostBookmarkListManagementScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.old.OldBookmarkListScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.CalendarCollectionsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.CalendarReminderSettingsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.CalendarsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.create.NewCalendarCollectionScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.create.NewCalendarEventScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.detail.CalendarEventDetailScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.CreateGroupScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.EditGroupInfoScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.MarmotGroupChatScreen
@@ -252,6 +258,17 @@ fun BuildNavigation(
composableFromEnd<Route.ProfileBadges> { ProfileBadgesScreen(accountViewModel, nav) }
composableFromBottomArgs<Route.AwardBadge> { AwardBadgeScreen(it.kind, it.pubKeyHex, it.dTag, accountViewModel, nav) }
composableFromEnd<Route.Pictures> { PicturesScreen(accountViewModel, nav) }
composableFromEnd<Route.Calendars> { CalendarsScreen(accountViewModel, nav) }
composableFromEnd<Route.CalendarCollections> { CalendarCollectionsScreen(accountViewModel, nav) }
composableFromEnd<Route.CalendarReminderSettings> { CalendarReminderSettingsScreen(nav) }
composableFromEndArgs<Route.CalendarEventDetail> {
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) }
composableFromEnd<Route.PublicChats> { PublicChatsScreen(accountViewModel, nav) }
@@ -50,6 +50,8 @@ enum class NavBarItem {
COMMUNITIES,
ARTICLES,
PICTURES,
CALENDARS,
CALENDAR_COLLECTIONS,
SHORTS,
PUBLIC_CHATS,
FOLLOW_PACKS,
@@ -199,6 +201,20 @@ val NavBarCatalog: Map<NavBarItem, NavBarItemDef> =
icon = MaterialSymbols.Photo,
resolveRoute = { Route.Pictures },
),
NavBarItem.CALENDARS to
NavBarItemDef(
id = NavBarItem.CALENDARS,
labelRes = R.string.route_calendars,
icon = MaterialSymbols.CalendarMonth,
resolveRoute = { Route.Calendars },
),
NavBarItem.CALENDAR_COLLECTIONS to
NavBarItemDef(
id = NavBarItem.CALENDAR_COLLECTIONS,
labelRes = R.string.route_calendar_collections,
icon = MaterialSymbols.AutoMirrored.FormatListBulleted,
resolveRoute = { Route.CalendarCollections },
),
NavBarItem.SHORTS to
NavBarItemDef(
id = NavBarItem.SHORTS,
@@ -318,6 +334,8 @@ val DrawerFeedsItems: List<NavBarItem> =
NavBarItem.COMMUNITIES,
NavBarItem.ARTICLES,
NavBarItem.PICTURES,
NavBarItem.CALENDARS,
NavBarItem.CALENDAR_COLLECTIONS,
NavBarItem.SHORTS,
NavBarItem.PUBLIC_CHATS,
NavBarItem.FOLLOW_PACKS,
@@ -149,6 +149,18 @@ fun routeForInner(
Route.GitRepository(noteEvent.kind, noteEvent.pubKey, noteEvent.dTag())
}
// Calendar appointments route to their dedicated detail screen rather than the generic
// Route.Note that AddressableEvent would fall through to — without this the notification
// tap and `nostr:naddr…` deep links land on the bare note view instead of the calendar
// detail with RSVPs, participants, and the "in calendars" list.
is com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent -> {
Route.CalendarEventDetail(noteEvent.kind, noteEvent.pubKey, noteEvent.dTag())
}
is com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent -> {
Route.CalendarEventDetail(noteEvent.kind, noteEvent.pubKey, noteEvent.dTag())
}
is GiftWrapEvent -> {
noteEvent.innerEventId?.let {
routeFor(LocalCache.getOrCreateNote(it), loggedIn)
@@ -81,6 +81,45 @@ sealed class Route {
@Serializable object Pictures : Route()
@Serializable object Calendars : Route()
@Serializable object CalendarCollections : Route()
@Serializable
data class NewCalendarEvent(
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,
) : Route()
@Serializable data class CalendarEventDetail(
val kind: Int,
val pubKeyHex: HexKey,
val dTag: String,
) : Route() {
constructor(address: Address) : this(
kind = address.kind,
pubKeyHex = address.pubKeyHex,
dTag = address.dTag,
)
}
@Serializable object Products : Route()
@Serializable object Shorts : Route()
@@ -243,6 +282,8 @@ sealed class Route {
@Serializable object NotificationSettings : Route()
@Serializable object CalendarReminderSettings : Route()
@Serializable object Lists : Route()
@Serializable data class MyPeopleListView(
@@ -116,7 +116,9 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderAttestorRecommendation
import com.vitorpamplona.amethyst.ui.note.types.RenderAudioHeader
import com.vitorpamplona.amethyst.ui.note.types.RenderAudioTrack
import com.vitorpamplona.amethyst.ui.note.types.RenderBadgeAward
import com.vitorpamplona.amethyst.ui.note.types.RenderCalendarCollectionEvent
import com.vitorpamplona.amethyst.ui.note.types.RenderCalendarDateSlotEvent
import com.vitorpamplona.amethyst.ui.note.types.RenderCalendarRSVPEvent
import com.vitorpamplona.amethyst.ui.note.types.RenderCalendarTimeSlotEvent
import com.vitorpamplona.amethyst.ui.note.types.RenderCashuMint
import com.vitorpamplona.amethyst.ui.note.types.RenderChannelMessage
@@ -254,6 +256,8 @@ import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.relaySets.RelaySetEvent
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.nip52Calendar.rsvp.CalendarRSVPEvent
import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent
import com.vitorpamplona.quartz.nip53LiveActivities.clip.LiveActivitiesClipEvent
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingRoomEvent
@@ -1189,6 +1193,14 @@ private fun RenderNoteRow(
RenderCalendarDateSlotEvent(baseNote, accountViewModel, nav)
}
is CalendarEvent -> {
RenderCalendarCollectionEvent(baseNote, accountViewModel, nav)
}
is CalendarRSVPEvent -> {
RenderCalendarRSVPEvent(baseNote, accountViewModel, nav)
}
is GoalEvent -> {
RenderGoal(baseNote, accountViewModel, nav)
}
@@ -0,0 +1,97 @@
/*
* 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.note.types
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.amethyst.ui.theme.replyModifier
import com.vitorpamplona.quartz.nip52Calendar.calendar.CalendarEvent
@Composable
fun RenderCalendarCollectionEvent(
note: Note,
accountViewModel: AccountViewModel,
nav: INav,
) {
val event = note.event as? CalendarEvent ?: return
Column(MaterialTheme.colorScheme.replyModifier) {
Row(
modifier = Modifier.fillMaxWidth().padding(start = 10.dp, end = 10.dp, top = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
symbol = MaterialSymbols.CalendarMonth,
contentDescription = null,
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.primary,
)
Spacer(modifier = Modifier.size(8.dp))
Text(
text = event.title() ?: "",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
if (event.content.isNotBlank()) {
Spacer(modifier = StdVertSpacer)
Text(
text = event.content,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp),
maxLines = 4,
overflow = TextOverflow.Ellipsis,
)
}
Spacer(modifier = StdVertSpacer)
Text(
text = stringRes(R.string.calendar_collection_count, event.calendarEventAddresses().size),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(start = 10.dp, end = 10.dp, bottom = 12.dp),
)
}
}
@@ -86,6 +86,15 @@ fun RenderCalendarTimeSlotEvent(
dateRange = dateRange,
note = note,
accountViewModel = accountViewModel,
rsvpRow = {
CalendarRsvpRow(
eventKind = CalendarTimeSlotEvent.KIND,
eventPubKey = noteEvent.pubKey,
eventDTag = noteEvent.dTag(),
eventId = noteEvent.id,
accountViewModel = accountViewModel,
)
},
)
}
@@ -119,6 +128,15 @@ fun RenderCalendarDateSlotEvent(
dateRange = dateRange,
note = note,
accountViewModel = accountViewModel,
rsvpRow = {
CalendarRsvpRow(
eventKind = CalendarDateSlotEvent.KIND,
eventPubKey = noteEvent.pubKey,
eventDTag = noteEvent.dTag(),
eventId = noteEvent.id,
accountViewModel = accountViewModel,
)
},
)
}
@@ -131,6 +149,7 @@ private fun CalendarHeader(
dateRange: String?,
note: Note,
accountViewModel: AccountViewModel,
rsvpRow: @Composable () -> Unit = {},
) {
Column(MaterialTheme.colorScheme.replyModifier) {
image?.let {
@@ -209,6 +228,8 @@ private fun CalendarHeader(
if (summary == null) {
Spacer(modifier = StdVertSpacer)
}
rsvpRow()
}
}
@@ -0,0 +1,120 @@
/*
* 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.note.types
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.amethyst.ui.theme.replyModifier
import com.vitorpamplona.quartz.nip52Calendar.appt.tags.RSVPStatusTag
import com.vitorpamplona.quartz.nip52Calendar.rsvp.CalendarRSVPEvent
@Composable
fun RenderCalendarRSVPEvent(
note: Note,
accountViewModel: AccountViewModel,
nav: INav,
) {
val event = note.event as? CalendarRSVPEvent ?: return
val status = event.status()
val targetAddress = event.calendarEventAddress()
val freebusy = event.freebusy()
val statusLabel =
when (status) {
RSVPStatusTag.STATUS.ACCEPTED -> stringRes(R.string.calendar_rsvp_going)
RSVPStatusTag.STATUS.TENTATIVE -> stringRes(R.string.calendar_rsvp_maybe)
RSVPStatusTag.STATUS.DECLINED -> stringRes(R.string.calendar_rsvp_not_going)
null -> ""
}
val statusColor =
when (status) {
RSVPStatusTag.STATUS.ACCEPTED -> MaterialTheme.colorScheme.primary
RSVPStatusTag.STATUS.TENTATIVE -> MaterialTheme.colorScheme.tertiary
RSVPStatusTag.STATUS.DECLINED -> MaterialTheme.colorScheme.error
null -> Color.Gray
}
Column(MaterialTheme.colorScheme.replyModifier) {
Row(
modifier = Modifier.fillMaxWidth().padding(start = 10.dp, end = 10.dp, top = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
symbol = MaterialSymbols.CalendarMonth,
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = statusColor,
)
Spacer(modifier = Modifier.size(8.dp))
Text(
text = statusLabel,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = statusColor,
)
}
if (event.content.isNotBlank()) {
Spacer(modifier = StdVertSpacer)
Text(
text = event.content,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 10.dp, end = 10.dp),
)
}
targetAddress?.let { addr ->
Spacer(modifier = StdVertSpacer)
Text(
text = "${addr.toValue()}",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 10.dp, end = 10.dp, bottom = 12.dp),
)
}
if (freebusy != null) {
Spacer(modifier = StdVertSpacer)
}
}
}
@@ -0,0 +1,187 @@
/*
* 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.note.types
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.FilledTonalButton
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 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(
eventKind: Int,
eventPubKey: String,
eventDTag: String,
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),
) {
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 = colorFor(status),
contentColor = Color.White,
),
) {
Text(text = label)
}
} else {
OutlinedButton(
onClick = { onClick(status) },
modifier = modifier,
) {
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,
targetAddress: Address,
eventId: String,
myPubKey: String,
status: RSVPStatusTag.STATUS,
) {
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(
CalendarRSVPEvent.build(
calendarEventAddress = aTag,
status = status,
calendarEventAuthor = pTag,
dTag = dTag,
),
)
}
}
@@ -30,6 +30,8 @@ import com.vitorpamplona.amethyst.ui.feeds.ChannelFeedContentState
import com.vitorpamplona.amethyst.ui.screen.TopNavFilterState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.articles.dal.ArticlesFeedFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.dal.BadgesFeedFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.dal.CalendarAppointmentsFeedFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.dal.CalendarCollectionsFeedFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.dal.ChatroomListKnownFeedFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.dal.ChatroomListNewFeedFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.list.dal.CommunitiesFeedFilter
@@ -99,6 +101,8 @@ class AccountFeedContentStates(
val communitiesList = FeedContentState(CommunitiesFeedFilter(account), scope, LocalCache)
val picturesFeed = FeedContentState(PictureFeedFilter(account), scope, LocalCache)
val calendarAppointmentsFeed = FeedContentState(CalendarAppointmentsFeedFilter(account), scope, LocalCache)
val calendarCollectionsFeed = FeedContentState(CalendarCollectionsFeedFilter(account), scope, LocalCache)
val productsFeed = FeedContentState(ProductsFeedFilter(account), scope, LocalCache)
val shortsFeed = FeedContentState(ShortsFeedFilter(account), scope, LocalCache)
val publicChatsFeed = FeedContentState(PublicChatsFeedFilter(account), scope, LocalCache)
@@ -196,6 +200,9 @@ class AccountFeedContentStates(
longsFeed.updateFeedWith(newNotes)
articlesFeed.updateFeedWith(newNotes)
calendarAppointmentsFeed.updateFeedWith(newNotes)
calendarCollectionsFeed.updateFeedWith(newNotes)
notifications.updateFeedWith(newNotes)
if (account.settings.splitNotificationsEnabled.value) {
notificationsFollowing.updateFeedWith(newNotes)
@@ -248,6 +255,9 @@ class AccountFeedContentStates(
longsFeed.deleteFromFeed(newNotes)
articlesFeed.deleteFromFeed(newNotes)
calendarAppointmentsFeed.deleteFromFeed(newNotes)
calendarCollectionsFeed.deleteFromFeed(newNotes)
notifications.deleteFromFeed(newNotes)
if (account.settings.splitNotificationsEnabled.value) {
notificationsFollowing.deleteFromFeed(newNotes)
@@ -27,6 +27,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarItem
import com.vitorpamplona.amethyst.ui.screen.loggedIn.articles.datasource.ArticlesFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.datasource.BadgesFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.datasource.CalendarsFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource.ChatroomListFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.list.datasource.CommunitiesListFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.datasource.DiscoveryFilterAssemblerSubscription
@@ -85,6 +86,10 @@ private fun PreloadFor(
NavBarItem.PICTURES -> PicturesFilterAssemblerSubscription(accountViewModel)
NavBarItem.CALENDARS,
NavBarItem.CALENDAR_COLLECTIONS,
-> CalendarsFilterAssemblerSubscription(accountViewModel)
NavBarItem.SHORTS -> ShortsFilterAssemblerSubscription(accountViewModel)
NavBarItem.PUBLIC_CHATS -> PublicChatsFilterAssemblerSubscription(accountViewModel)
@@ -0,0 +1,103 @@
/*
* 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
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.provider.CalendarContract
import com.vitorpamplona.amethyst.commons.model.nip52Calendar.parseIsoDateToUnixSeconds
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent
import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent
/**
* Opens the system "New Event" composer (Google Calendar / Samsung / iCloud / etc.) pre-populated
* with this appointment's title, time, location, and description. The user sees their normal
* calendar UI with one tap to "Save" strictly nicer than the .ics-via-share-sheet path because
* it doesn't go through a file and surfaces the user's preferred calendar app directly.
*
* Returns true if a calendar app handled the intent, false if no handler was found the caller
* can decide whether to fall back to the .ics share path.
*/
fun addToPhoneCalendar(
context: Context,
event: Event,
): Boolean {
val (title, location, summary) =
when (event) {
is CalendarTimeSlotEvent -> Triple(event.title(), event.location(), event.summary())
is CalendarDateSlotEvent -> Triple(event.title(), event.location(), event.summary())
else -> return false
}
val (beginMs, endMs, allDay) = computeRangeMs(event) ?: return false
val description =
buildString {
summary?.let { append(it) }
if (event.content.isNotBlank()) {
if (isNotEmpty()) append("\n\n")
append(event.content)
}
}
val intent =
Intent(Intent.ACTION_INSERT).apply {
data = CalendarContract.Events.CONTENT_URI
putExtra(CalendarContract.Events.TITLE, title.orEmpty())
location?.let { putExtra(CalendarContract.Events.EVENT_LOCATION, it) }
if (description.isNotEmpty()) {
putExtra(CalendarContract.Events.DESCRIPTION, description)
}
putExtra(CalendarContract.EXTRA_EVENT_BEGIN_TIME, beginMs)
putExtra(CalendarContract.EXTRA_EVENT_END_TIME, endMs)
putExtra(CalendarContract.EXTRA_EVENT_ALL_DAY, allDay)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
return try {
context.startActivity(intent)
true
} catch (_: ActivityNotFoundException) {
false
}
}
/**
* Computes the (beginMillis, endMillis, isAllDay) triple from a calendar event. NIP-52 date-slot
* uses ISO dates that we anchor at local midnight; time-slot uses unix seconds. End defaults to
* begin + 1 hour for time-slot events without an end and to begin + 1 day for all-day events
* without an end (calendar providers expect end > start for any visible event).
*/
private fun computeRangeMs(event: Event): Triple<Long, Long, Boolean>? =
when (event) {
is CalendarTimeSlotEvent -> {
val start = event.start() ?: return null
val end = event.end() ?: (start + 3600L)
Triple(start * 1000L, end * 1000L, false)
}
is CalendarDateSlotEvent -> {
val startSec = parseIsoDateToUnixSeconds(event.start()) ?: return null
val endSec = parseIsoDateToUnixSeconds(event.end()) ?: (startSec + 86400L)
Triple(startSec * 1000L, endSec * 1000L, true)
}
else -> null
}
@@ -0,0 +1,102 @@
/*
* 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
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState
import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel
import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold
import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar
import com.vitorpamplona.amethyst.ui.navigation.bottombars.FabBottomBarPadded
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.datasource.CalendarsFilterAssemblerSubscription
/**
* Top-level screen for browsing NIP-52 kind-31924 calendars (collections of appointments). Reuses
* the [CalendarsFilterAssembler] subscription so opening this screen also keeps the appointment
* subscription warm both feeds share one relay subscription.
*/
@Composable
fun CalendarCollectionsScreen(
accountViewModel: AccountViewModel,
nav: INav,
) {
CalendarCollectionsScreen(
feedState = accountViewModel.feedStates.calendarCollectionsFeed,
accountViewModel = accountViewModel,
nav = nav,
)
}
@Composable
fun CalendarCollectionsScreen(
feedState: FeedContentState,
accountViewModel: AccountViewModel,
nav: INav,
) {
WatchLifecycleAndUpdateModel(feedState)
WatchAccountForCalendarCollectionsScreen(feedState, accountViewModel)
CalendarsFilterAssemblerSubscription(accountViewModel)
DisappearingScaffold(
isInvertedLayout = false,
topBar = {
CalendarCollectionsTopBar(accountViewModel, nav)
},
bottomBar = {
AppBottomBar(Route.CalendarCollections, nav, accountViewModel) { route ->
if (route == Route.CalendarCollections) {
feedState.sendToTop()
} else {
nav.navBottomBar(route)
}
}
},
floatingButton = {
FabBottomBarPadded(nav) {
NewCalendarButton(nav)
}
},
accountViewModel = accountViewModel,
) {
CalendarCollectionsView(feedState, accountViewModel, nav)
}
}
@Composable
private fun WatchAccountForCalendarCollectionsScreen(
feedState: FeedContentState,
accountViewModel: AccountViewModel,
) {
val listState by accountViewModel.account.liveCalendarsFollowLists.collectAsStateWithLifecycle()
val hiddenUsers by
accountViewModel.account.hiddenUsers.flow
.collectAsStateWithLifecycle()
LaunchedEffect(accountViewModel, listState, hiddenUsers) {
feedState.checkKeysInvalidateDataAndSendToTop()
}
}
@@ -0,0 +1,70 @@
/*
* 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
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.TopFilter
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.FeedFilterSpinner
import com.vitorpamplona.amethyst.ui.navigation.topbars.UserDrawerSearchTopBar
import com.vitorpamplona.amethyst.ui.screen.FeedDefinition
import com.vitorpamplona.amethyst.ui.screen.TopNavFilterState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
@Composable
fun CalendarCollectionsTopBar(
accountViewModel: AccountViewModel,
nav: INav,
) {
UserDrawerSearchTopBar(accountViewModel, nav) {
val list by accountViewModel.account.settings.defaultCalendarsFollowList
.collectAsStateWithLifecycle()
CalendarCollectionsTopNavFilterBar(
followListsModel = accountViewModel.feedStates.feedListOptions,
listName = list,
accountViewModel = accountViewModel,
onChange = accountViewModel.account.settings::changeDefaultCalendarsFollowList,
)
}
}
@Composable
private fun CalendarCollectionsTopNavFilterBar(
followListsModel: TopNavFilterState,
listName: TopFilter,
accountViewModel: AccountViewModel,
onChange: (FeedDefinition) -> Unit,
) {
val allLists by followListsModel.kind3GlobalPeopleRoutes.collectAsStateWithLifecycle()
FeedFilterSpinner(
placeholderCode = listName,
explainer = stringRes(R.string.select_list_to_filter),
options = allLists,
onSelect = onChange,
accountViewModel = accountViewModel,
)
}
@@ -0,0 +1,230 @@
/*
* 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
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.nip52Calendar.IcsExport
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox
import com.vitorpamplona.amethyst.ui.layouts.rememberFeedContentPadding
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.UserCardHeader
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
import com.vitorpamplona.quartz.nip01Core.core.Address
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
@Composable
fun CalendarCollectionsView(
feedState: FeedContentState,
accountViewModel: AccountViewModel,
nav: INav,
) {
RefresheableBox(feedState, true) {
val state by feedState.feedContent.collectAsStateWithLifecycle()
when (val s = state) {
is FeedState.Loaded -> CollectionsBody(s, accountViewModel, nav)
is FeedState.Empty -> EmptyCollections()
is FeedState.Loading -> Box(modifier = Modifier.fillMaxSize())
is FeedState.FeedError ->
Box(
modifier = Modifier.fillMaxSize().padding(32.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = s.errorMessage,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error,
)
}
}
}
}
@Composable
private fun CollectionsBody(
loaded: FeedState.Loaded,
accountViewModel: AccountViewModel,
nav: INav,
) {
val items by loaded.feed.collectAsStateWithLifecycle()
LazyColumn(
// Reserve top space for the surrounding [DisappearingScaffold]'s top bar — without this
// the first card scrolls under it on the initial render.
contentPadding = rememberFeedContentPadding(FeedPadding),
modifier = Modifier.fillMaxSize(),
) {
items(items.list, key = { it.idHex }) { note ->
CalendarCollectionCard(note, accountViewModel, nav)
}
}
}
@Composable
private fun EmptyCollections() {
CalendarEmptyState(
title = stringRes(R.string.calendar_empty_collections_title),
subtitle = stringRes(R.string.calendar_empty_collections_subtitle),
)
}
@Composable
fun CalendarCollectionCard(
note: Note,
accountViewModel: AccountViewModel,
nav: INav,
) {
val event = note.event as? CalendarEvent ?: return
val title = remember(note.idHex) { event.title() }
val description = remember(note.idHex) { event.content.take(180) }
val count = remember(note.idHex) { event.calendarEventAddresses().size }
val context = LocalContext.current
// Calendar collections are addressable (kind 31924); route to the dedicated
// CalendarEventDetail screen instead of the generic Route.Note thread view, which
// didn't know how to render them as a list of member appointments.
val collectionRoute = remember(note.idHex, event) { Route.CalendarEventDetail(event.address()) }
Card(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 6.dp)
.clickable { nav.nav(collectionRoute) },
shape = RoundedCornerShape(14.dp),
colors = CardDefaults.elevatedCardColors(),
elevation = CardDefaults.elevatedCardElevation(defaultElevation = 2.dp),
) {
// Author header matches every other social card in the app, but without the createdAt
// timestamp — the user cares about the collection's events, not when the metadata was
// last edited.
UserCardHeader(
baseNote = note,
accountViewModel = accountViewModel,
nav = nav,
showTimeAgo = false,
)
Row(
modifier = Modifier.padding(start = 14.dp, end = 14.dp, bottom = 14.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
symbol = MaterialSymbols.CalendarMonth,
contentDescription = null,
modifier = Modifier.size(32.dp),
tint = MaterialTheme.colorScheme.primary,
)
Column(
modifier = Modifier.weight(1f).padding(start = 14.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = title ?: "",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
if (description.isNotBlank()) {
Text(
text = description,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
Text(
text = stringRes(R.string.calendar_collection_count, count),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
)
}
IconButton(onClick = {
val members = collectMembers(event)
val ics = IcsExport.calendarToIcs(event, members, TimeUtils.now())
val filename = IcsExport.calendarFilename(event)
shareIcs(context, filename, ics)
}) {
Icon(
symbol = MaterialSymbols.Share,
contentDescription = stringRes(R.string.calendar_export_event),
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
/**
* Resolves a calendar's member addresses to their cached events, skipping members that haven't
* arrived from relays yet or aren't appointments. Returns a list compatible with
* [IcsExport.calendarToIcs].
*/
private fun collectMembers(calendar: CalendarEvent): List<Pair<Address, Any>> =
calendar
.calendarEventAddresses()
.mapNotNull { addr ->
val cachedEvent = LocalCache.addressables.get(addr)?.event
if (cachedEvent is CalendarTimeSlotEvent || cachedEvent is CalendarDateSlotEvent) {
addr to cachedEvent
} else {
null
}
}
@@ -0,0 +1,233 @@
/*
* 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
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.nip52Calendar.appointmentView
import com.vitorpamplona.amethyst.commons.model.nip52Calendar.calendarLocalDayKeyRange
import com.vitorpamplona.amethyst.commons.model.nip52Calendar.groupByDayKeyExpanded
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.layouts.rememberFeedContentPadding
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
import java.time.LocalDate
import java.time.ZoneId
@Composable
fun CalendarDayView(
feedState: FeedContentState,
accountViewModel: AccountViewModel,
nav: INav,
filterAddresses: Set<com.vitorpamplona.quartz.nip01Core.core.Address>? = null,
) {
val state by feedState.feedContent.collectAsStateWithLifecycle()
val notes =
when (val s = state) {
is FeedState.Loaded ->
s.feed
.collectAsStateWithLifecycle()
.value.list
.applyCalendarFilter(filterAddresses)
else -> emptyList()
}
val today = remember { LocalDate.now() }
// Persisting an epoch-day Long is auto-saveable; arithmetic in [LocalDate] is DST-safe
// (millisecond stepping was off by an hour after spring/fall transitions).
var visibleEpochDay by rememberSaveable { mutableStateOf(today.toEpochDay()) }
val visibleDate = LocalDate.ofEpochDay(visibleEpochDay)
val byDay by remember(notes) { derivedStateOf { groupByDayKeyExpanded(notes) } }
val dayEvents = byDay[visibleDate.toEpochDay()].orEmpty()
val sorted =
remember(dayEvents) {
// All-day events bubble to the top (Long.MIN_VALUE), then time-slot events in order.
dayEvents.sortedBy { it.appointmentView()?.startSeconds ?: Long.MAX_VALUE }
}
// Single LazyColumn — nav header is the first item so it scrolls with the disappearing
// top bar instead of staying pinned mid-screen when the bar collapses.
LazyColumn(
contentPadding = rememberFeedContentPadding(FeedPadding),
modifier =
Modifier
.fillMaxSize()
.calendarSwipeNavigation(
key = visibleEpochDay,
onSwipeLeft = { visibleEpochDay = visibleDate.plusDays(1).toEpochDay() },
onSwipeRight = { visibleEpochDay = visibleDate.minusDays(1).toEpochDay() },
),
) {
item(key = "day-nav") {
CalendarNavigationHeader(
title = formatLongDate(visibleDate.atStartOfDay(ZoneId.systemDefault()).toEpochSecond()),
prevContentDescription = stringRes(R.string.calendar_nav_previous_day),
nextContentDescription = stringRes(R.string.calendar_nav_next_day),
onPrev = { visibleEpochDay = visibleDate.minusDays(1).toEpochDay() },
onNext = { visibleEpochDay = visibleDate.plusDays(1).toEpochDay() },
onToday = { visibleEpochDay = LocalDate.now().toEpochDay() },
)
}
if (dayEvents.isEmpty()) {
item(key = "day-empty") {
CalendarEmptyState(
title = stringRes(R.string.calendar_empty_day_title),
subtitle = stringRes(R.string.calendar_empty_day_subtitle),
)
}
} else {
items(sorted, key = { it.idHex }) { note ->
Column(modifier = Modifier.padding(horizontal = 12.dp)) {
DayRow(
note = note,
visibleEpochDay = visibleEpochDay,
onClick = { nav.nav(Route.Note(note.idHex)) },
)
HorizontalDivider()
}
}
}
}
}
@Composable
private fun DayRow(
note: Note,
visibleEpochDay: Long,
onClick: () -> Unit,
) {
val view = note.appointmentView() ?: return
val range = note.calendarLocalDayKeyRange()
// Position within a multi-day event: today is "Day 2 of 4". Renders below the time label so
// a continuation day on a 3-day conference reads as "9:00 AM / Day 2 of 3" rather than
// looking like a fresh event.
val dayOfTotal =
if (range != null && range.last > range.first) {
(visibleEpochDay - range.first + 1).toInt() to (range.last - range.first + 1).toInt()
} else {
null
}
val startSeconds = view.startSeconds
val timeLabel =
when {
view.isAllDay -> stringRes(R.string.calendar_all_day)
startSeconds != null && visibleEpochDay > (range?.first ?: visibleEpochDay) ->
// Continuation day of a multi-day timed event — the "9:00 AM" of day 1 is
// misleading on day 2 since the event has been ongoing overnight. Show a
// continuation marker so the user reads it as "still happening".
stringRes(R.string.calendar_continues)
startSeconds != null -> formatTimeOfDay(startSeconds)
else -> ""
}
Row(
modifier =
Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(vertical = 10.dp),
verticalAlignment = Alignment.Top,
) {
Text(
text = timeLabel,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.width(72.dp),
)
Box(
modifier =
Modifier
.width(3.dp)
.height(40.dp)
.background(MaterialTheme.colorScheme.primary, RoundedCornerShape(2.dp)),
)
Column(
modifier = Modifier.padding(start = 12.dp),
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
view.title?.let {
Text(
text = it,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.SemiBold,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
dayOfTotal?.let { (day, total) ->
Text(
text = stringRes(R.string.calendar_day_of_total, day, total),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.SemiBold,
)
}
view.location?.let {
Text(
text = it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
}
@@ -0,0 +1,71 @@
/*
* 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
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
/**
* Shared empty-state layout used across the calendar surfaces (feed, day, week, collections).
* Title is the headline; subtitle gives the user one concrete next step ("tap + to create one").
* Keeping all calendar empty states uniform avoids the previous one-line walls of text that
* gave the user no guidance about what to do next.
*/
@Composable
fun CalendarEmptyState(
title: String,
subtitle: String,
) {
Box(
modifier = Modifier.fillMaxSize().padding(32.dp),
contentAlignment = Alignment.Center,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center,
)
Text(
text = subtitle,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
}
}
}
@@ -0,0 +1,237 @@
/*
* 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
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.nip52Calendar.appointmentView
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.components.MyAsyncImage
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.UserCardHeader
import com.vitorpamplona.quartz.utils.TimeUtils
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.util.Locale
// Thread-safe and hoisted: previously each CalendarDateBadge recompose allocated a new
// SimpleDateFormat, which (a) is not thread-safe and (b) created 500 allocations while scrolling.
private val MonthShortFormatter: DateTimeFormatter =
DateTimeFormatter.ofPattern("MMM", Locale.getDefault())
@Composable
fun CalendarEventListCard(
note: Note,
accountViewModel: AccountViewModel,
nav: INav,
modifier: Modifier = Modifier,
) {
val view = note.appointmentView() ?: return
val range = remember(note.idHex) { formatCalendarRange(note) }
val context = LocalContext.current
val relative = remember(note.idHex, view.startSeconds) { relativeTimeLabel(context, view, TimeUtils.now()) }
val event = note.event ?: return
val detailRoute =
remember(event.id) {
val addr =
(event as? com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent)?.address()
addr?.let { Route.CalendarEventDetail(it) } ?: Route.Note(note.idHex)
}
Card(
modifier =
modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 6.dp)
.clickable { nav.nav(detailRoute) },
shape = RoundedCornerShape(14.dp),
colors = CardDefaults.elevatedCardColors(),
elevation = CardDefaults.elevatedCardElevation(defaultElevation = 2.dp),
) {
// Author header matches the picture-feed / shorts card shape: avatar + display name +
// time-ago at the top of every social card in the app. Without this, calendar cards
// looked alien next to the rest of the feed.
UserCardHeader(
baseNote = note,
accountViewModel = accountViewModel,
nav = nav,
// Hide the "posted N ago" timestamp — would be visually identical to the event's
// own start time below and confuse users into reading the publication date as the
// event date.
showTimeAgo = false,
)
Row(
modifier = Modifier.padding(start = 12.dp, end = 12.dp, bottom = 12.dp),
verticalAlignment = Alignment.Top,
) {
CalendarDateBadge(view.startSeconds)
Spacer(modifier = Modifier.size(12.dp))
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
view.title?.let {
Text(
text = it,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
range?.let {
Text(
text = it,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
relative?.let {
Text(
text = it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
view.location?.let {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
symbol = MaterialSymbols.LocationOn,
contentDescription = null,
modifier = Modifier.size(14.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.size(4.dp))
Text(
text = it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
val image = view.image
val summary = view.summary
if (!image.isNullOrBlank()) {
Spacer(modifier = Modifier.size(4.dp))
MyAsyncImage(
imageUrl = image,
contentDescription = view.title,
contentScale = ContentScale.Crop,
mainImageModifier = Modifier.fillMaxWidth().height(120.dp),
loadedImageModifier = Modifier,
accountViewModel = accountViewModel,
onLoadingBackground = { Box(modifier = Modifier.fillMaxWidth().height(120.dp)) },
onError = { Box(modifier = Modifier.fillMaxWidth().height(120.dp)) },
)
}
if (!summary.isNullOrBlank() && image.isNullOrBlank()) {
Text(
text = summary,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
}
}
}
}
@Composable
private fun CalendarDateBadge(startSeconds: Long?) {
if (startSeconds == null) {
Box(
modifier = Modifier.size(width = 52.dp, height = 60.dp),
contentAlignment = Alignment.Center,
) {
Icon(
symbol = MaterialSymbols.CalendarMonth,
contentDescription = null,
modifier = Modifier.size(28.dp),
tint = MaterialTheme.colorScheme.primary,
)
}
return
}
val localDate =
remember(startSeconds) {
Instant.ofEpochSecond(startSeconds).atZone(ZoneId.systemDefault()).toLocalDate()
}
val day = localDate.dayOfMonth.toString()
val month = remember(localDate) { MonthShortFormatter.format(localDate).uppercase() }
Column(
modifier = Modifier.size(width = 52.dp, height = 60.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text(
text = month,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.SemiBold,
)
Text(
text = day,
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.Bold,
)
}
}
@@ -0,0 +1,201 @@
/*
* 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
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
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.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.nip52Calendar.calendarEndSeconds
import com.vitorpamplona.amethyst.commons.model.nip52Calendar.calendarStartSeconds
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox
import com.vitorpamplona.amethyst.ui.feeds.WatchScrollToTop
import com.vitorpamplona.amethyst.ui.layouts.rememberFeedContentPadding
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
import com.vitorpamplona.quartz.utils.TimeUtils
@Composable
fun CalendarFeedView(
feedState: FeedContentState,
accountViewModel: AccountViewModel,
nav: INav,
filterAddresses: Set<com.vitorpamplona.quartz.nip01Core.core.Address>? = null,
) {
RefresheableBox(feedState, true) {
val state by feedState.feedContent.collectAsStateWithLifecycle()
when (val s = state) {
is FeedState.Loaded -> CalendarFeedLoadedBody(s, feedState, accountViewModel, nav, filterAddresses)
is FeedState.Empty -> CalendarFeedEmpty()
is FeedState.Loading -> Box(modifier = Modifier.fillMaxSize())
is FeedState.FeedError -> CalendarFeedError(s)
}
}
}
@Composable
private fun CalendarFeedLoadedBody(
loaded: FeedState.Loaded,
feedState: FeedContentState,
accountViewModel: AccountViewModel,
nav: INav,
filterAddresses: Set<com.vitorpamplona.quartz.nip01Core.core.Address>?,
) {
val items by loaded.feed.collectAsStateWithLifecycle()
val split by remember(filterAddresses) {
derivedStateOf {
partitionUpcomingPast(items.list.applyCalendarFilter(filterAddresses))
}
}
// Without this the top-bar filter switch fires `sendToTop()`, but the LazyColumn never hears
// it — so the scroll position from the previous filter (e.g. mid-way through a tiny People
// List) is preserved when the user flips back to Global, leaving the user staring at the
// past-events section of a 100-item feed instead of the top.
val listState = rememberLazyListState()
WatchScrollToTop(feedState, listState)
LazyColumn(
state = listState,
contentPadding = rememberFeedContentPadding(FeedPadding),
modifier = Modifier.fillMaxSize(),
) {
if (split.upcoming.isNotEmpty()) {
item(key = "section-upcoming") {
SectionHeader(stringRes(R.string.calendar_section_upcoming))
}
items(split.upcoming, key = { it.idHex }) { note ->
CalendarEventListCard(note, accountViewModel, nav)
}
}
if (split.past.isNotEmpty()) {
item(key = "section-past") {
if (split.upcoming.isNotEmpty()) {
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
}
SectionHeader(stringRes(R.string.calendar_section_past))
}
items(split.past, key = { it.idHex }) { note ->
CalendarEventListCard(note, accountViewModel, nav)
}
}
}
}
@Composable
private fun SectionHeader(text: String) {
Text(
text = text,
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(start = 16.dp, top = 14.dp, bottom = 6.dp),
)
}
@Composable
private fun CalendarFeedEmpty() {
CalendarEmptyState(
title = stringRes(R.string.calendar_empty_feed_title),
subtitle = stringRes(R.string.calendar_empty_feed_subtitle),
)
}
@Composable
private fun CalendarFeedError(state: FeedState.FeedError) {
Box(
modifier = Modifier.fillMaxSize().padding(32.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = state.errorMessage,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error,
)
}
}
data class UpcomingPastSplit(
val upcoming: List<Note>,
val past: List<Note>,
)
/**
* Returns only notes whose appointment address is in [filterAddresses]. Pass null to skip
* filtering (the common path when "All" is selected). Lives here as a shared helper so each
* view body can keep its own collection logic and just opt-into the filter via one call.
*/
fun List<Note>.applyCalendarFilter(filterAddresses: Set<com.vitorpamplona.quartz.nip01Core.core.Address>?): List<Note> {
if (filterAddresses == null) return this
return filter { note ->
val addr =
(note.event as? com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent)?.address()
addr != null && addr in filterAddresses
}
}
/**
* [nowSeconds] is taken as a parameter (rather than reading `TimeUtils.now()` internally) so the
* split can be unit-tested deterministically and so callers that already snapshot `now` for a
* sort don't read the clock twice.
*/
fun partitionUpcomingPast(
items: List<Note>,
nowSeconds: Long = TimeUtils.now(),
): UpcomingPastSplit {
val upcoming = mutableListOf<Note>()
val past = mutableListOf<Note>()
items.forEach {
val s = it.calendarStartSeconds() ?: return@forEach
// An event that started yesterday but ends tomorrow is "happening now", not over.
// Fall back to start when end is missing so the legacy single-instant behaviour is kept.
val effectiveEnd = it.calendarEndSeconds() ?: s
if (effectiveEnd >= nowSeconds) {
upcoming.add(it)
} else {
past.add(it)
}
}
return UpcomingPastSplit(upcoming, past)
}
@@ -0,0 +1,206 @@
/*
* 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
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.AssistChip
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
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.nip52Calendar.calendar.CalendarEvent
/**
* Top-bar affordance that scopes the appointments feed to a single kind-31924 calendar's
* member set. Selecting "All" clears the filter.
*
* Filter state lives on the screen (passed as [selectedDTag] / [onSelect]) so it survives
* configuration changes via rememberSaveable but doesn't persist across launches keeping a
* filter sticky between sessions would surprise a user who set it once and forgot. The filter
* is applied client-side after the feed loads, so changing it doesn't trigger a relay refetch.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CalendarFilterChip(
selectedDTag: String?,
onSelect: (String?) -> Unit,
accountViewModel: AccountViewModel,
) {
val myPubKey = accountViewModel.userProfile().pubkeyHex
val ownCalendars by produceState<List<CalendarEvent>>(initialValue = ownCalendars(myPubKey), myPubKey) {
LocalCache.live.newEventBundles.collect {
value = ownCalendars(myPubKey)
}
}
val selected = ownCalendars.firstOrNull { it.dTag() == selectedDTag }
val label =
selected?.title()?.takeIf { it.isNotBlank() }
?: stringRes(R.string.calendar_filter_all)
var showSheet by remember { mutableStateOf(false) }
AssistChip(
onClick = { showSheet = true },
label = {
Text(text = label, maxLines = 1, overflow = TextOverflow.Ellipsis)
},
)
if (showSheet) {
ModalBottomSheet(
onDismissRequest = { showSheet = false },
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
) {
Column(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = stringRes(R.string.calendar_filter_sheet_title),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
)
FilterChoiceRow(
title = stringRes(R.string.calendar_filter_all),
isSelected = selectedDTag == null,
onClick = {
onSelect(null)
showSheet = false
},
)
if (ownCalendars.isEmpty()) {
Box(
modifier = Modifier.fillMaxWidth().padding(vertical = 16.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = stringRes(R.string.calendar_filter_no_calendars),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
} else {
LazyColumn(modifier = Modifier.fillMaxWidth()) {
items(ownCalendars, key = { it.dTag() }) { calendar ->
FilterChoiceRow(
title = calendar.title() ?: stringRes(R.string.calendar_untitled),
isSelected = selectedDTag == calendar.dTag(),
onClick = {
onSelect(calendar.dTag())
showSheet = false
},
)
}
}
}
}
}
}
}
@Composable
private fun FilterChoiceRow(
title: String,
isSelected: Boolean,
onClick: () -> Unit,
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
RadioButton(selected = isSelected, onClick = onClick)
Text(
text = title,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.padding(start = 4.dp),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
private fun ownCalendars(myPubKey: String): List<CalendarEvent> =
LocalCache.addressables
.filterIntoSet { _, note ->
val e = note.event
e is CalendarEvent && e.pubKey == myPubKey
}.mapNotNull { it.event as? CalendarEvent }
.sortedBy { it.title()?.lowercase() ?: "" }
/**
* Resolves the selected calendar's member address set, or null when no filter is set. Returned
* set is suitable for `.filter { it.calendarAddress() in filter }` membership checks on the
* notes the feed views render.
*/
@Composable
fun rememberCalendarFilterAddresses(
selectedDTag: String?,
accountViewModel: AccountViewModel,
): Set<Address>? {
if (selectedDTag == null) return null
val myPubKey = accountViewModel.userProfile().pubkeyHex
val addr = remember(selectedDTag, myPubKey) { Address(CalendarEvent.KIND, myPubKey, selectedDTag) }
val state by produceState<Set<Address>?>(initialValue = null, addr) {
// Re-evaluate on relay-driven changes — when the user edits the calendar elsewhere, the
// member set updates here without leaving the screen.
value = lookupMembers(addr)
LocalCache.live.newEventBundles.collect {
value = lookupMembers(addr)
}
}
return state
}
private fun lookupMembers(addr: Address): Set<Address> =
(LocalCache.addressables.get(addr)?.event as? CalendarEvent)
?.calendarEventAddresses()
?.toSet()
?: emptySet()
@@ -0,0 +1,377 @@
/*
* 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
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.nip52Calendar.MONTH_GRID_MAX_LANES
import com.vitorpamplona.amethyst.commons.model.nip52Calendar.MonthGridBarSegment
import com.vitorpamplona.amethyst.commons.model.nip52Calendar.computeMonthGridBars
import com.vitorpamplona.amethyst.commons.model.nip52Calendar.groupByDayKeyExpanded
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState
import com.vitorpamplona.amethyst.ui.layouts.rememberFeedContentPadding
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
import java.time.LocalDate
import java.time.YearMonth
import java.time.ZoneId
@Composable
fun CalendarMonthView(
feedState: FeedContentState,
accountViewModel: AccountViewModel,
nav: INav,
filterAddresses: Set<com.vitorpamplona.quartz.nip01Core.core.Address>? = null,
) {
val state by feedState.feedContent.collectAsStateWithLifecycle()
val notes =
when (val s = state) {
is FeedState.Loaded ->
s.feed
.collectAsStateWithLifecycle()
.value.list
.applyCalendarFilter(filterAddresses)
else -> emptyList()
}
val today = remember { LocalDate.now() }
// YearMonth is not Parcelable/auto-saveable; persist the two ints and rebuild on each read.
var visibleYear by rememberSaveable { mutableStateOf(today.year) }
var visibleMonthValue by rememberSaveable { mutableStateOf(today.monthValue) }
val visibleMonth = YearMonth.of(visibleYear, visibleMonthValue)
fun setVisibleMonth(ym: YearMonth) {
visibleYear = ym.year
visibleMonthValue = ym.monthValue
}
val eventsByDay by remember(notes) { derivedStateOf { groupByDayKeyExpanded(notes) } }
val barsByDay by remember(notes) { derivedStateOf { computeMonthGridBars(notes) } }
var selectedDayKey by rememberSaveable { mutableStateOf<Long?>(null) }
val selectedEvents = selectedDayKey?.let { eventsByDay[it] }.orEmpty()
// Single LazyColumn — nav header + weekday header + grid scroll together with the
// disappearing top bar so the grid doesn't stay pinned mid-screen when the bar collapses.
LazyColumn(
contentPadding = rememberFeedContentPadding(FeedPadding),
modifier =
Modifier
.fillMaxSize()
.calendarSwipeNavigation(
key = visibleYear to visibleMonthValue,
onSwipeLeft = {
setVisibleMonth(visibleMonth.plusMonths(1))
selectedDayKey = null
},
onSwipeRight = {
setVisibleMonth(visibleMonth.minusMonths(1))
selectedDayKey = null
},
),
) {
item(key = "month-nav") {
CalendarNavigationHeader(
title = formatMonthYear(visibleMonth.year, visibleMonth.monthValue - 1),
prevContentDescription = stringRes(R.string.calendar_nav_previous_month),
nextContentDescription = stringRes(R.string.calendar_nav_next_month),
onPrev = {
setVisibleMonth(visibleMonth.minusMonths(1))
selectedDayKey = null
},
onNext = {
setVisibleMonth(visibleMonth.plusMonths(1))
selectedDayKey = null
},
onToday = {
setVisibleMonth(YearMonth.from(LocalDate.now()))
selectedDayKey = null
},
)
}
item(key = "month-weekday-header") {
WeekdayHeader()
}
item(key = "month-grid") {
MonthGrid(
visibleMonth = visibleMonth,
today = today,
barsByDay = barsByDay,
selectedDayKey = selectedDayKey,
onDayClick = { dayKey ->
selectedDayKey = if (selectedDayKey == dayKey) null else dayKey
},
)
}
item(key = "month-grid-spacer") {
Spacer(modifier = Modifier.height(8.dp))
}
if (selectedEvents.isNotEmpty()) {
items(selectedEvents, key = { it.idHex }) { note ->
CalendarEventListCard(note, accountViewModel, nav)
}
}
}
}
@Composable
private fun WeekdayHeader() {
Row(modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp)) {
for (i in 0..6) {
Text(
text = formatShortWeekday(i),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f).padding(vertical = 4.dp),
textAlign = TextAlign.Center,
fontWeight = FontWeight.SemiBold,
)
}
}
}
@Composable
private fun MonthGrid(
visibleMonth: YearMonth,
today: LocalDate,
barsByDay: Map<Long, List<MonthGridBarSegment>>,
selectedDayKey: Long?,
onDayClick: (Long) -> Unit,
) {
val firstOfMonth = visibleMonth.atDay(1)
// SUNDAY = 7 in DayOfWeek; we want Sunday = 0 to match `formatShortWeekday`.
val firstWeekdayIndex = firstOfMonth.dayOfWeek.value % 7
val daysInMonth = visibleMonth.lengthOfMonth()
val rows = ((firstWeekdayIndex + daysInMonth + 6) / 7)
val isCurrentMonth = visibleMonth == YearMonth.from(today)
Column(modifier = Modifier.fillMaxWidth()) {
for (r in 0 until rows) {
Row(modifier = Modifier.fillMaxWidth()) {
for (c in 0..6) {
val cellIndex = r * 7 + c
val dayNumber = cellIndex - firstWeekdayIndex + 1
if (dayNumber in 1..daysInMonth) {
val date = visibleMonth.atDay(dayNumber)
val dayKey = date.toEpochDay()
val cellBars = barsByDay[dayKey].orEmpty()
val isToday = isCurrentMonth && date == today
val isSelected = selectedDayKey == dayKey
DayCell(
modifier = Modifier.weight(1f),
dayNumber = dayNumber,
isToday = isToday,
isSelected = isSelected,
bars = cellBars,
// Anything past the visible lane cap collapses into a "+N" tail —
// keeps each cell readable when a day has more than three events.
extraEventCount = cellBars.count { it.lane >= MONTH_GRID_MAX_LANES },
isWeekStart = c == 0,
isWeekEnd = c == 6,
// Full date label fed to the screen-reader content description so
// TalkBack reads "Wednesday January 15 2025, 2 events" instead of
// just "15".
dateLabel = formatLongDate(date.atStartOfDay(ZoneId.systemDefault()).toEpochSecond()),
totalEventCount = cellBars.size,
onClick = { onDayClick(dayKey) },
)
} else {
Box(modifier = Modifier.weight(1f).height(MONTH_CELL_HEIGHT))
}
}
}
}
}
}
private val MONTH_CELL_HEIGHT = 72.dp
@Composable
private fun DayCell(
modifier: Modifier,
dayNumber: Int,
isToday: Boolean,
isSelected: Boolean,
bars: List<MonthGridBarSegment>,
extraEventCount: Int,
isWeekStart: Boolean,
isWeekEnd: Boolean,
dateLabel: String,
totalEventCount: Int,
onClick: () -> Unit,
) {
val bg =
if (isSelected) {
MaterialTheme.colorScheme.primaryContainer
} else {
MaterialTheme.colorScheme.surface
}
val baseDescription =
pluralStringResource(R.plurals.calendar_day_a11y_events, totalEventCount, dateLabel, totalEventCount)
val todaySuffix = stringRes(R.string.calendar_day_a11y_today_suffix)
val selectedSuffix = stringRes(R.string.calendar_day_a11y_selected_suffix)
val a11y =
buildString {
append(baseDescription)
if (isToday) append(", ").append(todaySuffix)
if (isSelected) append(", ").append(selectedSuffix)
}
Box(
modifier =
modifier
.height(MONTH_CELL_HEIGHT)
// Vertical-only padding so adjacent cells in a row touch horizontally — a
// multi-day bar that extends from the right edge of one cell to the left edge of
// the next visually merges into a single uninterrupted line.
.padding(vertical = 2.dp)
.background(bg, RoundedCornerShape(8.dp))
.border(
width = if (isToday) 1.5.dp else 0.5.dp,
color = if (isToday) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outlineVariant,
shape = RoundedCornerShape(8.dp),
).clickable(role = Role.Button, onClick = onClick)
.semantics(mergeDescendants = true) { contentDescription = a11y },
) {
Column(
modifier = Modifier.fillMaxSize().padding(horizontal = 2.dp, vertical = 4.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = dayNumber.toString(),
style = MaterialTheme.typography.bodyMedium,
fontWeight = if (isToday) FontWeight.Bold else FontWeight.Normal,
color =
if (isToday) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurface
},
)
Spacer(modifier = Modifier.height(2.dp))
EventBarLanes(
bars = bars,
extraEventCount = extraEventCount,
isWeekStart = isWeekStart,
isWeekEnd = isWeekEnd,
)
}
}
}
/**
* Renders up to [MONTH_GRID_MAX_LANES] horizontal bars stacked vertically. Each lane occupies a
* fixed height across every cell so a multi-day event sits on the same y-row in every column it
* covers the visual continuity that makes "spans 3 days" readable at a glance.
*
* The bar is rounded only at the event's start (`isLeftEnd`) and end (`isRightEnd`). On week
* boundaries we also round so each row of the grid looks self-contained instead of bleeding into
* an unaligned next row.
*/
@Composable
private fun EventBarLanes(
bars: List<MonthGridBarSegment>,
extraEventCount: Int,
isWeekStart: Boolean,
isWeekEnd: Boolean,
) {
val barColor = MaterialTheme.colorScheme.primary
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(1.dp),
) {
for (i in 0 until MONTH_GRID_MAX_LANES) {
val seg = bars.firstOrNull { it.lane == i }
if (seg != null) {
val roundLeft = seg.isLeftEnd || isWeekStart
val roundRight = seg.isRightEnd || isWeekEnd
Box(
modifier =
Modifier
.fillMaxWidth()
.height(5.dp)
.background(
color = barColor,
shape =
RoundedCornerShape(
topStart = if (roundLeft) 2.dp else 0.dp,
bottomStart = if (roundLeft) 2.dp else 0.dp,
topEnd = if (roundRight) 2.dp else 0.dp,
bottomEnd = if (roundRight) 2.dp else 0.dp,
),
),
)
} else {
Spacer(modifier = Modifier.fillMaxWidth().height(5.dp))
}
}
if (extraEventCount > 0) {
Text(
text = "+$extraEventCount",
style = MaterialTheme.typography.labelSmall,
color = barColor,
fontWeight = FontWeight.SemiBold,
)
}
}
}
@@ -0,0 +1,94 @@
/*
* 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
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.ui.stringRes
/**
* Shared `[] title []` header used by month / week / day view bodies. Tapping the title
* jumps back to today.
*/
@Composable
fun CalendarNavigationHeader(
title: String,
prevContentDescription: String,
nextContentDescription: String,
onPrev: () -> Unit,
onNext: () -> Unit,
onToday: () -> Unit,
) {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(onClick = onPrev) {
Icon(
symbol = MaterialSymbols.AutoMirrored.ArrowBack,
contentDescription = prevContentDescription,
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.onSurface,
)
}
// Title doubles as the "jump to today" affordance. Adding a TalkBack-only contentDescription
// makes that role discoverable for screen-reader users — the bare text alone reads as a
// label, not an action.
val jumpToToday = stringRes(R.string.calendar_nav_jump_to_today)
Text(
text = title,
style = MaterialTheme.typography.titleLarge,
modifier =
Modifier
.weight(1f)
.clickable(role = Role.Button, onClickLabel = jumpToToday, onClick = onToday)
.semantics { contentDescription = "$title, $jumpToToday" },
textAlign = TextAlign.Center,
fontWeight = FontWeight.Bold,
)
IconButton(onClick = onNext) {
Icon(
symbol = MaterialSymbols.ChevronRight,
contentDescription = nextContentDescription,
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.onSurface,
)
}
}
}
@@ -0,0 +1,77 @@
/*
* 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
import android.content.Context
import android.text.format.DateUtils
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.nip52Calendar.CalendarAppointmentView
/**
* Localised "starts in 2 hours" / "started 5 minutes ago" / "Happening now · ends in 2 hours"
* label for an appointment. Returns null when the event has no parseable start (nothing to
* anchor a relative phrase to).
*
* Uses [DateUtils.getRelativeTimeSpanString] for the underlying minute/hour/day phrasing that
* helper is locale-aware and ages from "just now" through "in N days" to absolute date for
* far-out events. For all-day events we extend the resolution to DAY so we get "tomorrow",
* "in 3 days" instead of an hour-precision phrase that would lie about the start moment.
*
* Ongoing events (start now end) get a composite "Happening now · ends in X" so a user
* mid-event sees how much time is left rather than the misleading "started X minutes ago" that
* DateUtils would produce on its own.
*/
fun relativeTimeLabel(
context: Context,
view: CalendarAppointmentView,
nowSeconds: Long,
): String? {
val start = view.startSeconds ?: return null
val end = view.endSeconds
if (end != null && start <= nowSeconds && nowSeconds <= end) {
val ongoing = context.getString(R.string.calendar_relative_ongoing)
val endsIn =
DateUtils
.getRelativeTimeSpanString(
end * 1000L,
nowSeconds * 1000L,
if (view.isAllDay) DateUtils.DAY_IN_MILLIS else DateUtils.MINUTE_IN_MILLIS,
DateUtils.FORMAT_ABBREV_RELATIVE,
).toString()
return context.getString(R.string.calendar_relative_ongoing_with_end, ongoing, endsIn)
}
val minResolution =
if (view.isAllDay) {
DateUtils.DAY_IN_MILLIS
} else {
DateUtils.MINUTE_IN_MILLIS
}
return DateUtils
.getRelativeTimeSpanString(
start * 1000L,
nowSeconds * 1000L,
minResolution,
DateUtils.FORMAT_ABBREV_RELATIVE,
).toString()
}
@@ -0,0 +1,147 @@
/*
* 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
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.service.calendar.CalendarReminderPrefs
import com.vitorpamplona.amethyst.service.calendar.CalendarReminderWorker
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.stringRes
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CalendarReminderSettingsScreen(nav: INav) {
val context = LocalContext.current
val prefs = remember { CalendarReminderPrefs(context) }
var enabled by remember { mutableStateOf(prefs.isEnabled()) }
var leadMinutes by remember { mutableIntStateOf(prefs.leadMinutes()) }
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringRes(R.string.calendar_reminder_settings_title)) },
navigationIcon = {
IconButton(onClick = { nav.popBack() }) {
Icon(
symbol = MaterialSymbols.AutoMirrored.ArrowBack,
contentDescription = stringRes(R.string.back),
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.onSurface,
)
}
},
)
},
) { pad ->
Column(
modifier =
Modifier
.padding(pad)
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = stringRes(R.string.calendar_reminder_settings_enabled_title),
style = MaterialTheme.typography.titleSmall,
)
Text(
text = stringRes(R.string.calendar_reminder_settings_enabled_subtitle),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Switch(
checked = enabled,
onCheckedChange = {
enabled = it
prefs.setEnabled(it)
// Toggling off doesn't cancel the worker — the worker itself short-
// circuits when isEnabled() returns false. Keeping the schedule alive
// means flipping it back on takes effect immediately without needing a
// re-launch via AppModules.
if (it) CalendarReminderWorker.schedule(context)
},
)
}
Text(
text = stringRes(R.string.calendar_reminder_settings_lead_title),
style = MaterialTheme.typography.titleSmall,
modifier = Modifier.padding(top = 8.dp),
)
Text(
text = stringRes(R.string.calendar_reminder_settings_lead_subtitle),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
CalendarReminderPrefs.LEAD_TIME_CHOICES.forEach { choice ->
FilterChip(
selected = choice == leadMinutes,
onClick = {
leadMinutes = choice
prefs.setLeadMinutes(choice)
},
enabled = enabled,
label = {
Text(stringRes(R.string.calendar_reminder_settings_lead_choice, choice))
},
)
}
}
}
}
}
@@ -0,0 +1,41 @@
/*
* 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
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import com.vitorpamplona.amethyst.ui.layouts.LocalDisappearingScaffoldPadding
/**
* Applies the surrounding [com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold]'s reserved
* top/bottom space as padding. The scaffold places content at y=0 by design it lets feeds
* scroll *behind* the disappearing bar but the month/week/day views are static-headered grids,
* so without this modifier the grid header would render under the top app bar. Outside a
* scaffold the local default is zero and the modifier is a no-op.
*/
@Composable
fun Modifier.disappearingScaffoldPadding(): Modifier =
composed {
val padding = LocalDisappearingScaffoldPadding.current
this.padding(padding)
}
@@ -0,0 +1,59 @@
/*
* 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
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.pointerInput
/**
* Swipe-to-navigate gesture for calendar surfaces. Horizontal drag past the threshold fires
* [onSwipeLeft] (next period) or [onSwipeRight] (previous period). The threshold is in pixels
* we accumulate raw drag deltas because [detectHorizontalDragGestures]' final-velocity callback
* isn't surfaced here; a positional threshold gives the user predictable, latch-style behaviour
* comparable to the previous/next arrows in [CalendarNavigationHeader].
*
* The `key` lets a host that swaps state (week next week, day next day) restart the gesture
* detector so a long sequence of partial drags doesn't accumulate across navigations.
*/
fun Modifier.calendarSwipeNavigation(
key: Any?,
onSwipeLeft: () -> Unit,
onSwipeRight: () -> Unit,
thresholdPx: Float = 120f,
): Modifier =
this.pointerInput(key) {
var totalDrag = 0f
detectHorizontalDragGestures(
onDragStart = { totalDrag = 0f },
onDragEnd = {
if (totalDrag <= -thresholdPx) {
onSwipeLeft()
} else if (totalDrag >= thresholdPx) {
onSwipeRight()
}
totalDrag = 0f
},
onDragCancel = { totalDrag = 0f },
) { _, dragAmount ->
totalDrag += dragAmount
}
}
@@ -0,0 +1,101 @@
/*
* 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
import com.vitorpamplona.amethyst.commons.model.nip52Calendar.appointmentView
import com.vitorpamplona.amethyst.model.Note
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Date
import java.util.Locale
private val DayMonthFormat = SimpleDateFormat("EEE, MMM d", Locale.getDefault())
private val FullDateFormat = SimpleDateFormat("EEEE, MMMM d, yyyy", Locale.getDefault())
private val MonthYearFormat = SimpleDateFormat("MMMM yyyy", Locale.getDefault())
private val TimeFormat = SimpleDateFormat("h:mm a", Locale.getDefault())
private val WeekdayShortFormat = SimpleDateFormat("EEE", Locale.getDefault())
fun formatCalendarRange(note: Note): String? {
val view = note.appointmentView() ?: return null
val start = view.startSeconds ?: return null
return if (view.isAllDay) {
formatDateRange(start, view.endSeconds)
} else {
formatTimeRange(start, view.endSeconds)
}
}
private fun formatTimeRange(
start: Long,
end: Long?,
): String {
val startMs = start * 1000
val startStr = "${DayMonthFormat.format(Date(startMs))} · ${TimeFormat.format(Date(startMs))}"
if (end == null || end == start) return startStr
val endMs = end * 1000
return if (isSameDay(startMs, endMs)) {
"$startStr ${TimeFormat.format(Date(endMs))}"
} else {
"$startStr ${DayMonthFormat.format(Date(endMs))} · ${TimeFormat.format(Date(endMs))}"
}
}
private fun formatDateRange(
start: Long,
end: Long?,
): String {
val startStr = DayMonthFormat.format(Date(start * 1000))
if (end == null || end == start) return startStr
return "$startStr ${DayMonthFormat.format(Date(end * 1000))}"
}
fun formatLongDate(unixSeconds: Long): String = FullDateFormat.format(Date(unixSeconds * 1000))
fun formatMonthYear(
year: Int,
monthZeroBased: Int,
): String {
val cal = Calendar.getInstance()
cal.clear()
cal.set(year, monthZeroBased, 1)
return MonthYearFormat.format(cal.time)
}
fun formatTimeOfDay(unixSeconds: Long): String = TimeFormat.format(Date(unixSeconds * 1000))
fun formatShortWeekday(weekdayZeroBased: Int): String {
val cal = Calendar.getInstance()
cal.clear()
cal.firstDayOfWeek = Calendar.SUNDAY
cal.set(Calendar.DAY_OF_WEEK, Calendar.SUNDAY)
cal.add(Calendar.DAY_OF_YEAR, weekdayZeroBased)
return WeekdayShortFormat.format(cal.time)
}
private fun isSameDay(
aMs: Long,
bMs: Long,
): Boolean {
val ca = Calendar.getInstance().apply { timeInMillis = aMs }
val cb = Calendar.getInstance().apply { timeInMillis = bMs }
return ca.get(Calendar.YEAR) == cb.get(Calendar.YEAR) &&
ca.get(Calendar.DAY_OF_YEAR) == cb.get(Calendar.DAY_OF_YEAR)
}
@@ -0,0 +1,277 @@
/*
* 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
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.nip52Calendar.groupByDayKeyExpanded
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.layouts.rememberFeedContentPadding
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
import java.time.LocalDate
import java.time.ZoneId
@Composable
fun CalendarWeekView(
feedState: FeedContentState,
accountViewModel: AccountViewModel,
nav: INav,
filterAddresses: Set<com.vitorpamplona.quartz.nip01Core.core.Address>? = null,
) {
val state by feedState.feedContent.collectAsStateWithLifecycle()
val notes =
when (val s = state) {
is FeedState.Loaded ->
s.feed
.collectAsStateWithLifecycle()
.value.list
.applyCalendarFilter(filterAddresses)
else -> emptyList()
}
val today = remember { LocalDate.now() }
// Persist the week-start as an epoch-day Long (auto-saveable), reconstruct LocalDate on use.
var weekStartEpochDay by rememberSaveable {
mutableStateOf(startOfWeek(today).toEpochDay())
}
val weekStart = LocalDate.ofEpochDay(weekStartEpochDay)
var selectedDayIndex by rememberSaveable { mutableStateOf(0) }
val eventsByDay by remember(notes) { derivedStateOf { groupByDayKeyExpanded(notes) } }
val selectedDate = weekStart.plusDays(selectedDayIndex.toLong())
val dayNotes = eventsByDay[selectedDate.toEpochDay()].orEmpty()
// Single LazyColumn containing nav + strip + day-summary + events so the whole stack
// scrolls together with the [DisappearingScaffold]'s top bar. The previous Column-with-
// disappearingScaffoldPadding kept the strip pinned at a fixed offset, so when the top bar
// collapsed on scroll the strip stayed put and left a visual gap.
LazyColumn(
contentPadding = rememberFeedContentPadding(FeedPadding),
modifier =
Modifier
.fillMaxSize()
.calendarSwipeNavigation(
key = weekStartEpochDay,
onSwipeLeft = {
weekStartEpochDay = weekStart.plusWeeks(1).toEpochDay()
selectedDayIndex = 0
},
onSwipeRight = {
weekStartEpochDay = weekStart.minusWeeks(1).toEpochDay()
selectedDayIndex = 0
},
),
) {
item(key = "week-nav") {
CalendarNavigationHeader(
title = formatMonthYear(weekStart.year, weekStart.monthValue - 1),
prevContentDescription = stringRes(R.string.calendar_nav_previous_week),
nextContentDescription = stringRes(R.string.calendar_nav_next_week),
onPrev = {
weekStartEpochDay = weekStart.minusWeeks(1).toEpochDay()
selectedDayIndex = 0
},
onNext = {
weekStartEpochDay = weekStart.plusWeeks(1).toEpochDay()
selectedDayIndex = 0
},
onToday = {
weekStartEpochDay = startOfWeek(LocalDate.now()).toEpochDay()
selectedDayIndex = 0
},
)
}
item(key = "week-strip") {
WeekStrip(
weekStart = weekStart,
today = today,
selectedIndex = selectedDayIndex,
eventsByDay = eventsByDay,
onSelect = { selectedDayIndex = it },
)
}
item(key = "week-spacer") {
Spacer(modifier = Modifier.height(8.dp))
}
item(key = "week-day-summary") {
DaySummaryHeader(selectedDate)
}
if (dayNotes.isEmpty()) {
item(key = "week-empty") {
CalendarEmptyState(
title = stringRes(R.string.calendar_empty_week_title),
subtitle = stringRes(R.string.calendar_empty_week_subtitle),
)
}
} else {
items(dayNotes, key = { it.idHex }) { note ->
CalendarEventListCard(note, accountViewModel, nav)
}
}
}
}
@Composable
private fun WeekStrip(
weekStart: LocalDate,
today: LocalDate,
selectedIndex: Int,
eventsByDay: Map<Long, List<Note>>,
onSelect: (Int) -> Unit,
) {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp),
) {
for (i in 0..6) {
val date = weekStart.plusDays(i.toLong())
val count = eventsByDay[date.toEpochDay()]?.size ?: 0
val isToday = date == today
val isSelected = i == selectedIndex
val bg =
when {
isSelected -> MaterialTheme.colorScheme.primary
isToday -> MaterialTheme.colorScheme.primaryContainer
else -> MaterialTheme.colorScheme.surface
}
val fg =
when {
isSelected -> MaterialTheme.colorScheme.onPrimary
isToday -> MaterialTheme.colorScheme.onPrimaryContainer
else -> MaterialTheme.colorScheme.onSurface
}
val dateLabel = formatLongDate(date.atStartOfDay(ZoneId.systemDefault()).toEpochSecond())
val baseA11y = pluralStringResource(R.plurals.calendar_day_a11y_events, count, dateLabel, count)
val todaySuffix = stringRes(R.string.calendar_day_a11y_today_suffix)
val selectedSuffix = stringRes(R.string.calendar_day_a11y_selected_suffix)
val a11y =
buildString {
append(baseA11y)
if (isToday) append(", ").append(todaySuffix)
if (isSelected) append(", ").append(selectedSuffix)
}
Column(
modifier =
Modifier
.weight(1f)
.padding(3.dp)
.background(bg, RoundedCornerShape(10.dp))
.border(
width = 0.5.dp,
color = MaterialTheme.colorScheme.outlineVariant,
shape = RoundedCornerShape(10.dp),
).clickable(role = Role.Tab) { onSelect(i) }
.padding(vertical = 6.dp)
.semantics(mergeDescendants = true) { contentDescription = a11y },
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = formatShortWeekday(i),
style = MaterialTheme.typography.labelSmall,
color = fg,
fontWeight = FontWeight.SemiBold,
)
Text(
text = date.dayOfMonth.toString(),
style = MaterialTheme.typography.titleMedium,
color = fg,
fontWeight = FontWeight.Bold,
)
if (count > 0) {
Text(
text = if (count > 9) "9+" else count.toString(),
style = MaterialTheme.typography.labelSmall,
color = fg,
maxLines = 1,
overflow = TextOverflow.Clip,
)
} else {
Spacer(modifier = Modifier.height(14.dp))
}
}
}
}
}
@Composable
private fun DaySummaryHeader(date: LocalDate) {
Text(
text = formatLongDate(date.atStartOfDay(ZoneId.systemDefault()).toEpochSecond()),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(start = 16.dp, top = 8.dp, bottom = 4.dp),
)
}
/**
* Returns the Sunday on or before [date]. DST-safe because [LocalDate] arithmetic ignores zones.
* `DayOfWeek.SUNDAY.value` is 7 in java.time, so `% 7` collapses Sunday 0 with the rest of the
* week following in order.
*/
private fun startOfWeek(date: LocalDate): LocalDate {
val daysFromSunday = date.dayOfWeek.value % 7
return date.minusDays(daysFromSunday.toLong())
}
@@ -0,0 +1,136 @@
/*
* 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
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState
import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel
import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold
import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar
import com.vitorpamplona.amethyst.ui.navigation.bottombars.FabBottomBarPadded
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.datasource.CalendarsFilterAssemblerSubscription
@Composable
fun CalendarsScreen(
accountViewModel: AccountViewModel,
nav: INav,
) {
CalendarsScreen(
feedState = accountViewModel.feedStates.calendarAppointmentsFeed,
accountViewModel = accountViewModel,
nav = nav,
)
}
@Composable
fun CalendarsScreen(
feedState: FeedContentState,
accountViewModel: AccountViewModel,
nav: INav,
) {
WatchLifecycleAndUpdateModel(feedState)
WatchAccountForCalendarsScreen(feedState, accountViewModel)
CalendarsFilterAssemblerSubscription(accountViewModel)
var viewMode by rememberSaveable { mutableStateOf(CalendarsViewMode.FEED) }
var filterDTag by rememberSaveable { mutableStateOf<String?>(null) }
// Resolve the selected calendar's member addresses (or null when "All"). Plumbed into each
// view so the membership filter is applied client-side after the feed loads — changing the
// filter doesn't trigger a relay refetch.
val filterAddresses = rememberCalendarFilterAddresses(filterDTag, accountViewModel)
DisappearingScaffold(
isInvertedLayout = false,
topBar = {
CalendarsTopBar(
viewMode = viewMode,
onViewModeChange = { viewMode = it },
accountViewModel = accountViewModel,
nav = nav,
trailing = {
CalendarFilterChip(
selectedDTag = filterDTag,
onSelect = { filterDTag = it },
accountViewModel = accountViewModel,
)
},
)
},
bottomBar = {
AppBottomBar(Route.Calendars, nav, accountViewModel) { route ->
if (route == Route.Calendars) {
feedState.sendToTop()
} else {
nav.navBottomBar(route)
}
}
},
floatingButton = {
FabBottomBarPadded(nav) {
NewCalendarButton(nav)
}
},
accountViewModel = accountViewModel,
) {
Box(modifier = Modifier.fillMaxSize()) {
Column(modifier = Modifier.fillMaxSize()) {
when (viewMode) {
CalendarsViewMode.FEED ->
CalendarFeedView(feedState, accountViewModel, nav, filterAddresses)
CalendarsViewMode.MONTH ->
CalendarMonthView(feedState, accountViewModel, nav, filterAddresses)
CalendarsViewMode.WEEK ->
CalendarWeekView(feedState, accountViewModel, nav, filterAddresses)
CalendarsViewMode.DAY ->
CalendarDayView(feedState, accountViewModel, nav, filterAddresses)
}
}
}
}
}
@Composable
private fun WatchAccountForCalendarsScreen(
feedState: FeedContentState,
accountViewModel: AccountViewModel,
) {
val listState by accountViewModel.account.liveCalendarsFollowLists.collectAsStateWithLifecycle()
val hiddenUsers by
accountViewModel.account.hiddenUsers.flow
.collectAsStateWithLifecycle()
LaunchedEffect(accountViewModel, listState, hiddenUsers) {
feedState.checkKeysInvalidateDataAndSendToTop()
}
}
@@ -0,0 +1,132 @@
/*
* 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
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material3.FilterChip
import androidx.compose.material3.FilterChipDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.TopFilter
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.FeedFilterSpinner
import com.vitorpamplona.amethyst.ui.navigation.topbars.UserDrawerSearchTopBar
import com.vitorpamplona.amethyst.ui.screen.FeedDefinition
import com.vitorpamplona.amethyst.ui.screen.TopNavFilterState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
@Composable
fun CalendarsTopBar(
viewMode: CalendarsViewMode,
onViewModeChange: (CalendarsViewMode) -> Unit,
accountViewModel: AccountViewModel,
nav: INav,
// Optional trailing slot for screen-specific extras (the calendar-membership filter
// currently lives here). Kept generic so future controls can plug in the same way without
// top-bar surgery.
trailing: (@Composable () -> Unit)? = null,
) {
Column {
UserDrawerSearchTopBar(accountViewModel, nav) {
val list by accountViewModel.account.settings.defaultCalendarsFollowList
.collectAsStateWithLifecycle()
CalendarsTopNavFilterBar(
followListsModel = accountViewModel.feedStates.feedListOptions,
listName = list,
accountViewModel = accountViewModel,
onChange = accountViewModel.account.settings::changeDefaultCalendarsFollowList,
)
}
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp),
verticalAlignment = androidx.compose.ui.Alignment.CenterVertically,
) {
CalendarsViewModeTabs(
current = viewMode,
onChange = onViewModeChange,
modifier = Modifier.weight(1f),
)
trailing?.invoke()
}
}
}
@Composable
private fun CalendarsTopNavFilterBar(
followListsModel: TopNavFilterState,
listName: TopFilter,
accountViewModel: AccountViewModel,
onChange: (FeedDefinition) -> Unit,
) {
val allLists by followListsModel.kind3GlobalPeopleRoutes.collectAsStateWithLifecycle()
FeedFilterSpinner(
placeholderCode = listName,
explainer = stringRes(R.string.select_list_to_filter),
options = allLists,
onSelect = onChange,
accountViewModel = accountViewModel,
)
}
@Composable
private fun CalendarsViewModeTabs(
current: CalendarsViewMode,
onChange: (CalendarsViewMode) -> Unit,
modifier: Modifier = Modifier,
) {
Row(
modifier =
modifier
.horizontalScroll(rememberScrollState())
.padding(vertical = 4.dp),
) {
CalendarsViewMode.entries.forEach { mode ->
FilterChip(
selected = mode == current,
onClick = { onChange(mode) },
label = {
Text(
text = stringRes(mode.labelRes),
style = MaterialTheme.typography.labelLarge,
)
},
modifier = Modifier.padding(end = 6.dp),
colors = FilterChipDefaults.filterChipColors(),
shape = MaterialTheme.shapes.small,
)
}
}
}
@@ -0,0 +1,38 @@
/*
* 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
import androidx.annotation.StringRes
import com.vitorpamplona.amethyst.R
/**
* Lenses on the same appointment timeline. Calendar *collections* (kind 31924) live on their
* own screen ([CalendarCollectionsScreen]) since they're a sibling feed, not a different view
* of the appointment data.
*/
enum class CalendarsViewMode(
@StringRes val labelRes: Int,
) {
FEED(R.string.calendar_view_feed),
MONTH(R.string.calendar_view_month),
WEEK(R.string.calendar_view_week),
DAY(R.string.calendar_view_day),
}
@@ -0,0 +1,61 @@
/*
* 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
import android.content.Context
import android.content.Intent
import androidx.core.content.FileProvider
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.stringRes
import java.io.File
/**
* Writes [content] to a `.ics` file in the app's cache dir and opens the system share sheet so
* the user can hand the file to a calendar app, email client, file manager, etc.
*
* The file lives in `cacheDir/calendar/` covered by the existing `<cache-path>` entry in
* `file_paths.xml`, so [FileProvider] can hand out a `content://` URI without further config.
* The receiver gets a read-permission grant via [Intent.FLAG_GRANT_READ_URI_PERMISSION] that
* lasts only for the duration of the share.
*/
fun shareIcs(
context: Context,
filename: String,
content: String,
) {
val dir = File(context.cacheDir, "calendar").apply { mkdirs() }
val file = File(dir, filename)
file.writeText(content)
val uri = FileProvider.getUriForFile(context, "${context.packageName}.provider", file)
val intent =
Intent(Intent.ACTION_SEND).apply {
type = "text/calendar"
putExtra(Intent.EXTRA_STREAM, uri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
val chooser =
Intent
.createChooser(intent, stringRes(context, R.string.calendar_export_share_title))
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
context.startActivity(chooser)
}
@@ -0,0 +1,138 @@
/*
* 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
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size26Modifier
import com.vitorpamplona.amethyst.ui.theme.Size55Modifier
@Composable
fun NewCalendarButton(nav: INav) {
var isOpen by remember { mutableStateOf(false) }
Column {
AnimatedVisibility(
visible = isOpen,
enter = slideInVertically(initialOffsetY = { it / 2 }) + fadeIn(),
exit = slideOutVertically(targetOffsetY = { it / 2 }) + fadeOut(),
) {
Column {
FloatingActionButton(
onClick = {
isOpen = false
nav.nav(Route.NewCalendarCollection())
},
modifier = Size55Modifier,
shape = CircleShape,
containerColor = MaterialTheme.colorScheme.primary,
) {
Icon(
symbol = MaterialSymbols.CalendarMonth,
// Spell out the full intent for screen readers — the bare "New collection"
// string is ambiguous out of context.
contentDescription = stringRes(R.string.calendar_fab_new_collection),
modifier = Size26Modifier,
tint = Color.White,
)
}
Spacer(modifier = Modifier.height(20.dp))
FloatingActionButton(
onClick = {
isOpen = false
nav.nav(Route.NewCalendarEvent())
},
modifier = Size55Modifier,
shape = CircleShape,
containerColor = MaterialTheme.colorScheme.primary,
) {
Icon(
symbol = MaterialSymbols.Add,
contentDescription = stringRes(R.string.calendar_fab_new_event),
modifier = Size26Modifier,
tint = Color.White,
)
}
Spacer(modifier = Modifier.height(20.dp))
}
}
FloatingActionButton(
onClick = { isOpen = !isOpen },
modifier = Size55Modifier,
shape = CircleShape,
containerColor = MaterialTheme.colorScheme.primary,
) {
AnimatedVisibility(
visible = isOpen,
enter = fadeIn(),
exit = fadeOut(),
) {
Icon(
symbol = MaterialSymbols.Close,
contentDescription = stringRes(R.string.calendar_fab_toggle),
modifier = Size26Modifier,
tint = Color.White,
)
}
AnimatedVisibility(
visible = !isOpen,
enter = fadeIn(),
exit = fadeOut(),
) {
Icon(
symbol = MaterialSymbols.Add,
// Top FAB toggles the sub-FABs in/out — describe that, not the sub-action.
contentDescription = stringRes(R.string.calendar_fab_toggle),
modifier = Size26Modifier,
tint = Color.White,
)
}
}
}
}
@@ -0,0 +1,193 @@
/*
* 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.foundation.layout.fillMaxWidth
import androidx.compose.material3.DatePicker
import androidx.compose.material3.DatePickerDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TimePicker
import androidx.compose.material3.TimePickerDialog
import androidx.compose.material3.rememberDatePickerState
import androidx.compose.material3.rememberTimePickerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.stringRes
import java.text.DateFormat
import java.text.SimpleDateFormat
import java.time.Instant
import java.time.ZoneId
import java.time.ZoneOffset
import java.util.Date
import java.util.Locale
/**
* Tap-to-edit button that opens a Material3 DatePicker (and, when [includeTime] is true,
* chains into a TimePicker). The resolved instant is converted to UTC epoch seconds using
* the device's zone offset *at the picked moment*, so DST transitions are handled correctly.
*
* Pass `0L` for [unixSeconds] when the user hasn't picked anything yet the button shows
* [placeholder] instead.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CalendarDateTimePickerButton(
unixSeconds: Long,
placeholder: String,
includeTime: Boolean,
onChange: (Long) -> Unit,
modifier: Modifier = Modifier,
) {
var showDate by remember { mutableStateOf(false) }
var showTime by remember { mutableStateOf(false) }
val pretty =
if (unixSeconds <= 0L) {
placeholder
} else if (includeTime) {
DateFormat
.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT)
.format(Date(unixSeconds * 1000))
} else {
SimpleDateFormat("EEEE, MMMM d, yyyy", Locale.getDefault()).format(Date(unixSeconds * 1000))
}
val initialMillis = if (unixSeconds > 0L) unixSeconds * 1000L else System.currentTimeMillis()
val initialLocal =
Instant
.ofEpochMilli(initialMillis)
.atZone(ZoneId.systemDefault())
.toLocalDateTime()
val datePickerState =
rememberDatePickerState(
initialSelectedDateMillis = initialMillis,
)
val timePickerState =
rememberTimePickerState(
initialHour = initialLocal.hour,
initialMinute = initialLocal.minute,
is24Hour = false,
)
fun reset() {
datePickerState.selectedDateMillis = initialMillis
timePickerState.hour = initialLocal.hour
timePickerState.minute = initialLocal.minute
}
OutlinedButton(
onClick = { showDate = true },
modifier = modifier.fillMaxWidth(),
) {
Text(pretty)
}
if (showDate) {
DatePickerDialog(
onDismissRequest = {
reset()
showDate = false
},
confirmButton = {
TextButton(onClick = {
showDate = false
if (includeTime) {
showTime = true
} else {
commit(datePickerState.selectedDateMillis, includeTime = false, hour = 0, minute = 0, onChange = onChange)
}
}) { Text(stringRes(R.string.confirm)) }
},
dismissButton = {
TextButton(onClick = {
reset()
showDate = false
}) { Text(stringRes(R.string.cancel)) }
},
) {
DatePicker(state = datePickerState)
}
}
if (showTime) {
TimePickerDialog(
title = { Text(stringRes(R.string.calendar_event_pick_time)) },
onDismissRequest = {
reset()
showTime = false
},
confirmButton = {
TextButton(onClick = {
commit(
dayMillisUtc = datePickerState.selectedDateMillis,
includeTime = true,
hour = timePickerState.hour,
minute = timePickerState.minute,
onChange = onChange,
)
showTime = false
}) { Text(stringRes(R.string.confirm)) }
},
dismissButton = {
TextButton(onClick = {
reset()
showTime = false
}) { Text(stringRes(R.string.cancel)) }
},
) {
TimePicker(state = timePickerState)
}
}
}
private fun commit(
dayMillisUtc: Long?,
includeTime: Boolean,
hour: Int,
minute: Int,
onChange: (Long) -> Unit,
) {
if (dayMillisUtc == null) return
val zone = ZoneId.systemDefault()
val localDate =
Instant
.ofEpochMilli(dayMillisUtc)
.atZone(ZoneOffset.UTC)
.toLocalDate()
val picked =
if (includeTime) {
localDate.atTime(hour, minute).atZone(zone).toEpochSecond()
} else {
// For date-only events, anchor to midnight in the user's local zone so the day
// boundary matches their wall-clock intent.
localDate.atStartOfDay(zone).toEpochSecond()
}
onChange(picked)
}
@@ -0,0 +1,262 @@
/*
* 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.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
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.AlertDialog
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Checkbox
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
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
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun NewCalendarCollectionScreen(
nav: INav,
accountViewModel: AccountViewModel,
editDTag: String? = null,
) {
val vm: NewCalendarCollectionViewModel = viewModel()
vm.init(accountViewModel, editDTag)
Scaffold(
topBar = {
SavingTopBar(
titleRes = if (editDTag == null) R.string.new_calendar_collection else R.string.edit_calendar_collection,
onCancel = { nav.popBack() },
onPost = {
accountViewModel.launchSigner {
if (vm.publish()) {
nav.popBack()
}
}
},
)
},
) { pad ->
Column(
modifier =
Modifier
.padding(
start = 16.dp,
end = 16.dp,
top = pad.calculateTopPadding(),
bottom = pad.calculateBottomPadding(),
).consumeWindowInsets(pad)
.imePadding()
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
OutlinedTextField(
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 = !vm.isValid(),
)
OutlinedTextField(
value = vm.description.value,
onValueChange = { vm.description.value = it },
label = { Text(stringRes(R.string.calendar_collection_description)) },
modifier = Modifier.fillMaxWidth(),
minLines = 4,
keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Sentences),
)
if (!vm.isValid()) {
Text(
text = stringRes(R.string.calendar_collection_invalid),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
}
AppointmentPickerSection(vm)
if (vm.isEditing) {
DeleteCalendarRow(vm = vm, onDeleted = { nav.popBack() }, accountViewModel = accountViewModel)
}
}
}
}
@Composable
private fun DeleteCalendarRow(
vm: NewCalendarCollectionViewModel,
onDeleted: () -> Unit,
accountViewModel: AccountViewModel,
) {
var confirming by rememberSaveable { mutableStateOf(false) }
OutlinedButton(
onClick = { confirming = true },
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error),
) {
Text(text = stringRes(R.string.calendar_collection_delete))
}
if (confirming) {
AlertDialog(
onDismissRequest = { confirming = false },
title = { Text(stringRes(R.string.calendar_collection_delete_confirm_title)) },
text = { Text(stringRes(R.string.calendar_collection_delete_confirm_message)) },
confirmButton = {
TextButton(onClick = {
confirming = false
accountViewModel.launchSigner {
if (vm.deleteLoaded()) onDeleted()
}
}) {
Text(
text = stringRes(R.string.calendar_collection_delete),
color = MaterialTheme.colorScheme.error,
)
}
},
dismissButton = {
TextButton(onClick = { confirming = false }) {
Text(stringRes(R.string.cancel))
}
},
)
}
}
@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,
) {
// `stringRes` must be called outside `remember` (it's @Composable). The date format is the
// only piece worth memoising — the localised "All-day" label is a single lookup per row.
val allDayLabel = stringRes(R.string.calendar_all_day)
val whenLabel =
remember(summary.address, summary.startSeconds, summary.isAllDay, allDayLabel) {
when {
summary.isAllDay -> allDayLabel
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.ifBlank { stringRes(R.string.calendar_untitled) },
style = MaterialTheme.typography.bodyLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = whenLabel,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@@ -0,0 +1,208 @@
/*
* 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 androidx.lifecycle.viewModelScope
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
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
/**
* 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
/** The full original event in edit mode; needed to publish a NIP-09 deletion. */
private var loadedEvent: com.vitorpamplona.quartz.nip52Calendar.calendar.CalendarEvent? = null
val selectedAddresses = mutableStateListOf<Address>()
val availableAppointments = mutableStateOf<List<OwnedAppointmentSummary>>(emptyList())
private var liveScanJob: Job? = null
val isEditing: Boolean
get() = dTag != null
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 ->
loadedEvent = existing
title.value = existing.title().orEmpty()
description.value = existing.content
selectedAddresses.addAll(existing.calendarEventAddresses())
}
}
availableAppointments.value = loadOwnedAppointments()
// Reactively re-scan when new events arrive in LocalCache. Without this, an appointment
// the user publishes from another screen (or that arrives from a relay) while the editor
// is open wouldn't appear in the picker until the screen reopens. The Job is stored so
// re-init in editing-mode doesn't stack subscribers (init() returns early after the
// first call anyway, but defence in depth).
liveScanJob?.cancel()
liveScanJob =
viewModelScope.launch(Dispatchers.IO) {
LocalCache.live.newEventBundles.collect {
availableAppointments.value = loadOwnedAppointments()
}
}
}
override fun onCleared() {
liveScanJob?.cancel()
super.onCleared()
}
fun toggle(address: Address) {
if (selectedAddresses.remove(address)) return
selectedAddresses.add(address)
}
/**
* Publishes a NIP-09 deletion event for the loaded calendar. No-op when called outside
* edit mode (we wouldn't have a target to delete). Returns true when the deletion was
* dispatched so the caller can pop back.
*/
suspend fun deleteLoaded(): Boolean {
val target = loadedEvent ?: return false
account.delete(target, emptySet())
return true
}
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` may be empty; the picker row substitutes a localised
// "(untitled)" string at render time — the VM stays string-free.
title = e.title().orEmpty(),
startSeconds = e.start(),
isAllDay = false,
)
is CalendarDateSlotEvent ->
OwnedAppointmentSummary(
address = e.address(),
// `title` may be empty; the picker row substitutes a localised
// "(untitled)" string at render time — the VM stays string-free.
title = e.title().orEmpty(),
// 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 },
),
)
}
}
@@ -0,0 +1,382 @@
/*
* 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.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.SavingTopBar
import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.ShowUserSuggestionList
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size30dp
import com.vitorpamplona.amethyst.ui.theme.SuggestionListDefaultHeightChat
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
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 = if (vm.isEditing) R.string.edit_calendar_event else R.string.new_calendar_event,
onCancel = { nav.popBack() },
onPost = {
accountViewModel.launchSigner {
if (vm.publish()) {
nav.popBack()
}
}
},
)
},
) { pad ->
Column(
modifier =
Modifier
.padding(
start = 16.dp,
end = 16.dp,
top = pad.calculateTopPadding(),
bottom = pad.calculateBottomPadding(),
).consumeWindowInsets(pad)
.imePadding()
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
AllDayToggleRow(vm)
OutlinedTextField(
value = vm.title.value,
onValueChange = { vm.title.value = it },
label = { Text(stringRes(R.string.calendar_event_title)) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Sentences),
)
val isAllDay by vm.isAllDay
FieldLabel(stringRes(R.string.calendar_event_start))
CalendarDateTimePickerButton(
unixSeconds = vm.startSeconds.value,
placeholder = stringRes(R.string.calendar_event_pick_date),
includeTime = !isAllDay,
onChange = { vm.startSeconds.value = it },
)
FieldLabel(stringRes(R.string.calendar_event_end))
CalendarDateTimePickerButton(
unixSeconds = vm.endSeconds.value,
placeholder = stringRes(R.string.calendar_event_pick_date),
includeTime = !isAllDay,
onChange = { vm.endSeconds.value = it },
)
OutlinedTextField(
value = vm.location.value,
onValueChange = { vm.location.value = it },
label = { Text(stringRes(R.string.calendar_event_location)) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
OutlinedTextField(
value = vm.summary.value,
onValueChange = { vm.summary.value = it },
label = { Text(stringRes(R.string.calendar_event_summary)) },
modifier = Modifier.fillMaxWidth(),
minLines = 3,
keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Sentences),
)
ImageRow(vm = vm, accountViewModel = accountViewModel)
OutlinedTextField(
value = vm.hashtags.value,
onValueChange = { vm.hashtags.value = it },
label = { Text(stringRes(R.string.calendar_event_hashtags)) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
ParticipantsRow(vm = vm, accountViewModel = accountViewModel)
if (!vm.isValid()) {
Text(
text = stringRes(R.string.calendar_event_invalid),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
} else if (!vm.isEndAfterStart()) {
Text(
text = stringRes(R.string.calendar_event_end_before_start),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
}
Spacer(modifier = Modifier.height(24.dp))
}
}
}
@Composable
private fun AllDayToggleRow(vm: NewCalendarEventViewModel) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
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,
)
}
}
@Composable
private fun FieldLabel(text: String) {
Text(
text = text,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(start = 4.dp),
)
}
/**
* URL field + gallery-picker icon. Tapping the icon launches the system picker; once the user
* picks an image we hand it to [NewCalendarEventViewModel.uploadAndSetImage], which sends it
* to the user's configured file server (Blossom / NIP-96 / NIP-95) and writes the resulting
* URL into [vm.imageUrl]. A small inline progress indicator covers the upload window.
*/
@Composable
private fun ImageRow(
vm: NewCalendarEventViewModel,
accountViewModel: AccountViewModel,
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val launcher =
androidx.activity.compose.rememberLauncherForActivityResult(
contract =
androidx.activity.result.contract.ActivityResultContracts
.GetContent(),
) { uri ->
if (uri == null) return@rememberLauncherForActivityResult
val mime = context.contentResolver.getType(uri)
scope.launch {
val ok = vm.uploadAndSetImage(uri, mime, context)
if (!ok) {
accountViewModel.toastManager.toast(
R.string.calendar_event_image_upload_failed,
R.string.calendar_event_image_upload_failed_body,
)
}
}
}
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
OutlinedTextField(
value = vm.imageUrl.value,
onValueChange = { vm.imageUrl.value = it },
label = { Text(stringRes(R.string.calendar_event_image)) },
modifier = Modifier.weight(1f),
singleLine = true,
enabled = !vm.isUploadingImage.value,
)
if (vm.isUploadingImage.value) {
CircularProgressIndicator(
modifier = Modifier.padding(start = 8.dp).size(20.dp),
strokeWidth = 2.dp,
)
} else {
IconButton(onClick = { launcher.launch("image/*") }) {
Icon(
symbol = MaterialSymbols.AddPhotoAlternate,
contentDescription = stringRes(R.string.calendar_event_pick_image),
modifier = Modifier.size(22.dp),
tint = MaterialTheme.colorScheme.primary,
)
}
}
}
}
/**
* Inline participant picker. The simplest workable shape: a single-line OutlinedTextField that
* accepts a 64-hex pubkey or an npub, and an "Add" button that pushes it into the list. The
* current participants render as a column of [UserRow] entries with an X button each.
*
* Search-by-display-name is an obvious follow-up but the field accepting npubs directly is the
* primary nostr-native flow today; copy-pasting an npub from a profile is how most users add
* collaborators in other apps.
*/
@Composable
private fun ParticipantsRow(
vm: NewCalendarEventViewModel,
accountViewModel: AccountViewModel,
) {
// Same suggestion machinery the badge-award / DM / new-post screens use: type a name, npub,
// hex, or nip-05; the LazyColumn below shows live matches from the local cache + relay
// search; tapping a row adds the user to the participants list.
val userSuggestions =
remember { UserSuggestionState(accountViewModel.account, accountViewModel.nip05ClientBuilder()) }
DisposableEffect(Unit) { onDispose { userSuggestions.reset() } }
var searchInput by rememberSaveable { mutableStateOf("") }
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
FieldLabel(stringRes(R.string.calendar_event_participants_section, vm.participants.size))
OutlinedTextField(
value = searchInput,
onValueChange = {
searchInput = it
if (it.length > 2) {
userSuggestions.processCurrentWord(it)
} else {
userSuggestions.reset()
}
},
label = { Text(stringRes(R.string.calendar_event_participant_input)) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
if (searchInput.length > 2) {
ShowUserSuggestionList(
userSuggestions = userSuggestions,
onSelect = { user ->
vm.addParticipant(user.pubkeyHex)
searchInput = ""
userSuggestions.reset()
},
accountViewModel = accountViewModel,
modifier = SuggestionListDefaultHeightChat,
)
}
vm.participants.forEach { pubKey ->
Row(verticalAlignment = Alignment.CenterVertically) {
ClickableUserPicture(
baseUserHex = pubKey,
size = Size30dp,
accountViewModel = accountViewModel,
)
LoadUser(baseUserHex = pubKey, accountViewModel = accountViewModel) { user ->
if (user != null) {
UsernameDisplay(
baseUser = user,
weight = Modifier.weight(1f).padding(horizontal = 8.dp),
accountViewModel = accountViewModel,
)
} else {
Text(
text = pubKey.take(8) + "" + pubKey.takeLast(8),
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f).padding(horizontal = 8.dp),
)
}
}
IconButton(onClick = { vm.removeParticipant(pubKey) }) {
Icon(
symbol = MaterialSymbols.Close,
contentDescription = stringRes(R.string.calendar_event_participant_remove),
modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
@@ -0,0 +1,281 @@
/*
* 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 android.content.Context
import android.net.Uri
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
import com.vitorpamplona.amethyst.commons.model.nip52Calendar.parseIsoDateToUnixSeconds
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.uploads.CompressorQuality
import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator
import com.vitorpamplona.amethyst.service.uploads.UploadingState
import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent
import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent
import java.text.SimpleDateFormat
import java.time.ZoneId
import java.util.Locale
import java.util.TimeZone
import com.vitorpamplona.quartz.nip52Calendar.appt.day.image as dayImage
import com.vitorpamplona.quartz.nip52Calendar.appt.day.locations as dayLocations
import com.vitorpamplona.quartz.nip52Calendar.appt.day.participants as dayParticipants
import com.vitorpamplona.quartz.nip52Calendar.appt.day.summary as daySummary
import com.vitorpamplona.quartz.nip52Calendar.appt.time.image as timeImage
import com.vitorpamplona.quartz.nip52Calendar.appt.time.locations as timeLocations
import com.vitorpamplona.quartz.nip52Calendar.appt.time.participants as timeParticipants
import com.vitorpamplona.quartz.nip52Calendar.appt.time.summary as timeSummary
class NewCalendarEventViewModel : ViewModel() {
private lateinit var account: Account
val isAllDay = mutableStateOf(false)
val title = mutableStateOf("")
val summary = mutableStateOf("")
val location = mutableStateOf("")
val imageUrl = mutableStateOf("")
val hashtags = mutableStateOf("") // comma-separated
/** Start instant in epoch seconds. 0 means unset; the create screen guards against publishing without a real value. */
val startSeconds = mutableStateOf(0L)
val endSeconds = mutableStateOf(0L)
val isPublishing = mutableStateOf(false)
val isUploadingImage = mutableStateOf(false)
/** Participant pubkeys to embed as `p` tags on the published event. */
val participants = androidx.compose.runtime.mutableStateListOf<String>()
/**
* 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
participants.clear()
participants.addAll(existing.participants().map { it.pubKey })
}
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
participants.clear()
participants.addAll(existing.participants().map { it.pubKey })
}
}
}
fun isValid(): Boolean = title.value.isNotBlank() && startSeconds.value > 0L
fun isEndAfterStart(): Boolean = endSeconds.value == 0L || endSeconds.value >= startSeconds.value
fun addParticipant(pubKeyHex: String) {
val trimmed = pubKeyHex.trim()
if (trimmed.isBlank() || trimmed in participants) return
participants.add(trimmed)
}
fun removeParticipant(pubKeyHex: String) {
participants.remove(pubKeyHex)
}
/**
* Picks a single image from the gallery uploads to the user's default file server
* writes the resulting URL into [imageUrl]. Returns true on success so the screen can
* surface a toast on failure. Wraps [UploadOrchestrator.upload] with sensible defaults
* (MEDIUM quality, strip metadata, no content warning) so the calendar create screen
* doesn't have to drag in NewMediaModel's full surface area.
*/
suspend fun uploadAndSetImage(
uri: Uri,
mimeType: String?,
context: Context,
): Boolean {
if (!::account.isInitialized) return false
isUploadingImage.value = true
try {
val server = account.settings.defaultFileServer ?: DEFAULT_MEDIA_SERVERS[0]
val result =
UploadOrchestrator().upload(
uri = uri,
mimeType = mimeType,
alt = title.value.ifBlank { null },
contentWarningReason = null,
compressionQuality = CompressorQuality.MEDIUM,
server = server,
account = account,
context = context,
)
val serverResult =
(result as? UploadingState.Finished)?.result as? UploadOrchestrator.OrchestratorResult.ServerResult
return serverResult?.url?.also { imageUrl.value = it } != null
} finally {
isUploadingImage.value = false
}
}
suspend fun publish(): Boolean {
if (!isValid() || !isEndAfterStart()) return false
isPublishing.value = true
try {
val parsedHashtags =
hashtags.value
.split(',', '\n', ' ')
.map { it.trim().trimStart('#') }
.filter { it.isNotBlank() }
val parsedSummary = summary.value.trim().takeIf { it.isNotBlank() }
val parsedImage = imageUrl.value.trim().takeIf { it.isNotBlank() }
val parsedLocation = location.value.trim().takeIf { it.isNotBlank() }
val parsedParticipants = participants.map { PTag(it) }
val tzId = TimeZone.getDefault().id
val targetDTag = editAddress?.dTag
if (isAllDay.value) {
account.signAndComputeBroadcast(
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)
if (parsedParticipants.isNotEmpty()) dayParticipants(parsedParticipants)
}
} 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)
if (parsedParticipants.isNotEmpty()) dayParticipants(parsedParticipants)
}
},
)
} else {
account.signAndComputeBroadcast(
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)
if (parsedParticipants.isNotEmpty()) timeParticipants(parsedParticipants)
}
} 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)
if (parsedParticipants.isNotEmpty()) timeParticipants(parsedParticipants)
}
},
)
}
return true
} finally {
isPublishing.value = false
}
}
}
private val IsoFormat =
SimpleDateFormat("yyyy-MM-dd", Locale.US).apply {
// 31922 uses calendar-date strings; format the user's local date.
timeZone = TimeZone.getTimeZone(ZoneId.systemDefault())
}
private fun toIsoDate(epochSeconds: Long): String = IsoFormat.format(java.util.Date(epochSeconds * 1000))
@@ -0,0 +1,88 @@
/*
* 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.dal
import com.vitorpamplona.amethyst.commons.model.nip52Calendar.upcomingFirstCalendarOrder
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.TopFilter
import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter
import com.vitorpamplona.amethyst.ui.dal.FilterByListParams
import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent
import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent
/**
* Feed of NIP-52 calendar *appointments* kinds 31922 (date-slot) and 31923 (time-slot). The
* NIP calls kind 31924 a "calendar" (a list of appointments), so this filter intentionally does
* not load 31924; see [CalendarCollectionsFeedFilter] for that.
*/
class CalendarAppointmentsFeedFilter(
val account: Account,
) : AdditiveFeedFilter<Note>() {
override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + followList().code
override fun limit() = 500
fun followList(): TopFilter = account.settings.defaultCalendarsFollowList.value
private fun TopFilter.isMuteList() = this is TopFilter.MuteList
private fun TopFilter.isBlockList() = this is TopFilter.PeopleList && this.address == account.blockPeopleList.getBlockListAddress()
private fun TopFilter.wantsToSeeNegativeStuff() = isMuteList() || isBlockList()
override fun showHiddenKey(): Boolean = followList().wantsToSeeNegativeStuff()
override fun feed(): List<Note> {
val params = buildFilterParams(account)
val notes =
LocalCache.notes.filterIntoSet { _, it ->
val e = it.event
(e is CalendarTimeSlotEvent || e is CalendarDateSlotEvent) && params.match(e, it.relays)
}
return sort(notes)
}
override fun applyFilter(newItems: Set<Note>): Set<Note> = innerApplyFilter(newItems)
private fun buildFilterParams(account: Account): FilterByListParams =
FilterByListParams.create(
account.liveCalendarsFollowLists.value,
account.hiddenUsers.flow.value,
)
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
val params = buildFilterParams(account)
return collection.filterTo(HashSet()) {
val e = it.event
(e is CalendarTimeSlotEvent || e is CalendarDateSlotEvent) && params.match(e, it.relays)
}
}
override fun sort(items: Set<Note>): List<Note> =
items.sortedWith(
upcomingFirstCalendarOrder(
com.vitorpamplona.quartz.utils.TimeUtils
.now(),
),
)
}
@@ -0,0 +1,74 @@
/*
* 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.dal
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.TopFilter
import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter
import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder
import com.vitorpamplona.amethyst.ui.dal.FilterByListParams
import com.vitorpamplona.quartz.nip52Calendar.calendar.CalendarEvent
class CalendarCollectionsFeedFilter(
val account: Account,
) : AdditiveFeedFilter<Note>() {
override fun feedKey(): String = account.userProfile().pubkeyHex + "-collections-" + followList().code
override fun limit() = 200
fun followList(): TopFilter = account.settings.defaultCalendarsFollowList.value
private fun TopFilter.isMuteList() = this is TopFilter.MuteList
private fun TopFilter.isBlockList() = this is TopFilter.PeopleList && this.address == account.blockPeopleList.getBlockListAddress()
override fun showHiddenKey(): Boolean = followList().let { it.isMuteList() || it.isBlockList() }
override fun feed(): List<Note> {
val params = buildFilterParams(account)
val notes =
LocalCache.addressables.filterIntoSet { _, it ->
val e = it.event
e is CalendarEvent && params.match(e, it.relays)
}
return sort(notes)
}
override fun applyFilter(newItems: Set<Note>): Set<Note> = innerApplyFilter(newItems)
private fun buildFilterParams(account: Account): FilterByListParams =
FilterByListParams.create(
account.liveCalendarsFollowLists.value,
account.hiddenUsers.flow.value,
)
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
val params = buildFilterParams(account)
return collection.filterTo(HashSet()) {
val e = it.event
e is CalendarEvent && params.match(e, it.relays)
}
}
override fun sort(items: Set<Note>): List<Note> = items.sortedWith(DefaultFeedOrder)
}
@@ -0,0 +1,50 @@
/*
* 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.datasource
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountFeedContentStates
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import kotlinx.coroutines.CoroutineScope
class CalendarsQueryState(
val account: Account,
val feedStates: AccountFeedContentStates,
val scope: CoroutineScope,
)
@Stable
class CalendarsFilterAssembler(
client: INostrClient,
) : ComposeSubscriptionManager<CalendarsQueryState>() {
val group =
listOf(
CalendarsSubAssembler(client, ::allKeys),
)
override fun invalidateKeys() = invalidateFilters()
override fun invalidateFilters() = group.forEach { it.invalidateFilters() }
override fun destroy() = group.forEach { it.destroy() }
}
@@ -0,0 +1,48 @@
/*
* 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.datasource
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.LifecycleAwareKeyDataSourceSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable
fun CalendarsFilterAssemblerSubscription(accountViewModel: AccountViewModel) {
CalendarsFilterAssemblerSubscription(
accountViewModel.dataSources().calendars,
accountViewModel,
)
}
@Composable
fun CalendarsFilterAssemblerSubscription(
dataSource: CalendarsFilterAssembler,
accountViewModel: AccountViewModel,
) {
val state =
remember(accountViewModel.account) {
CalendarsQueryState(accountViewModel.account, accountViewModel.feedStates, accountViewModel.viewModelScope)
}
LifecycleAwareKeyDataSourceSubscription(state, dataSource)
}
@@ -0,0 +1,104 @@
/*
* 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.datasource
import com.vitorpamplona.amethyst.model.TopFilter
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserAndFollowListEoseManager
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.sample
import kotlinx.coroutines.launch
class CalendarsSubAssembler(
client: INostrClient,
allKeys: () -> Set<CalendarsQueryState>,
) : PerUserAndFollowListEoseManager<CalendarsQueryState, TopFilter>(client, allKeys) {
override fun updateFilter(
key: CalendarsQueryState,
since: SincePerRelayMap?,
): List<RelayBasedFilter> {
val feedSettings = key.followsPerRelay()
return makeCalendarsFilter(feedSettings, since, key.feedStates.calendarAppointmentsFeed.lastNoteCreatedAtIfFilled())
}
override fun user(key: CalendarsQueryState) = key.account.userProfile()
override fun list(key: CalendarsQueryState) = key.listName()
fun CalendarsQueryState.listNameFlow() = account.settings.defaultCalendarsFollowList
fun CalendarsQueryState.listName() = listNameFlow().value
fun CalendarsQueryState.followsPerRelayFlow() = account.liveCalendarsFollowListsPerRelay
fun CalendarsQueryState.followsPerRelay() = followsPerRelayFlow().value
val userJobMap = mutableMapOf<User, List<Job>>()
@OptIn(FlowPreview::class)
override fun newSub(key: CalendarsQueryState): Subscription {
val user = user(key)
userJobMap[user]?.forEach { it.cancel() }
userJobMap[user] =
listOf(
key.scope.launch(Dispatchers.IO) {
key.listNameFlow().collectLatest {
// Calendar events are addressables stored in LocalCache's WeakReference
// map: while the user is viewing list B the strong refs from list A's UI
// are gone and the GC may reclaim those notes. If the EOSE cursor for the
// list the user is switching back to still says "you have everything up
// to T", the relay won't re-send the now-evicted events. Clearing the
// cursor here forces a fresh fetch so the feed comes back whole.
clearEoseFor(key)
invalidateFilters()
}
},
key.scope.launch(Dispatchers.IO) {
key.followsPerRelayFlow().sample(500).collectLatest {
invalidateFilters()
}
},
key.account.scope.launch(Dispatchers.IO) {
key.feedStates.calendarAppointmentsFeed.lastNoteCreatedAtWhenFullyLoaded.sample(5000).collectLatest {
invalidateFilters()
}
},
)
return super.newSub(key)
}
override fun endSub(
key: User,
subId: String,
) {
super.endSub(key, subId)
userJobMap[key]?.forEach { it.cancel() }
}
}
@@ -0,0 +1,52 @@
/*
* 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.datasource
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.datasource.subassemblies.filterCalendarsByAuthors
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.datasource.subassemblies.filterCalendarsByFollows
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.datasource.subassemblies.filterCalendarsByGeohashes
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.datasource.subassemblies.filterCalendarsByHashtag
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.datasource.subassemblies.filterCalendarsByMutedAuthors
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.datasource.subassemblies.filterCalendarsGlobal
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
fun makeCalendarsFilter(
feedSettings: IFeedTopNavPerRelayFilterSet,
since: SincePerRelayMap?,
defaultSince: Long? = null,
): List<RelayBasedFilter> =
when (feedSettings) {
is AllFollowsTopNavPerRelayFilterSet -> filterCalendarsByFollows(feedSettings, since, defaultSince)
is AuthorsTopNavPerRelayFilterSet -> filterCalendarsByAuthors(feedSettings, since, defaultSince)
is GlobalTopNavPerRelayFilterSet -> filterCalendarsGlobal(feedSettings, since, defaultSince)
is HashtagTopNavPerRelayFilterSet -> filterCalendarsByHashtag(feedSettings, since, defaultSince)
is LocationTopNavPerRelayFilterSet -> filterCalendarsByGeohashes(feedSettings, since, defaultSince)
is MutedAuthorsTopNavPerRelayFilterSet -> filterCalendarsByMutedAuthors(feedSettings, since, defaultSince)
else -> emptyList()
}
@@ -0,0 +1,39 @@
/*
* 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.datasource.subassemblies
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.nip52Calendar.rsvp.CalendarRSVPEvent
// Appointments are the only kinds shown in the main calendar feed/views. RSVPs and
// collections come along on the same subscription so detail screens can render without
// a second round-trip, but they don't drive the timeline DAL.
val CalendarAppointmentKinds = listOf(CalendarTimeSlotEvent.KIND, CalendarDateSlotEvent.KIND)
val AllCalendarKinds =
listOf(
CalendarTimeSlotEvent.KIND,
CalendarDateSlotEvent.KIND,
CalendarEvent.KIND,
CalendarRSVPEvent.KIND,
)
@@ -0,0 +1,91 @@
/*
* 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.datasource.subassemblies
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
fun filterCalendarsByAuthors(
relay: NormalizedRelayUrl,
authors: Set<HexKey>,
since: Long? = null,
): List<RelayBasedFilter> {
val authorList = authors.sorted()
return listOf(
RelayBasedFilter(
relay = relay,
filter =
Filter(
authors = authorList,
kinds = AllCalendarKinds,
limit = 500,
since = since,
),
),
)
}
fun filterCalendarsByAuthors(
authorSet: AuthorsTopNavPerRelayFilterSet,
since: SincePerRelayMap?,
defaultSince: Long? = null,
): List<RelayBasedFilter> {
if (authorSet.set.isEmpty()) return emptyList()
return authorSet.set
.mapNotNull {
if (it.value.authors.isEmpty()) {
null
} else {
filterCalendarsByAuthors(
relay = it.key,
authors = it.value.authors,
since = since?.get(it.key)?.time ?: defaultSince,
)
}
}.flatten()
}
fun filterCalendarsByMutedAuthors(
authorSet: MutedAuthorsTopNavPerRelayFilterSet,
since: SincePerRelayMap?,
defaultSince: Long? = null,
): List<RelayBasedFilter> {
if (authorSet.set.isEmpty()) return emptyList()
return authorSet.set
.mapNotNull {
if (it.value.authors.isEmpty()) {
null
} else {
filterCalendarsByAuthors(
relay = it.key,
authors = it.value.authors,
since = since?.get(it.key)?.time ?: defaultSince,
)
}
}.flatten()
}
@@ -0,0 +1,44 @@
/*
* 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.datasource.subassemblies
import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
fun filterCalendarsByFollows(
followsSet: AllFollowsTopNavPerRelayFilterSet,
since: SincePerRelayMap?,
defaultSince: Long? = null,
): List<RelayBasedFilter> {
if (followsSet.set.isEmpty()) return emptyList()
return followsSet.set.flatMap {
val sinceForRelay = since?.get(it.key)?.time ?: defaultSince
val relay = it.key
listOfNotNull(
it.value.authors?.let { authors ->
filterCalendarsByAuthors(relay, authors, sinceForRelay)
},
).flatten()
}
}
@@ -0,0 +1,69 @@
/*
* 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.datasource.subassemblies
import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
fun filterCalendarsByGeohashes(
relay: NormalizedRelayUrl,
geotags: Set<String>,
since: Long?,
): List<RelayBasedFilter> {
if (geotags.isEmpty()) return emptyList()
return listOf(
RelayBasedFilter(
relay = relay,
filter =
Filter(
kinds = CalendarAppointmentKinds,
tags = mapOf("g" to geotags.sorted()),
limit = 200,
since = since,
),
),
)
}
fun filterCalendarsByGeohashes(
geoSet: LocationTopNavPerRelayFilterSet,
since: SincePerRelayMap?,
defaultSince: Long?,
): List<RelayBasedFilter> {
if (geoSet.set.isEmpty()) return emptyList()
return geoSet.set
.mapNotNull {
if (it.value.geotags.isEmpty()) {
null
} else {
filterCalendarsByGeohashes(
relay = it.key,
geotags = it.value.geotags,
since = since?.get(it.key)?.time ?: defaultSince,
)
}
}.flatten()
}
@@ -0,0 +1,66 @@
/*
* 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.datasource.subassemblies
import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
fun filterCalendarsByHashtag(
relay: NormalizedRelayUrl,
hashtags: Set<String>,
since: Long? = null,
): List<RelayBasedFilter> =
listOf(
RelayBasedFilter(
relay = relay,
filter =
Filter(
kinds = CalendarAppointmentKinds,
tags = mapOf("t" to hashtags.toList()),
limit = 200,
since = since,
),
),
)
fun filterCalendarsByHashtag(
hashtagSet: HashtagTopNavPerRelayFilterSet,
since: SincePerRelayMap?,
defaultSince: Long? = null,
): List<RelayBasedFilter> {
if (hashtagSet.set.isEmpty()) return emptyList()
return hashtagSet.set
.mapNotNull { relayHashSet ->
if (relayHashSet.value.hashtags.isEmpty()) {
null
} else {
filterCalendarsByHashtag(
relay = relayHashSet.key,
hashtags = relayHashSet.value.hashtags,
since = since?.get(relayHashSet.key)?.time ?: defaultSince,
)
}
}.flatten()
}
@@ -0,0 +1,48 @@
/*
* 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.datasource.subassemblies
import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.utils.TimeUtils
fun filterCalendarsGlobal(
relays: GlobalTopNavPerRelayFilterSet,
since: SincePerRelayMap?,
defaultSince: Long? = null,
): List<RelayBasedFilter> {
if (relays.set.isEmpty()) return emptyList()
return relays.set.map {
val sinceForRelay = since?.get(it.key)?.time ?: defaultSince ?: TimeUtils.oneMonthAgo()
RelayBasedFilter(
relay = it.key,
filter =
Filter(
kinds = AllCalendarKinds,
limit = 500,
since = sinceForRelay,
),
)
}
}
@@ -0,0 +1,186 @@
/*
* 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.detail
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Checkbox
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
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.aTag.aTags
import com.vitorpamplona.quartz.nip52Calendar.calendar.CalendarEvent
/**
* Bottom sheet that lists the current user's own kind-31924 calendars with a checkbox per
* calendar. Tapping a row toggles membership of [targetAddress] in that calendar and re-signs
* the calendar with the updated `a` tag list. The sheet stays open while edits flow so users
* can toggle multiple calendars without dismissing.
*
* Reactive: collects [LocalCache.live.newEventBundles] so newly-broadcast calendars (or our own
* just-published edits) appear / disappear without dismissing and reopening.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AddToCalendarSheet(
targetAddress: Address,
accountViewModel: AccountViewModel,
onDismiss: () -> Unit,
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val myPubKey = accountViewModel.userProfile().pubkeyHex
val ownCalendars by produceState<List<CalendarEvent>>(initialValue = ownCalendars(myPubKey), myPubKey) {
LocalCache.live.newEventBundles.collect {
value = ownCalendars(myPubKey)
}
}
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState,
) {
Column(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
Text(
text = stringRes(R.string.calendar_add_to_calendar_title),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
)
if (ownCalendars.isEmpty()) {
Box(
modifier = Modifier.fillMaxWidth().padding(vertical = 16.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = stringRes(R.string.calendar_add_to_calendar_none),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
return@Column
}
LazyColumn(modifier = Modifier.fillMaxWidth()) {
items(ownCalendars, key = { it.dTag() }) { calendar ->
val isMember = calendar.calendarEventAddresses().contains(targetAddress)
CalendarPickerRow(
title = calendar.title() ?: stringRes(R.string.calendar_untitled),
isMember = isMember,
onToggle = {
toggleMembership(
accountViewModel = accountViewModel,
calendar = calendar,
targetAddress = targetAddress,
isCurrentlyMember = isMember,
)
},
)
}
}
}
}
}
@Composable
private fun CalendarPickerRow(
title: String,
isMember: Boolean,
onToggle: () -> Unit,
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.clickable(onClick = onToggle)
.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Checkbox(checked = isMember, onCheckedChange = { onToggle() })
Text(
text = title,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.padding(start = 4.dp),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
private fun ownCalendars(myPubKey: String): List<CalendarEvent> =
LocalCache.addressables
.filterIntoSet { _, note ->
val e = note.event
e is CalendarEvent && e.pubKey == myPubKey
}.mapNotNull { it.event as? CalendarEvent }
.sortedBy { it.title()?.lowercase() ?: "" }
private fun toggleMembership(
accountViewModel: AccountViewModel,
calendar: CalendarEvent,
targetAddress: Address,
isCurrentlyMember: Boolean,
) {
// Existing `a` tags minus the target if removing, plus the target if adding. Preserves
// the calendar's d-tag so the broadcast replaces the addressable rather than minting a
// new one — same pattern as the edit-collection flow.
val newAddresses =
if (isCurrentlyMember) {
calendar.calendarEventAddresses().filterNot { it == targetAddress }
} else {
calendar.calendarEventAddresses() + targetAddress
}
accountViewModel.launchSigner {
accountViewModel.account.signAndComputeBroadcast(
CalendarEvent.build(
title = calendar.title().orEmpty(),
content = calendar.content,
dTag = calendar.dTag(),
) {
if (newAddresses.isNotEmpty()) aTags(newAddresses.map { ATag(it) })
},
)
}
}
@@ -0,0 +1,865 @@
/*
* 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.detail
import android.content.Intent
import android.net.Uri
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.nip52Calendar.IcsExport
import com.vitorpamplona.amethyst.commons.model.nip52Calendar.appointmentView
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote
import com.vitorpamplona.amethyst.ui.components.MyAsyncImage
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture
import com.vitorpamplona.amethyst.ui.note.ReactionsRow
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.note.types.CalendarRsvpRow
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.addToPhoneCalendar
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.datasource.CalendarsFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.formatCalendarRange
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.formatLongDate
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.relativeTimeLabel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.shareIcs
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size30dp
import com.vitorpamplona.amethyst.ui.theme.Size35dp
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress
import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent
import com.vitorpamplona.quartz.nip52Calendar.appt.tags.RSVPStatusTag
import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent
import com.vitorpamplona.quartz.nip52Calendar.calendar.CalendarEvent
import com.vitorpamplona.quartz.nip52Calendar.rsvp.CalendarRSVPEvent
import com.vitorpamplona.quartz.utils.TimeUtils
/**
* Dedicated detail screen for a NIP-52 calendar appointment (kind 31922 or 31923). Renders the
* full event metadata along with three related sections that the inline note render can't
* easily fit:
* - participants (from the event's `p` tags)
* - RSVPs (kind 31925 events that a-tag this event)
* - calendars this event belongs to (kind 31924 events whose member list includes this event)
*
* The "related" sections are snapshotted at composition for simplicity; opening the screen
* triggers the appointments subscription so freshly-arrived related events are visible on the
* next entry. A future revision could subscribe to LocalCache.live for true reactivity.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CalendarEventDetailScreen(
kind: Int,
pubKeyHex: String,
dTag: String,
accountViewModel: AccountViewModel,
nav: INav,
) {
CalendarsFilterAssemblerSubscription(accountViewModel)
val targetAddress = remember(kind, pubKeyHex, dTag) { Address(kind, pubKeyHex, dTag) }
val targetNote = remember(targetAddress) { LocalCache.getOrCreateAddressableNote(targetAddress) }
// [observeNote] issues a per-event relay subscription on top of the LocalCache flow. This
// is the prefetch path for deep links (notification tap, `nostr:naddr…` from another app):
// landing on the screen without the event cached now triggers a targeted relay fetch instead
// of waiting for the broader calendars feed to happen to include it.
val noteState by observeNote(targetNote, accountViewModel)
val event = noteState.note.event
val isOwnEvent = event?.pubKey == accountViewModel.userProfile().pubkeyHex
Scaffold(
topBar = {
TopAppBar(
title = {
Text(
text = stringRes(R.string.route_calendar_event_detail),
style = MaterialTheme.typography.titleMedium,
)
},
navigationIcon = {
IconButton(onClick = { nav.popBack() }) {
Icon(
symbol = MaterialSymbols.AutoMirrored.ArrowBack,
contentDescription = stringRes(R.string.back),
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.onSurface,
)
}
},
actions = {
val context = LocalContext.current
// Two share modes:
// - .ics for non-nostr calendar apps (Google Calendar / iOS / Outlook)
// - nostr:naddr… link for sharing inside the nostr ecosystem (in DMs,
// in posts, in other clients). A single button with a chooser would be
// cleaner, but two icons keep both actions one tap away.
if (event != null) {
// Direct "Add to phone calendar" — opens the system event composer with
// every field pre-filled. Falls back to the .ics share path if the device
// has no calendar app registered for ACTION_INSERT (rare; Wear OS, some
// GrapheneOS profiles).
IconButton(onClick = {
if (!addToPhoneCalendar(context, event)) {
val ics = IcsExport.appointmentToIcs(event, targetAddress, TimeUtils.now())
val filename = IcsExport.appointmentFilename(event, targetAddress)
shareIcs(context, filename, ics)
}
}) {
Icon(
symbol = MaterialSymbols.EventAvailable,
contentDescription = stringRes(R.string.calendar_add_to_phone_calendar),
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.onSurface,
)
}
IconButton(onClick = {
val ics = IcsExport.appointmentToIcs(event, targetAddress, TimeUtils.now())
val filename = IcsExport.appointmentFilename(event, targetAddress)
shareIcs(context, filename, ics)
}) {
Icon(
symbol = MaterialSymbols.Share,
contentDescription = stringRes(R.string.calendar_export_event),
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.onSurface,
)
}
val shareTitle = stringRes(R.string.calendar_share_nostr_title)
IconButton(onClick = {
val naddr =
NAddress.create(
targetAddress.kind,
targetAddress.pubKeyHex,
targetAddress.dTag,
null,
)
val intent =
Intent(Intent.ACTION_SEND)
.setType("text/plain")
.putExtra(Intent.EXTRA_TEXT, "nostr:$naddr")
context.startActivity(
Intent
.createChooser(intent, shareTitle)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
)
}) {
Icon(
symbol = MaterialSymbols.AutoMirrored.Send,
contentDescription = stringRes(R.string.calendar_share_nostr),
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.onSurface,
)
}
}
// 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 ->
Column(
modifier =
Modifier
.padding(
top = pad.calculateTopPadding(),
bottom = pad.calculateBottomPadding(),
).consumeWindowInsets(pad)
.imePadding()
.fillMaxSize()
.verticalScroll(rememberScrollState()),
) {
when (event) {
is CalendarTimeSlotEvent,
is CalendarDateSlotEvent,
->
EventBody(
note = targetNote,
accountViewModel = accountViewModel,
nav = nav,
targetAddress = targetAddress,
)
is CalendarEvent ->
CollectionBody(
note = targetNote,
accountViewModel = accountViewModel,
nav = nav,
targetAddress = targetAddress,
)
else -> LoadingPlaceholder()
}
}
}
}
@Composable
private fun LoadingPlaceholder() {
Box(
modifier = Modifier.fillMaxSize().padding(32.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = stringRes(R.string.calendar_event_loading),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@Composable
private fun EventBody(
note: Note,
accountViewModel: AccountViewModel,
nav: INav,
targetAddress: Address,
) {
val view = note.appointmentView() ?: return
val event = note.event ?: return
val participants =
remember(note.idHex) {
when (val e = event) {
is CalendarTimeSlotEvent -> e.participants()
is CalendarDateSlotEvent -> e.participants()
else -> emptyList()
}
}
HeroImage(view.image, accountViewModel)
Spacer(modifier = Modifier.height(12.dp))
Column(modifier = Modifier.padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
view.title?.let {
Text(
text = it,
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
)
}
formatCalendarRange(note)?.let { range ->
Text(
text = range,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.primary,
)
}
val context = LocalContext.current
val relative =
remember(note.idHex, view.startSeconds) {
relativeTimeLabel(context, view, TimeUtils.now())
}
relative?.let {
Text(
text = it,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
view.location?.let { LocationRow(it) }
view.summary?.let {
Text(
text = it,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
// Standard social actions (zap, reactions/likes, repost, reply count → thread/comments).
// Uses the shared [ReactionsRow] so the affordances look and behave the same as every other
// note-detail surface in the app — no calendar-specific reinvention. The row carries its
// own internal vertical padding, so we don't add any here; an outer spacedBy compounded with
// that padding made the row look like it had huge top/bottom margins.
ReactionsRow(
baseNote = note,
showReactionDetail = true,
addPadding = true,
editState = null,
accountViewModel = accountViewModel,
nav = nav,
)
HorizontalDivider()
Spacer(modifier = Modifier.height(8.dp))
CalendarRsvpRow(
eventKind = event.kind,
eventPubKey = event.pubKey,
eventDTag = targetAddress.dTag,
eventId = event.id,
accountViewModel = accountViewModel,
)
Spacer(modifier = Modifier.height(8.dp))
if (participants.isNotEmpty()) {
HorizontalDivider()
Spacer(modifier = Modifier.height(12.dp))
ParticipantsSection(participants, accountViewModel, nav)
Spacer(modifier = Modifier.height(12.dp))
}
HorizontalDivider()
Spacer(modifier = Modifier.height(12.dp))
RsvpsSection(targetAddress, accountViewModel, nav)
Spacer(modifier = Modifier.height(12.dp))
HorizontalDivider()
Spacer(modifier = Modifier.height(12.dp))
InCalendarsSection(targetAddress, accountViewModel, nav)
Spacer(modifier = Modifier.height(24.dp))
}
/**
* Detail body for a NIP-52 kind-31924 calendar collection. Renders the same author header /
* social actions surface as an appointment, but the body is a list of the collection's member
* appointments instead of the appointment metadata block. Tapping a member routes to its own
* appointment detail.
*/
@Composable
private fun CollectionBody(
note: Note,
accountViewModel: AccountViewModel,
nav: INav,
targetAddress: Address,
) {
val event = note.event as? CalendarEvent ?: return
val title = remember(note.idHex) { event.title() }
val memberAddresses = remember(note.idHex) { event.calendarEventAddresses() }
Spacer(modifier = Modifier.height(12.dp))
Column(modifier = Modifier.padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
title?.let {
Text(
text = it,
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
)
}
if (event.content.isNotBlank()) {
Text(
text = event.content,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
ReactionsRow(
baseNote = note,
showReactionDetail = true,
addPadding = true,
editState = null,
accountViewModel = accountViewModel,
nav = nav,
)
HorizontalDivider()
Spacer(modifier = Modifier.height(12.dp))
CollectionMembersSection(memberAddresses, targetAddress, accountViewModel, nav)
Spacer(modifier = Modifier.height(24.dp))
}
/**
* Lists the appointments addressed by [memberAddresses]. Each row tries to resolve the cached
* appointment event; if it hasn't arrived yet we fall back to a thin "(loading…)" placeholder
* tied to the address so the user can still see what's expected. Tapping a row routes to that
* appointment's detail screen.
*/
@Composable
private fun CollectionMembersSection(
memberAddresses: List<Address>,
targetAddress: Address,
accountViewModel: AccountViewModel,
nav: INav,
) {
Column(modifier = Modifier.padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
SectionTitle(stringRes(R.string.calendar_collection_count, memberAddresses.size))
if (memberAddresses.isEmpty()) {
Text(
text = stringRes(R.string.calendar_collection_empty_members),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
return@Column
}
memberAddresses.forEach { address ->
CollectionMemberRow(address, accountViewModel, nav)
}
if (targetAddress.dTag.isNotEmpty()) Unit // suppress unused-parameter lint
}
}
/**
* One row in [CollectionMembersSection]. Reads the live note for [address] so a member event
* that arrives later in the session fills in without leaving the screen.
*/
@Composable
private fun CollectionMemberRow(
address: Address,
accountViewModel: AccountViewModel,
nav: INav,
) {
val memberNote = remember(address) { LocalCache.getOrCreateAddressableNote(address) }
// Issues a per-event relay subscription so missing members fill in while the user is on
// the screen.
val state by observeNote(memberNote, accountViewModel)
val memberEvent = state.note.event
val title =
when (memberEvent) {
is CalendarTimeSlotEvent -> memberEvent.title()
is CalendarDateSlotEvent -> memberEvent.title()
else -> null
}
val subtitle =
when (memberEvent) {
is CalendarTimeSlotEvent -> memberEvent.start()?.let(::formatLongDate)
is CalendarDateSlotEvent -> memberEvent.start()
else -> null
}
Row(
modifier =
Modifier
.fillMaxWidth()
.clickable { nav.nav(Route.CalendarEventDetail(address)) }
.padding(vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
Icon(
symbol = MaterialSymbols.CalendarMonth,
contentDescription = null,
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.primary,
)
Column(modifier = Modifier.weight(1f)) {
Text(
text = title ?: stringRes(R.string.calendar_untitled),
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.SemiBold,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
subtitle?.let {
Text(
text = it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
Icon(
symbol = MaterialSymbols.ChevronRight,
contentDescription = null,
modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@Composable
private fun HeroImage(
image: String?,
accountViewModel: AccountViewModel,
) {
if (image.isNullOrBlank()) return
MyAsyncImage(
imageUrl = image,
contentDescription = null,
contentScale = ContentScale.FillWidth,
mainImageModifier = Modifier.fillMaxWidth().height(200.dp),
loadedImageModifier = Modifier,
accountViewModel = accountViewModel,
onLoadingBackground = { Box(modifier = Modifier.fillMaxWidth().height(200.dp)) },
onError = { Box(modifier = Modifier.fillMaxWidth().height(200.dp)) },
)
}
@Composable
private fun LocationRow(location: String) {
val context = LocalContext.current
// NIP-52 `location` is free-text — a place name, an address, OR a meeting URL (Zoom, Jitsi,
// a livestream link). Treat http(s) URLs as web links so they open in the browser; otherwise
// hand to the maps app via the geo: intent.
val isUrl = remember(location) { isLocationUrl(location) }
Row(
modifier =
Modifier
.fillMaxWidth()
.clickable {
runCatching {
val trimmed = location.trim()
val intent =
if (isUrl) {
Intent(Intent.ACTION_VIEW, Uri.parse(trimmed))
} else {
// `geo:0,0?q=<location>` is the Android geo intent; the user's
// installed maps app handles it.
Intent(Intent.ACTION_VIEW, Uri.parse("geo:0,0?q=${Uri.encode(trimmed)}"))
}
// runCatching swallows ActivityNotFoundException when no handler is
// installed — we don't have anywhere useful to fall back to.
context.startActivity(intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
}
}.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
symbol = if (isUrl) MaterialSymbols.Link else MaterialSymbols.LocationOn,
contentDescription =
stringRes(if (isUrl) R.string.calendar_open_link else R.string.calendar_open_in_maps),
modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.primary,
)
Spacer(modifier = Modifier.size(6.dp))
Text(
text = location,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.weight(1f),
)
Icon(
symbol = MaterialSymbols.ChevronRight,
contentDescription = null,
modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@Composable
private fun ParticipantsSection(
participants: List<PTag>,
accountViewModel: AccountViewModel,
nav: INav,
) {
Column(modifier = Modifier.padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
SectionTitle(stringRes(R.string.calendar_participants_section, participants.size))
participants.forEach { p ->
UserRow(p.pubKey, accountViewModel, nav, trailing = null)
}
}
}
@Composable
private fun RsvpsSection(
targetAddress: Address,
accountViewModel: AccountViewModel,
nav: INav,
) {
// Reactively re-scan when LocalCache emits new bundles. Without this, RSVPs that arrive
// from relays while the screen is open don't show up until the user leaves and returns.
val rsvps by rememberRsvpsFor(targetAddress)
Column(modifier = Modifier.padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
SectionTitle(stringRes(R.string.calendar_rsvp_section, rsvps.size))
if (rsvps.isEmpty()) {
Text(
text = stringRes(R.string.calendar_rsvp_none),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
return@Column
}
rsvps.forEach { rsvp ->
UserRow(
pubKey = rsvp.pubKey,
accountViewModel = accountViewModel,
nav = nav,
trailing = { RsvpStatusBadge(rsvp.status()) },
)
}
}
}
@Composable
private fun InCalendarsSection(
targetAddress: Address,
accountViewModel: AccountViewModel,
nav: INav,
) {
val calendars by rememberCalendarsContaining(targetAddress)
var showAddSheet by remember { mutableStateOf(false) }
Column(modifier = Modifier.padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
SectionTitle(stringRes(R.string.calendar_event_in_calendars, calendars.size))
Spacer(modifier = Modifier.weight(1f))
// Affordance for adding/removing this event from the user's own calendars. Hidden
// when the user has no own calendars — the sheet would show an empty-state in that
// case anyway, but the IconButton would be a misleading entry point.
IconButton(onClick = { showAddSheet = true }) {
Icon(
symbol = MaterialSymbols.Add,
contentDescription = stringRes(R.string.calendar_add_to_calendar_action),
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.primary,
)
}
}
if (showAddSheet) {
AddToCalendarSheet(
targetAddress = targetAddress,
accountViewModel = accountViewModel,
onDismiss = { showAddSheet = false },
)
}
if (calendars.isEmpty()) {
Text(
text = stringRes(R.string.calendar_event_in_no_calendars),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
return@Column
}
calendars.forEach { calendar ->
Row(
modifier =
Modifier
.fillMaxWidth()
.clickable {
nav.nav(
Route.CalendarEventDetail(
kind = CalendarEvent.KIND,
pubKeyHex = calendar.pubKey,
dTag = calendar.dTag(),
),
)
}.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
ClickableUserPicture(
baseUserHex = calendar.pubKey,
size = Size30dp,
accountViewModel = accountViewModel,
)
Text(
text = calendar.title() ?: stringRes(R.string.calendar_untitled),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
}
}
}
/**
* Shared social-row layout: avatar (clickable profile), display name, optional trailing slot
* for things like RSVP badges. Matches the visual language of every other user-list across the
* app.
*/
@Composable
private fun UserRow(
pubKey: String,
accountViewModel: AccountViewModel,
nav: INav,
trailing: (@Composable () -> Unit)?,
) {
LoadUser(baseUserHex = pubKey, accountViewModel = accountViewModel) { user ->
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
ClickableUserPicture(
baseUserHex = pubKey,
size = Size35dp,
accountViewModel = accountViewModel,
onClick = { nav.nav(Route.Profile(pubKey)) },
)
if (user != null) {
UsernameDisplay(
baseUser = user,
weight = Modifier.weight(1f),
accountViewModel = accountViewModel,
)
} else {
// LoadUser is still resolving — show the npub-style fallback so the row
// doesn't visibly collapse.
Text(
text = formatPubKeyShort(pubKey),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
trailing?.invoke()
}
}
}
@Composable
private fun RsvpStatusBadge(status: RSVPStatusTag.STATUS?) {
val (label, color) =
when (status) {
RSVPStatusTag.STATUS.ACCEPTED ->
stringRes(R.string.calendar_rsvp_going_prefixed) to MaterialTheme.colorScheme.primary
RSVPStatusTag.STATUS.TENTATIVE ->
stringRes(R.string.calendar_rsvp_maybe_prefixed) to MaterialTheme.colorScheme.tertiary
RSVPStatusTag.STATUS.DECLINED ->
stringRes(R.string.calendar_rsvp_not_going_prefixed) to MaterialTheme.colorScheme.error
null -> "" to MaterialTheme.colorScheme.onSurfaceVariant
}
Text(
text = label,
style = MaterialTheme.typography.labelMedium,
color = color,
fontWeight = FontWeight.SemiBold,
)
}
@Composable
private fun SectionTitle(text: String) {
Text(
text = text,
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(bottom = 4.dp),
)
}
private fun formatPubKeyShort(pubKey: String): String = if (pubKey.length <= 16) pubKey else pubKey.take(8) + "" + pubKey.takeLast(8)
/**
* Whether a NIP-52 `location` value should be treated as a clickable web link (Zoom, Jitsi,
* livestream URL) instead of a place name to look up in the maps app. Trims so `" https://… "`
* still classifies as a URL.
*/
private fun isLocationUrl(location: String): Boolean {
val trimmed = location.trim()
return trimmed.startsWith("http://", ignoreCase = true) ||
trimmed.startsWith("https://", ignoreCase = true)
}
/**
* Reactive scan of [LocalCache] for kind-31925 RSVPs that a-tag [targetAddress]. Re-runs on
* every new-event bundle so RSVPs that arrive while the screen is open appear without a manual
* refresh. The scan is O(addressables) which is bounded by the relay subscription.
*/
@Composable
private fun rememberRsvpsFor(targetAddress: Address): State<List<CalendarRSVPEvent>> =
produceState(initialValue = findRsvpsFor(targetAddress), targetAddress) {
LocalCache.live.newEventBundles.collect {
value = findRsvpsFor(targetAddress)
}
}
@Composable
private fun rememberCalendarsContaining(targetAddress: Address): State<List<CalendarEvent>> =
produceState(initialValue = findCalendarsContaining(targetAddress), targetAddress) {
LocalCache.live.newEventBundles.collect {
value = findCalendarsContaining(targetAddress)
}
}
private fun findRsvpsFor(targetAddress: Address): List<CalendarRSVPEvent> =
LocalCache.addressables
.filterIntoSet { _, note ->
val e = note.event
e is CalendarRSVPEvent && e.calendarEventAddress() == targetAddress
}.mapNotNull { it.event as? CalendarRSVPEvent }
.sortedByDescending { it.createdAt }
private fun findCalendarsContaining(targetAddress: Address): List<CalendarEvent> =
LocalCache.addressables
.filterIntoSet { _, note ->
val e = note.event
e is CalendarEvent && e.calendarEventAddresses().contains(targetAddress)
}.mapNotNull { it.event as? CalendarEvent }
.sortedByDescending { it.createdAt }
@@ -224,6 +224,12 @@ fun AllSettingsScreen(
onClick = { nav.nav(Route.NotificationSettings) },
)
SettingsDivider()
SettingsItem(
title = R.string.calendar_reminder_settings_title,
icon = MaterialSymbols.CalendarMonth,
onClick = { nav.nav(Route.CalendarReminderSettings) },
)
SettingsDivider()
SettingsItem(
title = R.string.compose_settings,
icon = MaterialSymbols.Edit,
@@ -45,6 +45,7 @@ fun UserCardHeader(
baseNote: Note,
accountViewModel: AccountViewModel,
nav: INav,
showTimeAgo: Boolean = true,
) {
Row(
modifier =
@@ -74,7 +75,11 @@ fun UserCardHeader(
)
}
TimeAgo(baseNote)
// The header time-ago is the note's createdAt (publication time). For most card types
// that's a meaningful "posted 5h ago" affordance. For calendar appointments and
// collections, the user cares about the event time, not when the metadata was
// published — showing both was confusing, so calendar cards hide this one.
if (showTimeAgo) TimeAgo(baseNote)
MoreOptionsButton(
baseNote = baseNote,
+120
View File
@@ -583,6 +583,7 @@
<string name="profile_badges_description">Choose which of the badges you\'ve received appear on your profile.</string>
<string name="profile_badges_empty">You haven\'t received any badges yet.</string>
<string name="pictures">Pictures</string>
<string name="calendars">Calendars</string>
<string name="shorts">Shorts</string>
<string name="public_chats">Public Chats</string>
<string name="follow_packs">Follow Packs</string>
@@ -1851,6 +1852,8 @@
<string name="route_global">Global</string>
<string name="route_video">Shorts</string>
<string name="route_pictures">Pictures</string>
<string name="route_calendars">Calendars</string>
<string name="route_calendar_collections">Calendar lists</string>
<string name="route_chess">Chess</string>
<string name="wallet">Wallet</string>
<string name="wallet_balance">Balance</string>
@@ -1917,6 +1920,123 @@
<string name="new_zap_poll">New Zap Poll</string>
<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>
<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>
<string name="calendar_view_week">Week</string>
<string name="calendar_view_day">Day</string>
<string name="calendar_section_upcoming">Upcoming</string>
<string name="calendar_section_past">Past</string>
<string name="calendar_empty_feed">No upcoming or past calendar events from your selected feed yet.</string>
<string name="calendar_empty_collections">No calendar collections yet.</string>
<string name="calendar_empty_feed_title">Your calendar is empty</string>
<string name="calendar_empty_feed_subtitle">Events shared by people you follow appear here. Tap the + button to create your own.</string>
<string name="calendar_empty_collections_title">No collections yet</string>
<string name="calendar_empty_collections_subtitle">Group events together — a meetup series, a conference track, your team\'s roadmap. Tap + to create one.</string>
<string name="calendar_empty_day_title">Nothing scheduled</string>
<string name="calendar_empty_day_subtitle">No events on this day. Tap + to add one.</string>
<string name="calendar_empty_week_title">Nothing this week</string>
<string name="calendar_empty_week_subtitle">No events fall in this week.</string>
<string name="calendar_event_title">Title</string>
<string name="calendar_event_summary">Summary</string>
<string name="calendar_event_location">Location</string>
<string name="calendar_event_image">Image URL</string>
<string name="calendar_event_all_day">All-day event</string>
<string name="calendar_event_start">Starts</string>
<string name="calendar_event_end">Ends</string>
<string name="calendar_event_hashtags">Hashtags (comma-separated)</string>
<string name="calendar_event_pick_date">Pick date</string>
<string name="calendar_event_pick_time">Pick time</string>
<string name="calendar_event_invalid">Title and start are required.</string>
<string name="calendar_event_end_before_start">End must be after start.</string>
<string name="calendar_nav_previous_month">Previous month</string>
<string name="calendar_nav_next_month">Next month</string>
<string name="calendar_nav_previous_week">Previous week</string>
<string name="calendar_nav_next_week">Next week</string>
<string name="calendar_nav_previous_day">Previous day</string>
<string name="calendar_nav_next_day">Next day</string>
<string name="calendar_no_events_today">No events on this day</string>
<string name="calendar_no_events">No events</string>
<string name="calendar_continues">Continues</string>
<string name="calendar_day_of_total">Day %1$d of %2$d</string>
<string name="calendar_add_to_phone_calendar">Add to phone calendar</string>
<string name="calendar_nav_jump_to_today">Jump to today</string>
<plurals name="calendar_day_a11y_events">
<item quantity="zero">%1$s, no events</item>
<item quantity="one">%1$s, %2$d event</item>
<item quantity="other">%1$s, %2$d events</item>
</plurals>
<string name="calendar_day_a11y_today_suffix">today</string>
<string name="calendar_day_a11y_selected_suffix">selected</string>
<string name="calendar_fab_new_event">Create a new calendar event</string>
<string name="calendar_fab_new_collection">Create a new calendar collection</string>
<string name="calendar_fab_toggle">Show create options</string>
<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_not_going_prefixed">✗ Can\'t go</string>
<string name="calendar_collection_title">Title</string>
<string name="calendar_collection_description">Description</string>
<string name="calendar_collection_invalid">A title is required.</string>
<string name="calendar_collection_count">%1$d events</string>
<string name="calendar_collection_empty_members">No events in this calendar yet.</string>
<string name="calendar_rsvp_going">Going</string>
<string name="calendar_rsvp_maybe">Maybe</string>
<string name="calendar_rsvp_not_going">Can\'t go</string>
<string name="calendar_rsvp_section">RSVPs (%1$d)</string>
<string name="calendar_rsvp_none">No RSVPs yet.</string>
<string name="calendar_participants_section">Participants (%1$d)</string>
<string name="calendar_event_in_calendars">In calendars (%1$d)</string>
<string name="calendar_event_in_no_calendars">Not part of any calendar yet.</string>
<string name="calendar_event_loading">Loading event…</string>
<string name="calendar_relative_ongoing">Happening now</string>
<string name="calendar_relative_ongoing_with_end">%1$s · ends %2$s</string>
<string name="calendar_export_share_title">Share calendar event</string>
<string name="calendar_export_event">Export to calendar (.ics)</string>
<string name="calendar_reminder_channel_id">calendar_reminders</string>
<string name="calendar_reminder_channel_name">Calendar reminders</string>
<string name="calendar_reminder_channel_description">Heads-up when an event you\'re attending is about to start.</string>
<string name="calendar_reminder_default_title">Calendar event</string>
<string name="calendar_reminder_body">Starts in %1$d minutes</string>
<string name="calendar_add_to_calendar_action">Add to one of your calendars</string>
<string name="calendar_add_to_calendar_title">Add to a calendar</string>
<string name="calendar_add_to_calendar_none">You haven\'t created any calendars yet.</string>
<string name="calendar_collection_delete">Delete calendar</string>
<string name="calendar_collection_delete_confirm_title">Delete this calendar?</string>
<string name="calendar_collection_delete_confirm_message">The calendar list will be removed. Events inside it are not deleted.</string>
<string name="calendar_event_pick_image">Pick image</string>
<string name="calendar_event_image_upload_failed">Image upload failed</string>
<string name="calendar_event_image_upload_failed_body">The picked image couldn\'t be uploaded. Try again or paste a URL.</string>
<string name="calendar_event_participants_section">Participants (%1$d)</string>
<string name="calendar_event_participant_input">Search name, npub, or nip-05</string>
<string name="calendar_event_participant_invalid">Enter a valid npub… or 64-character hex pubkey.</string>
<string name="calendar_event_participant_remove">Remove participant</string>
<string name="calendar_reminder_settings_title">Calendar reminders</string>
<string name="calendar_reminder_settings_enabled_title">Send reminders</string>
<string name="calendar_reminder_settings_enabled_subtitle">A notification fires when an event you\'re attending is about to start.</string>
<string name="calendar_reminder_settings_lead_title">Reminder lead time</string>
<string name="calendar_reminder_settings_lead_subtitle">How many minutes before the event you want to be notified.</string>
<string name="calendar_reminder_settings_lead_choice">%1$d min</string>
<string name="calendar_share_nostr">Share as nostr link</string>
<string name="calendar_share_nostr_title">Share calendar link</string>
<string name="calendar_filter_all">All calendars</string>
<string name="calendar_filter_sheet_title">Show events from…</string>
<string name="calendar_filter_no_calendars">You haven\'t created any calendars yet.</string>
<string name="calendar_open_in_maps">Open in maps</string>
<string name="calendar_open_link">Open link</string>
<string name="route_calendar_event_detail">Event details</string>
<string name="new_short_video">New Short Video</string>
<string name="new_long_video">New Long Video</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())
}
}
@@ -0,0 +1,221 @@
/*
* 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.amethyst.commons.model.nip52Calendar.calendarLocalDayKeyRange
import com.vitorpamplona.amethyst.commons.model.nip52Calendar.groupByDayKey
import com.vitorpamplona.amethyst.commons.model.nip52Calendar.groupByDayKeyExpanded
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.partitionUpcomingPast
import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent
import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import java.time.LocalDate
import java.time.ZoneId
class CalendarFeedGroupingTest {
@Test
fun groupByDayKey_dateSlotsLandOnIsoDate_inEveryZone() {
// The "Jan 15" appointment should bucket under Jan 15 regardless of viewer timezone —
// that's the contract for 31922 date-slot events. Previous UTC anchoring put it under
// Jan 14 for users in Pacific time.
val note = dateSlotNote(id = "d", start = "2025-01-15")
val grouped = groupByDayKey(listOf(note))
val expectedKey = LocalDate.of(2025, 1, 15).toEpochDay()
assertEquals(1, grouped[expectedKey]?.size)
}
@Test
fun groupByDayKey_timeSlotsBucketByLocalDate() {
// Same instant should yield the same local-date key regardless of how many calls we
// make — sanity check that the helper doesn't accidentally read the clock.
val note = timeSlotNote(id = "t", startSeconds = 1736942400L) // 2025-01-15 12:00 UTC
val grouped1 = groupByDayKey(listOf(note))
val grouped2 = groupByDayKey(listOf(note))
assertEquals(grouped1, grouped2)
assertEquals(1, grouped1.values.first().size)
}
@Test
fun groupByDayKey_multipleEventsSameDay_collectIntoOneBucket() {
val a = timeSlotNote(id = "a", startSeconds = 1736942400L) // 12:00 UTC
val b = timeSlotNote(id = "b", startSeconds = 1736942400L + 3600L) // 13:00 UTC
val c = timeSlotNote(id = "c", startSeconds = 1736942400L + 7200L) // 14:00 UTC
val grouped = groupByDayKey(listOf(a, b, c))
assertEquals(1, grouped.size)
assertEquals(3, grouped.values.first().size)
}
@Test
fun groupByDayKey_dropsNotesWithoutResolvedStart() {
val withoutStart = Note("ghost") // no event at all
val withStart = timeSlotNote(id = "with", startSeconds = 1736942400L)
val grouped = groupByDayKey(listOf(withoutStart, withStart))
assertEquals(1, grouped.size)
assertEquals(
"with",
grouped.values
.first()
.single()
.idHex,
)
}
@Test
fun partitionUpcomingPast_basicSplit() {
val now = 1_000_000L
val past = timeSlotNote(id = "past", startSeconds = now - 3600)
val future = timeSlotNote(id = "future", startSeconds = now + 3600)
val split = partitionUpcomingPast(listOf(past, future), nowSeconds = now)
assertEquals(1, split.upcoming.size)
assertEquals("future", split.upcoming[0].idHex)
assertEquals(1, split.past.size)
assertEquals("past", split.past[0].idHex)
}
@Test
fun partitionUpcomingPast_ongoingMultiDay_classifiedAsUpcoming() {
// Regression test for the audit fix: a 3-day conference that started yesterday and ends
// tomorrow should appear in "Upcoming", not "Past". The fix uses `end ?: start` so this
// case requires no relative-time guess.
val now = 1_000_000L
val ongoing = timeSlotNote(id = "ongoing", startSeconds = now - 86400, endSeconds = now + 86400)
val split = partitionUpcomingPast(listOf(ongoing), nowSeconds = now)
assertEquals("ongoing should be upcoming, not past", 1, split.upcoming.size)
assertEquals(0, split.past.size)
}
@Test
fun partitionUpcomingPast_dropsEventsWithoutStart() {
val ghost = Note("ghost")
val split = partitionUpcomingPast(listOf(ghost), nowSeconds = 1_000_000L)
assertEquals(0, split.upcoming.size)
assertEquals(0, split.past.size)
}
@Test
fun groupByDayKey_dateSlot_ignoresViewerTimezone_byUsingIsoDirectly() {
// We can't change the JVM zone mid-test reliably, but we can prove the date-slot path
// doesn't touch ZoneId by parsing a value-with-no-instant equivalent. The key for
// 2025-01-15 is always LocalDate.of(2025,1,15).toEpochDay() — a constant.
val note = dateSlotNote(id = "d", start = "2025-01-15")
val grouped = groupByDayKey(listOf(note))
val key = LocalDate.of(2025, 1, 15).toEpochDay()
assertNotNull(grouped[key])
// A neighbouring day key should be empty.
assertNull(grouped[key + 1])
assertNull(grouped[key - 1])
}
@Test
fun groupByDayKey_localDateInfoConsistentWithSystemZone() {
// For time-slot events the bucket key is the LocalDate in the system zone.
// We assert that the same instant maps to the same LocalDate as Java's stdlib derives.
val seconds = 1736942400L
val expectedKey =
java.time.Instant
.ofEpochSecond(seconds)
.atZone(ZoneId.systemDefault())
.toLocalDate()
.toEpochDay()
val grouped = groupByDayKey(listOf(timeSlotNote("x", startSeconds = seconds)))
assertTrue(grouped.containsKey(expectedKey))
}
@Test
fun groupByDayKeyExpanded_singleDayEvent_landsOnlyOnStartDay() {
// Sanity: an event with no end (or end == start) doesn't multiply itself.
val note = dateSlotNote(id = "d", start = "2025-01-15")
val grouped = groupByDayKeyExpanded(listOf(note))
val key = LocalDate.of(2025, 1, 15).toEpochDay()
assertEquals(1, grouped.size)
assertEquals(1, grouped[key]?.size)
}
@Test
fun groupByDayKeyExpanded_multiDayDateSlot_landsOnEveryDay() {
// A 3-day date-slot event should appear in each of Jan 15, 16, 17.
val note = dateSlotNote(id = "d", start = "2025-01-15", end = "2025-01-17")
val grouped = groupByDayKeyExpanded(listOf(note))
val keys = listOf(15, 16, 17).map { LocalDate.of(2025, 1, it).toEpochDay() }
assertEquals(3, grouped.size)
for (k in keys) assertEquals(1, grouped[k]?.size)
}
@Test
fun groupByDayKeyExpanded_multiDayTimeSlot_landsOnEveryDayCovered() {
// Spans ~36 hours from 12:00 UTC Jan 15 to 00:00 UTC Jan 17. Whether that crosses 2 or 3
// local days depends on the runner zone; we just assert it covers more than one day.
val note = timeSlotNote(id = "t", startSeconds = 1736942400L, endSeconds = 1736942400L + 36 * 3600L)
val grouped = groupByDayKeyExpanded(listOf(note))
assertTrue("expected multi-day event to land on >1 day", grouped.size >= 2)
}
@Test
fun calendarLocalDayKeyRange_isCappedAt366Days() {
// Defence: a malformed event with end years in the future shouldn't expand to thousands
// of day-keys and blow up the month grid.
val absurdStart = 1736942400L
val absurdEnd = absurdStart + 365L * 86400L * 10 // 10 years
val note = timeSlotNote(id = "rogue", startSeconds = absurdStart, endSeconds = absurdEnd)
val range = note.calendarLocalDayKeyRange()
assertNotNull(range)
assertTrue((range!!.last - range.first) <= 366)
}
// ---- helpers ----
private fun timeSlotNote(
id: String,
startSeconds: Long,
endSeconds: Long? = null,
): Note {
val tags =
buildList {
add(arrayOf("d", "$id-d"))
add(arrayOf("title", "T"))
add(arrayOf("start", startSeconds.toString()))
endSeconds?.let { add(arrayOf("end", it.toString())) }
}.toTypedArray()
val e = CalendarTimeSlotEvent(id, "pub", 0L, tags, "", "sig")
return Note(id).apply { event = e }
}
private fun dateSlotNote(
id: String,
start: String,
end: String? = null,
): Note {
val tags =
buildList {
add(arrayOf("d", "$id-d"))
add(arrayOf("title", "D"))
add(arrayOf("start", start))
end?.let { add(arrayOf("end", it)) }
}.toTypedArray()
val e = CalendarDateSlotEvent(id, "pub", 0L, tags, "", "sig")
return Note(id).apply { event = e }
}
}
@@ -0,0 +1,237 @@
/*
* 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 android.content.Context
import android.content.SharedPreferences
import com.vitorpamplona.amethyst.service.calendar.CalendarReminderPrefs
import com.vitorpamplona.amethyst.service.calendar.CalendarReminderStore
import io.mockk.every
import io.mockk.mockk
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
/**
* Unit tests for the device-level reminder preferences and the per-event "already notified"
* store. Backed by an in-memory fake [SharedPreferences] so the test runs on the JVM without
* needing Robolectric.
*/
class CalendarReminderPrefsTest {
private lateinit var fakePrefs: FakeSharedPreferences
private lateinit var ctx: Context
@Before
fun setUp() {
fakePrefs = FakeSharedPreferences()
ctx = mockk()
every { ctx.getSharedPreferences(any(), any()) } returns fakePrefs
}
@Test
fun prefs_defaultsMatchPublicConstants() {
val prefs = CalendarReminderPrefs(ctx)
// Defaults are the contract callers in AppModules rely on — flipping these without an
// explicit migration would silently re-enable reminders for users who had turned them
// off (or vice versa).
assertEquals(CalendarReminderPrefs.DEFAULT_ENABLED, prefs.isEnabled())
assertEquals(CalendarReminderPrefs.DEFAULT_LEAD_MINUTES, prefs.leadMinutes())
}
@Test
fun prefs_setEnabled_roundTrips() {
val prefs = CalendarReminderPrefs(ctx)
prefs.setEnabled(false)
assertFalse(prefs.isEnabled())
prefs.setEnabled(true)
assertTrue(prefs.isEnabled())
}
@Test
fun prefs_setLeadMinutes_roundTrips() {
val prefs = CalendarReminderPrefs(ctx)
prefs.setLeadMinutes(30)
assertEquals(30, prefs.leadMinutes())
}
@Test
fun store_wasNotified_isFalseByDefault() {
val store = CalendarReminderStore(ctx)
assertFalse(store.wasNotified("event-a", 1_000_000L))
}
@Test
fun store_markNotified_makesWasNotifiedTrueForSameStart() {
val store = CalendarReminderStore(ctx)
store.markNotified("event-a", 1_000_000L)
assertTrue(store.wasNotified("event-a", 1_000_000L))
}
@Test
fun store_wasNotified_isFalseWhenStartChanges() {
// Regression test for the "moved meeting" case: if the author updates the appointment
// with a new start, the store should not silently swallow the new reminder.
val store = CalendarReminderStore(ctx)
store.markNotified("event-a", 1_000_000L)
assertFalse(store.wasNotified("event-a", 2_000_000L))
}
@Test
fun store_forgetBefore_dropsOldEntries() {
val store = CalendarReminderStore(ctx)
store.markNotified("old", 1_000_000L)
store.markNotified("recent", 5_000_000L)
store.forgetBefore(3_000_000L)
assertFalse(store.wasNotified("old", 1_000_000L))
assertTrue(store.wasNotified("recent", 5_000_000L))
}
}
/**
* Bare-bones in-memory implementation of [SharedPreferences] sufficient for the prefs/store
* round-trip tests. apply() is synchronous here fine because the production code never relies
* on apply()'s async semantics.
*/
private class FakeSharedPreferences : SharedPreferences {
private val data = mutableMapOf<String, Any?>()
override fun getAll(): MutableMap<String, *> = data
override fun getString(
key: String,
defValue: String?,
): String? = data[key] as? String ?: defValue
override fun getStringSet(
key: String,
defValues: MutableSet<String>?,
): MutableSet<String>? {
@Suppress("UNCHECKED_CAST")
return data[key] as? MutableSet<String> ?: defValues
}
override fun getInt(
key: String,
defValue: Int,
): Int = (data[key] as? Int) ?: defValue
override fun getLong(
key: String,
defValue: Long,
): Long = (data[key] as? Long) ?: defValue
override fun getFloat(
key: String,
defValue: Float,
): Float = (data[key] as? Float) ?: defValue
override fun getBoolean(
key: String,
defValue: Boolean,
): Boolean = (data[key] as? Boolean) ?: defValue
override fun contains(key: String): Boolean = data.containsKey(key)
override fun edit(): SharedPreferences.Editor = FakeEditor(data)
override fun registerOnSharedPreferenceChangeListener(listener: SharedPreferences.OnSharedPreferenceChangeListener?) = Unit
override fun unregisterOnSharedPreferenceChangeListener(listener: SharedPreferences.OnSharedPreferenceChangeListener?) = Unit
}
private class FakeEditor(
private val data: MutableMap<String, Any?>,
) : SharedPreferences.Editor {
private val pending = mutableMapOf<String, Any?>()
private val removed = mutableSetOf<String>()
private var clearAll = false
override fun putString(
key: String,
value: String?,
): SharedPreferences.Editor {
pending[key] = value
return this
}
override fun putStringSet(
key: String,
values: MutableSet<String>?,
): SharedPreferences.Editor {
pending[key] = values
return this
}
override fun putInt(
key: String,
value: Int,
): SharedPreferences.Editor {
pending[key] = value
return this
}
override fun putLong(
key: String,
value: Long,
): SharedPreferences.Editor {
pending[key] = value
return this
}
override fun putFloat(
key: String,
value: Float,
): SharedPreferences.Editor {
pending[key] = value
return this
}
override fun putBoolean(
key: String,
value: Boolean,
): SharedPreferences.Editor {
pending[key] = value
return this
}
override fun remove(key: String): SharedPreferences.Editor {
removed.add(key)
return this
}
override fun clear(): SharedPreferences.Editor {
clearAll = true
return this
}
override fun commit(): Boolean {
apply()
return true
}
override fun apply() {
if (clearAll) data.clear()
removed.forEach { data.remove(it) }
data.putAll(pending)
}
}
@@ -0,0 +1,204 @@
/*
* 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.amethyst.commons.model.nip52Calendar.appointmentView
import com.vitorpamplona.amethyst.commons.model.nip52Calendar.calendarEndSeconds
import com.vitorpamplona.amethyst.commons.model.nip52Calendar.calendarLocalDayKey
import com.vitorpamplona.amethyst.commons.model.nip52Calendar.calendarStartSeconds
import com.vitorpamplona.amethyst.commons.model.nip52Calendar.parseIsoDateToUnixSeconds
import com.vitorpamplona.amethyst.commons.model.nip52Calendar.upcomingFirstCalendarOrder
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent
import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import java.time.LocalDate
import java.time.ZoneId
class CalendarSortKeysTest {
// 2025-01-15 12:00:00 UTC
private val sampleEpochSeconds = 1736942400L
private val sampleEpochSecondsPlus2h = sampleEpochSeconds + 7200L
@Test
fun parseIsoDateToUnixSeconds_validDate_anchorsAtLocalMidnight() {
val parsed = parseIsoDateToUnixSeconds("2025-01-15")
assertEquals(
"Should equal local midnight of Jan 15",
LocalDate.of(2025, 1, 15).atStartOfDay(ZoneId.systemDefault()).toEpochSecond(),
parsed,
)
}
@Test
fun parseIsoDateToUnixSeconds_blankOrNull_returnsNull() {
assertNull(parseIsoDateToUnixSeconds(null))
assertNull(parseIsoDateToUnixSeconds(""))
assertNull(parseIsoDateToUnixSeconds(" "))
}
@Test
fun parseIsoDateToUnixSeconds_invalidFormat_returnsNull() {
assertNull(parseIsoDateToUnixSeconds("01/15/2025"))
assertNull(parseIsoDateToUnixSeconds("2025-13-01")) // month 13
assertNull(parseIsoDateToUnixSeconds("garbage"))
}
@Test
fun calendarStartSeconds_timeSlot_returnsEventStart() {
val note = noteWithTimeSlot(start = sampleEpochSeconds, end = sampleEpochSecondsPlus2h)
assertEquals(sampleEpochSeconds, note.calendarStartSeconds())
assertEquals(sampleEpochSecondsPlus2h, note.calendarEndSeconds())
}
@Test
fun calendarStartSeconds_dateSlot_anchorsLocalMidnight() {
val note = noteWithDateSlot(start = "2025-01-15", end = "2025-01-17")
val expected = LocalDate.of(2025, 1, 15).atStartOfDay(ZoneId.systemDefault()).toEpochSecond()
assertEquals(expected, note.calendarStartSeconds())
}
@Test
fun calendarLocalDayKey_timeSlot_usesLocalCalendarDate() {
val note = noteWithTimeSlot(start = sampleEpochSeconds)
val expected =
java.time.Instant
.ofEpochSecond(sampleEpochSeconds)
.atZone(ZoneId.systemDefault())
.toLocalDate()
.toEpochDay()
assertEquals(expected, note.calendarLocalDayKey())
}
@Test
fun calendarLocalDayKey_dateSlot_usesIsoDirectly() {
// Date-only events must land on the ISO date in every zone — no zone conversion.
val note = noteWithDateSlot(start = "2025-01-15")
assertEquals(LocalDate.of(2025, 1, 15).toEpochDay(), note.calendarLocalDayKey())
}
@Test
fun appointmentView_timeSlot_mapsAllFields() {
val note = noteWithTimeSlot(start = sampleEpochSeconds, end = sampleEpochSecondsPlus2h)
val view = note.appointmentView()
assertTrue(view != null)
assertEquals("Demo", view!!.title)
assertEquals(false, view.isAllDay)
assertEquals(sampleEpochSeconds, view.startSeconds)
assertEquals(sampleEpochSecondsPlus2h, view.endSeconds)
}
@Test
fun appointmentView_dateSlot_isAllDayTrue() {
val note = noteWithDateSlot(start = "2025-01-15", end = "2025-01-16")
val view = note.appointmentView()
assertTrue(view != null)
assertEquals(true, view!!.isAllDay)
}
@Test
fun appointmentView_nonCalendarNote_returnsNull() {
val note = Note("no-event")
assertNull(note.appointmentView())
}
@Test
fun upcomingFirstOrder_upcomingBeforePast() {
val now = sampleEpochSeconds
val past = noteWithTimeSlot(id = "p", start = now - 3600)
val future = noteWithTimeSlot(id = "f", start = now + 3600)
val sorted = listOf(past, future).sortedWith(upcomingFirstCalendarOrder(now))
assertEquals("f", sorted[0].idHex)
assertEquals("p", sorted[1].idHex)
}
@Test
fun upcomingFirstOrder_twoFutureEvents_nearestFirst() {
val now = sampleEpochSeconds
val nearFuture = noteWithTimeSlot(id = "near", start = now + 100)
val farFuture = noteWithTimeSlot(id = "far", start = now + 10_000)
val sorted = listOf(farFuture, nearFuture).sortedWith(upcomingFirstCalendarOrder(now))
assertEquals("near", sorted[0].idHex)
assertEquals("far", sorted[1].idHex)
}
@Test
fun upcomingFirstOrder_twoPastEvents_mostRecentFirst() {
val now = sampleEpochSeconds
val recentPast = noteWithTimeSlot(id = "recent", start = now - 100)
val ancientPast = noteWithTimeSlot(id = "ancient", start = now - 10_000)
val sorted = listOf(ancientPast, recentPast).sortedWith(upcomingFirstCalendarOrder(now))
assertEquals("recent", sorted[0].idHex)
assertEquals("ancient", sorted[1].idHex)
}
@Test
fun upcomingFirstOrder_isTransitive_acrossNowBoundary() {
// Guards the previous bug where `TimeUtils.now()` was sampled inside the comparator: if
// the clock moved while sorting, an event's "upcoming" classification could flip between
// pair comparisons and trigger an IllegalArgumentException from the JDK sort. Snapshotting
// `now` once eliminates that.
val now = sampleEpochSeconds
val notes =
(0..20).map { i ->
noteWithTimeSlot(id = "n$i", start = now + (i - 10) * 60L)
}
// Should not throw.
notes.sortedWith(upcomingFirstCalendarOrder(now))
}
// ---- helpers ----
private fun noteWithTimeSlot(
id: String = "test-time",
start: Long,
end: Long? = null,
): Note {
val tags =
buildList {
add(arrayOf("d", "$id-d"))
add(arrayOf("title", "Demo"))
add(arrayOf("start", start.toString()))
end?.let { add(arrayOf("end", it.toString())) }
}.toTypedArray()
val event = CalendarTimeSlotEvent(id, "pub", 0L, tags, "content", "sig")
return Note(id).apply { this.event = event }
}
private fun noteWithDateSlot(
id: String = "test-date",
start: String,
end: String? = null,
): Note {
val tags =
buildList {
add(arrayOf("d", "$id-d"))
add(arrayOf("title", "Demo"))
add(arrayOf("start", start))
end?.let { add(arrayOf("end", it)) }
}.toTypedArray()
val event = CalendarDateSlotEvent(id, "pub", 0L, tags, "content", "sig")
return Note(id).apply { this.event = event }
}
}
@@ -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.amethyst.commons.model.nip52Calendar.computeMonthGridBars
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import java.time.LocalDate
class MonthGridBarsTest {
@Test
fun singleDayEvent_oneSegment_bothEndsRounded() {
val note = dateSlot("a", "2025-01-15")
val key = LocalDate.of(2025, 1, 15).toEpochDay()
val byDay = computeMonthGridBars(listOf(note))
val seg = byDay[key]?.single()
assertNotNull(seg)
assertTrue("single-day event should round both ends", seg!!.isLeftEnd && seg.isRightEnd)
assertEquals(0, seg.lane)
}
@Test
fun threeDayEvent_segmentPerDay_endsOnlyOnBoundaries() {
val note = dateSlot("a", "2025-01-15", end = "2025-01-17")
val byDay = computeMonthGridBars(listOf(note))
val k15 = LocalDate.of(2025, 1, 15).toEpochDay()
val k16 = LocalDate.of(2025, 1, 16).toEpochDay()
val k17 = LocalDate.of(2025, 1, 17).toEpochDay()
assertEquals(true to false, byDay[k15]!!.single().run { isLeftEnd to isRightEnd })
assertEquals(false to false, byDay[k16]!!.single().run { isLeftEnd to isRightEnd })
assertEquals(false to true, byDay[k17]!!.single().run { isLeftEnd to isRightEnd })
}
@Test
fun overlappingEvents_assignedToDistinctLanes() {
// A: Jan 1517. B: Jan 1618. They overlap on 16 and 17 so must land in different lanes.
val a = dateSlot("a", "2025-01-15", end = "2025-01-17")
val b = dateSlot("b", "2025-01-16", end = "2025-01-18")
val byDay = computeMonthGridBars(listOf(a, b))
val k16 = LocalDate.of(2025, 1, 16).toEpochDay()
val lanes = byDay[k16]!!.map { it.lane }.toSet()
assertEquals("expected two distinct lanes on the overlap day", 2, lanes.size)
}
@Test
fun longerEventTakesLowerLane_amongTies() {
// Earliest-start ties broken by length-descending: the longer event sits on lane 0 so it
// visually anchors the top, with the shorter one tucked under it.
val long3 = dateSlot("L", "2025-01-15", end = "2025-01-17")
val short1 = dateSlot("S", "2025-01-15")
val byDay = computeMonthGridBars(listOf(short1, long3))
val k15 = byDay[LocalDate.of(2025, 1, 15).toEpochDay()]!!
val longLane = k15.first { it.note === long3 }.lane
val shortLane = k15.first { it.note === short1 }.lane
assertTrue("longer event should be in a lower lane", longLane < shortLane)
}
@Test
fun nonOverlappingEvents_reuseLowestLane() {
// A: Jan 15. B: Jan 16. C: Jan 17. No overlaps → all on lane 0.
val a = dateSlot("a", "2025-01-15")
val b = dateSlot("b", "2025-01-16")
val c = dateSlot("c", "2025-01-17")
val byDay = computeMonthGridBars(listOf(a, b, c))
for (note in listOf(a, b, c)) {
val key =
note.event!!
.tags
.first { it[0] == "start" }[1]
.let(LocalDate::parse)
.toEpochDay()
assertEquals(0, byDay[key]!!.single().lane)
}
}
@Test
fun noEvents_emptyMap() {
val byDay = computeMonthGridBars(emptyList())
assertTrue(byDay.isEmpty())
}
@Test
fun noteWithoutStart_dropped() {
val ghost = Note("ghost") // no event
val real = dateSlot("a", "2025-01-15")
val byDay = computeMonthGridBars(listOf(ghost, real))
assertEquals(1, byDay.size)
assertNull(byDay[0L])
}
private fun dateSlot(
id: String,
start: String,
end: String? = null,
): Note {
val tags =
buildList {
add(arrayOf("d", "$id-d"))
add(arrayOf("title", "T"))
add(arrayOf("start", start))
end?.let { add(arrayOf("end", it)) }
}.toTypedArray()
val e = CalendarDateSlotEvent(id, "pub", 0L, tags, "", "sig")
return Note(id).apply { event = e }
}
}
@@ -0,0 +1,70 @@
/*
* 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.amethyst.ui.note.types.rsvpAddressFor
import com.vitorpamplona.amethyst.ui.note.types.rsvpDTagFor
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip52Calendar.rsvp.CalendarRSVPEvent
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Test
class RsvpDTagTest {
private val targetA = Address(31923, "alice-pubkey", "my-event")
private val targetB = Address(31922, "bob-pubkey", "another-event")
private val myPubKey = "me-pubkey"
@Test
fun rsvpDTag_isDeterministic_forSameTarget() {
// Two calls for the same target must produce the same d-tag — that's the dedupe
// contract that makes RSVP buttons reflect "my current status" rather than a history
// of taps.
assertEquals(rsvpDTagFor(targetA), rsvpDTagFor(targetA))
}
@Test
fun rsvpDTag_differsAcrossTargets() {
assertNotEquals(rsvpDTagFor(targetA), rsvpDTagFor(targetB))
}
@Test
fun rsvpDTag_encodesAllAddressComponents() {
// Make sure the d-tag is stable across kind + pubkey + dtag axes, so a copy-paste of
// the same dTag across different kinds (or different authors) doesn't collide.
val sameKind = Address(targetA.kind, "another-pub", targetA.dTag)
val sameAuthor = Address(targetA.kind, targetA.pubKeyHex, "another-dtag")
val sameDtagDifferentKind = Address(31922, targetA.pubKeyHex, targetA.dTag)
val original = rsvpDTagFor(targetA)
assertNotEquals(original, rsvpDTagFor(sameKind))
assertNotEquals(original, rsvpDTagFor(sameAuthor))
assertNotEquals(original, rsvpDTagFor(sameDtagDifferentKind))
}
@Test
fun rsvpAddressFor_usesRsvpKindAndMyPubKey() {
val addr = rsvpAddressFor(myPubKey, targetA)
assertEquals(CalendarRSVPEvent.KIND, addr.kind)
assertEquals(myPubKey, addr.pubKeyHex)
assertEquals(rsvpDTagFor(targetA), addr.dTag)
}
}
@@ -97,6 +97,7 @@ object MaterialSymbols {
val EmojiEmotions = MaterialSymbol("\uEA22")
val Error = MaterialSymbol("\uF8B6")
val ErrorOutline = MaterialSymbol("\uF8B6")
val EventAvailable = MaterialSymbol("\uE614")
val ExpandLess = MaterialSymbol("\uE5CE")
val ExpandMore = MaterialSymbol("\uE5CF")
val Explore = MaterialSymbol("\uE87A")
@@ -0,0 +1,71 @@
/*
* 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.commons.model.nip52Calendar
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent
import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent
/**
* Shared projection of NIP-52 calendar appointments. The 31922 (date-slot) and 31923 (time-slot)
* event classes have identical UI surfaces but no common interface, so UI code repeated a
* `when (event) { is Time -> e.title(); is Date -> e.title() }` block per accessor. Materialising
* the projection once collapses those branches into a single linear read.
*
* [isAllDay] discriminates the two kinds; [startSeconds] is local-midnight for 31922 (matching
* the rest of the calendar code's day-anchoring).
*/
@Immutable
data class CalendarAppointmentView(
val title: String?,
val image: String?,
val summary: String?,
val location: String?,
val startSeconds: Long?,
val endSeconds: Long?,
val isAllDay: Boolean,
)
fun Note.appointmentView(): CalendarAppointmentView? =
when (val e = event) {
is CalendarTimeSlotEvent ->
CalendarAppointmentView(
title = e.title(),
image = e.image(),
summary = e.summary(),
location = e.location(),
startSeconds = e.start(),
endSeconds = e.end() ?: e.start(),
isAllDay = false,
)
is CalendarDateSlotEvent ->
CalendarAppointmentView(
title = e.title(),
image = e.image(),
summary = e.summary(),
location = e.location(),
startSeconds = parseIsoDateToUnixSeconds(e.start()),
endSeconds = parseIsoDateToUnixSeconds(e.end()) ?: parseIsoDateToUnixSeconds(e.start()),
isAllDay = true,
)
else -> null
}
@@ -0,0 +1,185 @@
/*
* 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.commons.model.nip52Calendar
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent
import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent
import com.vitorpamplona.quartz.utils.TimeUtils
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
import java.time.format.DateTimeFormatter
// java.time formatters are thread-safe; the SimpleDateFormat predecessor was shared by sort
// (background) and grouping (UI) paths and could throw under concurrent use.
private val IsoDateParser: DateTimeFormatter = DateTimeFormatter.ISO_LOCAL_DATE
private fun parseIsoDate(date: String?): LocalDate? {
if (date.isNullOrBlank()) return null
return try {
LocalDate.parse(date, IsoDateParser)
} catch (_: Throwable) {
null
}
}
/**
* Calendar 31922 carries a calendar date (no instant). Anchor it at local midnight so that
* "Jan 15" lands on Jan 15 in the user's grid and ordering reflects their local zone UTC
* anchoring made date-only events appear a day early west of UTC.
*/
fun parseIsoDateToUnixSeconds(date: String?): Long? = parseIsoDate(date)?.atStartOfDay(ZoneId.systemDefault())?.toEpochSecond()
/**
* Unified start time as unix-seconds. For 31923 (time-slot) this is the event's instant; for
* 31922 (date-slot) it is local midnight of the calendar date. Both are suitable for ordering
* against [TimeUtils.now] and for relative-time rendering.
*/
fun Note.calendarStartSeconds(): Long? =
when (val e = event) {
is CalendarTimeSlotEvent -> e.start()
is CalendarDateSlotEvent -> parseIsoDateToUnixSeconds(e.start())
else -> null
}
fun Note.calendarEndSeconds(): Long? =
when (val e = event) {
is CalendarTimeSlotEvent -> e.end() ?: e.start()
is CalendarDateSlotEvent -> parseIsoDateToUnixSeconds(e.end()) ?: parseIsoDateToUnixSeconds(e.start())
else -> null
}
/**
* Calendar-day bucket key (days since 1970-01-01) for grouping events into day cells.
*
* For 31923, the event's instant is converted to the viewer's local date; for 31922, the ISO
* date string is parsed directly with no zone conversion (a calendar date for "Jan 15" must
* land on Jan 15 in every zone). Returns null when the start cannot be resolved.
*/
fun Note.calendarLocalDayKey(): Long? =
when (val e = event) {
is CalendarTimeSlotEvent ->
e.start()?.let {
Instant
.ofEpochSecond(it)
.atZone(ZoneId.systemDefault())
.toLocalDate()
.toEpochDay()
}
is CalendarDateSlotEvent -> parseIsoDate(e.start())?.toEpochDay()
else -> null
}
/**
* Buckets appointments by local calendar day (returned as `LocalDate.toEpochDay`). Notes that
* are not calendar appointments or whose start can't be parsed are dropped.
*/
fun groupByDayKey(notes: List<Note>): Map<Long, List<Note>> {
val map = mutableMapOf<Long, MutableList<Note>>()
notes.forEach {
val dayKey = it.calendarLocalDayKey() ?: return@forEach
map.getOrPut(dayKey) { mutableListOf() }.add(it)
}
return map
}
/**
* Inclusive `[start, end]` range of day-keys an appointment covers. A single-day event yields
* one key; a multi-day event yields every day from start through end. Returns null when the
* note isn't a calendar appointment or has no parseable start.
*
* Capped at 366 days so a malformed event with a far-future end can't blow up month-view memory.
*/
fun Note.calendarLocalDayKeyRange(): LongRange? {
val startKey = calendarLocalDayKey() ?: return null
val endKey =
when (val e = event) {
is CalendarTimeSlotEvent ->
e.end()?.let {
Instant
.ofEpochSecond(it)
.atZone(ZoneId.systemDefault())
.toLocalDate()
.toEpochDay()
} ?: startKey
is CalendarDateSlotEvent -> parseIsoDate(e.end())?.toEpochDay() ?: startKey
else -> startKey
}
val safeEnd = endKey.coerceAtLeast(startKey).coerceAtMost(startKey + 366)
return startKey..safeEnd
}
/**
* Like [groupByDayKey] but a multi-day appointment lands in every day it covers (not just the
* start day). Used by month/week/day views so a 3-day conference shows on all three rows; the
* upcoming/past list view still uses [groupByDayKey] semantics via its own ordering.
*/
fun groupByDayKeyExpanded(notes: List<Note>): Map<Long, List<Note>> {
val map = mutableMapOf<Long, MutableList<Note>>()
notes.forEach { note ->
val range = note.calendarLocalDayKeyRange() ?: return@forEach
for (key in range) {
map.getOrPut(key) { mutableListOf() }.add(note)
}
}
return map
}
/**
* Sort by: upcoming events ascending (closest first), then past events descending (most-recent
* first). [nowSeconds] is captured once per sort so the comparator stays transitive across the
* full sort run reading the clock inside `compare` would violate the [Comparator] contract on
* boundary elements.
*/
fun upcomingFirstCalendarOrder(nowSeconds: Long): Comparator<Note> =
Comparator { a, b ->
val sa = a.calendarStartSeconds()
val sb = b.calendarStartSeconds()
val primary =
when {
sa == null && sb == null -> compareCreatedAt(a, b)
sa == null -> 1
sb == null -> -1
else -> {
val aUpcoming = sa >= nowSeconds
val bUpcoming = sb >= nowSeconds
when {
aUpcoming && !bUpcoming -> -1
!aUpcoming && bUpcoming -> 1
aUpcoming -> sa.compareTo(sb) // both future: nearest first
else -> sb.compareTo(sa) // both past: most recent first
}
}
}
if (primary != 0) primary else a.idHex.compareTo(b.idHex)
}
private fun compareCreatedAt(
a: Note,
b: Note,
): Int {
val ca = a.createdAt() ?: 0L
val cb = b.createdAt() ?: 0L
return cb.compareTo(ca)
}
@@ -0,0 +1,212 @@
/*
* 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.commons.model.nip52Calendar
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent
import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent
import com.vitorpamplona.quartz.nip52Calendar.calendar.CalendarEvent
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneOffset
import java.time.format.DateTimeFormatter
/**
* Serialises NIP-52 calendar events to RFC 5545 iCalendar (`.ics`) text. The output is the
* universal calendar interchange format: tapping a generated file in any email/calendar app
* lets users import the event into Google Calendar, Apple Calendar, Outlook, Thunderbird, etc.
*
* Implements only the subset needed for NIP-52 appointments:
* - `VEVENT` per appointment with `UID`, `DTSTAMP`, `DTSTART`, `DTEND`, `SUMMARY`,
* `DESCRIPTION`, `LOCATION`, and `CATEGORIES` for hashtags.
* - 31922 date-slot events emit `DTSTART;VALUE=DATE:YYYYMMDD` (no time component); 31923
* time-slot events emit UTC instants formatted as `YYYYMMDDTHHMMSSZ`.
* - 31924 calendar collections wrap their member appointments in one `VCALENDAR`.
*
* Line folding (75-octet limit per RFC 5545) is *not* implemented modern parsers tolerate
* long lines and the resulting files import cleanly across the apps tested.
*/
object IcsExport {
private const val PRODID = "-//Amethyst//NIP-52//EN"
private const val CRLF = "\r\n"
private val UtcStamp: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss'Z'")
private val IsoBasicDate: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyyMMdd")
fun appointmentToIcs(
event: Any,
address: Address,
nowSeconds: Long,
): String {
val sb = StringBuilder()
sb.append("BEGIN:VCALENDAR").append(CRLF)
sb.append("VERSION:2.0").append(CRLF)
sb.append("PRODID:").append(PRODID).append(CRLF)
sb.append("CALSCALE:GREGORIAN").append(CRLF)
appendVEvent(sb, event, address, nowSeconds)
sb.append("END:VCALENDAR").append(CRLF)
return sb.toString()
}
/**
* Wrap multiple appointments (the membership of a kind-31924 calendar) in one VCALENDAR.
* Members that aren't in [memberEvents] are silently skipped typically because they
* haven't been fetched from relays yet.
*/
fun calendarToIcs(
calendar: CalendarEvent,
memberEvents: List<Pair<Address, Any>>,
nowSeconds: Long,
): String {
val sb = StringBuilder()
sb.append("BEGIN:VCALENDAR").append(CRLF)
sb.append("VERSION:2.0").append(CRLF)
sb.append("PRODID:").append(PRODID).append(CRLF)
sb.append("CALSCALE:GREGORIAN").append(CRLF)
calendar.title()?.let {
sb.append("X-WR-CALNAME:").append(escapeText(it)).append(CRLF)
}
calendar.content.takeIf { it.isNotBlank() }?.let {
sb.append("X-WR-CALDESC:").append(escapeText(it)).append(CRLF)
}
memberEvents.forEach { (address, event) ->
appendVEvent(sb, event, address, nowSeconds)
}
sb.append("END:VCALENDAR").append(CRLF)
return sb.toString()
}
/**
* Suggested filename for a single appointment. Sanitises the title for filesystems but
* keeps it short enough for share-sheet thumbnails. Falls back to the d-tag.
*/
fun appointmentFilename(
event: Any,
address: Address,
): String {
val title =
when (event) {
is CalendarTimeSlotEvent -> event.title()
is CalendarDateSlotEvent -> event.title()
else -> null
}
return safeFilename(title ?: address.dTag) + ".ics"
}
fun calendarFilename(calendar: CalendarEvent): String = safeFilename(calendar.title() ?: "calendar") + ".ics"
private fun safeFilename(raw: String): String =
raw
.replace(Regex("[^a-zA-Z0-9._-]+"), "-")
.trim('-', '_')
.ifBlank { "calendar" }
.take(60)
private fun appendVEvent(
sb: StringBuilder,
event: Any,
address: Address,
nowSeconds: Long,
) {
sb.append("BEGIN:VEVENT").append(CRLF)
// UID must be globally unique; "<dtag>@<pubkey>.nostr" gives stable uniqueness without
// exposing relay metadata.
sb
.append("UID:")
.append(address.dTag)
.append('@')
.append(address.pubKeyHex)
.append(".nostr")
.append(CRLF)
sb.append("DTSTAMP:").append(formatUtcInstant(nowSeconds)).append(CRLF)
when (event) {
is CalendarTimeSlotEvent -> appendTimeSlot(sb, event)
is CalendarDateSlotEvent -> appendDateSlot(sb, event)
}
sb.append("END:VEVENT").append(CRLF)
}
private fun appendTimeSlot(
sb: StringBuilder,
event: CalendarTimeSlotEvent,
) {
event.start()?.let { sb.append("DTSTART:").append(formatUtcInstant(it)).append(CRLF) }
event.end()?.let { sb.append("DTEND:").append(formatUtcInstant(it)).append(CRLF) }
event.title()?.let { sb.append("SUMMARY:").append(escapeText(it)).append(CRLF) }
appendDescription(sb, event.summary().orEmpty().ifBlank { event.content })
event.location()?.let { sb.append("LOCATION:").append(escapeText(it)).append(CRLF) }
appendCategories(sb, event.hashtags())
}
private fun appendDateSlot(
sb: StringBuilder,
event: CalendarDateSlotEvent,
) {
event.start()?.let { iso ->
tryFormatBasicDate(iso)?.let { sb.append("DTSTART;VALUE=DATE:").append(it).append(CRLF) }
}
event.end()?.let { iso ->
tryFormatBasicDate(iso)?.let { sb.append("DTEND;VALUE=DATE:").append(it).append(CRLF) }
}
event.title()?.let { sb.append("SUMMARY:").append(escapeText(it)).append(CRLF) }
appendDescription(sb, event.summary().orEmpty().ifBlank { event.content })
event.location()?.let { sb.append("LOCATION:").append(escapeText(it)).append(CRLF) }
appendCategories(sb, event.hashtags())
}
private fun appendDescription(
sb: StringBuilder,
text: String,
) {
if (text.isBlank()) return
sb.append("DESCRIPTION:").append(escapeText(text)).append(CRLF)
}
private fun appendCategories(
sb: StringBuilder,
hashtags: List<String>,
) {
if (hashtags.isEmpty()) return
sb.append("CATEGORIES:").append(hashtags.joinToString(",") { escapeText(it) }).append(CRLF)
}
private fun formatUtcInstant(unixSeconds: Long): String = UtcStamp.format(Instant.ofEpochSecond(unixSeconds).atOffset(ZoneOffset.UTC))
private fun tryFormatBasicDate(iso: String): String? =
try {
IsoBasicDate.format(LocalDate.parse(iso))
} catch (_: Throwable) {
null
}
/**
* Escapes text per RFC 5545 §3.3.11: backslash, semicolon, comma, newline. Carriage
* returns are dropped (line folding handles physical newlines for us).
*/
internal fun escapeText(raw: String): String =
raw
.replace("\\", "\\\\")
.replace("\n", "\\n")
.replace("\r", "")
.replace(",", "\\,")
.replace(";", "\\;")
}
@@ -0,0 +1,98 @@
/*
* 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.commons.model.nip52Calendar
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.commons.model.Note
/**
* One bar drawn into one day cell, with `lane` controlling its vertical position so two
* overlapping multi-day events stack rather than collide. `isLeftEnd` / `isRightEnd` control
* which corners of the bar are rounded a continuation day in the middle of a 3-day event gets
* neither end rounded, so adjacent cells visually merge into one bar.
*
* Note: the underlying [Note] is exposed so the UI can colour or label bars per event. Equality
* is on idHex so a row of cells holding the same bar share segment identity for keys.
*/
@Immutable
data class MonthGridBarSegment(
val note: Note,
val lane: Int,
val isLeftEnd: Boolean,
val isRightEnd: Boolean,
)
/**
* Maximum lanes we render before collapsing the remainder into a "+N" overflow label. Three
* matches the previous dot-row capacity and keeps each 56dp cell readable on mid-range phones.
*/
const val MONTH_GRID_MAX_LANES = 3
/**
* Greedy lane-assignment for the month grid: sort events earliest-start-first (longer events
* wins ties so they take the top lane), then for each event pick the lowest lane index whose
* full day-range is unoccupied. Returns a per-day-key map so each cell can render its own
* segments without re-running the layout.
*
* Single-day events participate in the same layout they get bars too, just short ones,
* which keeps the visual language consistent.
*/
fun computeMonthGridBars(notes: List<Note>): Map<Long, List<MonthGridBarSegment>> {
val ranges =
notes
.distinctBy { it.idHex }
.mapNotNull { n -> n.calendarLocalDayKeyRange()?.let { n to it } }
.sortedWith(
compareBy(
{ it.second.first },
{ -(it.second.last - it.second.first) },
{ it.first.idHex },
),
)
// day-key → set of lanes already claimed for that day
val occupied = mutableMapOf<Long, MutableSet<Int>>()
val perDay = mutableMapOf<Long, MutableList<MonthGridBarSegment>>()
for ((note, range) in ranges) {
// Find the lowest lane index whose full range is free. Bounded at 32 so a pathological
// input can't loop forever; overflow events still render as "+N" via the cap downstream.
var lane = 0
while (lane < 32) {
val clash = (range).any { occupied[it]?.contains(lane) == true }
if (!clash) break
lane++
}
for (day in range) {
occupied.getOrPut(day) { mutableSetOf() }.add(lane)
perDay.getOrPut(day) { mutableListOf() }.add(
MonthGridBarSegment(
note = note,
lane = lane,
isLeftEnd = day == range.first,
isRightEnd = day == range.last,
),
)
}
}
// Within each cell, sort by lane so the rendering doesn't have to.
return perDay.mapValues { (_, list) -> list.sortedBy { it.lane } }
}
@@ -0,0 +1,252 @@
/*
* 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.commons.model.nip52Calendar
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent
import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent
import com.vitorpamplona.quartz.nip52Calendar.calendar.CalendarEvent
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class IcsExportTest {
private val nowSeconds = 1_700_000_000L // 2023-11-14 22:13:20 UTC
@Test
fun timeSlot_producesWellFormedVCalendar() {
// 1700000000 = 2023-11-14 22:13:20 UTC; +1h = 2023-11-14 23:13:20 UTC.
val event =
CalendarTimeSlotEvent(
id = "id",
pubKey = "pub",
createdAt = 0L,
tags =
arrayOf(
arrayOf("d", "my-event"),
arrayOf("title", "Bitcoin meetup"),
arrayOf("start", "1700000000"),
arrayOf("end", "1700003600"),
arrayOf("location", "Storgata 8"),
),
content = "Casual hang",
sig = "sig",
)
val ics = IcsExport.appointmentToIcs(event, Address(31923, "pub", "my-event"), nowSeconds)
// Outer envelope is required by every RFC 5545 parser.
assertTrue("must wrap in VCALENDAR", ics.contains("BEGIN:VCALENDAR\r\n"))
assertTrue("must close VCALENDAR", ics.endsWith("END:VCALENDAR\r\n"))
assertTrue("must include VEVENT", ics.contains("BEGIN:VEVENT\r\n"))
assertTrue("must close VEVENT", ics.contains("END:VEVENT\r\n"))
assertTrue("must have version", ics.contains("VERSION:2.0"))
// Time-slot stamps are UTC instants.
assertTrue("DTSTART must be UTC instant", ics.contains("DTSTART:20231114T221320Z"))
assertTrue("DTEND must be UTC instant", ics.contains("DTEND:20231114T231320Z"))
assertTrue("summary present", ics.contains("SUMMARY:Bitcoin meetup"))
assertTrue("location present", ics.contains("LOCATION:Storgata 8"))
assertTrue("description present", ics.contains("DESCRIPTION:Casual hang"))
assertTrue("UID format", ics.contains("UID:my-event@pub.nostr"))
}
@Test
fun dateSlot_emitsDateValueWithoutTimeComponent() {
val event =
CalendarDateSlotEvent(
id = "id",
pubKey = "pub",
createdAt = 0L,
tags =
arrayOf(
arrayOf("d", "conf"),
arrayOf("title", "Conference"),
arrayOf("start", "2025-01-15"),
arrayOf("end", "2025-01-17"),
),
content = "",
sig = "sig",
)
val ics = IcsExport.appointmentToIcs(event, Address(31922, "pub", "conf"), nowSeconds)
// Date-only events use VALUE=DATE so calendar apps don't render them as midnight events.
assertTrue("date-slot DTSTART carries VALUE=DATE", ics.contains("DTSTART;VALUE=DATE:20250115"))
assertTrue("date-slot DTEND carries VALUE=DATE", ics.contains("DTEND;VALUE=DATE:20250117"))
assertFalse("date-slot must not include time component", ics.contains("DTSTART:20250115T"))
}
@Test
fun escapeText_quotesSpecialCharacters() {
// RFC 5545 §3.3.11: backslash, comma, semicolon, newline are reserved.
assertEquals("a\\\\b", IcsExport.escapeText("a\\b"))
assertEquals("a\\,b", IcsExport.escapeText("a,b"))
assertEquals("a\\;b", IcsExport.escapeText("a;b"))
assertEquals("line1\\nline2", IcsExport.escapeText("line1\nline2"))
assertEquals("a", IcsExport.escapeText("a\r"))
}
@Test
fun escapingFiresInSummaryAndDescription() {
val event =
CalendarTimeSlotEvent(
id = "id",
pubKey = "pub",
createdAt = 0L,
tags =
arrayOf(
arrayOf("d", "x"),
arrayOf("title", "Hi, world; happy"),
arrayOf("start", "1700000000"),
),
content = "line1\nline2",
sig = "sig",
)
val ics = IcsExport.appointmentToIcs(event, Address(31923, "pub", "x"), nowSeconds)
// The escaped string must keep the surrounding `SUMMARY:` prefix and survive whatever
// line folding parsers re-apply.
assertTrue("comma escaped", ics.contains("SUMMARY:Hi\\, world\\; happy"))
assertTrue("newline escaped", ics.contains("DESCRIPTION:line1\\nline2"))
}
@Test
fun hashtags_renderAsCategoriesLine() {
val event =
CalendarTimeSlotEvent(
id = "id",
pubKey = "pub",
createdAt = 0L,
tags =
arrayOf(
arrayOf("d", "x"),
arrayOf("title", "T"),
arrayOf("start", "1700000000"),
arrayOf("t", "bitcoin"),
arrayOf("t", "meetup"),
),
content = "",
sig = "sig",
)
val ics = IcsExport.appointmentToIcs(event, Address(31923, "pub", "x"), nowSeconds)
assertTrue("CATEGORIES line present", ics.contains("CATEGORIES:bitcoin,meetup"))
}
@Test
fun calendarToIcs_wrapsAllMembers() {
val a =
CalendarTimeSlotEvent(
id = "a",
pubKey = "pub",
createdAt = 0L,
tags =
arrayOf(
arrayOf("d", "a"),
arrayOf("title", "A"),
arrayOf("start", "1700000000"),
),
content = "",
sig = "sig",
)
val b =
CalendarDateSlotEvent(
id = "b",
pubKey = "pub",
createdAt = 0L,
tags =
arrayOf(
arrayOf("d", "b"),
arrayOf("title", "B"),
arrayOf("start", "2025-02-01"),
),
content = "",
sig = "sig",
)
val calendar =
CalendarEvent(
id = "cal",
pubKey = "pub",
createdAt = 0L,
tags = arrayOf(arrayOf("d", "my-cal"), arrayOf("title", "My Calendar")),
content = "All my events",
sig = "sig",
)
val ics =
IcsExport.calendarToIcs(
calendar,
listOf(
Address(31923, "pub", "a") to a,
Address(31922, "pub", "b") to b,
),
nowSeconds,
)
assertTrue("calendar name present", ics.contains("X-WR-CALNAME:My Calendar"))
assertTrue("calendar description present", ics.contains("X-WR-CALDESC:All my events"))
// Both members must appear inside the one VCALENDAR.
assertEquals(
"exactly two VEVENT blocks",
2,
"BEGIN:VEVENT".toRegex().findAll(ics).count(),
)
assertTrue("member A present", ics.contains("UID:a@pub.nostr"))
assertTrue("member B present", ics.contains("UID:b@pub.nostr"))
}
@Test
fun filename_sanitisesPathSeparatorsAndSpaces() {
val event =
CalendarTimeSlotEvent(
id = "id",
pubKey = "pub",
createdAt = 0L,
tags =
arrayOf(
arrayOf("d", "x"),
arrayOf("title", "Slashes / & spaces .,;"),
arrayOf("start", "1700000000"),
),
content = "",
sig = "sig",
)
val name = IcsExport.appointmentFilename(event, Address(31923, "pub", "x"))
// Must end with .ics and contain no filesystem-hostile characters.
assertTrue(name.endsWith(".ics"))
assertFalse("no slashes", name.contains('/'))
assertFalse("no commas", name.contains(','))
assertFalse("no semicolons", name.contains(';'))
}
@Test
fun filename_fallsBackToDTagWhenTitleAbsent() {
val event =
CalendarTimeSlotEvent(
id = "id",
pubKey = "pub",
createdAt = 0L,
tags = arrayOf(arrayOf("d", "fallback-dtag"), arrayOf("start", "1700000000")),
content = "",
sig = "sig",
)
val name = IcsExport.appointmentFilename(event, Address(31923, "pub", "fallback-dtag"))
assertEquals("fallback-dtag.ics", name)
}
}