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.
Cycle the detailed board one card at a time, and give them room
e04988a · Gabriel Morell · 2026-09-08 20:14
Message
{commit_body(@commit)}
Files changed
modified
clients/android/app/src/main/java/io/neiam/apolloscrib/data/Settings.kt
+14
−9
@@ -51,16 +51,18 @@ class Settings(context: Context) {
| 51 | 51 | set(value) = prefs.edit { putBoolean(KEY_PUBLISH_LOCATION, value) } |
| 52 | 52 | |
| 53 | 53 | /** |
| 54 | − * Whether the board shows a few detailed cards rather than many compact | |
| 55 | − * ones. Toggled by holding the compass. | |
| 54 | + * How many detailed cards the board shows: 0 for the compact board, 1 to 3 | |
| 55 | + * for that many rich ones. Cycled by holding the compass. | |
| 56 | 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. | |
| 57 | + * A count rather than a switch, because how much detail you want is not a | |
| 58 | + * yes or no -- one card is a departure board for the stop you are standing | |
| 59 | + * at, three is a comparison. Coerced into range on read, so a value left | |
| 60 | + * by an older build cannot put the screen in a state the gesture cannot | |
| 61 | + * get out of. | |
| 60 | 62 | */ |
| 61 | − var richMode: Boolean | |
| 62 | − get() = prefs.getBoolean(KEY_RICH_MODE, false) | |
| 63 | − set(value) = prefs.edit { putBoolean(KEY_RICH_MODE, value) } | |
| 63 | + var richCards: Int | |
| 64 | + get() = prefs.getInt(KEY_RICH_CARDS, 0).coerceIn(0, MAX_RICH_CARDS) | |
| 65 | + set(value) = prefs.edit { putInt(KEY_RICH_CARDS, value.coerceIn(0, MAX_RICH_CARDS)) } | |
| 64 | 66 | |
| 65 | 67 | /** |
| 66 | 68 | * Which of the ported palettes the app's own screens use. Not a |
@@ -137,7 +139,10 @@ class Settings(context: Context) {
| 137 | 139 | private const val KEY_THEME = "theme" |
| 138 | 140 | private const val KEY_HAS_CONNECTED = "has_connected" |
| 139 | 141 | private const val KEY_PUBLISH_LOCATION = "publish_location" |
| 140 | − private const val KEY_RICH_MODE = "rich_mode" | |
| 142 | + private const val KEY_RICH_CARDS = "rich_cards" | |
| 143 | + | |
| 144 | + /** Past three the screen is a list again, which is the other mode. */ | |
| 145 | + const val MAX_RICH_CARDS = 3 | |
| 141 | 146 | private const val KEY_CLIENT_ID = "client_id" |
| 142 | 147 | private const val KEY_BINDING_PREFIX = "binding_" |
| 143 | 148 | private const val KEY_DISMISSED = "dismissed" |
modified
clients/android/app/src/main/java/io/neiam/apolloscrib/ui/BoardScreen.kt
+29
−15
@@ -82,8 +82,10 @@ fun BoardScreen(
| 82 | 82 | |
| 83 | 83 | // Held here rather than in the header, because the header only offers the |
| 84 | 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() } | |
| 85 | + var richCards by remember { mutableStateOf(Settings(context).richCards) } | |
| 86 | + val plus = remember(boards, richCards) { | |
| 87 | + if (richCards > 0) store.plusEntries() else emptyList() | |
| 88 | + } | |
| 87 | 89 | |
| 88 | 90 | // A pull asks for a board; the wait ends when one lands. There is no |
| 89 | 91 | // acknowledgement to wait for -- a Pythiae answers on its own tick and the |
@@ -158,10 +160,13 @@ fun BoardScreen(
| 158 | 160 | state = state, |
| 159 | 161 | lastUpdated = lastUpdated, |
| 160 | 162 | stale = stale, |
| 161 | − rich = rich, | |
| 163 | + richCards = richCards, | |
| 162 | 164 | onToggleRich = { |
| 163 | − rich = !rich | |
| 164 | − Settings(context).richMode = rich | |
| 165 | + // One more each time, and off the end back to the compact | |
| 166 | + // board -- so the gesture always has somewhere to go and never | |
| 167 | + // needs a second one to undo it. | |
| 168 | + richCards = (richCards + 1) % (Settings.MAX_RICH_CARDS + 1) | |
| 169 | + Settings(context).richCards = richCards | |
| 165 | 170 | }, |
| 166 | 171 | onEditConnection = onEditConnection, |
| 167 | 172 | modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 8.dp) |
@@ -206,13 +211,17 @@ fun BoardScreen(
| 206 | 211 | } |
| 207 | 212 | } |
| 208 | 213 | |
| 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 | − } | |
| 214 | + if (richCards > 0) { | |
| 215 | + // Soonest first, then as many as asked for. Taking them in payload | |
| 216 | + // order would show whichever the publisher happened to list, which | |
| 217 | + // for a stop with six routes is not the one leaving next. | |
| 218 | + val routes = plus | |
| 219 | + .flatMap { entry -> | |
| 220 | + entry.decode<List<GtfsPlusCondensed>>().orEmpty().map { entry to it } | |
| 221 | + } | |
| 222 | + .sortedBy { (_, route) -> | |
| 223 | + route.arrivals.firstNotNullOfOrNull { it.best() } ?: "99:99:99" | |
| 224 | + } | |
| 216 | 225 | |
| 217 | 226 | if (routes.isEmpty()) { |
| 218 | 227 | item { |
@@ -238,7 +247,7 @@ fun BoardScreen(
| 238 | 247 | } |
| 239 | 248 | |
| 240 | 249 | items( |
| 241 | − routes.take(3), | |
| 250 | + routes.take(richCards), | |
| 242 | 251 | key = { (entry, route) -> "${entry.key}-${route.route}-${route.dest}" } |
| 243 | 252 | ) { (entry, route) -> |
| 244 | 253 | RichCard(entry, route) |
@@ -285,7 +294,7 @@ private fun StatusHeader(
| 285 | 294 | state: AnkyraClient.State, |
| 286 | 295 | lastUpdated: Long?, |
| 287 | 296 | stale: Boolean, |
| 288 | − rich: Boolean, | |
| 297 | + richCards: Int, | |
| 289 | 298 | onToggleRich: () -> Unit, |
| 290 | 299 | onEditConnection: (() -> Unit)?, |
| 291 | 300 | modifier: Modifier = Modifier |
@@ -330,7 +339,12 @@ private fun StatusHeader(
| 330 | 339 | onClick = {}, |
| 331 | 340 | onLongClick = onToggleRich, |
| 332 | 341 | onLongClickLabel = |
| 333 | − if (rich) "Show every card" else "Show detailed cards" | |
| 342 | + if (richCards == Settings.MAX_RICH_CARDS) { | |
| 343 | + "Show every card" | |
| 344 | + } else { | |
| 345 | + "Show ${richCards + 1} detailed card" + | |
| 346 | + if (richCards == 0) "" else "s" | |
| 347 | + } | |
| 334 | 348 | ) |
| 335 | 349 | ) |
| 336 | 350 |
modified
clients/android/app/src/main/java/io/neiam/apolloscrib/ui/RichCard.kt
+49
−27
@@ -45,7 +45,7 @@ fun RichCard(entry: VisionEntry, route: GtfsPlusCondensed, modifier: Modifier =
| 45 | 45 | val palette = LocalAppTheme.current |
| 46 | 46 | |
| 47 | 47 | Card(modifier.fillMaxWidth()) { |
| 48 | − Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { | |
| 48 | + Column(Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) { | |
| 49 | 49 | Header(entry, route, palette.dim) |
| 50 | 50 | |
| 51 | 51 | // Four is what fits without the card becoming a timetable. Past |
@@ -79,39 +79,61 @@ fun RichCard(entry: VisionEntry, route: GtfsPlusCondensed, modifier: Modifier =
| 79 | 79 | |
| 80 | 80 | @Composable |
| 81 | 81 | private fun Header(entry: VisionEntry, route: GtfsPlusCondensed, dim: Color) { |
| 82 | − Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { | |
| 82 | + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { | |
| 83 | + // The route, and which way this stop lies. One line, because between | |
| 84 | + // them they are the card's identity. | |
| 83 | 85 | 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 | 86 | RouteBadge(route) |
| 88 | 87 | |
| 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 | − ) | |
| 88 | + Spacer(Modifier.weight(1f)) | |
| 98 | 89 | |
| 99 | 90 | 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) | |
| 91 | + Glyph(R.drawable.fa_location_arrow, 13.dp, dim) | |
| 92 | + Spacer(Modifier.width(5.dp)) | |
| 93 | + Text(bearing, style = MaterialTheme.typography.titleSmall, color = dim) | |
| 103 | 94 | } |
| 104 | 95 | } |
| 105 | 96 | |
| 97 | + // Where it goes, given the room to say so. This is what somebody | |
| 98 | + // reads first and the compact card could only ever abbreviate: two | |
| 99 | + // lines here rather than one ellipsised one, because "Alewife via | |
| 100 | + // Harvard" cut to "Alewife via..." is a different destination. | |
| 106 | 101 | 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, | |
| 102 | + route.dest ?: entry.label(), | |
| 103 | + style = MaterialTheme.typography.headlineSmall, | |
| 104 | + maxLines = 2, | |
| 113 | 105 | overflow = TextOverflow.Ellipsis |
| 114 | 106 | ) |
| 107 | + | |
| 108 | + // The stop it leaves from, on its own line. In compact mode this is | |
| 109 | + // the card's title and the destination is squeezed in beside it; here | |
| 110 | + // there is room for both to be themselves. | |
| 111 | + Row(verticalAlignment = Alignment.CenterVertically) { | |
| 112 | + Glyph(R.drawable.fa_location_dot, 13.dp, dim) | |
| 113 | + Spacer(Modifier.width(6.dp)) | |
| 114 | + Text( | |
| 115 | + entry.label(), | |
| 116 | + style = MaterialTheme.typography.titleSmall, | |
| 117 | + color = dim, | |
| 118 | + maxLines = 2, | |
| 119 | + overflow = TextOverflow.Ellipsis | |
| 120 | + ) | |
| 121 | + } | |
| 122 | + | |
| 123 | + val extra = | |
| 124 | + listOfNotNull(route.dir, route.route_long?.takeIf { it != route.dest }) | |
| 125 | + .distinct() | |
| 126 | + .joinToString(" · ") | |
| 127 | + | |
| 128 | + if (extra.isNotEmpty()) { | |
| 129 | + Text( | |
| 130 | + extra, | |
| 131 | + style = MaterialTheme.typography.bodySmall, | |
| 132 | + color = dim, | |
| 133 | + maxLines = 1, | |
| 134 | + overflow = TextOverflow.Ellipsis | |
| 135 | + ) | |
| 136 | + } | |
| 115 | 137 | } |
| 116 | 138 | } |
| 117 | 139 |
@@ -122,12 +144,12 @@ private fun RouteBadge(route: GtfsPlusCondensed) {
| 122 | 144 | |
| 123 | 145 | Text( |
| 124 | 146 | route.displayName(), |
| 125 | − style = MaterialTheme.typography.titleMedium, | |
| 147 | + style = MaterialTheme.typography.headlineSmall, | |
| 126 | 148 | color = foreground ?: MaterialTheme.colorScheme.onSecondary, |
| 127 | 149 | modifier = Modifier |
| 128 | − .clip(RoundedCornerShape(6.dp)) | |
| 150 | + .clip(RoundedCornerShape(8.dp)) | |
| 129 | 151 | .background(background ?: MaterialTheme.colorScheme.secondary) |
| 130 | − .padding(horizontal = 8.dp, vertical = 2.dp) | |
| 152 | + .padding(horizontal = 12.dp, vertical = 4.dp) | |
| 131 | 153 | ) |
| 132 | 154 | } |
| 133 | 155 |
@@ -153,7 +175,7 @@ private fun Arrival(arrival: PlusArrival, dim: Color) {
| 153 | 175 | |
| 154 | 176 | Text( |
| 155 | 177 | arrival.best()?.asClockTime() ?: "--:--", |
| 156 | − style = MaterialTheme.typography.titleMedium, | |
| 178 | + style = MaterialTheme.typography.headlineSmall, | |
| 157 | 179 | fontFamily = FontFamily.Monospace, |
| 158 | 180 | textDecoration = if (cancelled) TextDecoration.LineThrough else null, |
| 159 | 181 | color = if (cancelled) dim else MaterialTheme.colorScheme.onSurface |
added
clients/android/app/src/test/java/io/neiam/apolloscrib/data/RichCardsTest.kt
+32
−0
@@ -0,0 +1,32 @@
| 1 | +package io.neiam.apolloscrib.data | |
| 2 | + | |
| 3 | +import org.junit.Assert.assertEquals | |
| 4 | +import org.junit.Test | |
| 5 | + | |
| 6 | +/** | |
| 7 | + * How the compass gesture moves through the modes. | |
| 8 | + * | |
| 9 | + * One more card each press, and off the end back to the compact board -- so | |
| 10 | + * the gesture always has somewhere to go and never needs a second one to undo | |
| 11 | + * it. | |
| 12 | + */ | |
| 13 | +class RichCardsTest { | |
| 14 | + | |
| 15 | + private fun next(from: Int) = (from + 1) % (Settings.MAX_RICH_CARDS + 1) | |
| 16 | + | |
| 17 | + @Test | |
| 18 | + fun `each press adds a card until three, then resets`() { | |
| 19 | + assertEquals(1, next(0)) | |
| 20 | + assertEquals(2, next(1)) | |
| 21 | + assertEquals(3, next(2)) | |
| 22 | + assertEquals(0, next(3)) | |
| 23 | + } | |
| 24 | + | |
| 25 | + @Test | |
| 26 | + fun `four presses return to where they started`() { | |
| 27 | + var mode = 0 | |
| 28 | + repeat(4) { mode = next(mode) } | |
| 29 | + | |
| 30 | + assertEquals(0, mode) | |
| 31 | + } | |
| 32 | +} |
Parents: e996024