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.
Add a detailed board, held behind the compass
725a8da · Gabriel Morell · 2026-09-08 20:01
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