Skip to content

Commit 676d630

Browse files
committed
Directions ranks the most efficient trips first; places first in search
- The planner drops "leave at 3 am, wait hours for the first train" trips when a far shorter one gets there about as soon, merges near-duplicates, and orders options by door-to-door time, changes and arrival - Directions says when nothing runs for a while, instead of leaving it to the times; trip cards give the first ride's time and status its own line - Search and the place picker list places first for words, and routes and stops first for numbers; places are ranked near where you are - The bottom bar's "Directions" is "Plan", and labels stay on one line - A stop with nothing due shows where its buses go, or its name, not "to …" Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011C2fAvFFcMRkaF7qSvRA1v
1 parent 6cd647e commit 676d630

11 files changed

Lines changed: 171 additions & 76 deletions

File tree

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

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -355,7 +355,8 @@ class AppViewModel(app: Application) : AndroidViewModel(app) {
355355
net.searchStops(t, 8).forEach { out += SearchHit.StopHit(it) }
356356
}
357357
}
358-
try { Places.search(t).take(6).forEach { out += SearchHit.PlaceHit(it) } } catch (_: Exception) { }
358+
val near = near()
359+
try { Places.search(t, near?.first, near?.second).take(6).forEach { out += SearchHit.PlaceHit(it) } } catch (_: Exception) { }
359360
return out
360361
}
361362

