Compare commits

..
14 changed files with 388 additions and 91 deletions
+13
View File
@@ -5,6 +5,19 @@ 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]
### Added
- **Pages can open the split agenda.** Swipe up from a full month to reveal the compact grid and month agenda; tap a day to filter it, then swipe down to return. The transition gives haptic feedback ([#111]).
- **Week numbers open their week.** Tap a week number in the month grid to open Week at that row; Back returns to Month ([#197]).
### 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]).
- **Today stands out in the month grid.** Its whole cell has a distinct background; tapping Today briefly expands that cell and nudges its neighbours aside ([#241]).
### Fixed
- **Multi-day events stay above shorter events.** Month and Week bars, Split day lists, and Agenda now place multi-day events before one-day all-day events, then timed events; declined events remain last ([#291]).
## [2.21.0] — 2026-09-23
### Added
@@ -0,0 +1,18 @@
package de.jeanlucmakiola.calendula.domain
import kotlinx.datetime.TimeZone
/** Lower values appear first in lists and get the upper visible lanes in grids. */
fun EventInstance.displayPriority(zone: TimeZone): Int = when {
isDeclined -> 3
spansMultipleDays(zone) -> 0
isAllDay -> 1
else -> 2
}
/** Shared order for agenda and split day lists. Grid bars add their lane geometry. */
fun eventDisplayComparator(zone: TimeZone): Comparator<EventInstance> =
compareBy<EventInstance> { it.displayPriority(zone) }
.thenBy { it.start }
.thenBy { it.title }
.thenBy { it.instanceId }
@@ -34,6 +34,7 @@ import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
import de.jeanlucmakiola.calendula.ui.common.LocalManageCalendars
import de.jeanlucmakiola.calendula.ui.common.RescheduleViewModel
import de.jeanlucmakiola.calendula.ui.common.drillToDay
import de.jeanlucmakiola.calendula.ui.common.drillToWeek
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
import de.jeanlucmakiola.calendula.ui.common.selectView
import de.jeanlucmakiola.calendula.ui.common.viewBaseStack
@@ -112,6 +113,11 @@ fun CalendarHost(
pendingDayIso = date.toString()
viewStack = viewStack.drillToDay()
}
var pendingWeekIso by rememberSaveable { mutableStateOf<String?>(null) }
val onOpenWeek: (LocalDate) -> Unit = { date ->
pendingWeekIso = date.toString()
viewStack = viewStack.drillToWeek()
}
// The event-detail screen (S4) is a full-screen destination hoisted here so
// it overlays whichever calendar view is active. We forward the tapped
@@ -380,6 +386,8 @@ fun CalendarHost(
quickSwitchViews = quickSwitchViews,
drawerViewOrder = drawerViewOrder,
todayInToolbar = todayInToolbar,
initialDateIso = pendingWeekIso,
onInitialDateConsumed = { pendingWeekIso = null },
)
CalendarView.MultiDay -> MultiDayScreen(
selectedView = currentView,
@@ -409,6 +417,7 @@ fun CalendarHost(
selectedView = currentView,
onSelectView = onSelectView,
onOpenDay = onOpenDay,
onOpenWeek = onOpenWeek,
onEventClick = onEventClick,
onOpenSettings = onOpenSettings,
onOpenSearch = onOpenSearch,
@@ -1,6 +1,7 @@
package de.jeanlucmakiola.calendula.ui.agenda
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.eventDisplayComparator
import de.jeanlucmakiola.calendula.domain.FailureReason
import de.jeanlucmakiola.calendula.domain.spanFirstDay
import de.jeanlucmakiola.calendula.domain.spanLastDay
@@ -86,11 +87,7 @@ fun groupAgendaDays(
return byDay.map { (date, dayEvents) ->
AgendaDay(
date = date,
events = dayEvents.sortedWith(
compareByDescending<EventInstance> { it.isAllDay }
.thenBy { it.start }
.thenBy { it.title },
),
events = dayEvents.sortedWith(eventDisplayComparator(zone)),
)
}
}
@@ -129,3 +129,7 @@ fun viewBaseStack(default: CalendarView, source: CalendarView): List<CalendarVie
/** Push the day view as a drill-in over the current stack (no-op if already on it). */
fun List<CalendarView>.drillToDay(): List<CalendarView> =
if (lastOrNull() == CalendarView.Day) this else this + CalendarView.Day
/** Push the week view over the month after a week-number tap. */
fun List<CalendarView>.drillToWeek(): List<CalendarView> =
if (lastOrNull() == CalendarView.Week) this else this + CalendarView.Week
@@ -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
@@ -18,10 +19,12 @@ import kotlinx.coroutines.flow.drop
import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.animation.SharedTransitionLayout
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.VectorConverter
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.snap
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
@@ -55,10 +58,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 +71,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
@@ -77,6 +83,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.DisposableEffect
@@ -85,6 +92,7 @@ import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
@@ -103,6 +111,7 @@ import de.jeanlucmakiola.calendula.ui.common.eventPaint
import de.jeanlucmakiola.calendula.ui.common.eventSurface
import de.jeanlucmakiola.calendula.ui.common.monthBarCuts
import kotlin.math.roundToInt
import kotlin.math.abs
import de.jeanlucmakiola.calendula.ui.common.rememberDragSurface
import de.jeanlucmakiola.calendula.ui.common.eventMoveAction
import de.jeanlucmakiola.calendula.ui.common.eventTitleOverflow
@@ -131,6 +140,8 @@ import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
@@ -148,6 +159,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
@@ -193,12 +205,55 @@ import kotlin.time.Instant
import java.time.format.TextStyle as JavaTextStyle
import java.util.Locale
private val LocalTodayPulse = staticCompositionLocalOf { 0 }
private val LocalOpenWeek = staticCompositionLocalOf<((LocalDate) -> Unit)?> { null }
/** A brief push from today's full cell into its immediate neighbours. */
@Composable
private fun rememberTodayRipple(active: Boolean): Animatable<Float, androidx.compose.animation.core.AnimationVector1D> {
val pulse = remember { Animatable(0f) }
val pulseKey = LocalTodayPulse.current
val reduceMotion = rememberReduceMotion()
LaunchedEffect(pulseKey, active, reduceMotion) {
if (active && pulseKey > 0 && !reduceMotion) {
pulse.snapTo(0f)
pulse.animateTo(1f, tween(durationMillis = 180))
pulse.animateTo(
0f,
spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessLow,
),
)
} else {
pulse.snapTo(0f)
}
}
return pulse
}
/** Paint-only motion keeps hit targets and the month grid's lane geometry stable. */
private fun Modifier.todayRipple(relativeColumn: Int?, progress: () -> Float): Modifier {
if (relativeColumn == null || abs(relativeColumn) > 1) return this
return graphicsLayer {
val pulse = progress()
if (relativeColumn == 0) {
scaleX = 1f + 0.10f * pulse
scaleY = 1f + 0.06f * pulse
} else {
translationX = (if (relativeColumn < 0) -1f else 1f) * 4.dp.toPx() * pulse
scaleX = 1f - 0.025f * pulse
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MonthScreen(
selectedView: CalendarView,
onSelectView: (CalendarView) -> Unit,
onOpenDay: (LocalDate) -> Unit,
onOpenWeek: (LocalDate) -> Unit,
onEventClick: (EventInstance) -> Unit,
onOpenSettings: () -> Unit,
onOpenSearch: () -> Unit,
@@ -327,6 +382,9 @@ fun MonthScreen(
}
val isOnCurrentMonth = titleMonth == YearMonth(today.year, today.month)
var splitExpanded by rememberSaveable(viewStyle) {
mutableStateOf(viewStyle == MonthViewStyle.Paged)
}
// Continuous names the month on the block's own sticky header, so the bar
// carries the year instead of repeating it two lines further down. Dense has
@@ -348,7 +406,9 @@ fun MonthScreen(
val jumpToMonth: (YearMonth) -> Unit = { ym ->
scope.launch { pagerState.jumpToPage(monthIndexOf(ym), reduceMotion, pageSpec) }
}
var todayPulse by remember { mutableIntStateOf(0) }
val jumpToToday: () -> Unit = {
todayPulse++
if (scrolling) {
scope.launch {
listState.animateScrollToItem(
@@ -428,7 +488,9 @@ fun MonthScreen(
// today when its month is shown, else the 1st.
onCreateEvent(
when {
viewStyle == MonthViewStyle.Split -> selectedDate
(viewStyle == MonthViewStyle.Split ||
(viewStyle == MonthViewStyle.Paged && !splitExpanded)) -> selectedDate
?: if (isOnCurrentMonth) today else LocalDate(titleMonth.year, titleMonth.month, 1)
isOnCurrentMonth -> today
else -> LocalDate(titleMonth.year, titleMonth.month, 1)
},
@@ -454,6 +516,8 @@ fun MonthScreen(
CompositionLocalProvider(
LocalDimCutoff provides dimCutoff,
LocalMonthDrag provides chipDrag,
LocalTodayPulse provides todayPulse,
LocalOpenWeek provides onOpenWeek,
) {
if (scrolling) {
ContinuousMonthContent(
@@ -465,14 +529,20 @@ fun MonthScreen(
onOpenDay = onOpenDay,
onEventClick = onEventClick,
)
} else if (viewStyle == MonthViewStyle.Split) {
} else if (viewStyle == MonthViewStyle.Split || viewStyle == MonthViewStyle.Paged) {
SplitMonthContent(
state = state,
selected = selectedDate,
initiallyExpanded = viewStyle == MonthViewStyle.Paged,
expanded = splitExpanded,
onExpandedChange = { splitExpanded = it },
pagerState = pagerState,
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
@@ -866,7 +936,7 @@ private fun rememberWeekNumberGutter(): Dp {
val style = weekNumberStyle()
return remember(style, density, measurer) {
val text = with(density) { measurer.measure(WEEK_NUMBER_SAMPLE, style).size.width.toDp() }
text + (WEEK_NUMBER_PADDING + CELL_GAP) * 2
(text + (WEEK_NUMBER_PADDING + CELL_GAP) * 2).coerceAtLeast(48.dp)
}
}
@@ -1293,11 +1363,17 @@ private val MONTH_EXPAND_THRESHOLD = 48.dp
@Composable
private fun SplitMonthContent(
state: MonthUiState,
selected: LocalDate,
selected: LocalDate?,
initiallyExpanded: Boolean,
expanded: Boolean,
onExpandedChange: (Boolean) -> Unit,
pagerState: PagerState,
weekStart: DayOfWeek,
monthState: (YearMonth, DayOfWeek) -> StateFlow<MonthUiState>,
showWeekNumbers: Boolean,
onPaneSwipeNext: () -> Unit,
onPaneSwipePrev: () -> Unit,
onClearSelection: () -> Unit,
onRetry: () -> Unit,
onSelectDay: (LocalDate) -> Unit,
onOpenDay: (LocalDate) -> Unit,
@@ -1306,15 +1382,23 @@ private fun SplitMonthContent(
) {
val fadeSpec = rememberCalendarFadeSpec()
val reduceMotion = rememberReduceMotion()
var expanded by rememberSaveable { mutableStateOf(false) }
// Back collapses before it does anything else. Expanding replaces the whole
// screen, so it is a place you can be, and every other place in the app can
// be backed out of. Declared deeper than CalendarHost's view-stack handler,
// which is what makes it win while it is enabled.
BackHandler(enabled = expanded) { expanded = false }
val haptics = LocalHapticFeedback.current
BackHandler(enabled = expanded != initiallyExpanded) {
onExpandedChange(initiallyExpanded)
}
val expandDrag = rememberSplitExpandDrag(
onExpand = { expanded = true },
onCollapse = { expanded = false },
onExpand = {
if (!expanded) {
onExpandedChange(true)
haptics.performHapticFeedback(HapticFeedbackType.SegmentTick)
}
},
onCollapse = {
if (expanded) {
onExpandedChange(false)
haptics.performHapticFeedback(HapticFeedbackType.SegmentTick)
}
},
)
when (state) {
@@ -1357,13 +1441,13 @@ private fun SplitMonthContent(
// the day you picked.
onPickDay = {
onSelectDay(it)
expanded = false
onExpandedChange(false)
},
// A tapped chip is asking for that event, not for its
// day, so it opens the detail from here too rather
// than collapsing (#187).
onEventClick = onEventClick,
onCollapse = { expanded = false },
onCollapse = { onExpandedChange(false) },
)
} else {
SplitMonthCollapsed(
@@ -1373,10 +1457,13 @@ private fun SplitMonthContent(
showWeekNumbers = showWeekNumbers,
expandDrag = expandDrag,
onSelectDay = onSelectDay,
onPaneSwipeNext = onPaneSwipeNext,
onPaneSwipePrev = onPaneSwipePrev,
onClearSelection = onClearSelection,
onOpenDay = onOpenDay,
onEventClick = onEventClick,
onCreateEvent = onCreateEvent,
onExpand = { expanded = true },
onExpand = { onExpandedChange(true) },
)
}
}
@@ -1398,10 +1485,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 +1530,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 +1605,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,
@@ -1542,7 +1678,7 @@ private fun SplitExpandHandle(
* 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_ROW_HEIGHT = 48.dp
/** Vertical padding and row gap of [SplitMonthGrid], which [splitGridHeight] pays for. */
private val SPLIT_GRID_VERTICAL_PADDING = 4.dp
@@ -1581,13 +1717,15 @@ 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,
) {
val dark = isSystemInDarkTheme()
val month = state.month
val hasToday = state.today.year == month.year && state.today.month == month.month
val ripple = rememberTodayRipple(active = hasToday)
Column(
modifier = modifier
.fillMaxWidth()
@@ -1595,6 +1733,7 @@ internal fun SplitMonthGrid(
verticalArrangement = Arrangement.spacedBy(SPLIT_ROW_GAP),
) {
state.weeks.forEach { week ->
val todayCol = week.days.indexOf(state.today).takeIf { hasToday && it >= 0 }
Row(Modifier.fillMaxWidth().height(SPLIT_ROW_HEIGHT)) {
if (showWeekNumbers) {
WeekNumberGutter(
@@ -1625,6 +1764,8 @@ internal fun SplitMonthGrid(
// page transition.
isSelected = day == selected && inMonth,
inMonth = inMonth,
relativeToToday = todayCol?.let { col - it },
rippleProgress = { ripple.value },
dark = dark,
onClick = { onSelectDay(day) },
modifier = Modifier.weight(1f).fillMaxHeight(),
@@ -1651,6 +1792,8 @@ private fun SplitDayCell(
isToday: Boolean,
isSelected: Boolean,
inMonth: Boolean,
relativeToToday: Int?,
rippleProgress: () -> Float,
dark: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
@@ -1659,6 +1802,7 @@ private fun SplitDayCell(
// the whole cell, which read as a second state on top of the day rather than
// as a mark on it — and fought today's own circle when they coincided.
val background = when {
isToday && inMonth -> MaterialTheme.colorScheme.primaryContainer
inMonth -> MaterialTheme.colorScheme.surfaceContainer
else -> MaterialTheme.colorScheme.surfaceContainerLow
}
@@ -1691,6 +1835,7 @@ private fun SplitDayCell(
Box(
modifier = modifier
.padding(horizontal = CELL_GAP, vertical = 1.dp)
.todayRipple(relativeToToday, rippleProgress)
.selectable(selected = isSelected, onClick = onClick),
contentAlignment = Alignment.TopCenter,
) {
@@ -1816,6 +1961,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
@@ -2039,6 +2232,8 @@ private fun MonthWeekRow(
selected: LocalDate? = null,
) {
val dark = isSystemInDarkTheme()
val todayCol = week.days.indexOf(today).takeIf { it >= 0 && inMonth(today) }
val ripple = rememberTodayRipple(active = todayCol != null)
val overflowRow = rememberOverflowRowHeight()
val laneCap = week.laneCapFor(rowHeight, overflowRow)
val laneCount = (week.spans.maxOfOrNull { it.lane } ?: -1) + 1
@@ -2158,15 +2353,17 @@ private fun MonthWeekRow(
// Spanning bars draw on top of these, bridging cells, so they still read
// as one continuous event.
Row(Modifier.matchParentSize()) {
week.days.forEach { d ->
week.days.forEachIndexed { col, d ->
Box(
Modifier
.weight(1f)
.fillMaxHeight()
.padding(horizontal = CELL_GAP, vertical = 1.dp)
.morphElement(MonthMorphKey.Cell(d))
.todayRipple(todayCol?.let { col - it }) { ripple.value }
.background(
color = when {
d == today && inMonth(d) -> MaterialTheme.colorScheme.primaryContainer
inMonth(d) -> MaterialTheme.colorScheme.surfaceContainer
// A blanked cell draws nothing at all: it is
// the gap that shows where the month starts.
@@ -2181,7 +2378,7 @@ private fun MonthWeekRow(
Column(Modifier.fillMaxSize().padding(top = CELL_TOP_PADDING)) {
Row(Modifier.fillMaxWidth()) {
week.days.forEach { d ->
week.days.forEachIndexed { col, d ->
if (blankOutside && !inMonth(d)) {
Spacer(Modifier.weight(1f).height(DAY_NUMBER_HEIGHT))
} else {
@@ -2198,7 +2395,8 @@ private fun MonthWeekRow(
},
modifier = Modifier
.weight(1f)
.morphElement(MonthMorphKey.DayNumber(d)),
.morphElement(MonthMorphKey.DayNumber(d))
.todayRipple(todayCol?.let { col - it }) { ripple.value },
)
}
}
@@ -2313,6 +2511,7 @@ private fun MonthWeekRow(
.morphBounds(MonthMorphKey.Event(d, ev.instanceId))
.width(colW)
.height(EVENT_ROW_HEIGHT)
.todayRipple(todayCol?.let { col - it }) { ripple.value }
.padding(horizontal = MONTH_CHIP_INSET, vertical = 1.dp),
)
}
@@ -2546,8 +2745,13 @@ private fun monthChipDragModifier(
private fun WeekNumberGutter(weekStart: LocalDate, modifier: Modifier = Modifier) {
val weekNumber = remember(weekStart) { weekStart.toJavaLocalDate().isoWeekNumber() }
val label = stringResource(R.string.week_number_label)
val openLabel = stringResource(R.string.month_open_week, weekNumber)
val onOpenWeek = LocalOpenWeek.current
Box(
modifier = modifier
.then(if (onOpenWeek != null) Modifier.clickable(onClickLabel = openLabel) {
onOpenWeek(weekStart)
} else Modifier)
.padding(horizontal = CELL_GAP, vertical = 1.dp)
.background(MaterialTheme.colorScheme.secondaryContainer, CELL_SHAPE)
.semantics { contentDescription = "$label $weekNumber" },
@@ -13,6 +13,7 @@ import de.jeanlucmakiola.calendula.domain.FailureReason
import de.jeanlucmakiola.calendula.domain.calendarListFailure
import de.jeanlucmakiola.calendula.ui.common.PageStateCache
import de.jeanlucmakiola.calendula.domain.isDeclined
import de.jeanlucmakiola.calendula.domain.eventDisplayComparator
import de.jeanlucmakiola.calendula.ui.week.coversDay
import de.jeanlucmakiola.calendula.ui.week.layoutAllDay
import de.jeanlucmakiola.calendula.ui.week.spansMultipleDays
@@ -292,12 +293,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<LocalDate> = _selectedDate
private val _selectedDate = MutableStateFlow<LocalDate?>(null)
val selectedDate: StateFlow<LocalDate?> = _selectedDate
/**
* Select [date], following it to its month if it sits in the grid's leading
@@ -305,23 +304,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 +339,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). */
@@ -499,11 +507,7 @@ internal fun instancesByDay(
days.associateWith { day ->
instances
.filter { it.coversDay(day, zone) }
.sortedWith(
compareBy<EventInstance> { it.isDeclined }
.thenByDescending { it.isAllDay }
.thenBy { it.start },
)
.sortedWith(eventDisplayComparator(zone))
}
/**
@@ -544,11 +548,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 {
@@ -199,23 +199,33 @@ fun WeekScreen(
quickSwitchViews: List<CalendarView> = IMPLEMENTED_VIEWS,
drawerViewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
todayInToolbar: Boolean = false,
initialDateIso: String? = null,
onInitialDateConsumed: () -> Unit = {},
modifier: Modifier = Modifier,
viewModel: WeekViewModel = hiltViewModel(),
) = PagedTimelineScreen(
viewModel = viewModel,
showWeekNumber = true,
selectedView = selectedView,
onSelectView = onSelectView,
onOpenDay = onOpenDay,
onEventClick = onEventClick,
onOpenSettings = onOpenSettings,
onOpenSearch = onOpenSearch,
onCreateEvent = onCreateEvent,
quickSwitchViews = quickSwitchViews,
drawerViewOrder = drawerViewOrder,
todayInToolbar = todayInToolbar,
modifier = modifier,
)
) {
LaunchedEffect(initialDateIso) {
initialDateIso?.let {
viewModel.goToDate(LocalDate.parse(it))
onInitialDateConsumed()
}
}
PagedTimelineScreen(
viewModel = viewModel,
showWeekNumber = true,
selectedView = selectedView,
onSelectView = onSelectView,
onOpenDay = onOpenDay,
onEventClick = onEventClick,
onOpenSettings = onOpenSettings,
onOpenSearch = onOpenSearch,
onCreateEvent = onCreateEvent,
quickSwitchViews = quickSwitchViews,
drawerViewOrder = drawerViewOrder,
todayInToolbar = todayInToolbar,
modifier = modifier,
)
}
/** The multi-day view (#193): the week view's timeline over the day count from Settings. */
@Composable
@@ -9,9 +9,9 @@ import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.displayPriority
import de.jeanlucmakiola.calendula.domain.FailureReason
import de.jeanlucmakiola.calendula.domain.calendarListFailure
import de.jeanlucmakiola.calendula.domain.isDeclined
import de.jeanlucmakiola.calendula.ui.common.PageStateCache
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.ExperimentalCoroutinesApi
@@ -150,6 +150,7 @@ abstract class PagedTimelineViewModel(
}
fun goToToday() = jumpTo(todayDate)
fun goToDate(date: LocalDate) = jumpTo(date)
private fun buildState(
span: DaySpan,
@@ -227,7 +228,13 @@ internal fun layoutAllDay(
}
// Declined bars are packed after every other one, so on any day they
// cover they land in a lane below it (#230).
.sortedWith(compareBy({ it.event.isDeclined }, { it.startCol }, { it.endCol }))
.sortedWith(
compareBy<Raw> { it.event.displayPriority(zone) }
.thenBy { it.startCol }
.thenBy { it.endCol }
.thenBy { it.event.start }
.thenBy { it.event.instanceId },
)
// What each lane already holds, rather than just how far right it reaches.
// A single "last occupied column" only answers correctly while spans arrive
+7 -5
View File
@@ -1,10 +1,10 @@
<resources>
<string name="app_name">Calendula</string>
<string name="app_tagline">A modern calendar.</string>
<!-- Alternative launcher label offered by the "App name" setting (issue #44).
translatable="false": this string becomes a launcher component's label, so
it must never be changed by an unreviewed community translation. -->
<string name="app_name_calendar_alias" translatable="false">Calendar</string>
<!-- Alternative launcher label offered by the "App name" setting (issue #44),
shown under the app icon. Translate it as the plain, generic word your
language uses for a calendar app (e.g. "Kalender"), nothing else (#251). -->
<string name="app_name_calendar_alias">Calendar</string>
<!-- Loading / Failure / Success generic strings (used across screens) -->
<string name="state_loading">Loading…</string>
@@ -54,7 +54,9 @@
<!-- Month view (S1) -->
<string name="month_prev">Previous month</string>
<string name="month_next">Next month</string>
<string name="month_open_week">Open week %1$d</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_open_menu">Open menu</string>
<string name="month_action_settings">Settings</string>
@@ -550,7 +552,7 @@
<string name="settings_month_header">Month view</string>
<string name="settings_month_view_style">Month view style</string>
<string name="month_style_paged">Pages</string>
<string name="month_style_paged_summary">One month fills the screen. Swipe left or right to change month.</string>
<string name="month_style_paged_summary">One month fills the screen. Swipe left or right to change month, or swipe up to show the month agenda.</string>
<string name="month_style_continuous">Scrolling months</string>
<string name="month_style_continuous_summary">Each month sits under its own heading, with a little space setting it apart from the next.</string>
<string name="month_style_dense">Seamless weeks</string>
@@ -77,6 +77,16 @@ class GroupAgendaDaysTest {
.containsExactly(LocalDate(2026, 7, 2))
}
@Test
fun `multi-day events precede single all-day and timed events`() {
val multi = event(1, "Multi", at(2026, 7, 1), at(2026, 7, 4), isAllDay = true)
val single = event(2, "Single", at(2026, 7, 2), at(2026, 7, 3), isAllDay = true)
val timed = event(3, "Timed", at(2026, 7, 2, 8), at(2026, 7, 2, 9))
val second = days(listOf(timed, single, multi)).first { it.date == LocalDate(2026, 7, 2) }
assertThat(second.events.map { it.title })
.containsExactly("Multi", "Single", "Timed").inOrder()
}
@Test
fun `an event ending exactly at midnight does not reach the next day`() {
val e = event(1, "late", at(2026, 7, 1, 22, 0), at(2026, 7, 2, 0, 0))
@@ -65,6 +65,14 @@ class ViewBackStackTest {
assertThat(stack.drillToDay()).isEqualTo(stack)
}
@Test
fun `week number drills into week and back returns to month`() {
val stack = listOf(CalendarView.Month).drillToWeek()
assertThat(stack).containsExactly(CalendarView.Month, CalendarView.Week).inOrder()
assertThat(stack.dropLast(1).last()).isEqualTo(CalendarView.Month)
assertThat(stack.drillToWeek()).isEqualTo(stack)
}
@Test
fun `agenda widget date tap backs out agenda then home`() {
// openDate(source = Agenda): root in Agenda over the Week home, then drill.
@@ -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))
}
}
@@ -163,6 +163,18 @@ class MonthLayoutTest {
assertThat(week.spans.map { it.lane }).containsExactly(0, 1)
}
@Test
fun `multi-day bar is above a one-day all-day bar on the same date`() {
val day = LocalDate(2026, 6, 10)
val single = allDay(day, id = 1L, title = "Single")
val multi = allDay(day, day.plus(2, DateTimeUnit.DAY), id = 2L, title = "Multi")
val week = layoutCalendarWeek(weekOf8th, listOf(single, multi), zone)
val lanes = week.spans.associate { it.event.title to it.lane }
assertThat(lanes.getValue("Multi")).isLessThan(lanes.getValue("Single"))
assertThat(instancesByDay(listOf(day), listOf(single, multi), zone).getValue(day).map { it.title })
.containsExactly("Multi", "Single").inOrder()
}
@Test
fun `single-day timed events stay pills and sort by start`() {
val late = timed(LocalDate(2026, 6, 10), 14, 15, id = 1L)