From becb9a67100a82e010126e607a04c659cae62861 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 20 Jul 2026 17:00:22 +0200 Subject: [PATCH] feat(month): split style with a day pane (#53) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds MonthViewStyle.Split: the month compressed to day numbers and event dots over a list of the selected day's events. - The app's first selected-day concept. Every other view drills straight into a date; here a tap selects, because the pane below is already the answer to "what's on this day" and opening a whole screen would defeat the layout. The full Day view stays one tap away on the pane's date header, matching the Week and Agenda headers (#37). - The pane reuses the agenda's row vocabulary (extracted in the groundwork commit) rather than growing a parallel set, so the two surfaces read as one app. It needs no extra provider query: instancesByDay is already covered by the month grid range. - Selection follows the month — today when the new month holds it, else the 1st — so the pane never lists a day the grid isn't showing. Tapping a leading or trailing day follows it to its own month. - Selection and today are different signals (tinted, outlined cell vs. the filled circle the other views use for today), so both read when they land on the same day. - The swipe stays on the grid alone; the pane scrolls and is full of tappable rows. MonthScreen gains onEventClick, wired in CalendarHost alongside the other three views, and MonthUiState.Success now carries its zone for the same reason the agenda's does. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../calendula/ui/CalendarHost.kt | 1 + .../calendula/ui/month/MonthScreen.kt | 311 +++++++++++++++++- .../calendula/ui/month/MonthUiState.kt | 8 + .../calendula/ui/month/MonthViewModel.kt | 46 +++ .../ui/month/ContinuousWeekIndexTest.kt | 17 + 5 files changed, 365 insertions(+), 18 deletions(-) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt index cf5abea..b830f74 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt @@ -338,6 +338,7 @@ fun CalendarHost( selectedView = currentView, onSelectView = onSelectView, onOpenDay = onOpenDay, + onEventClick = onEventClick, onOpenSettings = onOpenSettings, onOpenSearch = onOpenSearch, onCreateEvent = onCreateEvent, 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 b5ddc8e..6a5e821 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 @@ -2,6 +2,7 @@ package de.jeanlucmakiola.calendula.ui.month import androidx.compose.animation.AnimatedContent import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.detectHorizontalDragGestures import androidx.compose.foundation.isSystemInDarkTheme @@ -23,7 +24,9 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.selection.selectable import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons @@ -31,6 +34,7 @@ import androidx.compose.material.icons.filled.Menu import androidx.compose.material.icons.filled.Search import androidx.compose.material3.DrawerValue import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -71,7 +75,11 @@ import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import de.jeanlucmakiola.calendula.R +import de.jeanlucmakiola.calendula.domain.EventInstance 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.common.formatCalendarTitle import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn @@ -88,6 +96,7 @@ import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec +import de.jeanlucmakiola.floret.components.positionOf import de.jeanlucmakiola.floret.identity.rememberReduceMotion import de.jeanlucmakiola.floret.locale.currentLocale import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec @@ -113,6 +122,7 @@ fun MonthScreen( selectedView: CalendarView, onSelectView: (CalendarView) -> Unit, onOpenDay: (LocalDate) -> Unit, + onEventClick: (EventInstance) -> Unit, onOpenSettings: () -> Unit, onOpenSearch: () -> Unit, onCreateEvent: (LocalDate, Int?) -> Unit, @@ -127,6 +137,7 @@ fun MonthScreen( val month by viewModel.month.collectAsStateWithLifecycle() val weekStart by viewModel.weekStart.collectAsStateWithLifecycle() val viewStyle by viewModel.viewStyle.collectAsStateWithLifecycle() + val selectedDate by viewModel.selectedDate.collectAsStateWithLifecycle() val dimCompleted by viewModel.dimCompletedEvents.collectAsStateWithLifecycle() val showWeekNumbers by viewModel.showWeekNumbers.collectAsStateWithLifecycle() // The instant before which an event counts as completed, or null when dimming @@ -299,6 +310,19 @@ fun MonthScreen( onRetry = jumpToToday, onOpenDay = onOpenDay, ) + } else if (viewStyle == MonthViewStyle.Split) { + SplitMonthContent( + state = state, + selected = selectedDate, + showWeekNumbers = showWeekNumbers, + onSwipeNext = goNext, + onSwipePrev = goPrev, + onRetry = jumpToToday, + onSelectDay = viewModel::selectDate, + onOpenDay = onOpenDay, + onEventClick = onEventClick, + onCreateEvent = { onCreateEvent(it, null) }, + ) } else { MonthContent( state = state, @@ -326,27 +350,10 @@ private fun MonthContent( onRetry: () -> Unit, onOpenDay: (LocalDate) -> Unit, ) { - val density = LocalDensity.current - val threshold = with(density) { 6.dp.toPx() } - var dragAccum by remember { mutableFloatStateOf(0f) } val slideSpec = rememberCalendarSlideSpec() val fadeSpec = rememberCalendarFadeSpec() val reduceMotion = rememberReduceMotion() - - val swipeModifier = Modifier.pointerInput(Unit) { - detectHorizontalDragGestures( - onDragStart = { dragAccum = 0f }, - onDragEnd = { - when { - dragAccum < -threshold -> onSwipeNext() - dragAccum > threshold -> onSwipePrev() - } - dragAccum = 0f - }, - onDragCancel = { dragAccum = 0f }, - onHorizontalDrag = { _, drag -> dragAccum += drag }, - ) - } + val swipeModifier = rememberMonthSwipeModifier(onSwipeNext, onSwipePrev) AnimatedContent( targetState = state, @@ -578,6 +585,274 @@ private fun ContinuousMonthGrid( } } +/** + * The month-changing horizontal swipe, shared by the paged and split styles. + * Accumulates the drag and commits past a small threshold on release — the grid + * doesn't follow the finger, so there is no distance to rubber-band against. + */ +@Composable +private fun rememberMonthSwipeModifier( + onSwipeNext: () -> Unit, + onSwipePrev: () -> Unit, +): Modifier { + val threshold = with(LocalDensity.current) { 6.dp.toPx() } + var dragAccum by remember { mutableFloatStateOf(0f) } + return Modifier.pointerInput(Unit) { + detectHorizontalDragGestures( + onDragStart = { dragAccum = 0f }, + onDragEnd = { + when { + dragAccum < -threshold -> onSwipeNext() + dragAccum > threshold -> onSwipePrev() + } + dragAccum = 0f + }, + onDragCancel = { dragAccum = 0f }, + onHorizontalDrag = { _, drag -> dragAccum += drag }, + ) + } +} + +/** + * Split style content: the compact grid keeps the month swipe, the pane below it + * lists whatever day is selected. + * + * No [AnimatedContent] here, unlike the paged style. The grid is 4–6 rows tall + * depending on the month, so sliding one month over another would animate a + * height change under a pane that is trying to hold still. + */ +@Composable +private fun SplitMonthContent( + state: MonthUiState, + selected: LocalDate, + showWeekNumbers: Boolean, + onSwipeNext: () -> Unit, + onSwipePrev: () -> Unit, + onRetry: () -> Unit, + onSelectDay: (LocalDate) -> Unit, + onOpenDay: (LocalDate) -> Unit, + onEventClick: (EventInstance) -> Unit, + onCreateEvent: (LocalDate) -> Unit, +) { + when (state) { + MonthUiState.Loading -> MonthGridLoading() + is MonthUiState.Failure -> CalendarFailure(reason = state.reason, onRetry = onRetry) + is MonthUiState.Success -> Column(Modifier.fillMaxSize()) { + SplitMonthGrid( + state = state, + selected = selected, + showWeekNumbers = showWeekNumbers, + onSelectDay = onSelectDay, + // The swipe lives on the grid alone — the pane scrolls and is + // full of tappable rows, so it shouldn't also be a month gesture. + modifier = rememberMonthSwipeModifier(onSwipeNext, onSwipePrev), + ) + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) + SplitDayPane( + date = selected, + today = state.today, + events = state.instancesByDay[selected].orEmpty(), + zone = state.zone, + onOpenDay = onOpenDay, + onEventClick = onEventClick, + onCreateEvent = onCreateEvent, + modifier = Modifier.weight(1f).fillMaxWidth(), + ) + } + } +} + +// --- Split style (#53) ---------------------------------------------------- + +/** + * Row height in the split grid. Only a day number and a row of dots to seat, so + * 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_DOT_SIZE = 5.dp +private const val SPLIT_MAX_DOTS = 3 + +/** + * The split style's grid (#53): the month compressed to day numbers and event + * dots, with the selected day listed underneath by [SplitDayPane]. + * + * Tapping selects rather than drilling into the Day view — the pane is the + * answer to "what's on this day", so opening a whole screen for it would defeat + * the layout. The full Day view stays one tap away on the pane's date header. + */ +@Composable +private fun SplitMonthGrid( + state: MonthUiState.Success, + selected: LocalDate, + showWeekNumbers: Boolean, + onSelectDay: (LocalDate) -> Unit, + modifier: Modifier = Modifier, +) { + val dark = isSystemInDarkTheme() + val month = state.month + Column( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 4.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + state.weeks.forEach { week -> + Row(Modifier.fillMaxWidth().height(SPLIT_ROW_HEIGHT)) { + if (showWeekNumbers) { + WeekNumberGutter( + weekStart = week.days.first(), + modifier = Modifier + .width(WEEK_NUMBER_GUTTER) + .fillMaxHeight(), + ) + } + week.days.forEach { day -> + SplitDayCell( + date = day, + events = state.instancesByDay[day].orEmpty(), + isToday = day == state.today, + isSelected = day == selected, + inMonth = day.month == month.month && day.year == month.year, + dark = dark, + onClick = { onSelectDay(day) }, + modifier = Modifier.weight(1f).fillMaxHeight(), + ) + } + } + } + } +} + +/** + * One compact day: its number over up to [SPLIT_MAX_DOTS] event dots. + * + * Selection and today are deliberately different signals — a tinted, outlined + * cell versus the filled circle the other views already use for today — so the + * two read at once when they land on the same day. + */ +@Composable +private fun SplitDayCell( + date: LocalDate, + events: List, + isToday: Boolean, + isSelected: Boolean, + inMonth: Boolean, + dark: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val background = when { + isSelected -> MaterialTheme.colorScheme.primaryContainer + inMonth -> MaterialTheme.colorScheme.surfaceContainer + else -> MaterialTheme.colorScheme.surfaceContainerLow + } + Box( + modifier = modifier + .padding(horizontal = CELL_GAP, vertical = 1.dp) + .clip(CELL_SHAPE) + .background(background) + .then( + if (isSelected) { + Modifier.border(1.5.dp, MaterialTheme.colorScheme.primary, CELL_SHAPE) + } else { + Modifier + }, + ) + .selectable(selected = isSelected, onClick = onClick), + contentAlignment = Alignment.TopCenter, + ) { + Column( + modifier = Modifier.fillMaxSize().padding(top = 4.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + DayNumberCell( + date = date, + isToday = isToday, + inMonth = inMonth, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(2.dp)) + SplitDots(events = events, dark = dark) + } + } +} + +/** Up to three colour dots for a day, plus a count when more are hidden. */ +@Composable +private fun SplitDots(events: List, dark: Boolean) { + if (events.isEmpty()) return + val soften = LocalSoftenColors.current + val colors = remember(events) { events.map { it.color }.distinct().take(SPLIT_MAX_DOTS) } + val extra = events.size - colors.size + Row( + horizontalArrangement = Arrangement.spacedBy(2.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + colors.forEach { argb -> + Box( + modifier = Modifier + .size(SPLIT_DOT_SIZE) + .background(eventFill(argb, dark, soften), CircleShape), + ) + } + if (extra > 0) { + Text( + text = "+$extra", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +/** + * 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 + * the Week and Agenda headers (#37). + */ +@Composable +private fun SplitDayPane( + date: LocalDate, + today: LocalDate, + events: List, + zone: TimeZone, + onOpenDay: (LocalDate) -> Unit, + onEventClick: (EventInstance) -> Unit, + onCreateEvent: (LocalDate) -> Unit, + modifier: Modifier = Modifier, +) { + val dimCutoff = LocalDimCutoff.current + Column(modifier = modifier) { + AgendaDayHeader(date = date, today = today, onOpenDay = onOpenDay) + if (events.isEmpty()) { + AgendaEmptyDayRow( + text = stringResource(R.string.month_split_no_events), + onClick = { onCreateEvent(date) }, + ) + } else { + LazyColumn( + // Bottom inset clears the FAB stack so the last row stays tappable. + contentPadding = PaddingValues(bottom = 96.dp), + ) { + itemsIndexed( + items = events, + key = { _, event -> event.instanceId }, + ) { index, event -> + AgendaEventRow( + event = event, + day = date, + zone = zone, + position = positionOf(index, events.size), + dimmed = dimCutoff != null && event.hasEnded(dimCutoff), + onClick = { onEventClick(event) }, + ) + } + } + } + } +} + /** A week the sliding window hasn't loaded yet — same height, so nothing jumps. */ @Composable private fun ContinuousWeekPlaceholder() { diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthUiState.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthUiState.kt index 7e1f3f0..cb59ccf 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthUiState.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthUiState.kt @@ -4,6 +4,7 @@ import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.FailureReason import kotlinx.datetime.DayOfWeek import kotlinx.datetime.LocalDate +import kotlinx.datetime.TimeZone import kotlinx.datetime.YearMonth /** @@ -70,5 +71,12 @@ sealed interface MonthUiState { val today: LocalDate, val weeks: List, val instancesByDay: Map> = emptyMap(), + /** + * Travels on the state rather than being read at the call site, for the + * same reason the agenda does it: a file-level constant would be fixed + * for the process lifetime and drift from the zone the events were laid + * out in after a device time-zone change. + */ + val zone: TimeZone = TimeZone.currentSystemDefault(), ) : MonthUiState } 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 1de6dab..81f33ad 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 @@ -167,21 +167,54 @@ 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. + + private val _selectedDate = MutableStateFlow(todayDate) + val selectedDate: StateFlow = _selectedDate + + /** + * Select [date], following it to its month if it sits in the grid's leading + * or trailing days — tapping a greyed-out day should show that day, not + * silently list a date the grid isn't pointing at. + */ + fun selectDate(date: LocalDate) { + _selectedDate.value = date + val target = YearMonth(date.year, date.month) + 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. + */ + private fun realignSelection() { + _selectedDate.value = selectionForMonth(_month.value, todayDate) + } + fun goToPrev() { _month.value = _month.value.minus(1, DateTimeUnit.MONTH) + realignSelection() } fun goToNext() { _month.value = _month.value.plus(1, DateTimeUnit.MONTH) + realignSelection() } fun goToToday() { _month.value = YearMonth(todayDate.year, todayDate.month) + _selectedDate.value = todayDate } /** Jump to the month containing [date] (drawer jump-to-date). */ fun goToDate(date: LocalDate) { _month.value = YearMonth(date.year, date.month) + _selectedDate.value = date } private fun buildState( @@ -199,6 +232,7 @@ class MonthViewModel @Inject constructor( today = todayDate, weeks = weeks, instancesByDay = instancesByDay(weeks.flatMap { it.days }, instances, zone), + zone = zone, ) } } @@ -310,6 +344,18 @@ internal fun monthGridRange( private const val WINDOW_PAD = 12 private const val WINDOW_EDGE = 4 +/** + * 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. + */ +internal fun selectionForMonth(month: YearMonth, today: LocalDate): LocalDate = + if (YearMonth(today.year, today.month) == month) { + today + } else { + LocalDate(month.year, month.month, 1) + } + /** * The window to load for a visible range, or null to keep the current one. * diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/ContinuousWeekIndexTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/ContinuousWeekIndexTest.kt index a45a2c3..2b17915 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/ContinuousWeekIndexTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/ContinuousWeekIndexTest.kt @@ -4,6 +4,7 @@ import com.google.common.truth.Truth.assertThat import kotlinx.datetime.DateTimeUnit import kotlinx.datetime.DayOfWeek import kotlinx.datetime.LocalDate +import kotlinx.datetime.YearMonth import kotlinx.datetime.plus import org.junit.jupiter.api.Test @@ -101,4 +102,20 @@ class ContinuousWeekIndexTest { loaded = window assertThat(nextLoadWindow(loaded, firstVisible = 2, lastVisible = 7)).isNull() } + + @Test + fun `the split selection follows the month, landing on today when it's there`() { + val today = LocalDate(2026, 6, 10) + assertThat(selectionForMonth(YearMonth(2026, kotlinx.datetime.Month.JUNE), today)) + .isEqualTo(today) + } + + @Test + fun `the split selection falls to the 1st of any other month`() { + val today = LocalDate(2026, 6, 10) + assertThat(selectionForMonth(YearMonth(2026, kotlinx.datetime.Month.JULY), today)) + .isEqualTo(LocalDate(2026, 7, 1)) + assertThat(selectionForMonth(YearMonth(2025, kotlinx.datetime.Month.JUNE), today)) + .isEqualTo(LocalDate(2025, 6, 1)) + } }