fix(calendars): top-bar filter switch resets scroll; week/month/day headers scroll with the disappearing bar

Two related fixes for the calendar surface:

1. Filter switch leaving the user in the past section. The feed-content
   state already calls sendToTop() when feedKey changes, but the calendar
   feed's LazyColumn never subscribed to the scrollToTop signal — so a
   filter switch (e.g. People List → Global) preserved the previous
   scroll position. Often that landed the user mid-feed in the "Past"
   section of the much larger Global list, looking like "the events
   didn't come back". Added a LazyListState + WatchScrollToTop wiring.

2. Week/month/day headers stayed pinned when the DisappearingScaffold's
   top bar collapsed on scroll. The views had a Column with
   disappearingScaffoldPadding() that reserved a fixed top inset, so when
   the bar slid up the headers stayed put and a visual gap opened. Pulled
   the nav header (and for the week view, the WeekStrip + DaySummaryHeader)
   into the same LazyColumn as the events, with rememberFeedContentPadding
   as contentPadding — now the whole stack scrolls under the bar together.
This commit is contained in:
Claude
2026-05-19 21:58:34 +00:00
parent 638e22c262
commit d96209b20d
4 changed files with 154 additions and 120 deletions
@@ -57,10 +57,12 @@ import com.vitorpamplona.amethyst.commons.model.nip52Calendar.groupByDayKeyExpan
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
@@ -91,58 +93,54 @@ fun CalendarDayView(
val byDay by remember(notes) { derivedStateOf { groupByDayKeyExpanded(notes) } }
val dayEvents = byDay[visibleDate.toEpochDay()].orEmpty()
Column(
modifier =
Modifier
.fillMaxSize()
.disappearingScaffoldPadding()
.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),
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()) {
CalendarEmptyState(
title = stringRes(R.string.calendar_empty_day_title),
subtitle = stringRes(R.string.calendar_empty_day_subtitle),
)
return@Column
}
DayTimeline(dayEvents, visibleEpochDay, nav)
}
}
@Composable
private fun DayTimeline(
dayEvents: List<Note>,
visibleEpochDay: Long,
nav: INav,
) {
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 }
}
LazyColumn(modifier = Modifier.fillMaxSize().padding(horizontal = 12.dp)) {
items(sorted, key = { it.idHex }) { note ->
DayRow(
note = note,
visibleEpochDay = visibleEpochDay,
onClick = { nav.nav(Route.Note(note.idHex)) },
// 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() },
)
HorizontalDivider()
}
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()
}
}
}
}
}
@@ -25,6 +25,7 @@ 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
@@ -44,6 +45,7 @@ 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
@@ -62,7 +64,7 @@ fun CalendarFeedView(
val state by feedState.feedContent.collectAsStateWithLifecycle()
when (val s = state) {
is FeedState.Loaded -> CalendarFeedLoadedBody(s, accountViewModel, nav, filterAddresses)
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)
@@ -73,6 +75,7 @@ fun CalendarFeedView(
@Composable
private fun CalendarFeedLoadedBody(
loaded: FeedState.Loaded,
feedState: FeedContentState,
accountViewModel: AccountViewModel,
nav: INav,
filterAddresses: Set<com.vitorpamplona.quartz.nip01Core.core.Address>?,
@@ -85,7 +88,15 @@ private fun CalendarFeedLoadedBody(
}
}
// 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(),
) {
@@ -61,9 +61,11 @@ import com.vitorpamplona.amethyst.commons.model.nip52Calendar.computeMonthGridBa
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
@@ -102,11 +104,15 @@ fun CalendarMonthView(
var selectedDayKey by rememberSaveable { mutableStateOf<Long?>(null) }
Column(
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()
.disappearingScaffoldPadding()
.calendarSwipeNavigation(
key = visibleYear to visibleMonthValue,
onSwipeLeft = {
@@ -119,44 +125,49 @@ fun CalendarMonthView(
},
),
) {
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-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
},
)
}
WeekdayHeader()
item(key = "month-weekday-header") {
WeekdayHeader()
}
MonthGrid(
visibleMonth = visibleMonth,
today = today,
barsByDay = barsByDay,
selectedDayKey = selectedDayKey,
onDayClick = { dayKey ->
selectedDayKey = if (selectedDayKey == dayKey) null else dayKey
},
)
item(key = "month-grid") {
MonthGrid(
visibleMonth = visibleMonth,
today = today,
barsByDay = barsByDay,
selectedDayKey = selectedDayKey,
onDayClick = { dayKey ->
selectedDayKey = if (selectedDayKey == dayKey) null else dayKey
},
)
}
Spacer(modifier = Modifier.height(8.dp))
item(key = "month-grid-spacer") {
Spacer(modifier = Modifier.height(8.dp))
}
val selectedEvents = selectedDayKey?.let { eventsByDay[it] }.orEmpty()
if (selectedEvents.isNotEmpty()) {
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(selectedEvents, key = { it.idHex }) { note ->
CalendarEventListCard(note, accountViewModel, nav)
}
items(selectedEvents, key = { it.idHex }) { note ->
CalendarEventListCard(note, accountViewModel, nav)
}
}
}
@@ -57,9 +57,11 @@ import com.vitorpamplona.amethyst.commons.model.nip52Calendar.groupByDayKeyExpan
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
@@ -92,11 +94,18 @@ fun CalendarWeekView(
val eventsByDay by remember(notes) { derivedStateOf { groupByDayKeyExpanded(notes) } }
Column(
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()
.disappearingScaffoldPadding()
.calendarSwipeNavigation(
key = weekStartEpochDay,
onSwipeLeft = {
@@ -109,49 +118,54 @@ fun CalendarWeekView(
},
),
) {
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-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
},
)
}
WeekStrip(
weekStart = weekStart,
today = today,
selectedIndex = selectedDayIndex,
eventsByDay = eventsByDay,
onSelect = { selectedDayIndex = it },
)
item(key = "week-strip") {
WeekStrip(
weekStart = weekStart,
today = today,
selectedIndex = selectedDayIndex,
eventsByDay = eventsByDay,
onSelect = { selectedDayIndex = it },
)
}
Spacer(modifier = Modifier.height(8.dp))
item(key = "week-spacer") {
Spacer(modifier = Modifier.height(8.dp))
}
val selectedDate = weekStart.plusDays(selectedDayIndex.toLong())
val dayNotes = eventsByDay[selectedDate.toEpochDay()].orEmpty()
DaySummaryHeader(selectedDate)
item(key = "week-day-summary") {
DaySummaryHeader(selectedDate)
}
if (dayNotes.isEmpty()) {
CalendarEmptyState(
title = stringRes(R.string.calendar_empty_week_title),
subtitle = stringRes(R.string.calendar_empty_week_subtitle),
)
item(key = "week-empty") {
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 ->
CalendarEventListCard(note, accountViewModel, nav)
}
items(dayNotes, key = { it.idHex }) { note ->
CalendarEventListCard(note, accountViewModel, nav)
}
}
}