Keep short events at the end of the day fully visible (#483) (#485)
Beta — Codeberg pre-release / detect (push) Successful in 7s
Beta — Codeberg pre-release / beta (push) Skipped

Short events near midnight keep their minimum height flush against midnight and get their own lane, and the timeline scrolls far enough past midnight to clear the + button.
This commit is contained in:
Jean-Luc Makiola committed 2026-11-06 11:31:30 +01:00
1 parent 2da892e34a
commit 4d3859490b
20 files changed
+306 -90

No files matched your search

+2
View File
@@ -24,6 +24,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Widgets show your changes straight away.** After you added, edited or deleted an event, a widget could keep showing the old events until something else redrew it ([#420], [#229]).
- **Widgets follow the app's language.** After you picked a language in Settings, widgets kept the old one until something redrew them ([#90]).
- **The month widget handles busy months.** It could show "Can't show content", or only one or two events per day. Each day now shows as many events as its height fits, and the rest fold into +N ([#372]).
- **Short events late in the day are fully visible.** An event like 23:58–23:59 used to be cut down to a sliver at the bottom of the day, week and 3-day views. It now keeps its usual minimum height and sits flush against midnight ([#483]).
## [2.23.0] — 2026-10-21
@@ -1922,3 +1923,4 @@ automatically, with zero telemetry and no internet permission.
[#439]: https://codeberg.org/jlmakiola/calendula/issues/439
[#441]: https://codeberg.org/jlmakiola/calendula/issues/441
[#476]: https://codeberg.org/jlmakiola/calendula/issues/476
[#483]: https://codeberg.org/jlmakiola/calendula/issues/483
@@ -1,5 +1,6 @@
package de.jeanlucmakiola.calendula.ui.agenda
import de.jeanlucmakiola.calendula.ui.common.FabClearance
import de.jeanlucmakiola.calendula.ui.common.PublishChrome
import de.jeanlucmakiola.calendula.ui.common.ScreenActions
import de.jeanlucmakiola.calendula.ui.common.ScreenBody
@@ -432,7 +433,7 @@ private fun AgendaList(
modifier = modifier.nestedScroll(loadOnScrollUp),
state = listState,
// Bottom inset clears the FAB stack so the last row stays tappable.
contentPadding = PaddingValues(top = 8.dp, bottom = 96.dp),
contentPadding = PaddingValues(top = 8.dp, bottom = FabClearance),
) {
agendaDays(
days = days,
@@ -38,6 +38,9 @@ import de.jeanlucmakiola.calendula.R
/** The standard FAB's size, shared by the selection bar that unrolls from it (#141). */
val FabSize = 56.dp
/** Bottom inset that lets a scrolling view's last row or hour scroll clear of the FAB. */
val FabClearance = 96.dp
/** What the FAB is while an event is selected (#141): the toggle for its action bar. */
@Stable
class FabSelection(
@@ -56,7 +56,8 @@ private const val DIMMED_HOUR_ALPHA = 0.3f
* The timeline's hour gutter. Scrolls in sync with the day columns through the
* shared [scrollState], and while [dragController] holds a lifted block it dims
* the hour labels and floats a badge with the drag's current start time at the
* row the block would land on.
* row the block would land on. Its content ends in the same [FabClearance]
* the day columns do, or the two drift apart at the bottom of the scroll.
*/
@Composable
fun HourGutter(
@@ -86,7 +87,8 @@ fun HourGutter(
.width(GUTTER_WIDTH)
.padding(start = GUTTER_CONTENT_START_INSET)
.fillMaxHeight()
.verticalScroll(scrollState),
.verticalScroll(scrollState)
.padding(bottom = FabClearance),
) {
Column {
(0 until 24).forEach { h ->
@@ -70,27 +70,24 @@ data class OutOfViewEvents(val above: TimedBlock?, val below: TimedBlock?) {
/** Which edge of the viewport a hidden event sits past. */
enum class ViewportEdge { Top, Bottom }
/** Where [block]'s rendered bottom lands, in minutes — short blocks keep a floor. */
internal fun renderedEndMin(block: TimedBlock): Float =
maxOf(block.endMin.toFloat(), block.startMin + MIN_EVENT_FRACTION * 60f)
/**
* The blocks of one column lying out of a viewport spanning [topMin]..[bottomMin]
* (minutes of the day), nearest first. A block leaves this set as soon as any
* part of its rendered height enters the viewport.
* part of its [renderedSpan] enters the viewport.
*/
fun outOfViewEvents(
blocks: List<TimedBlock>,
topMin: Float,
bottomMin: Float,
): OutOfViewEvents {
val above = blocks
.filter { renderedEndMin(it) <= topMin }
.maxWithOrNull(compareBy<TimedBlock>({ renderedEndMin(it) }, { it.startMin }))
val below = blocks
.filter { it.startMin >= bottomMin }
.minWithOrNull(compareBy<TimedBlock>({ it.startMin }, { -renderedEndMin(it) }))
return OutOfViewEvents(above, below)
val spans = blocks.map { it to it.renderedSpan() }
val above = spans
.filter { (_, span) -> span.bottomMin <= topMin }
.maxWithOrNull(compareBy({ it.second.bottomMin }, { it.first.startMin }))
val below = spans
.filter { (_, span) -> span.topMin >= bottomMin }
.minWithOrNull(compareBy({ it.second.topMin }, { -it.second.bottomMin }))
return OutOfViewEvents(above?.first, below?.first)
}
/**
@@ -107,8 +104,9 @@ fun revealScrollTarget(
marginPx: Float,
maxScroll: Int,
): Int {
val startPx = block.startMin / 60f * hourPx
val endPx = renderedEndMin(block) / 60f * hourPx
val span = block.renderedSpan()
val startPx = span.topMin / 60f * hourPx
val endPx = span.bottomMin / 60f * hourPx
val target = when (edge) {
ViewportEdge.Top -> startPx - marginPx
ViewportEdge.Bottom -> minOf(endPx + marginPx - viewportPx, startPx - marginPx)
@@ -293,6 +291,7 @@ private fun EdgeBar(
modifier: Modifier = Modifier,
) {
block ?: return
val span = remember(block) { block.renderedSpan() }
val color = eventFill(block.event.color, dark, LocalSoftenColors.current)
val label = stringResource(
if (edge == ViewportEdge.Top) R.string.timeline_earlier_events else R.string.timeline_later_events,
@@ -303,8 +302,8 @@ private fun EdgeBar(
.height(EDGE_TOUCH_HEIGHT)
.graphicsLayer {
val gapPx = when (edge) {
ViewportEdge.Top -> scrollState.value - renderedEndMin(block) / 60f * hourPx
ViewportEdge.Bottom -> block.startMin / 60f * hourPx -
ViewportEdge.Top -> scrollState.value - span.bottomMin / 60f * hourPx
ViewportEdge.Bottom -> span.topMin / 60f * hourPx -
(scrollState.value + scrollState.viewportSize)
}
alpha = edgeBarAlpha(gapPx, fadeLeadPx)
@@ -267,9 +267,10 @@ internal fun dragPieceBounds(
): DragPieceBounds {
val top = if (slice.continuesBefore) 0f else insetPx
val bottom = if (slice.continuesAfter) 0f else insetPx
val full = maxOf(slice.spanMin / 60f * hourPx, MIN_EVENT_FRACTION * hourPx)
val span = renderedSpan(slice.startMin, slice.startMin + slice.spanMin, slice.continuesAfter)
val full = (span.bottomMin - span.topMin) / 60f * hourPx
return DragPieceBounds(
top = slice.startMin / 60f * hourPx + top,
top = span.topMin / 60f * hourPx + top,
height = (full - top - bottom).coerceAtLeast(0f),
)
}
@@ -327,12 +328,13 @@ internal fun draftStartMin(minutes: Float): Int =
internal fun draftBlock(draft: EventInstance, date: LocalDate, zone: TimeZone): TimedBlock? {
// Clipped to the day the way the grid lays out events, so it's drawn the same across a DST change (#387).
// The end is exclusive, so a draft ending at midnight belongs to the day before.
val dayEnd = date.plus(1, DateTimeUnit.DAY).atStartOfDayIn(zone)
val start = maxOf(draft.start, date.atStartOfDayIn(zone))
val end = minOf(draft.end, date.plus(1, DateTimeUnit.DAY).atStartOfDayIn(zone))
val end = minOf(draft.end, dayEnd)
if (end <= start) return null
val startMin = wallClockMinutes(start, date, zone)
val endMin = wallClockEndMinutes(startMin, start, end, date, zone).coerceAtMost(MINUTES_PER_DAY)
return TimedBlock(draft, startMin, endMin, lane = 0, laneCount = 1)
return TimedBlock(draft, startMin, endMin, lane = 0, laneCount = 1, continuesAfter = draft.end > dayEnd)
}
/** Which end of an event a resize handle moves (#141). */
@@ -903,7 +905,9 @@ class TimelineDragController {
val edgeIndex = geometry.days.indexOf(span.day.plus(edgeDay, DateTimeUnit.DAY))
session.column = edgeIndex.takeIf { it >= 0 }
?.let { column(it) }
session.grabMin = (pointerResizeMinutes(session) ?: 0f) -
// The top handle sits on the drawn top, which a short block near midnight lifts (#483).
val lift = if (edge == ResizeEdge.Top) block.startMin - block.renderedSpan().topMin else 0f
session.grabMin = (pointerResizeMinutes(session) ?: 0f) + lift -
if (edge == ResizeEdge.Top) session.startMin else session.endMin
originSlot = null
recompute()
@@ -963,7 +967,9 @@ class TimelineDragController {
session.column = held
val dayOffset = (days[column(held)].toEpochDays() - session.day.toEpochDays()).toInt()
session.edgeDay = dayOffset
return dayOffset * MINUTES_PER_DAY + (pointer.y - origin.y) / hourPx * 60f
// Held to its column's midnight: the clearance under it is not the next day.
val minutes = ((pointer.y - origin.y) / hourPx * 60f).coerceAtMost(MINUTES_PER_DAY.toFloat())
return dayOffset * MINUTES_PER_DAY + minutes
}
private fun recomputeResize(session: ResizeSession) {
@@ -1215,10 +1221,6 @@ fun TimedBlock.clipOffsetMinutes(day: LocalDate, zone: TimeZone): Int {
fun TimedBlock.continuesBefore(day: LocalDate, zone: TimeZone): Boolean =
event.start < day.atStartOfDayIn(zone)
/** True when the event runs past [day]'s midnight, so this block is cut there. */
fun TimedBlock.continuesAfter(day: LocalDate, zone: TimeZone): Boolean =
event.end > day.plus(1, DateTimeUnit.DAY).atStartOfDayIn(zone)
/**
* The instant a drop asks for — the *event's* new start, so the tail of an
* overnight event drops back onto the day its own start belongs to. Read in the
@@ -43,14 +43,21 @@ data class TimelineHours(
/**
* The scroll offset that puts [startHour] at the top of a timeline laid out
* with [maxValue] scroll range over a [viewportPx] viewport, or null while that
* with [maxValue] scroll range over a [viewportPx] viewport, [clearancePx] of
* which is the empty inset under midnight, or null while that
* layout is not yet at [hourPx] per hour — the scale changes a frame before the
* content grows to match it, and scrolling against the old layout would land on
* the wrong hour.
*/
internal fun startHourScroll(maxValue: Int, viewportPx: Int, startHour: Int, hourPx: Float): Int? {
internal fun startHourScroll(
maxValue: Int,
viewportPx: Int,
startHour: Int,
hourPx: Float,
clearancePx: Int = 0,
): Int? {
if (viewportPx <= 0 || maxValue < 0 || maxValue == Int.MAX_VALUE) return null
val laidOutHourPx = (maxValue + viewportPx) / 24f
val laidOutHourPx = (maxValue + viewportPx - clearancePx) / 24f
if (abs(laidOutHourPx - hourPx) > 1f) return null
return (startHour * laidOutHourPx).roundToInt().coerceIn(0, maxValue)
}
@@ -4,6 +4,8 @@ import androidx.annotation.StringRes
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.ui.week.MINUTES_PER_DAY
import de.jeanlucmakiola.calendula.ui.week.TimedBlock
/**
* How tall one hour is drawn in the week and day timelines (#56).
@@ -59,8 +61,9 @@ sealed interface TimelineScale {
}
/**
* Hour height for this scale. [viewportHeight] is the visible height of the
* scrolling timeline and is only consulted by [TimelineScale.FitDay].
* Hour height for this scale. [viewportHeight] is the height the day is fitted
* to, the scrolling timeline's [dayFitHeight], and is not consulted by the
* fixed presets.
*
* The fit-day result is clamped: below [FIT_DAY_MIN] the 24 gutter labels stop
* being legible, and above [FIT_DAY_MAX] a short landscape day would stretch its
@@ -85,6 +88,13 @@ fun TimelineScale.hourHeight(viewportHeight: Dp): Dp = when (this) {
.coerceAtLeast(fillHourHeight(viewportHeight))
}
/**
* The part of a [viewportHeight]-tall timeline the day is fitted to: the
* [FabClearance] under midnight, which lets the last hour scroll clear of the
* FAB (#483), is left out. Pass this, not the raw viewport, to [hourHeight].
*/
fun dayFitHeight(viewportHeight: Dp): Dp = (viewportHeight - FabClearance).coerceAtLeast(0.dp)
/**
* The minute of the day a tap [offsetY] px down a timeline column means, snapped
* to the hour it landed in.
@@ -105,6 +115,28 @@ fun tappedMinuteOfDay(offsetY: Float, hourPx: Float): Int =
*/
const val MIN_EVENT_FRACTION = 26f / 60f
/** Where a timed block is drawn down its day, in minutes from midnight. */
data class RenderedSpan(val topMin: Float, val bottomMin: Float)
/**
* Where a block of [startMin]..[endMin] is drawn, at least [MIN_EVENT_FRACTION]
* of an hour tall. A short block near midnight rises until that floor ends at
* midnight instead of running off the day, unless it [continuesAfter] into the
* next one (#483).
*/
fun renderedSpan(startMin: Int, endMin: Int, continuesAfter: Boolean): RenderedSpan {
val floor = MIN_EVENT_FRACTION * 60f
val bottom = maxOf(endMin.toFloat(), startMin + floor)
return if (continuesAfter || bottom <= MINUTES_PER_DAY) {
RenderedSpan(startMin.toFloat(), bottom)
} else {
RenderedSpan(MINUTES_PER_DAY - floor, MINUTES_PER_DAY.toFloat())
}
}
/** This block's [renderedSpan]. */
fun TimedBlock.renderedSpan(): RenderedSpan = renderedSpan(startMin, endMin, continuesAfter)
/** Smallest hour height [TimelineScale.FitDay] will resolve to. */
val FIT_DAY_MIN = 24.dp
@@ -32,16 +32,21 @@ val LocalTimelineScroll = staticCompositionLocalOf<TimelineScrollMemory?> { null
/** A timeline's scroll, and where it opens before it can scroll there. */
@Stable
class TimelineScroll(val state: ScrollState, noonPending: Boolean, private val openingHour: Int?) {
class TimelineScroll(
val state: ScrollState,
noonPending: Boolean,
private val openingHour: Int?,
private val clearancePx: Float = 0f,
) {
/** Whether it is still to be put on noon, which it only can be once laid out. */
var noonPending by mutableStateOf(noonPending)
internal set
/** The top of what it shows, or will once open, over [content] pixels in a [viewport] pixels high. */
/** The top of what it shows, or will once open, over [content] pixels of day in a [viewport] pixels high. */
fun top(viewport: Float, content: Float): Float =
if (noonPending) {
(openingHour?.let { it * content / 24f } ?: (content - viewport) / 2f)
.coerceIn(0f, (content - viewport).coerceAtLeast(0f))
.coerceIn(0f, (content + clearancePx - viewport).coerceAtLeast(0f))
} else state.value.toFloat()
}
@@ -58,6 +63,7 @@ fun rememberTimelineScrollState(timelineTop: Dp): TimelineScroll {
val density = LocalDensity.current
val zoom = LocalTimelineZoom.current
val topPx = with(density) { timelineTop.toPx() }
val clearancePx = with(density) { FabClearance.roundToPx() }
val remembered = memory?.midnightY
val scrollState = rememberScrollState(
initial = remembered?.let { (topPx - it).roundToInt().coerceAtLeast(0) } ?: 0,
@@ -67,6 +73,7 @@ fun rememberTimelineScrollState(timelineTop: Dp): TimelineScroll {
scrollState,
noonPending = remembered == null,
openingHour = zoom.hours.takeIf { it.enabled }?.startHour,
clearancePx = clearancePx.toFloat(),
)
}
val currentTop by rememberUpdatedState(topPx)
@@ -80,20 +87,20 @@ fun rememberTimelineScrollState(timelineTop: Dp): TimelineScroll {
val startHour = zoom.open()
if (startHour == null) {
snapshotFlow { scrollState.maxValue }.first { it > 0 }
scrollState.scrollTo(scrollState.maxValue / 2)
scrollState.scrollTo(((scrollState.maxValue - clearancePx) / 2).coerceAtLeast(0))
} else {
// Fitted to the scroll viewport itself: the layout resolves scales
// against the viewport plus the all-day strip, which would push the
// end hour below the screen.
val viewport = snapshotFlow { scrollState.viewportSize }.first { it > 0 }
val hourHeight = zoom.scale.hourHeight(with(density) { viewport.toDp() })
val hourHeight = zoom.scale.hourHeight(dayFitHeight(with(density) { viewport.toDp() }))
zoom.holdFit(hourHeight)
val hourPx = with(density) { hourHeight.toPx() }
val target = withTimeoutOrNull(FIT_LAYOUT_TIMEOUT_MILLIS) {
snapshotFlow {
startHourScroll(scrollState.maxValue, scrollState.viewportSize, startHour, hourPx)
startHourScroll(scrollState.maxValue, scrollState.viewportSize, startHour, hourPx, clearancePx)
}.filterNotNull().first()
} ?: (startHour * (scrollState.maxValue + scrollState.viewportSize) / 24)
} ?: (startHour * (scrollState.maxValue + scrollState.viewportSize - clearancePx) / 24)
.coerceIn(0, scrollState.maxValue.coerceAtLeast(0))
scrollState.scrollTo(target)
}
@@ -12,6 +12,7 @@ import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.runtime.setValue
import androidx.compose.runtime.rememberUpdatedState
import de.jeanlucmakiola.calendula.ui.agenda.agendaDays
import de.jeanlucmakiola.calendula.ui.common.FabClearance
import de.jeanlucmakiola.calendula.ui.common.PublishChrome
import de.jeanlucmakiola.calendula.ui.common.ScreenActions
import de.jeanlucmakiola.calendula.ui.common.ScreenBody
@@ -965,7 +966,7 @@ internal fun ContinuousMonthGrid(
state = listState,
modifier = modifier.fillMaxSize(),
// Bottom inset clears the FAB stack so the last row stays tappable.
contentPadding = PaddingValues(bottom = 96.dp),
contentPadding = PaddingValues(bottom = FabClearance),
) {
repeat(monthCount) { index ->
val month = yearMonthForIndex(index)
@@ -1111,7 +1112,7 @@ internal fun DenseMonthGrid(
.padding(top = DENSE_HEADER_GAP),
verticalArrangement = Arrangement.spacedBy(2.dp),
// Bottom inset clears the FAB stack so the last row stays tappable.
contentPadding = PaddingValues(bottom = 96.dp),
contentPadding = PaddingValues(bottom = FabClearance),
) {
items(count = weekCount, key = { it }) { index ->
val week = state.weeksByIndex[index]
@@ -1893,7 +1894,7 @@ private fun SplitMonthAgendaPane(
}
val dimCutoff = LocalDimCutoff.current
val listState = key(state.month) { rememberLazyListState() }
LazyColumn(state = listState, contentPadding = PaddingValues(bottom = 96.dp)) {
LazyColumn(state = listState, contentPadding = PaddingValues(bottom = FabClearance)) {
// A fixed first item, so a day added above the first listed one shows
// up rather than landing above a list that holds its scroll position.
item(key = "top") { Spacer(Modifier.height(8.dp)) }
@@ -1968,7 +1969,7 @@ internal fun SplitDayPane(
else -> LazyColumn(
// Bottom inset clears the FAB stack so the last row stays
// tappable.
contentPadding = PaddingValues(bottom = 96.dp),
contentPadding = PaddingValues(bottom = FabClearance),
) {
itemsIndexed(
items = dayEvents,
@@ -115,6 +115,7 @@ import androidx.compose.ui.layout.ParentDataModifier
import androidx.compose.ui.unit.Density
import androidx.compose.ui.layout.layout
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity
@@ -161,7 +162,6 @@ import de.jeanlucmakiola.calendula.ui.common.StripDrop
import de.jeanlucmakiola.calendula.ui.common.TimelineDragOverlay
import de.jeanlucmakiola.calendula.ui.common.TimelineDrop
import de.jeanlucmakiola.calendula.ui.common.clipOffsetMinutes
import de.jeanlucmakiola.calendula.ui.common.continuesAfter
import de.jeanlucmakiola.calendula.ui.common.continuesBefore
import de.jeanlucmakiola.calendula.ui.common.ChipCuts
import de.jeanlucmakiola.calendula.ui.common.timedBlockCuts
@@ -196,8 +196,10 @@ import de.jeanlucmakiola.floret.locale.currentLocale
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourGrid
import de.jeanlucmakiola.calendula.ui.common.LocalTimelineZoom
import de.jeanlucmakiola.calendula.ui.common.MIN_EVENT_FRACTION
import de.jeanlucmakiola.calendula.ui.common.renderedSpan
import de.jeanlucmakiola.calendula.ui.common.hourHeight
import de.jeanlucmakiola.calendula.ui.common.dayFitHeight
import de.jeanlucmakiola.calendula.ui.common.FabClearance
import de.jeanlucmakiola.calendula.ui.common.rememberTimelinePinchZoom
import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay
import de.jeanlucmakiola.calendula.ui.common.GUTTER_CONTENT_START_INSET
@@ -944,8 +946,9 @@ private fun WeekFrame(
),
) {
val zoom = LocalTimelineZoom.current
val fitHeight = if (animatedAllDayHeight == null) maxHeight + allDayHeight
else maxHeight
val fitHeight = dayFitHeight(
if (animatedAllDayHeight == null) maxHeight + allDayHeight else maxHeight,
)
val hourHeight = zoom.scale.hourHeight(fitHeight)
HourGutter(
scrollState = actions.scrollState,
@@ -1383,7 +1386,9 @@ private fun Timeline(
else Modifier.stableMorphViewport(animatedAllDayHeight, fullViewportHeight),
),
) {
val fitHeight = if (animatedAllDayHeight == null) maxHeight + allDayHeight else maxHeight
val fitHeight = dayFitHeight(
if (animatedAllDayHeight == null) maxHeight + allDayHeight else maxHeight,
)
val hourHeight = zoom.scale.hourHeight(fitHeight)
val totalHeight = hourHeight * 24
// The pinch sits above the scroll viewport: it has to outrank the
@@ -1416,9 +1421,11 @@ private fun Timeline(
.fillMaxSize()
.then(pinch)
.padding(end = TIMELINE_CONTENT_END_INSET)
.clip(RoundedCornerShape(16.dp))
.clip(RoundedCornerShape(TIMELINE_CORNER))
.verticalScroll(scrollState)
.onGloballyPositioned { if (active) dragController.geometry.viewport = it },
.onGloballyPositioned { if (active) dragController.geometry.viewport = it }
// Past midnight, outside the columns: nothing there takes a tap or a drop.
.padding(bottom = FabClearance),
) {
TimelineColumns(
geometry = geometry,
@@ -1454,6 +1461,7 @@ private fun Timeline(
visibleTop = visibleTop,
viewportHeight = visibleViewport,
actions = actions,
shape = columnShape(day == geometry.dates.first(), day == geometry.dates.last()),
modifier = Modifier.fillMaxHeight(),
)
}
@@ -1461,6 +1469,19 @@ private fun Timeline(
}
}
/**
* The outer columns round their bottom corner at midnight: the room left below
* for the buttons means the rounded window no longer does it there.
*/
private fun columnShape(first: Boolean, last: Boolean): Shape =
if (!first && !last) RectangleShape
else RoundedCornerShape(
bottomStart = if (first) TIMELINE_CORNER else 0.dp,
bottomEnd = if (last) TIMELINE_CORNER else 0.dp,
)
private val TIMELINE_CORNER = 16.dp
@Composable
private fun DayColumnCard(
blocks: List<TimedBlock>,
@@ -1472,6 +1493,7 @@ private fun DayColumnCard(
visibleTop: () -> Float,
viewportHeight: () -> Float,
actions: TimelineActions,
shape: Shape = RectangleShape,
modifier: Modifier = Modifier,
) {
val scrollState = actions.scrollState
@@ -1502,8 +1524,8 @@ private fun DayColumnCard(
val draftSurface = rememberDraftSurface(date, edges, hourPx, dragController, actions.onDrop)
Card(
// Plain rectangular columns — the soft corners come from the outer
// rounded scroll viewport, so inner rounding would look odd at the edges.
shape = RectangleShape,
// rounded scroll viewport, apart from the outer ones' bottoms ([columnShape]).
shape = shape,
colors = CardDefaults.cardColors(
// With the grid on, the container colour moves onto the hour cells
// and the column behind them recedes to surface, so the gap between
@@ -1543,7 +1565,6 @@ private fun DayColumnCard(
// block or handle on top can claim the press first.
.then(draftSurface),
) {
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
// a column holding two occurrences of one series.
@@ -1551,9 +1572,17 @@ private fun DayColumnCard(
shown.forEach { block ->
val ordinal = ordinals.merge(block.event.eventId, 1, Int::plus)!! - 1
key(block.event.eventId, ordinal) {
val top = hourHeight * (block.startMin / 60f)
val rawHeight = hourHeight * ((block.endMin - block.startMin) / 60f)
val height = if (rawHeight < minEventHeight) minEventHeight else rawHeight
// Where this block is cut at midnight, which decides both
// its shape and the half-gap it gives up to the hour cells.
val cuts = remember(block, date, zone) {
timedBlockCuts(
block.continuesBefore(date, zone),
block.continuesAfter,
)
}
val span = remember(block) { block.renderedSpan() }
val top = hourHeight * (span.topMin / 60f)
val height = hourHeight * ((span.bottomMin - span.topMin) / 60f)
val place = animatedBlockPlacement(
xFraction = block.lane.toFloat() / block.laneCount,
widthFraction = 1f / block.laneCount,
@@ -1561,14 +1590,6 @@ private fun DayColumnCard(
height = height,
landing = dragController.ghosts(block),
)
// Where this block is cut at midnight, which decides both
// its shape and the half-gap it gives up to the hour cells.
val cuts = remember(block, date, zone) {
timedBlockCuts(
block.continuesBefore(date, zone),
block.continuesAfter(date, zone),
)
}
val topInset = hourCellBlockInset(showHourGrid, cuts.top)
val bottomInset = hourCellBlockInset(showHourGrid, cuts.bottom)
val blockHeight = (place.height - topInset - bottomInset)
@@ -1904,7 +1925,7 @@ private fun rememberDraftSurface(
block = block,
zone = zone,
pointerInRoot = pointer,
blockInRoot = node + Offset(0f, startMin / 60f * hour),
blockInRoot = node + Offset(0f, block.renderedSpan().topMin / 60f * hour),
)
true
},
@@ -1952,8 +1973,10 @@ private fun WeekLoading(dayCount: Int) {
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
// Same scale resolution as the loaded timeline, so the skeleton's
// columns don't resize the moment the real week arrives.
val fitHeight = maxHeight + SKELETON_HEADER_HEIGHT + SKELETON_HEADER_PADDING * 2 -
headerHeight - TIMELINE_TOP_GAP
val fitHeight = dayFitHeight(
maxHeight + SKELETON_HEADER_HEIGHT + SKELETON_HEADER_PADDING * 2 -
headerHeight - TIMELINE_TOP_GAP,
)
val hourHeight = scale.hourHeight(fitHeight)
val totalHeight = hourHeight * 24
// The skeleton wears the loaded columns' own ground, so the arrival
@@ -1966,7 +1989,8 @@ private fun WeekLoading(dayCount: Int) {
modifier = Modifier
.fillMaxSize()
.padding(end = TIMELINE_CONTENT_END_INSET)
.verticalScroll(scrollState),
.verticalScroll(scrollState)
.padding(bottom = FabClearance),
) {
Spacer(Modifier.width(GUTTER_WIDTH))
repeat(dayCount) {
@@ -15,6 +15,8 @@ import kotlinx.datetime.LocalDate
* minimum tap-target height)
* @param lane 0-based column within [laneCount]
* @param laneCount number of columns the event's overlap-cluster needs
* @param continuesAfter whether the event runs past this day's midnight, so the
* block is cut there
*/
data class TimedBlock(
val event: EventInstance,
@@ -22,6 +24,7 @@ data class TimedBlock(
val endMin: Int,
val lane: Int,
val laneCount: Int,
val continuesAfter: Boolean = false,
)
/**
@@ -1,6 +1,7 @@
package de.jeanlucmakiola.calendula.ui.week
import de.jeanlucmakiola.calendula.ui.common.dayRange
import de.jeanlucmakiola.calendula.ui.common.renderedSpan
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
@@ -439,6 +440,8 @@ internal fun List<EventInstance>.splitWholeDays(zone: TimeZone): Pair<List<Event
* Clip [events] to a single [day] and assign lanes so overlapping events render
* side-by-side. Lane count is computed per overlap-cluster (a maximal run of
* chained-overlapping events), matching the common phone week-view behaviour.
* Overlap is judged on each block's [renderedSpan], so a short block's floor
* (lifted off midnight, #483) never hides another block.
*
* Takes the timed half of [splitWholeDays]; the rest lives in the all-day strip.
*/
@@ -450,54 +453,53 @@ internal fun layoutDay(
val dayStart = day.atStartOfDayIn(zone)
val dayEnd = day.plus(1, DateTimeUnit.DAY).atStartOfDayIn(zone)
data class Raw(val event: EventInstance, val startMin: Int, val endMin: Int)
val raw = events.asSequence()
.mapNotNull { ev ->
if (ev.start == ev.end) {
// Instant event: keep only if the point falls inside this day.
if (ev.start < dayStart || ev.start >= dayEnd) return@mapNotNull null
val m = wallClockMinutes(ev.start, day, zone)
Raw(ev, m, m)
TimedBlock(ev, m, m, lane = 0, laneCount = 1)
} else {
val s = maxOf(ev.start, dayStart)
val e = minOf(ev.end, dayEnd)
if (e <= s) return@mapNotNull null
val startMin = wallClockMinutes(s, day, zone)
val endMin = wallClockEndMinutes(startMin, s, e, day, zone).coerceAtMost(MINUTES_PER_DAY)
Raw(ev, startMin, endMin)
TimedBlock(ev, startMin, endMin, lane = 0, laneCount = 1, continuesAfter = ev.end > dayEnd)
}
}
.sortedWith(compareBy({ it.startMin }, { it.endMin }))
.map { it to it.renderedSpan() }
.sortedWith(compareBy({ it.second.topMin }, { it.second.bottomMin }))
.toList()
val result = ArrayList<TimedBlock>(raw.size)
var i = 0
while (i < raw.size) {
// Grow a cluster of chained-overlapping events.
var clusterEnd = raw[i].endMin
var clusterEnd = raw[i].second.bottomMin
var j = i + 1
while (j < raw.size && raw[j].startMin < clusterEnd) {
clusterEnd = maxOf(clusterEnd, raw[j].endMin)
while (j < raw.size && raw[j].second.topMin < clusterEnd) {
clusterEnd = maxOf(clusterEnd, raw[j].second.bottomMin)
j++
}
val cluster = raw.subList(i, j)
// Greedy first-fit column assignment (= max overlap depth in the cluster).
val laneEnd = ArrayList<Int>()
val laneEnd = ArrayList<Float>()
val lanes = IntArray(cluster.size)
cluster.forEachIndexed { k, r ->
var placed = laneEnd.indexOfFirst { it <= r.startMin }
cluster.forEachIndexed { k, (_, span) ->
var placed = laneEnd.indexOfFirst { it <= span.topMin }
if (placed == -1) {
laneEnd.add(r.endMin)
laneEnd.add(span.bottomMin)
placed = laneEnd.size - 1
} else {
laneEnd[placed] = r.endMin
laneEnd[placed] = span.bottomMin
}
lanes[k] = placed
}
val laneCount = laneEnd.size
cluster.forEachIndexed { k, r ->
result.add(TimedBlock(r.event, r.startMin, r.endMin, lanes[k], laneCount))
cluster.forEachIndexed { k, (block, _) ->
result.add(block.copy(lane = lanes[k], laneCount = laneCount))
}
i = j
}
@@ -72,6 +72,13 @@ class DragPieceBoundsTest {
assertThat(bounds.height).isEqualTo(MIN_EVENT_FRACTION * hourPx - insetPx * 2)
}
@Test
fun `a short piece at the end of the day rises as its block does`() {
val bounds = dragPieceBounds(slice(startMin = 1438, spanMin = 1), hourPx, insetPx = 0f)
assertThat(bounds.top + bounds.height).isWithin(0.01f).of(24 * hourPx)
assertThat(bounds.height).isWithin(0.01f).of(MIN_EVENT_FRACTION * hourPx)
}
@Test
fun `an inset taller than the piece leaves no height at all`() {
val bounds = dragPieceBounds(slice(startMin = 0, spanMin = 60), hourPx = 4f, insetPx = insetPx)
@@ -78,6 +78,21 @@ class OutOfViewEdgesTest {
assertThat(outOf(blip).above).isNull()
}
@Test
fun `a short event at midnight is judged by where it rises to`() {
// 23:58–23:59 draws from 23:34, so a viewport ending at 23:40 already shows it.
val late = block(6, 23 * 60 + 58, 23 * 60 + 59)
assertThat(outOfViewEvents(listOf(late), topMin = 1000f, bottomMin = 1420f).below).isNull()
val target = revealScrollTarget(late, ViewportEdge.Bottom, hourPx = 60f, viewportPx = 10, marginPx = 0f, maxScroll = 1440)
assertThat(target).isEqualTo(1440 - 26)
}
@Test
fun `a short event carrying on past midnight is judged by its own start`() {
val overnight = block(7, 23 * 60 + 58, 24 * 60).copy(continuesAfter = true)
assertThat(outOfViewEvents(listOf(overnight), topMin = 1000f, bottomMin = 1420f).below).isEqualTo(overnight)
}
@Test
fun `an event above scrolls to its start less the margin`() {
val target = revealScrollTarget(breakfast, ViewportEdge.Top, hourPx = 100f, viewportPx = 700, marginPx = 20f, maxScroll = 1700)
@@ -0,0 +1,52 @@
package de.jeanlucmakiola.calendula.ui.common
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
/** A short block near midnight keeps its whole floor rather than running off the day (#483). */
class RenderedSpanTest {
private val floor = MIN_EVENT_FRACTION * 60f
@Test
fun `a long block draws at its own times`() {
assertThat(renderedSpan(540, 600, continuesAfter = false)).isEqualTo(RenderedSpan(540f, 600f))
}
@Test
fun `a short block in the day is floored downwards`() {
val span = renderedSpan(20 * 60 + 58, 20 * 60 + 59, continuesAfter = false)
assertThat(span).isEqualTo(RenderedSpan(1258f, 1258f + floor))
}
@Test
fun `a short block at the end of the day rises to end at midnight`() {
val span = renderedSpan(23 * 60 + 58, 23 * 60 + 59, continuesAfter = false)
assertThat(span).isEqualTo(RenderedSpan(1440f - floor, 1440f))
}
@Test
fun `a block ending at midnight rises too`() {
val span = renderedSpan(23 * 60 + 59, 1440, continuesAfter = false)
assertThat(span).isEqualTo(RenderedSpan(1440f - floor, 1440f))
}
@Test
fun `an instant event at the end of the day rises`() {
val span = renderedSpan(1439, 1439, continuesAfter = false)
assertThat(span.bottomMin).isEqualTo(1440f)
assertThat(span.bottomMin - span.topMin).isEqualTo(floor)
}
@Test
fun `a block that just fits stays put`() {
val start = 1440 - 26
assertThat(renderedSpan(start, start + 5, continuesAfter = false)).isEqualTo(RenderedSpan(start.toFloat(), 1440f))
}
@Test
fun `a block carrying on past midnight keeps its start`() {
val span = renderedSpan(23 * 60 + 58, 1440, continuesAfter = true)
assertThat(span.topMin).isEqualTo(1438f)
}
}
@@ -37,7 +37,7 @@ class TimedBlockShapeTest {
val block = layoutDay(listOf(ev), day1, zone).single()
assertThat(block.continuesBefore(day1, zone)).isFalse()
assertThat(block.continuesAfter(day1, zone)).isFalse()
assertThat(block.continuesAfter).isFalse()
}
@Test
@@ -46,11 +46,11 @@ class TimedBlockShapeTest {
val head = layoutDay(listOf(ev), day1, zone).single()
assertThat(head.continuesBefore(day1, zone)).isFalse()
assertThat(head.continuesAfter(day1, zone)).isTrue()
assertThat(head.continuesAfter).isTrue()
val tail = layoutDay(listOf(ev), day2, zone).single()
assertThat(tail.continuesBefore(day2, zone)).isTrue()
assertThat(tail.continuesAfter(day2, zone)).isFalse()
assertThat(tail.continuesAfter).isFalse()
}
@Test
@@ -59,7 +59,7 @@ class TimedBlockShapeTest {
val middle = layoutDay(listOf(ev), day2, zone).single()
assertThat(middle.continuesBefore(day2, zone)).isTrue()
assertThat(middle.continuesAfter(day2, zone)).isTrue()
assertThat(middle.continuesAfter).isTrue()
}
@Test
@@ -67,7 +67,7 @@ class TimedBlockShapeTest {
val ev = event(at(day1, 20), at(day2, 0))
val block = layoutDay(listOf(ev), day1, zone).single()
assertThat(block.continuesAfter(day1, zone)).isFalse()
assertThat(block.continuesAfter).isFalse()
}
@Test
@@ -45,6 +45,14 @@ class TimelineHoursTest {
.isEqualTo(280)
}
@Test
fun `the clearance under midnight is not counted as day`() {
// 40px an hour plus 100px of clearance over a 600px viewport: 460px of scroll.
assertThat(
startHourScroll(maxValue = 460, viewportPx = 600, startHour = 7, hourPx = 40f, clearancePx = 100),
).isEqualTo(280)
}
@Test
fun `a layout still at the previous scale is waited out`() {
// Still 56px an hour: 1344 - 600 = 744 of scroll.
@@ -40,6 +40,19 @@ class TimelineScaleTest {
assertThat(h * 24).isGreaterThan(phoneViewport * 0.9f)
}
@Test
fun `fit-day leaves the FAB clearance under midnight`() {
val viewport = 800.dp
val h = TimelineScale.FitDay.hourHeight(dayFitHeight(viewport))
assertThat(h * 24 + FabClearance).isAtMost(viewport)
assertThat(h * 24 + FabClearance).isGreaterThan(viewport * 0.99f)
}
@Test
fun `the fitted height never goes negative`() {
assertThat(dayFitHeight(40.dp)).isEqualTo(0.dp)
}
@Test
fun `fit-day clamps instead of shrinking hours past legibility`() {
// A very short viewport (split screen, tiny device) would otherwise give
@@ -108,6 +108,42 @@ class WeekLayoutTest {
assertThat(blocks.all { it.lane == 0 && it.laneCount == 1 }).isTrue()
}
@Test
fun `two short entries near midnight sit side by side`() {
// Both lift to end at midnight, so on real times alone the later one would hide the earlier (#483).
val a = event(at(wed, 23, 40), at(wed, 23, 41), id = 1L)
val b = event(at(wed, 23, 55), at(wed, 23, 56), id = 2L)
val blocks = layoutDay(listOf(a, b), wed, zone).sortedBy { it.lane }
assertThat(blocks.map { it.event.instanceId }).containsExactly(1L, 2L).inOrder()
assertThat(blocks.all { it.laneCount == 2 }).isTrue()
}
@Test
fun `a short entry lifted into the event before it takes its own lane`() {
val a = event(at(wed, 23), at(wed, 23, 45), id = 1L)
val b = event(at(wed, 23, 58), at(wed, 23, 59), id = 2L)
val blocks = layoutDay(listOf(a, b), wed, zone)
assertThat(blocks.map { it.lane }).containsExactly(0, 1)
assertThat(blocks.all { it.laneCount == 2 }).isTrue()
}
@Test
fun `a short entry clear of the next one keeps a single lane`() {
val a = event(at(wed, 9), at(wed, 9, 10), id = 1L)
val b = event(at(wed, 9, 30), at(wed, 10), id = 2L)
val blocks = layoutDay(listOf(a, b), wed, zone)
assertThat(blocks.all { it.lane == 0 && it.laneCount == 1 }).isTrue()
}
@Test
fun `a block knows whether its event carries on past midnight`() {
val overnight = event(at(wed, 23), at(wed.plusDays(1), 1))
assertThat(layoutDay(listOf(overnight), wed, zone).single().continuesAfter).isTrue()
assertThat(layoutDay(listOf(overnight), wed.plusDays(1), zone).single().continuesAfter).isFalse()
val toMidnight = event(at(wed, 23), at(wed.plusDays(1), 0))
assertThat(layoutDay(listOf(toMidnight), wed, zone).single().continuesAfter).isFalse()
}
@Test
fun `event spanning midnight is clipped to the day`() {
// Starts the previous evening, ends 02:00 on wed.