Tint a day column's edge when events sit out of view (#188)
This commit is contained in:
@@ -122,3 +122,31 @@ const val GREY_CHROMA = 0.02f
|
||||
/** Chroma band for harmonised colours: distinct, but never electric. */
|
||||
const val MIN_CHROMA = 0.07f
|
||||
const val MAX_CHROMA = 0.16f
|
||||
|
||||
/**
|
||||
* Colour for the timeline's out-of-view edge bar (#188): the [eventTone]
|
||||
* container's hue and chroma, stepped [EDGE_HINT_STEP] away from its lightness
|
||||
* so the bar still reads apart from a block of the same calendar sitting right
|
||||
* at the edge. It steps towards the surface's opposite — darker on a light
|
||||
* scheme, lighter on a dark one — and only turns back when that runs out of
|
||||
* room.
|
||||
*/
|
||||
fun edgeHintTone(rawArgb: Int, dark: Boolean, harmonise: Boolean): Int {
|
||||
val container = oklchOf(eventTone(rawArgb, dark, harmonise).container)
|
||||
val towardsContrast = if (dark) EDGE_HINT_STEP else -EDGE_HINT_STEP
|
||||
val stepped = container.lightness + towardsContrast
|
||||
val back = container.lightness - towardsContrast
|
||||
val lightness = when {
|
||||
stepped in EDGE_HINT_MIN_LIGHTNESS..EDGE_HINT_MAX_LIGHTNESS -> stepped
|
||||
back in 0f..1f -> back
|
||||
else -> stepped.coerceIn(0f, 1f)
|
||||
}
|
||||
return container.copy(lightness = lightness).toArgb()
|
||||
}
|
||||
|
||||
/** How far the edge bar's lightness sits from its event's container. */
|
||||
const val EDGE_HINT_STEP = 0.16f
|
||||
|
||||
/** Lightness band the edge bar stays inside, clear of pure black and white. */
|
||||
const val EDGE_HINT_MIN_LIGHTNESS = 0.22f
|
||||
const val EDGE_HINT_MAX_LIGHTNESS = 0.94f
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.ScrollState
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.domain.color.edgeHintTone
|
||||
import de.jeanlucmakiola.calendula.ui.week.TimedBlock
|
||||
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/** Thickness of the out-of-view edge bar. */
|
||||
private val EDGE_BAR_THICKNESS = 3.dp
|
||||
|
||||
/** Tappable band the bar sits in, taller than the bar itself. */
|
||||
private val EDGE_TOUCH_HEIGHT = 24.dp
|
||||
|
||||
/**
|
||||
* How far the bar sits in from the viewport's edges — enough to clear the
|
||||
* timeline's rounded 16dp corners on the outer columns.
|
||||
*/
|
||||
private val EDGE_BAR_VERTICAL_INSET = 4.dp
|
||||
private val EDGE_BAR_HORIZONTAL_INSET = 6.dp
|
||||
|
||||
/** A block showing no more than this at the edge still counts as out of view. */
|
||||
private val EDGE_SLACK = 12.dp
|
||||
|
||||
/** The nearest timed blocks wholly above and below a column's viewport. */
|
||||
@Immutable
|
||||
data class OutOfViewEvents(val above: TimedBlock?, val below: TimedBlock?) {
|
||||
companion object {
|
||||
val None = OutOfViewEvents(null, null)
|
||||
}
|
||||
}
|
||||
|
||||
/** 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 that shows at most [slackMin] at
|
||||
* the edge counts as out of view, but only if some of it is actually hidden, so
|
||||
* a column scrolled to its very top or bottom never points past it.
|
||||
*/
|
||||
fun outOfViewEvents(
|
||||
blocks: List<TimedBlock>,
|
||||
topMin: Float,
|
||||
bottomMin: Float,
|
||||
slackMin: Float,
|
||||
): OutOfViewEvents {
|
||||
val above = blocks
|
||||
.filter { it.startMin < topMin && renderedEndMin(it) <= topMin + slackMin }
|
||||
.maxWithOrNull(compareBy<TimedBlock>({ renderedEndMin(it) }, { it.startMin }))
|
||||
val below = blocks
|
||||
.filter { renderedEndMin(it) > bottomMin && it.startMin >= bottomMin - slackMin }
|
||||
.minWithOrNull(compareBy<TimedBlock>({ it.startMin }, { -renderedEndMin(it) }))
|
||||
return OutOfViewEvents(above, below)
|
||||
}
|
||||
|
||||
/**
|
||||
* The scroll offset that brings [block] into a [viewportPx]-tall viewport from
|
||||
* past [edge], with [marginPx] of room so it clears the edge bar. An event above
|
||||
* lands with its start at the top; one below lands whole at the bottom when it
|
||||
* fits, and with its start at the top when it does not.
|
||||
*/
|
||||
fun revealScrollTarget(
|
||||
block: TimedBlock,
|
||||
edge: ViewportEdge,
|
||||
hourPx: Float,
|
||||
viewportPx: Int,
|
||||
marginPx: Float,
|
||||
maxScroll: Int,
|
||||
): Int {
|
||||
val startPx = block.startMin / 60f * hourPx
|
||||
val endPx = renderedEndMin(block) / 60f * hourPx
|
||||
val target = when (edge) {
|
||||
ViewportEdge.Top -> startPx - marginPx
|
||||
ViewportEdge.Bottom -> minOf(endPx + marginPx - viewportPx, startPx - marginPx)
|
||||
}
|
||||
return target.roundToInt().coerceIn(0, maxOf(maxScroll, 0))
|
||||
}
|
||||
|
||||
/**
|
||||
* Thin bars at the top and bottom of a day column's visible viewport, tinted in
|
||||
* the colour of the nearest timed event hidden past that edge (#188). Tapping
|
||||
* one scrolls the event into view.
|
||||
*
|
||||
* Lives inside the column's scrolling content, so taps on it still let the
|
||||
* timeline scroll and pinch, and follows the viewport by offsetting against
|
||||
* [scrollState] at placement rather than recomposing every frame.
|
||||
*/
|
||||
@Composable
|
||||
fun BoxScope.OutOfViewEdgeBars(
|
||||
blocks: List<TimedBlock>,
|
||||
scrollState: ScrollState,
|
||||
hourPx: Float,
|
||||
dark: Boolean,
|
||||
) {
|
||||
val density = LocalDensity.current
|
||||
val slackPx = with(density) { EDGE_SLACK.toPx() }
|
||||
val touchPx = with(density) { EDGE_TOUCH_HEIGHT.roundToPx() }
|
||||
val events by remember(blocks, hourPx, slackPx, scrollState) {
|
||||
derivedStateOf {
|
||||
val viewport = scrollState.viewportSize
|
||||
if (viewport <= 0 || hourPx <= 0f) {
|
||||
OutOfViewEvents.None
|
||||
} else {
|
||||
val top = scrollState.value.toFloat()
|
||||
outOfViewEvents(
|
||||
blocks = blocks,
|
||||
topMin = top / hourPx * 60f,
|
||||
bottomMin = (top + viewport) / hourPx * 60f,
|
||||
slackMin = slackPx / hourPx * 60f,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
val scope = rememberCoroutineScope()
|
||||
val reduceMotion = rememberReduceMotion()
|
||||
val revealSpec = rememberCalendarPageSpec()
|
||||
val reveal: (TimedBlock, ViewportEdge) -> Unit = { block, edge ->
|
||||
val target = revealScrollTarget(
|
||||
block = block,
|
||||
edge = edge,
|
||||
hourPx = hourPx,
|
||||
viewportPx = scrollState.viewportSize,
|
||||
marginPx = touchPx.toFloat(),
|
||||
maxScroll = scrollState.maxValue,
|
||||
)
|
||||
scope.launch {
|
||||
if (reduceMotion) {
|
||||
scrollState.scrollTo(target)
|
||||
} else {
|
||||
scrollState.animateScrollTo(target, revealSpec)
|
||||
}
|
||||
}
|
||||
}
|
||||
EdgeBar(
|
||||
block = events.above,
|
||||
edge = ViewportEdge.Top,
|
||||
dark = dark,
|
||||
onReveal = reveal,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopStart)
|
||||
.offset { IntOffset(0, scrollState.value) },
|
||||
)
|
||||
EdgeBar(
|
||||
block = events.below,
|
||||
edge = ViewportEdge.Bottom,
|
||||
dark = dark,
|
||||
onReveal = reveal,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopStart)
|
||||
.offset { IntOffset(0, scrollState.value + scrollState.viewportSize - touchPx) },
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
private fun EdgeBar(
|
||||
block: TimedBlock?,
|
||||
edge: ViewportEdge,
|
||||
dark: Boolean,
|
||||
onReveal: (TimedBlock, ViewportEdge) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
// Held so the bar keeps its colour, and its tap, while it fades out.
|
||||
val held = remember { mutableStateOf<TimedBlock?>(null) }
|
||||
SideEffect { if (block != null) held.value = block }
|
||||
val shown = block ?: held.value
|
||||
val soften = LocalSoftenColors.current
|
||||
val target = shown?.let { Color(edgeHintTone(it.event.color, dark, soften)) } ?: Color.Transparent
|
||||
val effects = MaterialTheme.motionScheme.defaultEffectsSpec<Float>()
|
||||
val color by animateColorAsState(
|
||||
targetValue = target,
|
||||
animationSpec = MaterialTheme.motionScheme.defaultEffectsSpec(),
|
||||
label = "edge-bar-color",
|
||||
)
|
||||
val label = stringResource(
|
||||
if (edge == ViewportEdge.Top) R.string.timeline_earlier_events else R.string.timeline_later_events,
|
||||
)
|
||||
AnimatedVisibility(
|
||||
visible = block != null,
|
||||
enter = fadeIn(effects),
|
||||
exit = fadeOut(effects),
|
||||
modifier = modifier,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(EDGE_TOUCH_HEIGHT)
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
// The scroll it sets off is the feedback; a ripple across the
|
||||
// column's edge would be the noise the bar is meant to avoid.
|
||||
indication = null,
|
||||
role = Role.Button,
|
||||
) { shown?.let { onReveal(it, edge) } }
|
||||
.semantics { contentDescription = label },
|
||||
contentAlignment = if (edge == ViewportEdge.Top) Alignment.TopCenter else Alignment.BottomCenter,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(
|
||||
horizontal = EDGE_BAR_HORIZONTAL_INSET,
|
||||
vertical = EDGE_BAR_VERTICAL_INSET,
|
||||
)
|
||||
.height(EDGE_BAR_THICKNESS)
|
||||
.background(color, CircleShape),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -106,6 +106,7 @@ import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
|
||||
import de.jeanlucmakiola.calendula.ui.common.MoveRequest
|
||||
import de.jeanlucmakiola.calendula.ui.common.MoveTarget
|
||||
import de.jeanlucmakiola.calendula.ui.common.NowLine
|
||||
import de.jeanlucmakiola.calendula.ui.common.OutOfViewEdgeBars
|
||||
import de.jeanlucmakiola.calendula.ui.common.TimelineDragController
|
||||
import de.jeanlucmakiola.calendula.ui.common.TimelineDragOverlay
|
||||
import de.jeanlucmakiola.calendula.ui.common.TimelineDrop
|
||||
@@ -707,6 +708,7 @@ private fun Timeline(
|
||||
date = state.date,
|
||||
today = state.today,
|
||||
hourHeight = hourHeight,
|
||||
scrollState = scrollState,
|
||||
dragController = dragController,
|
||||
onEventClick = onEventClick,
|
||||
onCreateAt = onCreateAt,
|
||||
@@ -742,6 +744,7 @@ private fun DayColumnCard(
|
||||
date: LocalDate,
|
||||
today: LocalDate,
|
||||
hourHeight: Dp,
|
||||
scrollState: ScrollState,
|
||||
dragController: TimelineDragController,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
onCreateAt: (LocalDate, Int) -> Unit,
|
||||
@@ -849,6 +852,12 @@ private fun DayColumnCard(
|
||||
if (date == today) {
|
||||
NowLine(date = date, hourHeight = hourHeight)
|
||||
}
|
||||
OutOfViewEdgeBars(
|
||||
blocks = blocks,
|
||||
scrollState = scrollState,
|
||||
hourPx = hourPx,
|
||||
dark = dark,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,6 +132,7 @@ import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff
|
||||
import de.jeanlucmakiola.calendula.ui.common.EventChipShape
|
||||
import de.jeanlucmakiola.calendula.ui.common.trimmedLines
|
||||
import de.jeanlucmakiola.calendula.ui.common.NowLine
|
||||
import de.jeanlucmakiola.calendula.ui.common.OutOfViewEdgeBars
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
|
||||
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
|
||||
@@ -997,6 +998,7 @@ private fun Timeline(
|
||||
date = day,
|
||||
today = state.today,
|
||||
hourHeight = hourHeight,
|
||||
scrollState = scrollState,
|
||||
dragController = dragController,
|
||||
onEventClick = onEventClick,
|
||||
onCreateAt = onCreateAt,
|
||||
@@ -1018,6 +1020,7 @@ private fun DayColumnCard(
|
||||
date: LocalDate,
|
||||
today: LocalDate,
|
||||
hourHeight: Dp,
|
||||
scrollState: ScrollState,
|
||||
dragController: TimelineDragController,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
onCreateAt: (LocalDate, Int) -> Unit,
|
||||
@@ -1125,6 +1128,12 @@ private fun DayColumnCard(
|
||||
if (date == today) {
|
||||
NowLine(date = date, hourHeight = hourHeight)
|
||||
}
|
||||
OutOfViewEdgeBars(
|
||||
blocks = blocks,
|
||||
scrollState = scrollState,
|
||||
hourPx = hourPx,
|
||||
dark = dark,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,6 +67,10 @@
|
||||
<!-- Day view (S3) -->
|
||||
<string name="day_today_action">Today</string>
|
||||
|
||||
<!-- Week and day timelines — edge bar for events scrolled out of view (#188) -->
|
||||
<string name="timeline_earlier_events">Earlier events</string>
|
||||
<string name="timeline_later_events">Later events</string>
|
||||
|
||||
<!-- Event detail screen (S4) -->
|
||||
<string name="event_detail_back">Back</string>
|
||||
<string name="event_detail_edit">Edit</string>
|
||||
|
||||
@@ -156,4 +156,38 @@ class EventToneTest {
|
||||
assertThat(eventTone(0xFFFFF6C0.toInt(), dark = false, harmonise = false).onContainer)
|
||||
.isEqualTo(0xFF000000.toInt())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the edge bar steps clear of its block's container on every hue`() {
|
||||
for (dark in listOf(false, true)) {
|
||||
for (harmonise in listOf(false, true)) {
|
||||
(hues + 0xFFFFF6C0.toInt() + 0xFF000000.toInt() + 0xFFFFFFFF.toInt()).forEach { raw ->
|
||||
val container = oklchOf(eventTone(raw, dark, harmonise).container)
|
||||
val bar = oklchOf(edgeHintTone(raw, dark, harmonise))
|
||||
assertThat(kotlin.math.abs(bar.lightness - container.lightness))
|
||||
.isAtLeast(EDGE_HINT_STEP - 0.02f)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the edge bar goes darker on a light scheme and lighter on a dark one`() {
|
||||
hues.forEach { raw ->
|
||||
val light = eventTone(raw, dark = false, harmonise = true).container
|
||||
val dark = eventTone(raw, dark = true, harmonise = true).container
|
||||
assertThat(oklchOf(edgeHintTone(raw, dark = false, harmonise = true)).lightness)
|
||||
.isLessThan(oklchOf(light).lightness)
|
||||
assertThat(oklchOf(edgeHintTone(raw, dark = true, harmonise = true)).lightness)
|
||||
.isGreaterThan(oklchOf(dark).lightness)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a pale block on a dark scheme gets a deeper bar rather than a white one`() {
|
||||
val pale = 0xFFFFF6C0.toInt()
|
||||
val container = oklchOf(eventTone(pale, dark = true, harmonise = true).container)
|
||||
assertThat(oklchOf(edgeHintTone(pale, dark = true, harmonise = true)).lightness)
|
||||
.isLessThan(container.lightness)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.ui.week.TimedBlock
|
||||
import kotlin.time.Instant
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
/** Which events a column's edge bars point at, and where a tap scrolls (#188). */
|
||||
class OutOfViewEdgesTest {
|
||||
|
||||
private fun block(id: Long, startMin: Int, endMin: Int) = TimedBlock(
|
||||
event = EventInstance(
|
||||
instanceId = id,
|
||||
eventId = id,
|
||||
calendarId = 1L,
|
||||
title = "Event $id",
|
||||
start = Instant.fromEpochMilliseconds(0),
|
||||
end = Instant.fromEpochMilliseconds(0),
|
||||
isAllDay = false,
|
||||
color = 0xFF112233.toInt(),
|
||||
location = null,
|
||||
),
|
||||
startMin = startMin,
|
||||
endMin = endMin,
|
||||
lane = 0,
|
||||
laneCount = 1,
|
||||
)
|
||||
|
||||
private val early = block(1, 7 * 60, 8 * 60)
|
||||
private val breakfast = block(2, 8 * 60, 9 * 60)
|
||||
private val lunch = block(3, 12 * 60, 13 * 60)
|
||||
private val dinner = block(4, 19 * 60, 20 * 60)
|
||||
private val late = block(5, 21 * 60, 22 * 60)
|
||||
|
||||
// Viewport 10:00–17:00.
|
||||
private fun outOf(vararg blocks: TimedBlock, slackMin: Float = 0f) =
|
||||
outOfViewEvents(blocks.toList(), topMin = 600f, bottomMin = 1020f, slackMin = slackMin)
|
||||
|
||||
@Test
|
||||
fun `the nearest hidden event on each side is picked`() {
|
||||
val events = outOf(early, breakfast, lunch, dinner, late)
|
||||
assertThat(events.above).isEqualTo(breakfast)
|
||||
assertThat(events.below).isEqualTo(dinner)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an event in view points nowhere`() {
|
||||
assertThat(outOf(lunch)).isEqualTo(OutOfViewEvents.None)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an event cut by the edge is not out of view`() {
|
||||
val straddling = block(6, 9 * 60, 11 * 60)
|
||||
assertThat(outOf(straddling).above).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a sliver within the slack still counts as out of view`() {
|
||||
val sliver = block(6, 9 * 60, 10 * 60 + 5)
|
||||
assertThat(outOf(sliver).above).isNull()
|
||||
assertThat(outOf(sliver, slackMin = 10f).above).isEqualTo(sliver)
|
||||
val tip = block(7, 17 * 60 - 5, 18 * 60)
|
||||
assertThat(outOf(tip).below).isNull()
|
||||
assertThat(outOf(tip, slackMin = 10f).below).isEqualTo(tip)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a column scrolled to its top points at nothing above it`() {
|
||||
val first = block(6, 0, 30)
|
||||
val events = outOfViewEvents(listOf(first), topMin = 0f, bottomMin = 420f, slackMin = 30f)
|
||||
assertThat(events.above).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a short event is judged by the height it renders at`() {
|
||||
// Five minutes ending at 09:58 still draws 26 minutes tall, past 10:00.
|
||||
val blip = block(6, 9 * 60 + 53, 9 * 60 + 58)
|
||||
assertThat(outOf(blip).above).isNull()
|
||||
}
|
||||
|
||||
@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)
|
||||
assertThat(target).isEqualTo(780)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an event below that fits lands whole at the bottom`() {
|
||||
val target = revealScrollTarget(dinner, ViewportEdge.Bottom, hourPx = 100f, viewportPx = 700, marginPx = 20f, maxScroll = 1700)
|
||||
// Its end, 2000, plus the margin, sits at the viewport's bottom.
|
||||
assertThat(target).isEqualTo(1320)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an event below taller than the viewport lands with its start at the top`() {
|
||||
val long = block(6, 12 * 60, 22 * 60)
|
||||
val target = revealScrollTarget(long, ViewportEdge.Bottom, hourPx = 100f, viewportPx = 700, marginPx = 20f, maxScroll = 1700)
|
||||
assertThat(target).isEqualTo(1180)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the target stays within the scroll range`() {
|
||||
val first = block(6, 0, 30)
|
||||
assertThat(
|
||||
revealScrollTarget(first, ViewportEdge.Top, hourPx = 100f, viewportPx = 700, marginPx = 20f, maxScroll = 1700),
|
||||
).isEqualTo(0)
|
||||
val last = block(7, 23 * 60, 24 * 60)
|
||||
assertThat(
|
||||
revealScrollTarget(last, ViewportEdge.Bottom, hourPx = 100f, viewportPx = 700, marginPx = 20f, maxScroll = 1700),
|
||||
).isEqualTo(1700)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user