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.
Give every kind a rich card, and one rotation to share
8dcaa80 · Gabriel Morell · 2026-09-08 20:22
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