Skip to content

Commit de4e3cf

Browse files
committed
Real satellite maps, what bus is coming, and a livelier bus screen
- MapLibre (open source) maps: Esri World Imagery for the satellite view, or LINZ's NZ aerials with a free Basemaps key; OpenFreeMap streets - Bus route map is now a live satellite preview that opens full screen, with buses gliding between GPS fixes; tap one for what it is - Trains: Diagram / Satellite / Map switch shows every train at its real GPS position - Bus model from the vehicle label via assets/fleet.tsv (e.g. TR3884 -> Tranzurban CRRC eD12 MAX); the scene draws double-deckers and electrics - Stops-away track with the last stop the bus passed, animated countdowns, pull to refresh, next-bus summary, Rangitoto and Maungakiekie backdrops Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011C2fAvFFcMRkaF7qSvRA1v
1 parent 9bef681 commit de4e3cf

14 files changed

Lines changed: 1176 additions & 235 deletions

File tree

‎.github/workflows/build.yml‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ jobs:
3333
- name: Build
3434
env:
3535
AT_API_KEY: ${{ secrets.AT_API_KEY }}
36+
LINZ_API_KEY: ${{ secrets.LINZ_API_KEY }}
3637
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
3738
run: gradle :app:assembleRelease --no-daemon --stacktrace
3839

@@ -41,7 +42,7 @@ jobs:
4142
continue-on-error: true
4243
env:
4344
AT_API_KEY: ${{ secrets.AT_API_KEY }}
44-
run: python3 tools/fleet_survey.py --raw
45+
run: python3 tools/fleet_survey.py
4546

4647
- name: Name the APK
4748
run: cp app/build/outputs/apk/release/app-release.apk AKL-Live.apk

‎.github/workflows/screenshots.yml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ jobs:
3939
if: inputs.apk == 'build'
4040
env:
4141
AT_API_KEY: ${{ secrets.AT_API_KEY }}
42+
LINZ_API_KEY: ${{ secrets.LINZ_API_KEY }}
4243
run: |
4344
gradle :app:assembleRelease --no-daemon --stacktrace
4445
cp app/build/outputs/apk/release/app-release.apk AKL-Live.apk

