feat(month): month view style picker + Split pull-to-expand (#38, #53) #90

Merged
makiolaj merged 36 commits from feat/month-view-style into release/v2.16.0 2026-07-20 21:25:50 +00:00
5 changed files with 365 additions and 18 deletions
Showing only changes of commit becb9a6710 - Show all commits

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