From d1664b28def4a23f9171d312f08a7a708900c7bd Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 27 Sep 2026 11:16:28 +0200 Subject: [PATCH] Tint a day column's edge when events sit out of view (#188) --- .../calendula/domain/color/EventTone.kt | 28 ++ .../calendula/ui/common/OutOfViewEdges.kt | 251 ++++++++++++++++++ .../calendula/ui/day/DayScreen.kt | 9 + .../calendula/ui/week/WeekScreen.kt | 9 + app/src/main/res/values/strings.xml | 4 + .../calendula/domain/color/EventToneTest.kt | 34 +++ .../calendula/ui/common/OutOfViewEdgesTest.kt | 113 ++++++++ 7 files changed, 448 insertions(+) create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/ui/common/OutOfViewEdges.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/calendula/ui/common/OutOfViewEdgesTest.kt diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/color/EventTone.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/color/EventTone.kt index 9ae51fe7..12f0cc34 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/color/EventTone.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/color/EventTone.kt @@ -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 diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/OutOfViewEdges.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/OutOfViewEdges.kt new file mode 100644 index 00000000..707393e8 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/OutOfViewEdges.kt @@ -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, + topMin: Float, + bottomMin: Float, + slackMin: Float, +): OutOfViewEvents { + val above = blocks + .filter { it.startMin < topMin && renderedEndMin(it) <= topMin + slackMin } + .maxWithOrNull(compareBy({ renderedEndMin(it) }, { it.startMin })) + val below = blocks + .filter { renderedEndMin(it) > bottomMin && it.startMin >= bottomMin - slackMin } + .minWithOrNull(compareBy({ 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, + 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(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() + 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), + ) + } + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt index 13b2ef26..b106f044 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt @@ -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, + ) } } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt index 7cee5eef..7e066a24 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt @@ -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, + ) } } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 82e8ca80..f1dcca08 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -67,6 +67,10 @@ Today + + Earlier events + Later events + Back Edit diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/domain/color/EventToneTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/domain/color/EventToneTest.kt index caed3b22..9d0a7b0c 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/domain/color/EventToneTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/domain/color/EventToneTest.kt @@ -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) + } } diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/OutOfViewEdgesTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/OutOfViewEdgesTest.kt new file mode 100644 index 00000000..74263c31 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/OutOfViewEdgesTest.kt @@ -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) + } +}