Tint a day column's edge when events sit out of view (#188)

This commit is contained in:
2026-09-30 12:48:06 +02:00
parent 252689038e
commit d1664b28de
7 changed files with 448 additions and 0 deletions
@@ -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,
)
}
}
}
+4
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>
@@ -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)
}
}