diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index f50cd12b3..73d4c6976 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -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 -> diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index d91a5bf8f..9b2197180 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -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), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 96eaab07b..79600dd18 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -489,6 +489,9 @@ class Account( val livePicturesFollowLists: StateFlow = topNavFilterFlow(settings.defaultPicturesFollowList) val livePicturesFollowListsPerRelay = OutboxLoaderState(livePicturesFollowLists, cache, scope).flow + val liveCalendarsFollowLists: StateFlow = topNavFilterFlow(settings.defaultCalendarsFollowList) + val liveCalendarsFollowListsPerRelay = OutboxLoaderState(liveCalendarsFollowLists, cache, scope).flow + val liveProductsFollowLists: StateFlow = topNavFilterFlow(settings.defaultProductsFollowList) val liveProductsFollowListsPerRelay = OutboxLoaderState(liveProductsFollowLists, cache, scope).flow diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt index 94dc103b9..07ae07b86 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -163,6 +163,7 @@ class AccountSettings( val defaultDiscoveryFollowList: MutableStateFlow = MutableStateFlow(TopFilter.Global), val defaultPollsFollowList: MutableStateFlow = MutableStateFlow(TopFilter.Global), val defaultPicturesFollowList: MutableStateFlow = MutableStateFlow(TopFilter.Global), + val defaultCalendarsFollowList: MutableStateFlow = MutableStateFlow(TopFilter.Global), val defaultProductsFollowList: MutableStateFlow = MutableStateFlow(TopFilter.AroundMe), val defaultShortsFollowList: MutableStateFlow = MutableStateFlow(TopFilter.Global), val defaultPublicChatsFollowList: MutableStateFlow = 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) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/calendar/CalendarReminderNotifier.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/calendar/CalendarReminderNotifier.kt new file mode 100644 index 000000000..c496d512b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/calendar/CalendarReminderNotifier.kt @@ -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() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/calendar/CalendarReminderPrefs.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/calendar/CalendarReminderPrefs.kt new file mode 100644 index 000000000..f3dda3056 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/calendar/CalendarReminderPrefs.kt @@ -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" + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/calendar/CalendarReminderStore.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/calendar/CalendarReminderStore.kt new file mode 100644 index 000000000..eb78d5632 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/calendar/CalendarReminderStore.kt @@ -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 + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/calendar/CalendarReminderWorker.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/calendar/CalendarReminderWorker.kt new file mode 100644 index 000000000..4f1f30f7e --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/calendar/CalendarReminderWorker.kt @@ -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(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) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/PerUserAndFollowListEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/PerUserAndFollowListEoseManager.kt index 0635acab8..80bce3f6f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/PerUserAndFollowListEoseManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/PerUserAndFollowListEoseManager.kt @@ -55,6 +55,17 @@ abstract class PerUserAndFollowListEoseManager( 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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt index d4322fcda..47096c4d0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relays/EOSE.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relays/EOSE.kt index e5e8179c0..403fcaf39 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relays/EOSE.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relays/EOSE.kt @@ -51,6 +51,11 @@ open class EOSEByKey( 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( 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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt index fa62e44fe..739b41535 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt @@ -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 + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RememberForeverStates.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RememberForeverStates.kt index ede1702f2..a14f3bd52 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RememberForeverStates.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RememberForeverStates.kt @@ -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" diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index 7ad6288f6..013f94fb6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -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 { ProfileBadgesScreen(accountViewModel, nav) } composableFromBottomArgs { AwardBadgeScreen(it.kind, it.pubKeyHex, it.dTag, accountViewModel, nav) } composableFromEnd { PicturesScreen(accountViewModel, nav) } + composableFromEnd { CalendarsScreen(accountViewModel, nav) } + composableFromEnd { CalendarCollectionsScreen(accountViewModel, nav) } + composableFromEnd { CalendarReminderSettingsScreen(nav) } + composableFromEndArgs { + CalendarEventDetailScreen(it.kind, it.pubKeyHex, it.dTag, accountViewModel, nav) + } + composableFromBottomArgs { NewCalendarEventScreen(nav, accountViewModel) } + composableFromBottomArgs { + NewCalendarEventScreen(nav, accountViewModel, editKind = it.kind, editPubKeyHex = it.pubKeyHex, editDTag = it.dTag) + } + composableFromBottomArgs { NewCalendarCollectionScreen(nav, accountViewModel, it.dTag) } composableFromEnd { ProductsScreen(accountViewModel, nav) } composableFromEnd { ShortsScreen(accountViewModel, nav) } composableFromEnd { PublicChatsScreen(accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/NavBarItem.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/NavBarItem.kt index aa46500d9..95bbaf8d8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/NavBarItem.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/NavBarItem.kt @@ -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 = 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.COMMUNITIES, NavBarItem.ARTICLES, NavBarItem.PICTURES, + NavBarItem.CALENDARS, + NavBarItem.CALENDAR_COLLECTIONS, NavBarItem.SHORTS, NavBarItem.PUBLIC_CHATS, NavBarItem.FOLLOW_PACKS, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/RouteMaker.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/RouteMaker.kt index 5612b8044..212ffb347 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/RouteMaker.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/RouteMaker.kt @@ -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) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index bc0c08c42..ba8c64e22 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -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( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index 484ba1d47..c8d01409d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -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) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/CalendarCollectionRender.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/CalendarCollectionRender.kt new file mode 100644 index 000000000..a25cc8623 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/CalendarCollectionRender.kt @@ -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), + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/CalendarEvent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/CalendarEvent.kt index 2cd1bb6d9..d4684ef9c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/CalendarEvent.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/CalendarEvent.kt @@ -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() } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/CalendarRsvpRender.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/CalendarRsvpRender.kt new file mode 100644 index 000000000..13df36d32 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/CalendarRsvpRender.kt @@ -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) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/CalendarRsvpRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/CalendarRsvpRow.kt new file mode 100644 index 000000000..a14098a7f --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/CalendarRsvpRow.kt @@ -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::`). + */ +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, + ), + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt index c180e87fe..525f56eac 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt @@ -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) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/BottomBarFeedPreloaders.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/BottomBarFeedPreloaders.kt index fbe454c32..74f195431 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/BottomBarFeedPreloaders.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/BottomBarFeedPreloaders.kt @@ -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) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/AddToPhoneCalendar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/AddToPhoneCalendar.kt new file mode 100644 index 000000000..11a93892f --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/AddToPhoneCalendar.kt @@ -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? = + 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 + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarCollectionsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarCollectionsScreen.kt new file mode 100644 index 000000000..4defd26fa --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarCollectionsScreen.kt @@ -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() + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarCollectionsTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarCollectionsTopBar.kt new file mode 100644 index 000000000..e34b97aa9 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarCollectionsTopBar.kt @@ -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, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarCollectionsView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarCollectionsView.kt new file mode 100644 index 000000000..4da24d43e --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarCollectionsView.kt @@ -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> = + calendar + .calendarEventAddresses() + .mapNotNull { addr -> + val cachedEvent = LocalCache.addressables.get(addr)?.event + if (cachedEvent is CalendarTimeSlotEvent || cachedEvent is CalendarDateSlotEvent) { + addr to cachedEvent + } else { + null + } + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarDayView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarDayView.kt new file mode 100644 index 000000000..a937a04db --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarDayView.kt @@ -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? = 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, + ) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarEmptyState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarEmptyState.kt new file mode 100644 index 000000000..f930da510 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarEmptyState.kt @@ -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, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarEventListCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarEventListCard.kt new file mode 100644 index 000000000..e3f0599e8 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarEventListCard.kt @@ -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, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarFeedView.kt new file mode 100644 index 000000000..b5e311f8b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarFeedView.kt @@ -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? = 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?, +) { + 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, + val past: List, +) + +/** + * 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.applyCalendarFilter(filterAddresses: Set?): List { + 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, + nowSeconds: Long = TimeUtils.now(), +): UpcomingPastSplit { + val upcoming = mutableListOf() + val past = mutableListOf() + 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) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarFilterChip.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarFilterChip.kt new file mode 100644 index 000000000..8d0e00970 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarFilterChip.kt @@ -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>(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 = + 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
? { + if (selectedDTag == null) return null + val myPubKey = accountViewModel.userProfile().pubkeyHex + val addr = remember(selectedDTag, myPubKey) { Address(CalendarEvent.KIND, myPubKey, selectedDTag) } + val state by produceState?>(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
= + (LocalCache.addressables.get(addr)?.event as? CalendarEvent) + ?.calendarEventAddresses() + ?.toSet() + ?: emptySet() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarMonthView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarMonthView.kt new file mode 100644 index 000000000..648210bee --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarMonthView.kt @@ -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? = 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(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>, + 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, + 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, + 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, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarNavigationHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarNavigationHeader.kt new file mode 100644 index 000000000..c7107c56e --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarNavigationHeader.kt @@ -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, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarRelativeTime.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarRelativeTime.kt new file mode 100644 index 000000000..ebc8caa32 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarRelativeTime.kt @@ -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() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarReminderSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarReminderSettingsScreen.kt new file mode 100644 index 000000000..543fc987d --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarReminderSettingsScreen.kt @@ -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)) + }, + ) + } + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarScaffoldPadding.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarScaffoldPadding.kt new file mode 100644 index 000000000..5e22270e1 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarScaffoldPadding.kt @@ -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) + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarSwipeNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarSwipeNavigation.kt new file mode 100644 index 000000000..22fd2de93 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarSwipeNavigation.kt @@ -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 + } + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarTimeFormat.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarTimeFormat.kt new file mode 100644 index 000000000..f24a7cd20 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarTimeFormat.kt @@ -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) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarWeekView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarWeekView.kt new file mode 100644 index 000000000..c71f126c2 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarWeekView.kt @@ -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? = 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>, + 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()) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarsScreen.kt new file mode 100644 index 000000000..cf32ddb37 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarsScreen.kt @@ -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(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() + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarsTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarsTopBar.kt new file mode 100644 index 000000000..a351f86b3 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarsTopBar.kt @@ -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, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarsViewMode.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarsViewMode.kt new file mode 100644 index 000000000..76dace19a --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarsViewMode.kt @@ -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), +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/IcsShare.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/IcsShare.kt new file mode 100644 index 000000000..7a3ce84c4 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/IcsShare.kt @@ -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 `` 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) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/NewCalendarButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/NewCalendarButton.kt new file mode 100644 index 000000000..c4b0981c2 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/NewCalendarButton.kt @@ -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, + ) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/create/CalendarDateTimePickerButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/create/CalendarDateTimePickerButton.kt new file mode 100644 index 000000000..434d24629 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/create/CalendarDateTimePickerButton.kt @@ -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) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/create/NewCalendarCollectionScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/create/NewCalendarCollectionScreen.kt new file mode 100644 index 000000000..5f60418e0 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/create/NewCalendarCollectionScreen.kt @@ -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, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/create/NewCalendarCollectionViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/create/NewCalendarCollectionViewModel.kt new file mode 100644 index 000000000..84e811a22 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/create/NewCalendarCollectionViewModel.kt @@ -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
() + val availableAppointments = mutableStateOf>(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 { + 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 }, + ), + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/create/NewCalendarEventScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/create/NewCalendarEventScreen.kt new file mode 100644 index 000000000..c34fec7b1 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/create/NewCalendarEventScreen.kt @@ -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, + ) + } + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/create/NewCalendarEventViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/create/NewCalendarEventViewModel.kt new file mode 100644 index 000000000..637f0f0d4 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/create/NewCalendarEventViewModel.kt @@ -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() + + /** + * 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)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/dal/CalendarAppointmentsFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/dal/CalendarAppointmentsFeedFilter.kt new file mode 100644 index 000000000..2b8acb82b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/dal/CalendarAppointmentsFeedFilter.kt @@ -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() { + 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 { + 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): Set = innerApplyFilter(newItems) + + private fun buildFilterParams(account: Account): FilterByListParams = + FilterByListParams.create( + account.liveCalendarsFollowLists.value, + account.hiddenUsers.flow.value, + ) + + private fun innerApplyFilter(collection: Collection): Set { + 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): List = + items.sortedWith( + upcomingFirstCalendarOrder( + com.vitorpamplona.quartz.utils.TimeUtils + .now(), + ), + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/dal/CalendarCollectionsFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/dal/CalendarCollectionsFeedFilter.kt new file mode 100644 index 000000000..1abd38f17 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/dal/CalendarCollectionsFeedFilter.kt @@ -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() { + 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 { + 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): Set = innerApplyFilter(newItems) + + private fun buildFilterParams(account: Account): FilterByListParams = + FilterByListParams.create( + account.liveCalendarsFollowLists.value, + account.hiddenUsers.flow.value, + ) + + private fun innerApplyFilter(collection: Collection): Set { + 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): List = items.sortedWith(DefaultFeedOrder) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/datasource/CalendarsFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/datasource/CalendarsFilterAssembler.kt new file mode 100644 index 000000000..79c1cddd0 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/datasource/CalendarsFilterAssembler.kt @@ -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() { + val group = + listOf( + CalendarsSubAssembler(client, ::allKeys), + ) + + override fun invalidateKeys() = invalidateFilters() + + override fun invalidateFilters() = group.forEach { it.invalidateFilters() } + + override fun destroy() = group.forEach { it.destroy() } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/datasource/CalendarsFilterAssemblerSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/datasource/CalendarsFilterAssemblerSubscription.kt new file mode 100644 index 000000000..5f77520fb --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/datasource/CalendarsFilterAssemblerSubscription.kt @@ -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) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/datasource/CalendarsSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/datasource/CalendarsSubAssembler.kt new file mode 100644 index 000000000..56f7ff6ac --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/datasource/CalendarsSubAssembler.kt @@ -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, +) : PerUserAndFollowListEoseManager(client, allKeys) { + override fun updateFilter( + key: CalendarsQueryState, + since: SincePerRelayMap?, + ): List { + 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>() + + @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() } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/datasource/SubAssemblyHelper.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/datasource/SubAssemblyHelper.kt new file mode 100644 index 000000000..4b47c5e53 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/datasource/SubAssemblyHelper.kt @@ -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 = + 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() + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/datasource/subassemblies/CalendarKinds.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/datasource/subassemblies/CalendarKinds.kt new file mode 100644 index 000000000..369ea047c --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/datasource/subassemblies/CalendarKinds.kt @@ -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, + ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/datasource/subassemblies/FilterCalendarsByAuthors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/datasource/subassemblies/FilterCalendarsByAuthors.kt new file mode 100644 index 000000000..11b8c7e65 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/datasource/subassemblies/FilterCalendarsByAuthors.kt @@ -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, + since: Long? = null, +): List { + 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 { + 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 { + 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() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/datasource/subassemblies/FilterCalendarsByFollows.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/datasource/subassemblies/FilterCalendarsByFollows.kt new file mode 100644 index 000000000..508ef13cc --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/datasource/subassemblies/FilterCalendarsByFollows.kt @@ -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 { + 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() + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/datasource/subassemblies/FilterCalendarsByGeohashes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/datasource/subassemblies/FilterCalendarsByGeohashes.kt new file mode 100644 index 000000000..cc1d0338d --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/datasource/subassemblies/FilterCalendarsByGeohashes.kt @@ -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, + since: Long?, +): List { + 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 { + 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() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/datasource/subassemblies/FilterCalendarsByHashtag.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/datasource/subassemblies/FilterCalendarsByHashtag.kt new file mode 100644 index 000000000..04a1c983e --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/datasource/subassemblies/FilterCalendarsByHashtag.kt @@ -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, + since: Long? = null, +): List = + 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 { + 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() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/datasource/subassemblies/FilterCalendarsGlobal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/datasource/subassemblies/FilterCalendarsGlobal.kt new file mode 100644 index 000000000..65f0dc4c9 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/datasource/subassemblies/FilterCalendarsGlobal.kt @@ -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 { + 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, + ), + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/detail/AddToCalendarSheet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/detail/AddToCalendarSheet.kt new file mode 100644 index 000000000..223257de7 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/detail/AddToCalendarSheet.kt @@ -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>(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 = + 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) }) + }, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/detail/CalendarEventDetailScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/detail/CalendarEventDetailScreen.kt new file mode 100644 index 000000000..b304da4ef --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/detail/CalendarEventDetailScreen.kt @@ -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
, + 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=` 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, + 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> = + produceState(initialValue = findRsvpsFor(targetAddress), targetAddress) { + LocalCache.live.newEventBundles.collect { + value = findRsvpsFor(targetAddress) + } + } + +@Composable +private fun rememberCalendarsContaining(targetAddress: Address): State> = + produceState(initialValue = findCalendarsContaining(targetAddress), targetAddress) { + LocalCache.live.newEventBundles.collect { + value = findCalendarsContaining(targetAddress) + } + } + +private fun findRsvpsFor(targetAddress: Address): List = + 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 = + LocalCache.addressables + .filterIntoSet { _, note -> + val e = note.event + e is CalendarEvent && e.calendarEventAddresses().contains(targetAddress) + }.mapNotNull { it.event as? CalendarEvent } + .sortedByDescending { it.createdAt } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AllSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AllSettingsScreen.kt index 7fb17f1be..4988276dd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AllSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AllSettingsScreen.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/UserCardHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/UserCardHeader.kt index 5169bf2fd..d29cdeef2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/UserCardHeader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/UserCardHeader.kt @@ -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, diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index d5a6d8ca5..bb1c69e62 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -583,6 +583,7 @@ Choose which of the badges you\'ve received appear on your profile. You haven\'t received any badges yet. Pictures + Calendars Shorts Public Chats Follow Packs @@ -1851,6 +1852,8 @@ Global Shorts Pictures + Calendars + Calendar lists Chess Wallet Balance @@ -1917,6 +1920,123 @@ New Zap Poll New Regular Poll New Picture + New Calendar Event + Edit Calendar Event + Locked while editing — changing this would create a new event instead. + New Calendar + Edit Calendar + Events in this calendar (%1$d) + You haven\'t created any calendar events yet. + + Feed + Month + Week + Day + + Upcoming + Past + No upcoming or past calendar events from your selected feed yet. + No calendar collections yet. + Your calendar is empty + Events shared by people you follow appear here. Tap the + button to create your own. + No collections yet + Group events together — a meetup series, a conference track, your team\'s roadmap. Tap + to create one. + Nothing scheduled + No events on this day. Tap + to add one. + Nothing this week + No events fall in this week. + + Title + Summary + Location + Image URL + All-day event + Starts + Ends + Hashtags (comma-separated) + Pick date + Pick time + Title and start are required. + End must be after start. + Previous month + Next month + Previous week + Next week + Previous day + Next day + No events on this day + No events + Continues + Day %1$d of %2$d + Add to phone calendar + Jump to today + + %1$s, no events + %1$s, %2$d event + %1$s, %2$d events + + today + selected + Create a new calendar event + Create a new calendar collection + Show create options + (untitled) + All-day + ✓ Going + \? Maybe + ✗ Can\'t go + + Title + Description + A title is required. + %1$d events + No events in this calendar yet. + + Going + Maybe + Can\'t go + RSVPs (%1$d) + No RSVPs yet. + Participants (%1$d) + In calendars (%1$d) + Not part of any calendar yet. + Loading event… + Happening now + %1$s · ends %2$s + Share calendar event + Export to calendar (.ics) + calendar_reminders + Calendar reminders + Heads-up when an event you\'re attending is about to start. + Calendar event + Starts in %1$d minutes + Add to one of your calendars + Add to a calendar + You haven\'t created any calendars yet. + Delete calendar + Delete this calendar? + The calendar list will be removed. Events inside it are not deleted. + Pick image + Image upload failed + The picked image couldn\'t be uploaded. Try again or paste a URL. + Participants (%1$d) + Search name, npub, or nip-05 + Enter a valid npub… or 64-character hex pubkey. + Remove participant + Calendar reminders + Send reminders + A notification fires when an event you\'re attending is about to start. + Reminder lead time + How many minutes before the event you want to be notified. + %1$d min + Share as nostr link + Share calendar link + All calendars + Show events from… + You haven\'t created any calendars yet. + Open in maps + Open link + Event details New Short Video New Long Video diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/calendar/CalendarEditLoadTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/calendar/CalendarEditLoadTest.kt new file mode 100644 index 000000000..32ffdb4dd --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/calendar/CalendarEditLoadTest.kt @@ -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(), event.hashtags()) + } +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/calendar/CalendarFeedGroupingTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/calendar/CalendarFeedGroupingTest.kt new file mode 100644 index 000000000..313a966b4 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/calendar/CalendarFeedGroupingTest.kt @@ -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 } + } +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/calendar/CalendarReminderPrefsTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/calendar/CalendarReminderPrefsTest.kt new file mode 100644 index 000000000..56006280b --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/calendar/CalendarReminderPrefsTest.kt @@ -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() + + override fun getAll(): MutableMap = data + + override fun getString( + key: String, + defValue: String?, + ): String? = data[key] as? String ?: defValue + + override fun getStringSet( + key: String, + defValues: MutableSet?, + ): MutableSet? { + @Suppress("UNCHECKED_CAST") + return data[key] as? MutableSet ?: 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, +) : SharedPreferences.Editor { + private val pending = mutableMapOf() + private val removed = mutableSetOf() + 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?, + ): 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) + } +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/calendar/CalendarSortKeysTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/calendar/CalendarSortKeysTest.kt new file mode 100644 index 000000000..fded0c4bc --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/calendar/CalendarSortKeysTest.kt @@ -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 } + } +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/calendar/MonthGridBarsTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/calendar/MonthGridBarsTest.kt new file mode 100644 index 000000000..6307d8066 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/calendar/MonthGridBarsTest.kt @@ -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 15–17. B: Jan 16–18. 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 } + } +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/calendar/RsvpDTagTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/calendar/RsvpDTagTest.kt new file mode 100644 index 000000000..878356910 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/calendar/RsvpDTagTest.kt @@ -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) + } +} diff --git a/commons/src/commonMain/composeResources/font/material_symbols_outlined.ttf b/commons/src/commonMain/composeResources/font/material_symbols_outlined.ttf index f57dc675b..91b81096f 100644 Binary files a/commons/src/commonMain/composeResources/font/material_symbols_outlined.ttf and b/commons/src/commonMain/composeResources/font/material_symbols_outlined.ttf differ diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbols.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbols.kt index 74bd0fae8..1530c9af5 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbols.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbols.kt @@ -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") diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/nip52Calendar/CalendarAppointmentView.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/nip52Calendar/CalendarAppointmentView.kt new file mode 100644 index 000000000..51f1aa22b --- /dev/null +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/nip52Calendar/CalendarAppointmentView.kt @@ -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 + } diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/nip52Calendar/CalendarSortKeys.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/nip52Calendar/CalendarSortKeys.kt new file mode 100644 index 000000000..a09f17880 --- /dev/null +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/nip52Calendar/CalendarSortKeys.kt @@ -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): Map> { + val map = mutableMapOf>() + 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): Map> { + val map = mutableMapOf>() + 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 = + 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) +} diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/nip52Calendar/IcsExport.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/nip52Calendar/IcsExport.kt new file mode 100644 index 000000000..ecf32fda4 --- /dev/null +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/nip52Calendar/IcsExport.kt @@ -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>, + 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; "@.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, + ) { + 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(";", "\\;") +} diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/nip52Calendar/MonthGridBars.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/nip52Calendar/MonthGridBars.kt new file mode 100644 index 000000000..509cc03a1 --- /dev/null +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/nip52Calendar/MonthGridBars.kt @@ -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): Map> { + 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>() + val perDay = mutableMapOf>() + + 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 } } +} diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip52Calendar/IcsExportTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip52Calendar/IcsExportTest.kt new file mode 100644 index 000000000..f6e5ec7e8 --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip52Calendar/IcsExportTest.kt @@ -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) + } +}