Rotate the detailed cards, ring the compass, and stop dropping types

17fd74d · Gabriel Morell · 2026-09-08 20:18

25 files +373 -15
Message
{commit_body(@commit)}

Files changed

added clients/android/app/src/main/java/io/neiam/apolloscrib/targets/GtfsPlusRenderer.kt
+154 −0
@@ -0,0 +1,154 @@
1 +package io.neiam.apolloscrib.targets
2 +
3 +import io.neiam.apolloscrib.R
4 +import io.neiam.apolloscrib.types.AlertCondensed
5 +import io.neiam.apolloscrib.types.GtfsPlusCondensed
6 +import io.neiam.apolloscrib.types.PlusArrival
7 +import io.neiam.apolloscrib.types.SourceType
8 +import io.neiam.apolloscrib.types.VisionEntry
9 +
10 +/**
11 + * A stop from the Plus board, as a compact card.
12 + *
13 + * The same shape as [GtfsRenderer]'s — one card per stop, three rows of
14 + * routes — because a widget and a Smartspace target have the same room
15 + * whichever board the data came from. What Plus adds is what each row can
16 + * *say*: a delay, a platform, a cancellation, where Basic could only give a
17 + * time.
18 + *
19 + * Without this, a Plus board drew nothing at all outside the detailed view:
20 + * `gtfs_plus` is its own type, and a type with no renderer is dropped. That is
21 + * the right behaviour for a type this build does not know, and the wrong one
22 + * for the board it is meant to be reading.
23 + */
24 +object GtfsPlusRenderer : SourceRenderer {
25 +
26 + override val type = SourceType.GtfsPlus
27 + override val iconRes = R.drawable.fa_bus_simple
28 + override val label = "Apollo's Crib: Transit (detailed)"
29 + override val description = "Next departures, with what the feed says about each"
30 +
31 + override fun preview(entry: VisionEntry): List<Preview> {
32 + val routes = entry.decode<List<GtfsPlusCondensed>>().orEmpty()
33 +
34 + // As in the Basic renderer: a stop with nothing due is not news, and
35 + // it crowds out the stops that have something. An alert still shows.
36 + if (routes.all { it.arrivals.isEmpty() }) {
37 + return listOfNotNull(alert(entry, routes))
38 + }
39 +
40 + val soonest = routes.sortedBy { route ->
41 + route.arrivals.firstNotNullOfOrNull { it.best() } ?: "99:99:99"
42 + }
43 +
44 + val board = Preview(
45 + id = entry.key,
46 + title = entry.label(),
47 + subtitle = summary(soonest),
48 + iconRes = soonest.firstOrNull()?.let { modeIcon(it.mode) } ?: iconRes,
49 + rows = soonest.map { row(it) },
50 + empty = "Nothing due"
51 + )
52 +
53 + return listOfNotNull(alert(entry, routes)) + board
54 + }
55 +
56 + /**
57 + * `[bus] 87 Arlington [tower] 15:33 · 3 late`
58 + *
59 + * Two departures, as Basic shows — but each stamped with what the feed
60 + * said about it rather than only when it is due.
61 + */
62 + private fun row(route: GtfsPlusCondensed): Row = Row(
63 + iconRes = modeIcon(route.mode),
64 + text = "${route.displayName()} ${route.dest.orEmpty()}".trim(),
65 + stamps = route.arrivals.take(2).map { arrival ->
66 + val time = arrival.best()?.asClockTime() ?: "--:--"
67 + val note = noteOf(arrival)
68 +
69 + Stamp(
70 + iconRes = if (arrival.live()) R.drawable.fa_tower_broadcast else R.drawable.fa_clock,
71 + text = if (note == null) time else "$time · $note",
72 + // Smartspace list rows are plain text, so the glyph cannot
73 + // carry the meaning and the word has to.
74 + flat = if (note == null) time else "$time $note"
75 + )
76 + }
77 + )
78 +
79 + /**
80 + * The one thing worth adding to a time, when there is one.
81 + *
82 + * Cancellation first: a rider who reads only the time and walks to the
83 + * stop has been failed by the card. Then lateness, then how full — in the
84 + * order that changes what somebody does.
85 + */
86 + private fun noteOf(arrival: PlusArrival): String? = when {
87 + arrival.trip_status == "CANCELED" -> "cancelled"
88 + arrival.stop_status == "SKIPPED" -> "not stopping"
89 + else -> arrival.delayMinutes()?.let { minutes ->
90 + when {
91 + minutes >= 1 -> "$minutes late"
92 + minutes <= -1 -> "${-minutes} early"
93 + else -> null
94 + }
95 + } ?: crowding(arrival)
96 + }
97 +
98 + private fun crowding(arrival: PlusArrival): String? = when (arrival.occupancy) {
99 + "CRUSHED_STANDING_ROOM_ONLY", "FULL" -> "full"
100 + "STANDING_ROOM_ONLY" -> "standing"
101 + "NOT_ACCEPTING_PASSENGERS" -> "not boarding"
102 + else -> null
103 + }
104 +
105 + private fun summary(routes: List<GtfsPlusCondensed>): String? {
106 + val first = routes.firstOrNull() ?: return null
107 + val arrival = first.arrivals.firstOrNull() ?: return null
108 + val time = arrival.best()?.asClockTime() ?: return null
109 +
110 + val note = noteOf(arrival)
111 + val live = if (arrival.live()) " · live" else ""
112 +
113 + return if (note == null) {
114 + "${first.displayName()} at $time$live"
115 + } else {
116 + "${first.displayName()} at $time · $note"
117 + }
118 + }
119 +
120 + /** As Basic does it: severe, or naming this stop. */
121 + private fun alert(entry: VisionEntry, routes: List<GtfsPlusCondensed>): Preview? {
122 + val alerts = routes.flatMap { it.alerts.orEmpty() }.distinctBy { it.header ?: it.effect }
123 + val lead = alerts.firstOrNull { it.severity == "SEVERE" || it.stop_specific == true }
124 + ?: return null
125 +
126 + return Preview(
127 + id = "${entry.key}-alert",
128 + title = lead.header ?: lead.effect.humanise(),
129 + subtitle = entry.label(),
130 + iconRes = alertIcon(lead),
131 + style = Preview.Style.Basic
132 + )
133 + }
134 +
135 + private fun alertIcon(alert: AlertCondensed): Int =
136 + if (alert.stop_specific == true) R.drawable.fa_location_dot
137 + else R.drawable.fa_triangle_exclamation
138 +
139 + // The same mapping live_preview.ex uses, so a route looks like itself.
140 + private fun modeIcon(mode: String?): Int = when (mode) {
141 + "Bus" -> R.drawable.fa_bus
142 + "Trolleybus" -> R.drawable.fa_bus_simple
143 + "Subway" -> R.drawable.fa_train_subway
144 + "Rail" -> R.drawable.fa_train
145 + "LightRail" -> R.drawable.fa_train_tram
146 + "Ferry" -> R.drawable.fa_ferry
147 + "CableCar", "Gondola" -> R.drawable.fa_cable_car
148 + "Funicular" -> R.drawable.fa_mountain
149 + else -> R.drawable.fa_train_tram
150 + }
151 +
152 + private fun String.humanise(): String =
153 + lowercase().replace('_', ' ').replaceFirstChar { it.uppercase() }
154 +}
modified clients/android/app/src/main/java/io/neiam/apolloscrib/targets/Renderers.kt
+1 −0
@@ -157,6 +157,7 @@ object Targets {
157 157
158 158 val renderers: List<SourceRenderer> = listOf(
159 159 GtfsRenderer,
160 + GtfsPlusRenderer,
160 161 GbfsRenderer,
161 162 WeatherRenderer,
162 163 TidalRenderer,
modified clients/android/app/src/main/java/io/neiam/apolloscrib/ui/BoardScreen.kt
+36 −12
@@ -3,6 +3,7 @@ package io.neiam.apolloscrib.ui
3 3 import androidx.compose.foundation.ExperimentalFoundationApi
4 4 import androidx.compose.foundation.combinedClickable
5 5 import io.neiam.apolloscrib.types.GtfsPlusCondensed
6 +import io.neiam.apolloscrib.types.SourceType
6 7 import androidx.compose.foundation.gestures.detectHorizontalDragGestures
7 8 import androidx.compose.foundation.layout.Arrangement
8 9 import androidx.compose.foundation.layout.PaddingValues
@@ -87,6 +88,24 @@ fun BoardScreen(
87 88 if (richCards > 0) store.plusEntries() else emptyList()
88 89 }
89 90
91 + // Soonest first, then a window onto them. Taking them in payload order
92 + // would show whichever the publisher happened to list, which for a stop
93 + // with six routes is not the one leaving next.
94 + val routes = remember(plus) {
95 + plus
96 + .flatMap { entry ->
97 + entry.decode<List<GtfsPlusCondensed>>().orEmpty().map { entry to it }
98 + }
99 + .sortedBy { (_, route) ->
100 + route.arrivals.firstNotNullOfOrNull { it.best() } ?: "99:99:99"
101 + }
102 + }
103 +
104 + // Rotated only when there is more than fits. Turning a list that is
105 + // already whole would take cards away and give them back for nothing.
106 + val window = rotatingWindow(routes.size, richCards)
107 + val rotation by rememberRotationProgress(rotating = routes.size > richCards && richCards > 0)
108 +
90 109 // A pull asks for a board; the wait ends when one lands. There is no
91 110 // acknowledgement to wait for -- a Pythiae answers on its own tick and the
92 111 // request is debounced at the other end -- so a new payload is the only
@@ -161,6 +180,7 @@ fun BoardScreen(
161 180 lastUpdated = lastUpdated,
162 181 stale = stale,
163 182 richCards = richCards,
183 + rotation = rotation,
164 184 onToggleRich = {
165 185 // One more each time, and off the end back to the compact
166 186 // board -- so the gesture always has somewhere to go and never
@@ -212,16 +232,6 @@ fun BoardScreen(
212 232 }
213 233
214 234 if (richCards > 0) {
215 − // Soonest first, then as many as asked for. Taking them in payload
216 − // order would show whichever the publisher happened to list, which
217 − // for a stop with six routes is not the one leaving next.
218 − val routes = plus
219 − .flatMap { entry ->
220 − entry.decode<List<GtfsPlusCondensed>>().orEmpty().map { entry to it }
221 − }
222 − .sortedBy { (_, route) ->
223 − route.arrivals.firstNotNullOfOrNull { it.best() } ?: "99:99:99"
224 − }
225 235
226 236 if (routes.isEmpty()) {
227 237 item {
@@ -247,11 +257,22 @@ fun BoardScreen(
247 257 }
248 258
249 259 items(
250 − routes.take(richCards),
260 + window.map { routes[it] },
251 261 key = { (entry, route) -> "${entry.key}-${route.route}-${route.dest}" }
252 262 ) { (entry, route) ->
253 263 RichCard(entry, route)
254 264 }
265 +
266 + // Everything Plus does not have an extended reading for -- the
267 + // weather, the bikes, the tides. Plus carries Basic's own answer
268 + // for those, so they are the same cards they always were, and
269 + // leaving them out would make this mode a transit app rather than
270 + // a board with detail on it.
271 + plus.filter { it.type != SourceType.GtfsPlus }.forEach { entry ->
272 + items(Targets.preview(entry), key = { "${entry.key}-${it.id}" }) { preview ->
273 + PreviewCard(preview)
274 + }
275 + }
255 276 } else {
256 277 entries.forEach { entry ->
257 278 // No preview means the renderer had nothing worth a card -- a
@@ -295,6 +316,8 @@ private fun StatusHeader(
295 316 lastUpdated: Long?,
296 317 stale: Boolean,
297 318 richCards: Int,
319 + /** How far through the card rotation, or null when nothing is rotating. */
320 + rotation: Float?,
298 321 onToggleRich: () -> Unit,
299 322 onEditConnection: (() -> Unit)?,
300 323 modifier: Modifier = Modifier
@@ -329,7 +352,8 @@ private fun StatusHeader(
329 352 // other.
330 353 Compass(
331 354 heading,
332 − Modifier
355 + rotation = rotation,
356 + modifier = Modifier
333 357 .padding(horizontal = 12.dp)
334 358 // A long press rather than a tap: the compass is a readout,
335 359 // and something you read should not change the screen when
modified clients/android/app/src/main/java/io/neiam/apolloscrib/ui/Compass.kt
+35 −2
@@ -210,7 +210,12 @@ private fun declinationAt(context: Context): Float? {
210 210 * cards saying a stop is NE of you.
211 211 */
212 212 @Composable
213 −fun Compass(heading: Heading, modifier: Modifier = Modifier) {
213 +fun Compass(
214 + heading: Heading,
215 + modifier: Modifier = Modifier,
216 + /** How far through the card rotation, 0..1, or null when nothing rotates. */
217 + rotation: Float? = null
218 +) {
214 219 if (!heading.available) return
215 220
216 221 val palette = LocalAppTheme.current
@@ -220,7 +225,10 @@ fun Compass(heading: Heading, modifier: Modifier = Modifier) {
220 225 horizontalAlignment = Alignment.CenterHorizontally,
221 226 verticalArrangement = Arrangement.spacedBy(1.dp)
222 227 ) {
223 − Canvas(Modifier.size(34.dp)) { drawRose(heading.degrees, palette.dim, palette.accent) }
228 + Canvas(Modifier.size(34.dp)) {
229 + drawRose(heading.degrees, palette.dim, palette.accent)
230 + rotation?.let { drawTurn(it, palette.accent) }
231 + }
224 232
225 233 Text(
226 234 "${wrap(heading.degrees).roundToInt()}° ${compassPoint(heading.degrees)}",
@@ -231,6 +239,31 @@ fun Compass(heading: Heading, modifier: Modifier = Modifier) {
231 239 }
232 240 }
233 241
242 +/**
243 + * How much of the current turn has passed, as an arc outside the dial.
244 + *
245 + * Outside rather than over it, so it never obscures the reading it is drawn
246 + * around -- the compass is the thing being looked at and the timer is the
247 + * thing being glanced at.
248 + */
249 +private fun DrawScope.drawTurn(progress: Float, accent: androidx.compose.ui.graphics.Color) {
250 + val inset = 0.5f
251 +
252 + drawArc(
253 + color = accent.copy(alpha = 0.75f),
254 + // From the top, clockwise, like anything else measuring a turn.
255 + startAngle = -90f,
256 + sweepAngle = 360f * progress.coerceIn(0f, 1f),
257 + useCenter = false,
258 + topLeft = Offset(inset, inset),
259 + size = androidx.compose.ui.geometry.Size(
260 + size.minDimension - inset * 2,
261 + size.minDimension - inset * 2
262 + ),
263 + style = Stroke(width = 2f)
264 + )
265 +}
266 +
234 267 private fun DrawScope.drawRose(
235 268 degrees: Float,
236 269 dim: androidx.compose.ui.graphics.Color,
added clients/android/app/src/main/java/io/neiam/apolloscrib/ui/Rotation.kt
+83 −0
@@ -0,0 +1,83 @@
1 +package io.neiam.apolloscrib.ui
2 +
3 +import androidx.compose.runtime.Composable
4 +import androidx.compose.runtime.LaunchedEffect
5 +import androidx.compose.runtime.State
6 +import androidx.compose.runtime.getValue
7 +import androidx.compose.runtime.mutableFloatStateOf
8 +import androidx.compose.runtime.mutableIntStateOf
9 +import androidx.compose.runtime.remember
10 +import androidx.compose.runtime.setValue
11 +import androidx.compose.runtime.withFrameNanos
12 +
13 +/** How long one page of cards stays before the next takes its place. */
14 +const val ROTATION_MS = 12_000L
15 +
16 +/**
17 + * Which cards to show right now, as indices into the list.
18 + *
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.
23 + */
24 +@Composable
25 +fun rotatingWindow(total: Int, size: Int): List<Int> {
26 + if (total <= 0 || size <= 0) return emptyList()
27 + if (total <= size) return (0 until total).toList()
28 +
29 + val pages = (total + size - 1) / size
30 + var page by remember(total, size) { mutableIntStateOf(0) }
31 +
32 + LaunchedEffect(total, size) {
33 + while (true) {
34 + kotlinx.coroutines.delay(ROTATION_MS)
35 + page = (page + 1) % pages
36 + }
37 + }
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 }
44 +}
45 +
46 +/**
47 + * How far through the current turn, 0 to 1, or null when nothing is rotating.
48 + *
49 + * Advanced per frame rather than by a timer, so the ring it draws moves
50 + * smoothly instead of stepping. Null is what tells the compass to draw no ring
51 + * at all: a full circle sitting still would say a turn is about to happen when
52 + * none is.
53 + */
54 +@Composable
55 +fun rememberRotationProgress(rotating: Boolean): State<Float?> {
56 + val progress = remember { mutableFloatStateOf(0f) }
57 + val result = remember { androidx.compose.runtime.mutableStateOf<Float?>(null) }
58 +
59 + LaunchedEffect(rotating) {
60 + if (!rotating) {
61 + result.value = null
62 + return@LaunchedEffect
63 + }
64 +
65 + var started = withFrameNanos { it }
66 +
67 + while (true) {
68 + val now = withFrameNanos { it }
69 + val elapsed = (now - started) / 1_000_000f
70 +
71 + if (elapsed >= ROTATION_MS) {
72 + started = now
73 + progress.floatValue = 0f
74 + } else {
75 + progress.floatValue = elapsed / ROTATION_MS
76 + }
77 +
78 + result.value = progress.floatValue
79 + }
80 + }
81 +
82 + return result
83 +}
modified clients/android/app/src/main/java/io/neiam/apolloscrib/widget/BoardWidgetService.kt
+8 −1
@@ -33,7 +33,14 @@ private class BoardFactory(private val context: Context) : RemoteViewsService.Re
33 33 */
34 34 override fun onDataSetChanged() {
35 35 palette = appThemeByKey(Settings(context).themeKey)
36 − cards = store.entries().flatMap { Targets.preview(it) }
36 + // The Plus board where there is one: it carries the extended reading
37 + // for transit and Basic's own answer for every type Plus does not
38 + // write, so it is the same board with more on it rather than a
39 + // different one. Falls back the moment it is absent, which is every
40 + // Pythiae that has not been asked to publish it.
41 + val board = store.plusEntries().ifEmpty { store.entries() }
42 +
43 + cards = board.flatMap { Targets.preview(it) }
37 44 }
38 45
39 46 override fun onDestroy() {
added clients/android/app/src/test/java/io/neiam/apolloscrib/ui/WindowTest.kt
+56 −0
@@ -0,0 +1,56 @@
1 +package io.neiam.apolloscrib.ui
2 +
3 +import org.junit.Assert.assertEquals
4 +import org.junit.Test
5 +
6 +/**
7 + * Which page of cards a rotation shows.
8 + *
9 + * The composable owns the timer; this is the arithmetic under it, which is
10 + * where the awkward cases live: a last page with room to spare, and a list
11 + * that already fits.
12 + */
13 +class WindowTest {
14 +
15 + // The same expression `rotatingWindow` uses, once a page is chosen.
16 + private fun page(total: Int, size: Int, page: Int): List<Int> {
17 + if (total <= 0 || size <= 0) return emptyList()
18 + if (total <= size) return (0 until total).toList()
19 +
20 + val start = page * size
21 + return (start until start + size).map { it % total }
22 + }
23 +
24 + @Test
25 + fun `a list that fits does not rotate`() {
26 + assertEquals(listOf(0, 1), page(total = 2, size = 3, page = 0))
27 + assertEquals(listOf(0, 1, 2), page(total = 3, size = 3, page = 0))
28 + }
29 +
30 + @Test
31 + fun `each turn moves on by a whole page`() {
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))
34 + }
35 +
36 + @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))
42 + }
43 +
44 + @Test
45 + fun `one card at a time walks the list`() {
46 + assertEquals(listOf(0), page(total = 3, size = 1, page = 0))
47 + assertEquals(listOf(1), page(total = 3, size = 1, page = 1))
48 + assertEquals(listOf(2), page(total = 3, size = 1, page = 2))
49 + }
50 +
51 + @Test
52 + fun `nothing to show is nothing rather than a crash`() {
53 + assertEquals(emptyList<Int>(), page(total = 0, size = 3, page = 0))
54 + assertEquals(emptyList<Int>(), page(total = 5, size = 0, page = 0))
55 + }
56 +}

Parents: e04988a