Give every kind a rich card, and one rotation to share

8dcaa80 · Gabriel Morell · 2026-09-08 20:22

13 files +310 -23
Message
{commit_body(@commit)}

Files changed

modified clients/android/app/src/main/java/io/neiam/apolloscrib/ui/BoardScreen.kt
+46 −23
@@ -4,6 +4,7 @@ import androidx.compose.foundation.ExperimentalFoundationApi
4 4 import androidx.compose.foundation.combinedClickable
5 5 import io.neiam.apolloscrib.types.GtfsPlusCondensed
6 6 import io.neiam.apolloscrib.types.SourceType
7 +import io.neiam.apolloscrib.types.VisionEntry
7 8 import androidx.compose.foundation.gestures.detectHorizontalDragGestures
8 9 import androidx.compose.foundation.layout.Arrangement
9 10 import androidx.compose.foundation.layout.PaddingValues
@@ -88,23 +89,33 @@ fun BoardScreen(
88 89 if (richCards > 0) store.plusEntries() else emptyList()
89 90 }
90 91
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
92 + // Everything the board has, as cards of one weight, in one order.
93 + //
94 + // Transit first and soonest first within it, because a departure is the
95 + // only thing here that expires -- the weather is as true in ten minutes as
96 + // it is now, and a bus is not. Everything else follows in the order the
97 + // board gave it.
98 + val cards: List<RichItem> = remember(plus) {
99 + val routes = plus
96 100 .flatMap { entry ->
97 101 entry.decode<List<GtfsPlusCondensed>>().orEmpty().map { entry to it }
98 102 }
99 103 .sortedBy { (_, route) ->
100 104 route.arrivals.firstNotNullOfOrNull { it.best() } ?: "99:99:99"
101 105 }
106 + .map { (entry, route) -> RichItem.Transit(entry, route) }
107 +
108 + val others = plus
109 + .filter { it.type != SourceType.GtfsPlus }
110 + .mapNotNull { entry -> factsFor(entry)?.let { RichItem.Facts(entry, it) } }
111 +
112 + routes + others
102 113 }
103 114
104 115 // Rotated only when there is more than fits. Turning a list that is
105 116 // 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)
117 + val window = rotatingWindow(cards.size, richCards)
118 + val rotation by rememberRotationProgress(rotating = cards.size > richCards && richCards > 0)
108 119
109 120 // A pull asks for a board; the wait ends when one lands. There is no
110 121 // acknowledgement to wait for -- a Pythiae answers on its own tick and the
@@ -233,7 +244,7 @@ fun BoardScreen(
233 244
234 245 if (richCards > 0) {
235 246
236 − if (routes.isEmpty()) {
247 + if (cards.isEmpty()) {
237 248 item {
238 249 Card(Modifier.fillMaxWidth()) {
239 250 Column(
@@ -256,21 +267,10 @@ fun BoardScreen(
256 267 }
257 268 }
258 269
259 − items(
260 − window.map { routes[it] },
261 − key = { (entry, route) -> "${entry.key}-${route.route}-${route.dest}" }
262 − ) { (entry, route) ->
263 − RichCard(entry, route)
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)
270 + items(window.map { cards[it] }, key = { it.key }) { card ->
271 + when (card) {
272 + is RichItem.Transit -> RichCard(card.entry, card.route)
273 + is RichItem.Facts -> RichFactsCard(card.facts)
274 274 }
275 275 }
276 276 } else {
@@ -501,3 +501,26 @@ private fun Glyph(res: Int, size: Dp, tint: Color) {
501 501 }
502 502
503 503 private fun clock(at: Long): String = DateFormat.getTimeInstance().format(Date(at))
504 +
505 +/**
506 + * One card in the detailed rotation.
507 + *
508 + * Transit keeps its own shape because it has genuinely more to say -- a list
509 + * of departures, each with its own delay and crowding -- where everything else
510 + * is a headline and some named facts. Both are cards of the same weight, which
511 + * is what lets them share one turn.
512 + */
513 +sealed interface RichItem {
514 + val key: String
515 +
516 + data class Transit(
517 + val entry: VisionEntry,
518 + val route: io.neiam.apolloscrib.types.GtfsPlusCondensed
519 + ) : RichItem {
520 + override val key = "${entry.key}-${route.route}-${route.dest}"
521 + }
522 +
523 + data class Facts(val entry: VisionEntry, val facts: RichFacts) : RichItem {
524 + override val key = entry.key
525 + }
526 +}
modified clients/android/app/src/main/java/io/neiam/apolloscrib/ui/RichCard.kt
+74 −0
@@ -261,3 +261,77 @@ private fun Glyph(res: Int, size: androidx.compose.ui.unit.Dp, tint: Color) {
261 261 tint = tint
262 262 )
263 263 }
264 +
265 +/**
266 + * A non-transit query, in as much detail as it has.
267 + *
268 + * The same weight as [RichCard] so the two sit together in one rotation: a
269 + * headline that answers the question, and beneath it the things that qualify
270 + * it, each saying its own name.
271 + */
272 +@Composable
273 +fun RichFactsCard(facts: RichFacts, modifier: Modifier = Modifier) {
274 + val palette = LocalAppTheme.current
275 +
276 + Card(modifier.fillMaxWidth()) {
277 + Column(Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) {
278 + Row(verticalAlignment = Alignment.CenterVertically) {
279 + Glyph(facts.iconRes, 18.dp, palette.accent)
280 + Spacer(Modifier.width(10.dp))
281 + Text(
282 + facts.title,
283 + style = MaterialTheme.typography.titleMedium,
284 + maxLines = 2,
285 + overflow = TextOverflow.Ellipsis
286 + )
287 + }
288 +
289 + if (facts.headline != null) {
290 + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
291 + Text(facts.headline, style = MaterialTheme.typography.displaySmall)
292 +
293 + facts.caption?.let {
294 + Text(
295 + it,
296 + style = MaterialTheme.typography.titleSmall,
297 + color = palette.dim
298 + )
299 + }
300 + }
301 + } else {
302 + facts.caption?.let {
303 + Text(it, style = MaterialTheme.typography.titleMedium, color = palette.dim)
304 + }
305 + }
306 +
307 + // Two to a row: a label and its value need to stay together, and
308 + // one per line would make a card of six facts taller than the
309 + // screen for no gain.
310 + facts.facts.chunked(2).forEach { pair ->
311 + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
312 + pair.forEach { fact ->
313 + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(1.dp)) {
314 + Text(
315 + fact.label,
316 + style = MaterialTheme.typography.labelSmall,
317 + color = palette.dim,
318 + maxLines = 1,
319 + overflow = TextOverflow.Ellipsis
320 + )
321 + Text(
322 + fact.value.ifBlank { "—" },
323 + style = MaterialTheme.typography.titleMedium,
324 + maxLines = 1,
325 + overflow = TextOverflow.Ellipsis
326 + )
327 + }
328 + }
329 +
330 + // A lone fact on the last row keeps its half rather than
331 + // stretching across a width the others do not use.
332 + if (pair.size == 1) Spacer(Modifier.weight(1f))
333 + }
334 + }
335 + }
336 + }
337 +}
added clients/android/app/src/main/java/io/neiam/apolloscrib/ui/RichFacts.kt
+190 −0
@@ -0,0 +1,190 @@
1 +package io.neiam.apolloscrib.ui
2 +
3 +import io.neiam.apolloscrib.R
4 +import io.neiam.apolloscrib.targets.Targets
5 +import io.neiam.apolloscrib.targets.asClockTime
6 +import io.neiam.apolloscrib.types.AqiCondensed
7 +import io.neiam.apolloscrib.types.EphemerisCondensed
8 +import io.neiam.apolloscrib.types.GbfsCondensed
9 +import io.neiam.apolloscrib.types.SourceType
10 +import io.neiam.apolloscrib.types.TidalCondensed
11 +import io.neiam.apolloscrib.types.VisionEntry
12 +import io.neiam.apolloscrib.types.WeatherCondensed
13 +import kotlin.math.roundToInt
14 +
15 +/**
16 + * One card's worth of a query, in named parts.
17 + *
18 + * The compact card is a title and a few rows of icon-and-number. That is the
19 + * right shape for something glanced at, and it throws away the one thing a
20 + * detailed card is for: what the number *is*. A weather card shows 21° beside
21 + * a thermometer and 44 beside a droplet, and you know which is which because
22 + * you know what droplets mean -- read that sentence about pressure, or
23 + * visibility, and it stops working.
24 + *
25 + * So a rich card carries labels. [headline] is the thing being asked about,
26 + * [facts] are the things that qualify it, and each fact says its own name.
27 + */
28 +data class RichFacts(
29 + val title: String,
30 + val iconRes: Int,
31 + val headline: String? = null,
32 + val caption: String? = null,
33 + val facts: List<Fact> = emptyList()
34 +) {
35 + data class Fact(val label: String, val value: String)
36 +}
37 +
38 +/**
39 + * The rich reading of an entry, or null for one with nothing to say.
40 + *
41 + * Falls back to whatever the compact renderer made of it, so a type nobody has
42 + * written a rich reading for still gets a card rather than disappearing from a
43 + * mode that is meant to show more, not less.
44 + */
45 +fun factsFor(entry: VisionEntry): RichFacts? = when (entry.type) {
46 + SourceType.Weather -> weather(entry)
47 + SourceType.Aqi -> aqi(entry)
48 + SourceType.Ephem -> ephem(entry)
49 + SourceType.Tidal -> tidal(entry)
50 + SourceType.Gbfs -> gbfs(entry)
51 + else -> generic(entry)
52 +}
53 +
54 +private fun weather(entry: VisionEntry): RichFacts? {
55 + val now = entry.decode<List<WeatherCondensed>>()?.firstOrNull() ?: return null
56 + val degrees = now.degrees()
57 +
58 + return RichFacts(
59 + title = now.name ?: entry.label(),
60 + iconRes = R.drawable.fa_temperature_half,
61 + headline = now.temp?.let { "${it.roundToInt()}$degrees" },
62 + caption = now.weather,
63 + facts = listOfNotNull(
64 + now.feel?.let { RichFacts.Fact("Feels like", "${it.roundToInt()}$degrees") },
65 + now.hum?.let { RichFacts.Fact("Humidity", "${it.roundToInt()}%") },
66 + now.pressure?.let { RichFacts.Fact("Pressure", "${it.roundToInt()} hPa") },
67 + now.wind?.speed?.let { speed ->
68 + val bearing = now.wind.deg?.let { " ${compassPoint(it.toFloat())}" }.orEmpty()
69 + RichFacts.Fact("Wind", "${speed.roundToInt()} m/s$bearing")
70 + },
71 + now.visibility?.let { RichFacts.Fact("Visibility", "${(it / 1000).roundToInt()} km") }
72 + )
73 + )
74 +}
75 +
76 +private fun aqi(entry: VisionEntry): RichFacts? {
77 + val site = entry.decode<List<AqiCondensed>>()?.firstOrNull() ?: return null
78 +
79 + // The worst reading leads, because that is the one that decides whether to
80 + // go outside; the rest qualify it.
81 + val worst = site.measurements.maxByOrNull { it.value.toIntOrNull() ?: -1 }
82 +
83 + return RichFacts(
84 + title = site.name ?: entry.label(),
85 + iconRes = R.drawable.fa_wind,
86 + headline = worst?.value,
87 + caption = worst?.key,
88 + facts = site.measurements
89 + .filterKeys { it != worst?.key }
90 + .map { (pollutant, value) -> RichFacts.Fact(pollutant, value) }
91 + )
92 +}
93 +
94 +private fun ephem(entry: VisionEntry): RichFacts? {
95 + val sky = entry.decode<List<EphemerisCondensed>>()?.firstOrNull() ?: return null
96 +
97 + return RichFacts(
98 + title = sky.name ?: entry.label(),
99 + iconRes = R.drawable.fa_sun,
100 + headline = sky.sunset?.asClockTime(),
101 + caption = sky.sunset?.let { "Sunset" },
102 + facts = listOfNotNull(
103 + sky.sunrise?.let { RichFacts.Fact("Sunrise", it.asClockTime()) },
104 + sky.moonrise?.let { RichFacts.Fact("Moonrise", it.asClockTime()) },
105 + sky.moonset?.let { RichFacts.Fact("Moonset", it.asClockTime()) },
106 + sky.phase?.let { RichFacts.Fact("Moon", it) }
107 + )
108 + )
109 +}
110 +
111 +private fun tidal(entry: VisionEntry): RichFacts? {
112 + val tide = entry.decode<List<TidalCondensed>>()?.firstOrNull() ?: return null
113 +
114 + val facts = listOfNotNull(
115 + tide.first_h?.let { RichFacts.Fact("High", "${it.asClockTime()} ${tide.first_hv.orEmpty()}".trim()) },
116 + tide.first_l?.let { RichFacts.Fact("Low", "${it.asClockTime()} ${tide.first_lv.orEmpty()}".trim()) },
117 + tide.second_h?.let { RichFacts.Fact("Then high", "${it.asClockTime()} ${tide.second_hv.orEmpty()}".trim()) },
118 + tide.second_l?.let { RichFacts.Fact("Then low", "${it.asClockTime()} ${tide.second_lv.orEmpty()}".trim()) }
119 + )
120 +
121 + if (facts.isEmpty()) return null
122 +
123 + return RichFacts(
124 + title = entry.label(),
125 + iconRes = R.drawable.fa_water,
126 + headline = facts.first().value.substringBefore(" "),
127 + caption = facts.first().label + " tide",
128 + facts = facts.drop(1)
129 + )
130 +}
131 +
132 +private fun gbfs(entry: VisionEntry): RichFacts? {
133 + val stations = entry.decode<List<GbfsCondensed>>().orEmpty()
134 + if (stations.isEmpty()) return null
135 +
136 + val loose = stations.all { it.isFreeBike() }
137 + val bikes = stations.sumOf { it.avail ?: 0 }
138 +
139 + return RichFacts(
140 + title = entry.label(),
141 + iconRes = R.drawable.fa_bicycle,
142 + headline = if (loose) stations.size.toString() else bikes.toString(),
143 + caption = if (loose) "nearby" else "available",
144 + facts = if (loose) {
145 + // A loose fleet has no docks to count; what it has is charge, and
146 + // which way to walk.
147 + stations.take(4).mapNotNull { bike ->
148 + val charge = bike.fuel_pct?.let { "${(it * 100).roundToInt()}%" }
149 + val where = bike.dir
150 + val value = listOfNotNull(charge, where).joinToString(" · ")
151 +
152 + if (value.isEmpty()) null else RichFacts.Fact(bike.name, value)
153 + }
154 + } else {
155 + listOfNotNull(
156 + stations.sumOf { it.avail_elec ?: 0 }.takeIf { it > 0 }
157 + ?.let { RichFacts.Fact("Electric", it.toString()) },
158 + stations.sumOf { it.docks_avail ?: 0 }.takeIf { it > 0 }
159 + ?.let { RichFacts.Fact("Docks free", it.toString()) },
160 + stations.firstOrNull()?.dir?.let { RichFacts.Fact("Direction", it) }
161 + ) + stations.take(3).map {
162 + RichFacts.Fact(it.name, "${it.avail ?: 0} bikes")
163 + }
164 + }
165 + )
166 +}
167 +
168 +/**
169 + * Whatever the compact renderer made of it, laid out with room.
170 + *
171 + * No labels, because the compact card has none to give -- its rows are a glyph
172 + * and a value and the glyph carries the meaning. Still worth showing: a type
173 + * without a rich reading written for it should be quieter in this mode, not
174 + * absent from it.
175 + */
176 +private fun generic(entry: VisionEntry): RichFacts? {
177 + val preview = Targets.preview(entry).firstOrNull() ?: return null
178 +
179 + return RichFacts(
180 + title = preview.title,
181 + iconRes = preview.iconRes,
182 + caption = preview.subtitle,
183 + facts = preview.rows.take(6).map { row ->
184 + RichFacts.Fact(
185 + row.text.ifBlank { "—" },
186 + row.stamps.joinToString(" · ") { it.text }
187 + )
188 + }
189 + )
190 +}

Parents: 17fd74d