feat(month): split style with a day pane (#53)

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) <noreply@anthropic.com>
This commit is contained in:
2026-07-20 17:00:22 +02:00
parent 542744e342
commit becb9a6710
5 changed files with 365 additions and 18 deletions

View File

@@ -338,6 +338,7 @@ fun CalendarHost(
selectedView = currentView,
onSelectView = onSelectView,
onOpenDay = onOpenDay,
onEventClick = onEventClick,
onOpenSettings = onOpenSettings,
onOpenSearch = onOpenSearch,
onCreateEvent = onCreateEvent,

View File

@@ -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 46 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<EventInstance>,
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<EventInstance>, 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<EventInstance>,
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() {

View File

@@ -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<MonthWeek>,
val instancesByDay: Map<LocalDate, List<EventInstance>> = 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
}

View File

@@ -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<LocalDate> = _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.
*

View File

@@ -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))
}
}