Merge pull request #2994 from vitorpamplona/claude/nip52-calendar-screens-yQc3j
Add NIP-52 calendar events support with UI and reminder system
This commit is contained in:
@@ -721,6 +721,12 @@ class AppModules(
|
||||
ScheduledPostWorker.schedule(appContext)
|
||||
ScheduledPostWorker.scheduleCatchUp(appContext)
|
||||
|
||||
// Periodic scan that posts "starting soon" notifications for NIP-52 appointments the
|
||||
// user has RSVP'd to as ACCEPTED. 15-minute cadence matches both the WorkManager
|
||||
// periodic minimum and the lead-time window.
|
||||
com.vitorpamplona.amethyst.service.calendar.CalendarReminderWorker
|
||||
.schedule(appContext)
|
||||
|
||||
// Watch for account login and start/stop always-on notification service
|
||||
applicationIOScope.launch {
|
||||
sessionManager.accountContent.collectLatest { state ->
|
||||
|
||||
@@ -104,6 +104,7 @@ private object PrefKeys {
|
||||
const val DEFAULT_DISCOVERY_FOLLOW_LIST = "defaultDiscoveryFollowList"
|
||||
const val DEFAULT_POLLS_FOLLOW_LIST = "defaultPollsFollowList"
|
||||
const val DEFAULT_PICTURES_FOLLOW_LIST = "defaultPicturesFollowList"
|
||||
const val DEFAULT_CALENDARS_FOLLOW_LIST = "defaultCalendarsFollowList"
|
||||
const val DEFAULT_PRODUCTS_FOLLOW_LIST = "defaultProductsFollowList"
|
||||
const val DEFAULT_SHORTS_FOLLOW_LIST = "defaultShortsFollowList"
|
||||
const val DEFAULT_PUBLIC_CHATS_FOLLOW_LIST = "defaultPublicChatsFollowList"
|
||||
@@ -361,6 +362,7 @@ object LocalPreferences {
|
||||
|
||||
putString(PrefKeys.DEFAULT_POLLS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultPollsFollowList.value))
|
||||
putString(PrefKeys.DEFAULT_PICTURES_FOLLOW_LIST, JsonMapper.toJson(settings.defaultPicturesFollowList.value))
|
||||
putString(PrefKeys.DEFAULT_CALENDARS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultCalendarsFollowList.value))
|
||||
putString(PrefKeys.DEFAULT_PRODUCTS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultProductsFollowList.value))
|
||||
putString(PrefKeys.DEFAULT_SHORTS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultShortsFollowList.value))
|
||||
putString(PrefKeys.DEFAULT_PUBLIC_CHATS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultPublicChatsFollowList.value))
|
||||
@@ -641,6 +643,7 @@ object LocalPreferences {
|
||||
defaultDiscoveryFollowList = MutableStateFlow(followListPrefs.discovery),
|
||||
defaultPollsFollowList = MutableStateFlow(followListPrefs.polls),
|
||||
defaultPicturesFollowList = MutableStateFlow(followListPrefs.pictures),
|
||||
defaultCalendarsFollowList = MutableStateFlow(followListPrefs.calendars),
|
||||
defaultProductsFollowList = MutableStateFlow(followListPrefs.products),
|
||||
defaultShortsFollowList = MutableStateFlow(followListPrefs.shorts),
|
||||
defaultPublicChatsFollowList = MutableStateFlow(followListPrefs.publicChats),
|
||||
@@ -712,6 +715,7 @@ object LocalPreferences {
|
||||
val discovery: TopFilter,
|
||||
val polls: TopFilter,
|
||||
val pictures: TopFilter,
|
||||
val calendars: TopFilter,
|
||||
val products: TopFilter,
|
||||
val shorts: TopFilter,
|
||||
val publicChats: TopFilter,
|
||||
@@ -733,6 +737,7 @@ object LocalPreferences {
|
||||
discovery = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_DISCOVERY_FOLLOW_LIST, null), TopFilter.Global),
|
||||
polls = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_POLLS_FOLLOW_LIST, null), TopFilter.Global),
|
||||
pictures = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_PICTURES_FOLLOW_LIST, null), TopFilter.Global),
|
||||
calendars = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_CALENDARS_FOLLOW_LIST, null), TopFilter.Global),
|
||||
products = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_PRODUCTS_FOLLOW_LIST, null), TopFilter.AroundMe),
|
||||
shorts = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_SHORTS_FOLLOW_LIST, null), TopFilter.Global),
|
||||
publicChats = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_PUBLIC_CHATS_FOLLOW_LIST, null), TopFilter.Global),
|
||||
|
||||
@@ -489,6 +489,9 @@ class Account(
|
||||
val livePicturesFollowLists: StateFlow<IFeedTopNavFilter> = topNavFilterFlow(settings.defaultPicturesFollowList)
|
||||
val livePicturesFollowListsPerRelay = OutboxLoaderState(livePicturesFollowLists, cache, scope).flow
|
||||
|
||||
val liveCalendarsFollowLists: StateFlow<IFeedTopNavFilter> = topNavFilterFlow(settings.defaultCalendarsFollowList)
|
||||
val liveCalendarsFollowListsPerRelay = OutboxLoaderState(liveCalendarsFollowLists, cache, scope).flow
|
||||
|
||||
val liveProductsFollowLists: StateFlow<IFeedTopNavFilter> = topNavFilterFlow(settings.defaultProductsFollowList)
|
||||
val liveProductsFollowListsPerRelay = OutboxLoaderState(liveProductsFollowLists, cache, scope).flow
|
||||
|
||||
|
||||
@@ -163,6 +163,7 @@ class AccountSettings(
|
||||
val defaultDiscoveryFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
|
||||
val defaultPollsFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
|
||||
val defaultPicturesFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
|
||||
val defaultCalendarsFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
|
||||
val defaultProductsFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.AroundMe),
|
||||
val defaultShortsFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
|
||||
val defaultPublicChatsFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
|
||||
@@ -529,6 +530,17 @@ class AccountSettings(
|
||||
}
|
||||
}
|
||||
|
||||
fun changeDefaultCalendarsFollowList(name: FeedDefinition) {
|
||||
changeDefaultCalendarsFollowList(name.code)
|
||||
}
|
||||
|
||||
fun changeDefaultCalendarsFollowList(name: TopFilter) {
|
||||
if (defaultCalendarsFollowList.value != name) {
|
||||
defaultCalendarsFollowList.tryEmit(name)
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
fun changeDefaultProductsFollowList(name: FeedDefinition) {
|
||||
changeDefaultProductsFollowList(name.code)
|
||||
}
|
||||
|
||||
+119
@@ -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()
|
||||
}
|
||||
+65
@@ -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"
|
||||
}
|
||||
}
|
||||
+83
@@ -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
|
||||
}
|
||||
}
|
||||
+138
@@ -0,0 +1,138 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.calendar
|
||||
|
||||
import android.content.Context
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.ExistingPeriodicWorkPolicy
|
||||
import androidx.work.PeriodicWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.WorkerParameters
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.model.nip52Calendar.appointmentView
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent
|
||||
import com.vitorpamplona.quartz.nip52Calendar.appt.tags.RSVPStatusTag
|
||||
import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent
|
||||
import com.vitorpamplona.quartz.nip52Calendar.rsvp.CalendarRSVPEvent
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Periodic scan that posts "starting soon" notifications for appointments the user has RSVP'd
|
||||
* to as ACCEPTED.
|
||||
*
|
||||
* The work is bounded — scans LocalCache (which is bounded by the relay subscription) and
|
||||
* consults [CalendarReminderStore] to skip events that have already been notified for. Run as
|
||||
* a 15-minute periodic worker: that's the WorkManager minimum and matches the resolution of
|
||||
* the reminder UI ("starts in ~15 min" is the smallest interval users perceive as "soon").
|
||||
*/
|
||||
class CalendarReminderWorker(
|
||||
appContext: Context,
|
||||
params: WorkerParameters,
|
||||
) : CoroutineWorker(appContext, params) {
|
||||
override suspend fun doWork(): Result {
|
||||
val prefs = CalendarReminderPrefs(applicationContext)
|
||||
if (!prefs.isEnabled()) {
|
||||
Log.d(TAG) { "Reminders disabled; skipping scan." }
|
||||
return Result.success()
|
||||
}
|
||||
val now = TimeUtils.now()
|
||||
val windowEnd = now + prefs.leadMinutes() * 60L
|
||||
val store = CalendarReminderStore(applicationContext)
|
||||
|
||||
// Walk every kind-31925 RSVP authored by an account on this device. We don't have a
|
||||
// multi-account "all logged-in pubkeys" view here, so we accept any RSVP that's
|
||||
// present in cache — the alternative (looking only at the foreground account) would
|
||||
// silently break notifications for account switching during the lead window.
|
||||
val acceptedRsvps =
|
||||
LocalCache.addressables
|
||||
.filterIntoSet { _, note ->
|
||||
val e = note.event
|
||||
e is CalendarRSVPEvent && e.status() == RSVPStatusTag.STATUS.ACCEPTED
|
||||
}.mapNotNull { it.event as? CalendarRSVPEvent }
|
||||
|
||||
Log.d(TAG) { "Worker scanning ${acceptedRsvps.size} accepted RSVPs (now=$now, lead=${prefs.leadMinutes()}m)" }
|
||||
|
||||
acceptedRsvps.forEach { rsvp ->
|
||||
val targetAddress = rsvp.calendarEventAddress() ?: return@forEach
|
||||
val targetNote = LocalCache.addressables.get(targetAddress) ?: return@forEach
|
||||
val view = targetNote.appointmentView() ?: return@forEach
|
||||
val start = view.startSeconds ?: return@forEach
|
||||
val eventId =
|
||||
(targetNote.event as? CalendarTimeSlotEvent)?.id
|
||||
?: (targetNote.event as? CalendarDateSlotEvent)?.id
|
||||
?: return@forEach
|
||||
|
||||
if (start !in now..windowEnd) return@forEach
|
||||
// Keyed on (eventId, start) so a moved appointment re-fires when the new start
|
||||
// enters the lead window — the old notification stays valid in the system tray.
|
||||
if (store.wasNotified(eventId, start)) return@forEach
|
||||
|
||||
val title = view.title ?: stringRes(applicationContext, R.string.calendar_reminder_default_title)
|
||||
val minutesAway = ((start - now).coerceAtLeast(0L) / 60L).toInt()
|
||||
val body =
|
||||
stringRes(
|
||||
applicationContext,
|
||||
R.string.calendar_reminder_body,
|
||||
minutesAway,
|
||||
)
|
||||
val deepLink =
|
||||
"nostr:" +
|
||||
com.vitorpamplona.quartz.nip19Bech32.entities.NAddress
|
||||
.create(targetAddress.kind, targetAddress.pubKeyHex, targetAddress.dTag, null)
|
||||
CalendarReminderNotifier.notifyReminder(applicationContext, eventId, title, body, deepLink)
|
||||
store.markNotified(eventId, start)
|
||||
Log.d(TAG) { "Notified $eventId (starts in ${minutesAway}m)" }
|
||||
}
|
||||
|
||||
// Prune entries for events that ended more than a day ago — they can't fire again.
|
||||
store.forgetBefore(now - PRUNE_AGE_SECONDS)
|
||||
return Result.success()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "CalendarReminderWorker"
|
||||
private const val WORK_NAME = "calendar_reminder_worker"
|
||||
|
||||
// Don't bother remembering "I notified for this" entries for events whose start was
|
||||
// more than a day ago; they can't fire again so the entry is pure overhead.
|
||||
private const val PRUNE_AGE_SECONDS = 24L * 60L * 60L
|
||||
|
||||
fun schedule(context: Context) {
|
||||
val request =
|
||||
PeriodicWorkRequestBuilder<CalendarReminderWorker>(15, TimeUnit.MINUTES)
|
||||
.build()
|
||||
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
|
||||
WORK_NAME,
|
||||
ExistingPeriodicWorkPolicy.KEEP,
|
||||
request,
|
||||
)
|
||||
Log.d(TAG) { "schedule(): enqueueUniquePeriodicWork($WORK_NAME, 15 MIN, KEEP)" }
|
||||
}
|
||||
|
||||
fun cancel(context: Context) {
|
||||
WorkManager.getInstance(context).cancelUniqueWork(WORK_NAME)
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -55,6 +55,17 @@ abstract class PerUserAndFollowListEoseManager<T, U : Any>(
|
||||
|
||||
fun since(key: T) = latestEOSEs.since(user(key), list(key))
|
||||
|
||||
/**
|
||||
* Drops the EOSE state for the (user, list) pair this key resolves to so the next subscription
|
||||
* assembly will ask the relay from scratch instead of carrying over a stale `since` cursor.
|
||||
* Use when the in-memory note store may have evicted previously-loaded events (e.g.
|
||||
* [LargeSoftCache]'s WeakReference store under memory pressure) and a list switch needs to
|
||||
* re-fetch them rather than rely on a cache that's no longer there.
|
||||
*/
|
||||
protected fun clearEoseFor(key: T) {
|
||||
latestEOSEs.clear(user(key), list(key))
|
||||
}
|
||||
|
||||
fun newEose(
|
||||
key: T,
|
||||
relay: NormalizedRelayUrl,
|
||||
|
||||
+3
@@ -31,6 +31,7 @@ import com.vitorpamplona.amethyst.service.relayClient.searchCommand.SearchFilter
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.articles.datasource.ArticlesFilterAssembler
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.datasource.BadgesFilterAssembler
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.profile.datasource.ProfileBadgesFilterAssembler
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.datasource.CalendarsFilterAssembler
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.datasource.ChatroomFilterAssembler
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource.ChannelFilterAssembler
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource.ChatroomListFilterAssembler
|
||||
@@ -111,6 +112,7 @@ class RelaySubscriptionsCoordinator(
|
||||
|
||||
val polls = PollsFilterAssembler(client)
|
||||
val pictures = PicturesFilterAssembler(client)
|
||||
val calendars = CalendarsFilterAssembler(client)
|
||||
val products = ProductsFilterAssembler(client)
|
||||
val shorts = ShortsFilterAssembler(client)
|
||||
val publicChats = PublicChatsFilterAssembler(client)
|
||||
@@ -141,6 +143,7 @@ class RelaySubscriptionsCoordinator(
|
||||
discovery,
|
||||
polls,
|
||||
pictures,
|
||||
calendars,
|
||||
products,
|
||||
shorts,
|
||||
publicChats,
|
||||
|
||||
@@ -51,6 +51,11 @@ open class EOSEByKey<U : Any>(
|
||||
|
||||
fun since(listCode: U) = followList[listCode]?.relayList
|
||||
|
||||
/** Drops the EOSE state for [listCode] so the next filter assembly starts fresh. */
|
||||
fun clear(listCode: U) {
|
||||
followList.remove(listCode)
|
||||
}
|
||||
|
||||
fun newEose(
|
||||
listCode: U,
|
||||
relayUrl: NormalizedRelayUrl,
|
||||
@@ -83,6 +88,19 @@ open class EOSEAccountKey<U : Any>(
|
||||
users.remove(user)
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops the EOSE state for the (user, list) pair so the next assembly will ask the relay
|
||||
* from scratch. Used by feeds that need to force a re-fetch — e.g. calendar / pictures
|
||||
* where [LargeSoftCache]'s WeakReference store may have evicted the previously-loaded notes
|
||||
* while the user was viewing a different list.
|
||||
*/
|
||||
fun clear(
|
||||
user: User,
|
||||
listCode: U,
|
||||
) {
|
||||
users[user]?.clear(listCode)
|
||||
}
|
||||
|
||||
fun since(
|
||||
key: User,
|
||||
listCode: U,
|
||||
|
||||
@@ -49,6 +49,8 @@ import com.vitorpamplona.quartz.nip19Bech32.entities.NNote
|
||||
import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile
|
||||
import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
|
||||
import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent
|
||||
import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import com.vitorpamplona.quartz.utils.UriParser
|
||||
import kotlinx.coroutines.CancellationException
|
||||
@@ -192,7 +194,7 @@ fun uriToRoute(
|
||||
routeFor(
|
||||
note = LocalCache.getOrCreateAddressableNote(nip19.address()),
|
||||
loggedIn = account,
|
||||
) ?: Route.EventRedirect(nip19.aTag())
|
||||
) ?: calendarDirectRoute(nip19) ?: Route.EventRedirect(nip19.aTag())
|
||||
}
|
||||
|
||||
is NEmbed -> {
|
||||
@@ -254,3 +256,19 @@ fun uriToRoute(
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Direct route for an `naddr` whose event hasn't arrived in [LocalCache] yet. When a notification
|
||||
* is tapped (or a `nostr:naddr…` deep link arrives) for a calendar appointment we know is kind
|
||||
* 31922/31923, route straight to the dedicated detail screen instead of bouncing through
|
||||
* [Route.EventRedirect]. The detail screen issues its own per-event subscription, so the user
|
||||
* sees the calendar-specific loading placeholder while the event arrives, not the generic
|
||||
* redirect screen.
|
||||
*/
|
||||
private fun calendarDirectRoute(nip19: NAddress): Route? =
|
||||
when (nip19.kind) {
|
||||
CalendarTimeSlotEvent.KIND,
|
||||
CalendarDateSlotEvent.KIND,
|
||||
-> Route.CalendarEventDetail(nip19.kind, nip19.author, nip19.dTag)
|
||||
else -> null
|
||||
}
|
||||
|
||||
@@ -65,6 +65,8 @@ object ScrollStateKeys {
|
||||
const val BROWSE_EMOJI_SETS_SCREEN = "BrowseEmojiSetsFeed"
|
||||
const val COMMUNITIES_LIST = "CommunitiesListFeed"
|
||||
const val PICTURES_SCREEN = "PicturesFeed"
|
||||
const val CALENDARS_SCREEN = "CalendarsFeed"
|
||||
const val CALENDAR_COLLECTIONS_SCREEN = "CalendarCollectionsFeed"
|
||||
const val PRODUCTS_SCREEN = "ProductsFeed"
|
||||
const val SHORTS_SCREEN = "ShortsFeed"
|
||||
const val PUBLIC_CHATS_SCREEN = "PublicChatsFeed"
|
||||
|
||||
@@ -73,6 +73,12 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.list.metadat
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.membershipManagement.ArticleBookmarkListManagementScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.membershipManagement.PostBookmarkListManagementScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.old.OldBookmarkListScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.CalendarCollectionsScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.CalendarReminderSettingsScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.CalendarsScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.create.NewCalendarCollectionScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.create.NewCalendarEventScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.detail.CalendarEventDetailScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.CreateGroupScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.EditGroupInfoScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.MarmotGroupChatScreen
|
||||
@@ -252,6 +258,17 @@ fun BuildNavigation(
|
||||
composableFromEnd<Route.ProfileBadges> { ProfileBadgesScreen(accountViewModel, nav) }
|
||||
composableFromBottomArgs<Route.AwardBadge> { AwardBadgeScreen(it.kind, it.pubKeyHex, it.dTag, accountViewModel, nav) }
|
||||
composableFromEnd<Route.Pictures> { PicturesScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.Calendars> { CalendarsScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.CalendarCollections> { CalendarCollectionsScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.CalendarReminderSettings> { CalendarReminderSettingsScreen(nav) }
|
||||
composableFromEndArgs<Route.CalendarEventDetail> {
|
||||
CalendarEventDetailScreen(it.kind, it.pubKeyHex, it.dTag, accountViewModel, nav)
|
||||
}
|
||||
composableFromBottomArgs<Route.NewCalendarEvent> { NewCalendarEventScreen(nav, accountViewModel) }
|
||||
composableFromBottomArgs<Route.EditCalendarEvent> {
|
||||
NewCalendarEventScreen(nav, accountViewModel, editKind = it.kind, editPubKeyHex = it.pubKeyHex, editDTag = it.dTag)
|
||||
}
|
||||
composableFromBottomArgs<Route.NewCalendarCollection> { NewCalendarCollectionScreen(nav, accountViewModel, it.dTag) }
|
||||
composableFromEnd<Route.Products> { ProductsScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.Shorts> { ShortsScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.PublicChats> { PublicChatsScreen(accountViewModel, nav) }
|
||||
|
||||
+18
@@ -50,6 +50,8 @@ enum class NavBarItem {
|
||||
COMMUNITIES,
|
||||
ARTICLES,
|
||||
PICTURES,
|
||||
CALENDARS,
|
||||
CALENDAR_COLLECTIONS,
|
||||
SHORTS,
|
||||
PUBLIC_CHATS,
|
||||
FOLLOW_PACKS,
|
||||
@@ -199,6 +201,20 @@ val NavBarCatalog: Map<NavBarItem, NavBarItemDef> =
|
||||
icon = MaterialSymbols.Photo,
|
||||
resolveRoute = { Route.Pictures },
|
||||
),
|
||||
NavBarItem.CALENDARS to
|
||||
NavBarItemDef(
|
||||
id = NavBarItem.CALENDARS,
|
||||
labelRes = R.string.route_calendars,
|
||||
icon = MaterialSymbols.CalendarMonth,
|
||||
resolveRoute = { Route.Calendars },
|
||||
),
|
||||
NavBarItem.CALENDAR_COLLECTIONS to
|
||||
NavBarItemDef(
|
||||
id = NavBarItem.CALENDAR_COLLECTIONS,
|
||||
labelRes = R.string.route_calendar_collections,
|
||||
icon = MaterialSymbols.AutoMirrored.FormatListBulleted,
|
||||
resolveRoute = { Route.CalendarCollections },
|
||||
),
|
||||
NavBarItem.SHORTS to
|
||||
NavBarItemDef(
|
||||
id = NavBarItem.SHORTS,
|
||||
@@ -318,6 +334,8 @@ val DrawerFeedsItems: List<NavBarItem> =
|
||||
NavBarItem.COMMUNITIES,
|
||||
NavBarItem.ARTICLES,
|
||||
NavBarItem.PICTURES,
|
||||
NavBarItem.CALENDARS,
|
||||
NavBarItem.CALENDAR_COLLECTIONS,
|
||||
NavBarItem.SHORTS,
|
||||
NavBarItem.PUBLIC_CHATS,
|
||||
NavBarItem.FOLLOW_PACKS,
|
||||
|
||||
@@ -149,6 +149,18 @@ fun routeForInner(
|
||||
Route.GitRepository(noteEvent.kind, noteEvent.pubKey, noteEvent.dTag())
|
||||
}
|
||||
|
||||
// Calendar appointments route to their dedicated detail screen rather than the generic
|
||||
// Route.Note that AddressableEvent would fall through to — without this the notification
|
||||
// tap and `nostr:naddr…` deep links land on the bare note view instead of the calendar
|
||||
// detail with RSVPs, participants, and the "in calendars" list.
|
||||
is com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent -> {
|
||||
Route.CalendarEventDetail(noteEvent.kind, noteEvent.pubKey, noteEvent.dTag())
|
||||
}
|
||||
|
||||
is com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent -> {
|
||||
Route.CalendarEventDetail(noteEvent.kind, noteEvent.pubKey, noteEvent.dTag())
|
||||
}
|
||||
|
||||
is GiftWrapEvent -> {
|
||||
noteEvent.innerEventId?.let {
|
||||
routeFor(LocalCache.getOrCreateNote(it), loggedIn)
|
||||
|
||||
@@ -81,6 +81,45 @@ sealed class Route {
|
||||
|
||||
@Serializable object Pictures : Route()
|
||||
|
||||
@Serializable object Calendars : Route()
|
||||
|
||||
@Serializable object CalendarCollections : Route()
|
||||
|
||||
@Serializable
|
||||
data class NewCalendarEvent(
|
||||
val draft: String? = null,
|
||||
) : Route()
|
||||
|
||||
@Serializable
|
||||
data class EditCalendarEvent(
|
||||
val kind: Int,
|
||||
val pubKeyHex: HexKey,
|
||||
val dTag: String,
|
||||
) : Route() {
|
||||
constructor(address: Address) : this(
|
||||
kind = address.kind,
|
||||
pubKeyHex = address.pubKeyHex,
|
||||
dTag = address.dTag,
|
||||
)
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class NewCalendarCollection(
|
||||
val dTag: String? = null,
|
||||
) : Route()
|
||||
|
||||
@Serializable data class CalendarEventDetail(
|
||||
val kind: Int,
|
||||
val pubKeyHex: HexKey,
|
||||
val dTag: String,
|
||||
) : Route() {
|
||||
constructor(address: Address) : this(
|
||||
kind = address.kind,
|
||||
pubKeyHex = address.pubKeyHex,
|
||||
dTag = address.dTag,
|
||||
)
|
||||
}
|
||||
|
||||
@Serializable object Products : Route()
|
||||
|
||||
@Serializable object Shorts : Route()
|
||||
@@ -243,6 +282,8 @@ sealed class Route {
|
||||
|
||||
@Serializable object NotificationSettings : Route()
|
||||
|
||||
@Serializable object CalendarReminderSettings : Route()
|
||||
|
||||
@Serializable object Lists : Route()
|
||||
|
||||
@Serializable data class MyPeopleListView(
|
||||
|
||||
@@ -116,7 +116,9 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderAttestorRecommendation
|
||||
import com.vitorpamplona.amethyst.ui.note.types.RenderAudioHeader
|
||||
import com.vitorpamplona.amethyst.ui.note.types.RenderAudioTrack
|
||||
import com.vitorpamplona.amethyst.ui.note.types.RenderBadgeAward
|
||||
import com.vitorpamplona.amethyst.ui.note.types.RenderCalendarCollectionEvent
|
||||
import com.vitorpamplona.amethyst.ui.note.types.RenderCalendarDateSlotEvent
|
||||
import com.vitorpamplona.amethyst.ui.note.types.RenderCalendarRSVPEvent
|
||||
import com.vitorpamplona.amethyst.ui.note.types.RenderCalendarTimeSlotEvent
|
||||
import com.vitorpamplona.amethyst.ui.note.types.RenderCashuMint
|
||||
import com.vitorpamplona.amethyst.ui.note.types.RenderChannelMessage
|
||||
@@ -254,6 +256,8 @@ import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.relaySets.RelaySetEvent
|
||||
import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent
|
||||
import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent
|
||||
import com.vitorpamplona.quartz.nip52Calendar.calendar.CalendarEvent
|
||||
import com.vitorpamplona.quartz.nip52Calendar.rsvp.CalendarRSVPEvent
|
||||
import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent
|
||||
import com.vitorpamplona.quartz.nip53LiveActivities.clip.LiveActivitiesClipEvent
|
||||
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingRoomEvent
|
||||
@@ -1189,6 +1193,14 @@ private fun RenderNoteRow(
|
||||
RenderCalendarDateSlotEvent(baseNote, accountViewModel, nav)
|
||||
}
|
||||
|
||||
is CalendarEvent -> {
|
||||
RenderCalendarCollectionEvent(baseNote, accountViewModel, nav)
|
||||
}
|
||||
|
||||
is CalendarRSVPEvent -> {
|
||||
RenderCalendarRSVPEvent(baseNote, accountViewModel, nav)
|
||||
}
|
||||
|
||||
is GoalEvent -> {
|
||||
RenderGoal(baseNote, accountViewModel, nav)
|
||||
}
|
||||
|
||||
+97
@@ -0,0 +1,97 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.note.types
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.replyModifier
|
||||
import com.vitorpamplona.quartz.nip52Calendar.calendar.CalendarEvent
|
||||
|
||||
@Composable
|
||||
fun RenderCalendarCollectionEvent(
|
||||
note: Note,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val event = note.event as? CalendarEvent ?: return
|
||||
|
||||
Column(MaterialTheme.colorScheme.replyModifier) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(start = 10.dp, end = 10.dp, top = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.CalendarMonth,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(24.dp),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Spacer(modifier = Modifier.size(8.dp))
|
||||
Text(
|
||||
text = event.title() ?: "—",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
|
||||
if (event.content.isNotBlank()) {
|
||||
Spacer(modifier = StdVertSpacer)
|
||||
Text(
|
||||
text = event.content,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp),
|
||||
maxLines = 4,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = StdVertSpacer)
|
||||
Text(
|
||||
text = stringRes(R.string.calendar_collection_count, event.calendarEventAddresses().size),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(start = 10.dp, end = 10.dp, bottom = 12.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -86,6 +86,15 @@ fun RenderCalendarTimeSlotEvent(
|
||||
dateRange = dateRange,
|
||||
note = note,
|
||||
accountViewModel = accountViewModel,
|
||||
rsvpRow = {
|
||||
CalendarRsvpRow(
|
||||
eventKind = CalendarTimeSlotEvent.KIND,
|
||||
eventPubKey = noteEvent.pubKey,
|
||||
eventDTag = noteEvent.dTag(),
|
||||
eventId = noteEvent.id,
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -119,6 +128,15 @@ fun RenderCalendarDateSlotEvent(
|
||||
dateRange = dateRange,
|
||||
note = note,
|
||||
accountViewModel = accountViewModel,
|
||||
rsvpRow = {
|
||||
CalendarRsvpRow(
|
||||
eventKind = CalendarDateSlotEvent.KIND,
|
||||
eventPubKey = noteEvent.pubKey,
|
||||
eventDTag = noteEvent.dTag(),
|
||||
eventId = noteEvent.id,
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -131,6 +149,7 @@ private fun CalendarHeader(
|
||||
dateRange: String?,
|
||||
note: Note,
|
||||
accountViewModel: AccountViewModel,
|
||||
rsvpRow: @Composable () -> Unit = {},
|
||||
) {
|
||||
Column(MaterialTheme.colorScheme.replyModifier) {
|
||||
image?.let {
|
||||
@@ -209,6 +228,8 @@ private fun CalendarHeader(
|
||||
if (summary == null) {
|
||||
Spacer(modifier = StdVertSpacer)
|
||||
}
|
||||
|
||||
rsvpRow()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.note.types
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.replyModifier
|
||||
import com.vitorpamplona.quartz.nip52Calendar.appt.tags.RSVPStatusTag
|
||||
import com.vitorpamplona.quartz.nip52Calendar.rsvp.CalendarRSVPEvent
|
||||
|
||||
@Composable
|
||||
fun RenderCalendarRSVPEvent(
|
||||
note: Note,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val event = note.event as? CalendarRSVPEvent ?: return
|
||||
val status = event.status()
|
||||
val targetAddress = event.calendarEventAddress()
|
||||
val freebusy = event.freebusy()
|
||||
|
||||
val statusLabel =
|
||||
when (status) {
|
||||
RSVPStatusTag.STATUS.ACCEPTED -> stringRes(R.string.calendar_rsvp_going)
|
||||
RSVPStatusTag.STATUS.TENTATIVE -> stringRes(R.string.calendar_rsvp_maybe)
|
||||
RSVPStatusTag.STATUS.DECLINED -> stringRes(R.string.calendar_rsvp_not_going)
|
||||
null -> "—"
|
||||
}
|
||||
|
||||
val statusColor =
|
||||
when (status) {
|
||||
RSVPStatusTag.STATUS.ACCEPTED -> MaterialTheme.colorScheme.primary
|
||||
RSVPStatusTag.STATUS.TENTATIVE -> MaterialTheme.colorScheme.tertiary
|
||||
RSVPStatusTag.STATUS.DECLINED -> MaterialTheme.colorScheme.error
|
||||
null -> Color.Gray
|
||||
}
|
||||
|
||||
Column(MaterialTheme.colorScheme.replyModifier) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(start = 10.dp, end = 10.dp, top = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.CalendarMonth,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = statusColor,
|
||||
)
|
||||
Spacer(modifier = Modifier.size(8.dp))
|
||||
Text(
|
||||
text = statusLabel,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = statusColor,
|
||||
)
|
||||
}
|
||||
|
||||
if (event.content.isNotBlank()) {
|
||||
Spacer(modifier = StdVertSpacer)
|
||||
Text(
|
||||
text = event.content,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(start = 10.dp, end = 10.dp),
|
||||
)
|
||||
}
|
||||
|
||||
targetAddress?.let { addr ->
|
||||
Spacer(modifier = StdVertSpacer)
|
||||
Text(
|
||||
text = "→ ${addr.toValue()}",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(start = 10.dp, end = 10.dp, bottom = 12.dp),
|
||||
)
|
||||
}
|
||||
|
||||
if (freebusy != null) {
|
||||
Spacer(modifier = StdVertSpacer)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.note.types
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
|
||||
import com.vitorpamplona.quartz.nip52Calendar.appt.tags.RSVPStatusTag
|
||||
import com.vitorpamplona.quartz.nip52Calendar.rsvp.CalendarRSVPEvent
|
||||
|
||||
/**
|
||||
* Renders a 3-button RSVP row (Going / Maybe / Can't go) below a NIP-52 calendar appointment.
|
||||
*
|
||||
* Uses a deterministic d-tag derived from the target appointment's address so the user has
|
||||
* exactly one RSVP per event from this client — tapping again replaces it rather than appending
|
||||
* another addressable. The button matching the current status renders as filled-tonal; the
|
||||
* others render outlined.
|
||||
*/
|
||||
@Composable
|
||||
fun CalendarRsvpRow(
|
||||
eventKind: Int,
|
||||
eventPubKey: String,
|
||||
eventDTag: String,
|
||||
eventId: String,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
val myPubKey = accountViewModel.userProfile().pubkeyHex
|
||||
val targetAddress = remember(eventKind, eventPubKey, eventDTag) { Address(eventKind, eventPubKey, eventDTag) }
|
||||
val myRsvpAddress = remember(targetAddress, myPubKey) { rsvpAddressFor(myPubKey, targetAddress) }
|
||||
|
||||
val myRsvpNote = remember(myRsvpAddress) { LocalCache.getOrCreateAddressableNote(myRsvpAddress) }
|
||||
val myRsvpState by myRsvpNote
|
||||
.flow()
|
||||
.metadata.stateFlow
|
||||
.collectAsStateWithLifecycle()
|
||||
|
||||
val currentStatus = (myRsvpState.note.event as? CalendarRSVPEvent)?.status()
|
||||
|
||||
val onTap: (RSVPStatusTag.STATUS) -> Unit = { newStatus ->
|
||||
sendRsvp(
|
||||
accountViewModel = accountViewModel,
|
||||
targetAddress = targetAddress,
|
||||
eventId = eventId,
|
||||
myPubKey = myPubKey,
|
||||
status = newStatus,
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(start = 10.dp, end = 10.dp, top = 10.dp, bottom = 12.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
RsvpButton(
|
||||
label = stringRes(R.string.calendar_rsvp_going),
|
||||
status = RSVPStatusTag.STATUS.ACCEPTED,
|
||||
currentStatus = currentStatus,
|
||||
modifier = Modifier.weight(1f),
|
||||
onClick = onTap,
|
||||
)
|
||||
RsvpButton(
|
||||
label = stringRes(R.string.calendar_rsvp_maybe),
|
||||
status = RSVPStatusTag.STATUS.TENTATIVE,
|
||||
currentStatus = currentStatus,
|
||||
modifier = Modifier.weight(1f),
|
||||
onClick = onTap,
|
||||
)
|
||||
RsvpButton(
|
||||
label = stringRes(R.string.calendar_rsvp_not_going),
|
||||
status = RSVPStatusTag.STATUS.DECLINED,
|
||||
currentStatus = currentStatus,
|
||||
modifier = Modifier.weight(1f),
|
||||
onClick = onTap,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RsvpButton(
|
||||
label: String,
|
||||
status: RSVPStatusTag.STATUS,
|
||||
currentStatus: RSVPStatusTag.STATUS?,
|
||||
modifier: Modifier,
|
||||
onClick: (RSVPStatusTag.STATUS) -> Unit,
|
||||
) {
|
||||
val selected = status == currentStatus
|
||||
if (selected) {
|
||||
FilledTonalButton(
|
||||
onClick = { onClick(status) },
|
||||
modifier = modifier,
|
||||
colors =
|
||||
ButtonDefaults.filledTonalButtonColors(
|
||||
containerColor = colorFor(status),
|
||||
contentColor = Color.White,
|
||||
),
|
||||
) {
|
||||
Text(text = label)
|
||||
}
|
||||
} else {
|
||||
OutlinedButton(
|
||||
onClick = { onClick(status) },
|
||||
modifier = modifier,
|
||||
) {
|
||||
Text(text = label)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun colorFor(status: RSVPStatusTag.STATUS) =
|
||||
when (status) {
|
||||
RSVPStatusTag.STATUS.ACCEPTED -> MaterialTheme.colorScheme.primary
|
||||
RSVPStatusTag.STATUS.TENTATIVE -> MaterialTheme.colorScheme.tertiary
|
||||
RSVPStatusTag.STATUS.DECLINED -> MaterialTheme.colorScheme.error
|
||||
}
|
||||
|
||||
/**
|
||||
* Deterministic per-target d-tag so each user's RSVP for a given event is a single addressable.
|
||||
* The format mirrors the a-tag coordinate so it's debuggable (`rsvp:31923:<pubkey>:<dtag>`).
|
||||
*/
|
||||
fun rsvpDTagFor(targetAddress: Address): String = "rsvp:${targetAddress.kind}:${targetAddress.pubKeyHex}:${targetAddress.dTag}"
|
||||
|
||||
fun rsvpAddressFor(
|
||||
myPubKey: String,
|
||||
targetAddress: Address,
|
||||
): Address = Address(CalendarRSVPEvent.KIND, myPubKey, rsvpDTagFor(targetAddress))
|
||||
|
||||
private fun sendRsvp(
|
||||
accountViewModel: AccountViewModel,
|
||||
targetAddress: Address,
|
||||
eventId: String,
|
||||
myPubKey: String,
|
||||
status: RSVPStatusTag.STATUS,
|
||||
) {
|
||||
val relayHint = LocalCache.getNoteIfExists(eventId)?.relays?.firstOrNull()
|
||||
val aTag = ATag(targetAddress, relayHint)
|
||||
val pTag = PTag(targetAddress.pubKeyHex)
|
||||
val dTag = rsvpDTagFor(targetAddress)
|
||||
|
||||
accountViewModel.launchSigner {
|
||||
accountViewModel.account.signAndComputeBroadcast(
|
||||
CalendarRSVPEvent.build(
|
||||
calendarEventAddress = aTag,
|
||||
status = status,
|
||||
calendarEventAuthor = pTag,
|
||||
dTag = dTag,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
+10
@@ -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)
|
||||
|
||||
+5
@@ -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)
|
||||
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars
|
||||
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.provider.CalendarContract
|
||||
import com.vitorpamplona.amethyst.commons.model.nip52Calendar.parseIsoDateToUnixSeconds
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent
|
||||
import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent
|
||||
|
||||
/**
|
||||
* Opens the system "New Event" composer (Google Calendar / Samsung / iCloud / etc.) pre-populated
|
||||
* with this appointment's title, time, location, and description. The user sees their normal
|
||||
* calendar UI with one tap to "Save" — strictly nicer than the .ics-via-share-sheet path because
|
||||
* it doesn't go through a file and surfaces the user's preferred calendar app directly.
|
||||
*
|
||||
* Returns true if a calendar app handled the intent, false if no handler was found — the caller
|
||||
* can decide whether to fall back to the .ics share path.
|
||||
*/
|
||||
fun addToPhoneCalendar(
|
||||
context: Context,
|
||||
event: Event,
|
||||
): Boolean {
|
||||
val (title, location, summary) =
|
||||
when (event) {
|
||||
is CalendarTimeSlotEvent -> Triple(event.title(), event.location(), event.summary())
|
||||
is CalendarDateSlotEvent -> Triple(event.title(), event.location(), event.summary())
|
||||
else -> return false
|
||||
}
|
||||
val (beginMs, endMs, allDay) = computeRangeMs(event) ?: return false
|
||||
|
||||
val description =
|
||||
buildString {
|
||||
summary?.let { append(it) }
|
||||
if (event.content.isNotBlank()) {
|
||||
if (isNotEmpty()) append("\n\n")
|
||||
append(event.content)
|
||||
}
|
||||
}
|
||||
|
||||
val intent =
|
||||
Intent(Intent.ACTION_INSERT).apply {
|
||||
data = CalendarContract.Events.CONTENT_URI
|
||||
putExtra(CalendarContract.Events.TITLE, title.orEmpty())
|
||||
location?.let { putExtra(CalendarContract.Events.EVENT_LOCATION, it) }
|
||||
if (description.isNotEmpty()) {
|
||||
putExtra(CalendarContract.Events.DESCRIPTION, description)
|
||||
}
|
||||
putExtra(CalendarContract.EXTRA_EVENT_BEGIN_TIME, beginMs)
|
||||
putExtra(CalendarContract.EXTRA_EVENT_END_TIME, endMs)
|
||||
putExtra(CalendarContract.EXTRA_EVENT_ALL_DAY, allDay)
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
|
||||
return try {
|
||||
context.startActivity(intent)
|
||||
true
|
||||
} catch (_: ActivityNotFoundException) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes the (beginMillis, endMillis, isAllDay) triple from a calendar event. NIP-52 date-slot
|
||||
* uses ISO dates that we anchor at local midnight; time-slot uses unix seconds. End defaults to
|
||||
* begin + 1 hour for time-slot events without an end and to begin + 1 day for all-day events
|
||||
* without an end (calendar providers expect end > start for any visible event).
|
||||
*/
|
||||
private fun computeRangeMs(event: Event): Triple<Long, Long, Boolean>? =
|
||||
when (event) {
|
||||
is CalendarTimeSlotEvent -> {
|
||||
val start = event.start() ?: return null
|
||||
val end = event.end() ?: (start + 3600L)
|
||||
Triple(start * 1000L, end * 1000L, false)
|
||||
}
|
||||
is CalendarDateSlotEvent -> {
|
||||
val startSec = parseIsoDateToUnixSeconds(event.start()) ?: return null
|
||||
val endSec = parseIsoDateToUnixSeconds(event.end()) ?: (startSec + 86400L)
|
||||
Triple(startSec * 1000L, endSec * 1000L, true)
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
+102
@@ -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()
|
||||
}
|
||||
}
|
||||
+70
@@ -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,
|
||||
)
|
||||
}
|
||||
+230
@@ -0,0 +1,230 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.model.nip52Calendar.IcsExport
|
||||
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState
|
||||
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox
|
||||
import com.vitorpamplona.amethyst.ui.layouts.rememberFeedContentPadding
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.UserCardHeader
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent
|
||||
import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent
|
||||
import com.vitorpamplona.quartz.nip52Calendar.calendar.CalendarEvent
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
|
||||
@Composable
|
||||
fun CalendarCollectionsView(
|
||||
feedState: FeedContentState,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
RefresheableBox(feedState, true) {
|
||||
val state by feedState.feedContent.collectAsStateWithLifecycle()
|
||||
|
||||
when (val s = state) {
|
||||
is FeedState.Loaded -> CollectionsBody(s, accountViewModel, nav)
|
||||
is FeedState.Empty -> EmptyCollections()
|
||||
is FeedState.Loading -> Box(modifier = Modifier.fillMaxSize())
|
||||
is FeedState.FeedError ->
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize().padding(32.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = s.errorMessage,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CollectionsBody(
|
||||
loaded: FeedState.Loaded,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val items by loaded.feed.collectAsStateWithLifecycle()
|
||||
LazyColumn(
|
||||
// Reserve top space for the surrounding [DisappearingScaffold]'s top bar — without this
|
||||
// the first card scrolls under it on the initial render.
|
||||
contentPadding = rememberFeedContentPadding(FeedPadding),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
items(items.list, key = { it.idHex }) { note ->
|
||||
CalendarCollectionCard(note, accountViewModel, nav)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EmptyCollections() {
|
||||
CalendarEmptyState(
|
||||
title = stringRes(R.string.calendar_empty_collections_title),
|
||||
subtitle = stringRes(R.string.calendar_empty_collections_subtitle),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CalendarCollectionCard(
|
||||
note: Note,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val event = note.event as? CalendarEvent ?: return
|
||||
val title = remember(note.idHex) { event.title() }
|
||||
val description = remember(note.idHex) { event.content.take(180) }
|
||||
val count = remember(note.idHex) { event.calendarEventAddresses().size }
|
||||
val context = LocalContext.current
|
||||
|
||||
// Calendar collections are addressable (kind 31924); route to the dedicated
|
||||
// CalendarEventDetail screen instead of the generic Route.Note thread view, which
|
||||
// didn't know how to render them as a list of member appointments.
|
||||
val collectionRoute = remember(note.idHex, event) { Route.CalendarEventDetail(event.address()) }
|
||||
Card(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 6.dp)
|
||||
.clickable { nav.nav(collectionRoute) },
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
colors = CardDefaults.elevatedCardColors(),
|
||||
elevation = CardDefaults.elevatedCardElevation(defaultElevation = 2.dp),
|
||||
) {
|
||||
// Author header matches every other social card in the app, but without the createdAt
|
||||
// timestamp — the user cares about the collection's events, not when the metadata was
|
||||
// last edited.
|
||||
UserCardHeader(
|
||||
baseNote = note,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
showTimeAgo = false,
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier.padding(start = 14.dp, end = 14.dp, bottom = 14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.CalendarMonth,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(32.dp),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier.weight(1f).padding(start = 14.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Text(
|
||||
text = title ?: "—",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (description.isNotBlank()) {
|
||||
Text(
|
||||
text = description,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = stringRes(R.string.calendar_collection_count, count),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
IconButton(onClick = {
|
||||
val members = collectMembers(event)
|
||||
val ics = IcsExport.calendarToIcs(event, members, TimeUtils.now())
|
||||
val filename = IcsExport.calendarFilename(event)
|
||||
shareIcs(context, filename, ics)
|
||||
}) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Share,
|
||||
contentDescription = stringRes(R.string.calendar_export_event),
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a calendar's member addresses to their cached events, skipping members that haven't
|
||||
* arrived from relays yet or aren't appointments. Returns a list compatible with
|
||||
* [IcsExport.calendarToIcs].
|
||||
*/
|
||||
private fun collectMembers(calendar: CalendarEvent): List<Pair<Address, Any>> =
|
||||
calendar
|
||||
.calendarEventAddresses()
|
||||
.mapNotNull { addr ->
|
||||
val cachedEvent = LocalCache.addressables.get(addr)?.event
|
||||
if (cachedEvent is CalendarTimeSlotEvent || cachedEvent is CalendarDateSlotEvent) {
|
||||
addr to cachedEvent
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
+233
@@ -0,0 +1,233 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.model.nip52Calendar.appointmentView
|
||||
import com.vitorpamplona.amethyst.commons.model.nip52Calendar.calendarLocalDayKeyRange
|
||||
import com.vitorpamplona.amethyst.commons.model.nip52Calendar.groupByDayKeyExpanded
|
||||
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState
|
||||
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.ui.layouts.rememberFeedContentPadding
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
|
||||
import java.time.LocalDate
|
||||
import java.time.ZoneId
|
||||
|
||||
@Composable
|
||||
fun CalendarDayView(
|
||||
feedState: FeedContentState,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
filterAddresses: Set<com.vitorpamplona.quartz.nip01Core.core.Address>? = null,
|
||||
) {
|
||||
val state by feedState.feedContent.collectAsStateWithLifecycle()
|
||||
val notes =
|
||||
when (val s = state) {
|
||||
is FeedState.Loaded ->
|
||||
s.feed
|
||||
.collectAsStateWithLifecycle()
|
||||
.value.list
|
||||
.applyCalendarFilter(filterAddresses)
|
||||
else -> emptyList()
|
||||
}
|
||||
|
||||
val today = remember { LocalDate.now() }
|
||||
// Persisting an epoch-day Long is auto-saveable; arithmetic in [LocalDate] is DST-safe
|
||||
// (millisecond stepping was off by an hour after spring/fall transitions).
|
||||
var visibleEpochDay by rememberSaveable { mutableStateOf(today.toEpochDay()) }
|
||||
val visibleDate = LocalDate.ofEpochDay(visibleEpochDay)
|
||||
|
||||
val byDay by remember(notes) { derivedStateOf { groupByDayKeyExpanded(notes) } }
|
||||
val dayEvents = byDay[visibleDate.toEpochDay()].orEmpty()
|
||||
|
||||
val sorted =
|
||||
remember(dayEvents) {
|
||||
// All-day events bubble to the top (Long.MIN_VALUE), then time-slot events in order.
|
||||
dayEvents.sortedBy { it.appointmentView()?.startSeconds ?: Long.MAX_VALUE }
|
||||
}
|
||||
|
||||
// Single LazyColumn — nav header is the first item so it scrolls with the disappearing
|
||||
// top bar instead of staying pinned mid-screen when the bar collapses.
|
||||
LazyColumn(
|
||||
contentPadding = rememberFeedContentPadding(FeedPadding),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.calendarSwipeNavigation(
|
||||
key = visibleEpochDay,
|
||||
onSwipeLeft = { visibleEpochDay = visibleDate.plusDays(1).toEpochDay() },
|
||||
onSwipeRight = { visibleEpochDay = visibleDate.minusDays(1).toEpochDay() },
|
||||
),
|
||||
) {
|
||||
item(key = "day-nav") {
|
||||
CalendarNavigationHeader(
|
||||
title = formatLongDate(visibleDate.atStartOfDay(ZoneId.systemDefault()).toEpochSecond()),
|
||||
prevContentDescription = stringRes(R.string.calendar_nav_previous_day),
|
||||
nextContentDescription = stringRes(R.string.calendar_nav_next_day),
|
||||
onPrev = { visibleEpochDay = visibleDate.minusDays(1).toEpochDay() },
|
||||
onNext = { visibleEpochDay = visibleDate.plusDays(1).toEpochDay() },
|
||||
onToday = { visibleEpochDay = LocalDate.now().toEpochDay() },
|
||||
)
|
||||
}
|
||||
|
||||
if (dayEvents.isEmpty()) {
|
||||
item(key = "day-empty") {
|
||||
CalendarEmptyState(
|
||||
title = stringRes(R.string.calendar_empty_day_title),
|
||||
subtitle = stringRes(R.string.calendar_empty_day_subtitle),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
items(sorted, key = { it.idHex }) { note ->
|
||||
Column(modifier = Modifier.padding(horizontal = 12.dp)) {
|
||||
DayRow(
|
||||
note = note,
|
||||
visibleEpochDay = visibleEpochDay,
|
||||
onClick = { nav.nav(Route.Note(note.idHex)) },
|
||||
)
|
||||
HorizontalDivider()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DayRow(
|
||||
note: Note,
|
||||
visibleEpochDay: Long,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val view = note.appointmentView() ?: return
|
||||
val range = note.calendarLocalDayKeyRange()
|
||||
// Position within a multi-day event: today is "Day 2 of 4". Renders below the time label so
|
||||
// a continuation day on a 3-day conference reads as "9:00 AM / Day 2 of 3" rather than
|
||||
// looking like a fresh event.
|
||||
val dayOfTotal =
|
||||
if (range != null && range.last > range.first) {
|
||||
(visibleEpochDay - range.first + 1).toInt() to (range.last - range.first + 1).toInt()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
val startSeconds = view.startSeconds
|
||||
val timeLabel =
|
||||
when {
|
||||
view.isAllDay -> stringRes(R.string.calendar_all_day)
|
||||
startSeconds != null && visibleEpochDay > (range?.first ?: visibleEpochDay) ->
|
||||
// Continuation day of a multi-day timed event — the "9:00 AM" of day 1 is
|
||||
// misleading on day 2 since the event has been ongoing overnight. Show a
|
||||
// continuation marker so the user reads it as "still happening".
|
||||
stringRes(R.string.calendar_continues)
|
||||
startSeconds != null -> formatTimeOfDay(startSeconds)
|
||||
else -> "—"
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick)
|
||||
.padding(vertical = 10.dp),
|
||||
verticalAlignment = Alignment.Top,
|
||||
) {
|
||||
Text(
|
||||
text = timeLabel,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.width(72.dp),
|
||||
)
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.width(3.dp)
|
||||
.height(40.dp)
|
||||
.background(MaterialTheme.colorScheme.primary, RoundedCornerShape(2.dp)),
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier.padding(start = 12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
view.title?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
dayOfTotal?.let { (day, total) ->
|
||||
Text(
|
||||
text = stringRes(R.string.calendar_day_of_total, day, total),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
}
|
||||
view.location?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+71
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+237
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+201
@@ -0,0 +1,201 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.model.nip52Calendar.calendarEndSeconds
|
||||
import com.vitorpamplona.amethyst.commons.model.nip52Calendar.calendarStartSeconds
|
||||
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState
|
||||
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox
|
||||
import com.vitorpamplona.amethyst.ui.feeds.WatchScrollToTop
|
||||
import com.vitorpamplona.amethyst.ui.layouts.rememberFeedContentPadding
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
|
||||
@Composable
|
||||
fun CalendarFeedView(
|
||||
feedState: FeedContentState,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
filterAddresses: Set<com.vitorpamplona.quartz.nip01Core.core.Address>? = null,
|
||||
) {
|
||||
RefresheableBox(feedState, true) {
|
||||
val state by feedState.feedContent.collectAsStateWithLifecycle()
|
||||
|
||||
when (val s = state) {
|
||||
is FeedState.Loaded -> CalendarFeedLoadedBody(s, feedState, accountViewModel, nav, filterAddresses)
|
||||
is FeedState.Empty -> CalendarFeedEmpty()
|
||||
is FeedState.Loading -> Box(modifier = Modifier.fillMaxSize())
|
||||
is FeedState.FeedError -> CalendarFeedError(s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CalendarFeedLoadedBody(
|
||||
loaded: FeedState.Loaded,
|
||||
feedState: FeedContentState,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
filterAddresses: Set<com.vitorpamplona.quartz.nip01Core.core.Address>?,
|
||||
) {
|
||||
val items by loaded.feed.collectAsStateWithLifecycle()
|
||||
|
||||
val split by remember(filterAddresses) {
|
||||
derivedStateOf {
|
||||
partitionUpcomingPast(items.list.applyCalendarFilter(filterAddresses))
|
||||
}
|
||||
}
|
||||
|
||||
// Without this the top-bar filter switch fires `sendToTop()`, but the LazyColumn never hears
|
||||
// it — so the scroll position from the previous filter (e.g. mid-way through a tiny People
|
||||
// List) is preserved when the user flips back to Global, leaving the user staring at the
|
||||
// past-events section of a 100-item feed instead of the top.
|
||||
val listState = rememberLazyListState()
|
||||
WatchScrollToTop(feedState, listState)
|
||||
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
contentPadding = rememberFeedContentPadding(FeedPadding),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
if (split.upcoming.isNotEmpty()) {
|
||||
item(key = "section-upcoming") {
|
||||
SectionHeader(stringRes(R.string.calendar_section_upcoming))
|
||||
}
|
||||
items(split.upcoming, key = { it.idHex }) { note ->
|
||||
CalendarEventListCard(note, accountViewModel, nav)
|
||||
}
|
||||
}
|
||||
|
||||
if (split.past.isNotEmpty()) {
|
||||
item(key = "section-past") {
|
||||
if (split.upcoming.isNotEmpty()) {
|
||||
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
|
||||
}
|
||||
SectionHeader(stringRes(R.string.calendar_section_past))
|
||||
}
|
||||
items(split.past, key = { it.idHex }) { note ->
|
||||
CalendarEventListCard(note, accountViewModel, nav)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SectionHeader(text: String) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(start = 16.dp, top = 14.dp, bottom = 6.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CalendarFeedEmpty() {
|
||||
CalendarEmptyState(
|
||||
title = stringRes(R.string.calendar_empty_feed_title),
|
||||
subtitle = stringRes(R.string.calendar_empty_feed_subtitle),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CalendarFeedError(state: FeedState.FeedError) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize().padding(32.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = state.errorMessage,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
data class UpcomingPastSplit(
|
||||
val upcoming: List<Note>,
|
||||
val past: List<Note>,
|
||||
)
|
||||
|
||||
/**
|
||||
* Returns only notes whose appointment address is in [filterAddresses]. Pass null to skip
|
||||
* filtering (the common path when "All" is selected). Lives here as a shared helper so each
|
||||
* view body can keep its own collection logic and just opt-into the filter via one call.
|
||||
*/
|
||||
fun List<Note>.applyCalendarFilter(filterAddresses: Set<com.vitorpamplona.quartz.nip01Core.core.Address>?): List<Note> {
|
||||
if (filterAddresses == null) return this
|
||||
return filter { note ->
|
||||
val addr =
|
||||
(note.event as? com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent)?.address()
|
||||
addr != null && addr in filterAddresses
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* [nowSeconds] is taken as a parameter (rather than reading `TimeUtils.now()` internally) so the
|
||||
* split can be unit-tested deterministically and so callers that already snapshot `now` for a
|
||||
* sort don't read the clock twice.
|
||||
*/
|
||||
fun partitionUpcomingPast(
|
||||
items: List<Note>,
|
||||
nowSeconds: Long = TimeUtils.now(),
|
||||
): UpcomingPastSplit {
|
||||
val upcoming = mutableListOf<Note>()
|
||||
val past = mutableListOf<Note>()
|
||||
items.forEach {
|
||||
val s = it.calendarStartSeconds() ?: return@forEach
|
||||
// An event that started yesterday but ends tomorrow is "happening now", not over.
|
||||
// Fall back to start when end is missing so the legacy single-instant behaviour is kept.
|
||||
val effectiveEnd = it.calendarEndSeconds() ?: s
|
||||
if (effectiveEnd >= nowSeconds) {
|
||||
upcoming.add(it)
|
||||
} else {
|
||||
past.add(it)
|
||||
}
|
||||
}
|
||||
return UpcomingPastSplit(upcoming, past)
|
||||
}
|
||||
+206
@@ -0,0 +1,206 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.AssistChip
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip52Calendar.calendar.CalendarEvent
|
||||
|
||||
/**
|
||||
* Top-bar affordance that scopes the appointments feed to a single kind-31924 calendar's
|
||||
* member set. Selecting "All" clears the filter.
|
||||
*
|
||||
* Filter state lives on the screen (passed as [selectedDTag] / [onSelect]) so it survives
|
||||
* configuration changes via rememberSaveable but doesn't persist across launches — keeping a
|
||||
* filter sticky between sessions would surprise a user who set it once and forgot. The filter
|
||||
* is applied client-side after the feed loads, so changing it doesn't trigger a relay refetch.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun CalendarFilterChip(
|
||||
selectedDTag: String?,
|
||||
onSelect: (String?) -> Unit,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
val myPubKey = accountViewModel.userProfile().pubkeyHex
|
||||
val ownCalendars by produceState<List<CalendarEvent>>(initialValue = ownCalendars(myPubKey), myPubKey) {
|
||||
LocalCache.live.newEventBundles.collect {
|
||||
value = ownCalendars(myPubKey)
|
||||
}
|
||||
}
|
||||
val selected = ownCalendars.firstOrNull { it.dTag() == selectedDTag }
|
||||
val label =
|
||||
selected?.title()?.takeIf { it.isNotBlank() }
|
||||
?: stringRes(R.string.calendar_filter_all)
|
||||
|
||||
var showSheet by remember { mutableStateOf(false) }
|
||||
|
||||
AssistChip(
|
||||
onClick = { showSheet = true },
|
||||
label = {
|
||||
Text(text = label, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
},
|
||||
)
|
||||
|
||||
if (showSheet) {
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = { showSheet = false },
|
||||
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringRes(R.string.calendar_filter_sheet_title),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
FilterChoiceRow(
|
||||
title = stringRes(R.string.calendar_filter_all),
|
||||
isSelected = selectedDTag == null,
|
||||
onClick = {
|
||||
onSelect(null)
|
||||
showSheet = false
|
||||
},
|
||||
)
|
||||
if (ownCalendars.isEmpty()) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 16.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = stringRes(R.string.calendar_filter_no_calendars),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
LazyColumn(modifier = Modifier.fillMaxWidth()) {
|
||||
items(ownCalendars, key = { it.dTag() }) { calendar ->
|
||||
FilterChoiceRow(
|
||||
title = calendar.title() ?: stringRes(R.string.calendar_untitled),
|
||||
isSelected = selectedDTag == calendar.dTag(),
|
||||
onClick = {
|
||||
onSelect(calendar.dTag())
|
||||
showSheet = false
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FilterChoiceRow(
|
||||
title: String,
|
||||
isSelected: Boolean,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick)
|
||||
.padding(vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
RadioButton(selected = isSelected, onClick = onClick)
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
modifier = Modifier.padding(start = 4.dp),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun ownCalendars(myPubKey: String): List<CalendarEvent> =
|
||||
LocalCache.addressables
|
||||
.filterIntoSet { _, note ->
|
||||
val e = note.event
|
||||
e is CalendarEvent && e.pubKey == myPubKey
|
||||
}.mapNotNull { it.event as? CalendarEvent }
|
||||
.sortedBy { it.title()?.lowercase() ?: "" }
|
||||
|
||||
/**
|
||||
* Resolves the selected calendar's member address set, or null when no filter is set. Returned
|
||||
* set is suitable for `.filter { it.calendarAddress() in filter }` membership checks on the
|
||||
* notes the feed views render.
|
||||
*/
|
||||
@Composable
|
||||
fun rememberCalendarFilterAddresses(
|
||||
selectedDTag: String?,
|
||||
accountViewModel: AccountViewModel,
|
||||
): Set<Address>? {
|
||||
if (selectedDTag == null) return null
|
||||
val myPubKey = accountViewModel.userProfile().pubkeyHex
|
||||
val addr = remember(selectedDTag, myPubKey) { Address(CalendarEvent.KIND, myPubKey, selectedDTag) }
|
||||
val state by produceState<Set<Address>?>(initialValue = null, addr) {
|
||||
// Re-evaluate on relay-driven changes — when the user edits the calendar elsewhere, the
|
||||
// member set updates here without leaving the screen.
|
||||
value = lookupMembers(addr)
|
||||
LocalCache.live.newEventBundles.collect {
|
||||
value = lookupMembers(addr)
|
||||
}
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
private fun lookupMembers(addr: Address): Set<Address> =
|
||||
(LocalCache.addressables.get(addr)?.event as? CalendarEvent)
|
||||
?.calendarEventAddresses()
|
||||
?.toSet()
|
||||
?: emptySet()
|
||||
+377
@@ -0,0 +1,377 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.model.nip52Calendar.MONTH_GRID_MAX_LANES
|
||||
import com.vitorpamplona.amethyst.commons.model.nip52Calendar.MonthGridBarSegment
|
||||
import com.vitorpamplona.amethyst.commons.model.nip52Calendar.computeMonthGridBars
|
||||
import com.vitorpamplona.amethyst.commons.model.nip52Calendar.groupByDayKeyExpanded
|
||||
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState
|
||||
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState
|
||||
import com.vitorpamplona.amethyst.ui.layouts.rememberFeedContentPadding
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
|
||||
import java.time.LocalDate
|
||||
import java.time.YearMonth
|
||||
import java.time.ZoneId
|
||||
|
||||
@Composable
|
||||
fun CalendarMonthView(
|
||||
feedState: FeedContentState,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
filterAddresses: Set<com.vitorpamplona.quartz.nip01Core.core.Address>? = null,
|
||||
) {
|
||||
val state by feedState.feedContent.collectAsStateWithLifecycle()
|
||||
val notes =
|
||||
when (val s = state) {
|
||||
is FeedState.Loaded ->
|
||||
s.feed
|
||||
.collectAsStateWithLifecycle()
|
||||
.value.list
|
||||
.applyCalendarFilter(filterAddresses)
|
||||
else -> emptyList()
|
||||
}
|
||||
|
||||
val today = remember { LocalDate.now() }
|
||||
// YearMonth is not Parcelable/auto-saveable; persist the two ints and rebuild on each read.
|
||||
var visibleYear by rememberSaveable { mutableStateOf(today.year) }
|
||||
var visibleMonthValue by rememberSaveable { mutableStateOf(today.monthValue) }
|
||||
val visibleMonth = YearMonth.of(visibleYear, visibleMonthValue)
|
||||
|
||||
fun setVisibleMonth(ym: YearMonth) {
|
||||
visibleYear = ym.year
|
||||
visibleMonthValue = ym.monthValue
|
||||
}
|
||||
|
||||
val eventsByDay by remember(notes) { derivedStateOf { groupByDayKeyExpanded(notes) } }
|
||||
val barsByDay by remember(notes) { derivedStateOf { computeMonthGridBars(notes) } }
|
||||
|
||||
var selectedDayKey by rememberSaveable { mutableStateOf<Long?>(null) }
|
||||
|
||||
val selectedEvents = selectedDayKey?.let { eventsByDay[it] }.orEmpty()
|
||||
|
||||
// Single LazyColumn — nav header + weekday header + grid scroll together with the
|
||||
// disappearing top bar so the grid doesn't stay pinned mid-screen when the bar collapses.
|
||||
LazyColumn(
|
||||
contentPadding = rememberFeedContentPadding(FeedPadding),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.calendarSwipeNavigation(
|
||||
key = visibleYear to visibleMonthValue,
|
||||
onSwipeLeft = {
|
||||
setVisibleMonth(visibleMonth.plusMonths(1))
|
||||
selectedDayKey = null
|
||||
},
|
||||
onSwipeRight = {
|
||||
setVisibleMonth(visibleMonth.minusMonths(1))
|
||||
selectedDayKey = null
|
||||
},
|
||||
),
|
||||
) {
|
||||
item(key = "month-nav") {
|
||||
CalendarNavigationHeader(
|
||||
title = formatMonthYear(visibleMonth.year, visibleMonth.monthValue - 1),
|
||||
prevContentDescription = stringRes(R.string.calendar_nav_previous_month),
|
||||
nextContentDescription = stringRes(R.string.calendar_nav_next_month),
|
||||
onPrev = {
|
||||
setVisibleMonth(visibleMonth.minusMonths(1))
|
||||
selectedDayKey = null
|
||||
},
|
||||
onNext = {
|
||||
setVisibleMonth(visibleMonth.plusMonths(1))
|
||||
selectedDayKey = null
|
||||
},
|
||||
onToday = {
|
||||
setVisibleMonth(YearMonth.from(LocalDate.now()))
|
||||
selectedDayKey = null
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
item(key = "month-weekday-header") {
|
||||
WeekdayHeader()
|
||||
}
|
||||
|
||||
item(key = "month-grid") {
|
||||
MonthGrid(
|
||||
visibleMonth = visibleMonth,
|
||||
today = today,
|
||||
barsByDay = barsByDay,
|
||||
selectedDayKey = selectedDayKey,
|
||||
onDayClick = { dayKey ->
|
||||
selectedDayKey = if (selectedDayKey == dayKey) null else dayKey
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
item(key = "month-grid-spacer") {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
|
||||
if (selectedEvents.isNotEmpty()) {
|
||||
items(selectedEvents, key = { it.idHex }) { note ->
|
||||
CalendarEventListCard(note, accountViewModel, nav)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WeekdayHeader() {
|
||||
Row(modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp)) {
|
||||
for (i in 0..6) {
|
||||
Text(
|
||||
text = formatShortWeekday(i),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.weight(1f).padding(vertical = 4.dp),
|
||||
textAlign = TextAlign.Center,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MonthGrid(
|
||||
visibleMonth: YearMonth,
|
||||
today: LocalDate,
|
||||
barsByDay: Map<Long, List<MonthGridBarSegment>>,
|
||||
selectedDayKey: Long?,
|
||||
onDayClick: (Long) -> Unit,
|
||||
) {
|
||||
val firstOfMonth = visibleMonth.atDay(1)
|
||||
// SUNDAY = 7 in DayOfWeek; we want Sunday = 0 to match `formatShortWeekday`.
|
||||
val firstWeekdayIndex = firstOfMonth.dayOfWeek.value % 7
|
||||
val daysInMonth = visibleMonth.lengthOfMonth()
|
||||
val rows = ((firstWeekdayIndex + daysInMonth + 6) / 7)
|
||||
val isCurrentMonth = visibleMonth == YearMonth.from(today)
|
||||
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
for (r in 0 until rows) {
|
||||
Row(modifier = Modifier.fillMaxWidth()) {
|
||||
for (c in 0..6) {
|
||||
val cellIndex = r * 7 + c
|
||||
val dayNumber = cellIndex - firstWeekdayIndex + 1
|
||||
if (dayNumber in 1..daysInMonth) {
|
||||
val date = visibleMonth.atDay(dayNumber)
|
||||
val dayKey = date.toEpochDay()
|
||||
val cellBars = barsByDay[dayKey].orEmpty()
|
||||
val isToday = isCurrentMonth && date == today
|
||||
val isSelected = selectedDayKey == dayKey
|
||||
DayCell(
|
||||
modifier = Modifier.weight(1f),
|
||||
dayNumber = dayNumber,
|
||||
isToday = isToday,
|
||||
isSelected = isSelected,
|
||||
bars = cellBars,
|
||||
// Anything past the visible lane cap collapses into a "+N" tail —
|
||||
// keeps each cell readable when a day has more than three events.
|
||||
extraEventCount = cellBars.count { it.lane >= MONTH_GRID_MAX_LANES },
|
||||
isWeekStart = c == 0,
|
||||
isWeekEnd = c == 6,
|
||||
// Full date label fed to the screen-reader content description so
|
||||
// TalkBack reads "Wednesday January 15 2025, 2 events" instead of
|
||||
// just "15".
|
||||
dateLabel = formatLongDate(date.atStartOfDay(ZoneId.systemDefault()).toEpochSecond()),
|
||||
totalEventCount = cellBars.size,
|
||||
onClick = { onDayClick(dayKey) },
|
||||
)
|
||||
} else {
|
||||
Box(modifier = Modifier.weight(1f).height(MONTH_CELL_HEIGHT))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val MONTH_CELL_HEIGHT = 72.dp
|
||||
|
||||
@Composable
|
||||
private fun DayCell(
|
||||
modifier: Modifier,
|
||||
dayNumber: Int,
|
||||
isToday: Boolean,
|
||||
isSelected: Boolean,
|
||||
bars: List<MonthGridBarSegment>,
|
||||
extraEventCount: Int,
|
||||
isWeekStart: Boolean,
|
||||
isWeekEnd: Boolean,
|
||||
dateLabel: String,
|
||||
totalEventCount: Int,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val bg =
|
||||
if (isSelected) {
|
||||
MaterialTheme.colorScheme.primaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surface
|
||||
}
|
||||
|
||||
val baseDescription =
|
||||
pluralStringResource(R.plurals.calendar_day_a11y_events, totalEventCount, dateLabel, totalEventCount)
|
||||
val todaySuffix = stringRes(R.string.calendar_day_a11y_today_suffix)
|
||||
val selectedSuffix = stringRes(R.string.calendar_day_a11y_selected_suffix)
|
||||
val a11y =
|
||||
buildString {
|
||||
append(baseDescription)
|
||||
if (isToday) append(", ").append(todaySuffix)
|
||||
if (isSelected) append(", ").append(selectedSuffix)
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier =
|
||||
modifier
|
||||
.height(MONTH_CELL_HEIGHT)
|
||||
// Vertical-only padding so adjacent cells in a row touch horizontally — a
|
||||
// multi-day bar that extends from the right edge of one cell to the left edge of
|
||||
// the next visually merges into a single uninterrupted line.
|
||||
.padding(vertical = 2.dp)
|
||||
.background(bg, RoundedCornerShape(8.dp))
|
||||
.border(
|
||||
width = if (isToday) 1.5.dp else 0.5.dp,
|
||||
color = if (isToday) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outlineVariant,
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
).clickable(role = Role.Button, onClick = onClick)
|
||||
.semantics(mergeDescendants = true) { contentDescription = a11y },
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize().padding(horizontal = 2.dp, vertical = 4.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(
|
||||
text = dayNumber.toString(),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = if (isToday) FontWeight.Bold else FontWeight.Normal,
|
||||
color =
|
||||
if (isToday) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
},
|
||||
)
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
EventBarLanes(
|
||||
bars = bars,
|
||||
extraEventCount = extraEventCount,
|
||||
isWeekStart = isWeekStart,
|
||||
isWeekEnd = isWeekEnd,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders up to [MONTH_GRID_MAX_LANES] horizontal bars stacked vertically. Each lane occupies a
|
||||
* fixed height across every cell so a multi-day event sits on the same y-row in every column it
|
||||
* covers — the visual continuity that makes "spans 3 days" readable at a glance.
|
||||
*
|
||||
* The bar is rounded only at the event's start (`isLeftEnd`) and end (`isRightEnd`). On week
|
||||
* boundaries we also round so each row of the grid looks self-contained instead of bleeding into
|
||||
* an unaligned next row.
|
||||
*/
|
||||
@Composable
|
||||
private fun EventBarLanes(
|
||||
bars: List<MonthGridBarSegment>,
|
||||
extraEventCount: Int,
|
||||
isWeekStart: Boolean,
|
||||
isWeekEnd: Boolean,
|
||||
) {
|
||||
val barColor = MaterialTheme.colorScheme.primary
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(1.dp),
|
||||
) {
|
||||
for (i in 0 until MONTH_GRID_MAX_LANES) {
|
||||
val seg = bars.firstOrNull { it.lane == i }
|
||||
if (seg != null) {
|
||||
val roundLeft = seg.isLeftEnd || isWeekStart
|
||||
val roundRight = seg.isRightEnd || isWeekEnd
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(5.dp)
|
||||
.background(
|
||||
color = barColor,
|
||||
shape =
|
||||
RoundedCornerShape(
|
||||
topStart = if (roundLeft) 2.dp else 0.dp,
|
||||
bottomStart = if (roundLeft) 2.dp else 0.dp,
|
||||
topEnd = if (roundRight) 2.dp else 0.dp,
|
||||
bottomEnd = if (roundRight) 2.dp else 0.dp,
|
||||
),
|
||||
),
|
||||
)
|
||||
} else {
|
||||
Spacer(modifier = Modifier.fillMaxWidth().height(5.dp))
|
||||
}
|
||||
}
|
||||
if (extraEventCount > 0) {
|
||||
Text(
|
||||
text = "+$extraEventCount",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = barColor,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+94
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+77
@@ -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()
|
||||
}
|
||||
+147
@@ -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))
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+41
@@ -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)
|
||||
}
|
||||
+59
@@ -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
|
||||
}
|
||||
}
|
||||
+101
@@ -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)
|
||||
}
|
||||
+277
@@ -0,0 +1,277 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.model.nip52Calendar.groupByDayKeyExpanded
|
||||
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState
|
||||
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.ui.layouts.rememberFeedContentPadding
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
|
||||
import java.time.LocalDate
|
||||
import java.time.ZoneId
|
||||
|
||||
@Composable
|
||||
fun CalendarWeekView(
|
||||
feedState: FeedContentState,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
filterAddresses: Set<com.vitorpamplona.quartz.nip01Core.core.Address>? = null,
|
||||
) {
|
||||
val state by feedState.feedContent.collectAsStateWithLifecycle()
|
||||
val notes =
|
||||
when (val s = state) {
|
||||
is FeedState.Loaded ->
|
||||
s.feed
|
||||
.collectAsStateWithLifecycle()
|
||||
.value.list
|
||||
.applyCalendarFilter(filterAddresses)
|
||||
else -> emptyList()
|
||||
}
|
||||
|
||||
val today = remember { LocalDate.now() }
|
||||
// Persist the week-start as an epoch-day Long (auto-saveable), reconstruct LocalDate on use.
|
||||
var weekStartEpochDay by rememberSaveable {
|
||||
mutableStateOf(startOfWeek(today).toEpochDay())
|
||||
}
|
||||
val weekStart = LocalDate.ofEpochDay(weekStartEpochDay)
|
||||
|
||||
var selectedDayIndex by rememberSaveable { mutableStateOf(0) }
|
||||
|
||||
val eventsByDay by remember(notes) { derivedStateOf { groupByDayKeyExpanded(notes) } }
|
||||
|
||||
val selectedDate = weekStart.plusDays(selectedDayIndex.toLong())
|
||||
val dayNotes = eventsByDay[selectedDate.toEpochDay()].orEmpty()
|
||||
|
||||
// Single LazyColumn containing nav + strip + day-summary + events so the whole stack
|
||||
// scrolls together with the [DisappearingScaffold]'s top bar. The previous Column-with-
|
||||
// disappearingScaffoldPadding kept the strip pinned at a fixed offset, so when the top bar
|
||||
// collapsed on scroll the strip stayed put and left a visual gap.
|
||||
LazyColumn(
|
||||
contentPadding = rememberFeedContentPadding(FeedPadding),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.calendarSwipeNavigation(
|
||||
key = weekStartEpochDay,
|
||||
onSwipeLeft = {
|
||||
weekStartEpochDay = weekStart.plusWeeks(1).toEpochDay()
|
||||
selectedDayIndex = 0
|
||||
},
|
||||
onSwipeRight = {
|
||||
weekStartEpochDay = weekStart.minusWeeks(1).toEpochDay()
|
||||
selectedDayIndex = 0
|
||||
},
|
||||
),
|
||||
) {
|
||||
item(key = "week-nav") {
|
||||
CalendarNavigationHeader(
|
||||
title = formatMonthYear(weekStart.year, weekStart.monthValue - 1),
|
||||
prevContentDescription = stringRes(R.string.calendar_nav_previous_week),
|
||||
nextContentDescription = stringRes(R.string.calendar_nav_next_week),
|
||||
onPrev = {
|
||||
weekStartEpochDay = weekStart.minusWeeks(1).toEpochDay()
|
||||
selectedDayIndex = 0
|
||||
},
|
||||
onNext = {
|
||||
weekStartEpochDay = weekStart.plusWeeks(1).toEpochDay()
|
||||
selectedDayIndex = 0
|
||||
},
|
||||
onToday = {
|
||||
weekStartEpochDay = startOfWeek(LocalDate.now()).toEpochDay()
|
||||
selectedDayIndex = 0
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
item(key = "week-strip") {
|
||||
WeekStrip(
|
||||
weekStart = weekStart,
|
||||
today = today,
|
||||
selectedIndex = selectedDayIndex,
|
||||
eventsByDay = eventsByDay,
|
||||
onSelect = { selectedDayIndex = it },
|
||||
)
|
||||
}
|
||||
|
||||
item(key = "week-spacer") {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
|
||||
item(key = "week-day-summary") {
|
||||
DaySummaryHeader(selectedDate)
|
||||
}
|
||||
|
||||
if (dayNotes.isEmpty()) {
|
||||
item(key = "week-empty") {
|
||||
CalendarEmptyState(
|
||||
title = stringRes(R.string.calendar_empty_week_title),
|
||||
subtitle = stringRes(R.string.calendar_empty_week_subtitle),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
items(dayNotes, key = { it.idHex }) { note ->
|
||||
CalendarEventListCard(note, accountViewModel, nav)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WeekStrip(
|
||||
weekStart: LocalDate,
|
||||
today: LocalDate,
|
||||
selectedIndex: Int,
|
||||
eventsByDay: Map<Long, List<Note>>,
|
||||
onSelect: (Int) -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp),
|
||||
) {
|
||||
for (i in 0..6) {
|
||||
val date = weekStart.plusDays(i.toLong())
|
||||
val count = eventsByDay[date.toEpochDay()]?.size ?: 0
|
||||
val isToday = date == today
|
||||
val isSelected = i == selectedIndex
|
||||
|
||||
val bg =
|
||||
when {
|
||||
isSelected -> MaterialTheme.colorScheme.primary
|
||||
isToday -> MaterialTheme.colorScheme.primaryContainer
|
||||
else -> MaterialTheme.colorScheme.surface
|
||||
}
|
||||
val fg =
|
||||
when {
|
||||
isSelected -> MaterialTheme.colorScheme.onPrimary
|
||||
isToday -> MaterialTheme.colorScheme.onPrimaryContainer
|
||||
else -> MaterialTheme.colorScheme.onSurface
|
||||
}
|
||||
|
||||
val dateLabel = formatLongDate(date.atStartOfDay(ZoneId.systemDefault()).toEpochSecond())
|
||||
val baseA11y = pluralStringResource(R.plurals.calendar_day_a11y_events, count, dateLabel, count)
|
||||
val todaySuffix = stringRes(R.string.calendar_day_a11y_today_suffix)
|
||||
val selectedSuffix = stringRes(R.string.calendar_day_a11y_selected_suffix)
|
||||
val a11y =
|
||||
buildString {
|
||||
append(baseA11y)
|
||||
if (isToday) append(", ").append(todaySuffix)
|
||||
if (isSelected) append(", ").append(selectedSuffix)
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.weight(1f)
|
||||
.padding(3.dp)
|
||||
.background(bg, RoundedCornerShape(10.dp))
|
||||
.border(
|
||||
width = 0.5.dp,
|
||||
color = MaterialTheme.colorScheme.outlineVariant,
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
).clickable(role = Role.Tab) { onSelect(i) }
|
||||
.padding(vertical = 6.dp)
|
||||
.semantics(mergeDescendants = true) { contentDescription = a11y },
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(
|
||||
text = formatShortWeekday(i),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = fg,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Text(
|
||||
text = date.dayOfMonth.toString(),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = fg,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
if (count > 0) {
|
||||
Text(
|
||||
text = if (count > 9) "9+" else count.toString(),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = fg,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Clip,
|
||||
)
|
||||
} else {
|
||||
Spacer(modifier = Modifier.height(14.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DaySummaryHeader(date: LocalDate) {
|
||||
Text(
|
||||
text = formatLongDate(date.atStartOfDay(ZoneId.systemDefault()).toEpochSecond()),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(start = 16.dp, top = 8.dp, bottom = 4.dp),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the Sunday on or before [date]. DST-safe because [LocalDate] arithmetic ignores zones.
|
||||
* `DayOfWeek.SUNDAY.value` is 7 in java.time, so `% 7` collapses Sunday → 0 with the rest of the
|
||||
* week following in order.
|
||||
*/
|
||||
private fun startOfWeek(date: LocalDate): LocalDate {
|
||||
val daysFromSunday = date.dayOfWeek.value % 7
|
||||
return date.minusDays(daysFromSunday.toLong())
|
||||
}
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState
|
||||
import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel
|
||||
import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.FabBottomBarPadded
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.datasource.CalendarsFilterAssemblerSubscription
|
||||
|
||||
@Composable
|
||||
fun CalendarsScreen(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
CalendarsScreen(
|
||||
feedState = accountViewModel.feedStates.calendarAppointmentsFeed,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CalendarsScreen(
|
||||
feedState: FeedContentState,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
WatchLifecycleAndUpdateModel(feedState)
|
||||
WatchAccountForCalendarsScreen(feedState, accountViewModel)
|
||||
CalendarsFilterAssemblerSubscription(accountViewModel)
|
||||
|
||||
var viewMode by rememberSaveable { mutableStateOf(CalendarsViewMode.FEED) }
|
||||
var filterDTag by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
// Resolve the selected calendar's member addresses (or null when "All"). Plumbed into each
|
||||
// view so the membership filter is applied client-side after the feed loads — changing the
|
||||
// filter doesn't trigger a relay refetch.
|
||||
val filterAddresses = rememberCalendarFilterAddresses(filterDTag, accountViewModel)
|
||||
|
||||
DisappearingScaffold(
|
||||
isInvertedLayout = false,
|
||||
topBar = {
|
||||
CalendarsTopBar(
|
||||
viewMode = viewMode,
|
||||
onViewModeChange = { viewMode = it },
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
trailing = {
|
||||
CalendarFilterChip(
|
||||
selectedDTag = filterDTag,
|
||||
onSelect = { filterDTag = it },
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
},
|
||||
)
|
||||
},
|
||||
bottomBar = {
|
||||
AppBottomBar(Route.Calendars, nav, accountViewModel) { route ->
|
||||
if (route == Route.Calendars) {
|
||||
feedState.sendToTop()
|
||||
} else {
|
||||
nav.navBottomBar(route)
|
||||
}
|
||||
}
|
||||
},
|
||||
floatingButton = {
|
||||
FabBottomBarPadded(nav) {
|
||||
NewCalendarButton(nav)
|
||||
}
|
||||
},
|
||||
accountViewModel = accountViewModel,
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
when (viewMode) {
|
||||
CalendarsViewMode.FEED ->
|
||||
CalendarFeedView(feedState, accountViewModel, nav, filterAddresses)
|
||||
CalendarsViewMode.MONTH ->
|
||||
CalendarMonthView(feedState, accountViewModel, nav, filterAddresses)
|
||||
CalendarsViewMode.WEEK ->
|
||||
CalendarWeekView(feedState, accountViewModel, nav, filterAddresses)
|
||||
CalendarsViewMode.DAY ->
|
||||
CalendarDayView(feedState, accountViewModel, nav, filterAddresses)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WatchAccountForCalendarsScreen(
|
||||
feedState: FeedContentState,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
val listState by accountViewModel.account.liveCalendarsFollowLists.collectAsStateWithLifecycle()
|
||||
val hiddenUsers by
|
||||
accountViewModel.account.hiddenUsers.flow
|
||||
.collectAsStateWithLifecycle()
|
||||
|
||||
LaunchedEffect(accountViewModel, listState, hiddenUsers) {
|
||||
feedState.checkKeysInvalidateDataAndSendToTop()
|
||||
}
|
||||
}
|
||||
+132
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+38
@@ -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),
|
||||
}
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.core.content.FileProvider
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Writes [content] to a `.ics` file in the app's cache dir and opens the system share sheet so
|
||||
* the user can hand the file to a calendar app, email client, file manager, etc.
|
||||
*
|
||||
* The file lives in `cacheDir/calendar/` — covered by the existing `<cache-path>` entry in
|
||||
* `file_paths.xml`, so [FileProvider] can hand out a `content://` URI without further config.
|
||||
* The receiver gets a read-permission grant via [Intent.FLAG_GRANT_READ_URI_PERMISSION] that
|
||||
* lasts only for the duration of the share.
|
||||
*/
|
||||
fun shareIcs(
|
||||
context: Context,
|
||||
filename: String,
|
||||
content: String,
|
||||
) {
|
||||
val dir = File(context.cacheDir, "calendar").apply { mkdirs() }
|
||||
val file = File(dir, filename)
|
||||
file.writeText(content)
|
||||
|
||||
val uri = FileProvider.getUriForFile(context, "${context.packageName}.provider", file)
|
||||
val intent =
|
||||
Intent(Intent.ACTION_SEND).apply {
|
||||
type = "text/calendar"
|
||||
putExtra(Intent.EXTRA_STREAM, uri)
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
val chooser =
|
||||
Intent
|
||||
.createChooser(intent, stringRes(context, R.string.calendar_export_share_title))
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
context.startActivity(chooser)
|
||||
}
|
||||
+138
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+193
@@ -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)
|
||||
}
|
||||
+262
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+208
@@ -0,0 +1,208 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.create
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.aTag.aTags
|
||||
import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent
|
||||
import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent
|
||||
import com.vitorpamplona.quartz.nip52Calendar.calendar.CalendarEvent
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Lightweight projection of a calendar appointment authored by the current user, used to power
|
||||
* the multi-select picker on the collection editor.
|
||||
*/
|
||||
@Immutable
|
||||
data class OwnedAppointmentSummary(
|
||||
val address: Address,
|
||||
val title: String,
|
||||
val startSeconds: Long?,
|
||||
val isAllDay: Boolean,
|
||||
)
|
||||
|
||||
class NewCalendarCollectionViewModel : ViewModel() {
|
||||
private lateinit var account: Account
|
||||
|
||||
val title = mutableStateOf("")
|
||||
val description = mutableStateOf("")
|
||||
val isPublishing = mutableStateOf(false)
|
||||
|
||||
/** Stable d-tag for the addressable: random for create, preserved when editing. */
|
||||
private var dTag: String? = null
|
||||
|
||||
/** The full original event in edit mode; needed to publish a NIP-09 deletion. */
|
||||
private var loadedEvent: com.vitorpamplona.quartz.nip52Calendar.calendar.CalendarEvent? = null
|
||||
|
||||
val selectedAddresses = mutableStateListOf<Address>()
|
||||
val availableAppointments = mutableStateOf<List<OwnedAppointmentSummary>>(emptyList())
|
||||
|
||||
private var liveScanJob: Job? = null
|
||||
|
||||
val isEditing: Boolean
|
||||
get() = dTag != null
|
||||
|
||||
fun init(
|
||||
accountViewModel: AccountViewModel,
|
||||
editDTag: String?,
|
||||
) {
|
||||
if (::account.isInitialized) return // idempotent across recompositions
|
||||
this.account = accountViewModel.account
|
||||
dTag = editDTag
|
||||
|
||||
editDTag?.let { existingDTag ->
|
||||
val existingAddress = Address(CalendarEvent.KIND, account.userProfile().pubkeyHex, existingDTag)
|
||||
val existingNote = LocalCache.addressables.get(existingAddress)
|
||||
(existingNote?.event as? CalendarEvent)?.let { existing ->
|
||||
loadedEvent = existing
|
||||
title.value = existing.title().orEmpty()
|
||||
description.value = existing.content
|
||||
selectedAddresses.addAll(existing.calendarEventAddresses())
|
||||
}
|
||||
}
|
||||
|
||||
availableAppointments.value = loadOwnedAppointments()
|
||||
|
||||
// Reactively re-scan when new events arrive in LocalCache. Without this, an appointment
|
||||
// the user publishes from another screen (or that arrives from a relay) while the editor
|
||||
// is open wouldn't appear in the picker until the screen reopens. The Job is stored so
|
||||
// re-init in editing-mode doesn't stack subscribers (init() returns early after the
|
||||
// first call anyway, but defence in depth).
|
||||
liveScanJob?.cancel()
|
||||
liveScanJob =
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
LocalCache.live.newEventBundles.collect {
|
||||
availableAppointments.value = loadOwnedAppointments()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
liveScanJob?.cancel()
|
||||
super.onCleared()
|
||||
}
|
||||
|
||||
fun toggle(address: Address) {
|
||||
if (selectedAddresses.remove(address)) return
|
||||
selectedAddresses.add(address)
|
||||
}
|
||||
|
||||
/**
|
||||
* Publishes a NIP-09 deletion event for the loaded calendar. No-op when called outside
|
||||
* edit mode (we wouldn't have a target to delete). Returns true when the deletion was
|
||||
* dispatched so the caller can pop back.
|
||||
*/
|
||||
suspend fun deleteLoaded(): Boolean {
|
||||
val target = loadedEvent ?: return false
|
||||
account.delete(target, emptySet())
|
||||
return true
|
||||
}
|
||||
|
||||
fun isValid(): Boolean = title.value.isNotBlank()
|
||||
|
||||
suspend fun publish(): Boolean {
|
||||
if (!isValid()) return false
|
||||
isPublishing.value = true
|
||||
try {
|
||||
val effectiveDTag = dTag
|
||||
val selected = selectedAddresses.toList()
|
||||
val parsedTitle = title.value.trim()
|
||||
val parsedDescription = description.value.trim()
|
||||
|
||||
account.signAndComputeBroadcast(
|
||||
if (effectiveDTag != null) {
|
||||
CalendarEvent.build(
|
||||
title = parsedTitle,
|
||||
content = parsedDescription,
|
||||
dTag = effectiveDTag,
|
||||
) {
|
||||
if (selected.isNotEmpty()) aTags(selected.map { ATag(it) })
|
||||
}
|
||||
} else {
|
||||
CalendarEvent.build(
|
||||
title = parsedTitle,
|
||||
content = parsedDescription,
|
||||
) {
|
||||
if (selected.isNotEmpty()) aTags(selected.map { ATag(it) })
|
||||
}
|
||||
},
|
||||
)
|
||||
return true
|
||||
} finally {
|
||||
isPublishing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadOwnedAppointments(): List<OwnedAppointmentSummary> {
|
||||
val mePubKey = account.userProfile().pubkeyHex
|
||||
val results =
|
||||
LocalCache.notes
|
||||
.filterIntoSet { _, note ->
|
||||
val e = note.event
|
||||
(e is CalendarTimeSlotEvent || e is CalendarDateSlotEvent) && e.pubKey == mePubKey
|
||||
}.mapNotNull { note ->
|
||||
when (val e = note.event) {
|
||||
is CalendarTimeSlotEvent ->
|
||||
OwnedAppointmentSummary(
|
||||
address = e.address(),
|
||||
// `title` may be empty; the picker row substitutes a localised
|
||||
// "(untitled)" string at render time — the VM stays string-free.
|
||||
title = e.title().orEmpty(),
|
||||
startSeconds = e.start(),
|
||||
isAllDay = false,
|
||||
)
|
||||
is CalendarDateSlotEvent ->
|
||||
OwnedAppointmentSummary(
|
||||
address = e.address(),
|
||||
// `title` may be empty; the picker row substitutes a localised
|
||||
// "(untitled)" string at render time — the VM stays string-free.
|
||||
title = e.title().orEmpty(),
|
||||
// Date-only events don't have an instant; null sorts last in the
|
||||
// upcoming-first comparator below.
|
||||
startSeconds = null,
|
||||
isAllDay = true,
|
||||
)
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
// Upcoming events first (closest start), then date-only/past — same intent as the
|
||||
// main feed's UpcomingFirst ordering, simplified for the picker context.
|
||||
val now = TimeUtils.now()
|
||||
return results.sortedWith(
|
||||
compareBy(
|
||||
{ if (it.startSeconds == null || it.startSeconds >= now) 0 else 1 },
|
||||
{ it.startSeconds ?: Long.MAX_VALUE },
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
+382
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+281
@@ -0,0 +1,281 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.create
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.lifecycle.ViewModel
|
||||
import com.vitorpamplona.amethyst.commons.model.nip52Calendar.parseIsoDateToUnixSeconds
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.service.uploads.CompressorQuality
|
||||
import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator
|
||||
import com.vitorpamplona.amethyst.service.uploads.UploadingState
|
||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
|
||||
import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent
|
||||
import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent
|
||||
import java.text.SimpleDateFormat
|
||||
import java.time.ZoneId
|
||||
import java.util.Locale
|
||||
import java.util.TimeZone
|
||||
import com.vitorpamplona.quartz.nip52Calendar.appt.day.image as dayImage
|
||||
import com.vitorpamplona.quartz.nip52Calendar.appt.day.locations as dayLocations
|
||||
import com.vitorpamplona.quartz.nip52Calendar.appt.day.participants as dayParticipants
|
||||
import com.vitorpamplona.quartz.nip52Calendar.appt.day.summary as daySummary
|
||||
import com.vitorpamplona.quartz.nip52Calendar.appt.time.image as timeImage
|
||||
import com.vitorpamplona.quartz.nip52Calendar.appt.time.locations as timeLocations
|
||||
import com.vitorpamplona.quartz.nip52Calendar.appt.time.participants as timeParticipants
|
||||
import com.vitorpamplona.quartz.nip52Calendar.appt.time.summary as timeSummary
|
||||
|
||||
class NewCalendarEventViewModel : ViewModel() {
|
||||
private lateinit var account: Account
|
||||
|
||||
val isAllDay = mutableStateOf(false)
|
||||
val title = mutableStateOf("")
|
||||
val summary = mutableStateOf("")
|
||||
val location = mutableStateOf("")
|
||||
val imageUrl = mutableStateOf("")
|
||||
val hashtags = mutableStateOf("") // comma-separated
|
||||
|
||||
/** Start instant in epoch seconds. 0 means unset; the create screen guards against publishing without a real value. */
|
||||
val startSeconds = mutableStateOf(0L)
|
||||
val endSeconds = mutableStateOf(0L)
|
||||
|
||||
val isPublishing = mutableStateOf(false)
|
||||
val isUploadingImage = mutableStateOf(false)
|
||||
|
||||
/** Participant pubkeys to embed as `p` tags on the published event. */
|
||||
val participants = androidx.compose.runtime.mutableStateListOf<String>()
|
||||
|
||||
/**
|
||||
* When non-null, [publish] preserves this d-tag and kind so the broadcast replaces an
|
||||
* existing addressable appointment instead of minting a new one. The UI also locks the
|
||||
* all-day toggle in edit mode — switching kinds mid-edit would leave a stale event under
|
||||
* the original kind/d-tag combination.
|
||||
*/
|
||||
private var editAddress: Address? = null
|
||||
|
||||
val isEditing: Boolean
|
||||
get() = editAddress != null
|
||||
|
||||
fun init(accountViewModel: AccountViewModel) {
|
||||
if (::account.isInitialized) return
|
||||
this.account = accountViewModel.account
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-populate from an existing appointment for edit mode. Idempotent: a recomposition that
|
||||
* calls this again is a no-op. Only the author of the appointment should reach this path —
|
||||
* the screen guards via UI affordance, but [publish] will also produce an unsigned event if
|
||||
* the current account doesn't own the address.
|
||||
*/
|
||||
fun loadForEdit(
|
||||
accountViewModel: AccountViewModel,
|
||||
kind: Int,
|
||||
pubKeyHex: String,
|
||||
dTag: String,
|
||||
) {
|
||||
init(accountViewModel)
|
||||
if (editAddress != null) return // already loaded
|
||||
|
||||
val address = Address(kind, pubKeyHex, dTag)
|
||||
val existing = LocalCache.addressables.get(address)?.event ?: return
|
||||
editAddress = address
|
||||
|
||||
when (existing) {
|
||||
is CalendarTimeSlotEvent -> {
|
||||
isAllDay.value = false
|
||||
title.value = existing.title().orEmpty()
|
||||
summary.value = existing.summary().orEmpty().ifBlank { existing.content }
|
||||
location.value = existing.location().orEmpty()
|
||||
imageUrl.value = existing.image().orEmpty()
|
||||
hashtags.value = existing.hashtags().joinToString(", ")
|
||||
startSeconds.value = existing.start() ?: 0L
|
||||
endSeconds.value = existing.end() ?: 0L
|
||||
participants.clear()
|
||||
participants.addAll(existing.participants().map { it.pubKey })
|
||||
}
|
||||
is CalendarDateSlotEvent -> {
|
||||
isAllDay.value = true
|
||||
title.value = existing.title().orEmpty()
|
||||
summary.value = existing.summary().orEmpty().ifBlank { existing.content }
|
||||
location.value = existing.location().orEmpty()
|
||||
imageUrl.value = existing.image().orEmpty()
|
||||
hashtags.value = existing.hashtags().joinToString(", ")
|
||||
startSeconds.value = parseIsoDateToUnixSeconds(existing.start()) ?: 0L
|
||||
endSeconds.value = parseIsoDateToUnixSeconds(existing.end()) ?: 0L
|
||||
participants.clear()
|
||||
participants.addAll(existing.participants().map { it.pubKey })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun isValid(): Boolean = title.value.isNotBlank() && startSeconds.value > 0L
|
||||
|
||||
fun isEndAfterStart(): Boolean = endSeconds.value == 0L || endSeconds.value >= startSeconds.value
|
||||
|
||||
fun addParticipant(pubKeyHex: String) {
|
||||
val trimmed = pubKeyHex.trim()
|
||||
if (trimmed.isBlank() || trimmed in participants) return
|
||||
participants.add(trimmed)
|
||||
}
|
||||
|
||||
fun removeParticipant(pubKeyHex: String) {
|
||||
participants.remove(pubKeyHex)
|
||||
}
|
||||
|
||||
/**
|
||||
* Picks a single image from the gallery → uploads to the user's default file server →
|
||||
* writes the resulting URL into [imageUrl]. Returns true on success so the screen can
|
||||
* surface a toast on failure. Wraps [UploadOrchestrator.upload] with sensible defaults
|
||||
* (MEDIUM quality, strip metadata, no content warning) so the calendar create screen
|
||||
* doesn't have to drag in NewMediaModel's full surface area.
|
||||
*/
|
||||
suspend fun uploadAndSetImage(
|
||||
uri: Uri,
|
||||
mimeType: String?,
|
||||
context: Context,
|
||||
): Boolean {
|
||||
if (!::account.isInitialized) return false
|
||||
isUploadingImage.value = true
|
||||
try {
|
||||
val server = account.settings.defaultFileServer ?: DEFAULT_MEDIA_SERVERS[0]
|
||||
val result =
|
||||
UploadOrchestrator().upload(
|
||||
uri = uri,
|
||||
mimeType = mimeType,
|
||||
alt = title.value.ifBlank { null },
|
||||
contentWarningReason = null,
|
||||
compressionQuality = CompressorQuality.MEDIUM,
|
||||
server = server,
|
||||
account = account,
|
||||
context = context,
|
||||
)
|
||||
val serverResult =
|
||||
(result as? UploadingState.Finished)?.result as? UploadOrchestrator.OrchestratorResult.ServerResult
|
||||
return serverResult?.url?.also { imageUrl.value = it } != null
|
||||
} finally {
|
||||
isUploadingImage.value = false
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun publish(): Boolean {
|
||||
if (!isValid() || !isEndAfterStart()) return false
|
||||
isPublishing.value = true
|
||||
try {
|
||||
val parsedHashtags =
|
||||
hashtags.value
|
||||
.split(',', '\n', ' ')
|
||||
.map { it.trim().trimStart('#') }
|
||||
.filter { it.isNotBlank() }
|
||||
val parsedSummary = summary.value.trim().takeIf { it.isNotBlank() }
|
||||
val parsedImage = imageUrl.value.trim().takeIf { it.isNotBlank() }
|
||||
val parsedLocation = location.value.trim().takeIf { it.isNotBlank() }
|
||||
val parsedParticipants = participants.map { PTag(it) }
|
||||
val tzId = TimeZone.getDefault().id
|
||||
val targetDTag = editAddress?.dTag
|
||||
|
||||
if (isAllDay.value) {
|
||||
account.signAndComputeBroadcast(
|
||||
if (targetDTag != null) {
|
||||
CalendarDateSlotEvent.build(
|
||||
title = title.value.trim(),
|
||||
start = toIsoDate(startSeconds.value),
|
||||
end = endSeconds.value.takeIf { it > 0L }?.let { toIsoDate(it) },
|
||||
content = parsedSummary.orEmpty(),
|
||||
dTag = targetDTag,
|
||||
) {
|
||||
parsedSummary?.let { daySummary(it) }
|
||||
parsedImage?.let { dayImage(it) }
|
||||
parsedLocation?.let { dayLocations(listOf(it)) }
|
||||
if (parsedHashtags.isNotEmpty()) hashtags(parsedHashtags)
|
||||
if (parsedParticipants.isNotEmpty()) dayParticipants(parsedParticipants)
|
||||
}
|
||||
} else {
|
||||
CalendarDateSlotEvent.build(
|
||||
title = title.value.trim(),
|
||||
start = toIsoDate(startSeconds.value),
|
||||
end = endSeconds.value.takeIf { it > 0L }?.let { toIsoDate(it) },
|
||||
content = parsedSummary.orEmpty(),
|
||||
) {
|
||||
parsedSummary?.let { daySummary(it) }
|
||||
parsedImage?.let { dayImage(it) }
|
||||
parsedLocation?.let { dayLocations(listOf(it)) }
|
||||
if (parsedHashtags.isNotEmpty()) hashtags(parsedHashtags)
|
||||
if (parsedParticipants.isNotEmpty()) dayParticipants(parsedParticipants)
|
||||
}
|
||||
},
|
||||
)
|
||||
} else {
|
||||
account.signAndComputeBroadcast(
|
||||
if (targetDTag != null) {
|
||||
CalendarTimeSlotEvent.build(
|
||||
title = title.value.trim(),
|
||||
start = startSeconds.value,
|
||||
end = endSeconds.value.takeIf { it > 0L },
|
||||
startTzId = tzId,
|
||||
endTzId = tzId,
|
||||
content = parsedSummary.orEmpty(),
|
||||
dTag = targetDTag,
|
||||
) {
|
||||
parsedSummary?.let { timeSummary(it) }
|
||||
parsedImage?.let { timeImage(it) }
|
||||
parsedLocation?.let { timeLocations(listOf(it)) }
|
||||
if (parsedHashtags.isNotEmpty()) hashtags(parsedHashtags)
|
||||
if (parsedParticipants.isNotEmpty()) timeParticipants(parsedParticipants)
|
||||
}
|
||||
} else {
|
||||
CalendarTimeSlotEvent.build(
|
||||
title = title.value.trim(),
|
||||
start = startSeconds.value,
|
||||
end = endSeconds.value.takeIf { it > 0L },
|
||||
startTzId = tzId,
|
||||
endTzId = tzId,
|
||||
content = parsedSummary.orEmpty(),
|
||||
) {
|
||||
parsedSummary?.let { timeSummary(it) }
|
||||
parsedImage?.let { timeImage(it) }
|
||||
parsedLocation?.let { timeLocations(listOf(it)) }
|
||||
if (parsedHashtags.isNotEmpty()) hashtags(parsedHashtags)
|
||||
if (parsedParticipants.isNotEmpty()) timeParticipants(parsedParticipants)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
return true
|
||||
} finally {
|
||||
isPublishing.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val IsoFormat =
|
||||
SimpleDateFormat("yyyy-MM-dd", Locale.US).apply {
|
||||
// 31922 uses calendar-date strings; format the user's local date.
|
||||
timeZone = TimeZone.getTimeZone(ZoneId.systemDefault())
|
||||
}
|
||||
|
||||
private fun toIsoDate(epochSeconds: Long): String = IsoFormat.format(java.util.Date(epochSeconds * 1000))
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.dal
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.model.nip52Calendar.upcomingFirstCalendarOrder
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.TopFilter
|
||||
import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.dal.FilterByListParams
|
||||
import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent
|
||||
import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent
|
||||
|
||||
/**
|
||||
* Feed of NIP-52 calendar *appointments* — kinds 31922 (date-slot) and 31923 (time-slot). The
|
||||
* NIP calls kind 31924 a "calendar" (a list of appointments), so this filter intentionally does
|
||||
* not load 31924; see [CalendarCollectionsFeedFilter] for that.
|
||||
*/
|
||||
class CalendarAppointmentsFeedFilter(
|
||||
val account: Account,
|
||||
) : AdditiveFeedFilter<Note>() {
|
||||
override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + followList().code
|
||||
|
||||
override fun limit() = 500
|
||||
|
||||
fun followList(): TopFilter = account.settings.defaultCalendarsFollowList.value
|
||||
|
||||
private fun TopFilter.isMuteList() = this is TopFilter.MuteList
|
||||
|
||||
private fun TopFilter.isBlockList() = this is TopFilter.PeopleList && this.address == account.blockPeopleList.getBlockListAddress()
|
||||
|
||||
private fun TopFilter.wantsToSeeNegativeStuff() = isMuteList() || isBlockList()
|
||||
|
||||
override fun showHiddenKey(): Boolean = followList().wantsToSeeNegativeStuff()
|
||||
|
||||
override fun feed(): List<Note> {
|
||||
val params = buildFilterParams(account)
|
||||
val notes =
|
||||
LocalCache.notes.filterIntoSet { _, it ->
|
||||
val e = it.event
|
||||
(e is CalendarTimeSlotEvent || e is CalendarDateSlotEvent) && params.match(e, it.relays)
|
||||
}
|
||||
return sort(notes)
|
||||
}
|
||||
|
||||
override fun applyFilter(newItems: Set<Note>): Set<Note> = innerApplyFilter(newItems)
|
||||
|
||||
private fun buildFilterParams(account: Account): FilterByListParams =
|
||||
FilterByListParams.create(
|
||||
account.liveCalendarsFollowLists.value,
|
||||
account.hiddenUsers.flow.value,
|
||||
)
|
||||
|
||||
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
|
||||
val params = buildFilterParams(account)
|
||||
return collection.filterTo(HashSet()) {
|
||||
val e = it.event
|
||||
(e is CalendarTimeSlotEvent || e is CalendarDateSlotEvent) && params.match(e, it.relays)
|
||||
}
|
||||
}
|
||||
|
||||
override fun sort(items: Set<Note>): List<Note> =
|
||||
items.sortedWith(
|
||||
upcomingFirstCalendarOrder(
|
||||
com.vitorpamplona.quartz.utils.TimeUtils
|
||||
.now(),
|
||||
),
|
||||
)
|
||||
}
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.dal
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.TopFilter
|
||||
import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder
|
||||
import com.vitorpamplona.amethyst.ui.dal.FilterByListParams
|
||||
import com.vitorpamplona.quartz.nip52Calendar.calendar.CalendarEvent
|
||||
|
||||
class CalendarCollectionsFeedFilter(
|
||||
val account: Account,
|
||||
) : AdditiveFeedFilter<Note>() {
|
||||
override fun feedKey(): String = account.userProfile().pubkeyHex + "-collections-" + followList().code
|
||||
|
||||
override fun limit() = 200
|
||||
|
||||
fun followList(): TopFilter = account.settings.defaultCalendarsFollowList.value
|
||||
|
||||
private fun TopFilter.isMuteList() = this is TopFilter.MuteList
|
||||
|
||||
private fun TopFilter.isBlockList() = this is TopFilter.PeopleList && this.address == account.blockPeopleList.getBlockListAddress()
|
||||
|
||||
override fun showHiddenKey(): Boolean = followList().let { it.isMuteList() || it.isBlockList() }
|
||||
|
||||
override fun feed(): List<Note> {
|
||||
val params = buildFilterParams(account)
|
||||
val notes =
|
||||
LocalCache.addressables.filterIntoSet { _, it ->
|
||||
val e = it.event
|
||||
e is CalendarEvent && params.match(e, it.relays)
|
||||
}
|
||||
return sort(notes)
|
||||
}
|
||||
|
||||
override fun applyFilter(newItems: Set<Note>): Set<Note> = innerApplyFilter(newItems)
|
||||
|
||||
private fun buildFilterParams(account: Account): FilterByListParams =
|
||||
FilterByListParams.create(
|
||||
account.liveCalendarsFollowLists.value,
|
||||
account.hiddenUsers.flow.value,
|
||||
)
|
||||
|
||||
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
|
||||
val params = buildFilterParams(account)
|
||||
return collection.filterTo(HashSet()) {
|
||||
val e = it.event
|
||||
e is CalendarEvent && params.match(e, it.relays)
|
||||
}
|
||||
}
|
||||
|
||||
override fun sort(items: Set<Note>): List<Note> = items.sortedWith(DefaultFeedOrder)
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.datasource
|
||||
|
||||
import androidx.compose.runtime.Stable
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountFeedContentStates
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
|
||||
class CalendarsQueryState(
|
||||
val account: Account,
|
||||
val feedStates: AccountFeedContentStates,
|
||||
val scope: CoroutineScope,
|
||||
)
|
||||
|
||||
@Stable
|
||||
class CalendarsFilterAssembler(
|
||||
client: INostrClient,
|
||||
) : ComposeSubscriptionManager<CalendarsQueryState>() {
|
||||
val group =
|
||||
listOf(
|
||||
CalendarsSubAssembler(client, ::allKeys),
|
||||
)
|
||||
|
||||
override fun invalidateKeys() = invalidateFilters()
|
||||
|
||||
override fun invalidateFilters() = group.forEach { it.invalidateFilters() }
|
||||
|
||||
override fun destroy() = group.forEach { it.destroy() }
|
||||
}
|
||||
+48
@@ -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)
|
||||
}
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.datasource
|
||||
|
||||
import com.vitorpamplona.amethyst.model.TopFilter
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserAndFollowListEoseManager
|
||||
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.sample
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class CalendarsSubAssembler(
|
||||
client: INostrClient,
|
||||
allKeys: () -> Set<CalendarsQueryState>,
|
||||
) : PerUserAndFollowListEoseManager<CalendarsQueryState, TopFilter>(client, allKeys) {
|
||||
override fun updateFilter(
|
||||
key: CalendarsQueryState,
|
||||
since: SincePerRelayMap?,
|
||||
): List<RelayBasedFilter> {
|
||||
val feedSettings = key.followsPerRelay()
|
||||
|
||||
return makeCalendarsFilter(feedSettings, since, key.feedStates.calendarAppointmentsFeed.lastNoteCreatedAtIfFilled())
|
||||
}
|
||||
|
||||
override fun user(key: CalendarsQueryState) = key.account.userProfile()
|
||||
|
||||
override fun list(key: CalendarsQueryState) = key.listName()
|
||||
|
||||
fun CalendarsQueryState.listNameFlow() = account.settings.defaultCalendarsFollowList
|
||||
|
||||
fun CalendarsQueryState.listName() = listNameFlow().value
|
||||
|
||||
fun CalendarsQueryState.followsPerRelayFlow() = account.liveCalendarsFollowListsPerRelay
|
||||
|
||||
fun CalendarsQueryState.followsPerRelay() = followsPerRelayFlow().value
|
||||
|
||||
val userJobMap = mutableMapOf<User, List<Job>>()
|
||||
|
||||
@OptIn(FlowPreview::class)
|
||||
override fun newSub(key: CalendarsQueryState): Subscription {
|
||||
val user = user(key)
|
||||
userJobMap[user]?.forEach { it.cancel() }
|
||||
userJobMap[user] =
|
||||
listOf(
|
||||
key.scope.launch(Dispatchers.IO) {
|
||||
key.listNameFlow().collectLatest {
|
||||
// Calendar events are addressables stored in LocalCache's WeakReference
|
||||
// map: while the user is viewing list B the strong refs from list A's UI
|
||||
// are gone and the GC may reclaim those notes. If the EOSE cursor for the
|
||||
// list the user is switching back to still says "you have everything up
|
||||
// to T", the relay won't re-send the now-evicted events. Clearing the
|
||||
// cursor here forces a fresh fetch so the feed comes back whole.
|
||||
clearEoseFor(key)
|
||||
invalidateFilters()
|
||||
}
|
||||
},
|
||||
key.scope.launch(Dispatchers.IO) {
|
||||
key.followsPerRelayFlow().sample(500).collectLatest {
|
||||
invalidateFilters()
|
||||
}
|
||||
},
|
||||
key.account.scope.launch(Dispatchers.IO) {
|
||||
key.feedStates.calendarAppointmentsFeed.lastNoteCreatedAtWhenFullyLoaded.sample(5000).collectLatest {
|
||||
invalidateFilters()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
return super.newSub(key)
|
||||
}
|
||||
|
||||
override fun endSub(
|
||||
key: User,
|
||||
subId: String,
|
||||
) {
|
||||
super.endSub(key, subId)
|
||||
userJobMap[key]?.forEach { it.cancel() }
|
||||
}
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.datasource
|
||||
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet
|
||||
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.datasource.subassemblies.filterCalendarsByAuthors
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.datasource.subassemblies.filterCalendarsByFollows
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.datasource.subassemblies.filterCalendarsByGeohashes
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.datasource.subassemblies.filterCalendarsByHashtag
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.datasource.subassemblies.filterCalendarsByMutedAuthors
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.datasource.subassemblies.filterCalendarsGlobal
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
|
||||
|
||||
fun makeCalendarsFilter(
|
||||
feedSettings: IFeedTopNavPerRelayFilterSet,
|
||||
since: SincePerRelayMap?,
|
||||
defaultSince: Long? = null,
|
||||
): List<RelayBasedFilter> =
|
||||
when (feedSettings) {
|
||||
is AllFollowsTopNavPerRelayFilterSet -> filterCalendarsByFollows(feedSettings, since, defaultSince)
|
||||
is AuthorsTopNavPerRelayFilterSet -> filterCalendarsByAuthors(feedSettings, since, defaultSince)
|
||||
is GlobalTopNavPerRelayFilterSet -> filterCalendarsGlobal(feedSettings, since, defaultSince)
|
||||
is HashtagTopNavPerRelayFilterSet -> filterCalendarsByHashtag(feedSettings, since, defaultSince)
|
||||
is LocationTopNavPerRelayFilterSet -> filterCalendarsByGeohashes(feedSettings, since, defaultSince)
|
||||
is MutedAuthorsTopNavPerRelayFilterSet -> filterCalendarsByMutedAuthors(feedSettings, since, defaultSince)
|
||||
else -> emptyList()
|
||||
}
|
||||
+39
@@ -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,
|
||||
)
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.datasource.subassemblies
|
||||
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet
|
||||
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
|
||||
fun filterCalendarsByAuthors(
|
||||
relay: NormalizedRelayUrl,
|
||||
authors: Set<HexKey>,
|
||||
since: Long? = null,
|
||||
): List<RelayBasedFilter> {
|
||||
val authorList = authors.sorted()
|
||||
return listOf(
|
||||
RelayBasedFilter(
|
||||
relay = relay,
|
||||
filter =
|
||||
Filter(
|
||||
authors = authorList,
|
||||
kinds = AllCalendarKinds,
|
||||
limit = 500,
|
||||
since = since,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fun filterCalendarsByAuthors(
|
||||
authorSet: AuthorsTopNavPerRelayFilterSet,
|
||||
since: SincePerRelayMap?,
|
||||
defaultSince: Long? = null,
|
||||
): List<RelayBasedFilter> {
|
||||
if (authorSet.set.isEmpty()) return emptyList()
|
||||
|
||||
return authorSet.set
|
||||
.mapNotNull {
|
||||
if (it.value.authors.isEmpty()) {
|
||||
null
|
||||
} else {
|
||||
filterCalendarsByAuthors(
|
||||
relay = it.key,
|
||||
authors = it.value.authors,
|
||||
since = since?.get(it.key)?.time ?: defaultSince,
|
||||
)
|
||||
}
|
||||
}.flatten()
|
||||
}
|
||||
|
||||
fun filterCalendarsByMutedAuthors(
|
||||
authorSet: MutedAuthorsTopNavPerRelayFilterSet,
|
||||
since: SincePerRelayMap?,
|
||||
defaultSince: Long? = null,
|
||||
): List<RelayBasedFilter> {
|
||||
if (authorSet.set.isEmpty()) return emptyList()
|
||||
|
||||
return authorSet.set
|
||||
.mapNotNull {
|
||||
if (it.value.authors.isEmpty()) {
|
||||
null
|
||||
} else {
|
||||
filterCalendarsByAuthors(
|
||||
relay = it.key,
|
||||
authors = it.value.authors,
|
||||
since = since?.get(it.key)?.time ?: defaultSince,
|
||||
)
|
||||
}
|
||||
}.flatten()
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.datasource.subassemblies
|
||||
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet
|
||||
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
|
||||
|
||||
fun filterCalendarsByFollows(
|
||||
followsSet: AllFollowsTopNavPerRelayFilterSet,
|
||||
since: SincePerRelayMap?,
|
||||
defaultSince: Long? = null,
|
||||
): List<RelayBasedFilter> {
|
||||
if (followsSet.set.isEmpty()) return emptyList()
|
||||
|
||||
return followsSet.set.flatMap {
|
||||
val sinceForRelay = since?.get(it.key)?.time ?: defaultSince
|
||||
val relay = it.key
|
||||
|
||||
listOfNotNull(
|
||||
it.value.authors?.let { authors ->
|
||||
filterCalendarsByAuthors(relay, authors, sinceForRelay)
|
||||
},
|
||||
).flatten()
|
||||
}
|
||||
}
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.datasource.subassemblies
|
||||
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet
|
||||
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
|
||||
fun filterCalendarsByGeohashes(
|
||||
relay: NormalizedRelayUrl,
|
||||
geotags: Set<String>,
|
||||
since: Long?,
|
||||
): List<RelayBasedFilter> {
|
||||
if (geotags.isEmpty()) return emptyList()
|
||||
|
||||
return listOf(
|
||||
RelayBasedFilter(
|
||||
relay = relay,
|
||||
filter =
|
||||
Filter(
|
||||
kinds = CalendarAppointmentKinds,
|
||||
tags = mapOf("g" to geotags.sorted()),
|
||||
limit = 200,
|
||||
since = since,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fun filterCalendarsByGeohashes(
|
||||
geoSet: LocationTopNavPerRelayFilterSet,
|
||||
since: SincePerRelayMap?,
|
||||
defaultSince: Long?,
|
||||
): List<RelayBasedFilter> {
|
||||
if (geoSet.set.isEmpty()) return emptyList()
|
||||
|
||||
return geoSet.set
|
||||
.mapNotNull {
|
||||
if (it.value.geotags.isEmpty()) {
|
||||
null
|
||||
} else {
|
||||
filterCalendarsByGeohashes(
|
||||
relay = it.key,
|
||||
geotags = it.value.geotags,
|
||||
since = since?.get(it.key)?.time ?: defaultSince,
|
||||
)
|
||||
}
|
||||
}.flatten()
|
||||
}
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.datasource.subassemblies
|
||||
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet
|
||||
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
|
||||
fun filterCalendarsByHashtag(
|
||||
relay: NormalizedRelayUrl,
|
||||
hashtags: Set<String>,
|
||||
since: Long? = null,
|
||||
): List<RelayBasedFilter> =
|
||||
listOf(
|
||||
RelayBasedFilter(
|
||||
relay = relay,
|
||||
filter =
|
||||
Filter(
|
||||
kinds = CalendarAppointmentKinds,
|
||||
tags = mapOf("t" to hashtags.toList()),
|
||||
limit = 200,
|
||||
since = since,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
fun filterCalendarsByHashtag(
|
||||
hashtagSet: HashtagTopNavPerRelayFilterSet,
|
||||
since: SincePerRelayMap?,
|
||||
defaultSince: Long? = null,
|
||||
): List<RelayBasedFilter> {
|
||||
if (hashtagSet.set.isEmpty()) return emptyList()
|
||||
|
||||
return hashtagSet.set
|
||||
.mapNotNull { relayHashSet ->
|
||||
if (relayHashSet.value.hashtags.isEmpty()) {
|
||||
null
|
||||
} else {
|
||||
filterCalendarsByHashtag(
|
||||
relay = relayHashSet.key,
|
||||
hashtags = relayHashSet.value.hashtags,
|
||||
since = since?.get(relayHashSet.key)?.time ?: defaultSince,
|
||||
)
|
||||
}
|
||||
}.flatten()
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.datasource.subassemblies
|
||||
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet
|
||||
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
|
||||
fun filterCalendarsGlobal(
|
||||
relays: GlobalTopNavPerRelayFilterSet,
|
||||
since: SincePerRelayMap?,
|
||||
defaultSince: Long? = null,
|
||||
): List<RelayBasedFilter> {
|
||||
if (relays.set.isEmpty()) return emptyList()
|
||||
|
||||
return relays.set.map {
|
||||
val sinceForRelay = since?.get(it.key)?.time ?: defaultSince ?: TimeUtils.oneMonthAgo()
|
||||
RelayBasedFilter(
|
||||
relay = it.key,
|
||||
filter =
|
||||
Filter(
|
||||
kinds = AllCalendarKinds,
|
||||
limit = 500,
|
||||
since = sinceForRelay,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
+186
@@ -0,0 +1,186 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.detail
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.aTag.aTags
|
||||
import com.vitorpamplona.quartz.nip52Calendar.calendar.CalendarEvent
|
||||
|
||||
/**
|
||||
* Bottom sheet that lists the current user's own kind-31924 calendars with a checkbox per
|
||||
* calendar. Tapping a row toggles membership of [targetAddress] in that calendar and re-signs
|
||||
* the calendar with the updated `a` tag list. The sheet stays open while edits flow so users
|
||||
* can toggle multiple calendars without dismissing.
|
||||
*
|
||||
* Reactive: collects [LocalCache.live.newEventBundles] so newly-broadcast calendars (or our own
|
||||
* just-published edits) appear / disappear without dismissing and reopening.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AddToCalendarSheet(
|
||||
targetAddress: Address,
|
||||
accountViewModel: AccountViewModel,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
val myPubKey = accountViewModel.userProfile().pubkeyHex
|
||||
|
||||
val ownCalendars by produceState<List<CalendarEvent>>(initialValue = ownCalendars(myPubKey), myPubKey) {
|
||||
LocalCache.live.newEventBundles.collect {
|
||||
value = ownCalendars(myPubKey)
|
||||
}
|
||||
}
|
||||
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
sheetState = sheetState,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringRes(R.string.calendar_add_to_calendar_title),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
if (ownCalendars.isEmpty()) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 16.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = stringRes(R.string.calendar_add_to_calendar_none),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
return@Column
|
||||
}
|
||||
LazyColumn(modifier = Modifier.fillMaxWidth()) {
|
||||
items(ownCalendars, key = { it.dTag() }) { calendar ->
|
||||
val isMember = calendar.calendarEventAddresses().contains(targetAddress)
|
||||
CalendarPickerRow(
|
||||
title = calendar.title() ?: stringRes(R.string.calendar_untitled),
|
||||
isMember = isMember,
|
||||
onToggle = {
|
||||
toggleMembership(
|
||||
accountViewModel = accountViewModel,
|
||||
calendar = calendar,
|
||||
targetAddress = targetAddress,
|
||||
isCurrentlyMember = isMember,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CalendarPickerRow(
|
||||
title: String,
|
||||
isMember: Boolean,
|
||||
onToggle: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onToggle)
|
||||
.padding(vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Checkbox(checked = isMember, onCheckedChange = { onToggle() })
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
modifier = Modifier.padding(start = 4.dp),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun ownCalendars(myPubKey: String): List<CalendarEvent> =
|
||||
LocalCache.addressables
|
||||
.filterIntoSet { _, note ->
|
||||
val e = note.event
|
||||
e is CalendarEvent && e.pubKey == myPubKey
|
||||
}.mapNotNull { it.event as? CalendarEvent }
|
||||
.sortedBy { it.title()?.lowercase() ?: "" }
|
||||
|
||||
private fun toggleMembership(
|
||||
accountViewModel: AccountViewModel,
|
||||
calendar: CalendarEvent,
|
||||
targetAddress: Address,
|
||||
isCurrentlyMember: Boolean,
|
||||
) {
|
||||
// Existing `a` tags minus the target if removing, plus the target if adding. Preserves
|
||||
// the calendar's d-tag so the broadcast replaces the addressable rather than minting a
|
||||
// new one — same pattern as the edit-collection flow.
|
||||
val newAddresses =
|
||||
if (isCurrentlyMember) {
|
||||
calendar.calendarEventAddresses().filterNot { it == targetAddress }
|
||||
} else {
|
||||
calendar.calendarEventAddresses() + targetAddress
|
||||
}
|
||||
accountViewModel.launchSigner {
|
||||
accountViewModel.account.signAndComputeBroadcast(
|
||||
CalendarEvent.build(
|
||||
title = calendar.title().orEmpty(),
|
||||
content = calendar.content,
|
||||
dTag = calendar.dTag(),
|
||||
) {
|
||||
if (newAddresses.isNotEmpty()) aTags(newAddresses.map { ATag(it) })
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
+865
@@ -0,0 +1,865 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.detail
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.model.nip52Calendar.IcsExport
|
||||
import com.vitorpamplona.amethyst.commons.model.nip52Calendar.appointmentView
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote
|
||||
import com.vitorpamplona.amethyst.ui.components.MyAsyncImage
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture
|
||||
import com.vitorpamplona.amethyst.ui.note.ReactionsRow
|
||||
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
|
||||
import com.vitorpamplona.amethyst.ui.note.types.CalendarRsvpRow
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.addToPhoneCalendar
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.datasource.CalendarsFilterAssemblerSubscription
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.formatCalendarRange
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.formatLongDate
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.relativeTimeLabel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.shareIcs
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size30dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size35dp
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
|
||||
import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress
|
||||
import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent
|
||||
import com.vitorpamplona.quartz.nip52Calendar.appt.tags.RSVPStatusTag
|
||||
import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent
|
||||
import com.vitorpamplona.quartz.nip52Calendar.calendar.CalendarEvent
|
||||
import com.vitorpamplona.quartz.nip52Calendar.rsvp.CalendarRSVPEvent
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
|
||||
/**
|
||||
* Dedicated detail screen for a NIP-52 calendar appointment (kind 31922 or 31923). Renders the
|
||||
* full event metadata along with three related sections that the inline note render can't
|
||||
* easily fit:
|
||||
* - participants (from the event's `p` tags)
|
||||
* - RSVPs (kind 31925 events that a-tag this event)
|
||||
* - calendars this event belongs to (kind 31924 events whose member list includes this event)
|
||||
*
|
||||
* The "related" sections are snapshotted at composition for simplicity; opening the screen
|
||||
* triggers the appointments subscription so freshly-arrived related events are visible on the
|
||||
* next entry. A future revision could subscribe to LocalCache.live for true reactivity.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun CalendarEventDetailScreen(
|
||||
kind: Int,
|
||||
pubKeyHex: String,
|
||||
dTag: String,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
CalendarsFilterAssemblerSubscription(accountViewModel)
|
||||
|
||||
val targetAddress = remember(kind, pubKeyHex, dTag) { Address(kind, pubKeyHex, dTag) }
|
||||
val targetNote = remember(targetAddress) { LocalCache.getOrCreateAddressableNote(targetAddress) }
|
||||
// [observeNote] issues a per-event relay subscription on top of the LocalCache flow. This
|
||||
// is the prefetch path for deep links (notification tap, `nostr:naddr…` from another app):
|
||||
// landing on the screen without the event cached now triggers a targeted relay fetch instead
|
||||
// of waiting for the broader calendars feed to happen to include it.
|
||||
val noteState by observeNote(targetNote, accountViewModel)
|
||||
val event = noteState.note.event
|
||||
|
||||
val isOwnEvent = event?.pubKey == accountViewModel.userProfile().pubkeyHex
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
text = stringRes(R.string.route_calendar_event_detail),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { nav.popBack() }) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.AutoMirrored.ArrowBack,
|
||||
contentDescription = stringRes(R.string.back),
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
val context = LocalContext.current
|
||||
// Two share modes:
|
||||
// - .ics for non-nostr calendar apps (Google Calendar / iOS / Outlook)
|
||||
// - nostr:naddr… link for sharing inside the nostr ecosystem (in DMs,
|
||||
// in posts, in other clients). A single button with a chooser would be
|
||||
// cleaner, but two icons keep both actions one tap away.
|
||||
if (event != null) {
|
||||
// Direct "Add to phone calendar" — opens the system event composer with
|
||||
// every field pre-filled. Falls back to the .ics share path if the device
|
||||
// has no calendar app registered for ACTION_INSERT (rare; Wear OS, some
|
||||
// GrapheneOS profiles).
|
||||
IconButton(onClick = {
|
||||
if (!addToPhoneCalendar(context, event)) {
|
||||
val ics = IcsExport.appointmentToIcs(event, targetAddress, TimeUtils.now())
|
||||
val filename = IcsExport.appointmentFilename(event, targetAddress)
|
||||
shareIcs(context, filename, ics)
|
||||
}
|
||||
}) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.EventAvailable,
|
||||
contentDescription = stringRes(R.string.calendar_add_to_phone_calendar),
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
IconButton(onClick = {
|
||||
val ics = IcsExport.appointmentToIcs(event, targetAddress, TimeUtils.now())
|
||||
val filename = IcsExport.appointmentFilename(event, targetAddress)
|
||||
shareIcs(context, filename, ics)
|
||||
}) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Share,
|
||||
contentDescription = stringRes(R.string.calendar_export_event),
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
val shareTitle = stringRes(R.string.calendar_share_nostr_title)
|
||||
IconButton(onClick = {
|
||||
val naddr =
|
||||
NAddress.create(
|
||||
targetAddress.kind,
|
||||
targetAddress.pubKeyHex,
|
||||
targetAddress.dTag,
|
||||
null,
|
||||
)
|
||||
val intent =
|
||||
Intent(Intent.ACTION_SEND)
|
||||
.setType("text/plain")
|
||||
.putExtra(Intent.EXTRA_TEXT, "nostr:$naddr")
|
||||
context.startActivity(
|
||||
Intent
|
||||
.createChooser(intent, shareTitle)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
|
||||
)
|
||||
}) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.AutoMirrored.Send,
|
||||
contentDescription = stringRes(R.string.calendar_share_nostr),
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
}
|
||||
// The Edit affordance is only meaningful when the current account is the
|
||||
// author — relays will reject a signed-by-stranger replacement.
|
||||
if (isOwnEvent && event != null) {
|
||||
IconButton(onClick = {
|
||||
nav.nav(
|
||||
Route.EditCalendarEvent(
|
||||
kind = event.kind,
|
||||
pubKeyHex = event.pubKey,
|
||||
dTag = targetAddress.dTag,
|
||||
),
|
||||
)
|
||||
}) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Edit,
|
||||
contentDescription = stringRes(R.string.edit_calendar_event),
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
) { pad ->
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(
|
||||
top = pad.calculateTopPadding(),
|
||||
bottom = pad.calculateBottomPadding(),
|
||||
).consumeWindowInsets(pad)
|
||||
.imePadding()
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState()),
|
||||
) {
|
||||
when (event) {
|
||||
is CalendarTimeSlotEvent,
|
||||
is CalendarDateSlotEvent,
|
||||
->
|
||||
EventBody(
|
||||
note = targetNote,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
targetAddress = targetAddress,
|
||||
)
|
||||
|
||||
is CalendarEvent ->
|
||||
CollectionBody(
|
||||
note = targetNote,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
targetAddress = targetAddress,
|
||||
)
|
||||
|
||||
else -> LoadingPlaceholder()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LoadingPlaceholder() {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize().padding(32.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = stringRes(R.string.calendar_event_loading),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EventBody(
|
||||
note: Note,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
targetAddress: Address,
|
||||
) {
|
||||
val view = note.appointmentView() ?: return
|
||||
val event = note.event ?: return
|
||||
|
||||
val participants =
|
||||
remember(note.idHex) {
|
||||
when (val e = event) {
|
||||
is CalendarTimeSlotEvent -> e.participants()
|
||||
is CalendarDateSlotEvent -> e.participants()
|
||||
else -> emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
HeroImage(view.image, accountViewModel)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
Column(modifier = Modifier.padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
view.title?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
}
|
||||
formatCalendarRange(note)?.let { range ->
|
||||
Text(
|
||||
text = range,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
val context = LocalContext.current
|
||||
val relative =
|
||||
remember(note.idHex, view.startSeconds) {
|
||||
relativeTimeLabel(context, view, TimeUtils.now())
|
||||
}
|
||||
relative?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
view.location?.let { LocationRow(it) }
|
||||
view.summary?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Standard social actions (zap, reactions/likes, repost, reply count → thread/comments).
|
||||
// Uses the shared [ReactionsRow] so the affordances look and behave the same as every other
|
||||
// note-detail surface in the app — no calendar-specific reinvention. The row carries its
|
||||
// own internal vertical padding, so we don't add any here; an outer spacedBy compounded with
|
||||
// that padding made the row look like it had huge top/bottom margins.
|
||||
ReactionsRow(
|
||||
baseNote = note,
|
||||
showReactionDetail = true,
|
||||
addPadding = true,
|
||||
editState = null,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
CalendarRsvpRow(
|
||||
eventKind = event.kind,
|
||||
eventPubKey = event.pubKey,
|
||||
eventDTag = targetAddress.dTag,
|
||||
eventId = event.id,
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
if (participants.isNotEmpty()) {
|
||||
HorizontalDivider()
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
ParticipantsSection(participants, accountViewModel, nav)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
}
|
||||
|
||||
HorizontalDivider()
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
RsvpsSection(targetAddress, accountViewModel, nav)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
HorizontalDivider()
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
InCalendarsSection(targetAddress, accountViewModel, nav)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
}
|
||||
|
||||
/**
|
||||
* Detail body for a NIP-52 kind-31924 calendar collection. Renders the same author header /
|
||||
* social actions surface as an appointment, but the body is a list of the collection's member
|
||||
* appointments instead of the appointment metadata block. Tapping a member routes to its own
|
||||
* appointment detail.
|
||||
*/
|
||||
@Composable
|
||||
private fun CollectionBody(
|
||||
note: Note,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
targetAddress: Address,
|
||||
) {
|
||||
val event = note.event as? CalendarEvent ?: return
|
||||
val title = remember(note.idHex) { event.title() }
|
||||
val memberAddresses = remember(note.idHex) { event.calendarEventAddresses() }
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
Column(modifier = Modifier.padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
title?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
}
|
||||
if (event.content.isNotBlank()) {
|
||||
Text(
|
||||
text = event.content,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
ReactionsRow(
|
||||
baseNote = note,
|
||||
showReactionDetail = true,
|
||||
addPadding = true,
|
||||
editState = null,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
|
||||
HorizontalDivider()
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
CollectionMembersSection(memberAddresses, targetAddress, accountViewModel, nav)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists the appointments addressed by [memberAddresses]. Each row tries to resolve the cached
|
||||
* appointment event; if it hasn't arrived yet we fall back to a thin "(loading…)" placeholder
|
||||
* tied to the address so the user can still see what's expected. Tapping a row routes to that
|
||||
* appointment's detail screen.
|
||||
*/
|
||||
@Composable
|
||||
private fun CollectionMembersSection(
|
||||
memberAddresses: List<Address>,
|
||||
targetAddress: Address,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
Column(modifier = Modifier.padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
SectionTitle(stringRes(R.string.calendar_collection_count, memberAddresses.size))
|
||||
if (memberAddresses.isEmpty()) {
|
||||
Text(
|
||||
text = stringRes(R.string.calendar_collection_empty_members),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
return@Column
|
||||
}
|
||||
memberAddresses.forEach { address ->
|
||||
CollectionMemberRow(address, accountViewModel, nav)
|
||||
}
|
||||
if (targetAddress.dTag.isNotEmpty()) Unit // suppress unused-parameter lint
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One row in [CollectionMembersSection]. Reads the live note for [address] so a member event
|
||||
* that arrives later in the session fills in without leaving the screen.
|
||||
*/
|
||||
@Composable
|
||||
private fun CollectionMemberRow(
|
||||
address: Address,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val memberNote = remember(address) { LocalCache.getOrCreateAddressableNote(address) }
|
||||
// Issues a per-event relay subscription so missing members fill in while the user is on
|
||||
// the screen.
|
||||
val state by observeNote(memberNote, accountViewModel)
|
||||
val memberEvent = state.note.event
|
||||
|
||||
val title =
|
||||
when (memberEvent) {
|
||||
is CalendarTimeSlotEvent -> memberEvent.title()
|
||||
is CalendarDateSlotEvent -> memberEvent.title()
|
||||
else -> null
|
||||
}
|
||||
val subtitle =
|
||||
when (memberEvent) {
|
||||
is CalendarTimeSlotEvent -> memberEvent.start()?.let(::formatLongDate)
|
||||
is CalendarDateSlotEvent -> memberEvent.start()
|
||||
else -> null
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { nav.nav(Route.CalendarEventDetail(address)) }
|
||||
.padding(vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.CalendarMonth,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(24.dp),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = title ?: stringRes(R.string.calendar_untitled),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
subtitle?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
Icon(
|
||||
symbol = MaterialSymbols.ChevronRight,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HeroImage(
|
||||
image: String?,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
if (image.isNullOrBlank()) return
|
||||
MyAsyncImage(
|
||||
imageUrl = image,
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.FillWidth,
|
||||
mainImageModifier = Modifier.fillMaxWidth().height(200.dp),
|
||||
loadedImageModifier = Modifier,
|
||||
accountViewModel = accountViewModel,
|
||||
onLoadingBackground = { Box(modifier = Modifier.fillMaxWidth().height(200.dp)) },
|
||||
onError = { Box(modifier = Modifier.fillMaxWidth().height(200.dp)) },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LocationRow(location: String) {
|
||||
val context = LocalContext.current
|
||||
// NIP-52 `location` is free-text — a place name, an address, OR a meeting URL (Zoom, Jitsi,
|
||||
// a livestream link). Treat http(s) URLs as web links so they open in the browser; otherwise
|
||||
// hand to the maps app via the geo: intent.
|
||||
val isUrl = remember(location) { isLocationUrl(location) }
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable {
|
||||
runCatching {
|
||||
val trimmed = location.trim()
|
||||
val intent =
|
||||
if (isUrl) {
|
||||
Intent(Intent.ACTION_VIEW, Uri.parse(trimmed))
|
||||
} else {
|
||||
// `geo:0,0?q=<location>` is the Android geo intent; the user's
|
||||
// installed maps app handles it.
|
||||
Intent(Intent.ACTION_VIEW, Uri.parse("geo:0,0?q=${Uri.encode(trimmed)}"))
|
||||
}
|
||||
// runCatching swallows ActivityNotFoundException when no handler is
|
||||
// installed — we don't have anywhere useful to fall back to.
|
||||
context.startActivity(intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
|
||||
}
|
||||
}.padding(vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
symbol = if (isUrl) MaterialSymbols.Link else MaterialSymbols.LocationOn,
|
||||
contentDescription =
|
||||
stringRes(if (isUrl) R.string.calendar_open_link else R.string.calendar_open_in_maps),
|
||||
modifier = Modifier.size(18.dp),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Spacer(modifier = Modifier.size(6.dp))
|
||||
Text(
|
||||
text = location,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Icon(
|
||||
symbol = MaterialSymbols.ChevronRight,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ParticipantsSection(
|
||||
participants: List<PTag>,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
Column(modifier = Modifier.padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
SectionTitle(stringRes(R.string.calendar_participants_section, participants.size))
|
||||
participants.forEach { p ->
|
||||
UserRow(p.pubKey, accountViewModel, nav, trailing = null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RsvpsSection(
|
||||
targetAddress: Address,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
// Reactively re-scan when LocalCache emits new bundles. Without this, RSVPs that arrive
|
||||
// from relays while the screen is open don't show up until the user leaves and returns.
|
||||
val rsvps by rememberRsvpsFor(targetAddress)
|
||||
|
||||
Column(modifier = Modifier.padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
SectionTitle(stringRes(R.string.calendar_rsvp_section, rsvps.size))
|
||||
if (rsvps.isEmpty()) {
|
||||
Text(
|
||||
text = stringRes(R.string.calendar_rsvp_none),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
return@Column
|
||||
}
|
||||
rsvps.forEach { rsvp ->
|
||||
UserRow(
|
||||
pubKey = rsvp.pubKey,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
trailing = { RsvpStatusBadge(rsvp.status()) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InCalendarsSection(
|
||||
targetAddress: Address,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val calendars by rememberCalendarsContaining(targetAddress)
|
||||
var showAddSheet by remember { mutableStateOf(false) }
|
||||
|
||||
Column(modifier = Modifier.padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
SectionTitle(stringRes(R.string.calendar_event_in_calendars, calendars.size))
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
// Affordance for adding/removing this event from the user's own calendars. Hidden
|
||||
// when the user has no own calendars — the sheet would show an empty-state in that
|
||||
// case anyway, but the IconButton would be a misleading entry point.
|
||||
IconButton(onClick = { showAddSheet = true }) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Add,
|
||||
contentDescription = stringRes(R.string.calendar_add_to_calendar_action),
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (showAddSheet) {
|
||||
AddToCalendarSheet(
|
||||
targetAddress = targetAddress,
|
||||
accountViewModel = accountViewModel,
|
||||
onDismiss = { showAddSheet = false },
|
||||
)
|
||||
}
|
||||
if (calendars.isEmpty()) {
|
||||
Text(
|
||||
text = stringRes(R.string.calendar_event_in_no_calendars),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
return@Column
|
||||
}
|
||||
calendars.forEach { calendar ->
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable {
|
||||
nav.nav(
|
||||
Route.CalendarEventDetail(
|
||||
kind = CalendarEvent.KIND,
|
||||
pubKeyHex = calendar.pubKey,
|
||||
dTag = calendar.dTag(),
|
||||
),
|
||||
)
|
||||
}.padding(vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
ClickableUserPicture(
|
||||
baseUserHex = calendar.pubKey,
|
||||
size = Size30dp,
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
Text(
|
||||
text = calendar.title() ?: stringRes(R.string.calendar_untitled),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared social-row layout: avatar (clickable → profile), display name, optional trailing slot
|
||||
* for things like RSVP badges. Matches the visual language of every other user-list across the
|
||||
* app.
|
||||
*/
|
||||
@Composable
|
||||
private fun UserRow(
|
||||
pubKey: String,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
trailing: (@Composable () -> Unit)?,
|
||||
) {
|
||||
LoadUser(baseUserHex = pubKey, accountViewModel = accountViewModel) { user ->
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
ClickableUserPicture(
|
||||
baseUserHex = pubKey,
|
||||
size = Size35dp,
|
||||
accountViewModel = accountViewModel,
|
||||
onClick = { nav.nav(Route.Profile(pubKey)) },
|
||||
)
|
||||
if (user != null) {
|
||||
UsernameDisplay(
|
||||
baseUser = user,
|
||||
weight = Modifier.weight(1f),
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
} else {
|
||||
// LoadUser is still resolving — show the npub-style fallback so the row
|
||||
// doesn't visibly collapse.
|
||||
Text(
|
||||
text = formatPubKeyShort(pubKey),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.weight(1f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
trailing?.invoke()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RsvpStatusBadge(status: RSVPStatusTag.STATUS?) {
|
||||
val (label, color) =
|
||||
when (status) {
|
||||
RSVPStatusTag.STATUS.ACCEPTED ->
|
||||
stringRes(R.string.calendar_rsvp_going_prefixed) to MaterialTheme.colorScheme.primary
|
||||
RSVPStatusTag.STATUS.TENTATIVE ->
|
||||
stringRes(R.string.calendar_rsvp_maybe_prefixed) to MaterialTheme.colorScheme.tertiary
|
||||
RSVPStatusTag.STATUS.DECLINED ->
|
||||
stringRes(R.string.calendar_rsvp_not_going_prefixed) to MaterialTheme.colorScheme.error
|
||||
null -> "—" to MaterialTheme.colorScheme.onSurfaceVariant
|
||||
}
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = color,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SectionTitle(text: String) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(bottom = 4.dp),
|
||||
)
|
||||
}
|
||||
|
||||
private fun formatPubKeyShort(pubKey: String): String = if (pubKey.length <= 16) pubKey else pubKey.take(8) + "…" + pubKey.takeLast(8)
|
||||
|
||||
/**
|
||||
* Whether a NIP-52 `location` value should be treated as a clickable web link (Zoom, Jitsi,
|
||||
* livestream URL) instead of a place name to look up in the maps app. Trims so `" https://… "`
|
||||
* still classifies as a URL.
|
||||
*/
|
||||
private fun isLocationUrl(location: String): Boolean {
|
||||
val trimmed = location.trim()
|
||||
return trimmed.startsWith("http://", ignoreCase = true) ||
|
||||
trimmed.startsWith("https://", ignoreCase = true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reactive scan of [LocalCache] for kind-31925 RSVPs that a-tag [targetAddress]. Re-runs on
|
||||
* every new-event bundle so RSVPs that arrive while the screen is open appear without a manual
|
||||
* refresh. The scan is O(addressables) which is bounded by the relay subscription.
|
||||
*/
|
||||
@Composable
|
||||
private fun rememberRsvpsFor(targetAddress: Address): State<List<CalendarRSVPEvent>> =
|
||||
produceState(initialValue = findRsvpsFor(targetAddress), targetAddress) {
|
||||
LocalCache.live.newEventBundles.collect {
|
||||
value = findRsvpsFor(targetAddress)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun rememberCalendarsContaining(targetAddress: Address): State<List<CalendarEvent>> =
|
||||
produceState(initialValue = findCalendarsContaining(targetAddress), targetAddress) {
|
||||
LocalCache.live.newEventBundles.collect {
|
||||
value = findCalendarsContaining(targetAddress)
|
||||
}
|
||||
}
|
||||
|
||||
private fun findRsvpsFor(targetAddress: Address): List<CalendarRSVPEvent> =
|
||||
LocalCache.addressables
|
||||
.filterIntoSet { _, note ->
|
||||
val e = note.event
|
||||
e is CalendarRSVPEvent && e.calendarEventAddress() == targetAddress
|
||||
}.mapNotNull { it.event as? CalendarRSVPEvent }
|
||||
.sortedByDescending { it.createdAt }
|
||||
|
||||
private fun findCalendarsContaining(targetAddress: Address): List<CalendarEvent> =
|
||||
LocalCache.addressables
|
||||
.filterIntoSet { _, note ->
|
||||
val e = note.event
|
||||
e is CalendarEvent && e.calendarEventAddresses().contains(targetAddress)
|
||||
}.mapNotNull { it.event as? CalendarEvent }
|
||||
.sortedByDescending { it.createdAt }
|
||||
+6
@@ -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,
|
||||
|
||||
+6
-1
@@ -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,
|
||||
|
||||
@@ -583,6 +583,7 @@
|
||||
<string name="profile_badges_description">Choose which of the badges you\'ve received appear on your profile.</string>
|
||||
<string name="profile_badges_empty">You haven\'t received any badges yet.</string>
|
||||
<string name="pictures">Pictures</string>
|
||||
<string name="calendars">Calendars</string>
|
||||
<string name="shorts">Shorts</string>
|
||||
<string name="public_chats">Public Chats</string>
|
||||
<string name="follow_packs">Follow Packs</string>
|
||||
@@ -1851,6 +1852,8 @@
|
||||
<string name="route_global">Global</string>
|
||||
<string name="route_video">Shorts</string>
|
||||
<string name="route_pictures">Pictures</string>
|
||||
<string name="route_calendars">Calendars</string>
|
||||
<string name="route_calendar_collections">Calendar lists</string>
|
||||
<string name="route_chess">Chess</string>
|
||||
<string name="wallet">Wallet</string>
|
||||
<string name="wallet_balance">Balance</string>
|
||||
@@ -1917,6 +1920,123 @@
|
||||
<string name="new_zap_poll">New Zap Poll</string>
|
||||
<string name="new_regular_poll">New Regular Poll</string>
|
||||
<string name="new_picture">New Picture</string>
|
||||
<string name="new_calendar_event">New Calendar Event</string>
|
||||
<string name="edit_calendar_event">Edit Calendar Event</string>
|
||||
<string name="calendar_event_all_day_locked">Locked while editing — changing this would create a new event instead.</string>
|
||||
<string name="new_calendar_collection">New Calendar</string>
|
||||
<string name="edit_calendar_collection">Edit Calendar</string>
|
||||
<string name="calendar_collection_events_section">Events in this calendar (%1$d)</string>
|
||||
<string name="calendar_collection_no_events_yet">You haven\'t created any calendar events yet.</string>
|
||||
|
||||
<string name="calendar_view_feed">Feed</string>
|
||||
<string name="calendar_view_month">Month</string>
|
||||
<string name="calendar_view_week">Week</string>
|
||||
<string name="calendar_view_day">Day</string>
|
||||
|
||||
<string name="calendar_section_upcoming">Upcoming</string>
|
||||
<string name="calendar_section_past">Past</string>
|
||||
<string name="calendar_empty_feed">No upcoming or past calendar events from your selected feed yet.</string>
|
||||
<string name="calendar_empty_collections">No calendar collections yet.</string>
|
||||
<string name="calendar_empty_feed_title">Your calendar is empty</string>
|
||||
<string name="calendar_empty_feed_subtitle">Events shared by people you follow appear here. Tap the + button to create your own.</string>
|
||||
<string name="calendar_empty_collections_title">No collections yet</string>
|
||||
<string name="calendar_empty_collections_subtitle">Group events together — a meetup series, a conference track, your team\'s roadmap. Tap + to create one.</string>
|
||||
<string name="calendar_empty_day_title">Nothing scheduled</string>
|
||||
<string name="calendar_empty_day_subtitle">No events on this day. Tap + to add one.</string>
|
||||
<string name="calendar_empty_week_title">Nothing this week</string>
|
||||
<string name="calendar_empty_week_subtitle">No events fall in this week.</string>
|
||||
|
||||
<string name="calendar_event_title">Title</string>
|
||||
<string name="calendar_event_summary">Summary</string>
|
||||
<string name="calendar_event_location">Location</string>
|
||||
<string name="calendar_event_image">Image URL</string>
|
||||
<string name="calendar_event_all_day">All-day event</string>
|
||||
<string name="calendar_event_start">Starts</string>
|
||||
<string name="calendar_event_end">Ends</string>
|
||||
<string name="calendar_event_hashtags">Hashtags (comma-separated)</string>
|
||||
<string name="calendar_event_pick_date">Pick date</string>
|
||||
<string name="calendar_event_pick_time">Pick time</string>
|
||||
<string name="calendar_event_invalid">Title and start are required.</string>
|
||||
<string name="calendar_event_end_before_start">End must be after start.</string>
|
||||
<string name="calendar_nav_previous_month">Previous month</string>
|
||||
<string name="calendar_nav_next_month">Next month</string>
|
||||
<string name="calendar_nav_previous_week">Previous week</string>
|
||||
<string name="calendar_nav_next_week">Next week</string>
|
||||
<string name="calendar_nav_previous_day">Previous day</string>
|
||||
<string name="calendar_nav_next_day">Next day</string>
|
||||
<string name="calendar_no_events_today">No events on this day</string>
|
||||
<string name="calendar_no_events">No events</string>
|
||||
<string name="calendar_continues">Continues</string>
|
||||
<string name="calendar_day_of_total">Day %1$d of %2$d</string>
|
||||
<string name="calendar_add_to_phone_calendar">Add to phone calendar</string>
|
||||
<string name="calendar_nav_jump_to_today">Jump to today</string>
|
||||
<plurals name="calendar_day_a11y_events">
|
||||
<item quantity="zero">%1$s, no events</item>
|
||||
<item quantity="one">%1$s, %2$d event</item>
|
||||
<item quantity="other">%1$s, %2$d events</item>
|
||||
</plurals>
|
||||
<string name="calendar_day_a11y_today_suffix">today</string>
|
||||
<string name="calendar_day_a11y_selected_suffix">selected</string>
|
||||
<string name="calendar_fab_new_event">Create a new calendar event</string>
|
||||
<string name="calendar_fab_new_collection">Create a new calendar collection</string>
|
||||
<string name="calendar_fab_toggle">Show create options</string>
|
||||
<string name="calendar_untitled">(untitled)</string>
|
||||
<string name="calendar_all_day">All-day</string>
|
||||
<string name="calendar_rsvp_going_prefixed">✓ Going</string>
|
||||
<string name="calendar_rsvp_maybe_prefixed">\? Maybe</string>
|
||||
<string name="calendar_rsvp_not_going_prefixed">✗ Can\'t go</string>
|
||||
|
||||
<string name="calendar_collection_title">Title</string>
|
||||
<string name="calendar_collection_description">Description</string>
|
||||
<string name="calendar_collection_invalid">A title is required.</string>
|
||||
<string name="calendar_collection_count">%1$d events</string>
|
||||
<string name="calendar_collection_empty_members">No events in this calendar yet.</string>
|
||||
|
||||
<string name="calendar_rsvp_going">Going</string>
|
||||
<string name="calendar_rsvp_maybe">Maybe</string>
|
||||
<string name="calendar_rsvp_not_going">Can\'t go</string>
|
||||
<string name="calendar_rsvp_section">RSVPs (%1$d)</string>
|
||||
<string name="calendar_rsvp_none">No RSVPs yet.</string>
|
||||
<string name="calendar_participants_section">Participants (%1$d)</string>
|
||||
<string name="calendar_event_in_calendars">In calendars (%1$d)</string>
|
||||
<string name="calendar_event_in_no_calendars">Not part of any calendar yet.</string>
|
||||
<string name="calendar_event_loading">Loading event…</string>
|
||||
<string name="calendar_relative_ongoing">Happening now</string>
|
||||
<string name="calendar_relative_ongoing_with_end">%1$s · ends %2$s</string>
|
||||
<string name="calendar_export_share_title">Share calendar event</string>
|
||||
<string name="calendar_export_event">Export to calendar (.ics)</string>
|
||||
<string name="calendar_reminder_channel_id">calendar_reminders</string>
|
||||
<string name="calendar_reminder_channel_name">Calendar reminders</string>
|
||||
<string name="calendar_reminder_channel_description">Heads-up when an event you\'re attending is about to start.</string>
|
||||
<string name="calendar_reminder_default_title">Calendar event</string>
|
||||
<string name="calendar_reminder_body">Starts in %1$d minutes</string>
|
||||
<string name="calendar_add_to_calendar_action">Add to one of your calendars</string>
|
||||
<string name="calendar_add_to_calendar_title">Add to a calendar</string>
|
||||
<string name="calendar_add_to_calendar_none">You haven\'t created any calendars yet.</string>
|
||||
<string name="calendar_collection_delete">Delete calendar</string>
|
||||
<string name="calendar_collection_delete_confirm_title">Delete this calendar?</string>
|
||||
<string name="calendar_collection_delete_confirm_message">The calendar list will be removed. Events inside it are not deleted.</string>
|
||||
<string name="calendar_event_pick_image">Pick image</string>
|
||||
<string name="calendar_event_image_upload_failed">Image upload failed</string>
|
||||
<string name="calendar_event_image_upload_failed_body">The picked image couldn\'t be uploaded. Try again or paste a URL.</string>
|
||||
<string name="calendar_event_participants_section">Participants (%1$d)</string>
|
||||
<string name="calendar_event_participant_input">Search name, npub, or nip-05</string>
|
||||
<string name="calendar_event_participant_invalid">Enter a valid npub… or 64-character hex pubkey.</string>
|
||||
<string name="calendar_event_participant_remove">Remove participant</string>
|
||||
<string name="calendar_reminder_settings_title">Calendar reminders</string>
|
||||
<string name="calendar_reminder_settings_enabled_title">Send reminders</string>
|
||||
<string name="calendar_reminder_settings_enabled_subtitle">A notification fires when an event you\'re attending is about to start.</string>
|
||||
<string name="calendar_reminder_settings_lead_title">Reminder lead time</string>
|
||||
<string name="calendar_reminder_settings_lead_subtitle">How many minutes before the event you want to be notified.</string>
|
||||
<string name="calendar_reminder_settings_lead_choice">%1$d min</string>
|
||||
<string name="calendar_share_nostr">Share as nostr link</string>
|
||||
<string name="calendar_share_nostr_title">Share calendar link</string>
|
||||
<string name="calendar_filter_all">All calendars</string>
|
||||
<string name="calendar_filter_sheet_title">Show events from…</string>
|
||||
<string name="calendar_filter_no_calendars">You haven\'t created any calendars yet.</string>
|
||||
<string name="calendar_open_in_maps">Open in maps</string>
|
||||
<string name="calendar_open_link">Open link</string>
|
||||
<string name="route_calendar_event_detail">Event details</string>
|
||||
<string name="new_short_video">New Short Video</string>
|
||||
<string name="new_long_video">New Long Video</string>
|
||||
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.calendar
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags
|
||||
import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent
|
||||
import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Smoke tests for the parsing path used by [NewCalendarEventViewModel.loadForEdit]. The VM
|
||||
* itself can't be instantiated in a JVM test without the Account graph, but the per-field
|
||||
* extraction logic delegates to Quartz accessors that are pure functions on the parsed event —
|
||||
* exercising those here proves the round-trip from on-the-wire tags back to populated form
|
||||
* fields.
|
||||
*/
|
||||
class CalendarEditLoadTest {
|
||||
@Test
|
||||
fun timeSlot_roundTripsAllFields() {
|
||||
val event =
|
||||
CalendarTimeSlotEvent(
|
||||
id = "id",
|
||||
pubKey = "pub",
|
||||
createdAt = 0L,
|
||||
tags =
|
||||
arrayOf(
|
||||
arrayOf("d", "d-tag"),
|
||||
arrayOf("title", "Bitcoin meetup"),
|
||||
arrayOf("start", "1775671200"),
|
||||
arrayOf("end", "1775674800"),
|
||||
arrayOf("start_tzid", "Europe/Oslo"),
|
||||
arrayOf("summary", "An evening of stacking"),
|
||||
arrayOf("image", "https://example.com/img.png"),
|
||||
arrayOf("location", "Storgata 8"),
|
||||
arrayOf("t", "bitcoin"),
|
||||
arrayOf("t", "meetup"),
|
||||
),
|
||||
content = "Body",
|
||||
sig = "sig",
|
||||
)
|
||||
|
||||
assertEquals("Bitcoin meetup", event.title())
|
||||
assertEquals(1775671200L, event.start())
|
||||
assertEquals(1775674800L, event.end())
|
||||
assertEquals("Europe/Oslo", event.startTzId())
|
||||
assertEquals("An evening of stacking", event.summary())
|
||||
assertEquals("https://example.com/img.png", event.image())
|
||||
assertEquals("Storgata 8", event.location())
|
||||
assertEquals(listOf("bitcoin", "meetup"), event.hashtags())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun dateSlot_roundTripsAllFields() {
|
||||
val event =
|
||||
CalendarDateSlotEvent(
|
||||
id = "id",
|
||||
pubKey = "pub",
|
||||
createdAt = 0L,
|
||||
tags =
|
||||
arrayOf(
|
||||
arrayOf("d", "d-tag"),
|
||||
arrayOf("title", "Conference"),
|
||||
arrayOf("start", "2025-01-15"),
|
||||
arrayOf("end", "2025-01-17"),
|
||||
arrayOf("summary", "Three day affair"),
|
||||
arrayOf("image", "https://example.com/banner.png"),
|
||||
arrayOf("location", "Lisbon"),
|
||||
arrayOf("t", "tech"),
|
||||
),
|
||||
content = "Body",
|
||||
sig = "sig",
|
||||
)
|
||||
|
||||
assertEquals("Conference", event.title())
|
||||
assertEquals("2025-01-15", event.start())
|
||||
assertEquals("2025-01-17", event.end())
|
||||
assertEquals("Three day affair", event.summary())
|
||||
assertEquals("https://example.com/banner.png", event.image())
|
||||
assertEquals("Lisbon", event.location())
|
||||
assertEquals(listOf("tech"), event.hashtags())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun emptyFields_returnSafeDefaults() {
|
||||
// An event with no optional tags should parse without exceptions; the VM substitutes
|
||||
// empty-string defaults in those cases.
|
||||
val event =
|
||||
CalendarTimeSlotEvent(
|
||||
id = "id",
|
||||
pubKey = "pub",
|
||||
createdAt = 0L,
|
||||
tags =
|
||||
arrayOf(
|
||||
arrayOf("d", "d-tag"),
|
||||
arrayOf("title", "Bare"),
|
||||
arrayOf("start", "1000000"),
|
||||
),
|
||||
content = "",
|
||||
sig = "sig",
|
||||
)
|
||||
|
||||
assertEquals("Bare", event.title())
|
||||
assertEquals(1000000L, event.start())
|
||||
assertEquals(null, event.end())
|
||||
assertEquals(null, event.summary())
|
||||
assertEquals(null, event.image())
|
||||
assertEquals(null, event.location())
|
||||
assertEquals(emptyList<String>(), event.hashtags())
|
||||
}
|
||||
}
|
||||
+221
@@ -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 }
|
||||
}
|
||||
}
|
||||
+237
@@ -0,0 +1,237 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.calendar
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import com.vitorpamplona.amethyst.service.calendar.CalendarReminderPrefs
|
||||
import com.vitorpamplona.amethyst.service.calendar.CalendarReminderStore
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Unit tests for the device-level reminder preferences and the per-event "already notified"
|
||||
* store. Backed by an in-memory fake [SharedPreferences] so the test runs on the JVM without
|
||||
* needing Robolectric.
|
||||
*/
|
||||
class CalendarReminderPrefsTest {
|
||||
private lateinit var fakePrefs: FakeSharedPreferences
|
||||
private lateinit var ctx: Context
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
fakePrefs = FakeSharedPreferences()
|
||||
ctx = mockk()
|
||||
every { ctx.getSharedPreferences(any(), any()) } returns fakePrefs
|
||||
}
|
||||
|
||||
@Test
|
||||
fun prefs_defaultsMatchPublicConstants() {
|
||||
val prefs = CalendarReminderPrefs(ctx)
|
||||
// Defaults are the contract callers in AppModules rely on — flipping these without an
|
||||
// explicit migration would silently re-enable reminders for users who had turned them
|
||||
// off (or vice versa).
|
||||
assertEquals(CalendarReminderPrefs.DEFAULT_ENABLED, prefs.isEnabled())
|
||||
assertEquals(CalendarReminderPrefs.DEFAULT_LEAD_MINUTES, prefs.leadMinutes())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun prefs_setEnabled_roundTrips() {
|
||||
val prefs = CalendarReminderPrefs(ctx)
|
||||
prefs.setEnabled(false)
|
||||
assertFalse(prefs.isEnabled())
|
||||
prefs.setEnabled(true)
|
||||
assertTrue(prefs.isEnabled())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun prefs_setLeadMinutes_roundTrips() {
|
||||
val prefs = CalendarReminderPrefs(ctx)
|
||||
prefs.setLeadMinutes(30)
|
||||
assertEquals(30, prefs.leadMinutes())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun store_wasNotified_isFalseByDefault() {
|
||||
val store = CalendarReminderStore(ctx)
|
||||
assertFalse(store.wasNotified("event-a", 1_000_000L))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun store_markNotified_makesWasNotifiedTrueForSameStart() {
|
||||
val store = CalendarReminderStore(ctx)
|
||||
store.markNotified("event-a", 1_000_000L)
|
||||
assertTrue(store.wasNotified("event-a", 1_000_000L))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun store_wasNotified_isFalseWhenStartChanges() {
|
||||
// Regression test for the "moved meeting" case: if the author updates the appointment
|
||||
// with a new start, the store should not silently swallow the new reminder.
|
||||
val store = CalendarReminderStore(ctx)
|
||||
store.markNotified("event-a", 1_000_000L)
|
||||
assertFalse(store.wasNotified("event-a", 2_000_000L))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun store_forgetBefore_dropsOldEntries() {
|
||||
val store = CalendarReminderStore(ctx)
|
||||
store.markNotified("old", 1_000_000L)
|
||||
store.markNotified("recent", 5_000_000L)
|
||||
store.forgetBefore(3_000_000L)
|
||||
assertFalse(store.wasNotified("old", 1_000_000L))
|
||||
assertTrue(store.wasNotified("recent", 5_000_000L))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bare-bones in-memory implementation of [SharedPreferences] sufficient for the prefs/store
|
||||
* round-trip tests. apply() is synchronous here — fine because the production code never relies
|
||||
* on apply()'s async semantics.
|
||||
*/
|
||||
private class FakeSharedPreferences : SharedPreferences {
|
||||
private val data = mutableMapOf<String, Any?>()
|
||||
|
||||
override fun getAll(): MutableMap<String, *> = data
|
||||
|
||||
override fun getString(
|
||||
key: String,
|
||||
defValue: String?,
|
||||
): String? = data[key] as? String ?: defValue
|
||||
|
||||
override fun getStringSet(
|
||||
key: String,
|
||||
defValues: MutableSet<String>?,
|
||||
): MutableSet<String>? {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
return data[key] as? MutableSet<String> ?: defValues
|
||||
}
|
||||
|
||||
override fun getInt(
|
||||
key: String,
|
||||
defValue: Int,
|
||||
): Int = (data[key] as? Int) ?: defValue
|
||||
|
||||
override fun getLong(
|
||||
key: String,
|
||||
defValue: Long,
|
||||
): Long = (data[key] as? Long) ?: defValue
|
||||
|
||||
override fun getFloat(
|
||||
key: String,
|
||||
defValue: Float,
|
||||
): Float = (data[key] as? Float) ?: defValue
|
||||
|
||||
override fun getBoolean(
|
||||
key: String,
|
||||
defValue: Boolean,
|
||||
): Boolean = (data[key] as? Boolean) ?: defValue
|
||||
|
||||
override fun contains(key: String): Boolean = data.containsKey(key)
|
||||
|
||||
override fun edit(): SharedPreferences.Editor = FakeEditor(data)
|
||||
|
||||
override fun registerOnSharedPreferenceChangeListener(listener: SharedPreferences.OnSharedPreferenceChangeListener?) = Unit
|
||||
|
||||
override fun unregisterOnSharedPreferenceChangeListener(listener: SharedPreferences.OnSharedPreferenceChangeListener?) = Unit
|
||||
}
|
||||
|
||||
private class FakeEditor(
|
||||
private val data: MutableMap<String, Any?>,
|
||||
) : SharedPreferences.Editor {
|
||||
private val pending = mutableMapOf<String, Any?>()
|
||||
private val removed = mutableSetOf<String>()
|
||||
private var clearAll = false
|
||||
|
||||
override fun putString(
|
||||
key: String,
|
||||
value: String?,
|
||||
): SharedPreferences.Editor {
|
||||
pending[key] = value
|
||||
return this
|
||||
}
|
||||
|
||||
override fun putStringSet(
|
||||
key: String,
|
||||
values: MutableSet<String>?,
|
||||
): SharedPreferences.Editor {
|
||||
pending[key] = values
|
||||
return this
|
||||
}
|
||||
|
||||
override fun putInt(
|
||||
key: String,
|
||||
value: Int,
|
||||
): SharedPreferences.Editor {
|
||||
pending[key] = value
|
||||
return this
|
||||
}
|
||||
|
||||
override fun putLong(
|
||||
key: String,
|
||||
value: Long,
|
||||
): SharedPreferences.Editor {
|
||||
pending[key] = value
|
||||
return this
|
||||
}
|
||||
|
||||
override fun putFloat(
|
||||
key: String,
|
||||
value: Float,
|
||||
): SharedPreferences.Editor {
|
||||
pending[key] = value
|
||||
return this
|
||||
}
|
||||
|
||||
override fun putBoolean(
|
||||
key: String,
|
||||
value: Boolean,
|
||||
): SharedPreferences.Editor {
|
||||
pending[key] = value
|
||||
return this
|
||||
}
|
||||
|
||||
override fun remove(key: String): SharedPreferences.Editor {
|
||||
removed.add(key)
|
||||
return this
|
||||
}
|
||||
|
||||
override fun clear(): SharedPreferences.Editor {
|
||||
clearAll = true
|
||||
return this
|
||||
}
|
||||
|
||||
override fun commit(): Boolean {
|
||||
apply()
|
||||
return true
|
||||
}
|
||||
|
||||
override fun apply() {
|
||||
if (clearAll) data.clear()
|
||||
removed.forEach { data.remove(it) }
|
||||
data.putAll(pending)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.calendar
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.model.nip52Calendar.appointmentView
|
||||
import com.vitorpamplona.amethyst.commons.model.nip52Calendar.calendarEndSeconds
|
||||
import com.vitorpamplona.amethyst.commons.model.nip52Calendar.calendarLocalDayKey
|
||||
import com.vitorpamplona.amethyst.commons.model.nip52Calendar.calendarStartSeconds
|
||||
import com.vitorpamplona.amethyst.commons.model.nip52Calendar.parseIsoDateToUnixSeconds
|
||||
import com.vitorpamplona.amethyst.commons.model.nip52Calendar.upcomingFirstCalendarOrder
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent
|
||||
import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import java.time.LocalDate
|
||||
import java.time.ZoneId
|
||||
|
||||
class CalendarSortKeysTest {
|
||||
// 2025-01-15 12:00:00 UTC
|
||||
private val sampleEpochSeconds = 1736942400L
|
||||
private val sampleEpochSecondsPlus2h = sampleEpochSeconds + 7200L
|
||||
|
||||
@Test
|
||||
fun parseIsoDateToUnixSeconds_validDate_anchorsAtLocalMidnight() {
|
||||
val parsed = parseIsoDateToUnixSeconds("2025-01-15")
|
||||
assertEquals(
|
||||
"Should equal local midnight of Jan 15",
|
||||
LocalDate.of(2025, 1, 15).atStartOfDay(ZoneId.systemDefault()).toEpochSecond(),
|
||||
parsed,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseIsoDateToUnixSeconds_blankOrNull_returnsNull() {
|
||||
assertNull(parseIsoDateToUnixSeconds(null))
|
||||
assertNull(parseIsoDateToUnixSeconds(""))
|
||||
assertNull(parseIsoDateToUnixSeconds(" "))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseIsoDateToUnixSeconds_invalidFormat_returnsNull() {
|
||||
assertNull(parseIsoDateToUnixSeconds("01/15/2025"))
|
||||
assertNull(parseIsoDateToUnixSeconds("2025-13-01")) // month 13
|
||||
assertNull(parseIsoDateToUnixSeconds("garbage"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun calendarStartSeconds_timeSlot_returnsEventStart() {
|
||||
val note = noteWithTimeSlot(start = sampleEpochSeconds, end = sampleEpochSecondsPlus2h)
|
||||
assertEquals(sampleEpochSeconds, note.calendarStartSeconds())
|
||||
assertEquals(sampleEpochSecondsPlus2h, note.calendarEndSeconds())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun calendarStartSeconds_dateSlot_anchorsLocalMidnight() {
|
||||
val note = noteWithDateSlot(start = "2025-01-15", end = "2025-01-17")
|
||||
val expected = LocalDate.of(2025, 1, 15).atStartOfDay(ZoneId.systemDefault()).toEpochSecond()
|
||||
assertEquals(expected, note.calendarStartSeconds())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun calendarLocalDayKey_timeSlot_usesLocalCalendarDate() {
|
||||
val note = noteWithTimeSlot(start = sampleEpochSeconds)
|
||||
val expected =
|
||||
java.time.Instant
|
||||
.ofEpochSecond(sampleEpochSeconds)
|
||||
.atZone(ZoneId.systemDefault())
|
||||
.toLocalDate()
|
||||
.toEpochDay()
|
||||
assertEquals(expected, note.calendarLocalDayKey())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun calendarLocalDayKey_dateSlot_usesIsoDirectly() {
|
||||
// Date-only events must land on the ISO date in every zone — no zone conversion.
|
||||
val note = noteWithDateSlot(start = "2025-01-15")
|
||||
assertEquals(LocalDate.of(2025, 1, 15).toEpochDay(), note.calendarLocalDayKey())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun appointmentView_timeSlot_mapsAllFields() {
|
||||
val note = noteWithTimeSlot(start = sampleEpochSeconds, end = sampleEpochSecondsPlus2h)
|
||||
val view = note.appointmentView()
|
||||
assertTrue(view != null)
|
||||
assertEquals("Demo", view!!.title)
|
||||
assertEquals(false, view.isAllDay)
|
||||
assertEquals(sampleEpochSeconds, view.startSeconds)
|
||||
assertEquals(sampleEpochSecondsPlus2h, view.endSeconds)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun appointmentView_dateSlot_isAllDayTrue() {
|
||||
val note = noteWithDateSlot(start = "2025-01-15", end = "2025-01-16")
|
||||
val view = note.appointmentView()
|
||||
assertTrue(view != null)
|
||||
assertEquals(true, view!!.isAllDay)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun appointmentView_nonCalendarNote_returnsNull() {
|
||||
val note = Note("no-event")
|
||||
assertNull(note.appointmentView())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun upcomingFirstOrder_upcomingBeforePast() {
|
||||
val now = sampleEpochSeconds
|
||||
val past = noteWithTimeSlot(id = "p", start = now - 3600)
|
||||
val future = noteWithTimeSlot(id = "f", start = now + 3600)
|
||||
val sorted = listOf(past, future).sortedWith(upcomingFirstCalendarOrder(now))
|
||||
assertEquals("f", sorted[0].idHex)
|
||||
assertEquals("p", sorted[1].idHex)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun upcomingFirstOrder_twoFutureEvents_nearestFirst() {
|
||||
val now = sampleEpochSeconds
|
||||
val nearFuture = noteWithTimeSlot(id = "near", start = now + 100)
|
||||
val farFuture = noteWithTimeSlot(id = "far", start = now + 10_000)
|
||||
val sorted = listOf(farFuture, nearFuture).sortedWith(upcomingFirstCalendarOrder(now))
|
||||
assertEquals("near", sorted[0].idHex)
|
||||
assertEquals("far", sorted[1].idHex)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun upcomingFirstOrder_twoPastEvents_mostRecentFirst() {
|
||||
val now = sampleEpochSeconds
|
||||
val recentPast = noteWithTimeSlot(id = "recent", start = now - 100)
|
||||
val ancientPast = noteWithTimeSlot(id = "ancient", start = now - 10_000)
|
||||
val sorted = listOf(ancientPast, recentPast).sortedWith(upcomingFirstCalendarOrder(now))
|
||||
assertEquals("recent", sorted[0].idHex)
|
||||
assertEquals("ancient", sorted[1].idHex)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun upcomingFirstOrder_isTransitive_acrossNowBoundary() {
|
||||
// Guards the previous bug where `TimeUtils.now()` was sampled inside the comparator: if
|
||||
// the clock moved while sorting, an event's "upcoming" classification could flip between
|
||||
// pair comparisons and trigger an IllegalArgumentException from the JDK sort. Snapshotting
|
||||
// `now` once eliminates that.
|
||||
val now = sampleEpochSeconds
|
||||
val notes =
|
||||
(0..20).map { i ->
|
||||
noteWithTimeSlot(id = "n$i", start = now + (i - 10) * 60L)
|
||||
}
|
||||
// Should not throw.
|
||||
notes.sortedWith(upcomingFirstCalendarOrder(now))
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
private fun noteWithTimeSlot(
|
||||
id: String = "test-time",
|
||||
start: Long,
|
||||
end: Long? = null,
|
||||
): Note {
|
||||
val tags =
|
||||
buildList {
|
||||
add(arrayOf("d", "$id-d"))
|
||||
add(arrayOf("title", "Demo"))
|
||||
add(arrayOf("start", start.toString()))
|
||||
end?.let { add(arrayOf("end", it.toString())) }
|
||||
}.toTypedArray()
|
||||
val event = CalendarTimeSlotEvent(id, "pub", 0L, tags, "content", "sig")
|
||||
return Note(id).apply { this.event = event }
|
||||
}
|
||||
|
||||
private fun noteWithDateSlot(
|
||||
id: String = "test-date",
|
||||
start: String,
|
||||
end: String? = null,
|
||||
): Note {
|
||||
val tags =
|
||||
buildList {
|
||||
add(arrayOf("d", "$id-d"))
|
||||
add(arrayOf("title", "Demo"))
|
||||
add(arrayOf("start", start))
|
||||
end?.let { add(arrayOf("end", it)) }
|
||||
}.toTypedArray()
|
||||
val event = CalendarDateSlotEvent(id, "pub", 0L, tags, "content", "sig")
|
||||
return Note(id).apply { this.event = event }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.calendar
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.model.nip52Calendar.computeMonthGridBars
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import java.time.LocalDate
|
||||
|
||||
class MonthGridBarsTest {
|
||||
@Test
|
||||
fun singleDayEvent_oneSegment_bothEndsRounded() {
|
||||
val note = dateSlot("a", "2025-01-15")
|
||||
val key = LocalDate.of(2025, 1, 15).toEpochDay()
|
||||
val byDay = computeMonthGridBars(listOf(note))
|
||||
val seg = byDay[key]?.single()
|
||||
assertNotNull(seg)
|
||||
assertTrue("single-day event should round both ends", seg!!.isLeftEnd && seg.isRightEnd)
|
||||
assertEquals(0, seg.lane)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun threeDayEvent_segmentPerDay_endsOnlyOnBoundaries() {
|
||||
val note = dateSlot("a", "2025-01-15", end = "2025-01-17")
|
||||
val byDay = computeMonthGridBars(listOf(note))
|
||||
val k15 = LocalDate.of(2025, 1, 15).toEpochDay()
|
||||
val k16 = LocalDate.of(2025, 1, 16).toEpochDay()
|
||||
val k17 = LocalDate.of(2025, 1, 17).toEpochDay()
|
||||
assertEquals(true to false, byDay[k15]!!.single().run { isLeftEnd to isRightEnd })
|
||||
assertEquals(false to false, byDay[k16]!!.single().run { isLeftEnd to isRightEnd })
|
||||
assertEquals(false to true, byDay[k17]!!.single().run { isLeftEnd to isRightEnd })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun overlappingEvents_assignedToDistinctLanes() {
|
||||
// A: Jan 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 }
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Binary file not shown.
+1
@@ -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")
|
||||
|
||||
+71
@@ -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
|
||||
}
|
||||
+185
@@ -0,0 +1,185 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.model.nip52Calendar
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.model.Note
|
||||
import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent
|
||||
import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
// java.time formatters are thread-safe; the SimpleDateFormat predecessor was shared by sort
|
||||
// (background) and grouping (UI) paths and could throw under concurrent use.
|
||||
private val IsoDateParser: DateTimeFormatter = DateTimeFormatter.ISO_LOCAL_DATE
|
||||
|
||||
private fun parseIsoDate(date: String?): LocalDate? {
|
||||
if (date.isNullOrBlank()) return null
|
||||
return try {
|
||||
LocalDate.parse(date, IsoDateParser)
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calendar 31922 carries a calendar date (no instant). Anchor it at local midnight so that
|
||||
* "Jan 15" lands on Jan 15 in the user's grid and ordering reflects their local zone — UTC
|
||||
* anchoring made date-only events appear a day early west of UTC.
|
||||
*/
|
||||
fun parseIsoDateToUnixSeconds(date: String?): Long? = parseIsoDate(date)?.atStartOfDay(ZoneId.systemDefault())?.toEpochSecond()
|
||||
|
||||
/**
|
||||
* Unified start time as unix-seconds. For 31923 (time-slot) this is the event's instant; for
|
||||
* 31922 (date-slot) it is local midnight of the calendar date. Both are suitable for ordering
|
||||
* against [TimeUtils.now] and for relative-time rendering.
|
||||
*/
|
||||
fun Note.calendarStartSeconds(): Long? =
|
||||
when (val e = event) {
|
||||
is CalendarTimeSlotEvent -> e.start()
|
||||
is CalendarDateSlotEvent -> parseIsoDateToUnixSeconds(e.start())
|
||||
else -> null
|
||||
}
|
||||
|
||||
fun Note.calendarEndSeconds(): Long? =
|
||||
when (val e = event) {
|
||||
is CalendarTimeSlotEvent -> e.end() ?: e.start()
|
||||
is CalendarDateSlotEvent -> parseIsoDateToUnixSeconds(e.end()) ?: parseIsoDateToUnixSeconds(e.start())
|
||||
else -> null
|
||||
}
|
||||
|
||||
/**
|
||||
* Calendar-day bucket key (days since 1970-01-01) for grouping events into day cells.
|
||||
*
|
||||
* For 31923, the event's instant is converted to the viewer's local date; for 31922, the ISO
|
||||
* date string is parsed directly with no zone conversion (a calendar date for "Jan 15" must
|
||||
* land on Jan 15 in every zone). Returns null when the start cannot be resolved.
|
||||
*/
|
||||
fun Note.calendarLocalDayKey(): Long? =
|
||||
when (val e = event) {
|
||||
is CalendarTimeSlotEvent ->
|
||||
e.start()?.let {
|
||||
Instant
|
||||
.ofEpochSecond(it)
|
||||
.atZone(ZoneId.systemDefault())
|
||||
.toLocalDate()
|
||||
.toEpochDay()
|
||||
}
|
||||
is CalendarDateSlotEvent -> parseIsoDate(e.start())?.toEpochDay()
|
||||
else -> null
|
||||
}
|
||||
|
||||
/**
|
||||
* Buckets appointments by local calendar day (returned as `LocalDate.toEpochDay`). Notes that
|
||||
* are not calendar appointments or whose start can't be parsed are dropped.
|
||||
*/
|
||||
fun groupByDayKey(notes: List<Note>): Map<Long, List<Note>> {
|
||||
val map = mutableMapOf<Long, MutableList<Note>>()
|
||||
notes.forEach {
|
||||
val dayKey = it.calendarLocalDayKey() ?: return@forEach
|
||||
map.getOrPut(dayKey) { mutableListOf() }.add(it)
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
/**
|
||||
* Inclusive `[start, end]` range of day-keys an appointment covers. A single-day event yields
|
||||
* one key; a multi-day event yields every day from start through end. Returns null when the
|
||||
* note isn't a calendar appointment or has no parseable start.
|
||||
*
|
||||
* Capped at 366 days so a malformed event with a far-future end can't blow up month-view memory.
|
||||
*/
|
||||
fun Note.calendarLocalDayKeyRange(): LongRange? {
|
||||
val startKey = calendarLocalDayKey() ?: return null
|
||||
val endKey =
|
||||
when (val e = event) {
|
||||
is CalendarTimeSlotEvent ->
|
||||
e.end()?.let {
|
||||
Instant
|
||||
.ofEpochSecond(it)
|
||||
.atZone(ZoneId.systemDefault())
|
||||
.toLocalDate()
|
||||
.toEpochDay()
|
||||
} ?: startKey
|
||||
is CalendarDateSlotEvent -> parseIsoDate(e.end())?.toEpochDay() ?: startKey
|
||||
else -> startKey
|
||||
}
|
||||
val safeEnd = endKey.coerceAtLeast(startKey).coerceAtMost(startKey + 366)
|
||||
return startKey..safeEnd
|
||||
}
|
||||
|
||||
/**
|
||||
* Like [groupByDayKey] but a multi-day appointment lands in every day it covers (not just the
|
||||
* start day). Used by month/week/day views so a 3-day conference shows on all three rows; the
|
||||
* upcoming/past list view still uses [groupByDayKey] semantics via its own ordering.
|
||||
*/
|
||||
fun groupByDayKeyExpanded(notes: List<Note>): Map<Long, List<Note>> {
|
||||
val map = mutableMapOf<Long, MutableList<Note>>()
|
||||
notes.forEach { note ->
|
||||
val range = note.calendarLocalDayKeyRange() ?: return@forEach
|
||||
for (key in range) {
|
||||
map.getOrPut(key) { mutableListOf() }.add(note)
|
||||
}
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort by: upcoming events ascending (closest first), then past events descending (most-recent
|
||||
* first). [nowSeconds] is captured once per sort so the comparator stays transitive across the
|
||||
* full sort run — reading the clock inside `compare` would violate the [Comparator] contract on
|
||||
* boundary elements.
|
||||
*/
|
||||
fun upcomingFirstCalendarOrder(nowSeconds: Long): Comparator<Note> =
|
||||
Comparator { a, b ->
|
||||
val sa = a.calendarStartSeconds()
|
||||
val sb = b.calendarStartSeconds()
|
||||
|
||||
val primary =
|
||||
when {
|
||||
sa == null && sb == null -> compareCreatedAt(a, b)
|
||||
sa == null -> 1
|
||||
sb == null -> -1
|
||||
else -> {
|
||||
val aUpcoming = sa >= nowSeconds
|
||||
val bUpcoming = sb >= nowSeconds
|
||||
when {
|
||||
aUpcoming && !bUpcoming -> -1
|
||||
!aUpcoming && bUpcoming -> 1
|
||||
aUpcoming -> sa.compareTo(sb) // both future: nearest first
|
||||
else -> sb.compareTo(sa) // both past: most recent first
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (primary != 0) primary else a.idHex.compareTo(b.idHex)
|
||||
}
|
||||
|
||||
private fun compareCreatedAt(
|
||||
a: Note,
|
||||
b: Note,
|
||||
): Int {
|
||||
val ca = a.createdAt() ?: 0L
|
||||
val cb = b.createdAt() ?: 0L
|
||||
return cb.compareTo(ca)
|
||||
}
|
||||
+212
@@ -0,0 +1,212 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.model.nip52Calendar
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent
|
||||
import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent
|
||||
import com.vitorpamplona.quartz.nip52Calendar.calendar.CalendarEvent
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
import java.time.ZoneOffset
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
/**
|
||||
* Serialises NIP-52 calendar events to RFC 5545 iCalendar (`.ics`) text. The output is the
|
||||
* universal calendar interchange format: tapping a generated file in any email/calendar app
|
||||
* lets users import the event into Google Calendar, Apple Calendar, Outlook, Thunderbird, etc.
|
||||
*
|
||||
* Implements only the subset needed for NIP-52 appointments:
|
||||
* - `VEVENT` per appointment with `UID`, `DTSTAMP`, `DTSTART`, `DTEND`, `SUMMARY`,
|
||||
* `DESCRIPTION`, `LOCATION`, and `CATEGORIES` for hashtags.
|
||||
* - 31922 date-slot events emit `DTSTART;VALUE=DATE:YYYYMMDD` (no time component); 31923
|
||||
* time-slot events emit UTC instants formatted as `YYYYMMDDTHHMMSSZ`.
|
||||
* - 31924 calendar collections wrap their member appointments in one `VCALENDAR`.
|
||||
*
|
||||
* Line folding (75-octet limit per RFC 5545) is *not* implemented — modern parsers tolerate
|
||||
* long lines and the resulting files import cleanly across the apps tested.
|
||||
*/
|
||||
object IcsExport {
|
||||
private const val PRODID = "-//Amethyst//NIP-52//EN"
|
||||
private const val CRLF = "\r\n"
|
||||
private val UtcStamp: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss'Z'")
|
||||
private val IsoBasicDate: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyyMMdd")
|
||||
|
||||
fun appointmentToIcs(
|
||||
event: Any,
|
||||
address: Address,
|
||||
nowSeconds: Long,
|
||||
): String {
|
||||
val sb = StringBuilder()
|
||||
sb.append("BEGIN:VCALENDAR").append(CRLF)
|
||||
sb.append("VERSION:2.0").append(CRLF)
|
||||
sb.append("PRODID:").append(PRODID).append(CRLF)
|
||||
sb.append("CALSCALE:GREGORIAN").append(CRLF)
|
||||
appendVEvent(sb, event, address, nowSeconds)
|
||||
sb.append("END:VCALENDAR").append(CRLF)
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap multiple appointments (the membership of a kind-31924 calendar) in one VCALENDAR.
|
||||
* Members that aren't in [memberEvents] are silently skipped — typically because they
|
||||
* haven't been fetched from relays yet.
|
||||
*/
|
||||
fun calendarToIcs(
|
||||
calendar: CalendarEvent,
|
||||
memberEvents: List<Pair<Address, Any>>,
|
||||
nowSeconds: Long,
|
||||
): String {
|
||||
val sb = StringBuilder()
|
||||
sb.append("BEGIN:VCALENDAR").append(CRLF)
|
||||
sb.append("VERSION:2.0").append(CRLF)
|
||||
sb.append("PRODID:").append(PRODID).append(CRLF)
|
||||
sb.append("CALSCALE:GREGORIAN").append(CRLF)
|
||||
calendar.title()?.let {
|
||||
sb.append("X-WR-CALNAME:").append(escapeText(it)).append(CRLF)
|
||||
}
|
||||
calendar.content.takeIf { it.isNotBlank() }?.let {
|
||||
sb.append("X-WR-CALDESC:").append(escapeText(it)).append(CRLF)
|
||||
}
|
||||
memberEvents.forEach { (address, event) ->
|
||||
appendVEvent(sb, event, address, nowSeconds)
|
||||
}
|
||||
sb.append("END:VCALENDAR").append(CRLF)
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* Suggested filename for a single appointment. Sanitises the title for filesystems but
|
||||
* keeps it short enough for share-sheet thumbnails. Falls back to the d-tag.
|
||||
*/
|
||||
fun appointmentFilename(
|
||||
event: Any,
|
||||
address: Address,
|
||||
): String {
|
||||
val title =
|
||||
when (event) {
|
||||
is CalendarTimeSlotEvent -> event.title()
|
||||
is CalendarDateSlotEvent -> event.title()
|
||||
else -> null
|
||||
}
|
||||
return safeFilename(title ?: address.dTag) + ".ics"
|
||||
}
|
||||
|
||||
fun calendarFilename(calendar: CalendarEvent): String = safeFilename(calendar.title() ?: "calendar") + ".ics"
|
||||
|
||||
private fun safeFilename(raw: String): String =
|
||||
raw
|
||||
.replace(Regex("[^a-zA-Z0-9._-]+"), "-")
|
||||
.trim('-', '_')
|
||||
.ifBlank { "calendar" }
|
||||
.take(60)
|
||||
|
||||
private fun appendVEvent(
|
||||
sb: StringBuilder,
|
||||
event: Any,
|
||||
address: Address,
|
||||
nowSeconds: Long,
|
||||
) {
|
||||
sb.append("BEGIN:VEVENT").append(CRLF)
|
||||
// UID must be globally unique; "<dtag>@<pubkey>.nostr" gives stable uniqueness without
|
||||
// exposing relay metadata.
|
||||
sb
|
||||
.append("UID:")
|
||||
.append(address.dTag)
|
||||
.append('@')
|
||||
.append(address.pubKeyHex)
|
||||
.append(".nostr")
|
||||
.append(CRLF)
|
||||
sb.append("DTSTAMP:").append(formatUtcInstant(nowSeconds)).append(CRLF)
|
||||
|
||||
when (event) {
|
||||
is CalendarTimeSlotEvent -> appendTimeSlot(sb, event)
|
||||
is CalendarDateSlotEvent -> appendDateSlot(sb, event)
|
||||
}
|
||||
|
||||
sb.append("END:VEVENT").append(CRLF)
|
||||
}
|
||||
|
||||
private fun appendTimeSlot(
|
||||
sb: StringBuilder,
|
||||
event: CalendarTimeSlotEvent,
|
||||
) {
|
||||
event.start()?.let { sb.append("DTSTART:").append(formatUtcInstant(it)).append(CRLF) }
|
||||
event.end()?.let { sb.append("DTEND:").append(formatUtcInstant(it)).append(CRLF) }
|
||||
event.title()?.let { sb.append("SUMMARY:").append(escapeText(it)).append(CRLF) }
|
||||
appendDescription(sb, event.summary().orEmpty().ifBlank { event.content })
|
||||
event.location()?.let { sb.append("LOCATION:").append(escapeText(it)).append(CRLF) }
|
||||
appendCategories(sb, event.hashtags())
|
||||
}
|
||||
|
||||
private fun appendDateSlot(
|
||||
sb: StringBuilder,
|
||||
event: CalendarDateSlotEvent,
|
||||
) {
|
||||
event.start()?.let { iso ->
|
||||
tryFormatBasicDate(iso)?.let { sb.append("DTSTART;VALUE=DATE:").append(it).append(CRLF) }
|
||||
}
|
||||
event.end()?.let { iso ->
|
||||
tryFormatBasicDate(iso)?.let { sb.append("DTEND;VALUE=DATE:").append(it).append(CRLF) }
|
||||
}
|
||||
event.title()?.let { sb.append("SUMMARY:").append(escapeText(it)).append(CRLF) }
|
||||
appendDescription(sb, event.summary().orEmpty().ifBlank { event.content })
|
||||
event.location()?.let { sb.append("LOCATION:").append(escapeText(it)).append(CRLF) }
|
||||
appendCategories(sb, event.hashtags())
|
||||
}
|
||||
|
||||
private fun appendDescription(
|
||||
sb: StringBuilder,
|
||||
text: String,
|
||||
) {
|
||||
if (text.isBlank()) return
|
||||
sb.append("DESCRIPTION:").append(escapeText(text)).append(CRLF)
|
||||
}
|
||||
|
||||
private fun appendCategories(
|
||||
sb: StringBuilder,
|
||||
hashtags: List<String>,
|
||||
) {
|
||||
if (hashtags.isEmpty()) return
|
||||
sb.append("CATEGORIES:").append(hashtags.joinToString(",") { escapeText(it) }).append(CRLF)
|
||||
}
|
||||
|
||||
private fun formatUtcInstant(unixSeconds: Long): String = UtcStamp.format(Instant.ofEpochSecond(unixSeconds).atOffset(ZoneOffset.UTC))
|
||||
|
||||
private fun tryFormatBasicDate(iso: String): String? =
|
||||
try {
|
||||
IsoBasicDate.format(LocalDate.parse(iso))
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
}
|
||||
|
||||
/**
|
||||
* Escapes text per RFC 5545 §3.3.11: backslash, semicolon, comma, newline. Carriage
|
||||
* returns are dropped (line folding handles physical newlines for us).
|
||||
*/
|
||||
internal fun escapeText(raw: String): String =
|
||||
raw
|
||||
.replace("\\", "\\\\")
|
||||
.replace("\n", "\\n")
|
||||
.replace("\r", "")
|
||||
.replace(",", "\\,")
|
||||
.replace(";", "\\;")
|
||||
}
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.model.nip52Calendar
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.commons.model.Note
|
||||
|
||||
/**
|
||||
* One bar drawn into one day cell, with `lane` controlling its vertical position so two
|
||||
* overlapping multi-day events stack rather than collide. `isLeftEnd` / `isRightEnd` control
|
||||
* which corners of the bar are rounded — a continuation day in the middle of a 3-day event gets
|
||||
* neither end rounded, so adjacent cells visually merge into one bar.
|
||||
*
|
||||
* Note: the underlying [Note] is exposed so the UI can colour or label bars per event. Equality
|
||||
* is on idHex so a row of cells holding the same bar share segment identity for keys.
|
||||
*/
|
||||
@Immutable
|
||||
data class MonthGridBarSegment(
|
||||
val note: Note,
|
||||
val lane: Int,
|
||||
val isLeftEnd: Boolean,
|
||||
val isRightEnd: Boolean,
|
||||
)
|
||||
|
||||
/**
|
||||
* Maximum lanes we render before collapsing the remainder into a "+N" overflow label. Three
|
||||
* matches the previous dot-row capacity and keeps each 56dp cell readable on mid-range phones.
|
||||
*/
|
||||
const val MONTH_GRID_MAX_LANES = 3
|
||||
|
||||
/**
|
||||
* Greedy lane-assignment for the month grid: sort events earliest-start-first (longer events
|
||||
* wins ties so they take the top lane), then for each event pick the lowest lane index whose
|
||||
* full day-range is unoccupied. Returns a per-day-key map so each cell can render its own
|
||||
* segments without re-running the layout.
|
||||
*
|
||||
* Single-day events participate in the same layout — they get bars too, just short ones,
|
||||
* which keeps the visual language consistent.
|
||||
*/
|
||||
fun computeMonthGridBars(notes: List<Note>): Map<Long, List<MonthGridBarSegment>> {
|
||||
val ranges =
|
||||
notes
|
||||
.distinctBy { it.idHex }
|
||||
.mapNotNull { n -> n.calendarLocalDayKeyRange()?.let { n to it } }
|
||||
.sortedWith(
|
||||
compareBy(
|
||||
{ it.second.first },
|
||||
{ -(it.second.last - it.second.first) },
|
||||
{ it.first.idHex },
|
||||
),
|
||||
)
|
||||
|
||||
// day-key → set of lanes already claimed for that day
|
||||
val occupied = mutableMapOf<Long, MutableSet<Int>>()
|
||||
val perDay = mutableMapOf<Long, MutableList<MonthGridBarSegment>>()
|
||||
|
||||
for ((note, range) in ranges) {
|
||||
// Find the lowest lane index whose full range is free. Bounded at 32 so a pathological
|
||||
// input can't loop forever; overflow events still render as "+N" via the cap downstream.
|
||||
var lane = 0
|
||||
while (lane < 32) {
|
||||
val clash = (range).any { occupied[it]?.contains(lane) == true }
|
||||
if (!clash) break
|
||||
lane++
|
||||
}
|
||||
for (day in range) {
|
||||
occupied.getOrPut(day) { mutableSetOf() }.add(lane)
|
||||
perDay.getOrPut(day) { mutableListOf() }.add(
|
||||
MonthGridBarSegment(
|
||||
note = note,
|
||||
lane = lane,
|
||||
isLeftEnd = day == range.first,
|
||||
isRightEnd = day == range.last,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
// Within each cell, sort by lane so the rendering doesn't have to.
|
||||
return perDay.mapValues { (_, list) -> list.sortedBy { it.lane } }
|
||||
}
|
||||
+252
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user