Flick up to turn the card, and say which tide is next

778a0b4 · Gabriel Morell · 2026-09-08 20:33

22 files +109 -77
Message
{commit_body(@commit)}

Files changed

modified clients/android/app/src/main/java/io/neiam/apolloscrib/ui/BoardScreen.kt
+39 −5
@@ -8,6 +8,9 @@ import androidx.compose.foundation.combinedClickable
8 8 import io.neiam.apolloscrib.types.GtfsPlusCondensed
9 9 import io.neiam.apolloscrib.types.SourceType
10 10 import io.neiam.apolloscrib.types.VisionEntry
11 +import androidx.compose.foundation.gestures.detectVerticalDragGestures
12 +import androidx.compose.foundation.layout.Box
13 +import androidx.compose.foundation.layout.height
11 14 import androidx.compose.foundation.gestures.detectHorizontalDragGestures
12 15 import androidx.compose.foundation.layout.Arrangement
13 16 import androidx.compose.foundation.layout.PaddingValues
@@ -119,8 +122,8 @@ fun BoardScreen(
119 122 // Rotated only when there is more than fits. Turning a list that is
120 123 // already whole would take cards away and give them back for nothing.
121 124 val rotating = richCards > 0 && cards.size > richCards
122 − val window = rotatingWindow(cards.size, richCards, rotationMs)
123 − val rotation by rememberRotationProgress(rotating, rotationMs)
125 + val turn = rememberRotation(cards.size, richCards, rotationMs)
126 + val advance = turn.advance
124 127
125 128 // A pull asks for a board; the wait ends when one lands. There is no
126 129 // acknowledgement to wait for -- a Pythiae answers on its own tick and the
@@ -187,7 +190,8 @@ fun BoardScreen(
187 190 }
188 191 }
189 192
190 − Column(modifier = modifier.fillMaxSize().then(sideSwipeModifier)) {
193 + Box(modifier.fillMaxSize()) {
194 + Column(modifier = Modifier.fillMaxSize().then(sideSwipeModifier)) {
191 195 // Outside the list rather than its first row: what the connection is
192 196 // doing should not scroll away, since it is the thing that tells you
193 197 // whether the board under it is worth reading.
@@ -196,7 +200,7 @@ fun BoardScreen(
196 200 lastUpdated = lastUpdated,
197 201 stale = stale,
198 202 richCards = richCards,
199 − rotation = rotation,
203 + rotation = turn.progress,
200 204 rotationMs = rotationMs,
201 205 rotating = rotating,
202 206 onCycleSpeed = {
@@ -279,7 +283,7 @@ fun BoardScreen(
279 283 }
280 284 }
281 285
282 − items(window.map { cards[it] }, key = { it.key }) { card ->
286 + items(turn.window.map { cards[it] }, key = { it.key }) { card ->
283 287 // Keyed, so the list knows the two cards that stayed are the
284 288 // same two cards. Without that every turn is three removals
285 289 // and three insertions, and nothing can be animated because
@@ -329,6 +333,36 @@ fun BoardScreen(
329 333 }
330 334 }
331 335 }
336 +
337 + // A strip along the bottom edge, and only while something is turning.
338 + //
339 + // The board scrolls, so a drag anywhere in it belongs to the list -- this
340 + // takes the last few millimetres, which is where a flick up from off the
341 + // screen starts and where no card is expecting to be read. Absent when
342 + // there is nothing to advance, so it never takes a touch it has no use
343 + // for.
344 + if (rotating) {
345 + Box(
346 + Modifier
347 + .align(Alignment.BottomCenter)
348 + .fillMaxWidth()
349 + .height(28.dp)
350 + .pointerInput(Unit) {
351 + var travelled = 0f
352 +
353 + detectVerticalDragGestures(
354 + onDragStart = { travelled = 0f },
355 + onDragEnd = {
356 + // Upward is negative. A flick rather than a nudge:
357 + // far enough that resting a thumb on the edge does
358 + // not turn the card you were reading.
359 + if (travelled < -40.dp.toPx()) advance()
360 + }
361 + ) { _, amount -> travelled += amount }
362 + }
363 + )
364 + }
365 + }
332 366 }
333 367
334 368 @OptIn(ExperimentalFoundationApi::class)
modified clients/android/app/src/main/java/io/neiam/apolloscrib/ui/RichFacts.kt
+6 −5
@@ -122,12 +122,13 @@ private fun tidal(entry: VisionEntry): RichFacts? {
122 122 iconRes = R.drawable.fa_water,
123 123 // The next tide, not the first field the publisher filled in.
124 124 headline = next?.at?.asClockTime(),
125 − // What is happening now, which is the thing the numbers do not say.
125 + // Which tide is coming and how long until it. Named outright rather
126 + // than left to be worked out from the three below it -- and naming it
127 + // says which way the water is going, so saying that as well would be
128 + // the same fact twice.
126 129 caption = next?.let { extreme ->
127 − listOfNotNull(
128 − Tides.state(extreme),
129 − Tides.until(now, extreme.minutes)
130 − ).joinToString(" · ")
130 + val kind = if (extreme.high) "Next high" else "Next low"
131 + "$kind · ${Tides.until(now, extreme.minutes)}"
131 132 },
132 133 // The rest in clock order, each saying which it is and how high.
133 134 facts = extremes
modified clients/android/app/src/main/java/io/neiam/apolloscrib/ui/Rotation.kt
+57 −47
@@ -2,7 +2,6 @@ package io.neiam.apolloscrib.ui
2 2
3 3 import androidx.compose.runtime.Composable
4 4 import androidx.compose.runtime.LaunchedEffect
5 −import androidx.compose.runtime.State
6 5 import androidx.compose.runtime.getValue
7 6 import androidx.compose.runtime.mutableFloatStateOf
8 7 import androidx.compose.runtime.mutableIntStateOf
@@ -10,73 +9,84 @@ import androidx.compose.runtime.remember
10 9 import androidx.compose.runtime.setValue
11 10 import androidx.compose.runtime.withFrameNanos
12 11
13 −
14 12 /**
15 − * Which cards to show right now, as indices into the list.
16 − *
17 − * A window of [size] moved along by **one** each turn: a card slides in at the
18 − * top and the bottom one falls off, so the two you were already reading stay
19 − * put. Advancing by a whole page instead would replace everything at once, and
20 − * a card you were halfway through would be gone for no reason you could see.
13 + * A window onto a list, moved along by a timer or by hand.
21 14 *
22 − * Wraps, and when everything fits it never moves at all — rotating a list that
23 − * is already whole would take cards away and give them back for nothing.
15 + * @property window which cards to show, as indices
16 + * @property progress how far through the current turn, 0..1, or null when
17 + * nothing is turning — the compass draws no ring in that case, because a
18 + * full circle sitting still would promise a turn that is not coming
19 + * @property advance move on now, and start the turn again from the top
24 20 */
25 −@Composable
26 −fun rotatingWindow(total: Int, size: Int, intervalMs: Int): List<Int> {
27 − if (total <= 0 || size <= 0) return emptyList()
28 − if (total <= size) return (0 until total).toList()
29 −
30 − var first by remember(total, size) { mutableIntStateOf(0) }
31 −
32 − LaunchedEffect(total, size, intervalMs) {
33 − while (true) {
34 − kotlinx.coroutines.delay(intervalMs.toLong())
35 − first = (first + 1) % total
36 − }
37 − }
38 −
39 − // Modulo the total, so a window running off the end comes back round
40 − // rather than coming up short. Three cards should be three cards on every
41 − // turn.
42 − return (first until first + size).map { it % total }
43 −}
21 +data class Rotation(
22 + val window: List<Int>,
23 + val progress: Float?,
24 + val advance: () -> Unit
25 +)
44 26
45 27 /**
46 − * How far through the current turn, 0 to 1, or null when nothing is rotating.
28 + * The rotation for a list of [total] cards showing [size] at a time.
47 29 *
48 − * Advanced per frame rather than by a timer, so the ring it draws moves
49 − * smoothly instead of stepping. Null is what tells the compass to draw no ring
50 − * at all: a full circle sitting still would say a turn is about to happen when
51 − * none is.
30 + * One clock for both the moving and the drawing of it. Two would drift — the
31 + * ring would fill while the cards had already turned, or worse, an advance by
32 + * hand would leave the countdown where it was and the next card would arrive a
33 + * moment later.
34 + *
35 + * The window moves by **one**: a card slides in at the top and the bottom one
36 + * falls off, so what you were reading stays put. Wraps, and never moves at all
37 + * when everything already fits.
52 38 */
53 39 @Composable
54 −fun rememberRotationProgress(rotating: Boolean, intervalMs: Int): State<Float?> {
40 +fun rememberRotation(total: Int, size: Int, intervalMs: Int): Rotation {
41 + if (total <= 0 || size <= 0) return Rotation(emptyList(), null, {})
42 + if (total <= size) return Rotation((0 until total).toList(), null, {})
43 +
44 + var first by remember(total, size) { mutableIntStateOf(0) }
55 45 val progress = remember { mutableFloatStateOf(0f) }
56 − val result = remember { androidx.compose.runtime.mutableStateOf<Float?>(null) }
57 46
58 − LaunchedEffect(rotating, intervalMs) {
59 − if (!rotating) {
60 − result.value = null
61 − return@LaunchedEffect
62 − }
47 + // Bumped by a turn taken by hand. The loop owns the clock -- it is the
48 + // only thing holding a frame time -- so a flick asks for a reset rather
49 + // than writing one, which would mean inventing a timestamp from outside
50 + // the frame callback and getting it wrong by however long the frame was.
51 + var restarts by remember { mutableIntStateOf(0) }
63 52
64 − var started = withFrameNanos { it }
53 + LaunchedEffect(total, size, intervalMs) {
54 + var startedAt = withFrameNanos { it }
55 + var seen = restarts
65 56
66 57 while (true) {
67 58 val now = withFrameNanos { it }
68 − val elapsed = (now - started) / 1_000_000f
59 +
60 + if (restarts != seen) {
61 + seen = restarts
62 + startedAt = now
63 + progress.floatValue = 0f
64 + continue
65 + }
66 +
67 + val elapsed = (now - startedAt) / 1_000_000f
69 68
70 69 if (elapsed >= intervalMs) {
71 − started = now
70 + first = (first + 1) % total
71 + startedAt = now
72 72 progress.floatValue = 0f
73 73 } else {
74 74 progress.floatValue = elapsed / intervalMs
75 75 }
76 −
77 − result.value = progress.floatValue
78 76 }
79 77 }
80 78
81 − return result
79 + return Rotation(
80 + // Modulo the total, so a window running off the end comes back round
81 + // rather than coming up short. Three cards should be three cards on
82 + // every turn.
83 + window = (first until first + size).map { it % total },
84 + progress = progress.floatValue,
85 + advance = {
86 + first = (first + 1) % total
87 + // The turn starts again, rather than the next card landing
88 + // however much of the interval happened to be left.
89 + restarts += 1
90 + }
91 + )
82 92 }
modified clients/android/app/src/main/java/io/neiam/apolloscrib/ui/Tides.kt
+0 −13
@@ -42,19 +42,6 @@ object Tides {
42 42 fun next(extremes: List<Extreme>, nowMinutes: Int): Extreme? =
43 43 extremes.firstOrNull { it.minutes > nowMinutes } ?: extremes.firstOrNull()
44 44
45 − /**
46 − * Whether the water is rising or falling, from what comes next.
47 − *
48 − * On its way to a high means coming in. Null when there is nothing to
49 − * infer from, which is better than guessing at a coast somebody might be
50 − * standing on.
51 − */
52 − fun state(next: Extreme?): String? = when (next?.high) {
53 − true -> "Coming in"
54 − false -> "Going out"
55 − null -> null
56 − }
57 −
58 45 /**
59 46 * How long until then, as a person would say it.
60 47 *
modified clients/android/app/src/test/java/io/neiam/apolloscrib/ui/TidesTest.kt
+5 −6
@@ -50,14 +50,13 @@ class TidesTest {
50 50 }
51 51
52 52 @Test
53 − fun `which way the water is going comes from what is next`() {
53 + fun `the next tide says which kind it is`() {
54 54 val extremes = Tides.order(day)
55 55
56 − // Next is a high, so it is filling.
57 − assertEquals("Coming in", Tides.state(Tides.next(extremes, 5 * 60)))
58 − // Next is a low, so it is emptying.
59 − assertEquals("Going out", Tides.state(Tides.next(extremes, 10 * 60)))
60 − assertNull(Tides.state(null))
56 + // Which is the thing the card names, rather than leaving it to be
57 + // deduced from the three tides underneath it.
58 + assertEquals(true, Tides.next(extremes, 5 * 60)?.high)
59 + assertEquals(false, Tides.next(extremes, 10 * 60)?.high)
61 60 }
62 61
63 62 @Test
modified clients/android/app/src/test/java/io/neiam/apolloscrib/ui/WindowTest.kt
+2 −1
@@ -12,7 +12,8 @@ import org.junit.Test
12 12 */
13 13 class WindowTest {
14 14
15 − // The same expression `rotatingWindow` uses, once a turn is chosen.
15 + // The same expression `rememberRotation` uses, once a turn is chosen --
16 + // whether that turn came from the timer or from a flick.
16 17 private fun page(total: Int, size: Int, page: Int): List<Int> {
17 18 if (total <= 0 || size <= 0) return emptyList()
18 19 if (total <= size) return (0 until total).toList()

Parents: 3b8d9c7