‎app/build.gradle.kts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ plugins {
88
// built-in API key (enter one in the app's settings instead).
99
val runNumber = (System.getenv("GITHUB_RUN_NUMBER") ?: "1").toInt()
1010
val atKey = System.getenv("AT_API_KEY") ?: ""
11+
val linzKey = System.getenv("LINZ_API_KEY") ?: ""
1112
val keystore = file("release.p12")
1213

1314
android {
@@ -21,6 +22,9 @@ android {
2122
versionCode = runNumber
2223
versionName = "1.0.$runNumber"
2324
buildConfigField("String", "AT_API_KEY", "\"$atKey\"")
25+
buildConfigField("String", "LINZ_API_KEY", "\"$linzKey\"")
26+
// the map's native code: 64-bit phones (and the x86_64 emulator in CI)
27+
ndk { abiFilters += listOf("arm64-v8a", "x86_64") }
2428
}
2529

2630
signingConfigs {
@@ -68,4 +72,6 @@ dependencies {
6872
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")
6973
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7")
7074
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
75+
// open-source vector/raster maps (the satellite and street views)
76+
implementation("org.maplibre.gl:android-sdk:12.3.1")
7177
}

‎app/src/main/assets/fleet.tsv‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
# What bus is coming: AT's realtime feed labels every bus with its operator's
2+
# code and fleet number ("TR3884", "NB5014"). This maps fleet number ranges to
3+
# the model. tools/fleet_survey.py (a step in every CI build) lists the ranges
4+
# on the road that aren't covered here yet.
5+
#
6+
# Tab-separated: prefix, first, last, model, power (electric|diesel), decks (1|2)
7+
# Sources: AT Metro Wiki model pages, operator announcements, and the ranges
8+
# the survey sees running each route.
9+
10+
# Tranzurban: Scania + BCI double-deckers from 2018 (NX2), CRRCs for the
11+
# Northwestern from 2024-25 (eT12 on the 11T/11W, eD12 on the WX1)
12+
TR 3300 3399 Scania K320UB diesel 1
13+
TR 3500 3599 BCI CitiRider FBC6123 diesel 2
14+
TR 3810 3829 CRRC eT12 MAX electric 1
15+
TR 3860 3889 CRRC eD12 MAX electric 2
16+
17+
# NZ Bus (Kinetic): the Enviro200 fleet, then CRRC electrics from 2022-23
18+
NB 4001 4354 Alexander Dennis Enviro200 diesel 1
19+
NB 5752 5786 CRRC eT12 MAX electric 1
20+
NB 5800 5842 CRRC eT12 MAX electric 1

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

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ import nz.aryan.akllive.data.StopBoard
2020
import nz.aryan.akllive.data.TrainRepo
2121
import nz.aryan.akllive.data.TrainState
2222
import nz.aryan.akllive.data.TripDetail
23+
import nz.aryan.akllive.data.Fleet
24+
import nz.aryan.akllive.ui.Basemap
2325
import kotlin.math.atan2
2426
import kotlin.math.hypot
2527

@@ -42,6 +44,14 @@ class Prefs(ctx: Context) {
4244
get() = p.getBoolean("keepOn", false)
4345
set(v) = p.edit().putBoolean("keepOn", v).apply()
4446
val keyIsBuiltIn get() = p.getString("key", null).isNullOrBlank() && BuildConfig.AT_API_KEY.isNotBlank()
47+
/** LINZ Basemaps key for NZ's sharpest aerials; without one the satellite view uses Esri's imagery. */
48+
var linzKey: String
49+
get() = p.getString("linz", null)?.takeIf { it.isNotBlank() } ?: BuildConfig.LINZ_API_KEY
50+
set(v) = p.edit().putString("linz", v.trim()).apply()
51+
val linzIsBuiltIn get() = p.getString("linz", null).isNullOrBlank() && BuildConfig.LINZ_API_KEY.isNotBlank()
52+
var basemap: Basemap
53+
get() = if (p.getString("basemap", null) == "streets") Basemap.Streets else Basemap.Satellite
54+
set(v) = p.edit().putString("basemap", if (v == Basemap.Streets) "streets" else "satellite").apply()
4555
}
4656

4757
class AppViewModel(app: Application) : AndroidViewModel(app) {
@@ -54,6 +64,14 @@ class AppViewModel(app: Application) : AndroidViewModel(app) {
5464
val boards: StateFlow<List<StopBoard>> = _boards
5565
private val _busError = MutableStateFlow<String?>(null)
5666
val busError: StateFlow<String?> = _busError
67+
/** true from a pull-to-refresh until the buses have reloaded */
68+
private val _pulling = MutableStateFlow(false)
69+
val pulling: StateFlow<Boolean> = _pulling
70+
71+
private val _basemap = MutableStateFlow(prefs.basemap)
72+
val basemap: StateFlow<Basemap> = _basemap
73+
private val _linzKey = MutableStateFlow(prefs.linzKey)
74+
val linzKey: StateFlow<String> = _linzKey
5775

5876
private val _trains = MutableStateFlow(TrainState())
5977
val trains: StateFlow<TrainState> = _trains
@@ -67,6 +85,7 @@ class AppViewModel(app: Application) : AndroidViewModel(app) {
6785
@Volatile private var visible = false
6886

6987
init {
88+
Fleet.load(app)
7089
viewModelScope.launch { loop(30_000) { refreshBuses() } }
7190
viewModelScope.launch { loop(15_000) { refreshTrains() } }
7291
}
@@ -79,6 +98,16 @@ class AppViewModel(app: Application) : AndroidViewModel(app) {
7998

8099
fun refresh() { kick.tryEmit(Unit) }
81100

101+
fun pullRefresh() {
102+
_pulling.value = true
103+
refresh()
104+
}
105+
106+
fun setBasemap(b: Basemap) {
107+
prefs.basemap = b
108+
_basemap.value = b
109+
}
110+
82111
private suspend fun loop(every: Long, work: suspend () -> Unit) {
83112
while (true) {
84113
while (!visible) delay(400)
@@ -98,6 +127,7 @@ class AppViewModel(app: Application) : AndroidViewModel(app) {
98127
}
99128
_boards.value = out
100129
_busError.value = out.firstNotNullOfOrNull { it.error }
130+
_pulling.value = false
101131
}
102132

103133
private suspend fun refreshTrains() {
@@ -146,8 +176,12 @@ class AppViewModel(app: Application) : AndroidViewModel(app) {
146176
}
147177
}
148178

149-
fun saveSettings(key: String, stops: String, route: String, place: String) {
179+
fun saveSettings(key: String, stops: String, route: String, place: String, linz: String) {
150180
if (key.isNotBlank()) prefs.apiKey = key
181+
if (linz.isNotBlank()) {
182+
prefs.linzKey = linz
183+
_linzKey.value = prefs.linzKey
184+
}
151185
prefs.stops = stops.split(',', ' ').map { it.trim() }.filter { it.isNotEmpty() }
152186
prefs.route = route
153187
prefs.place = place
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
package nz.aryan.akllive.data
2+
3+
import android.content.Context
4+
5+
/** A bus's make and model (see assets/fleet.tsv). */
6+
class BusModel(val name: String, val electric: Boolean, val doubleDeck: Boolean) {
7+
/** "CRRC eT12 MAX" -> "eT12 MAX", for tight spots. */
8+
val short: String get() = MAKES.firstOrNull { name.startsWith(it) }?.let { name.removePrefix(it).trim() } ?: name
9+
10+
private companion object {
11+
val MAKES = listOf("Alexander Dennis", "ADL", "BCI", "CRRC", "Geely", "Scania", "Yutong")
12+
}
13+
}
14+
15+
/** Who runs a bus and what it is, worked out from AT's vehicle label ("TR3884"). */
16+
class BusInfo(val operator: String?, val fleetNo: String, val model: BusModel?)
17+
18+
object Fleet {
19+
/** AT's operator codes, as they appear at the front of a vehicle label. */
20+
private val OPERATORS = mapOf(
21+
"NB" to "NZ Bus", "GB" to "Go Bus", "RT" to "Ritchies", "HE" to "Howick & Eastern",
22+
"TR" to "Tranzurban", "BA" to "Bayes", "WB" to "Waiheke Bus Co",
23+
)
24+
private val LABEL = Regex("^([A-Za-z]{1,3})\\s*0*(\\d+)$")
25+
26+
private class Range(val prefix: String, val first: Int, val last: Int, val model: BusModel)
27+
28+
@Volatile private var ranges: List<Range> = emptyList()
29+
30+
fun load(ctx: Context) {
31+
if (ranges.isNotEmpty()) return
32+
ranges = try {
33+
ctx.assets.open("fleet.tsv").bufferedReader().useLines { lines ->
34+
lines.filter { it.isNotBlank() && !it.startsWith("#") }.mapNotNull { line ->
35+
val p = line.split('\t')
36+
if (p.size < 6) return@mapNotNull null
37+
Range(p[0].uppercase(), p[1].toInt(), p[2].toInt(),
38+
BusModel(p[3], p[4] == "electric", p[5].trim() == "2"))
39+
}.toList()
40+
}
41+
} catch (e: Exception) {
42+
emptyList()
43+
}
44+
}
45+
46+
/** Operator and model for a vehicle label; null if it isn't a bus fleet number. */
47+
fun info(label: String): BusInfo? {
48+
val m = LABEL.find(label.trim()) ?: return null
49+
val prefix = m.groupValues[1].uppercase()
50+
val num = m.groupValues[2].toIntOrNull() ?: return null
51+
val model = ranges.firstOrNull { it.prefix == prefix && num in it.first..it.last }?.model
52+
return BusInfo(OPERATORS[prefix], label.trim(), model)
53+
}
54+
}

0 commit comments

Comments
 (0)