Compare commits

...

4 Commits

Author SHA1 Message Date
0df420e551 feat(month): collapse the expanded month on back (#53)
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. Without this, back left the
Month view entirely and coming back found it still expanded.

Declared deeper than CalendarHost's view-stack handler, which is what makes
it take precedence while it is enabled.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 19:37:44 +02:00
488de490f9 feat(month): morph the split style's dots into the expanded grid (#53)
The expand cross-faded two unrelated grids, so a day's cell jumped from a
46dp row near the top of the screen to a full-height one halfway down and
the dots simply vanished where bars appeared. It read as a swap, not as the
month opening up.

Tag the pieces that mean the same thing on both sides and let Compose carry
them: each cell pill grows, its number rides along, and every dot travels
out to the bar it stood for — which only works because the dots are now
seated by lane, so each one has exactly one bar to become.

The tags travel as a composition local with a null default, so the paged,
continuous, and dense styles — which share these same row and cell
composables — provide nothing and pay nothing. Reduced motion provides
nothing either, leaving the plain cross-fade underneath.

A multi-day bar is anchored on the day it starts in its row; the dots on
the days it merely covers are left unmatched and fade where they stand
while the bar sweeps out over them. Only one of them could become the bar
without the others teleporting into it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 19:36:13 +02:00
f15fffa799 feat(month): let the split style pull open to the full month (#53)
Drag the compact grid down and the day pane gives way to the month drawn
the paged style's way — real bars and pills, not dots; drag up to bring the
pane back. Split could show you which days were busy, or one day in full,
but never the month's events at once without a trip to Settings.

The month swipe grows a second axis to do it, so the two are locked to one
decision per gesture: independent horizontal and vertical detectors would
each see their own component of a diagonal drag and both fire. Paging keeps
ties, so an ambiguous drag still reads as it did.

A tap in the expanded grid picks the day and drops back, which makes it a
chooser you dip into rather than a mode you can get stranded in — so the
grid needs the selection outline too, which MonthGrid now takes optionally.

Expansion is state, not a preference: persisted, someone would expand it
once and later find their Split style changed with nothing to explain why.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 19:30:37 +02:00
1499b5803d fix(month): seat the split style's dots by lane (#53)
The dots gathered a day's *distinct colours*, which meant dot i stood for
no particular event and two events sharing a calendar collapsed into one —
undercounting the day.

Seat them off the same lane assignment the paged grid draws from, so a dot
and the bar in that lane are the same event. That fixes the count, and it
is what will let one morph into the other when the grid expands.

The cap is now MAX_EVENT_ROWS rather than a constant of its own: the two
have to agree or a dot would have no bar to become.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 19:26:57 +02:00
5 changed files with 667 additions and 84 deletions

View File

@@ -0,0 +1,98 @@
package de.jeanlucmakiola.calendula.ui.month
import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.animation.SharedTransitionScope
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.runtime.Composable
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.ui.Modifier
import kotlinx.datetime.LocalDate
/**
* What lets the split style's compact grid *become* the full month rather than be
* swapped for it (#53).
*
* The two grids are separate composables — the compact one draws dots, the
* expanded one draws the paged style's bars and pills — so nothing about them is
* shared by construction. Tagging the pieces that mean the same thing on both
* sides with the same [MonthMorphKey] hands Compose enough to animate one into
* the other: a day's cell grows, its number rides along, and each dot travels out
* to the bar it stood for.
*
* It travels as a composition local rather than as parameters because the pieces
* sit five levels below where the scopes exist, and because a null default is
* exactly the right meaning for everyone else: the paged, continuous, and dense
* styles share the same row and cell composables, provide nothing, and pay
* nothing. Reduced motion provides nothing either, which leaves the plain
* cross-fade underneath.
*/
internal sealed interface MonthMorphKey {
/** A day's background pill — the structural anchor the rest rides on. */
data class Cell(val date: LocalDate) : MonthMorphKey
data class DayNumber(val date: LocalDate) : MonthMorphKey
/**
* One event *on one day*. Keyed by date as well as instance because a
* multi-day event has a dot on every day it covers but only one bar, drawn
* from where it starts: the start day's dot is the one that becomes the bar,
* and the rest are left unmatched on purpose. They fade where they stand
* while the bar sweeps out over them, which is the honest reading — one of
* them could not become the bar without the others teleporting into it.
*/
data class Event(val date: LocalDate, val instanceId: Long) : MonthMorphKey
/** The grab handle, which slides from the pane's seam to the foot of the grid. */
data object Handle : MonthMorphKey
}
@OptIn(ExperimentalSharedTransitionApi::class)
internal class MonthMorphScope(
val shared: SharedTransitionScope,
val visibility: AnimatedVisibilityScope,
)
internal val LocalMonthMorph = compositionLocalOf<MonthMorphScope?> { null }
/**
* Tag content that is *the same thing* on both sides — a background pill, a day
* number — so it animates between its two positions and sizes.
*/
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
internal fun Modifier.morphElement(key: MonthMorphKey): Modifier {
val morph = LocalMonthMorph.current ?: return this
return with(morph.shared) {
this@morphElement.sharedElement(
sharedContentState = rememberSharedContentState(key),
animatedVisibilityScope = morph.visibility,
)
}
}
/**
* Tag content that means the same thing but *is drawn differently* on each side —
* a 5dp dot and a titled bar — so only the bounds are shared and the contents
* cross-fade inside them.
*
* [ResizeMode.RemeasureToBounds][SharedTransitionScope.ResizeMode] rather than
* scaling: a bar's title laid out at the dot's 5dp and then scaled up would
* arrive as a smear. Remeasuring keeps the text at its real size throughout and
* simply clips it while there is no room, so what grows is the pill, not the type.
*/
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
internal fun Modifier.morphBounds(key: MonthMorphKey): Modifier {
val morph = LocalMonthMorph.current ?: return this
return with(morph.shared) {
this@morphBounds.sharedBounds(
sharedContentState = rememberSharedContentState(key),
animatedVisibilityScope = morph.visibility,
enter = fadeIn(),
exit = fadeOut(),
resizeMode = SharedTransitionScope.ResizeMode.RemeasureToBounds,
)
}
}

View File

@@ -1,6 +1,9 @@
package de.jeanlucmakiola.calendula.ui.month
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.animation.SharedTransitionLayout
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.snap
@@ -8,12 +11,13 @@ import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
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.gestures.detectDragGestures
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -60,16 +64,18 @@ import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.input.pointer.pointerInput
@@ -124,6 +130,7 @@ import kotlinx.datetime.plus
import kotlinx.datetime.YearMonth
import kotlinx.datetime.toJavaLocalDate
import kotlinx.datetime.toLocalDateTime
import kotlin.math.abs
import kotlin.time.Clock
import java.time.format.TextStyle as JavaTextStyle
import java.util.Locale
@@ -573,6 +580,12 @@ internal fun MonthGrid(
state: MonthUiState.Success,
showWeekNumbers: Boolean,
onOpenDay: (LocalDate) -> Unit,
/**
* Outlined when set, matching the compact grid's marker. Only the split
* style's expanded form passes one — the paged style has no selection, and
* marking a day there would invent a state it doesn't have (#53).
*/
selected: LocalDate? = null,
) {
Column(
modifier = Modifier
@@ -591,6 +604,7 @@ internal fun MonthGrid(
inMonth = { it.month == month.month && it.year == month.year },
showWeekNumbers = showWeekNumbers,
onOpenDay = onOpenDay,
selected = selected,
modifier = Modifier
.fillMaxWidth()
.weight(1f),
@@ -781,35 +795,79 @@ internal fun DenseMonthGrid(
}
}
/** Which way a drag went, decided once per gesture and then held. */
private enum class DragAxis { Undecided, Horizontal, Vertical }
/**
* The month-changing horizontal swipe, shared by the paged and split styles.
* Accumulates the drag and commits past a threshold on release — the grid
* doesn't follow the finger, so there is no distance to rubber-band against.
* The month grid's drag gesture: horizontal pages the month, vertical expands or
* collapses the split style (#53). Accumulates and commits past a threshold on
* release — the grid doesn't follow the finger, so there is no distance to
* rubber-band against.
*
* The threshold matches the week and day views'. It used to be 6dp, which is
* inside the distance a tap wanders: brushing the grid changed the month, and a
* page that turns on an unintended gesture reads as the animation misfiring
* rather than as the gesture being over-eager.
* The axis is **locked on the first movement and held for the whole gesture**, so
* a drag can page or expand but never both. Two independent detectors on one
* surface would each see their own component of a diagonal drag and both fire.
*
* The horizontal threshold matches the week and day views'. It used to be 6dp,
* which is inside the distance a tap wanders: brushing the grid changed the
* month, and a page that turns on an unintended gesture reads as the animation
* misfiring rather than as the gesture being over-eager. The vertical one is
* larger — swapping the whole layout out deserves a more deliberate pull than
* stepping to the next month.
*
* [onExpand]/[onCollapse] are null for the paged style, which leaves the vertical
* axis unclaimed: the lock still happens, so a vertical drag there does nothing
* rather than being re-read as a page turn.
*/
@Composable
private fun rememberMonthSwipeModifier(
onSwipeNext: () -> Unit,
onSwipePrev: () -> Unit,
onExpand: (() -> Unit)? = null,
onCollapse: (() -> Unit)? = null,
): Modifier {
val threshold = with(LocalDensity.current) { MONTH_SWIPE_THRESHOLD.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
val density = LocalDensity.current
val pageThreshold = with(density) { MONTH_SWIPE_THRESHOLD.toPx() }
val expandThreshold = with(density) { MONTH_EXPAND_THRESHOLD.toPx() }
return Modifier.pointerInput(onSwipeNext, onSwipePrev, onExpand, onCollapse) {
var accum = Offset.Zero
var axis = DragAxis.Undecided
detectDragGestures(
onDragStart = {
accum = Offset.Zero
axis = DragAxis.Undecided
},
onDragEnd = {
when (axis) {
DragAxis.Horizontal -> when {
accum.x < -pageThreshold -> onSwipeNext()
accum.x > pageThreshold -> onSwipePrev()
}
DragAxis.Vertical -> when {
accum.y > expandThreshold -> onExpand?.invoke()
accum.y < -expandThreshold -> onCollapse?.invoke()
}
DragAxis.Undecided -> Unit
}
accum = Offset.Zero
axis = DragAxis.Undecided
},
onDragCancel = {
accum = Offset.Zero
axis = DragAxis.Undecided
},
onDrag = { _, drag ->
accum += drag
if (axis == DragAxis.Undecided) {
// Ties go horizontal, keeping paging the default reading of an
// ambiguous drag as it was before the vertical axis existed.
axis = if (abs(accum.x) >= abs(accum.y)) {
DragAxis.Horizontal
} else {
DragAxis.Vertical
}
}
},
onDragCancel = { dragAccum = 0f },
onHorizontalDrag = { _, drag -> dragAccum += drag },
)
}
}
@@ -817,15 +875,26 @@ private fun rememberMonthSwipeModifier(
/** Drag distance that commits a month change, matching the week and day views. */
private val MONTH_SWIPE_THRESHOLD = 24.dp
/** Drag distance that commits an expand/collapse — deliberately longer than a page. */
private val MONTH_EXPAND_THRESHOLD = 48.dp
/**
* Split style content: the compact grid keeps the month swipe, the pane below it
* lists whatever day is selected.
* lists whatever day is selected — and a downward drag trades the pane away for
* the full paged grid, an upward one brings it back (#53).
*
* The grid slides between months like the paged style, which it can only do
* because it always reserves [SPLIT_GRID_ROWS] rows. Sized to its own month it
* stood 46 rows tall, so every swipe shunted the pane up or down by a row on
* top of swapping the grid — the pane now holds still and only the grid moves.
*
* Expansion is deliberately **not** a stored preference. It is a way to look at
* the month you are on, not a fourth style; persisted, someone would expand it
* once and later find their Split style permanently changed with nothing on
* screen to explain why. [rememberSaveable] carries it across a rotation, which
* is as long as it should live.
*/
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
private fun SplitMonthContent(
state: MonthUiState,
@@ -840,56 +909,248 @@ private fun SplitMonthContent(
onEventClick: (EventInstance) -> Unit,
onCreateEvent: (LocalDate) -> Unit,
) {
val slideSpec = rememberCalendarSlideSpec()
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 }
// The swipe wraps the grid rather than living inside it: mid-transition
// there are two grids, and the gesture belongs to neither. The pane is left
// out of it — it scrolls and is full of tappable rows.
val swipeModifier = rememberMonthSwipeModifier(onSwipeNext, onSwipePrev)
val swipeModifier = rememberMonthSwipeModifier(
onSwipeNext = onSwipeNext,
onSwipePrev = onSwipePrev,
onExpand = { expanded = true },
onCollapse = { expanded = false },
)
when (state) {
MonthUiState.Loading -> MonthGridLoading()
is MonthUiState.Failure -> CalendarFailure(reason = state.reason, onRetry = onRetry)
is MonthUiState.Success -> Column(Modifier.fillMaxSize()) {
is MonthUiState.Success -> SharedTransitionLayout(Modifier.fillMaxSize()) {
AnimatedContent(
// The selection travels *with* the state so each page keeps its
// own. Read from outside, both pages would show the incoming
// one, and paging visibly threw the marker across the outgoing
// grid — onto the new month's 1st, which the old grid still
// shows among its trailing days — before the new page arrived.
targetState = state to selected,
modifier = swipeModifier,
// Keyed on the month alone, so a provider notification refreshing
// the month you are on — or a tap moving the selection within it
// — updates in place instead of sliding.
contentKey = { (s, _) -> s.month },
transitionSpec = {
calendarSlideTransition(slideDir, slideSpec, fadeSpec, reduceMotion)
},
label = "split-month-transition",
) { (s, sel) ->
SplitMonthGrid(
state = s,
selected = sel,
showWeekNumbers = showWeekNumbers,
onSelectDay = onSelectDay,
)
targetState = expanded,
modifier = Modifier.fillMaxSize(),
// Both branches fill the same box — the grid grows into exactly the
// room the pane gives up — so there is no size change to contain.
// The travel between them is the shared elements' job, not a slide.
transitionSpec = { fadeIn(fadeSpec) togetherWith fadeOut(fadeSpec) },
label = "split-expand-transition",
) { isExpanded ->
CompositionLocalProvider(
// Null under reduced motion: nothing is tagged, nothing
// travels, and the cross-fade above is the whole transition.
LocalMonthMorph provides if (reduceMotion) {
null
} else {
MonthMorphScope(this@SharedTransitionLayout, this@AnimatedContent)
},
) {
SplitMonthBody(
expanded = isExpanded,
state = state,
selected = selected,
slideDir = slideDir,
showWeekNumbers = showWeekNumbers,
swipeModifier = swipeModifier,
onSelectDay = onSelectDay,
onOpenDay = onOpenDay,
onEventClick = onEventClick,
onCreateEvent = onCreateEvent,
onSetExpanded = { expanded = it },
)
}
}
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(),
}
}
}
/** The two faces of the split style, sharing one set of morph tags. */
@Composable
private fun SplitMonthBody(
expanded: Boolean,
state: MonthUiState.Success,
selected: LocalDate,
slideDir: Int,
showWeekNumbers: Boolean,
swipeModifier: Modifier,
onSelectDay: (LocalDate) -> Unit,
onOpenDay: (LocalDate) -> Unit,
onEventClick: (EventInstance) -> Unit,
onCreateEvent: (LocalDate) -> Unit,
onSetExpanded: (Boolean) -> Unit,
) {
if (expanded) {
SplitMonthExpanded(
state = state,
selected = selected,
slideDir = slideDir,
showWeekNumbers = showWeekNumbers,
swipeModifier = swipeModifier,
// A tap in the expanded grid picks the day and drops back, which
// gives the expanded month a job — a chooser you dip into — rather
// than a mode you can get stranded in. The collapse then runs with
// the selection already set, so the pane arrives showing the day
// you picked.
onPickDay = {
onSelectDay(it)
onSetExpanded(false)
},
onCollapse = { onSetExpanded(false) },
)
} else {
SplitMonthCollapsed(
state = state,
selected = selected,
slideDir = slideDir,
showWeekNumbers = showWeekNumbers,
swipeModifier = swipeModifier,
onSelectDay = onSelectDay,
onOpenDay = onOpenDay,
onEventClick = onEventClick,
onCreateEvent = onCreateEvent,
onExpand = { onSetExpanded(true) },
)
}
}
/** The split style at rest: compact grid, handle, then the selected day's events. */
@Composable
private fun SplitMonthCollapsed(
state: MonthUiState.Success,
selected: LocalDate,
slideDir: Int,
showWeekNumbers: Boolean,
swipeModifier: Modifier,
onSelectDay: (LocalDate) -> Unit,
onOpenDay: (LocalDate) -> Unit,
onEventClick: (EventInstance) -> Unit,
onCreateEvent: (LocalDate) -> Unit,
onExpand: () -> Unit,
) {
val slideSpec = rememberCalendarSlideSpec()
val fadeSpec = rememberCalendarFadeSpec()
val reduceMotion = rememberReduceMotion()
Column(Modifier.fillMaxSize()) {
AnimatedContent(
// The selection travels *with* the state so each page keeps its
// own. Read from outside, both pages would show the incoming
// one, and paging visibly threw the marker across the outgoing
// grid — onto the new month's 1st, which the old grid still
// shows among its trailing days — before the new page arrived.
targetState = state to selected,
modifier = swipeModifier,
// Keyed on the month alone, so a provider notification refreshing
// the month you are on — or a tap moving the selection within it
// — updates in place instead of sliding.
contentKey = { (s, _) -> s.month },
transitionSpec = {
calendarSlideTransition(slideDir, slideSpec, fadeSpec, reduceMotion)
},
label = "split-month-transition",
) { (s, sel) ->
SplitMonthGrid(
state = s,
selected = sel,
showWeekNumbers = showWeekNumbers,
onSelectDay = onSelectDay,
)
}
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(),
)
}
}
/**
* The split style pulled open: the pane is gone and the month gets the whole
* screen in the paged style's own vocabulary — real event bars and pills instead
* of dots. The handle stays, now at the foot of the grid, to pull it back.
*/
@Composable
private fun SplitMonthExpanded(
state: MonthUiState.Success,
selected: LocalDate,
slideDir: Int,
showWeekNumbers: Boolean,
swipeModifier: Modifier,
onPickDay: (LocalDate) -> Unit,
onCollapse: () -> Unit,
) {
val slideSpec = rememberCalendarSlideSpec()
val fadeSpec = rememberCalendarFadeSpec()
val reduceMotion = rememberReduceMotion()
Column(Modifier.fillMaxSize()) {
AnimatedContent(
targetState = state to selected,
modifier = Modifier.weight(1f).then(swipeModifier),
contentKey = { (s, _) -> s.month },
transitionSpec = {
calendarSlideTransition(slideDir, slideSpec, fadeSpec, reduceMotion)
},
label = "split-expanded-month-transition",
) { (s, sel) ->
MonthGrid(
state = s,
showWeekNumbers = showWeekNumbers,
onOpenDay = onPickDay,
selected = sel,
)
}
SplitExpandHandle(expanded = true, onToggle = onCollapse)
}
}
/**
* The grab handle at the seam between grid and pane — the same M3 drag-handle
* pill a bottom sheet uses, for the same reason: it advertises that the surface
* moves.
*
* The drag itself lives on the grid, not here. This exists so the gesture is
* findable at all, and it takes taps too — a hidden swipe is no use to someone
* who never tries it, or who can't make the gesture.
*/
@Composable
private fun SplitExpandHandle(
expanded: Boolean,
onToggle: () -> Unit,
modifier: Modifier = Modifier,
) {
val label = stringResource(
if (expanded) R.string.month_split_collapse else R.string.month_split_expand,
)
Box(
modifier = modifier
.fillMaxWidth()
.height(SPLIT_HANDLE_ROW_HEIGHT)
.clickable(onClick = onToggle)
.semantics { contentDescription = label },
contentAlignment = Alignment.Center,
) {
Box(
Modifier
.size(width = SPLIT_HANDLE_WIDTH, height = SPLIT_HANDLE_HEIGHT)
// Tagged too, so it slides from the pane's seam down to the foot
// of the grid rather than blinking out at one and in at the other.
.morphElement(MonthMorphKey.Handle)
.background(MaterialTheme.colorScheme.outlineVariant, CircleShape),
)
}
}
@@ -902,7 +1163,9 @@ private fun SplitMonthContent(
*/
private val SPLIT_ROW_HEIGHT = 46.dp
private val SPLIT_DOT_SIZE = 5.dp
private const val SPLIT_MAX_DOTS = 3
// Dots are capped by MAX_EVENT_ROWS, not a constant of their own: they stand for
// the paged grid's lanes, so the two caps have to be the same number or a dot
// would have no bar to become (#53).
/**
* Rows the split grid always reserves — the most any month needs. A month that
@@ -911,6 +1174,14 @@ private const val SPLIT_MAX_DOTS = 3
*/
private const val SPLIT_GRID_ROWS = 6
/**
* The expand handle: M3's drag-handle pill (32×4dp), in a row tall enough to be a
* comfortable tap target on its own.
*/
private val SPLIT_HANDLE_WIDTH = 32.dp
private val SPLIT_HANDLE_HEIGHT = 4.dp
private val SPLIT_HANDLE_ROW_HEIGHT = 24.dp
/**
* The split style's grid (#53): the month compressed to day numbers and event
* dots, with the selected day listed underneath by [SplitDayPane].
@@ -945,11 +1216,15 @@ internal fun SplitMonthGrid(
.fillMaxHeight(),
)
}
week.days.forEach { day ->
week.days.forEachIndexed { col, day ->
val inMonth = day.month == month.month && day.year == month.year
// Seated by lane rather than gathered by colour, so each dot
// is the event the expanded grid draws in that same lane.
val seated = week.laneEvents(col, day, MAX_EVENT_ROWS)
SplitDayCell(
date = day,
events = state.instancesByDay[day].orEmpty(),
events = seated,
hidden = (week.countByDay[day] ?: 0) - seated.size,
isToday = day == state.today,
// A page marks only the days its own month owns. Paging
// moves the selection before this month's replacement
@@ -979,7 +1254,7 @@ internal fun SplitMonthGrid(
}
/**
* One compact day: its number over up to [SPLIT_MAX_DOTS] event dots.
* One compact day: its number over up to [MAX_EVENT_ROWS] lane-seated 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
@@ -989,6 +1264,8 @@ internal fun SplitMonthGrid(
private fun SplitDayCell(
date: LocalDate,
events: List<EventInstance>,
/** Events on this day that didn't fit a lane, shown as a "+N" beside the dots. */
hidden: Int,
isToday: Boolean,
isSelected: Boolean,
inMonth: Boolean,
@@ -1013,19 +1290,28 @@ private fun SplitDayCell(
animationSpec = if (reduceMotion) snap() else fadeSpec,
label = "split-day-selection",
)
// Background and content are separate layers, mirroring how the paged row is
// built — and so the pill can be tagged for the morph on its own. Tagged as
// one piece with its contents inside, the dots would be nested shared
// elements within a shared element and travel twice.
Box(
modifier = modifier
.padding(horizontal = CELL_GAP, vertical = 1.dp)
.clip(CELL_SHAPE)
.background(background)
.border(
width = 1.5.dp,
color = MaterialTheme.colorScheme.primary.copy(alpha = selection),
shape = CELL_SHAPE,
)
.selectable(selected = isSelected, onClick = onClick),
contentAlignment = Alignment.TopCenter,
) {
Box(
Modifier
.fillMaxSize()
.morphElement(MonthMorphKey.Cell(date))
.clip(CELL_SHAPE)
.background(background)
.border(
width = 1.5.dp,
color = MaterialTheme.colorScheme.primary.copy(alpha = selection),
shape = CELL_SHAPE,
),
)
Column(
modifier = Modifier.fillMaxSize().padding(top = 4.dp),
horizontalAlignment = Alignment.CenterHorizontally,
@@ -1034,35 +1320,44 @@ private fun SplitDayCell(
date = date,
isToday = isToday,
inMonth = inMonth,
modifier = Modifier.fillMaxWidth(),
modifier = Modifier.fillMaxWidth().morphElement(MonthMorphKey.DayNumber(date)),
)
Spacer(Modifier.height(2.dp))
SplitDots(events = events, dark = dark)
SplitDots(date = date, events = events, hidden = hidden, dark = dark)
}
}
}
/** Up to three colour dots for a day, plus a count when more are hidden. */
/**
* One dot per seated event, in lane order, plus a "+N" for those that didn't fit.
*
* Deliberately *not* de-duplicated by colour: [events] arrives lane-seated so the
* dots line up with the bars the expanded grid draws, and collapsing two events
* that share a calendar into one dot would both undercount the day and leave a
* bar with no dot to grow out of.
*/
@Composable
private fun SplitDots(events: List<EventInstance>, dark: Boolean) {
private fun SplitDots(date: LocalDate, events: List<EventInstance>, hidden: Int, 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 ->
events.forEach { event ->
// The dot keeps its own circle and the bar its 4dp corners; they
// cross-fade inside shared bounds, and at the dot's size the two
// radii are a fraction of a pixel apart.
Box(
modifier = Modifier
.size(SPLIT_DOT_SIZE)
.background(eventFill(argb, dark, soften), CircleShape),
.morphBounds(MonthMorphKey.Event(date, event.instanceId))
.background(eventFill(event.color, dark, soften), CircleShape),
)
}
if (extra > 0) {
if (hidden > 0) {
Text(
text = "+$extra",
text = "+$hidden",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -1277,6 +1572,8 @@ private fun MonthWeekRow(
modifier: Modifier = Modifier,
blankOutside: Boolean = false,
labelMonthOnFirst: Boolean = false,
/** See [MonthGrid]'s `selected`; null for every style but expanded split. */
selected: LocalDate? = null,
) {
val dark = isSystemInDarkTheme()
val laneCount = (week.spans.maxOfOrNull { it.lane } ?: -1) + 1
@@ -1312,6 +1609,7 @@ private fun MonthWeekRow(
.weight(1f)
.fillMaxHeight()
.padding(horizontal = CELL_GAP, vertical = 1.dp)
.morphElement(MonthMorphKey.Cell(d))
.background(
color = when {
inMonth(d) -> MaterialTheme.colorScheme.surfaceContainer
@@ -1321,6 +1619,21 @@ private fun MonthWeekRow(
else -> MaterialTheme.colorScheme.surfaceContainerLow
},
shape = CELL_SHAPE,
)
// Scoped to the row's own month for the same reason the
// compact grid scopes it: a boundary week shows the
// neighbour month's dates too, and the marker belongs to
// exactly one of them.
.then(
if (d == selected && inMonth(d)) {
Modifier.border(
width = 1.5.dp,
color = MaterialTheme.colorScheme.primary,
shape = CELL_SHAPE,
)
} else {
Modifier
},
),
)
}
@@ -1343,7 +1656,9 @@ private fun MonthWeekRow(
} else {
null
},
modifier = Modifier.weight(1f),
modifier = Modifier
.weight(1f)
.morphElement(MonthMorphKey.DayNumber(d)),
)
}
}
@@ -1372,7 +1687,17 @@ private fun MonthWeekRow(
)
.width(colW * cols)
.height(EVENT_ROW_HEIGHT)
.padding(horizontal = CELL_GAP + 1.dp, vertical = 1.dp),
.padding(horizontal = CELL_GAP + 1.dp, vertical = 1.dp)
// Anchored on the day the bar starts *in this row*,
// which is where its dot sat. A bar carried in from
// the previous week starts at column 0, and column
// 0's dot is the one that grows into it.
.morphBounds(
MonthMorphKey.Event(
date = week.days[span.startCol],
instanceId = span.event.instanceId,
),
),
)
}
// Single-day timed pills + overflow, per column. Pills fill the
@@ -1400,7 +1725,8 @@ private fun MonthWeekRow(
)
.width(colW)
.height(EVENT_ROW_HEIGHT)
.padding(horizontal = CELL_GAP + 1.dp, vertical = 1.dp),
.padding(horizontal = CELL_GAP + 1.dp, vertical = 1.dp)
.morphBounds(MonthMorphKey.Event(d, ev.instanceId)),
)
}
val hidden = (week.countByDay[d] ?: 0) - occupied.size - pillsShown.size

View File

@@ -39,6 +39,34 @@ data class MonthWeek(
val countByDay: Map<LocalDate, Int>,
)
/**
* The events occupying each lane of [day] — column [col] of this week — in lane
* order, capped at [laneCap] lanes.
*
* This is the same seating [spans]/[timedByDay] get when the paged grid draws a
* week: bars keep the lane the row layout gave them, and the day's timed events
* fill whatever slots are left, top-most first. Reading it here means the split
* style's dots and the paged style's bars describe a day in the *same order*, so
* dot _i_ and lane _i_ are the same event and one can morph into the other (#53).
*
* Deriving the dots independently — by distinct colour, as they first were —
* left dot _i_ standing for no particular event, and quietly merged two events
* that shared a calendar into a single dot.
*/
fun MonthWeek.laneEvents(col: Int, day: LocalDate, laneCap: Int): List<EventInstance> {
val byLane = arrayOfNulls<EventInstance>(laneCap)
spans.forEach { span ->
if (span.lane < laneCap && col in span.startCol..span.endCol) {
byLane[span.lane] = span.event
}
}
val free = (0 until laneCap).filter { byLane[it] == null }
timedByDay[day].orEmpty().take(free.size).forEachIndexed { i, event ->
byLane[free[i]] = event
}
return byLane.filterNotNull()
}
/**
* State for the continuous style (#38): a vertical stream of *self-contained*
* months rather than one undifferentiated run of weeks. Each month is keyed by

View File

@@ -385,6 +385,8 @@
<string name="month_style_split">Split</string>
<string name="month_style_split_summary">A compact grid with dots for events, and the day you tap listed underneath.</string>
<string name="month_split_no_events">Nothing scheduled</string>
<string name="month_split_expand">Show the whole month</string>
<string name="month_split_collapse">Show the day\'s events</string>
<string name="settings_quick_switch_header">Quick-switch button</string>
<string name="settings_quick_switch_hint">Choose which views the top-right button cycles through, and drag to reorder them. Turned-off views stay reachable from the navigation menu.</string>
<string name="settings_drawer_order_header">Navigation menu</string>

View File

@@ -0,0 +1,129 @@
package de.jeanlucmakiola.calendula.ui.month
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.domain.EventInstance
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate
import kotlinx.datetime.Month
import kotlinx.datetime.TimeZone
import kotlinx.datetime.YearMonth
import kotlinx.datetime.atTime
import kotlinx.datetime.plus
import kotlinx.datetime.toInstant
import org.junit.jupiter.api.Test
/**
* What lets the split style's dots morph into the paged style's bars (#53): both
* read a day off the *same* lane seating, so dot _i_ and lane _i_ are one event.
*/
class LaneEventsTest {
private val zone = TimeZone.UTC
private val jul26 = YearMonth(2026, Month.JULY)
/** July 2026 starts on a Wednesday, so this row — Jul 612 — sits wholly inside it. */
private fun rowOfJuly6(events: List<EventInstance>) =
layoutMonthWeeks(jul26, DayOfWeek.MONDAY, events, zone)[1]
private fun allDay(from: LocalDate, toInclusive: LocalDate, id: Long, color: Int = BLUE) =
EventInstance(
instanceId = id,
eventId = id,
calendarId = 1L,
title = "A$id",
start = from.atTime(0, 0).toInstant(zone),
end = toInclusive.plus(1, DateTimeUnit.DAY).atTime(0, 0).toInstant(zone),
isAllDay = true,
color = color,
location = null,
)
private fun timed(date: LocalDate, hour: Int, id: Long, color: Int = RED) = EventInstance(
instanceId = id,
eventId = id,
calendarId = 1L,
title = "T$id",
start = date.atTime(hour, 0).toInstant(zone),
end = date.atTime(hour + 1, 0).toInstant(zone),
isAllDay = false,
color = color,
location = null,
)
@Test
fun `a bar keeps its lane and the day's timed events fill what's left`() {
val bar = allDay(LocalDate(2026, 7, 7), LocalDate(2026, 7, 9), id = 1L)
val meeting = timed(LocalDate(2026, 7, 7), hour = 9, id = 2L)
val week = rowOfJuly6(listOf(bar, meeting))
// Jul 7 is column 1 of a Monday-anchored row starting Jul 6.
assertThat(week.laneEvents(col = 1, day = LocalDate(2026, 7, 7), laneCap = 3))
.containsExactly(bar, meeting)
.inOrder()
}
@Test
fun `a day the bar misses seats its own events from lane zero`() {
val bar = allDay(LocalDate(2026, 7, 7), LocalDate(2026, 7, 9), id = 1L)
val monday = timed(LocalDate(2026, 7, 6), hour = 9, id = 2L)
val week = rowOfJuly6(listOf(bar, monday))
assertThat(week.laneEvents(col = 0, day = LocalDate(2026, 7, 6), laneCap = 3))
.containsExactly(monday)
}
@Test
fun `a multi-day bar is seated on every day it covers`() {
val bar = allDay(LocalDate(2026, 7, 7), LocalDate(2026, 7, 9), id = 1L)
val week = rowOfJuly6(listOf(bar))
(1..3).forEach { col ->
val day = LocalDate(2026, 7, 6 + col)
assertThat(week.laneEvents(col, day, laneCap = 3)).containsExactly(bar)
}
assertThat(week.laneEvents(col = 4, day = LocalDate(2026, 7, 10), laneCap = 3)).isEmpty()
}
/** The bug the colour-gathered dots had: one dot for two events on one calendar. */
@Test
fun `events sharing a colour each keep their own lane`() {
val first = timed(LocalDate(2026, 7, 6), hour = 9, id = 1L, color = RED)
val second = timed(LocalDate(2026, 7, 6), hour = 14, id = 2L, color = RED)
val week = rowOfJuly6(listOf(first, second))
assertThat(week.laneEvents(col = 0, day = LocalDate(2026, 7, 6), laneCap = 3))
.containsExactly(first, second)
.inOrder()
}
@Test
fun `seating stops at the cap and leaves the rest to the overflow count`() {
val events = (0 until 5).map { timed(LocalDate(2026, 7, 6), hour = 8 + it, id = it + 1L) }
val week = rowOfJuly6(events)
val seated = week.laneEvents(col = 0, day = LocalDate(2026, 7, 6), laneCap = 3)
assertThat(seated).hasSize(3)
assertThat(seated).containsExactlyElementsIn(events.take(3)).inOrder()
assertThat(week.countByDay[LocalDate(2026, 7, 6)]!! - seated.size).isEqualTo(2)
}
/** A bar parked below the cap is out of view, so it takes no dot with it. */
@Test
fun `a bar beyond the cap is left out`() {
val bars = (0 until 4).map {
allDay(LocalDate(2026, 7, 6), LocalDate(2026, 7, 8), id = it + 1L)
}
val week = rowOfJuly6(bars)
val seated = week.laneEvents(col = 0, day = LocalDate(2026, 7, 6), laneCap = 3)
assertThat(seated).hasSize(3)
assertThat(week.spans.filter { it.lane >= 3 }.map { it.event })
.containsNoneIn(seated)
}
private companion object {
const val BLUE = 0xFF3366CC.toInt()
const val RED = 0xFFCC3333.toInt()
}
}