Compare commits

..
9 changed files with 569 additions and 20 deletions
@@ -88,6 +88,7 @@ class ReminderNotifier @Inject constructor(
is24Hour = is24Hour,
today = Instant.now().atZone(zone).toLocalDate(),
firstDayOfWeek = firstDayOfWeek,
todayLabel = context.getString(R.string.reminder_day_today),
tomorrowLabel = context.getString(R.string.reminder_day_tomorrow),
yesterdayLabel = context.getString(R.string.reminder_day_yesterday),
)
@@ -27,12 +27,12 @@ import java.util.Locale
* alone would be ambiguous)
* - timed, crossing days: "11 Jun, 23:30 – 12 Jun, 00:30" (medium date + short time,
* already unambiguous)
* - all-day, one day: "11 Jun 2026"
* - all-day, multi-day: "11 Jun 2026 – 12 Jun 2026"
* - all-day, one day: "Tomorrow" / "Thu" / "11 Jun 2026" (same relative rules,
* plus [todayLabel] — no time range to lean on)
* - all-day, multi-day: "Tomorrow – 12 Jun 2026" (relative start, explicit end)
*
* All-day instances already carry an explicit date, so they never gain a
* relative prefix. They store UTC midnights with an exclusive end, so they are
* read in UTC and the end day is the last *covered* day.
* All-day instances store UTC midnights with an exclusive end, so they are read
* in UTC and the end day is the last *covered* day (issue #308).
*/
fun reminderTimeText(
beginMillis: Long,
@@ -43,6 +43,7 @@ fun reminderTimeText(
is24Hour: Boolean,
today: LocalDate,
firstDayOfWeek: DayOfWeek,
todayLabel: String,
tomorrowLabel: String,
yesterdayLabel: String,
): String {
@@ -53,11 +54,16 @@ fun reminderTimeText(
val lastDay = Instant.ofEpochMilli(endMillis).atZone(ZoneOffset.UTC).toLocalDate()
.minusDays(1)
.coerceAtLeast(firstDay)
return if (lastDay == firstDay) {
dateFormat.format(firstDay)
} else {
dateFormat.format(firstDay) + RANGE + dateFormat.format(lastDay)
}
val start = relativeDayPrefix(
day = firstDay,
today = today,
firstDayOfWeek = firstDayOfWeek,
locale = locale,
dateFormat = dateFormat,
tomorrowLabel = tomorrowLabel,
yesterdayLabel = yesterdayLabel,
) ?: todayLabel
return if (lastDay == firstDay) start else start + RANGE + dateFormat.format(lastDay)
}
val timeFormat = timeOfDayFormatter(is24Hour, locale)
@@ -0,0 +1,328 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.animation.core.FiniteAnimationSpec
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.background
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.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
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.onClick
import androidx.compose.ui.semantics.role
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.ui.week.TimedBlock
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import kotlin.math.roundToInt
/** Thickness of the out-of-view edge bar. */
private val EDGE_BAR_THICKNESS = 3.dp
/** Band the bar sits in, and the room a revealed event keeps from the edge. */
private val EDGE_TOUCH_HEIGHT = 24.dp
/** How far from the viewport's edge a tap on empty column still hits the bar. */
private val EDGE_TAP_REACH = 16.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
/** Distance outside the viewport over which a bar fades before its event appears. */
private val EDGE_FADE_LEAD = 24.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 leaves this set as soon as any
* part of its rendered height 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)
}
/**
* 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))
}
/**
* The edge whose bar a tap at [y] (content px) lands on, if that bar is showing
* and the tap is within [reachPx] of the viewport's edge.
*/
fun tappedEdge(
events: OutOfViewEvents,
y: Float,
viewportTop: Float,
viewportPx: Int,
reachPx: Float,
): ViewportEdge? = when {
events.above != null && y - viewportTop <= reachPx -> ViewportEdge.Top
events.below != null && viewportTop + viewportPx - y <= reachPx -> ViewportEdge.Bottom
else -> null
}
/** Fade while the event is still outside; never draw its bar at the boundary. */
internal fun edgeBarAlpha(gapPx: Float, fadeLeadPx: Float): Float = when {
gapPx <= 0f -> 0f
fadeLeadPx <= 0f -> 1f
else -> (gapPx / fadeLeadPx).coerceIn(0f, 1f)
}
/**
* The out-of-view state of one day column (#188): which events hide past its
* edges, and the scroll that reveals them.
*
* The bars take no touches themselves, so event blocks under them keep their
* taps and drags; the column's own tap handler offers taps to [tap] first.
*/
@Stable
class OutOfViewEdges internal constructor(
private val scrollState: ScrollState,
private val scope: CoroutineScope,
) {
internal var blocks by mutableStateOf(emptyList<TimedBlock>())
internal var hourPx by mutableFloatStateOf(0f)
internal var fadeLeadPx by mutableFloatStateOf(0f)
internal var marginPx by mutableFloatStateOf(0f)
internal var reachPx by mutableFloatStateOf(0f)
internal var reduceMotion = false
internal var revealSpec: FiniteAnimationSpec<Float>? = null
val events: OutOfViewEvents by 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,
)
}
}
/** Scrolls the event hidden past [edge] into view, if there is one. */
fun reveal(edge: ViewportEdge) {
val block = if (edge == ViewportEdge.Top) events.above else events.below
block ?: return
val target = revealScrollTarget(
block = block,
edge = edge,
hourPx = hourPx,
viewportPx = scrollState.viewportSize,
marginPx = marginPx,
maxScroll = scrollState.maxValue,
)
val spec = revealSpec
scope.launch {
if (reduceMotion || spec == null) {
scrollState.scrollTo(target)
} else {
scrollState.animateScrollTo(target, spec)
}
}
}
/** Reveals the hidden event if a tap at [y] (content px) lands on a bar. */
fun tap(y: Float): Boolean {
val edge = tappedEdge(
events = events,
y = y,
viewportTop = scrollState.value.toFloat(),
viewportPx = scrollState.viewportSize,
reachPx = reachPx,
) ?: return false
reveal(edge)
return true
}
}
@Composable
fun rememberOutOfViewEdges(
blocks: List<TimedBlock>,
scrollState: ScrollState,
hourPx: Float,
): OutOfViewEdges {
val scope = rememberCoroutineScope()
val edges = remember(scrollState, scope) { OutOfViewEdges(scrollState, scope) }
val density = LocalDensity.current
val reduceMotion = rememberReduceMotion()
val revealSpec = rememberCalendarPageSpec()
SideEffect {
edges.blocks = blocks
edges.hourPx = hourPx
edges.fadeLeadPx = with(density) { EDGE_FADE_LEAD.toPx() }
edges.marginPx = with(density) { EDGE_TOUCH_HEIGHT.roundToPx().toFloat() }
edges.reachPx = with(density) { EDGE_TAP_REACH.toPx() }
edges.reduceMotion = reduceMotion
edges.revealSpec = revealSpec
}
return edges
}
/**
* 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).
*
* Lives inside the column's scrolling content and follows the viewport by
* offsetting against [scrollState] at placement rather than recomposing every
* frame. Opacity follows the gap to the hidden event and reaches zero before
* the event enters view. Taps reach it through [OutOfViewEdges.tap]; screen
* readers get a button.
*/
@Composable
fun BoxScope.OutOfViewEdgeBars(
edges: OutOfViewEdges,
scrollState: ScrollState,
dark: Boolean,
) {
val touchPx = with(LocalDensity.current) { EDGE_TOUCH_HEIGHT.roundToPx() }
val events = edges.events
EdgeBar(
block = events.above,
edge = ViewportEdge.Top,
dark = dark,
onReveal = edges::reveal,
scrollState = scrollState,
hourPx = edges.hourPx,
fadeLeadPx = edges.fadeLeadPx,
modifier = Modifier
.align(Alignment.TopStart)
.offset { IntOffset(0, scrollState.value) },
)
EdgeBar(
block = events.below,
edge = ViewportEdge.Bottom,
dark = dark,
onReveal = edges::reveal,
scrollState = scrollState,
hourPx = edges.hourPx,
fadeLeadPx = edges.fadeLeadPx,
modifier = Modifier
.align(Alignment.TopStart)
.offset { IntOffset(0, scrollState.value + scrollState.viewportSize - touchPx) },
)
}
@Composable
private fun EdgeBar(
block: TimedBlock?,
edge: ViewportEdge,
dark: Boolean,
onReveal: (ViewportEdge) -> Unit,
scrollState: ScrollState,
hourPx: Float,
fadeLeadPx: Float,
modifier: Modifier = Modifier,
) {
block ?: return
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,
)
Box(
modifier = modifier
.fillMaxWidth()
.height(EDGE_TOUCH_HEIGHT)
.graphicsLayer {
val gapPx = when (edge) {
ViewportEdge.Top -> scrollState.value - renderedEndMin(block) / 60f * hourPx
ViewportEdge.Bottom -> block.startMin / 60f * hourPx -
(scrollState.value + scrollState.viewportSize)
}
alpha = edgeBarAlpha(gapPx, fadeLeadPx)
}
.semantics {
role = Role.Button
contentDescription = label
onClick {
onReveal(edge)
true
}
},
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,8 @@ 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.rememberOutOfViewEdges
import de.jeanlucmakiola.calendula.ui.common.TimelineDragController
import de.jeanlucmakiola.calendula.ui.common.TimelineDragOverlay
import de.jeanlucmakiola.calendula.ui.common.TimelineDrop
@@ -707,6 +709,7 @@ private fun Timeline(
date = state.date,
today = state.today,
hourHeight = hourHeight,
scrollState = scrollState,
dragController = dragController,
onEventClick = onEventClick,
onCreateAt = onCreateAt,
@@ -742,6 +745,7 @@ private fun DayColumnCard(
date: LocalDate,
today: LocalDate,
hourHeight: Dp,
scrollState: ScrollState,
dragController: TimelineDragController,
onEventClick: (EventInstance) -> Unit,
onCreateAt: (LocalDate, Int) -> Unit,
@@ -761,6 +765,8 @@ private fun DayColumnCard(
// them, or taps land on the scale the column had before the zoom (#148).
val currentHourPx = rememberUpdatedState(hourPx)
val currentOnCreateAt = rememberUpdatedState(onCreateAt)
val edges = rememberOutOfViewEdges(blocks, scrollState, hourPx)
val currentEdges = rememberUpdatedState(edges)
Card(
// Plain rectangular column — the soft corners come from the outer
// rounded scroll viewport, so inner rounding would look odd at the edges.
@@ -788,6 +794,7 @@ private fun DayColumnCard(
// only fires on the column background. Snaps to the tapped hour.
.pointerInput(date) {
detectTapGestures { offset ->
if (currentEdges.value.tap(offset.y)) return@detectTapGestures
currentOnCreateAt.value(
date,
tappedMinuteOfDay(offset.y, currentHourPx.value),
@@ -849,6 +856,11 @@ private fun DayColumnCard(
if (date == today) {
NowLine(date = date, hourHeight = hourHeight)
}
OutOfViewEdgeBars(
edges = edges,
scrollState = scrollState,
dark = dark,
)
}
}
}
@@ -132,6 +132,8 @@ 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.rememberOutOfViewEdges
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
@@ -997,6 +999,7 @@ private fun Timeline(
date = day,
today = state.today,
hourHeight = hourHeight,
scrollState = scrollState,
dragController = dragController,
onEventClick = onEventClick,
onCreateAt = onCreateAt,
@@ -1018,6 +1021,7 @@ private fun DayColumnCard(
date: LocalDate,
today: LocalDate,
hourHeight: Dp,
scrollState: ScrollState,
dragController: TimelineDragController,
onEventClick: (EventInstance) -> Unit,
onCreateAt: (LocalDate, Int) -> Unit,
@@ -1037,6 +1041,8 @@ private fun DayColumnCard(
// them, or taps land on the scale the column had before the zoom (#148).
val currentHourPx = rememberUpdatedState(hourPx)
val currentOnCreateAt = rememberUpdatedState(onCreateAt)
val edges = rememberOutOfViewEdges(blocks, scrollState, hourPx)
val currentEdges = rememberUpdatedState(edges)
Card(
// Plain rectangular columns — the soft corners come from the outer
// rounded scroll viewport, so inner rounding would look odd at the edges.
@@ -1064,6 +1070,7 @@ private fun DayColumnCard(
// blocks are consumed by their own handler first. Snaps to hour.
.pointerInput(date) {
detectTapGestures { offset ->
if (currentEdges.value.tap(offset.y)) return@detectTapGestures
currentOnCreateAt.value(
date,
tappedMinuteOfDay(offset.y, currentHourPx.value),
@@ -1125,6 +1132,11 @@ private fun DayColumnCard(
if (date == today) {
NowLine(date = date, hourHeight = hourHeight)
}
OutOfViewEdgeBars(
edges = edges,
scrollState = scrollState,
dark = dark,
)
}
}
}
+5
View File
@@ -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>
@@ -349,6 +353,7 @@
<string name="reminder_action_snooze">Snooze</string>
<string name="reminder_action_dismiss">Dismiss</string>
<!-- Day context prefix in a reminder for an event on another day (v2.15.0) -->
<string name="reminder_day_today">Today</string>
<string name="reminder_day_tomorrow">Tomorrow</string>
<string name="reminder_day_yesterday">Yesterday</string>
@@ -38,6 +38,7 @@ class ReminderTimeTextTest {
is24Hour = is24Hour,
today = today ?: java.time.Instant.ofEpochMilli(beginMillis).atZone(zone).toLocalDate(),
firstDayOfWeek = firstDayOfWeek,
todayLabel = "Today",
tomorrowLabel = "Tomorrow",
yesterdayLabel = "Yesterday",
)
@@ -130,38 +131,74 @@ class ReminderTimeTextTest {
}
@Test
fun `all-day single day shows one date, read in UTC`() {
fun `all-day single day today reads as today, read in UTC`() {
val text = text(
beginMillis = utcMidnight(LocalDate.of(2026, 6, 11)),
endMillis = utcMidnight(LocalDate.of(2026, 6, 12)),
isAllDay = true,
// Zone must not matter for all-day events: UTC midnight is
// 02:00 in Berlin — naive local reading would shift the day.
// the previous evening in Los Angeles — naive local reading would shift the day.
zone = ZoneId.of("America/Los_Angeles"),
today = LocalDate.of(2026, 6, 11),
)
assertThat(text).isEqualTo("11.06.2026")
assertThat(text).isEqualTo("Today")
}
@Test
fun `all-day event tomorrow still shows the exact date, no relative prefix`() {
fun `all-day event tomorrow reads as tomorrow`() {
val text = text(
beginMillis = utcMidnight(LocalDate.of(2026, 6, 12)),
endMillis = utcMidnight(LocalDate.of(2026, 6, 13)),
isAllDay = true,
today = LocalDate.of(2026, 6, 11),
)
assertThat(text).isEqualTo("12.06.2026")
assertThat(text).isEqualTo("Tomorrow")
}
@Test
fun `all-day multi-day shows the last covered day, not the exclusive end`() {
fun `all-day event later this week reads as the short weekday`() {
// 2026-06-11 is a Thursday; Saturday the 13th shares its week.
val text = text(
beginMillis = utcMidnight(LocalDate.of(2026, 6, 11)),
endMillis = utcMidnight(LocalDate.of(2026, 6, 13)),
beginMillis = utcMidnight(LocalDate.of(2026, 6, 13)),
endMillis = utcMidnight(LocalDate.of(2026, 6, 14)),
isAllDay = true,
locale = Locale.US,
today = LocalDate.of(2026, 6, 11),
)
assertThat(text).isEqualTo("Sat")
}
@Test
fun `all-day event next week falls back to the exact date`() {
val text = text(
beginMillis = utcMidnight(LocalDate.of(2026, 6, 16)),
endMillis = utcMidnight(LocalDate.of(2026, 6, 17)),
isAllDay = true,
today = LocalDate.of(2026, 6, 11),
)
assertThat(text).isEqualTo("11.06.2026 – 12.06.2026")
assertThat(text).isEqualTo("16.06.2026")
}
@Test
fun `all-day multi-day reads relative start and the last covered day`() {
val text = text(
beginMillis = utcMidnight(LocalDate.of(2026, 6, 12)),
endMillis = utcMidnight(LocalDate.of(2026, 6, 14)),
isAllDay = true,
today = LocalDate.of(2026, 6, 11),
)
assertThat(text).isEqualTo("Tomorrow – 13.06.2026")
}
@Test
fun `all-day multi-day further out keeps both dates`() {
val text = text(
beginMillis = utcMidnight(LocalDate.of(2026, 7, 1)),
endMillis = utcMidnight(LocalDate.of(2026, 7, 3)),
isAllDay = true,
today = LocalDate.of(2026, 6, 11),
)
assertThat(text).isEqualTo("01.07.2026 – 02.07.2026")
}
@Test
@@ -173,6 +210,6 @@ class ReminderTimeTextTest {
isAllDay = true,
today = LocalDate.of(2026, 6, 11),
)
assertThat(text).isEqualTo("11.06.2026")
assertThat(text).isEqualTo("Today")
}
}
@@ -156,4 +156,5 @@ class EventToneTest {
assertThat(eventTone(0xFFFFF6C0.toInt(), dark = false, harmonise = false).onContainer)
.isEqualTo(0xFF000000.toInt())
}
}
@@ -0,0 +1,147 @@
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) =
outOfViewEvents(blocks.toList(), topMin = 600f, bottomMin = 1020f)
@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 `an event disappears from the edge as soon as a sliver enters view`() {
val sliver = block(6, 9 * 60, 10 * 60 + 5)
assertThat(outOf(sliver).above).isNull()
val tip = block(7, 17 * 60 - 5, 18 * 60)
assertThat(outOf(tip).below).isNull()
val justAbove = block(8, 9 * 60, 10 * 60)
val justBelow = block(9, 17 * 60, 18 * 60)
assertThat(outOf(justAbove).above).isEqualTo(justAbove)
assertThat(outOf(justBelow).below).isEqualTo(justBelow)
}
@Test
fun `the bar fades before its event reaches the viewport`() {
assertThat(edgeBarAlpha(gapPx = 24f, fadeLeadPx = 24f)).isEqualTo(1f)
assertThat(edgeBarAlpha(gapPx = 12f, fadeLeadPx = 24f)).isEqualTo(0.5f)
assertThat(edgeBarAlpha(gapPx = 0f, fadeLeadPx = 24f)).isEqualTo(0f)
assertThat(edgeBarAlpha(gapPx = -1f, fadeLeadPx = 24f)).isEqualTo(0f)
}
@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)
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)
}
// Viewport 1000..1700 px with both bars showing, 40 px of reach.
private fun tapped(y: Float, events: OutOfViewEvents = OutOfViewEvents(breakfast, dinner)) =
tappedEdge(events, y, viewportTop = 1000f, viewportPx = 700, reachPx = 40f)
@Test
fun `a tap near an edge with a bar hits that bar`() {
assertThat(tapped(1010f)).isEqualTo(ViewportEdge.Top)
assertThat(tapped(1040f)).isEqualTo(ViewportEdge.Top)
assertThat(tapped(1690f)).isEqualTo(ViewportEdge.Bottom)
}
@Test
fun `a tap past the bar's reach is left to the column`() {
assertThat(tapped(1041f)).isNull()
assertThat(tapped(1350f)).isNull()
assertThat(tapped(1659f)).isNull()
}
@Test
fun `a tap near an edge without a bar is left to the column`() {
assertThat(tapped(1010f, OutOfViewEvents(null, dinner))).isNull()
assertThat(tapped(1690f, OutOfViewEvents(breakfast, null))).isNull()
}
}