Lay timeline columns out through one geometry (#184)

This commit is contained in:
2026-09-27 19:22:09 +02:00
parent 8d3d28c240
commit 6ed0b30230
4 changed files with 498 additions and 117 deletions
@@ -3,6 +3,7 @@ package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.FiniteAnimationSpec
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.snap
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialTheme
@@ -13,15 +14,19 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.layout.ParentDataModifier
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
import kotlin.math.roundToInt
/** Gap a timed block leaves to its neighbours in the column. */
val BLOCK_OUTER_INSET = 1.dp
@@ -224,9 +229,13 @@ fun BlockTimeLabel(
}
}
/** Where a timed block sits in its column, after tweening. */
/**
* Where a timed block sits in its column, after tweening: across as shares of
* the column's width, so a column resizing carries its blocks with it (#184),
* down in the timeline's own units.
*/
@Immutable
data class BlockPlacement(val x: Dp, val y: Dp, val width: Dp, val height: Dp)
data class BlockPlacement(val xFraction: Float, val widthFraction: Float, val y: Dp, val height: Dp)
/**
* A timed block's placement, tweened rather than jumped. Continuity comes from
@@ -239,17 +248,49 @@ data class BlockPlacement(val x: Dp, val y: Dp, val width: Dp, val height: Dp)
*/
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun animatedBlockPlacement(x: Dp, y: Dp, width: Dp, height: Dp): BlockPlacement {
val spec: FiniteAnimationSpec<Dp> = if (rememberReduceMotion() ||
LocalTimelineZoom.current.isPinching
) {
snap()
} else {
MaterialTheme.motionScheme.fastSpatialSpec()
fun animatedBlockPlacement(xFraction: Float, widthFraction: Float, y: Dp, height: Dp): BlockPlacement {
val still = rememberReduceMotion() || LocalTimelineZoom.current.isPinching
val dpSpec: FiniteAnimationSpec<Dp> = if (still) snap() else MaterialTheme.motionScheme.fastSpatialSpec()
val shareSpec: FiniteAnimationSpec<Float> = if (still) snap() else MaterialTheme.motionScheme.fastSpatialSpec()
val animatedX by animateFloatAsState(xFraction, shareSpec, label = "block-x")
val animatedWidth by animateFloatAsState(widthFraction, shareSpec, label = "block-width")
val animatedY by animateDpAsState(y, dpSpec, label = "block-y")
val animatedHeight by animateDpAsState(height, dpSpec, label = "block-height")
return BlockPlacement(animatedX, animatedWidth, animatedY, animatedHeight)
}
/** Seats a block across its [placement]'s share of a [LaneColumn]. */
fun Modifier.laneSlot(placement: BlockPlacement): Modifier =
then(LaneSlot(placement.xFraction, placement.widthFraction))
private data class LaneSlot(val x: Float, val width: Float) : ParentDataModifier {
override fun Density.modifyParentData(parentData: Any?): Any = this@LaneSlot
}
/**
* A day column's blocks, each measured across the share of the column its
* [laneSlot] gives it; anything else fills the column. The shares are applied
* at layout time, so the column can change width every frame without its
* blocks recomposing.
*/
@Composable
fun LaneColumn(modifier: Modifier = Modifier, content: @Composable () -> Unit) {
Layout(content = content, modifier = modifier) { measurables, constraints ->
val width = constraints.maxWidth
val loose = constraints.copy(minWidth = 0, minHeight = 0)
val placed = measurables.map { measurable ->
val slot = measurable.parentData as? LaneSlot
if (slot == null) {
measurable.measure(loose) to 0
} else {
val left = (slot.x * width).roundToInt()
val right = ((slot.x + slot.width) * width).roundToInt()
val laneWidth = (right - left).coerceAtLeast(0)
measurable.measure(loose.copy(minWidth = laneWidth, maxWidth = laneWidth)) to left
}
}
layout(width, constraints.maxHeight) {
placed.forEach { (placeable, x) -> placeable.placeRelative(x, 0) }
}
}
val animatedX by animateDpAsState(x, spec, label = "block-x")
val animatedY by animateDpAsState(y, spec, label = "block-y")
val animatedWidth by animateDpAsState(width, spec, label = "block-width")
val animatedHeight by animateDpAsState(height, spec, label = "block-height")
return BlockPlacement(animatedX, animatedY, animatedWidth, animatedHeight)
}
@@ -0,0 +1,169 @@
package de.jeanlucmakiola.calendula.ui.week
import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.key
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.unit.Dp
import de.jeanlucmakiola.calendula.domain.EventInstance
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDate
import kotlinx.datetime.daysUntil
import kotlinx.datetime.plus
import kotlin.math.roundToInt
/** A day column's place across the timeline, in pixels from its start edge. */
@Immutable
data class ColumnSlot(val left: Float, val width: Float) {
val right: Float get() = left + width
}
/**
* [date]'s column on a page showing this span across [width] pixels, with [gap]
* between columns. A date off the page carries on at the same pitch, off screen
* on its side — where a view switch slides it in from, or out to.
*/
fun DaySpan.slot(date: LocalDate, width: Float, gap: Float): ColumnSlot {
val pitch = (width + gap) / count
return ColumnSlot(left = start.daysUntil(date) * pitch, width = pitch - gap)
}
/** The last day of the span. */
val DaySpan.end: LocalDate get() = start.plus(count - 1, DateTimeUnit.DAY)
/**
* The timeline's columns: one page's, or — while switching between the day, the
* multi-day and the week view (#184) — every day of both pages, each column
* between where it sits in the one and where it sits in the other.
*
* @param progress how far from [from] to [to], read at layout time only.
*/
@Stable
class ColumnGeometry(val from: DaySpan, val to: DaySpan, val progress: () -> Float) {
/** Every day either page shows, in order: the two overlap, so they run on unbroken. */
val dates: List<LocalDate> = run {
val first = minOf(from.start, to.start)
val last = maxOf(from.end, to.end)
(0..first.daysUntil(last)).map { first.plus(it, DateTimeUnit.DAY) }
}
fun slot(date: LocalDate, width: Float, gap: Float): ColumnSlot {
val a = from.slot(date, width, gap)
if (from == to) return a
val b = to.slot(date, width, gap)
val p = progress()
return ColumnSlot(lerp(a.left, b.left, p), lerp(a.width, b.width, p))
}
/** The span of columns [range] covers, between its extent on each page. */
fun extent(fromRange: ClosedRange<LocalDate>, toRange: ClosedRange<LocalDate>, width: Float, gap: Float): ColumnSlot {
val a = from.extent(fromRange, width, gap)
if (from == to && fromRange == toRange) return a
val b = to.extent(toRange, width, gap)
val p = progress()
val left = lerp(a.left, b.left, p)
return ColumnSlot(left, lerp(a.right, b.right, p) - left)
}
companion object {
/** One page's columns, standing still. */
fun of(span: DaySpan): ColumnGeometry = ColumnGeometry(span, span) { 0f }
}
}
private fun DaySpan.extent(range: ClosedRange<LocalDate>, width: Float, gap: Float): ColumnSlot {
val left = slot(range.start, width, gap).left
return ColumnSlot(left, slot(range.endInclusive, width, gap).right - left)
}
internal fun lerp(a: Float, b: Float, p: Float): Float = a + (b - a) * p
/**
* Lays [content] out once per day of [geometry], each across its column. The
* columns are placed in the layout pass, so a view switch moving them every
* frame re-lays them out without recomposing them. Children fill the height
* the layout is given, or wrap it when it has none.
*/
@Composable
fun TimelineColumns(
geometry: ColumnGeometry,
gap: Dp,
modifier: Modifier = Modifier,
content: @Composable (LocalDate) -> Unit,
) {
val dates = geometry.dates
Layout(
content = {
dates.forEach { date ->
key(date) { Box(propagateMinConstraints = true) { content(date) } }
}
},
modifier = modifier,
) { measurables, constraints ->
val width = constraints.maxWidth
val gapPx = gap.toPx()
val slots = dates.map { geometry.slot(it, width.toFloat(), gapPx) }
val placeables = measurables.mapIndexed { i, measurable ->
val left = slots[i].left.roundToInt()
val columnWidth = (slots[i].right.roundToInt() - left).coerceAtLeast(0)
measurable.measure(constraints.copy(minWidth = columnWidth, maxWidth = columnWidth))
}
val height = placeables.maxOfOrNull { it.height }?.coerceAtLeast(constraints.minHeight)
?: constraints.minHeight
layout(width, height) {
placeables.forEachIndexed { i, placeable ->
placeable.placeRelative(slots[i].left.roundToInt(), 0)
}
}
}
}
/**
* An all-day bar as each page lays it out: the days it covers there, clipped to
* the page, and its lane. A bar only one page has keeps its days on the other,
* so it slides out with them rather than vanishing.
*/
@Immutable
data class PlacedBar(
val event: EventInstance,
/** The day the bar is drawn from on the page it is tagged on, for the view morph. */
val keyDate: LocalDate,
val fromRange: ClosedRange<LocalDate>,
val toRange: ClosedRange<LocalDate>,
val fromLane: Int,
val toLane: Int,
)
/** [state]'s all-day bars, standing still. */
internal fun staticBars(state: WeekUiState.Success): List<PlacedBar> =
state.allDaySpans.map { span ->
val range = state.days[span.startCol]..state.days[span.endCol]
PlacedBar(span.event, range.start, range, range, span.lane, span.lane)
}
/**
* The all-day bars of both pages of a view switch, each once: matched by
* occurrence, placed on each page as that page lays it out.
*/
internal fun morphBars(from: WeekUiState.Success?, to: WeekUiState.Success?): List<PlacedBar> {
val fromBars = from?.let(::staticBars).orEmpty().associateBy { it.event.occurrence() }
val toBars = to?.let(::staticBars).orEmpty().associateBy { it.event.occurrence() }
return (fromBars.keys + toBars.keys).map { occurrence ->
val a = fromBars[occurrence]
val b = toBars[occurrence]
PlacedBar(
event = (a ?: b)!!.event,
keyDate = (a ?: b)!!.keyDate,
fromRange = (a ?: b)!!.fromRange,
toRange = (b ?: a)!!.toRange,
fromLane = (a ?: b)!!.fromLane,
toLane = (b ?: a)!!.toLane,
)
}
}
private fun EventInstance.occurrence(): Pair<Long, Long> = eventId to start.toEpochMilliseconds()
@@ -68,6 +68,11 @@ import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.BiasAlignment
import androidx.compose.ui.Modifier
import de.jeanlucmakiola.calendula.ui.common.laneSlot
import de.jeanlucmakiola.calendula.ui.common.LaneColumn
import kotlin.math.roundToInt
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds
@@ -670,7 +675,8 @@ private fun WeekFrame(
)
}
}
Box(modifier = Modifier.weight(1f).fillMaxHeight()) { pages() }
// Clipped, as the pager was: a view switch slides columns in past its edges.
Box(modifier = Modifier.weight(1f).fillMaxHeight().clipToBounds()) { pages() }
}
}
@@ -728,6 +734,47 @@ private fun WeekPage(
onOpenDay: (LocalDate) -> Unit,
onCreateAt: (LocalDate, Int) -> Unit,
onDrop: (TimelineDrop) -> Unit,
) {
val geometry = remember(state.weekStart, state.days.size) {
ColumnGeometry.of(DaySpan(state.weekStart, state.days.size))
}
val bars = remember(state) { staticBars(state) }
TimelinePageContent(
geometry = geometry,
today = state.today,
timedFor = { state.timedByDay[it].orEmpty() },
bars = bars,
headerBias = headerBias,
allDayHeight = allDayHeight,
scrollState = scrollState,
dragController = dragController,
active = active,
onEventClick = onEventClick,
onOpenDay = onOpenDay,
onCreateAt = onCreateAt,
onDrop = onDrop,
)
}
/**
* A page's header, all-day strip and day columns, laid out over [geometry]: a
* single page's, or both pages' of a view switch mid-way (#184).
*/
@Composable
private fun TimelinePageContent(
geometry: ColumnGeometry,
today: LocalDate,
timedFor: (LocalDate) -> List<TimedBlock>,
bars: List<PlacedBar>,
headerBias: Float,
allDayHeight: Dp,
scrollState: ScrollState,
dragController: TimelineDragController,
active: Boolean,
onEventClick: (EventInstance) -> Unit,
onOpenDay: (LocalDate) -> Unit,
onCreateAt: (LocalDate, Int) -> Unit,
onDrop: (TimelineDrop) -> Unit,
) {
Column(modifier = Modifier.fillMaxSize()) {
Column(
@@ -735,18 +782,20 @@ private fun WeekPage(
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surface),
) {
WeekDayHeader(days = state.days, today = state.today, bias = headerBias, onOpenDay = onOpenDay)
WeekDayHeader(geometry = geometry, today = today, bias = headerBias, onOpenDay = onOpenDay)
// This strip is painted on `surface`, so an outlined chip has to
// fill with that and not with the columns' container (#230).
CompositionLocalProvider(
LocalChipGround provides MaterialTheme.colorScheme.surface,
) {
AllDayStrip(state = state, height = allDayHeight, onEventClick = onEventClick)
AllDayStrip(geometry = geometry, bars = bars, height = allDayHeight, onEventClick = onEventClick)
}
}
Spacer(Modifier.height(TIMELINE_TOP_GAP))
Timeline(
state = state,
geometry = geometry,
today = today,
timedFor = timedFor,
allDayHeight = allDayHeight,
scrollState = scrollState,
dragController = dragController,
@@ -822,65 +871,64 @@ private fun WeekTopBar(
@Composable
private fun WeekDayHeader(
days: List<LocalDate>,
geometry: ColumnGeometry,
today: LocalDate,
bias: Float,
onOpenDay: (LocalDate) -> Unit,
) {
val locale = currentLocale()
Row(
TimelineColumns(
geometry = geometry,
gap = 0.dp,
modifier = Modifier
.fillMaxWidth()
.padding(top = HEADER_TOP_PADDING, bottom = HEADER_BOTTOM_PADDING, end = TIMELINE_CONTENT_END_INSET),
) {
days.forEach { date ->
val javaDow = java.time.DayOfWeek.of(date.dayOfWeek.ordinal + 1)
val isToday = date == today
Column(
modifier = Modifier
.weight(1f)
.clip(RoundedCornerShape(12.dp))
.clickable { onOpenDay(date) }
.padding(start = HEADER_START_INSET * -bias),
horizontalAlignment = BiasAlignment.Horizontal(bias),
) { date ->
val javaDow = java.time.DayOfWeek.of(date.dayOfWeek.ordinal + 1)
val isToday = date == today
Column(
modifier = Modifier
.clip(RoundedCornerShape(12.dp))
.clickable { onOpenDay(date) }
.padding(start = HEADER_START_INSET * -bias),
horizontalAlignment = BiasAlignment.Horizontal(bias),
) {
// One line, as the gutter cell beside it assumes (see WeekNumberCell).
Text(
text = javaDow.getDisplayName(JavaTextStyle.SHORT, locale),
style = MaterialTheme.typography.labelSmall,
maxLines = 1,
overflow = TextOverflow.Clip,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(2.dp))
// Always reserve the circle slot so the header height is
// identical whether or not the week contains today.
Box(
modifier = Modifier.size(DATE_SLOT_SIZE),
contentAlignment = Alignment.Center,
) {
// One line, as the gutter cell beside it assumes (see WeekNumberCell).
Text(
text = javaDow.getDisplayName(JavaTextStyle.SHORT, locale),
style = MaterialTheme.typography.labelSmall,
maxLines = 1,
overflow = TextOverflow.Clip,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(2.dp))
// Always reserve the circle slot so the header height is
// identical whether or not the week contains today.
Box(
modifier = Modifier.size(DATE_SLOT_SIZE),
contentAlignment = Alignment.Center,
) {
if (isToday) {
Surface(
shape = CircleShape,
color = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.morphDay(date).fillMaxSize(),
) {
Box(contentAlignment = Alignment.Center) {
Text(
text = date.day.toString(),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
)
}
if (isToday) {
Surface(
shape = CircleShape,
color = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.morphDay(date).fillMaxSize(),
) {
Box(contentAlignment = Alignment.Center) {
Text(
text = date.day.toString(),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
)
}
} else {
Text(
text = date.day.toString(),
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.morphDay(date),
)
}
} else {
Text(
text = date.day.toString(),
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.morphDay(date),
)
}
}
}
@@ -936,7 +984,8 @@ private fun WeekNumberBadge(weekNumber: Int, modifier: Modifier = Modifier) {
@Composable
private fun AllDayStrip(
state: WeekUiState.Success,
geometry: ColumnGeometry,
bars: List<PlacedBar>,
height: Dp,
onEventClick: (EventInstance) -> Unit,
) {
@@ -957,29 +1006,39 @@ private fun AllDayStrip(
// Span bars are positioned absolutely so a multi-day event is one
// connected bar across columns rather than a chip per day. clipToBounds
// keeps bars from spilling out while the height animates.
BoxWithConstraints(
Layout(
content = {
bars.forEach { bar ->
key(bar.event.eventId, bar.event.start) {
AllDayBar(
event = bar.event,
dark = dark,
onClick = { onEventClick(bar.event) },
modifier = Modifier
.morphEvent(ViewMorphKey.event(bar.event, bar.keyDate))
.padding(horizontal = 1.dp, vertical = 1.dp),
)
}
}
},
modifier = Modifier
.weight(1f)
.fillMaxHeight()
.clipToBounds(),
) {
val colWidth = maxWidth / state.days.size
state.allDaySpans.forEach { span ->
val spanCols = span.endCol - span.startCol + 1
AllDayBar(
event = span.event,
dark = dark,
onClick = { onEventClick(span.event) },
modifier = Modifier
.offset(
x = colWidth * span.startCol,
y = ALL_DAY_ROW_HEIGHT * span.lane,
)
.morphEvent(ViewMorphKey.event(span.event, state.days[span.startCol]))
.width(colWidth * spanCols)
.height(ALL_DAY_ROW_HEIGHT)
.padding(horizontal = 1.dp, vertical = 1.dp),
)
) { measurables, constraints ->
val width = constraints.maxWidth.toFloat()
val rowPx = ALL_DAY_ROW_HEIGHT.toPx()
val progress = geometry.progress()
val placed = measurables.mapIndexed { i, measurable ->
val bar = bars[i]
val extent = geometry.extent(bar.fromRange, bar.toRange, width, 0f)
val left = extent.left.roundToInt()
val barWidth = (extent.right.roundToInt() - left).coerceAtLeast(0)
val y = (lerp(bar.fromLane.toFloat(), bar.toLane.toFloat(), progress) * rowPx).roundToInt()
Triple(measurable.measure(Constraints.fixed(barWidth, rowPx.roundToInt())), left, y)
}
layout(constraints.maxWidth, constraints.maxHeight) {
placed.forEach { (placeable, x, y) -> placeable.placeRelative(x, y) }
}
}
}
@@ -1020,7 +1079,9 @@ private fun AllDayBar(
@Composable
private fun Timeline(
state: WeekUiState.Success,
geometry: ColumnGeometry,
today: LocalDate,
timedFor: (LocalDate) -> List<TimedBlock>,
allDayHeight: Dp,
scrollState: ScrollState,
dragController: TimelineDragController,
@@ -1052,8 +1113,14 @@ private fun Timeline(
// vertical scroll, and it does that by watching the initial pass, which
// only reaches it if it is the viewport's ancestor.
val pinch = rememberTimelinePinchZoom(scrollState, fitHeight, hourHeight, zoom)
// What a block's text is fitted to: the column it will end up in once a
// view switch is past half way, so it reflows once rather than per frame.
val textColumns by remember(geometry) {
derivedStateOf { if (geometry.progress() >= 0.5f) geometry.to.count else geometry.from.count }
}
val columnWidth = (maxWidth - TIMELINE_CONTENT_END_INSET + COLUMN_GAP) / textColumns - COLUMN_GAP
TimelineGrounds(
days = state.days,
days = geometry.dates,
scrollState = scrollState,
hourHeight = hourHeight,
columnGap = COLUMN_GAP,
@@ -1075,7 +1142,9 @@ private fun Timeline(
.verticalScroll(scrollState)
.onGloballyPositioned { if (active) dragController.geometry.viewport = it },
) {
Row(
TimelineColumns(
geometry = geometry,
gap = COLUMN_GAP,
modifier = Modifier
.fillMaxWidth()
.height(totalHeight)
@@ -1091,29 +1160,25 @@ private fun Timeline(
it.hourPx = with(density) { hourHeight.toPx() }
it.blockInsetPx = blockInsetPx
it.columnGapPx = gap
it.columnWidthPx = (coords.size.width + gap) / state.days.size
it.days = state.days
it.columnWidthPx = (coords.size.width + gap) / geometry.to.count
it.days = geometry.dates
it.isRtl = isRtl
}
},
horizontalArrangement = Arrangement.spacedBy(COLUMN_GAP),
) {
state.days.forEach { day ->
DayColumnCard(
blocks = state.timedByDay[day].orEmpty(),
dark = dark,
date = day,
today = state.today,
hourHeight = hourHeight,
dragController = dragController,
onEventClick = onEventClick,
onCreateAt = onCreateAt,
onDrop = onDrop,
modifier = Modifier
.weight(1f)
.fillMaxHeight(),
)
}
) { day ->
DayColumnCard(
blocks = timedFor(day),
dark = dark,
date = day,
today = today,
hourHeight = hourHeight,
columnWidth = columnWidth,
dragController = dragController,
onEventClick = onEventClick,
onCreateAt = onCreateAt,
onDrop = onDrop,
modifier = Modifier.fillMaxHeight(),
)
}
}
}
@@ -1126,6 +1191,7 @@ private fun DayColumnCard(
date: LocalDate,
today: LocalDate,
hourHeight: Dp,
columnWidth: Dp,
dragController: TimelineDragController,
onEventClick: (EventInstance) -> Unit,
onCreateAt: (LocalDate, Int) -> Unit,
@@ -1162,7 +1228,7 @@ private fun DayColumnCard(
),
modifier = modifier,
) {
BoxWithConstraints(
LaneColumn(
modifier = Modifier
.fillMaxSize()
// The hour cells sit over the column background but under the
@@ -1179,7 +1245,6 @@ private fun DayColumnCard(
}
},
) {
val colWidth = maxWidth
val minEventHeight = hourHeight * MIN_EVENT_FRACTION
// Keyed by event, so a block that changes time or lane is the *same*
// composable afterwards and tweens there. The ordinal disambiguates
@@ -1188,14 +1253,13 @@ private fun DayColumnCard(
blocks.forEach { block ->
val ordinal = ordinals.merge(block.event.eventId, 1, Int::plus)!! - 1
key(block.event.eventId, ordinal) {
val laneWidth = colWidth / block.laneCount
val top = hourHeight * (block.startMin / 60f)
val rawHeight = hourHeight * ((block.endMin - block.startMin) / 60f)
val height = if (rawHeight < minEventHeight) minEventHeight else rawHeight
val place = animatedBlockPlacement(
x = laneWidth * block.lane,
xFraction = block.lane.toFloat() / block.laneCount,
widthFraction = 1f / block.laneCount,
y = top,
width = laneWidth,
height = height,
)
// Where this block is cut at midnight, which decides both
@@ -1214,7 +1278,7 @@ private fun DayColumnCard(
block = block,
dark = dark,
height = blockHeight,
width = place.width,
width = columnWidth / block.laneCount,
date = date,
cuts = cuts,
topInset = topInset,
@@ -1222,9 +1286,9 @@ private fun DayColumnCard(
onClick = { onEventClick(block.event) },
onDrop = onDrop,
modifier = Modifier
.offset(x = place.x, y = place.y + topInset)
.laneSlot(place)
.offset(y = place.y + topInset)
.morphEvent(ViewMorphKey.event(block.event, date))
.width(place.width)
.height(blockHeight)
.padding(horizontal = BLOCK_OUTER_INSET),
)
@@ -0,0 +1,107 @@
package de.jeanlucmakiola.calendula.ui.week
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.domain.EventInstance
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDate
import kotlinx.datetime.plus
import kotlin.time.Instant
import org.junit.jupiter.api.Test
/** The timeline's columns, standing still and mid view switch (#184). */
class ColumnGeometryTest {
// 2026-09-27 is a Sunday; the week runs Sun..Sat.
private val sunday = LocalDate(2026, 9, 27)
private val week = DaySpan(sunday, 7)
private val monday = sunday.plus(1, DateTimeUnit.DAY)
private val day = DaySpan(monday, 1)
private val width = 700f
private val gap = 7f
@Test
fun `a page's columns follow the pitch`() {
val slot = week.slot(sunday.plus(3, DateTimeUnit.DAY), width, gap)
assertThat(slot.left).isEqualTo(3 * 101f)
assertThat(slot.width).isEqualTo(94f)
}
@Test
fun `the ends of a switch are the two pages`() {
var p = 0f
val geometry = ColumnGeometry(week, day) { p }
assertThat(geometry.slot(monday, width, gap)).isEqualTo(week.slot(monday, width, gap))
p = 1f
assertThat(geometry.slot(monday, width, gap)).isEqualTo(day.slot(monday, width, gap))
}
@Test
fun `days off the target page end off screen on their side`() {
val geometry = ColumnGeometry(week, day) { 1f }
assertThat(geometry.slot(sunday, width, gap).right).isAtMost(0f)
assertThat(geometry.slot(monday.plus(1, DateTimeUnit.DAY), width, gap).left).isAtLeast(width)
}
@Test
fun `a switch covers both pages without a break`() {
// Focus on Saturday: the six-day run starting there reaches into next week.
val saturday = sunday.plus(6, DateTimeUnit.DAY)
val geometry = ColumnGeometry(week, DaySpan(saturday, 6)) { 0f }
assertThat(geometry.dates).hasSize(12)
assertThat(geometry.dates.first()).isEqualTo(sunday)
assertThat(geometry.dates.zipWithNext().all { (a, b) -> b == a.plus(1, DateTimeUnit.DAY) }).isTrue()
}
@Test
fun `a bar spans its extent on each page`() {
val tuesday = monday.plus(1, DateTimeUnit.DAY)
val range = monday..tuesday
val geometry = ColumnGeometry(week, day) { 0f }
val extent = geometry.extent(range, monday..monday, width, 0f)
assertThat(extent.left).isEqualTo(week.slot(monday, width, 0f).left)
assertThat(extent.right).isEqualTo(week.slot(tuesday, width, 0f).right)
}
@Test
fun `a bar only one page has keeps its days on the other`() {
val bar = AllDaySpan(event(1), startCol = 2, endCol = 3, lane = 1)
val from = state(week, listOf(bar))
val to = state(day, emptyList())
val placed = morphBars(from, to).single()
assertThat(placed.fromRange).isEqualTo(placed.toRange)
assertThat(placed.toLane).isEqualTo(1)
}
@Test
fun `a bar on both pages is placed once, clipped to each`() {
val event = event(1)
val from = state(week, listOf(AllDaySpan(event, startCol = 0, endCol = 6, lane = 0)))
val to = state(day, listOf(AllDaySpan(event, startCol = 0, endCol = 0, lane = 0)))
val placed = morphBars(from, to).single()
assertThat(placed.fromRange).isEqualTo(sunday..sunday.plus(6, DateTimeUnit.DAY))
assertThat(placed.toRange).isEqualTo(monday..monday)
}
private fun state(span: DaySpan, bars: List<AllDaySpan>): WeekUiState.Success {
val days = (0 until span.count).map { span.start.plus(it, DateTimeUnit.DAY) }
return WeekUiState.Success(
weekStart = span.start,
today = sunday,
days = days,
allDaySpans = bars,
timedByDay = emptyMap(),
)
}
private fun event(id: Long) = EventInstance(
instanceId = id,
eventId = id,
calendarId = 1L,
title = "Event",
start = Instant.fromEpochMilliseconds(0),
end = Instant.fromEpochMilliseconds(0),
isAllDay = true,
color = 0xFF000000.toInt(),
location = null,
)
}