refactor(calendars): rename appointments feed, split collections to own route

Naming:
- CalendarsFeedFilter → CalendarAppointmentsFeedFilter, calendarsFeed →
  calendarAppointmentsFeed. NIP-52 calls kind 31924 the "calendar" (a
  list of events); kinds 31922/31923 are appointments that *go into*
  calendars. The old name made `calendarsFeed` look like "the feed of
  calendars" when it was actually the feed of appointments.

Architecture:
- Dropped CalendarsViewMode.COLLECTIONS. Calendar collections are a
  sibling feed, not a view mode of the appointment timeline. They now
  live on a dedicated CalendarCollectionsScreen reached via the new
  Route.CalendarCollections, with their own drawer entry and bottom-bar
  slot under NavBarItem.CALENDAR_COLLECTIONS.
- Both screens share the same CalendarsFilterAssembler subscription
  (which already pulled all four kinds), so opening either keeps the
  relay subscription warm for the other.

https://claude.ai/code/session_01CbyrA2GdM4EQh8T6nsst6U
This commit is contained in:
Claude
2026-05-19 21:58:32 +00:00
parent 87f9a346cc
commit 447301599c
11 changed files with 216 additions and 17 deletions
@@ -73,6 +73,7 @@ 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.CalendarsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.create.NewCalendarCollectionScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.create.NewCalendarEventScreen
@@ -256,6 +257,7 @@ fun BuildNavigation(
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) }
composableFromBottomArgs<Route.NewCalendarEvent> { NewCalendarEventScreen(nav, accountViewModel) }
composableFromBottomArgs<Route.NewCalendarCollection> { NewCalendarCollectionScreen(nav, accountViewModel) }
composableFromEnd<Route.Products> { ProductsScreen(accountViewModel, nav) }
@@ -51,6 +51,7 @@ enum class NavBarItem {
ARTICLES,
PICTURES,
CALENDARS,
CALENDAR_COLLECTIONS,
SHORTS,
PUBLIC_CHATS,
FOLLOW_PACKS,
@@ -207,6 +208,13 @@ val NavBarCatalog: Map<NavBarItem, NavBarItemDef> =
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,
@@ -327,6 +335,7 @@ val DrawerFeedsItems: List<NavBarItem> =
NavBarItem.ARTICLES,
NavBarItem.PICTURES,
NavBarItem.CALENDARS,
NavBarItem.CALENDAR_COLLECTIONS,
NavBarItem.SHORTS,
NavBarItem.PUBLIC_CHATS,
NavBarItem.FOLLOW_PACKS,
@@ -30,8 +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.calendars.dal.CalendarsFeedFilter
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
@@ -101,7 +101,7 @@ class AccountFeedContentStates(
val communitiesList = FeedContentState(CommunitiesFeedFilter(account), scope, LocalCache)
val picturesFeed = FeedContentState(PictureFeedFilter(account), scope, LocalCache)
val calendarsFeed = FeedContentState(CalendarsFeedFilter(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)
@@ -86,7 +86,9 @@ private fun PreloadFor(
NavBarItem.PICTURES -> PicturesFilterAssemblerSubscription(accountViewModel)
NavBarItem.CALENDARS -> CalendarsFilterAssemblerSubscription(accountViewModel)
NavBarItem.CALENDARS,
NavBarItem.CALENDAR_COLLECTIONS,
-> CalendarsFilterAssemblerSubscription(accountViewModel)
NavBarItem.SHORTS -> ShortsFilterAssemblerSubscription(accountViewModel)
@@ -0,0 +1,102 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState
import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel
import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold
import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar
import com.vitorpamplona.amethyst.ui.navigation.bottombars.FabBottomBarPadded
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.datasource.CalendarsFilterAssemblerSubscription
/**
* Top-level screen for browsing NIP-52 kind-31924 calendars (collections of appointments). Reuses
* the [CalendarsFilterAssembler] subscription so opening this screen also keeps the appointment
* subscription warm — both feeds share one relay subscription.
*/
@Composable
fun CalendarCollectionsScreen(
accountViewModel: AccountViewModel,
nav: INav,
) {
CalendarCollectionsScreen(
feedState = accountViewModel.feedStates.calendarCollectionsFeed,
accountViewModel = accountViewModel,
nav = nav,
)
}
@Composable
fun CalendarCollectionsScreen(
feedState: FeedContentState,
accountViewModel: AccountViewModel,
nav: INav,
) {
WatchLifecycleAndUpdateModel(feedState)
WatchAccountForCalendarCollectionsScreen(feedState, accountViewModel)
CalendarsFilterAssemblerSubscription(accountViewModel)
DisappearingScaffold(
isInvertedLayout = false,
topBar = {
CalendarCollectionsTopBar(accountViewModel, nav)
},
bottomBar = {
AppBottomBar(Route.CalendarCollections, nav, accountViewModel) { route ->
if (route == Route.CalendarCollections) {
feedState.sendToTop()
} else {
nav.navBottomBar(route)
}
}
},
floatingButton = {
FabBottomBarPadded(nav) {
NewCalendarButton(nav)
}
},
accountViewModel = accountViewModel,
) {
CalendarCollectionsView(feedState, accountViewModel, nav)
}
}
@Composable
private fun WatchAccountForCalendarCollectionsScreen(
feedState: FeedContentState,
accountViewModel: AccountViewModel,
) {
val listState by accountViewModel.account.liveCalendarsFollowLists.collectAsStateWithLifecycle()
val hiddenUsers by
accountViewModel.account.hiddenUsers.flow
.collectAsStateWithLifecycle()
LaunchedEffect(accountViewModel, listState, hiddenUsers) {
feedState.checkKeysInvalidateDataAndSendToTop()
}
}
@@ -0,0 +1,82 @@
/*
* 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.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.text.font.FontWeight
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()
// We could reuse CalendarsTopNavFilterBar verbatim, but the screen title isn't shown by
// UserDrawerSearchTopBar's content slot — wrapping the spinner with the route title keeps
// the user oriented inside an otherwise filter-only header.
Text(
text = stringRes(R.string.route_calendar_collections),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontWeight = FontWeight.SemiBold,
)
FeedFilterSpinner(
placeholderCode = listName,
explainer = stringRes(R.string.select_list_to_filter),
options = allLists,
onSelect = onChange,
accountViewModel = accountViewModel,
)
}
@@ -47,8 +47,7 @@ fun CalendarsScreen(
nav: INav,
) {
CalendarsScreen(
feedState = accountViewModel.feedStates.calendarsFeed,
collectionsState = accountViewModel.feedStates.calendarCollectionsFeed,
feedState = accountViewModel.feedStates.calendarAppointmentsFeed,
accountViewModel = accountViewModel,
nav = nav,
)
@@ -57,13 +56,11 @@ fun CalendarsScreen(
@Composable
fun CalendarsScreen(
feedState: FeedContentState,
collectionsState: FeedContentState,
accountViewModel: AccountViewModel,
nav: INav,
) {
WatchLifecycleAndUpdateModel(feedState)
WatchLifecycleAndUpdateModel(collectionsState)
WatchAccountForCalendarsScreen(feedState, collectionsState, accountViewModel)
WatchAccountForCalendarsScreen(feedState, accountViewModel)
CalendarsFilterAssemblerSubscription(accountViewModel)
var viewMode by rememberSaveable { mutableStateOf(CalendarsViewMode.FEED) }
@@ -105,8 +102,6 @@ fun CalendarsScreen(
CalendarWeekView(feedState, accountViewModel, nav)
CalendarsViewMode.DAY ->
CalendarDayView(feedState, accountViewModel, nav)
CalendarsViewMode.COLLECTIONS ->
CalendarCollectionsView(collectionsState, accountViewModel, nav)
}
}
}
@@ -116,7 +111,6 @@ fun CalendarsScreen(
@Composable
private fun WatchAccountForCalendarsScreen(
feedState: FeedContentState,
collectionsState: FeedContentState,
accountViewModel: AccountViewModel,
) {
val listState by accountViewModel.account.liveCalendarsFollowLists.collectAsStateWithLifecycle()
@@ -126,6 +120,5 @@ private fun WatchAccountForCalendarsScreen(
LaunchedEffect(accountViewModel, listState, hiddenUsers) {
feedState.checkKeysInvalidateDataAndSendToTop()
collectionsState.checkKeysInvalidateDataAndSendToTop()
}
}
@@ -23,6 +23,11 @@ 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,
) {
@@ -30,5 +35,4 @@ enum class CalendarsViewMode(
MONTH(R.string.calendar_view_month),
WEEK(R.string.calendar_view_week),
DAY(R.string.calendar_view_day),
COLLECTIONS(R.string.calendar_view_collections),
}
@@ -29,7 +29,12 @@ import com.vitorpamplona.amethyst.ui.dal.FilterByListParams
import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent
import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent
class CalendarsFeedFilter(
/**
* 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
@@ -44,7 +44,7 @@ class CalendarsSubAssembler(
): List<RelayBasedFilter> {
val feedSettings = key.followsPerRelay()
return makeCalendarsFilter(feedSettings, since, key.feedStates.calendarsFeed.lastNoteCreatedAtIfFilled())
return makeCalendarsFilter(feedSettings, since, key.feedStates.calendarAppointmentsFeed.lastNoteCreatedAtIfFilled())
}
override fun user(key: CalendarsQueryState) = key.account.userProfile()
@@ -78,7 +78,7 @@ class CalendarsSubAssembler(
}
},
key.account.scope.launch(Dispatchers.IO) {
key.feedStates.calendarsFeed.lastNoteCreatedAtWhenFullyLoaded.sample(5000).collectLatest {
key.feedStates.calendarAppointmentsFeed.lastNoteCreatedAtWhenFullyLoaded.sample(5000).collectLatest {
invalidateFilters()
}
},
+1 -1
View File
@@ -1853,6 +1853,7 @@
<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>
@@ -1926,7 +1927,6 @@
<string name="calendar_view_month">Month</string>
<string name="calendar_view_week">Week</string>
<string name="calendar_view_day">Day</string>
<string name="calendar_view_collections">Calendars</string>
<string name="calendar_section_upcoming">Upcoming</string>
<string name="calendar_section_past">Past</string>