Add a detailed board, held behind the compass

725a8da · Gabriel Morell · 2026-09-08 20:01

28 files +551 -10
Message
{commit_body(@commit)}

Files changed

modified clients/android/app/src/main/java/io/neiam/apolloscrib/data/Settings.kt
+13 −0
@@ -50,6 +50,18 @@ class Settings(context: Context) {
50 50 get() = prefs.getBoolean(KEY_PUBLISH_LOCATION, false)
51 51 set(value) = prefs.edit { putBoolean(KEY_PUBLISH_LOCATION, value) }
52 52
53 + /**
54 + * Whether the board shows a few detailed cards rather than many compact
55 + * ones. Toggled by holding the compass.
56 + *
57 + * Remembered, because it is a way of using the app rather than a moment:
58 + * somebody who wants the whole picture of three departures wants it again
59 + * next time they open it.
60 + */
61 + var richMode: Boolean
62 + get() = prefs.getBoolean(KEY_RICH_MODE, false)
63 + set(value) = prefs.edit { putBoolean(KEY_RICH_MODE, value) }
64 +
53 65 /**
54 66 * Which of the ported palettes the app's own screens use. Not a
55 67 * light/dark switch: these are the same named themes the Scribus routes
@@ -125,6 +137,7 @@ class Settings(context: Context) {
125 137 private const val KEY_THEME = "theme"
126 138 private const val KEY_HAS_CONNECTED = "has_connected"
127 139 private const val KEY_PUBLISH_LOCATION = "publish_location"
140 + private const val KEY_RICH_MODE = "rich_mode"
128 141 private const val KEY_CLIENT_ID = "client_id"
129 142 private const val KEY_BINDING_PREFIX = "binding_"
130 143 private const val KEY_DISMISSED = "dismissed"
modified clients/android/app/src/main/java/io/neiam/apolloscrib/data/VisionStore.kt
+19 −0
@@ -22,6 +22,24 @@ class VisionStore(context: Context) {
22 22
23 23 private val file = File(context.applicationContext.filesDir, FILE_NAME)
24 24
25 + // Its own file. The two boards arrive on separate topics and at separate
26 + // times, and folding them into one would mean a Plus board that landed
27 + // first being overwritten by the Basic one that followed it.
28 + private val plusFile = File(context.applicationContext.filesDir, PLUS_FILE_NAME)
29 +
30 + /** The extended reading, kept beside the board rather than replacing it. */
31 + fun savePlus(payload: String) {
32 + // Written whole then moved, as the board is, and for the same reason.
33 + val temp = File(plusFile.parentFile, "$PLUS_FILE_NAME.tmp")
34 + temp.writeText(payload)
35 + temp.renameTo(plusFile)
36 + revision.value = revision.value + 1
37 + }
38 +
39 + fun plusEntries(): List<VisionEntry> =
40 + if (plusFile.exists()) runCatching { Wire.parse(plusFile.readText()) }.getOrDefault(emptyList())
41 + else emptyList()
42 +
25 43 fun save(payload: String) {
26 44 // Written whole then moved, so a provider reading mid-write sees the
27 45 // previous payload rather than half of this one.
@@ -72,6 +90,7 @@ class VisionStore(context: Context) {
72 90 val boards: StateFlow<Long> get() = revision
73 91
74 92 private const val FILE_NAME = "vision.json"
93 + private const val PLUS_FILE_NAME = "vision_plus.json"
75 94 private const val STALE_AFTER_MS = 30 * 60 * 1000L
76 95 }
77 96 }
modified clients/android/app/src/main/java/io/neiam/apolloscrib/mqtt/AnkyraClient.kt
+38 −2
@@ -24,7 +24,9 @@ import java.util.concurrent.TimeUnit
24 24 class AnkyraClient(
25 25 private val settings: Settings,
26 26 private val onMessage: (String) -> Unit,
27 − private val onState: (State) -> Unit
27 + private val onState: (State) -> Unit,
28 + /** The extended reading, when the Pythiae publishes one. */
29 + private val onPlus: (String) -> Unit = {}
28 30 ) {
29 31
30 32 enum class State { Disconnected, Connecting, Connected, Failed }
@@ -117,7 +119,17 @@ class AnkyraClient(
117 119 // reconnect the phone had ever made.
118 120 built.publishes(MqttGlobalPublishFilter.ALL) { publish ->
119 121 if (attempt == generation) {
120 − onMessage(String(publish.payloadAsBytes, StandardCharsets.UTF_8))
122 + val payload = String(publish.payloadAsBytes, StandardCharsets.UTF_8)
123 +
124 + // Told apart by the topic it arrived on rather than by looking
125 + // at the payload: the two boards carry the same query ids and
126 + // mostly the same types, and guessing from content would be
127 + // guessing.
128 + if (publish.topic.toString().endsWith(PLUS_SUFFIX)) {
129 + onPlus(payload)
130 + } else {
131 + onMessage(payload)
132 + }
121 133 }
122 134 }
123 135
@@ -142,6 +154,27 @@ class AnkyraClient(
142 154 }
143 155
144 156 private fun subscribe(client: Mqtt3AsyncClient, attempt: Int) {
157 + // The extended reading, on its own topic. Subscribed unconditionally:
158 + // a Pythiae that is not publishing Plus simply never sends one, which
159 + // costs nothing, where asking first would mean knowing before the
160 + // first board has arrived.
161 + client.subscribeWith()
162 + .topicFilter(settings.topic + PLUS_SUFFIX)
163 + .qos(MqttQos.AT_MOST_ONCE)
164 + .send()
165 + .whenComplete { _, error ->
166 + // Logged either way. A Pythiae that is not publishing Plus
167 + // sends nothing here for ever, which is indistinguishable from
168 + // a subscription that never happened -- and rich mode showing
169 + // an empty board is the same picture in both cases.
170 + if (attempt != generation) return@whenComplete
171 + if (error != null) {
172 + Log.w(TAG, "plus subscribe failed", error)
173 + } else {
174 + Log.d(TAG, "subscribed to ${settings.topic}$PLUS_SUFFIX")
175 + }
176 + }
177 +
145 178 client.subscribeWith()
146 179 .topicFilter(settings.topic)
147 180 // QoS 0, matching the queue name Ankyra looks for when it counts
@@ -193,5 +226,8 @@ class AnkyraClient(
193 226
194 227 companion object {
195 228 private const val TAG = "AnkyraClient"
229 +
230 + /** Where a Pythiae publishes its Plus reading, beside the Basic one. */
231 + const val PLUS_SUFFIX = ".plus"
196 232 }
197 233 }
modified clients/android/app/src/main/java/io/neiam/apolloscrib/mqtt/AnkyraService.kt
+11 −1
@@ -72,7 +72,8 @@ class AnkyraService : Service() {
72 72 client = AnkyraClient(
73 73 settings = settings,
74 74 onMessage = ::onPayload,
75 − onState = ::onState
75 + onState = ::onState,
76 + onPlus = ::onPlusPayload
76 77 ).also { it.connect() }
77 78 }
78 79 // Whether this reports its location is a setting, so every start is a
@@ -124,6 +125,15 @@ class AnkyraService : Service() {
124 125 if (reporter == null) onResult(false) else reporter.reportNow(onResult)
125 126 }
126 127
128 + // The extended reading. Stored and drawn only in rich mode, but stored
129 + // whenever it arrives: switching modes should show the board that is
130 + // current, not wait for the next tick to fetch one.
131 + private fun onPlusPayload(payload: String) {
132 + Log.d(TAG, "plus payload: ${payload.length} bytes")
133 + store.savePlus(payload)
134 + Targets.notifyAll(this)
135 + }
136 +
127 137 private fun onPayload(payload: String) {
128 138 Log.d(TAG, "payload: ${payload.length} bytes")
129 139 store.save(payload)
added clients/android/app/src/main/java/io/neiam/apolloscrib/types/GtfsPlus.kt
+92 −0
@@ -0,0 +1,92 @@
1 +package io.neiam.apolloscrib.types
2 +
3 +import kotlinx.serialization.Serializable
4 +
5 +/**
6 + * The extended reading of a transit query.
7 + *
8 + * Mirrors `RoomSanctum.Condenser.PlusMQTT`. Where Basic flattens a route into
9 + * parallel lists of times, Plus keeps each arrival whole — so everything the
10 + * feed said about *this* departure stays attached to it rather than to an
11 + * index into three lists.
12 + *
13 + * Every field past the route itself is optional, because every one of them is
14 + * something a particular agency may or may not publish. A feed that sends only
15 + * times reads here exactly as it does in Basic.
16 + */
17 +@Serializable
18 +data class GtfsPlusCondensed(
19 + val route: String,
20 + val dest: String? = null,
21 + val dir: String? = null,
22 + val mode: String? = null,
23 + val arrivals: List<PlusArrival> = emptyList(),
24 + val alerts: List<AlertCondensed>? = null,
25 + /** Which way this stop lies from you. Only a Plani sends it. */
26 + val bearing: String? = null,
27 + // Presentation, as Basic carries it.
28 + val route_name: String? = null,
29 + val route_long: String? = null,
30 + val color: String? = null,
31 + val text_color: String? = null
32 +) {
33 + /** What is written on the front of the vehicle, falling back to the id. */
34 + fun displayName(): String = route_name ?: route
35 +}
36 +
37 +/**
38 + * One departure, with everything the feed said about it.
39 + *
40 + * [time] is the timetable; [time_live] is the feed's revision of it, absent
41 + * when the feed said nothing. The rest is why Plus exists: how late, how sure,
42 + * how full, which platform, and whether it is running at all.
43 + */
44 +@Serializable
45 +data class PlusArrival(
46 + val time: String? = null,
47 + val time_live: String? = null,
48 + /** Seconds off the timetable. Negative is early. */
49 + val delay: Int? = null,
50 + /** The feed's own confidence: 0 means it is certain. */
51 + val uncertainty: Int? = null,
52 + val occupancy: String? = null,
53 + val occupancy_pct: Int? = null,
54 + /** The number on the side of the train, where an agency gives one. */
55 + val name: String? = null,
56 + val bikes: String? = null,
57 + val carriages: List<PlusCarriage>? = null,
58 + /** Not running at all: `CANCELED`, `ADDED`, `DUPLICATED`. */
59 + val trip_status: String? = null,
60 + /** Running, but not calling here: `SKIPPED`, `NO_DATA`. */
61 + val stop_status: String? = null,
62 + /** What this call says about itself, over what the trip says. */
63 + val headsign: String? = null,
64 + val platform: String? = null,
65 + val trip_id: String? = null
66 +) {
67 + /** The time to show: the feed's revision where it has one. */
68 + fun best(): String? = time_live ?: time
69 +
70 + /** Whether that time came from the feed rather than the timetable. */
71 + fun live(): Boolean = time_live != null
72 +
73 + /**
74 + * Whether this departure is not going to happen.
75 + *
76 + * A cancelled trip and a skipped stop are different news to a rider -- the
77 + * line is not running, versus it is running past you -- but both mean do
78 + * not stand here waiting for it.
79 + */
80 + fun cancelled(): Boolean = trip_status == "CANCELED" || stop_status == "SKIPPED"
81 +
82 + /** Minutes off the timetable, rounded, or null when the feed did not say. */
83 + fun delayMinutes(): Int? = delay?.let { it / 60 }
84 +}
85 +
86 +@Serializable
87 +data class PlusCarriage(
88 + val id: String? = null,
89 + val label: String? = null,
90 + val occupancy: String? = null,
91 + val occupancy_pct: Int? = null
92 +)
modified clients/android/app/src/main/java/io/neiam/apolloscrib/types/Wire.kt
+8 −1
@@ -136,7 +136,14 @@ enum class SourceType(val wire: String, val label: String) {
136 136 // the crate. Drawn generically, from whatever the publisher sent.
137 137 Cronos("cronos", "Cronos"),
138 138 Packages("packages", "Packages"),
139 − Const("const", "Constant");
139 + Const("const", "Constant"),
140 +
141 + // The extended reading of a transit query, published on `<topic>.plus`.
142 + // Its own type rather than a flag on Gtfs: the shape is different -- one
143 + // entry per route carrying whole arrivals rather than parallel lists --
144 + // and a key that says so means a payload describes itself whichever topic
145 + // it arrived on.
146 + GtfsPlus("gtfs_plus", "Transit");
140 147
141 148 companion object {
142 149 fun of(wire: String): SourceType? = entries.firstOrNull { it.wire == wire }
modified clients/android/app/src/main/java/io/neiam/apolloscrib/ui/BoardScreen.kt
+75 −6
@@ -1,5 +1,8 @@
1 1 package io.neiam.apolloscrib.ui
2 2
3 +import androidx.compose.foundation.ExperimentalFoundationApi
4 +import androidx.compose.foundation.combinedClickable
5 +import io.neiam.apolloscrib.types.GtfsPlusCondensed
3 6 import androidx.compose.foundation.gestures.detectHorizontalDragGestures
4 7 import androidx.compose.foundation.layout.Arrangement
5 8 import androidx.compose.foundation.layout.PaddingValues
@@ -77,6 +80,11 @@ fun BoardScreen(
77 80
78 81 val context = LocalContext.current
79 82
83 + // Held here rather than in the header, because the header only offers the
84 + // gesture -- what changes is the whole board below it.
85 + var rich by remember { mutableStateOf(Settings(context).richMode) }
86 + val plus = remember(boards, rich) { if (rich) store.plusEntries() else emptyList() }
87 +
80 88 // A pull asks for a board; the wait ends when one lands. There is no
81 89 // acknowledgement to wait for -- a Pythiae answers on its own tick and the
82 90 // request is debounced at the other end -- so a new payload is the only
@@ -150,6 +158,11 @@ fun BoardScreen(
150 158 state = state,
151 159 lastUpdated = lastUpdated,
152 160 stale = stale,
161 + rich = rich,
162 + onToggleRich = {
163 + rich = !rich
164 + Settings(context).richMode = rich
165 + },
153 166 onEditConnection = onEditConnection,
154 167 modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 8.dp)
155 168 )
@@ -193,11 +206,50 @@ fun BoardScreen(
193 206 }
194 207 }
195 208
196 − entries.forEach { entry ->
197 − // No preview means the renderer had nothing worth a card -- a
198 − // stop with nothing due says so by not being there.
199 − items(Targets.preview(entry), key = { "${entry.key}-${it.id}" }) { preview ->
200 − PreviewCard(preview)
209 + if (rich) {
210 + // Three, because the point of this mode is depth rather than
211 + // coverage: a card here is four departures with everything the
212 + // feed said about each, and a screen of those is unreadable.
213 + val routes = plus.flatMap { entry ->
214 + entry.decode<List<GtfsPlusCondensed>>().orEmpty().map { entry to it }
215 + }
216 +
217 + if (routes.isEmpty()) {
218 + item {
219 + Card(Modifier.fillMaxWidth()) {
220 + Column(
221 + Modifier.padding(16.dp),
222 + verticalArrangement = Arrangement.spacedBy(4.dp)
223 + ) {
224 + Text(
225 + "No detailed board yet",
226 + style = MaterialTheme.typography.titleMedium
227 + )
228 + Text(
229 + "The extended reading is published separately, and only " +
230 + "when the Pythiae is asked for it. Hold the compass " +
231 + "again to go back.",
232 + style = MaterialTheme.typography.bodySmall,
233 + color = LocalAppTheme.current.dim
234 + )
235 + }
236 + }
237 + }
238 + }
239 +
240 + items(
241 + routes.take(3),
242 + key = { (entry, route) -> "${entry.key}-${route.route}-${route.dest}" }
243 + ) { (entry, route) ->
244 + RichCard(entry, route)
245 + }
246 + } else {
247 + entries.forEach { entry ->
248 + // No preview means the renderer had nothing worth a card -- a
249 + // stop with nothing due says so by not being there.
250 + items(Targets.preview(entry), key = { "${entry.key}-${it.id}" }) { preview ->
251 + PreviewCard(preview)
252 + }
201 253 }
202 254 }
203 255
@@ -227,11 +279,14 @@ fun BoardScreen(
227 279 }
228 280 }
229 281
282 +@OptIn(ExperimentalFoundationApi::class)
230 283 @Composable
231 284 private fun StatusHeader(
232 285 state: AnkyraClient.State,
233 286 lastUpdated: Long?,
234 287 stale: Boolean,
288 + rich: Boolean,
289 + onToggleRich: () -> Unit,
235 290 onEditConnection: (() -> Unit)?,
236 291 modifier: Modifier = Modifier
237 292 ) {
@@ -263,7 +318,21 @@ private fun StatusHeader(
263 318 // is NE of you and this says which way NE is. Nothing to read without
264 319 // both, which is why it sits where the eye crosses from one to the
265 320 // other.
266 − Compass(heading, Modifier.padding(horizontal = 12.dp))
321 + Compass(
322 + heading,
323 + Modifier
324 + .padding(horizontal = 12.dp)
325 + // A long press rather than a tap: the compass is a readout,
326 + // and something you read should not change the screen when
327 + // your thumb brushes it. Nothing is bound to the short press,
328 + // so a stray tap does nothing at all.
329 + .combinedClickable(
330 + onClick = {},
331 + onLongClick = onToggleRich,
332 + onLongClickLabel =
333 + if (rich) "Show every card" else "Show detailed cards"
334 + )
335 + )
267 336
268 337 // The connection, and the way into it, on the right: both about the
269 338 // link rather than about the data.
added clients/android/app/src/main/java/io/neiam/apolloscrib/ui/RichCard.kt
+241 −0
@@ -0,0 +1,241 @@
1 +package io.neiam.apolloscrib.ui
2 +
3 +import androidx.compose.foundation.background
4 +import androidx.compose.foundation.layout.Arrangement
5 +import androidx.compose.foundation.layout.Column
6 +import androidx.compose.foundation.layout.Row
7 +import androidx.compose.foundation.layout.Spacer
8 +import androidx.compose.foundation.layout.fillMaxWidth
9 +import androidx.compose.foundation.layout.padding
10 +import androidx.compose.foundation.layout.size
11 +import androidx.compose.foundation.layout.width
12 +import androidx.compose.foundation.shape.RoundedCornerShape
13 +import androidx.compose.material3.Card
14 +import androidx.compose.material3.Icon
15 +import androidx.compose.material3.MaterialTheme
16 +import androidx.compose.material3.Text
17 +import androidx.compose.runtime.Composable
18 +import androidx.compose.ui.Alignment
19 +import androidx.compose.ui.Modifier
20 +import androidx.compose.ui.draw.clip
21 +import androidx.compose.ui.graphics.Color
22 +import androidx.compose.ui.res.painterResource
23 +import androidx.compose.ui.text.font.FontFamily
24 +import androidx.compose.ui.text.style.TextDecoration
25 +import androidx.compose.ui.text.style.TextOverflow
26 +import androidx.compose.ui.unit.dp
27 +import io.neiam.apolloscrib.R
28 +import io.neiam.apolloscrib.types.GtfsPlusCondensed
29 +import io.neiam.apolloscrib.types.PlusArrival
30 +import io.neiam.apolloscrib.targets.asClockTime
31 +import io.neiam.apolloscrib.types.VisionEntry
32 +import io.neiam.apolloscrib.ui.theme.LocalAppTheme
33 +
34 +/**
35 + * One route, in as much detail as the feed gave.
36 + *
37 + * The compact card answers "when is the next one". This answers the questions
38 + * you ask once you know that: how late is it really, is it actually running,
39 + * which platform, and is there any room on it. All of that arrives in the Plus
40 + * feed and none of it fits on a card three lines high, which is why this is a
41 + * mode rather than a bigger card.
42 + */
43 +@Composable
44 +fun RichCard(entry: VisionEntry, route: GtfsPlusCondensed, modifier: Modifier = Modifier) {
45 + val palette = LocalAppTheme.current
46 +
47 + Card(modifier.fillMaxWidth()) {
48 + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
49 + Header(entry, route, palette.dim)
50 +
51 + // Four is what fits without the card becoming a timetable. Past
52 + // that the answer is the stop's own board, not this.
53 + route.arrivals.take(4).forEach { arrival -> Arrival(arrival, palette.dim) }
54 +
55 + if (route.arrivals.isEmpty()) {
56 + Text(
57 + "Nothing due",
58 + style = MaterialTheme.typography.bodyMedium,
59 + color = palette.dim
60 + )
61 + }
62 +
63 + route.alerts.orEmpty().take(2).forEach { alert ->
64 + Row(verticalAlignment = Alignment.Top) {
65 + Glyph(R.drawable.fa_triangle_exclamation, 14.dp, MaterialTheme.colorScheme.error)
66 + Spacer(Modifier.width(8.dp))
67 + Text(
68 + alert.header ?: alert.effect,
69 + style = MaterialTheme.typography.bodySmall,
70 + color = MaterialTheme.colorScheme.error,
71 + maxLines = 3,
72 + overflow = TextOverflow.Ellipsis
73 + )
74 + }
75 + }
76 + }
77 + }
78 +}
79 +
80 +@Composable
81 +private fun Header(entry: VisionEntry, route: GtfsPlusCondensed, dim: Color) {
82 + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
83 + Row(verticalAlignment = Alignment.CenterVertically) {
84 + // The route's own colour, where the agency publishes one. A badge
85 + // rather than tinted text: this is the thing on the front of the
86 + // bus, and it is a badge there too.
87 + RouteBadge(route)
88 +
89 + Spacer(Modifier.width(10.dp))
90 +
91 + Text(
92 + route.dest ?: entry.label(),
93 + style = MaterialTheme.typography.titleMedium,
94 + maxLines = 1,
95 + overflow = TextOverflow.Ellipsis,
96 + modifier = Modifier.weight(1f)
97 + )
98 +
99 + route.bearing?.let { bearing ->
100 + Glyph(R.drawable.fa_location_arrow, 12.dp, dim)
101 + Spacer(Modifier.width(4.dp))
102 + Text(bearing, style = MaterialTheme.typography.labelMedium, color = dim)
103 + }
104 + }
105 +
106 + Text(
107 + listOfNotNull(entry.label(), route.dir, route.route_long?.takeIf { it != route.dest })
108 + .distinct()
109 + .joinToString(" · "),
110 + style = MaterialTheme.typography.bodySmall,
111 + color = dim,
112 + maxLines = 1,
113 + overflow = TextOverflow.Ellipsis
114 + )
115 + }
116 +}
117 +
118 +@Composable
119 +private fun RouteBadge(route: GtfsPlusCondensed) {
120 + val background = route.color?.let { runCatching { Color(android.graphics.Color.parseColor(it)) }.getOrNull() }
121 + val foreground = route.text_color?.let { runCatching { Color(android.graphics.Color.parseColor(it)) }.getOrNull() }
122 +
123 + Text(
124 + route.displayName(),
125 + style = MaterialTheme.typography.titleMedium,
126 + color = foreground ?: MaterialTheme.colorScheme.onSecondary,
127 + modifier = Modifier
128 + .clip(RoundedCornerShape(6.dp))
129 + .background(background ?: MaterialTheme.colorScheme.secondary)
130 + .padding(horizontal = 8.dp, vertical = 2.dp)
131 + )
132 +}
133 +
134 +/**
135 + * One departure and everything known about it.
136 + *
137 + * The time leads because it is what is being asked. What follows it qualifies
138 + * it, in the order a rider cares: is it happening, how late, from where, how
139 + * full.
140 + */
141 +@Composable
142 +private fun Arrival(arrival: PlusArrival, dim: Color) {
143 + val cancelled = arrival.cancelled()
144 +
145 + Row(verticalAlignment = Alignment.CenterVertically) {
146 + Glyph(
147 + if (arrival.live()) R.drawable.fa_tower_broadcast else R.drawable.fa_clock,
148 + 14.dp,
149 + if (arrival.live()) LocalAppTheme.current.liveGreen else dim
150 + )
151 +
152 + Spacer(Modifier.width(8.dp))
153 +
154 + Text(
155 + arrival.best()?.asClockTime() ?: "--:--",
156 + style = MaterialTheme.typography.titleMedium,
157 + fontFamily = FontFamily.Monospace,
158 + textDecoration = if (cancelled) TextDecoration.LineThrough else null,
159 + color = if (cancelled) dim else MaterialTheme.colorScheme.onSurface
160 + )
161 +
162 + Spacer(Modifier.width(10.dp))
163 +
164 + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(1.dp)) {
165 + val status = statusOf(arrival)
166 + if (status != null) {
167 + Text(
168 + status,
169 + style = MaterialTheme.typography.labelMedium,
170 + color = if (cancelled) MaterialTheme.colorScheme.error else dim
171 + )
172 + }
173 +
174 + val detail = listOfNotNull(
175 + arrival.platform?.let { "Platform $it" },
176 + arrival.name,
177 + arrival.headsign,
178 + occupancyOf(arrival),
179 + arrival.carriages?.size?.takeIf { it > 0 }?.let { "$it cars" }
180 + )
181 +
182 + if (detail.isNotEmpty()) {
183 + Text(
184 + detail.joinToString(" · "),
185 + style = MaterialTheme.typography.bodySmall,
186 + color = dim,
187 + maxLines = 2,
188 + overflow = TextOverflow.Ellipsis
189 + )
190 + }
191 + }
192 + }
193 +}
194 +
195 +/**
196 + * How this departure is doing, in a rider's words.
197 + *
198 + * "On time" is only said when the feed actually said so -- a scheduled time
199 + * with no realtime behind it is not a promise, and claiming it is on time
200 + * would be inventing confidence the agency never offered.
201 + */
202 +private fun statusOf(arrival: PlusArrival): String? = when {
203 + arrival.trip_status == "CANCELED" -> "Cancelled"
204 + arrival.stop_status == "SKIPPED" -> "Not stopping here"
205 + arrival.trip_status == "ADDED" -> "Extra service"
206 + else -> arrival.delayMinutes()?.let { minutes ->
207 + when {
208 + minutes >= 1 -> "$minutes min late"
209 + minutes <= -1 -> "${-minutes} min early"
210 + // Inside a minute either way, which is on time by any reading a
211 + // person standing at a stop would give it.
212 + else -> "On time"
213 + }
214 + }
215 +}
216 +
217 +// GTFS-RT spells these `FEW_SEATS_AVAILABLE`; nobody says that out loud.
218 +private fun occupancyOf(arrival: PlusArrival): String? {
219 + arrival.occupancy_pct?.let { return "$it% full" }
220 +
221 + return when (arrival.occupancy) {
222 + "EMPTY" -> "Empty"
223 + "MANY_SEATS_AVAILABLE" -> "Seats"
224 + "FEW_SEATS_AVAILABLE" -> "Few seats"
225 + "STANDING_ROOM_ONLY" -> "Standing"
226 + "CRUSHED_STANDING_ROOM_ONLY" -> "Very full"
227 + "FULL" -> "Full"
228 + "NOT_ACCEPTING_PASSENGERS" -> "Not boarding"
229 + else -> null
230 + }
231 +}
232 +
233 +@Composable
234 +private fun Glyph(res: Int, size: androidx.compose.ui.unit.Dp, tint: Color) {
235 + Icon(
236 + painter = painterResource(res),
237 + contentDescription = null,
238 + modifier = Modifier.size(size),
239 + tint = tint
240 + )
241 +}
modified clients/android/app/src/test/java/io/neiam/apolloscrib/types/WireTest.kt
+54 −0
@@ -144,6 +144,60 @@ class WireTest {
144 144 assertTrue(bikes[1].isFreeBike())
145 145 }
146 146
147 + @Test
148 + fun `the plus reading is its own type, with whole arrivals`() {
149 + // Published on <topic>.plus and keyed gtfs_plus, so a payload says
150 + // which reading it is whichever topic it arrived on.
151 + val payload = """
152 + {"gtfs_plus-12": {
153 + "query": {"name": "Davis", "meta": {}},
154 + "data": [{"route":"87","dest":"Arlington","dir":"In","mode":"Bus",
155 + "route_name":"87","color":"#FFC72C","bearing":"NE",
156 + "arrivals":[
157 + {"time":"08:00:00","time_live":"08:03:00","delay":180,
158 + "occupancy":"FEW_SEATS_AVAILABLE","platform":"2",
159 + "name":"1842","trip_id":"t1"},
160 + {"time":"08:20:00","trip_status":"CANCELED"}
161 + ]}]
162 + }}
163 + """.trimIndent()
164 +
165 + val entry = Wire.parse(payload).single()
166 + assertEquals(SourceType.GtfsPlus, entry.type)
167 +
168 + val route = entry.decode<List<GtfsPlusCondensed>>()!!.single()
169 + assertEquals("87", route.displayName())
170 + assertEquals("NE", route.bearing)
171 +
172 + val first = route.arrivals[0]
173 + assertEquals("08:03:00", first.best())
174 + assertTrue(first.live())
175 + assertEquals(3, first.delayMinutes())
176 + assertTrue(!first.cancelled())
177 +
178 + // A cancelled departure keeps its time -- it is struck through, not
179 + // hidden, because "the 08:20 is not running" is the news.
180 + val second = route.arrivals[1]
181 + assertEquals("08:20:00", second.best())
182 + assertTrue(!second.live())
183 + assertTrue(second.cancelled())
184 + }
185 +
186 + @Test
187 + fun `a plus arrival with no realtime claims nothing`() {
188 + val payload = """
189 + {"gtfs_plus-12": [{"route":"1","arrivals":[{"time":"09:00:00"}]}]}
190 + """.trimIndent()
191 +
192 + val arrival = Wire.parse(payload).single()
193 + .decode<List<GtfsPlusCondensed>>()!!.single().arrivals.single()
194 +
195 + assertEquals("09:00:00", arrival.best())
196 + assertTrue(!arrival.live())
197 + // No delay reported is not "on time"; it is nothing said.
198 + assertEquals(null, arrival.delayMinutes())
199 + }
200 +
147 201 @Test
148 202 fun `a car share is cars, not bikes`() {
149 203 // Getaround Barcelona publishes 293 cars over GBFS. Nothing about the

Parents: aa82462