diff --git a/CHANGELOG.md b/CHANGELOG.md index ad9c46b..0408cf5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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/), 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 ### Added diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt index 348ada3..d2e52e6 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt @@ -4,6 +4,7 @@ import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedContent import androidx.compose.animation.EnterExitState import androidx.compose.foundation.gestures.detectVerticalDragGestures +import androidx.compose.foundation.gestures.detectHorizontalDragGestures import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.PagerDefaults 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.items import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.selection.selectable import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape 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.Search import androidx.compose.material3.DrawerValue @@ -66,6 +69,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.InputChip import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalNavigationDrawer 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.AgendaEmptyDayRow 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.CalendarDrawer import de.jeanlucmakiola.calendula.ui.common.AppBarSpacing @@ -429,6 +434,7 @@ fun MonthScreen( onCreateEvent( when { viewStyle == MonthViewStyle.Split -> selectedDate + ?: if (isOnCurrentMonth) today else LocalDate(titleMonth.year, titleMonth.month, 1) isOnCurrentMonth -> today else -> LocalDate(titleMonth.year, titleMonth.month, 1) }, @@ -473,6 +479,9 @@ fun MonthScreen( weekStart = weekStart, monthState = viewModel::month, showWeekNumbers = showWeekNumbers, + onPaneSwipeNext = { viewModel.stepSplitPane(1) }, + onPaneSwipePrev = { viewModel.stepSplitPane(-1) }, + onClearSelection = viewModel::clearSelection, onRetry = jumpToToday, // A tapped leading/trailing day follows to its own // month, pager and all — straight away, or a swipe @@ -1293,11 +1302,14 @@ private val MONTH_EXPAND_THRESHOLD = 48.dp @Composable private fun SplitMonthContent( state: MonthUiState, - selected: LocalDate, + selected: LocalDate?, pagerState: PagerState, weekStart: DayOfWeek, monthState: (YearMonth, DayOfWeek) -> StateFlow, showWeekNumbers: Boolean, + onPaneSwipeNext: () -> Unit, + onPaneSwipePrev: () -> Unit, + onClearSelection: () -> Unit, onRetry: () -> Unit, onSelectDay: (LocalDate) -> Unit, onOpenDay: (LocalDate) -> Unit, @@ -1373,6 +1385,9 @@ private fun SplitMonthContent( showWeekNumbers = showWeekNumbers, expandDrag = expandDrag, onSelectDay = onSelectDay, + onPaneSwipeNext = onPaneSwipeNext, + onPaneSwipePrev = onPaneSwipePrev, + onClearSelection = onClearSelection, onOpenDay = onOpenDay, onEventClick = onEventClick, onCreateEvent = onCreateEvent, @@ -1398,10 +1413,13 @@ private class SplitMonths( private fun SplitMonthCollapsed( state: MonthUiState.Success, months: SplitMonths, - selected: LocalDate, + selected: LocalDate?, showWeekNumbers: Boolean, expandDrag: Modifier, onSelectDay: (LocalDate) -> Unit, + onPaneSwipeNext: () -> Unit, + onPaneSwipePrev: () -> Unit, + onClearSelection: () -> Unit, onOpenDay: (LocalDate) -> Unit, onEventClick: (EventInstance) -> Unit, onCreateEvent: (LocalDate) -> Unit, @@ -1440,18 +1458,64 @@ private fun SplitMonthCollapsed( } SplitExpandHandle(expanded = false, onToggle = onExpand) } - SplitDayPane( - date = selected, - today = state.today, - // Null, not empty: the selection moves to the new month before - // its data arrives, and a missing key means "not loaded yet". - // Passing an empty list would claim the day was free. - events = state.instancesByDay[selected], - zone = state.zone, - onOpenDay = onOpenDay, - onEventClick = onEventClick, - onCreateEvent = onCreateEvent, - modifier = Modifier.weight(1f).fillMaxWidth(), + Box( + Modifier.weight(1f).fillMaxWidth() + .then(rememberSplitPaneSwipe(onPaneSwipeNext, onPaneSwipePrev)), + ) { + if (selected == null) { + SplitMonthAgendaPane( + state = state, + onOpenDay = onOpenDay, + onEventClick = onEventClick, + onCreateEvent = onCreateEvent, + ) + } 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( months: SplitMonths, /** Marked nowhere here; it only anchors the outline's morph. */ - selected: LocalDate, + selected: LocalDate?, showWeekNumbers: Boolean, expandDrag: Modifier, onPickDay: (LocalDate) -> Unit, @@ -1581,7 +1645,7 @@ private val SPLIT_HANDLE_ROW_HEIGHT = 24.dp @Composable internal fun SplitMonthGrid( state: MonthUiState.Success, - selected: LocalDate, + selected: LocalDate?, showWeekNumbers: Boolean, onSelectDay: (LocalDate) -> Unit, 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 * surfaces read as one app. The date header opens the full Day view, matching diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthViewModel.kt index 04a5af2..b2318d9 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthViewModel.kt @@ -292,12 +292,10 @@ class MonthViewModel @Inject constructor( // --- Split style (#53) ------------------------------------------------ // - // The first selected-day concept in the app: the other views drill straight - // into a date, while the split style keeps one selected and lists it below - // the grid. + // Null means the full-month agenda; a date narrows the pane to that day. - private val _selectedDate = MutableStateFlow(todayDate) - val selectedDate: StateFlow = _selectedDate + private val _selectedDate = MutableStateFlow(null) + val selectedDate: StateFlow = _selectedDate /** * 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. */ fun selectDate(date: LocalDate) { - _selectedDate.value = date 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 } - /** - * 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 - * shows. - */ + fun clearSelection() { _selectedDate.value = null } + + /** A pane swipe walks days when filtered, months when showing the agenda. */ + 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() { - _selectedDate.value = selectionForMonth(_month.value, todayDate) + _selectedDate.value = null } fun goToToday() { _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 * 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 - * selection is realigned alongside it so that landing on the Split style - * shows a live day in the month on screen rather than a stale, off-month one. + * selection is cleared alongside it so Split opens the visible month's agenda. */ fun syncScrollMonth(ym: YearMonth) { if (_month.value == ym) return _month.value = ym - _selectedDate.value = selectionForMonth(ym, todayDate) + _selectedDate.value = null } /** 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) /** - * Which day the split style should select when the grid lands on [month]: - * [today] when the month holds it, otherwise the 1st. Pure so the rule can be - * tested without standing up a view model and a provider behind it. + * First day shown in the split agenda: today in the current month, otherwise + * the first of the visible month. */ -internal fun selectionForMonth(month: YearMonth, today: LocalDate): LocalDate = +internal fun agendaStartForMonth(month: YearMonth, today: LocalDate): LocalDate = if (YearMonth(today.year, today.month) == month) { today } else { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index af45498..4099239 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -55,6 +55,7 @@ Previous month Next month Today + Show full month agenda More actions Open menu Settings diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/ContinuousMonthIndexTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/ContinuousMonthIndexTest.kt index ef2c56b..71e9df2 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/ContinuousMonthIndexTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/ContinuousMonthIndexTest.kt @@ -222,17 +222,17 @@ class ContinuousMonthIndexTest { } @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) - assertThat(selectionForMonth(jun26, today)).isEqualTo(today) + assertThat(agendaStartForMonth(jun26, today)).isEqualTo(today) } @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) - assertThat(selectionForMonth(YearMonth(2026, Month.JULY), today)) + assertThat(agendaStartForMonth(YearMonth(2026, Month.JULY), today)) .isEqualTo(LocalDate(2026, 7, 1)) - assertThat(selectionForMonth(YearMonth(2025, Month.JUNE), today)) + assertThat(agendaStartForMonth(YearMonth(2025, Month.JUNE), today)) .isEqualTo(LocalDate(2025, 6, 1)) } }