neiam
/apollos_crib
It's Apollo's Crib
public · Issues · Pulls 1 · Labels · Forks · Compare · Actions failure · Badges · Packages
⭐
Log in to mark this repository.
Slide one card in at a time, and tap the compass to set the pace
9659c37 · Gabriel Morell · 2026-09-08 20:27
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