Skip to content

Commit d4e8c5e

Browse files
committed
Trains full screen, bus routes on the Live map, Fleet photos, tidier Directions
- Trains: the map fills the screen, with the header and line filters over it as on the Live map, and the network, a train or a station in a sheet you pull up - Live: tap a bus and its whole route shows, with the stops still to come (the last one named); the card lists the next stops with times, and can follow the bus or zoom to it. The floating "tap a bus" pill is gone; the header says how many are shown - Fleet: each model is a card with a real photo from Wikimedia Commons (credited on it, tap for the source), the count over it, and its operators below. The model page leads with the photo too - Directions: Now / Leave at / Arrive by as one segmented control, the chosen time as a chip to change it; Home set from your location shows its address Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011C2fAvFFcMRkaF7qSvRA1v
1 parent d30fea7 commit d4e8c5e

10 files changed

Lines changed: 427 additions & 192 deletions

File tree

‎app/src/main/java/nz/aryan/akllive/AppViewModel.kt‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -238,9 +238,11 @@ class AppViewModel(app: Application) : AndroidViewModel(app) {
238238
}
239239
}
240240

241-
fun selectBus(tripId: String?) {
242-
if (_busTrip.value?.tripId == tripId && tripId != null) return
243-
_busTrip.value = tripId?.let { BusTrip(it) }
241+
/** A tapped bus's trip; [refresh] fetches its delay and position again (keeping what's shown meanwhile). */
242+
fun selectBus(tripId: String?, refresh: Boolean = false) {
243+
val same = _busTrip.value?.tripId == tripId && tripId != null
244+
if (same && !refresh) return
245+
if (!same) _busTrip.value = tripId?.let { BusTrip(it) }
244246
if (tripId == null) return
245247
viewModelScope.launch {
246248
val t = try { liveRepo.trip(tripId) } catch (_: Exception) { return@launch }

‎app/src/main/java/nz/aryan/akllive/data/Models.kt‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ data class FleetState(
3030
)
3131

3232
/** Where a tapped bus is going and how late it's running, fetched on demand. */
33-
data class BusTrip(val tripId: String, val headsign: String? = null, val delay: Int? = null)
33+
data class BusTrip(val tripId: String, val headsign: String? = null, val delay: Int? = null, val seq: Int? = null)
3434

3535
/** One bus due at our stop. */
3636
data class BusDeparture(
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
package nz.aryan.akllive.data
2+
3+
import android.content.Context
4+
import android.graphics.Bitmap
5+
import android.graphics.BitmapFactory
6+
import android.util.LruCache
7+
import kotlinx.coroutines.Dispatchers
8+
import kotlinx.coroutines.sync.Mutex
9+
import kotlinx.coroutines.sync.withLock
10+
import kotlinx.coroutines.withContext
11+
import org.json.JSONObject
12+
import java.io.File
13+
import java.net.HttpURLConnection
14+
import java.net.URL
15+
import java.net.URLEncoder
16+
17+
/** A real photo of a bus model: the image, its page on Wikimedia Commons, and who to credit. */
18+
class Photo(val url: String, val page: String, val credit: String)
19+
20+
/**
21+
* Photos of each bus model from Wikimedia Commons (freely licensed, credited
22+
* under the photo), found by searching for the model in Auckland. Looked up
23+
* once a fortnight per model and kept on the phone.
24+
*/
25+
object Photos {
26+
private const val KEEP_MS = 14L * 24 * 3600 * 1000
27+
private val mem = HashMap<String, Photo?>()
28+
private val lock = Mutex()
29+
30+
suspend fun forModel(ctx: Context, m: BusModel): Photo? = lock.withLock {
31+
if (mem.containsKey(m.id)) return@withLock mem[m.id]
32+
val prefs = ctx.getSharedPreferences("photos", Context.MODE_PRIVATE)
33+
prefs.getString(m.id, null)?.let { raw ->
34+
val o = runCatching { JSONObject(raw) }.getOrNull()
35+
if (o != null && System.currentTimeMillis() - o.optLong("t") < KEEP_MS) {
36+
val p = o.optString("url").takeIf { it.isNotEmpty() }?.let { Photo(it, o.optString("page"), o.optString("credit")) }
37+
mem[m.id] = p
38+
return@withLock p
39+
}
40+
}
41+
val found = try {
42+
search("${m.name} Auckland") ?: search("${m.short} Auckland bus") ?: search("${m.name} bus")
43+
} catch (e: Exception) {
44+
return@withLock null // offline: try again next time
45+
}
46+
mem[m.id] = found
47+
prefs.edit().putString(m.id, JSONObject().put("t", System.currentTimeMillis()).put("url", found?.url ?: "")
48+
.put("page", found?.page ?: "").put("credit", found?.credit ?: "").toString()).apply()
49+
found
50+
}
51+
52+
private suspend fun search(q: String): Photo? {
53+
val url = "https://commons.wikimedia.org/w/api.php?action=query&format=json&generator=search&gsrnamespace=6&gsrlimit=8" +
54+
"&gsrsearch=" + URLEncoder.encode("$q filetype:bitmap", "UTF-8") +
55+
"&prop=imageinfo&iiprop=url%7Cextmetadata%7Cmime&iiurlwidth=800"
56+
val j = httpJson(url) as? JSONObject ?: throw java.io.IOException("Commons didn't answer")
57+
val pages = j.optJSONObject("query")?.optJSONObject("pages") ?: return null
58+
val best = pages.keys().asSequence().mapNotNull { pages.optJSONObject(it) }
59+
.filter { p -> p.optJSONArray("imageinfo")?.optJSONObject(0)?.optString("mime")?.let { it == "image/jpeg" || it == "image/png" } == true }
60+
.minByOrNull { it.optInt("index", 99) } ?: return null
61+
val info = best.getJSONArray("imageinfo").getJSONObject(0)
62+
val meta = info.optJSONObject("extmetadata")
63+
fun field(k: String) = meta?.optJSONObject(k)?.optString("value")?.replace(Regex("<[^>]+>"), "")?.trim()?.takeIf { it.isNotEmpty() }
64+
val credit = listOfNotNull(field("Artist")?.take(60), field("LicenseShortName")).joinToString(", ")
65+
return Photo(info.optString("thumburl").ifEmpty { info.optString("url") }, info.optString("descriptionurl"),
66+
"Photo: " + credit.ifEmpty { "Wikimedia Commons" } + if (credit.isNotEmpty()) ", Wikimedia Commons" else "")
67+
}
68+
}
69+
70+
/** Downloaded images, kept in memory and on disk. */
71+
object Images {
72+
private val mem = LruCache<String, Bitmap>(24)
73+
74+
suspend fun load(ctx: Context, url: String): Bitmap? = withContext(Dispatchers.IO) {
75+
mem.get(url)?.let { return@withContext it }
76+
val dir = File(ctx.cacheDir, "img").also { it.mkdirs() }
77+
val file = File(dir, Integer.toHexString(url.hashCode()) + ".img")
78+
try {
79+
if (!file.exists()) {
80+
val c = URL(url).openConnection() as HttpURLConnection
81+
c.connectTimeout = 15_000
82+
c.readTimeout = 30_000
83+
c.setRequestProperty("User-Agent", "AKL-Live-Android/1.0 (github.com/intelarc/akl-live-android)")
84+
try {
85+
if (c.responseCode != 200) return@withContext null
86+
val part = File(dir, file.name + ".part")
87+
c.inputStream.use { i -> part.outputStream().use { o -> i.copyTo(o) } }
88+
part.renameTo(file)
89+
} finally {
90+
c.disconnect()
91+
}
92+
}
93+
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
94+
BitmapFactory.decodeFile(file.path, bounds)
95+
var sample = 1
96+
while (bounds.outWidth / (sample * 2) >= 900) sample *= 2
97+
BitmapFactory.decodeFile(file.path, BitmapFactory.Options().apply { inSampleSize = sample })?.also { mem.put(url, it) }
98+
} catch (e: Exception) {
99+
null
100+
}
101+
}
102+
}

‎app/src/main/java/nz/aryan/akllive/data/Repos.kt‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -174,8 +174,8 @@ class LiveRepo(private val api: AtApi) {
174174
val head = headsigns[tripId] ?: api.get("/gtfs/v3/trips/$tripId")?.obj("data")?.obj("attributes")
175175
?.optString("trip_headsign")?.let(::cleanHeadsign)?.takeIf { it.isNotEmpty() }
176176
?.also { headsigns[tripId] = it }
177-
val delay = try { tripUpdates(api, listOf(tripId))[tripId]?.delay } catch (_: Exception) { null }
178-
return BusTrip(tripId, head, delay)
177+
val u = try { tripUpdates(api, listOf(tripId))[tripId] } catch (_: Exception) { null }
178+
return BusTrip(tripId, head, u?.delay, u?.seq)
179179
}
180180
}
181181

‎app/src/main/java/nz/aryan/akllive/ui/AppShell.kt‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,7 @@ private fun Screens(vm: AppViewModel, nav: NavHostController) {
160160
composable("live") { LiveScreen(vm, Modifier) }
161161
composable("trains") {
162162
DisposableEffect(Unit) { vm.watchTrains(true); onDispose { vm.watchTrains(false) } }
163-
TrainScreen(vm, Modifier.statusBarsPadding())
163+
TrainScreen(vm, Modifier)
164164
}
165165
composable("more") { MoreScreen(vm) }
166166
composable("search", enterTransition = { fadeIn(tween(180)) }) { SearchScreen(vm) }

‎app/src/main/java/nz/aryan/akllive/ui/FleetScreen.kt‎

Lines changed: 64 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,19 @@ import androidx.compose.ui.text.style.TextOverflow
5858
import androidx.compose.ui.unit.dp
5959
import androidx.compose.ui.unit.sp
6060
import androidx.lifecycle.compose.collectAsStateWithLifecycle
61+
import android.content.Intent
62+
import android.net.Uri
63+
import androidx.compose.animation.fadeIn
64+
import androidx.compose.animation.fadeOut
65+
import androidx.compose.foundation.Image
66+
import androidx.compose.runtime.produceState
67+
import androidx.compose.ui.graphics.ImageBitmap
68+
import androidx.compose.ui.graphics.asImageBitmap
69+
import androidx.compose.ui.layout.ContentScale
70+
import androidx.compose.ui.platform.LocalContext
71+
import nz.aryan.akllive.data.Images
72+
import nz.aryan.akllive.data.Photo
73+
import nz.aryan.akllive.data.Photos
6174
import nz.aryan.akllive.AppViewModel
6275
import nz.aryan.akllive.data.BusModel
6376
import nz.aryan.akllive.data.Fleet
@@ -158,35 +171,55 @@ private fun BusPortrait(model: BusModel?, modifier: Modifier, frameT: State<Floa
158171
}
159172
}
160173

174+
/**
175+
* A real photo of the model (from Wikimedia Commons, credited on it) over the
176+
* drawn bus, which shows until the photo's in, or if there isn't one.
177+
*/
178+
@Composable
179+
private fun ModelPhoto(m: BusModel?, modifier: Modifier, frameT: State<Float>? = null) {
180+
val ctx = LocalContext.current
181+
val photo by produceState<Photo?>(null, m?.id) { value = m?.let { Photos.forModel(ctx, it) } }
182+
val bmp by produceState<ImageBitmap?>(null, photo?.url) { value = photo?.url?.let { Images.load(ctx, it)?.asImageBitmap() } }
183+
Box(modifier) {
184+
BusPortrait(m, Modifier.fillMaxSize(), frameT)
185+
androidx.compose.animation.AnimatedVisibility(bmp != null, Modifier.fillMaxSize(), enter = fadeIn(), exit = fadeOut()) {
186+
bmp?.let { Image(it, m?.name, Modifier.fillMaxSize(), contentScale = ContentScale.Crop) }
187+
}
188+
val p = photo
189+
if (bmp != null && p != null) {
190+
Text(p.credit, color = Color.White, fontSize = 9.sp, maxLines = 1, overflow = TextOverflow.Ellipsis,
191+
modifier = Modifier.align(Alignment.BottomStart).fillMaxWidth()
192+
.background(Brush.verticalGradient(listOf(Color.Transparent, Color.Black.copy(alpha = 0.55f))))
193+
.clickable { try { ctx.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(p.page))) } catch (_: Exception) { } }
194+
.padding(start = 10.dp, end = 10.dp, top = 10.dp, bottom = 4.dp))
195+
}
196+
}
197+
}
198+
161199
@OptIn(ExperimentalLayoutApi::class)
162200
@Composable
163201
private fun ModelCard(m: BusModel, live: List<LiveBus>, click: () -> Unit) {
164-
Card(Modifier.fillMaxWidth().clickable(onClick = click), shape = RoundedCornerShape(22.dp),
165-
elevation = CardDefaults.cardElevation(2.dp),
166-
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)) {
167-
Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
168-
BusPortrait(m, Modifier.size(width = 118.dp, height = 74.dp).clip(RoundedCornerShape(14.dp)))
169-
Spacer(Modifier.width(12.dp))
170-
Column(Modifier.weight(1f)) {
171-
Text(m.name, fontWeight = FontWeight.Bold, style = MaterialTheme.typography.titleMedium,
172-
maxLines = 2, overflow = TextOverflow.Ellipsis)
173-
Text(m.kind, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
174-
Spacer(Modifier.height(6.dp))
175-
FlowRow(horizontalArrangement = Arrangement.spacedBy(4.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
176-
for ((op, _) in Fleet.numbers(m)) OperatorTag(op)
202+
Card(onClick = click, modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(24.dp),
203+
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLow)) {
204+
Box(Modifier.fillMaxWidth().height(150.dp)) {
205+
ModelPhoto(m, Modifier.fillMaxSize())
206+
Row(Modifier.align(Alignment.TopEnd).padding(10.dp).background(Color.Black.copy(alpha = 0.55f), RoundedCornerShape(50))
207+
.padding(horizontal = 10.dp, vertical = 4.dp), verticalAlignment = Alignment.CenterVertically) {
208+
if (live.isNotEmpty()) {
209+
Box(Modifier.size(7.dp).background(Pal.Live, CircleShape))
210+
Spacer(Modifier.width(5.dp))
177211
}
212+
Text(if (live.isEmpty()) "None out" else "${live.size} out now", color = Color.White, fontSize = 12.sp,
213+
fontWeight = FontWeight.Bold)
178214
}
179-
Spacer(Modifier.width(8.dp))
180-
Column(horizontalAlignment = Alignment.End) {
181-
Text("${live.size}", fontWeight = FontWeight.Black, fontSize = 26.sp,
182-
color = if (live.isEmpty()) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurface)
183-
Row(verticalAlignment = Alignment.CenterVertically) {
184-
if (live.isNotEmpty()) {
185-
Box(Modifier.size(7.dp).background(Pal.Live, CircleShape))
186-
Spacer(Modifier.width(4.dp))
187-
}
188-
Text("out now", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
189-
}
215+
}
216+
Column(Modifier.padding(horizontal = 16.dp, vertical = 12.dp)) {
217+
Text(m.name, fontWeight = FontWeight.Bold, style = MaterialTheme.typography.titleMedium, maxLines = 1,
218+
overflow = TextOverflow.Ellipsis)
219+
Text(m.kind, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
220+
Spacer(Modifier.height(8.dp))
221+
FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
222+
for ((op, _) in Fleet.numbers(m)) OperatorTag(op)
190223
}
191224
}
192225
}
@@ -196,11 +229,12 @@ private fun ModelCard(m: BusModel, live: List<LiveBus>, click: () -> Unit) {
196229
private fun OperatorTag(name: String) {
197230
val code = Fleet.OPERATORS.entries.firstOrNull { it.value == name }?.key ?: ""
198231
val c = operatorColor(code)
199-
Row(Modifier.background(c.copy(alpha = 0.14f), RoundedCornerShape(50)).padding(horizontal = 8.dp, vertical = 2.dp),
232+
Row(Modifier.background(MaterialTheme.colorScheme.surfaceContainerHighest, RoundedCornerShape(50))
233+
.padding(horizontal = 10.dp, vertical = 4.dp),
200234
verticalAlignment = Alignment.CenterVertically) {
201-
Box(Modifier.size(7.dp).background(c, CircleShape))
202-
Spacer(Modifier.width(4.dp))
203-
Text(name, fontSize = 11.sp, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurface)
235+
Box(Modifier.size(8.dp).background(c, CircleShape))
236+
Spacer(Modifier.width(6.dp))
237+
Text(name, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurface, maxLines = 1)
204238
}
205239
}
206240

@@ -249,8 +283,8 @@ private fun ModelScreen(vm: AppViewModel, id: String, all: List<LiveBus>, update
249283
val scroll = rememberScrollState()
250284

251285
Column(modifier.fillMaxSize().verticalScroll(scroll)) {
252-
Box(Modifier.fillMaxWidth().height(200.dp)) {
253-
BusPortrait(model, Modifier.fillMaxSize(), frameT)
286+
Box(Modifier.fillMaxWidth().height(230.dp)) {
287+
ModelPhoto(model, Modifier.fillMaxSize(), frameT)
254288
IconButton(onClick = close, modifier = Modifier.padding(8.dp)
255289
.background(Color.Black.copy(alpha = 0.35f), CircleShape)) {
256290
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back", tint = Color.White)

0 commit comments

Comments
 (0)