Slide one card in at a time, and tap the compass to set the pace

9659c37 · Gabriel Morell · 2026-09-08 20:27

23 files +129 -36
Message
{commit_body(@commit)}

Files changed

modified clients/android/app/src/main/java/io/neiam/apolloscrib/data/Settings.kt
+25 −0
@@ -60,6 +60,24 @@ class Settings(context: Context) {
60 60 * by an older build cannot put the screen in a state the gesture cannot
61 61 * get out of.
62 62 */
63 + /**
64 + * How long a card stays before the next slides in, in milliseconds.
65 + *
66 + * Cycled by tapping the compass. Offered as a short list rather than a
67 + * number: the useful range is "faster than I can read" to "long enough to
68 + * feel stuck", and five steps cover it.
69 + *
70 + * Snapped to the nearest offered value on read, so a stored number this
71 + * build no longer offers still lands somewhere sensible.
72 + */
73 + var rotationMs: Int
74 + get() {
75 + val stored = prefs.getInt(KEY_ROTATION_MS, ROTATION_CHOICES.last())
76 + return ROTATION_CHOICES.minByOrNull { kotlin.math.abs(it - stored) }
77 + ?: ROTATION_CHOICES.last()
78 + }
79 + set(value) = prefs.edit { putInt(KEY_ROTATION_MS, value) }
80 +
63 81 var richCards: Int
64 82 get() = prefs.getInt(KEY_RICH_CARDS, 0).coerceIn(0, MAX_RICH_CARDS)
65 83 set(value) = prefs.edit { putInt(KEY_RICH_CARDS, value.coerceIn(0, MAX_RICH_CARDS)) }
@@ -140,6 +158,13 @@ class Settings(context: Context) {
140 158 private const val KEY_HAS_CONNECTED = "has_connected"
141 159 private const val KEY_PUBLISH_LOCATION = "publish_location"
142 160 private const val KEY_RICH_CARDS = "rich_cards"
161 + private const val KEY_ROTATION_MS = "rotation_ms"
162 +
163 + /**
164 + * The turns on offer, quickest first. Twelve seconds is the default
165 + * because a card nobody has read yet is worse than one that lingers.
166 + */
167 + val ROTATION_CHOICES = listOf(2_000, 3_000, 5_000, 8_000, 12_000)
143 168
144 169 /** Past three the screen is a list again, which is the other mode. */
145 170 const val MAX_RICH_CARDS = 3
modified clients/android/app/src/main/java/io/neiam/apolloscrib/ui/BoardScreen.kt
+36 −5
@@ -1,5 +1,8 @@
1 1 package io.neiam.apolloscrib.ui
2 2
3 +import androidx.compose.animation.core.Spring
4 +import androidx.compose.animation.core.spring
5 +import androidx.compose.animation.core.tween
3 6 import androidx.compose.foundation.ExperimentalFoundationApi
4 7 import androidx.compose.foundation.combinedClickable
5 8 import io.neiam.apolloscrib.types.GtfsPlusCondensed
@@ -85,6 +88,7 @@ fun BoardScreen(
85 88 // Held here rather than in the header, because the header only offers the
86 89 // gesture -- what changes is the whole board below it.
87 90 var richCards by remember { mutableStateOf(Settings(context).richCards) }
91 + var rotationMs by remember { mutableStateOf(Settings(context).rotationMs) }
88 92 val plus = remember(boards, richCards) {
89 93 if (richCards > 0) store.plusEntries() else emptyList()
90 94 }
@@ -114,8 +118,9 @@ fun BoardScreen(
114 118
115 119 // Rotated only when there is more than fits. Turning a list that is
116 120 // already whole would take cards away and give them back for nothing.
117 − val window = rotatingWindow(cards.size, richCards)
118 − val rotation by rememberRotationProgress(rotating = cards.size > richCards && richCards > 0)
121 + val rotating = richCards > 0 && cards.size > richCards
122 + val window = rotatingWindow(cards.size, richCards, rotationMs)
123 + val rotation by rememberRotationProgress(rotating, rotationMs)
119 124
120 125 // A pull asks for a board; the wait ends when one lands. There is no
121 126 // acknowledgement to wait for -- a Pythiae answers on its own tick and the
@@ -192,6 +197,13 @@ fun BoardScreen(
192 197 stale = stale,
193 198 richCards = richCards,
194 199 rotation = rotation,
200 + rotationMs = rotationMs,
201 + rotating = rotating,
202 + onCycleSpeed = {
203 + val choices = Settings.ROTATION_CHOICES
204 + rotationMs = choices[(choices.indexOf(rotationMs) + 1) % choices.size]
205 + Settings(context).rotationMs = rotationMs
206 + },
195 207 onToggleRich = {
196 208 // One more each time, and off the end back to the compact
197 209 // board -- so the gesture always has somewhere to go and never
@@ -268,9 +280,19 @@ fun BoardScreen(
268 280 }
269 281
270 282 items(window.map { cards[it] }, key = { it.key }) { card ->
283 + // Keyed, so the list knows the two cards that stayed are the
284 + // same two cards. Without that every turn is three removals
285 + // and three insertions, and nothing can be animated because
286 + // nothing persisted.
287 + val slide = Modifier.animateItem(
288 + fadeInSpec = tween(220),
289 + placementSpec = spring(stiffness = Spring.StiffnessMediumLow),
290 + fadeOutSpec = tween(160)
291 + )
292 +
271 293 when (card) {
272 − is RichItem.Transit -> RichCard(card.entry, card.route)
273 − is RichItem.Facts -> RichFactsCard(card.facts)
294 + is RichItem.Transit -> RichCard(card.entry, card.route, slide)
295 + is RichItem.Facts -> RichFactsCard(card.facts, slide)
274 296 }
275 297 }
276 298 } else {
@@ -318,6 +340,9 @@ private fun StatusHeader(
318 340 richCards: Int,
319 341 /** How far through the card rotation, or null when nothing is rotating. */
320 342 rotation: Float?,
343 + rotationMs: Int,
344 + rotating: Boolean,
345 + onCycleSpeed: () -> Unit,
321 346 onToggleRich: () -> Unit,
322 347 onEditConnection: (() -> Unit)?,
323 348 modifier: Modifier = Modifier
@@ -360,7 +385,13 @@ private fun StatusHeader(
360 385 // your thumb brushes it. Nothing is bound to the short press,
361 386 // so a stray tap does nothing at all.
362 387 .combinedClickable(
363 − onClick = {},
388 + // Only while something is turning. Outside the detailed
389 + // mode there is no rotation to speed up, and a tap that
390 + // silently changes a setting you cannot see the effect of
391 + // is worse than one that does nothing.
392 + onClick = { if (rotating) onCycleSpeed() },
393 + onClickLabel =
394 + if (rotating) "Change how long each card stays" else null,
364 395 onLongClick = onToggleRich,
365 396 onLongClickLabel =
366 397 if (richCards == Settings.MAX_RICH_CARDS) {
modified clients/android/app/src/main/java/io/neiam/apolloscrib/ui/Rotation.kt
+20 −21
@@ -10,37 +10,36 @@ import androidx.compose.runtime.remember
10 10 import androidx.compose.runtime.setValue
11 11 import androidx.compose.runtime.withFrameNanos
12 12
13 −/** How long one page of cards stays before the next takes its place. */
14 −const val ROTATION_MS = 12_000L
15 13
16 14 /**
17 15 * Which cards to show right now, as indices into the list.
18 16 *
19 − * A window of [size] moved along by [size] each turn, so every route gets its
20 − * turn rather than the first few holding the screen for ever. Wraps, and when
21 − * everything fits it never moves at all — rotating a list that is already
22 − * whole would take cards away and give them back for no reason.
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.
21 + *
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.
23 24 */
24 25 @Composable
25 −fun rotatingWindow(total: Int, size: Int): List<Int> {
26 +fun rotatingWindow(total: Int, size: Int, intervalMs: Int): List<Int> {
26 27 if (total <= 0 || size <= 0) return emptyList()
27 28 if (total <= size) return (0 until total).toList()
28 29
29 − val pages = (total + size - 1) / size
30 − var page by remember(total, size) { mutableIntStateOf(0) }
30 + var first by remember(total, size) { mutableIntStateOf(0) }
31 31
32 − LaunchedEffect(total, size) {
32 + LaunchedEffect(total, size, intervalMs) {
33 33 while (true) {
34 − kotlinx.coroutines.delay(ROTATION_MS)
35 − page = (page + 1) % pages
34 + kotlinx.coroutines.delay(intervalMs.toLong())
35 + first = (first + 1) % total
36 36 }
37 37 }
38 38
39 − // Taken modulo the total rather than truncated, so a last page with room
40 − // to spare is filled from the front instead of coming up short. Three
41 − // cards should be three cards on every turn.
42 − val start = page * size
43 − return (start until start + size).map { it % total }
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 }
44 43 }
45 44
46 45 /**
@@ -52,11 +51,11 @@ fun rotatingWindow(total: Int, size: Int): List<Int> {
52 51 * none is.
53 52 */
54 53 @Composable
55 −fun rememberRotationProgress(rotating: Boolean): State<Float?> {
54 +fun rememberRotationProgress(rotating: Boolean, intervalMs: Int): State<Float?> {
56 55 val progress = remember { mutableFloatStateOf(0f) }
57 56 val result = remember { androidx.compose.runtime.mutableStateOf<Float?>(null) }
58 57
59 − LaunchedEffect(rotating) {
58 + LaunchedEffect(rotating, intervalMs) {
60 59 if (!rotating) {
61 60 result.value = null
62 61 return@LaunchedEffect
@@ -68,11 +67,11 @@ fun rememberRotationProgress(rotating: Boolean): State<Float?> {
68 67 val now = withFrameNanos { it }
69 68 val elapsed = (now - started) / 1_000_000f
70 69
71 − if (elapsed >= ROTATION_MS) {
70 + if (elapsed >= intervalMs) {
72 71 started = now
73 72 progress.floatValue = 0f
74 73 } else {
75 − progress.floatValue = elapsed / ROTATION_MS
74 + progress.floatValue = elapsed / intervalMs
76 75 }
77 76
78 77 result.value = progress.floatValue
added clients/android/app/src/test/java/io/neiam/apolloscrib/data/RotationSpeedTest.kt
+34 −0
@@ -0,0 +1,34 @@
1 +package io.neiam.apolloscrib.data
2 +
3 +import org.junit.Assert.assertEquals
4 +import org.junit.Test
5 +
6 +/** Tapping the compass walks the offered turns and comes back round. */
7 +class RotationSpeedTest {
8 +
9 + private fun next(from: Int): Int {
10 + val choices = Settings.ROTATION_CHOICES
11 + return choices[(choices.indexOf(from) + 1) % choices.size]
12 + }
13 +
14 + @Test
15 + fun `the offered turns are the ones asked for`() {
16 + assertEquals(listOf(2_000, 3_000, 5_000, 8_000, 12_000), Settings.ROTATION_CHOICES)
17 + }
18 +
19 + @Test
20 + fun `each tap moves to the next, and the last wraps`() {
21 + assertEquals(3_000, next(2_000))
22 + assertEquals(5_000, next(3_000))
23 + assertEquals(8_000, next(5_000))
24 + assertEquals(12_000, next(8_000))
25 + assertEquals(2_000, next(12_000))
26 + }
27 +
28 + @Test
29 + fun `a value this build does not offer still moves somewhere`() {
30 + // indexOf returns -1, so the next is the first. Not a crash, and not
31 + // stuck on a number that is no longer on the list.
32 + assertEquals(2_000, next(9_999))
33 + }
34 +}
modified clients/android/app/src/test/java/io/neiam/apolloscrib/ui/WindowTest.kt
+14 −10
@@ -12,13 +12,12 @@ import org.junit.Test
12 12 */
13 13 class WindowTest {
14 14
15 − // The same expression `rotatingWindow` uses, once a page is chosen.
15 + // The same expression `rotatingWindow` uses, once a turn is chosen.
16 16 private fun page(total: Int, size: Int, page: Int): List<Int> {
17 17 if (total <= 0 || size <= 0) return emptyList()
18 18 if (total <= size) return (0 until total).toList()
19 19
20 − val start = page * size
21 − return (start until start + size).map { it % total }
20 + return (page until page + size).map { it % total }
22 21 }
23 22
24 23 @Test
@@ -28,17 +27,22 @@ class WindowTest {
28 27 }
29 28
30 29 @Test
31 − fun `each turn moves on by a whole page`() {
30 + fun `each turn slides on by one, keeping what was already read`() {
31 + // The two cards you were reading stay; one arrives and one leaves.
32 32 assertEquals(listOf(0, 1, 2), page(total = 6, size = 3, page = 0))
33 − assertEquals(listOf(3, 4, 5), page(total = 6, size = 3, page = 1))
33 + assertEquals(listOf(1, 2, 3), page(total = 6, size = 3, page = 1))
34 + assertEquals(listOf(2, 3, 4), page(total = 6, size = 3, page = 2))
34 35 }
35 36
36 37 @Test
37 − fun `a short last page is filled from the front rather than coming up short`() {
38 − // Four routes, three at a time: the second page is 3, 0, 1 -- three
39 − // cards, as the mode promises, not one.
40 − assertEquals(listOf(0, 1, 2), page(total = 4, size = 3, page = 0))
41 − assertEquals(listOf(3, 0, 1), page(total = 4, size = 3, page = 1))
38 + fun `a window running off the end comes back round`() {
39 + // Still three cards, as the mode promises, not one and a gap.
40 + assertEquals(listOf(3, 0, 1), page(total = 4, size = 3, page = 3))
41 + }
42 +
43 + @Test
44 + fun `a full turn returns to the start`() {
45 + assertEquals(page(total = 5, size = 2, page = 0), page(total = 5, size = 2, page = 5))
42 46 }
43 47
44 48 @Test

Parents: 8dcaa80