Merge full month agenda in split view (#142)

This commit is contained in:
Jean-Luc Makiola
2026-09-30 13:12:22 +02:00
5 changed files with 165 additions and 41 deletions
+5
View File
@@ -5,6 +5,11 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### 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]).
## [2.21.0] — 2026-09-23 ## [2.21.0] — 2026-09-23
### Added ### Added
@@ -4,6 +4,7 @@ import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.EnterExitState import androidx.compose.animation.EnterExitState
import androidx.compose.foundation.gestures.detectVerticalDragGestures import androidx.compose.foundation.gestures.detectVerticalDragGestures
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.PagerDefaults import androidx.compose.foundation.pager.PagerDefaults
import androidx.compose.foundation.pager.PagerState import androidx.compose.foundation.pager.PagerState
@@ -55,10 +56,12 @@ import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.selection.selectable import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Menu import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.DrawerValue import androidx.compose.material3.DrawerValue
@@ -66,6 +69,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.InputChip
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalNavigationDrawer import androidx.compose.material3.ModalNavigationDrawer
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
@@ -148,6 +152,7 @@ import de.jeanlucmakiola.calendula.domain.hasEnded
import de.jeanlucmakiola.calendula.ui.agenda.AgendaDayHeader import de.jeanlucmakiola.calendula.ui.agenda.AgendaDayHeader
import de.jeanlucmakiola.calendula.ui.agenda.AgendaEmptyDayRow import de.jeanlucmakiola.calendula.ui.agenda.AgendaEmptyDayRow
import de.jeanlucmakiola.calendula.ui.agenda.AgendaEventRow import de.jeanlucmakiola.calendula.ui.agenda.AgendaEventRow
import de.jeanlucmakiola.calendula.ui.agenda.agendaDayLabel
import de.jeanlucmakiola.calendula.ui.common.formatCalendarTitle import de.jeanlucmakiola.calendula.ui.common.formatCalendarTitle
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
import de.jeanlucmakiola.calendula.ui.common.AppBarSpacing import de.jeanlucmakiola.calendula.ui.common.AppBarSpacing
@@ -429,6 +434,7 @@ fun MonthScreen(
onCreateEvent( onCreateEvent(
when { when {
viewStyle == MonthViewStyle.Split -> selectedDate viewStyle == MonthViewStyle.Split -> selectedDate
?: if (isOnCurrentMonth) today else LocalDate(titleMonth.year, titleMonth.month, 1)
isOnCurrentMonth -> today isOnCurrentMonth -> today
else -> LocalDate(titleMonth.year, titleMonth.month, 1) else -> LocalDate(titleMonth.year, titleMonth.month, 1)
}, },
@@ -473,6 +479,9 @@ fun MonthScreen(
weekStart = weekStart, weekStart = weekStart,
monthState = viewModel::month, monthState = viewModel::month,
showWeekNumbers = showWeekNumbers, showWeekNumbers = showWeekNumbers,
onPaneSwipeNext = { viewModel.stepSplitPane(1) },
onPaneSwipePrev = { viewModel.stepSplitPane(-1) },
onClearSelection = viewModel::clearSelection,
onRetry = jumpToToday, onRetry = jumpToToday,
// A tapped leading/trailing day follows to its own // A tapped leading/trailing day follows to its own
// month, pager and all — straight away, or a swipe // month, pager and all — straight away, or a swipe
@@ -1293,11 +1302,14 @@ private val MONTH_EXPAND_THRESHOLD = 48.dp
@Composable @Composable
private fun SplitMonthContent( private fun SplitMonthContent(
state: MonthUiState, state: MonthUiState,
selected: LocalDate, selected: LocalDate?,
pagerState: PagerState, pagerState: PagerState,
weekStart: DayOfWeek, weekStart: DayOfWeek,
monthState: (YearMonth, DayOfWeek) -> StateFlow<MonthUiState>, monthState: (YearMonth, DayOfWeek) -> StateFlow<MonthUiState>,
showWeekNumbers: Boolean, showWeekNumbers: Boolean,
onPaneSwipeNext: () -> Unit,
onPaneSwipePrev: () -> Unit,
onClearSelection: () -> Unit,
onRetry: () -> Unit, onRetry: () -> Unit,
onSelectDay: (LocalDate) -> Unit, onSelectDay: (LocalDate) -> Unit,
onOpenDay: (LocalDate) -> Unit, onOpenDay: (LocalDate) -> Unit,
@@ -1373,6 +1385,9 @@ private fun SplitMonthContent(
showWeekNumbers = showWeekNumbers, showWeekNumbers = showWeekNumbers,
expandDrag = expandDrag, expandDrag = expandDrag,
onSelectDay = onSelectDay, onSelectDay = onSelectDay,
onPaneSwipeNext = onPaneSwipeNext,
onPaneSwipePrev = onPaneSwipePrev,
onClearSelection = onClearSelection,
onOpenDay = onOpenDay, onOpenDay = onOpenDay,
onEventClick = onEventClick, onEventClick = onEventClick,
onCreateEvent = onCreateEvent, onCreateEvent = onCreateEvent,
@@ -1398,10 +1413,13 @@ private class SplitMonths(
private fun SplitMonthCollapsed( private fun SplitMonthCollapsed(
state: MonthUiState.Success, state: MonthUiState.Success,
months: SplitMonths, months: SplitMonths,
selected: LocalDate, selected: LocalDate?,
showWeekNumbers: Boolean, showWeekNumbers: Boolean,
expandDrag: Modifier, expandDrag: Modifier,
onSelectDay: (LocalDate) -> Unit, onSelectDay: (LocalDate) -> Unit,
onPaneSwipeNext: () -> Unit,
onPaneSwipePrev: () -> Unit,
onClearSelection: () -> Unit,
onOpenDay: (LocalDate) -> Unit, onOpenDay: (LocalDate) -> Unit,
onEventClick: (EventInstance) -> Unit, onEventClick: (EventInstance) -> Unit,
onCreateEvent: (LocalDate) -> Unit, onCreateEvent: (LocalDate) -> Unit,
@@ -1440,18 +1458,64 @@ private fun SplitMonthCollapsed(
} }
SplitExpandHandle(expanded = false, onToggle = onExpand) SplitExpandHandle(expanded = false, onToggle = onExpand)
} }
SplitDayPane( Box(
date = selected, Modifier.weight(1f).fillMaxWidth()
today = state.today, .then(rememberSplitPaneSwipe(onPaneSwipeNext, onPaneSwipePrev)),
// Null, not empty: the selection moves to the new month before ) {
// its data arrives, and a missing key means "not loaded yet". if (selected == null) {
// Passing an empty list would claim the day was free. SplitMonthAgendaPane(
events = state.instancesByDay[selected], state = state,
zone = state.zone, onOpenDay = onOpenDay,
onOpenDay = onOpenDay, onEventClick = onEventClick,
onEventClick = onEventClick, onCreateEvent = onCreateEvent,
onCreateEvent = onCreateEvent, )
modifier = Modifier.weight(1f).fillMaxWidth(), } else {
Column {
InputChip(
selected = true,
onClick = onClearSelection,
label = { Text(agendaDayLabel(selected, state.today)) },
trailingIcon = {
Icon(
Icons.Default.Close,
contentDescription = stringResource(R.string.month_split_clear_filter),
)
},
modifier = Modifier.padding(start = 16.dp),
)
SplitDayPane(
date = selected,
today = state.today,
events = state.instancesByDay[selected],
zone = state.zone,
onOpenDay = onOpenDay,
onEventClick = onEventClick,
onCreateEvent = onCreateEvent,
modifier = Modifier.weight(1f).fillMaxWidth(),
)
}
}
}
}
}
@Composable
private fun rememberSplitPaneSwipe(onNext: () -> Unit, onPrev: () -> Unit): Modifier {
val threshold = with(LocalDensity.current) { 24.dp.toPx() }
val currentNext by rememberUpdatedState(onNext)
val currentPrev by rememberUpdatedState(onPrev)
return Modifier.pointerInput(threshold) {
var distance = 0f
var fired = false
detectHorizontalDragGestures(
onDragStart = { distance = 0f; fired = false },
onHorizontalDrag = { _, drag ->
distance += drag
if (!fired && kotlin.math.abs(distance) >= threshold) {
fired = true
if (distance < 0f) currentNext() else currentPrev()
}
},
) )
} }
} }
@@ -1469,7 +1533,7 @@ private fun SplitMonthCollapsed(
private fun SplitMonthExpanded( private fun SplitMonthExpanded(
months: SplitMonths, months: SplitMonths,
/** Marked nowhere here; it only anchors the outline's morph. */ /** Marked nowhere here; it only anchors the outline's morph. */
selected: LocalDate, selected: LocalDate?,
showWeekNumbers: Boolean, showWeekNumbers: Boolean,
expandDrag: Modifier, expandDrag: Modifier,
onPickDay: (LocalDate) -> Unit, onPickDay: (LocalDate) -> Unit,
@@ -1581,7 +1645,7 @@ private val SPLIT_HANDLE_ROW_HEIGHT = 24.dp
@Composable @Composable
internal fun SplitMonthGrid( internal fun SplitMonthGrid(
state: MonthUiState.Success, state: MonthUiState.Success,
selected: LocalDate, selected: LocalDate?,
showWeekNumbers: Boolean, showWeekNumbers: Boolean,
onSelectDay: (LocalDate) -> Unit, onSelectDay: (LocalDate) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@@ -1816,6 +1880,54 @@ private fun SplitDots(
} }
} }
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun SplitMonthAgendaPane(
state: MonthUiState.Success,
onOpenDay: (LocalDate) -> Unit,
onEventClick: (EventInstance) -> Unit,
onCreateEvent: (LocalDate) -> Unit,
) {
val start = agendaStartForMonth(state.month, state.today)
val days = state.instancesByDay.keys
.filter { it >= start && it.year == state.month.year && it.month == state.month.month }
.filter { state.instancesByDay[it].orEmpty().isNotEmpty() }
.toMutableList()
.apply { if (start !in this) add(start) }
.sorted()
val dimCutoff = LocalDimCutoff.current
val listState = key(state.month) { rememberLazyListState() }
LazyColumn(state = listState, contentPadding = PaddingValues(top = 8.dp, bottom = 96.dp)) {
days.forEach { day ->
val events = state.instancesByDay[day].orEmpty()
stickyHeader(key = "header-$day") {
AgendaDayHeader(date = day, today = state.today, onOpenDay = onOpenDay)
}
if (events.isEmpty()) {
item(key = "empty-$day") {
AgendaEmptyDayRow(
text = stringResource(R.string.month_split_no_events),
onClick = { onCreateEvent(day) },
)
}
} else {
itemsIndexed(events, key = { _, event -> "$day-${event.instanceId}" }) { index, event ->
AgendaEventRow(
event = event,
day = day,
zone = state.zone,
position = positionOf(index, events.size),
dimmed = dimCutoff != null && event.hasEnded(dimCutoff),
modifier = animateItemMotion(),
onClick = { onEventClick(event) },
)
}
}
item(key = "gap-$day") { Spacer(Modifier.height(8.dp)) }
}
}
}
/** /**
* The selected day's events, in the agenda's own row vocabulary so the two * The selected day's events, in the agenda's own row vocabulary so the two
* surfaces read as one app. The date header opens the full Day view, matching * surfaces read as one app. The date header opens the full Day view, matching
@@ -292,12 +292,10 @@ class MonthViewModel @Inject constructor(
// --- Split style (#53) ------------------------------------------------ // --- Split style (#53) ------------------------------------------------
// //
// The first selected-day concept in the app: the other views drill straight // Null means the full-month agenda; a date narrows the pane to that day.
// into a date, while the split style keeps one selected and lists it below
// the grid.
private val _selectedDate = MutableStateFlow(todayDate) private val _selectedDate = MutableStateFlow<LocalDate?>(null)
val selectedDate: StateFlow<LocalDate> = _selectedDate val selectedDate: StateFlow<LocalDate?> = _selectedDate
/** /**
* Select [date], following it to its month if it sits in the grid's leading * Select [date], following it to its month if it sits in the grid's leading
@@ -305,23 +303,33 @@ class MonthViewModel @Inject constructor(
* silently list a date the grid isn't pointing at. * silently list a date the grid isn't pointing at.
*/ */
fun selectDate(date: LocalDate) { fun selectDate(date: LocalDate) {
_selectedDate.value = date
val target = YearMonth(date.year, date.month) val target = YearMonth(date.year, date.month)
_selectedDate.value = if (target == _month.value && _selectedDate.value == date) null else date
if (target != _month.value) _month.value = target if (target != _month.value) _month.value = target
} }
/** fun clearSelection() { _selectedDate.value = null }
* Move the selection with the month: today if the new month holds it, else
* its 1st. Leaving the old date selected would list a day the grid no longer /** A pane swipe walks days when filtered, months when showing the agenda. */
* shows. fun stepSplitPane(days: Int) {
*/ val selected = _selectedDate.value
if (selected == null) {
_month.value = yearMonthForIndex(monthIndexOf(_month.value) + days)
} else {
val next = selected.plus(days, DateTimeUnit.DAY)
_selectedDate.value = next
_month.value = YearMonth(next.year, next.month)
}
}
/** Moving the grid to another month restores its full agenda. */
private fun realignSelection() { private fun realignSelection() {
_selectedDate.value = selectionForMonth(_month.value, todayDate) _selectedDate.value = null
} }
fun goToToday() { fun goToToday() {
_month.value = YearMonth(todayDate.year, todayDate.month) _month.value = YearMonth(todayDate.year, todayDate.month)
_selectedDate.value = todayDate _selectedDate.value = null
} }
/** /**
@@ -330,13 +338,12 @@ class MonthViewModel @Inject constructor(
* otherwise sit wherever paged navigation last left it (today's month, on a * otherwise sit wherever paged navigation last left it (today's month, on a
* fresh open) — and a switch to a paged style, or a reseed of the other * fresh open) — and a switch to a paged style, or a reseed of the other
* scrolling style's list, would jump there instead of holding position. The * scrolling style's list, would jump there instead of holding position. The
* selection is realigned alongside it so that landing on the Split style * selection is cleared alongside it so Split opens the visible month's agenda.
* shows a live day in the month on screen rather than a stale, off-month one.
*/ */
fun syncScrollMonth(ym: YearMonth) { fun syncScrollMonth(ym: YearMonth) {
if (_month.value == ym) return if (_month.value == ym) return
_month.value = ym _month.value = ym
_selectedDate.value = selectionForMonth(ym, todayDate) _selectedDate.value = null
} }
/** Jump to the month containing [date] (drawer jump-to-date). */ /** Jump to the month containing [date] (drawer jump-to-date). */
@@ -544,11 +551,10 @@ private const val WINDOW_EDGE = 2
internal fun edgeForPad(pad: Int): Int = minOf(WINDOW_EDGE, pad - 1).coerceAtLeast(0) internal fun edgeForPad(pad: Int): Int = minOf(WINDOW_EDGE, pad - 1).coerceAtLeast(0)
/** /**
* Which day the split style should select when the grid lands on [month]: * First day shown in the split agenda: today in the current month, otherwise
* [today] when the month holds it, otherwise the 1st. Pure so the rule can be * the first of the visible month.
* tested without standing up a view model and a provider behind it.
*/ */
internal fun selectionForMonth(month: YearMonth, today: LocalDate): LocalDate = internal fun agendaStartForMonth(month: YearMonth, today: LocalDate): LocalDate =
if (YearMonth(today.year, today.month) == month) { if (YearMonth(today.year, today.month) == month) {
today today
} else { } else {
+1
View File
@@ -55,6 +55,7 @@
<string name="month_prev">Previous month</string> <string name="month_prev">Previous month</string>
<string name="month_next">Next month</string> <string name="month_next">Next month</string>
<string name="month_today_action">Today</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> <string name="month_more_actions">More actions</string>
<string name="month_open_menu">Open menu</string> <string name="month_open_menu">Open menu</string>
<string name="month_action_settings">Settings</string> <string name="month_action_settings">Settings</string>
@@ -222,17 +222,17 @@ class ContinuousMonthIndexTest {
} }
@Test @Test
fun `the split selection follows the month, landing on today when it's there`() { fun `the split agenda starts at today in the current month`() {
val today = LocalDate(2026, 6, 10) val today = LocalDate(2026, 6, 10)
assertThat(selectionForMonth(jun26, today)).isEqualTo(today) assertThat(agendaStartForMonth(jun26, today)).isEqualTo(today)
} }
@Test @Test
fun `the split selection falls to the 1st of any other month`() { fun `the split agenda starts at the 1st of another month`() {
val today = LocalDate(2026, 6, 10) val today = LocalDate(2026, 6, 10)
assertThat(selectionForMonth(YearMonth(2026, Month.JULY), today)) assertThat(agendaStartForMonth(YearMonth(2026, Month.JULY), today))
.isEqualTo(LocalDate(2026, 7, 1)) .isEqualTo(LocalDate(2026, 7, 1))
assertThat(selectionForMonth(YearMonth(2025, Month.JUNE), today)) assertThat(agendaStartForMonth(YearMonth(2025, Month.JUNE), today))
.isEqualTo(LocalDate(2025, 6, 1)) .isEqualTo(LocalDate(2025, 6, 1))
} }
} }