diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt index fa62e44fee..739b415353 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt @@ -49,6 +49,8 @@ import com.vitorpamplona.quartz.nip19Bech32.entities.NNote import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile import com.vitorpamplona.quartz.nip19Bech32.entities.NPub import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect +import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent +import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.UriParser import kotlinx.coroutines.CancellationException @@ -192,7 +194,7 @@ fun uriToRoute( routeFor( note = LocalCache.getOrCreateAddressableNote(nip19.address()), loggedIn = account, - ) ?: Route.EventRedirect(nip19.aTag()) + ) ?: calendarDirectRoute(nip19) ?: Route.EventRedirect(nip19.aTag()) } is NEmbed -> { @@ -254,3 +256,19 @@ fun uriToRoute( return null } + +/** + * Direct route for an `naddr` whose event hasn't arrived in [LocalCache] yet. When a notification + * is tapped (or a `nostr:naddr…` deep link arrives) for a calendar appointment we know is kind + * 31922/31923, route straight to the dedicated detail screen instead of bouncing through + * [Route.EventRedirect]. The detail screen issues its own per-event subscription, so the user + * sees the calendar-specific loading placeholder while the event arrives, not the generic + * redirect screen. + */ +private fun calendarDirectRoute(nip19: NAddress): Route? = + when (nip19.kind) { + CalendarTimeSlotEvent.KIND, + CalendarDateSlotEvent.KIND, + -> Route.CalendarEventDetail(nip19.kind, nip19.author, nip19.dTag) + else -> null + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarCollectionsView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarCollectionsView.kt index c197dcf54e..0cbd85cfdc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarCollectionsView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarCollectionsView.kt @@ -102,16 +102,10 @@ private fun CollectionsBody( @Composable private fun EmptyCollections() { - Box( - modifier = Modifier.fillMaxSize().padding(32.dp), - contentAlignment = Alignment.Center, - ) { - Text( - text = stringRes(R.string.calendar_empty_collections), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } + CalendarEmptyState( + title = stringRes(R.string.calendar_empty_collections_title), + subtitle = stringRes(R.string.calendar_empty_collections_subtitle), + ) } @Composable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarDayView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarDayView.kt index 16bdbd08c1..c2829fe61e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarDayView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarDayView.kt @@ -58,7 +58,8 @@ 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.dal.appointmentView -import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.dal.groupByDayKey +import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.dal.calendarLocalDayKeyRange +import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.dal.groupByDayKeyExpanded import com.vitorpamplona.amethyst.ui.stringRes import java.time.LocalDate import java.time.ZoneId @@ -87,10 +88,19 @@ fun CalendarDayView( var visibleEpochDay by rememberSaveable { mutableStateOf(today.toEpochDay()) } val visibleDate = LocalDate.ofEpochDay(visibleEpochDay) - val byDay by remember(notes) { derivedStateOf { groupByDayKey(notes) } } + val byDay by remember(notes) { derivedStateOf { groupByDayKeyExpanded(notes) } } val dayEvents = byDay[visibleDate.toEpochDay()].orEmpty() - Column(modifier = Modifier.fillMaxSize()) { + Column( + modifier = + Modifier + .fillMaxSize() + .calendarSwipeNavigation( + key = visibleEpochDay, + onSwipeLeft = { visibleEpochDay = visibleDate.plusDays(1).toEpochDay() }, + onSwipeRight = { visibleEpochDay = visibleDate.minusDays(1).toEpochDay() }, + ), + ) { CalendarNavigationHeader( title = formatLongDate(visibleDate.atStartOfDay(ZoneId.systemDefault()).toEpochSecond()), prevContentDescription = stringRes(R.string.calendar_nav_previous_day), @@ -101,26 +111,21 @@ fun CalendarDayView( ) if (dayEvents.isEmpty()) { - Box( - modifier = Modifier.fillMaxSize().padding(32.dp), - contentAlignment = Alignment.Center, - ) { - Text( - text = stringRes(R.string.calendar_no_events_today), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } + CalendarEmptyState( + title = stringRes(R.string.calendar_empty_day_title), + subtitle = stringRes(R.string.calendar_empty_day_subtitle), + ) return@Column } - DayTimeline(dayEvents, nav) + DayTimeline(dayEvents, visibleEpochDay, nav) } } @Composable private fun DayTimeline( dayEvents: List, + visibleEpochDay: Long, nav: INav, ) { val sorted = @@ -131,7 +136,11 @@ private fun DayTimeline( LazyColumn(modifier = Modifier.fillMaxSize().padding(horizontal = 12.dp)) { items(sorted, key = { it.idHex }) { note -> - DayRow(note = note, onClick = { nav.nav(Route.Note(note.idHex)) }) + DayRow( + note = note, + visibleEpochDay = visibleEpochDay, + onClick = { nav.nav(Route.Note(note.idHex)) }, + ) HorizontalDivider() } } @@ -140,13 +149,29 @@ private fun DayTimeline( @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 timeLabel = when { view.isAllDay -> stringRes(R.string.calendar_all_day) + view.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) view.startSeconds != null -> formatTimeOfDay(view.startSeconds) else -> "—" } @@ -186,6 +211,14 @@ private fun DayRow( 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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarEmptyState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarEmptyState.kt new file mode 100644 index 0000000000..f930da5103 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarEmptyState.kt @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp + +/** + * Shared empty-state layout used across the calendar surfaces (feed, day, week, collections). + * Title is the headline; subtitle gives the user one concrete next step ("tap + to create one"). + * Keeping all calendar empty states uniform avoids the previous one-line walls of text that + * gave the user no guidance about what to do next. + */ +@Composable +fun CalendarEmptyState( + title: String, + subtitle: String, +) { + Box( + modifier = Modifier.fillMaxSize().padding(32.dp), + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + ) + Text( + text = subtitle, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarFeedView.kt index 8cd2678293..d17613b72e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarFeedView.kt @@ -125,16 +125,10 @@ private fun SectionHeader(text: String) { @Composable private fun CalendarFeedEmpty() { - Box( - modifier = Modifier.fillMaxSize().padding(32.dp), - contentAlignment = Alignment.Center, - ) { - Text( - text = stringRes(R.string.calendar_empty_feed), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } + CalendarEmptyState( + title = stringRes(R.string.calendar_empty_feed_title), + subtitle = stringRes(R.string.calendar_empty_feed_subtitle), + ) } @Composable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarMonthView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarMonthView.kt index 008b21aee5..5eee4ff571 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarMonthView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarMonthView.kt @@ -59,7 +59,7 @@ import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState 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.screen.loggedIn.calendars.dal.groupByDayKey +import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.dal.groupByDayKeyExpanded import com.vitorpamplona.amethyst.ui.stringRes import java.time.LocalDate import java.time.YearMonth @@ -93,11 +93,26 @@ fun CalendarMonthView( visibleMonthValue = ym.monthValue } - val eventsByDay by remember(notes) { derivedStateOf { groupByDayKey(notes) } } + val eventsByDay by remember(notes) { derivedStateOf { groupByDayKeyExpanded(notes) } } var selectedDayKey by rememberSaveable { mutableStateOf(null) } - Column(modifier = Modifier.fillMaxSize()) { + Column( + modifier = + Modifier + .fillMaxSize() + .calendarSwipeNavigation( + key = visibleYear to visibleMonthValue, + onSwipeLeft = { + setVisibleMonth(visibleMonth.plusMonths(1)) + selectedDayKey = null + }, + onSwipeRight = { + setVisibleMonth(visibleMonth.minusMonths(1)) + selectedDayKey = null + }, + ), + ) { CalendarNavigationHeader( title = formatMonthYear(visibleMonth.year, visibleMonth.monthValue - 1), prevContentDescription = stringRes(R.string.calendar_nav_previous_month), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarRelativeTime.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarRelativeTime.kt index a10bdce66f..830f38aae6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarRelativeTime.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarRelativeTime.kt @@ -26,14 +26,18 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.dal.CalendarAppointmentView /** - * Localised "starts in 2 hours" / "started 5 minutes ago" / "ongoing" label for an appointment. - * Returns null when the event has no parseable start (in which case there's nothing to anchor - * a relative phrase to). + * 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, @@ -43,10 +47,17 @@ fun relativeTimeLabel( val start = view.startSeconds ?: return null val end = view.endSeconds - // If the event is happening right now (start ≤ now ≤ end), prefer an explicit "ongoing" - // label over the misleading "started X minutes ago" that DateUtils would produce. if (end != null && start <= nowSeconds && nowSeconds <= end) { - return context.getString(R.string.calendar_relative_ongoing) + 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 = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarSwipeNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarSwipeNavigation.kt new file mode 100644 index 0000000000..22fd2de939 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarSwipeNavigation.kt @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars + +import androidx.compose.foundation.gestures.detectHorizontalDragGestures +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.pointerInput + +/** + * Swipe-to-navigate gesture for calendar surfaces. Horizontal drag past the threshold fires + * [onSwipeLeft] (next period) or [onSwipeRight] (previous period). The threshold is in pixels + * — we accumulate raw drag deltas because [detectHorizontalDragGestures]' final-velocity callback + * isn't surfaced here; a positional threshold gives the user predictable, latch-style behaviour + * comparable to the previous/next arrows in [CalendarNavigationHeader]. + * + * The `key` lets a host that swaps state (week → next week, day → next day) restart the gesture + * detector so a long sequence of partial drags doesn't accumulate across navigations. + */ +fun Modifier.calendarSwipeNavigation( + key: Any?, + onSwipeLeft: () -> Unit, + onSwipeRight: () -> Unit, + thresholdPx: Float = 120f, +): Modifier = + this.pointerInput(key) { + var totalDrag = 0f + detectHorizontalDragGestures( + onDragStart = { totalDrag = 0f }, + onDragEnd = { + if (totalDrag <= -thresholdPx) { + onSwipeLeft() + } else if (totalDrag >= thresholdPx) { + onSwipeRight() + } + totalDrag = 0f + }, + onDragCancel = { totalDrag = 0f }, + ) { _, dragAmount -> + totalDrag += dragAmount + } + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarWeekView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarWeekView.kt index 1e00c995a8..2eb9754478 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarWeekView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarWeekView.kt @@ -23,7 +23,6 @@ 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.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -55,7 +54,7 @@ import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState 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.screen.loggedIn.calendars.dal.groupByDayKey +import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.dal.groupByDayKeyExpanded import com.vitorpamplona.amethyst.ui.stringRes import java.time.LocalDate import java.time.ZoneId @@ -87,9 +86,24 @@ fun CalendarWeekView( var selectedDayIndex by rememberSaveable { mutableStateOf(0) } - val eventsByDay by remember(notes) { derivedStateOf { groupByDayKey(notes) } } + val eventsByDay by remember(notes) { derivedStateOf { groupByDayKeyExpanded(notes) } } - Column(modifier = Modifier.fillMaxSize()) { + Column( + modifier = + Modifier + .fillMaxSize() + .calendarSwipeNavigation( + key = weekStartEpochDay, + onSwipeLeft = { + weekStartEpochDay = weekStart.plusWeeks(1).toEpochDay() + selectedDayIndex = 0 + }, + onSwipeRight = { + weekStartEpochDay = weekStart.minusWeeks(1).toEpochDay() + selectedDayIndex = 0 + }, + ), + ) { CalendarNavigationHeader( title = formatMonthYear(weekStart.year, weekStart.monthValue - 1), prevContentDescription = stringRes(R.string.calendar_nav_previous_week), @@ -124,16 +138,10 @@ fun CalendarWeekView( DaySummaryHeader(selectedDate) if (dayNotes.isEmpty()) { - Box( - modifier = Modifier.fillMaxSize().padding(32.dp), - contentAlignment = Alignment.Center, - ) { - Text( - text = stringRes(R.string.calendar_no_events), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } + CalendarEmptyState( + title = stringRes(R.string.calendar_empty_week_title), + subtitle = stringRes(R.string.calendar_empty_week_subtitle), + ) } else { LazyColumn(modifier = Modifier.fillMaxSize()) { items(dayNotes, key = { it.idHex }) { note -> diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/dal/CalendarSortKeys.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/dal/CalendarSortKeys.kt index d5312e6afb..9fbb6a1aee 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/dal/CalendarSortKeys.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/dal/CalendarSortKeys.kt @@ -102,6 +102,48 @@ fun groupByDayKey(notes: List): Map> { return map } +/** + * Inclusive `[start, end]` range of day-keys an appointment covers. A single-day event yields + * one key; a multi-day event yields every day from start through end. Returns null when the + * note isn't a calendar appointment or has no parseable start. + * + * Capped at 366 days so a malformed event with a far-future end can't blow up month-view memory. + */ +fun Note.calendarLocalDayKeyRange(): LongRange? { + val startKey = calendarLocalDayKey() ?: return null + val endKey = + when (val e = event) { + is CalendarTimeSlotEvent -> + e.end()?.let { + Instant + .ofEpochSecond(it) + .atZone(ZoneId.systemDefault()) + .toLocalDate() + .toEpochDay() + } ?: startKey + is CalendarDateSlotEvent -> parseIsoDate(e.end())?.toEpochDay() ?: startKey + else -> startKey + } + val safeEnd = endKey.coerceAtLeast(startKey).coerceAtMost(startKey + 366) + return startKey..safeEnd +} + +/** + * Like [groupByDayKey] but a multi-day appointment lands in every day it covers (not just the + * start day). Used by month/week/day views so a 3-day conference shows on all three rows; the + * upcoming/past list view still uses [groupByDayKey] semantics via its own ordering. + */ +fun groupByDayKeyExpanded(notes: List): Map> { + val map = mutableMapOf>() + notes.forEach { note -> + val range = note.calendarLocalDayKeyRange() ?: return@forEach + for (key in range) { + map.getOrPut(key) { mutableListOf() }.add(note) + } + } + return map +} + /** * Sort by: upcoming events ascending (closest first), then past events descending (most-recent * first). [nowSeconds] is captured once per sort so the comparator stays transitive across the diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/detail/CalendarEventDetailScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/detail/CalendarEventDetailScreen.kt index d7c8e4043c..9db6d06f29 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/detail/CalendarEventDetailScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/detail/CalendarEventDetailScreen.kt @@ -20,6 +20,8 @@ */ 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 @@ -43,39 +45,52 @@ 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 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.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.dal.IcsExport import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.dal.appointmentView 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.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 @@ -102,10 +117,11 @@ fun CalendarEventDetailScreen( val targetAddress = remember(kind, pubKeyHex, dTag) { Address(kind, pubKeyHex, dTag) } val targetNote = remember(targetAddress) { LocalCache.getOrCreateAddressableNote(targetAddress) } - val noteState by targetNote - .flow() - .metadata.stateFlow - .collectAsStateWithLifecycle() + // [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 @@ -130,7 +146,7 @@ fun CalendarEventDetailScreen( } }, actions = { - val context = androidx.compose.ui.platform.LocalContext.current + 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, @@ -138,19 +154,9 @@ fun CalendarEventDetailScreen( // cleaner, but two icons keep both actions one tap away. if (event != null) { IconButton(onClick = { - val ics = - com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.dal.IcsExport - .appointmentToIcs( - event, - targetAddress, - com.vitorpamplona.quartz.utils.TimeUtils - .now(), - ) - val filename = - com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.dal.IcsExport - .appointmentFilename(event, targetAddress) - com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars - .shareIcs(context, filename, ics) + val ics = IcsExport.appointmentToIcs(event, targetAddress, TimeUtils.now()) + val filename = IcsExport.appointmentFilename(event, targetAddress) + shareIcs(context, filename, ics) }) { Icon( symbol = MaterialSymbols.Share, @@ -162,22 +168,20 @@ fun CalendarEventDetailScreen( val shareTitle = stringRes(R.string.calendar_share_nostr_title) IconButton(onClick = { val naddr = - com.vitorpamplona.quartz.nip19Bech32.entities.NAddress - .create( - targetAddress.kind, - targetAddress.pubKeyHex, - targetAddress.dTag, - null, - ) + NAddress.create( + targetAddress.kind, + targetAddress.pubKeyHex, + targetAddress.dTag, + null, + ) val intent = - android.content - .Intent(android.content.Intent.ACTION_SEND) + Intent(Intent.ACTION_SEND) .setType("text/plain") - .putExtra(android.content.Intent.EXTRA_TEXT, "nostr:$naddr") + .putExtra(Intent.EXTRA_TEXT, "nostr:$naddr") context.startActivity( - android.content.Intent + Intent .createChooser(intent, shareTitle) - .addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK), + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK), ) }) { Icon( @@ -288,15 +292,10 @@ private fun EventBody( color = MaterialTheme.colorScheme.primary, ) } - val context = androidx.compose.ui.platform.LocalContext.current + val context = LocalContext.current val relative = remember(note.idHex, view.startSeconds) { - relativeTimeLabel( - context, - view, - com.vitorpamplona.quartz.utils.TimeUtils - .now(), - ) + relativeTimeLabel(context, view, TimeUtils.now()) } relative?.let { Text( @@ -315,6 +314,18 @@ private fun EventBody( } } + // 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. + ReactionsRow( + baseNote = note, + showReactionDetail = true, + addPadding = true, + editState = null, + accountViewModel = accountViewModel, + nav = nav, + ) + HorizontalDivider() CalendarRsvpRow( @@ -359,7 +370,7 @@ private fun HeroImage( @Composable private fun LocationRow(location: String) { - val context = androidx.compose.ui.platform.LocalContext.current + val context = LocalContext.current // The whole row is the affordance — a single click target with a trailing chevron makes the // action discoverable without the dead-button look the previous nested TextButton produced. Row( @@ -373,9 +384,8 @@ private fun LocationRow(location: String) { // the ActivityNotFoundException — we don't have anywhere useful to fall // back to. context.startActivity( - android.content - .Intent(android.content.Intent.ACTION_VIEW, "geo:0,0?q=${android.net.Uri.encode(location)}".let(android.net.Uri::parse)) - .addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK), + Intent(Intent.ACTION_VIEW, Uri.parse("geo:0,0?q=${Uri.encode(location)}")) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK), ) } }.padding(vertical = 4.dp), @@ -505,9 +515,9 @@ private fun InCalendarsSection( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp), ) { - com.vitorpamplona.amethyst.ui.note.ClickableUserPicture( + ClickableUserPicture( baseUserHex = calendar.pubKey, - size = com.vitorpamplona.amethyst.ui.theme.Size30dp, + size = Size30dp, accountViewModel = accountViewModel, ) Text( @@ -534,45 +544,39 @@ private fun UserRow( nav: INav, trailing: (@Composable () -> Unit)?, ) { - com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms - .LoadUser(baseUserHex = pubKey, accountViewModel = accountViewModel) { user -> - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(10.dp), - ) { - com.vitorpamplona.amethyst.ui.note.ClickableUserPicture( - baseUserHex = pubKey, - size = com.vitorpamplona.amethyst.ui.theme.Size35dp, + 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, - onClick = { - nav.nav( - com.vitorpamplona.amethyst.ui.navigation.routes.Route - .Profile(pubKey), - ) - }, ) - if (user != null) { - com.vitorpamplona.amethyst.ui.note.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() + } 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 @@ -614,16 +618,16 @@ private fun formatPubKeyShort(pubKey: String): String = if (pubKey.length <= 16) * refresh. The scan is O(addressables) which is bounded by the relay subscription. */ @Composable -private fun rememberRsvpsFor(targetAddress: Address): androidx.compose.runtime.State> = - androidx.compose.runtime.produceState(initialValue = findRsvpsFor(targetAddress), targetAddress) { +private fun rememberRsvpsFor(targetAddress: Address): State> = + produceState(initialValue = findRsvpsFor(targetAddress), targetAddress) { LocalCache.live.newEventBundles.collect { value = findRsvpsFor(targetAddress) } } @Composable -private fun rememberCalendarsContaining(targetAddress: Address): androidx.compose.runtime.State> = - androidx.compose.runtime.produceState(initialValue = findCalendarsContaining(targetAddress), targetAddress) { +private fun rememberCalendarsContaining(targetAddress: Address): State> = + produceState(initialValue = findCalendarsContaining(targetAddress), targetAddress) { LocalCache.live.newEventBundles.collect { value = findCalendarsContaining(targetAddress) } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index a722ed158c..26b9e7a8f5 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1937,6 +1937,14 @@ Past No upcoming or past calendar events from your selected feed yet. No calendar collections yet. + Your calendar is empty + Events shared by people you follow appear here. Tap the + button to create your own. + No collections yet + Group events together — a meetup series, a conference track, your team\'s roadmap. Tap + to create one. + Nothing scheduled + No events on this day. Tap + to add one. + Nothing this week + No events fall in this week. Title Summary @@ -1958,6 +1966,8 @@ Next day No events on this day No events + Continues + Day %1$d of %2$d (untitled) All-day ✓ Going @@ -1979,6 +1989,7 @@ Not part of any calendar yet. Loading event… Happening now + %1$s · ends %2$s Share calendar event Export to calendar (.ics) calendar_reminders diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/calendar/CalendarFeedGroupingTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/calendar/CalendarFeedGroupingTest.kt index 9cffecfc99..0bfd1c96c2 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/calendar/CalendarFeedGroupingTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/calendar/CalendarFeedGroupingTest.kt @@ -21,7 +21,9 @@ package com.vitorpamplona.amethyst.calendar import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.dal.calendarLocalDayKeyRange import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.dal.groupByDayKey +import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.dal.groupByDayKeyExpanded 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 @@ -142,6 +144,47 @@ class CalendarFeedGroupingTest { 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(