From d1664b28def4a23f9171d312f08a7a708900c7bd Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 27 Sep 2026 11:16:28 +0200 Subject: [PATCH 1/3] 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 9ae51fe..12f0cc3 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 0000000..707393e --- /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 13b2ef2..b106f04 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 7cee5ee..7e066a2 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 82e8ca8..f1dcca0 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 caed3b2..9d0a7b0 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 0000000..74263c3 --- /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) + } +} From 8d3081a0861517ed118d8ad166f6ff37408b03dd Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 27 Sep 2026 12:06:11 +0200 Subject: [PATCH 2/3] Let taps reach events under the edge bar (#188) --- .../calendula/ui/common/OutOfViewEdges.kt | 183 +++++++++++++----- .../calendula/ui/day/DayScreen.kt | 7 +- .../calendula/ui/week/WeekScreen.kt | 7 +- .../calendula/ui/common/OutOfViewEdgesTest.kt | 24 +++ 4 files changed, 165 insertions(+), 56 deletions(-) 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 index 707393e..7064134 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/OutOfViewEdges.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/OutOfViewEdges.kt @@ -2,12 +2,11 @@ package de.jeanlucmakiola.calendula.ui.common import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.FiniteAnimationSpec 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 @@ -19,12 +18,15 @@ import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.MaterialTheme 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.Color @@ -32,6 +34,8 @@ 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 @@ -39,15 +43,19 @@ 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.CoroutineScope 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. */ +/** 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. @@ -118,65 +126,136 @@ fun revealScrollTarget( } /** - * 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. + * 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. */ -@Composable -fun BoxScope.OutOfViewEdgeBars( - blocks: List, - scrollState: ScrollState, - hourPx: Float, - dark: Boolean, +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 +} + +/** + * 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, ) { - 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, - ) - } + internal var blocks by mutableStateOf(emptyList()) + internal var hourPx by mutableFloatStateOf(0f) + internal var slackPx by mutableFloatStateOf(0f) + internal var marginPx by mutableFloatStateOf(0f) + internal var reachPx by mutableFloatStateOf(0f) + internal var reduceMotion = false + internal var revealSpec: FiniteAnimationSpec? = 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, + slackMin = slackPx / hourPx * 60f, + ) } } - val scope = rememberCoroutineScope() - val reduceMotion = rememberReduceMotion() - val revealSpec = rememberCalendarPageSpec() - val reveal: (TimedBlock, ViewportEdge) -> Unit = { block, edge -> + + /** 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 = touchPx.toFloat(), + marginPx = marginPx, maxScroll = scrollState.maxValue, ) + val spec = revealSpec scope.launch { - if (reduceMotion) { + if (reduceMotion || spec == null) { scrollState.scrollTo(target) } else { - scrollState.animateScrollTo(target, revealSpec) + 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, + 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.slackPx = with(density) { EDGE_SLACK.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. 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 = reveal, + onReveal = edges::reveal, modifier = Modifier .align(Alignment.TopStart) .offset { IntOffset(0, scrollState.value) }, @@ -185,7 +264,7 @@ fun BoxScope.OutOfViewEdgeBars( block = events.below, edge = ViewportEdge.Bottom, dark = dark, - onReveal = reveal, + onReveal = edges::reveal, modifier = Modifier .align(Alignment.TopStart) .offset { IntOffset(0, scrollState.value + scrollState.viewportSize - touchPx) }, @@ -198,10 +277,10 @@ private fun EdgeBar( block: TimedBlock?, edge: ViewportEdge, dark: Boolean, - onReveal: (TimedBlock, ViewportEdge) -> Unit, + onReveal: (ViewportEdge) -> Unit, modifier: Modifier = Modifier, ) { - // Held so the bar keeps its colour, and its tap, while it fades out. + // Held so the bar keeps its colour while it fades out. val held = remember { mutableStateOf(null) } SideEffect { if (block != null) held.value = block } val shown = block ?: held.value @@ -226,14 +305,14 @@ private fun EdgeBar( 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 }, + .semantics { + role = Role.Button + contentDescription = label + onClick { + onReveal(edge) + true + } + }, contentAlignment = if (edge == ViewportEdge.Top) Alignment.TopCenter else Alignment.BottomCenter, ) { Box( 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 b106f04..3544cd0 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 @@ -107,6 +107,7 @@ 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 @@ -764,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. @@ -791,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), @@ -853,9 +857,8 @@ private fun DayColumnCard( NowLine(date = date, hourHeight = hourHeight) } OutOfViewEdgeBars( - blocks = blocks, + edges = edges, 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 7e066a2..8d157a5 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 @@ -133,6 +133,7 @@ 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 @@ -1040,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. @@ -1067,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), @@ -1129,9 +1133,8 @@ private fun DayColumnCard( NowLine(date = date, hourHeight = hourHeight) } OutOfViewEdgeBars( - blocks = blocks, + edges = edges, scrollState = scrollState, - hourPx = hourPx, dark = dark, ) } 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 index 74263c3..56a5865 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/OutOfViewEdgesTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/OutOfViewEdgesTest.kt @@ -110,4 +110,28 @@ class OutOfViewEdgesTest { 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() + } } From 2119a369692396e08f8ffa089e9997aea557b328 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 27 Sep 2026 19:53:22 +0200 Subject: [PATCH 3/3] Match edge indicators to event colors and clear before reveal (#188) --- .../calendula/domain/color/EventTone.kt | 28 ----- .../calendula/ui/common/OutOfViewEdges.kt | 118 +++++++++--------- .../calendula/domain/color/EventToneTest.kt | 33 ----- .../calendula/ui/common/OutOfViewEdgesTest.kt | 22 +++- 4 files changed, 74 insertions(+), 127 deletions(-) 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 12f0cc3..9ae51fe 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,31 +122,3 @@ 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 index 7064134..7954e3c 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/OutOfViewEdges.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/OutOfViewEdges.kt @@ -1,10 +1,6 @@ package de.jeanlucmakiola.calendula.ui.common -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.FiniteAnimationSpec -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut import androidx.compose.foundation.ScrollState import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box @@ -14,8 +10,6 @@ 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.Stable @@ -29,7 +23,7 @@ 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.Color +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role @@ -40,7 +34,6 @@ 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.CoroutineScope @@ -63,8 +56,8 @@ private val EDGE_TAP_REACH = 16.dp 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 +/** 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 @@ -83,21 +76,19 @@ internal fun renderedEndMin(block: TimedBlock): Float = /** * 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. + * (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, topMin: Float, bottomMin: Float, - slackMin: Float, ): OutOfViewEvents { val above = blocks - .filter { it.startMin < topMin && renderedEndMin(it) <= topMin + slackMin } + .filter { renderedEndMin(it) <= topMin } .maxWithOrNull(compareBy({ renderedEndMin(it) }, { it.startMin })) val below = blocks - .filter { renderedEndMin(it) > bottomMin && it.startMin >= bottomMin - slackMin } + .filter { it.startMin >= bottomMin } .minWithOrNull(compareBy({ it.startMin }, { -renderedEndMin(it) })) return OutOfViewEvents(above, below) } @@ -141,6 +132,13 @@ fun tappedEdge( 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. @@ -155,7 +153,7 @@ class OutOfViewEdges internal constructor( ) { internal var blocks by mutableStateOf(emptyList()) internal var hourPx by mutableFloatStateOf(0f) - internal var slackPx 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 @@ -171,7 +169,6 @@ class OutOfViewEdges internal constructor( blocks = blocks, topMin = top / hourPx * 60f, bottomMin = (top + viewport) / hourPx * 60f, - slackMin = slackPx / hourPx * 60f, ) } } @@ -226,7 +223,7 @@ fun rememberOutOfViewEdges( SideEffect { edges.blocks = blocks edges.hourPx = hourPx - edges.slackPx = with(density) { EDGE_SLACK.toPx() } + 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 @@ -241,7 +238,9 @@ fun rememberOutOfViewEdges( * * Lives inside the column's scrolling content and follows the viewport by * offsetting against [scrollState] at placement rather than recomposing every - * frame. Taps reach it through [OutOfViewEdges.tap]; screen readers get a button. + * 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( @@ -256,6 +255,9 @@ fun BoxScope.OutOfViewEdgeBars( 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) }, @@ -265,66 +267,62 @@ fun BoxScope.OutOfViewEdgeBars( 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) }, ) } -@OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable private fun EdgeBar( block: TimedBlock?, edge: ViewportEdge, dark: Boolean, onReveal: (ViewportEdge) -> Unit, + scrollState: ScrollState, + hourPx: Float, + fadeLeadPx: Float, modifier: Modifier = Modifier, ) { - // Held so the bar keeps its colour 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", - ) + 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, ) - AnimatedVisibility( - visible = block != null, - enter = fadeIn(effects), - exit = fadeOut(effects), - modifier = modifier, + 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() - .height(EDGE_TOUCH_HEIGHT) - .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), - ) - } + .padding( + horizontal = EDGE_BAR_HORIZONTAL_INSET, + vertical = EDGE_BAR_VERTICAL_INSET, + ) + .height(EDGE_BAR_THICKNESS) + .background(color, CircleShape), + ) } } 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 9d0a7b0..0446a90 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 @@ -157,37 +157,4 @@ class EventToneTest { .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 index 56a5865..6268b26 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/OutOfViewEdgesTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/OutOfViewEdgesTest.kt @@ -34,8 +34,8 @@ class OutOfViewEdgesTest { 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) + private fun outOf(vararg blocks: TimedBlock) = + outOfViewEvents(blocks.toList(), topMin = 600f, bottomMin = 1020f) @Test fun `the nearest hidden event on each side is picked`() { @@ -56,19 +56,29 @@ class OutOfViewEdgesTest { } @Test - fun `a sliver within the slack still counts as out of view`() { + 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() - 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) + 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, slackMin = 30f) + val events = outOfViewEvents(listOf(first), topMin = 0f, bottomMin = 420f) assertThat(events.above).isNull() }