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:
1 parent
2da892e34a
commit
4d3859490b
20 files changed
+306
-90
No files matched your search
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user