Merge week-number navigation (#197)

This commit is contained in:
Jean-Luc Makiola
2026-09-30 13:24:26 +02:00
8 changed files with 59 additions and 17 deletions
+1
View File
@@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- **Pages can open the split agenda.** Swipe up from a full month to reveal the compact grid and month agenda; tap a day to filter it, then swipe down to return. The transition gives haptic feedback ([#111]).
- **Week numbers open their week.** Tap a week number in the month grid to open Week at that row; Back returns to Month ([#197]).
### Changed
- **Split view opens with the month's agenda.** It starts at today in the current month; tapping a day filters the list, and the filter chip restores the full month. Swiping the list moves by day when filtered and by month otherwise ([#142]).
@@ -34,6 +34,7 @@ import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
import de.jeanlucmakiola.calendula.ui.common.LocalManageCalendars
import de.jeanlucmakiola.calendula.ui.common.RescheduleViewModel
import de.jeanlucmakiola.calendula.ui.common.drillToDay
import de.jeanlucmakiola.calendula.ui.common.drillToWeek
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
import de.jeanlucmakiola.calendula.ui.common.selectView
import de.jeanlucmakiola.calendula.ui.common.viewBaseStack
@@ -112,6 +113,11 @@ fun CalendarHost(
pendingDayIso = date.toString()
viewStack = viewStack.drillToDay()
}
var pendingWeekIso by rememberSaveable { mutableStateOf<String?>(null) }
val onOpenWeek: (LocalDate) -> Unit = { date ->
pendingWeekIso = date.toString()
viewStack = viewStack.drillToWeek()
}
// The event-detail screen (S4) is a full-screen destination hoisted here so
// it overlays whichever calendar view is active. We forward the tapped
@@ -380,6 +386,8 @@ fun CalendarHost(
quickSwitchViews = quickSwitchViews,
drawerViewOrder = drawerViewOrder,
todayInToolbar = todayInToolbar,
initialDateIso = pendingWeekIso,
onInitialDateConsumed = { pendingWeekIso = null },
)
CalendarView.MultiDay -> MultiDayScreen(
selectedView = currentView,
@@ -409,6 +417,7 @@ fun CalendarHost(
selectedView = currentView,
onSelectView = onSelectView,
onOpenDay = onOpenDay,
onOpenWeek = onOpenWeek,
onEventClick = onEventClick,
onOpenSettings = onOpenSettings,
onOpenSearch = onOpenSearch,
@@ -129,3 +129,7 @@ fun viewBaseStack(default: CalendarView, source: CalendarView): List<CalendarVie
/** Push the day view as a drill-in over the current stack (no-op if already on it). */
fun List<CalendarView>.drillToDay(): List<CalendarView> =
if (lastOrNull() == CalendarView.Day) this else this + CalendarView.Day
/** Push the week view over the month after a week-number tap. */
fun List<CalendarView>.drillToWeek(): List<CalendarView> =
if (lastOrNull() == CalendarView.Week) this else this + CalendarView.Week
@@ -206,6 +206,7 @@ import java.time.format.TextStyle as JavaTextStyle
import java.util.Locale
private val LocalTodayPulse = staticCompositionLocalOf { 0 }
private val LocalOpenWeek = staticCompositionLocalOf<((LocalDate) -> Unit)?> { null }
/** A brief push from today's full cell into its immediate neighbours. */
@Composable
@@ -252,6 +253,7 @@ fun MonthScreen(
selectedView: CalendarView,
onSelectView: (CalendarView) -> Unit,
onOpenDay: (LocalDate) -> Unit,
onOpenWeek: (LocalDate) -> Unit,
onEventClick: (EventInstance) -> Unit,
onOpenSettings: () -> Unit,
onOpenSearch: () -> Unit,
@@ -515,6 +517,7 @@ fun MonthScreen(
LocalDimCutoff provides dimCutoff,
LocalMonthDrag provides chipDrag,
LocalTodayPulse provides todayPulse,
LocalOpenWeek provides onOpenWeek,
) {
if (scrolling) {
ContinuousMonthContent(
@@ -933,7 +936,7 @@ private fun rememberWeekNumberGutter(): Dp {
val style = weekNumberStyle()
return remember(style, density, measurer) {
val text = with(density) { measurer.measure(WEEK_NUMBER_SAMPLE, style).size.width.toDp() }
text + (WEEK_NUMBER_PADDING + CELL_GAP) * 2
(text + (WEEK_NUMBER_PADDING + CELL_GAP) * 2).coerceAtLeast(48.dp)
}
}
@@ -1675,7 +1678,7 @@ private fun SplitExpandHandle(
* it's roughly a third of a paged row — which is the point: the space it gives
* up goes to the day pane below.
*/
private val SPLIT_ROW_HEIGHT = 46.dp
private val SPLIT_ROW_HEIGHT = 48.dp
/** Vertical padding and row gap of [SplitMonthGrid], which [splitGridHeight] pays for. */
private val SPLIT_GRID_VERTICAL_PADDING = 4.dp
@@ -2742,8 +2745,13 @@ private fun monthChipDragModifier(
private fun WeekNumberGutter(weekStart: LocalDate, modifier: Modifier = Modifier) {
val weekNumber = remember(weekStart) { weekStart.toJavaLocalDate().isoWeekNumber() }
val label = stringResource(R.string.week_number_label)
val openLabel = stringResource(R.string.month_open_week, weekNumber)
val onOpenWeek = LocalOpenWeek.current
Box(
modifier = modifier
.then(if (onOpenWeek != null) Modifier.clickable(onClickLabel = openLabel) {
onOpenWeek(weekStart)
} else Modifier)
.padding(horizontal = CELL_GAP, vertical = 1.dp)
.background(MaterialTheme.colorScheme.secondaryContainer, CELL_SHAPE)
.semantics { contentDescription = "$label $weekNumber" },
@@ -199,23 +199,33 @@ fun WeekScreen(
quickSwitchViews: List<CalendarView> = IMPLEMENTED_VIEWS,
drawerViewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
todayInToolbar: Boolean = false,
initialDateIso: String? = null,
onInitialDateConsumed: () -> Unit = {},
modifier: Modifier = Modifier,
viewModel: WeekViewModel = hiltViewModel(),
) = PagedTimelineScreen(
viewModel = viewModel,
showWeekNumber = true,
selectedView = selectedView,
onSelectView = onSelectView,
onOpenDay = onOpenDay,
onEventClick = onEventClick,
onOpenSettings = onOpenSettings,
onOpenSearch = onOpenSearch,
onCreateEvent = onCreateEvent,
quickSwitchViews = quickSwitchViews,
drawerViewOrder = drawerViewOrder,
todayInToolbar = todayInToolbar,
modifier = modifier,
)
) {
LaunchedEffect(initialDateIso) {
initialDateIso?.let {
viewModel.goToDate(LocalDate.parse(it))
onInitialDateConsumed()
}
}
PagedTimelineScreen(
viewModel = viewModel,
showWeekNumber = true,
selectedView = selectedView,
onSelectView = onSelectView,
onOpenDay = onOpenDay,
onEventClick = onEventClick,
onOpenSettings = onOpenSettings,
onOpenSearch = onOpenSearch,
onCreateEvent = onCreateEvent,
quickSwitchViews = quickSwitchViews,
drawerViewOrder = drawerViewOrder,
todayInToolbar = todayInToolbar,
modifier = modifier,
)
}
/** The multi-day view (#193): the week view's timeline over the day count from Settings. */
@Composable
@@ -150,6 +150,7 @@ abstract class PagedTimelineViewModel(
}
fun goToToday() = jumpTo(todayDate)
fun goToDate(date: LocalDate) = jumpTo(date)
private fun buildState(
span: DaySpan,
+1
View File
@@ -54,6 +54,7 @@
<!-- Month view (S1) -->
<string name="month_prev">Previous month</string>
<string name="month_next">Next month</string>
<string name="month_open_week">Open week %1$d</string>
<string name="month_today_action">Today</string>
<string name="month_split_clear_filter">Show full month agenda</string>
<string name="month_more_actions">More actions</string>
@@ -65,6 +65,14 @@ class ViewBackStackTest {
assertThat(stack.drillToDay()).isEqualTo(stack)
}
@Test
fun `week number drills into week and back returns to month`() {
val stack = listOf(CalendarView.Month).drillToWeek()
assertThat(stack).containsExactly(CalendarView.Month, CalendarView.Week).inOrder()
assertThat(stack.dropLast(1).last()).isEqualTo(CalendarView.Month)
assertThat(stack.drillToWeek()).isEqualTo(stack)
}
@Test
fun `agenda widget date tap backs out agenda then home`() {
// openDate(source = Agenda): root in Agenda over the Week home, then drill.