@@ -410,6 +411,26 @@ class AppViewModel(app: Application) : AndroidViewModel(app) {
410411

411412
// ======================= location =======================
412413

414+
/** the last fix [here] got, to rank nearby places first */
415+
@Volatile private var lastHere: Location? = null
416+
417+
/** Roughly where you are, without asking: the last fix, the phone's last known one, or home. */
418+
@SuppressLint("MissingPermission")
419+
fun near(): Pair<Double, Double>? {
420+
lastHere?.takeIf { System.currentTimeMillis() - it.time < 30 * 60_000 }?.let { return it.latitude to it.longitude }
421+
val ctx = getApplication<Application>()
422+
if (ContextCompat.checkSelfPermission(ctx, android.Manifest.permission.ACCESS_COARSE_LOCATION) ==
423+
android.content.pm.PackageManager.PERMISSION_GRANTED) {
424+
val lm = ctx.getSystemService(LocationManager::class.java)
425+
val fix = try {
426+
listOf(LocationManager.FUSED_PROVIDER, LocationManager.NETWORK_PROVIDER, LocationManager.GPS_PROVIDER)
427+
.mapNotNull { p -> runCatching { lm?.getLastKnownLocation(p) }.getOrNull() }.maxByOrNull { it.time }
428+
} catch (_: Exception) { null }
429+
if (fix != null) return fix.latitude to fix.longitude
430+
}
431+
return _settings.value.home?.let { it.lat to it.lon }
432+
}
433+
413434
/** Where the phone is, if location is allowed and on (null otherwise). */
414435
@SuppressLint("MissingPermission")
415436
suspend fun here(): Location? {
@@ -424,11 +445,11 @@ class AppViewModel(app: Application) : AndroidViewModel(app) {
424445
lm.isProviderEnabled(LocationManager.NETWORK_PROVIDER) -> LocationManager.NETWORK_PROVIDER
425446
else -> return null
426447
}
427-
lm.getLastKnownLocation(provider)?.takeIf { System.currentTimeMillis() - it.time < 120_000 }?.let { return it }
448+
lm.getLastKnownLocation(provider)?.takeIf { System.currentTimeMillis() - it.time < 120_000 }?.let { lastHere = it; return it }
428449
val d = CompletableDeferred<Location?>()
429450
LocationManagerCompat.getCurrentLocation(lm, provider, android.os.CancellationSignal(),
430451
ContextCompat.getMainExecutor(ctx)) { d.complete(it) }
431-
return withTimeoutOrNull(12_000) { d.await() } ?: lm.getLastKnownLocation(provider)
452+
return (withTimeoutOrNull(12_000) { d.await() } ?: lm.getLastKnownLocation(provider))?.also { lastHere = it }
432453
}
433454

434455
// ======================= bus spotting =======================

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

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -170,20 +170,23 @@ object Places {
170170
return Place(name, where.joinToString(", ").ifEmpty { "Auckland" }, lat, lon, PlaceKind.Place)
171171
}
172172

173-
/** Addresses and places matching the text, Auckland first. */
173+
/** Addresses and places matching the text, nearest [lat], [lon] first (the city if unknown). */
174174
@Suppress("UNCHECKED_CAST")
175-
suspend fun search(q: String): List<Place> {
175+
suspend fun search(q: String, lat: Double? = null, lon: Double? = null): List<Place> {
176176
val t = q.trim()
177177
if (t.length < 3) return emptyList()
178-
(cache["s:$t"] as? List<Place>)?.let { return it }
179-
val j = httpJson("https://photon.komoot.io/api/?q=${URLEncoder.encode(t, "UTF-8")}&lat=-36.87&lon=174.76" +
180-
"&limit=8&bbox=$BBOX&lang=en") as? JSONObject ?: return emptyList()
178+
val la = lat ?: -36.87
179+
val lo = lon ?: 174.76
180+
val key = "s:$t@%.2f,%.2f".format(la, lo)
181+
(cache[key] as? List<Place>)?.let { return it }
182+
val j = httpJson("https://photon.komoot.io/api/?q=${URLEncoder.encode(t, "UTF-8")}&lat=$la&lon=$lo" +
183+
"&location_bias_scale=0.5&limit=8&bbox=$BBOX&lang=en") as? JSONObject ?: return emptyList()
181184
val out = ArrayList<Place>()
182185
j.arr("features").objects { f ->
183186
val c = f.obj("geometry")?.optJSONArray("coordinates") ?: return@objects
184187
out += label(f, c.optDouble(1), c.optDouble(0))
185188
}
186-
cache["s:$t"] = out
189+
cache[key] = out
187190
return out
188191
}
189192

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,10 @@ data class StopBoard(
6161
val departures: List<BusDeparture> = emptyList(),
6262
val updated: Long = 0,
6363
val error: String? = null,
64-
)
64+
) {
65+
/** "to Britomart", or the stop's name when nothing says where its buses go */
66+
val towards: String get() = if (headsign.isNotEmpty()) "to $headsign" else name.ifEmpty { "Stop $code" }
67+
}
6568

6669
/** A train placed on the schematic map. */
6770
data class Train(

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,8 @@ internal suspend fun tripUpdates(api: AtApi, tripIds: Collection<String>): Map<S
6060

6161
class BusRepo(private val api: AtApi) {
6262
private val stops = HashMap<String, JSONObject>() // code -> attributes
63+
/** where buses from each stop were last going, for when none are due (late at night) */
64+
private val lastHead = HashMap<String, String>()
6365
private val schedule = HashMap<String, Pair<Long, List<BusDeparture>>>()
6466

6567
suspend fun board(code: String, route: String): StopBoard {
@@ -103,7 +105,8 @@ class BusRepo(private val api: AtApi) {
103105
lat = attr.optDouble("stop_lat"),
104106
lon = attr.optDouble("stop_lon"),
105107
route = shown.firstOrNull()?.route ?: route,
106-
headsign = shown.firstOrNull()?.headsign ?: trips.firstOrNull()?.headsign ?: "",
108+
headsign = (shown.firstOrNull()?.headsign ?: trips.firstOrNull()?.headsign)?.also { lastHead[code] = it }
109+
?: lastHead[code] ?: RouteData.ROUTES[code]?.first?.removePrefix("to ") ?: "",
107110
departures = shown,
108111
updated = now,
109112
)

‎app/src/main/java/nz/aryan/akllive/gtfs/Planner.kt‎

Lines changed: 37 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,39 @@ private fun Network.build(rk: Int, e: Int, arrive: Int, tau: Array<IntArray>, pT
189189
return RawIt(rk, arrive, legs)
190190
}
191191

192+
/**
193+
* The options worth showing, best first. RAPTOR finds the earliest arrivals,
194+
* which at night can mean "leave at 3 am and wait three hours for the first
195+
* train": those go when a much shorter trip gets there about as soon.
196+
* Near-duplicates (same times give or take two minutes, more changes) go too.
197+
* Best is door-to-door time, plus four minutes a change, plus half of
198+
* how much later than the earliest it arrives (or earlier than the latest it
199+
* leaves, arriving by a time).
200+
*/
201+
private fun rank(found: List<RawIt>, arriveBy: Boolean): List<RawIt> {
202+
if (found.isEmpty()) return found
203+
val slack = 120
204+
fun dur(x: RawIt) = x.arrive - x.leaveAt()
205+
val minArrive = found.minOf { it.arrive }
206+
val maxLeave = found.maxOf { it.leaveAt() }
207+
fun cost(x: RawIt): Double = dur(x) + 240.0 * maxOf(0, x.rides - 1) +
208+
0.5 * (if (arriveBy) maxLeave - x.leaveAt() else x.arrive - minArrive)
209+
val order = found.withIndex().associate { (i, x) -> x to i }
210+
fun better(b: RawIt, a: RawIt) = cost(b) < cost(a) || (cost(b) == cost(a) && order.getValue(b) < order.getValue(a))
211+
val kept = found.filter { a ->
212+
found.none { b ->
213+
if (b === a) return@none false
214+
// as good in every way, give or take two minutes, and better overall
215+
val same = b.arrive <= a.arrive + slack && b.leaveAt() >= a.leaveAt() - slack && b.rides <= a.rides && better(b, a)
216+
// far shorter, and gets there (or leaves) within 45 minutes of it
217+
val wasteful = dur(a) - dur(b) >= 30 * 60 && dur(b) <= dur(a) * 0.6 &&
218+
(if (arriveBy) b.leaveAt() >= a.leaveAt() - 45 * 60 else b.arrive <= a.arrive + 45 * 60)
219+
same || wasteful
220+
}
221+
}
222+
return kept.sortedBy { cost(it) }
223+
}
224+
192225
/**
193226
* Journeys from [from] to [to]. [time] is epoch seconds (leave at, or arrive
194227
* by); [dayStart] is the epoch second of this network's service-day midnight.
@@ -226,8 +259,9 @@ fun Network.plan(from: GeoPoint, to: GeoPoint, time: Long, dayStart: Long, arriv
226259
found.sortWith(compareByDescending<RawIt> { it.leaveAt() }.thenBy { it.arrive })
227260
} else {
228261
var at = t
229-
for (i in 0 until 8) {
230-
if (found.size >= count) break
262+
// a few more than asked for: some get ranked out as wasteful
263+
for (i in 0 until 10) {
264+
if (found.size >= count + 3) break
231265
val res = raptor(access, egress, at, o, secPerM)
232266
if (res.isEmpty()) break
233267
addAll(res)
@@ -236,13 +270,7 @@ fun Network.plan(from: GeoPoint, to: GeoPoint, time: Long, dayStart: Long, arriv
236270
}
237271
found.sortWith(compareBy<RawIt> { it.arrive }.thenByDescending { it.leaveAt() })
238272
}
239-
// drop options that are worse in every way than another one
240-
val kept = found.filter { a ->
241-
found.none { b ->
242-
b !== a && b.arrive <= a.arrive && b.leaveAt() >= a.leaveAt() && b.rides <= a.rides &&
243-
(b.arrive < a.arrive || b.leaveAt() > a.leaveAt() || b.rides < a.rides)
244-
}
245-
}
273+
val kept = rank(found, arriveBy)
246274
return PlanResult(kept.take(count).map { describe(it, from, to, dayStart) }, direct.roundToInt(), walkOnly)
247275
}
248276

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

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import androidx.compose.runtime.getValue
3939
import androidx.compose.runtime.remember
4040
import androidx.compose.ui.Modifier
4141
import androidx.compose.ui.graphics.vector.ImageVector
42+
import androidx.compose.ui.text.style.TextOverflow
4243
import androidx.compose.ui.platform.LocalView
4344
import androidx.lifecycle.compose.collectAsStateWithLifecycle
4445
import androidx.navigation.NavGraph.Companion.findStartDestination
@@ -57,7 +58,7 @@ private class Tab(val route: String, val label: String, val on: ImageVector, val
5758

5859
private val TABS = listOf(
5960
Tab("home", "Home", Icons.Rounded.Home, Icons.Outlined.Home),
60-
Tab("plan", "Directions", Icons.Rounded.Directions, Icons.Outlined.Directions),
61+
Tab("plan", "Plan", Icons.Rounded.Directions, Icons.Outlined.Directions),
6162
Tab("live", "Live", Icons.Rounded.Map, Icons.Outlined.Map),
6263
Tab("trains", "Trains", Icons.Rounded.Train, Icons.Outlined.Train),
6364
Tab("more", "More", Icons.Rounded.Apps, Icons.Outlined.Apps),
@@ -115,7 +116,7 @@ fun AklApp(vm: AppViewModel, deepLink: StateFlow<String?>, consumeLink: () -> Un
115116
BadgedBox(badge = { Badge { Text("$mine") } }) { Icon(if (on) t.on else t.off, null) }
116117
} else Icon(if (on) t.on else t.off, null)
117118
},
118-
label = { Text(t.label) },
119+
label = { Text(t.label, maxLines = 1, softWrap = false, overflow = TextOverflow.Ellipsis) },
119120
)
120121
}
121122
}

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -183,7 +183,7 @@ fun BusCard(b: StopBoard, now: Long, frameT: State<Float>, index: Int, weather:
183183
Column(Modifier.weight(1f)) {
184184
// a soft shadow keeps the text readable over clouds and sun
185185
val lift = TextStyle(shadow = Shadow(Color.Black.copy(alpha = 0.7f), Offset(0f, 1.5f), 8f))
186-
Text("to ${b.headsign.ifEmpty { "…" }}", color = Color.White, fontWeight = FontWeight.Bold,
186+
Text(b.towards, color = Color.White, fontWeight = FontWeight.Bold,
187187
fontSize = 19.sp, style = lift)
188188
Text(b.name.ifEmpty { "Stop ${b.code}" } + " · stop ${b.code}", color = Color.White,
189189
fontSize = 12.sp, fontWeight = FontWeight.SemiBold, style = lift)

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -335,7 +335,7 @@ private fun NextUp(boards: List<StopBoard>, s: Settings, now: Long) {
335335
Box(Modifier.size(10.dp).background(DirColors[i % 2], CircleShape))
336336
Spacer(Modifier.width(10.dp))
337337
Column {
338-
Text("to ${b.headsign.ifEmpty { "…" }}", style = MaterialTheme.typography.labelMedium,
338+
Text(b.towards, style = MaterialTheme.typography.labelMedium,
339339
color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1)
340340
Text(big, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Black, maxLines = 1)
341341
Text(small, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1)

0 commit comments

Comments
 (0)