Cycle the detailed board one card at a time, and give them room

e04988a · Gabriel Morell · 2026-09-08 20:14

21 files +124 -51
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