From a64500bc50d780f1e713a63f30c155c07e869230 Mon Sep 17 00:00:00 2001 From: Christian Date: Thu, 30 Jul 2026 06:26:52 -0400 Subject: [PATCH 01/14] Build Transit: MBTA/RIPTA schedule + realtime tool for Light Phone III Adds a full transit tool on top of the Light SDK scaffold: - GTFS static ingestion (SQLite) with agency picker for MBTA/RIPTA - Explore Schedules: line type -> route -> direction -> stop -> departures -> trip detail, with tap-to-connect to other routes at any stop - Leave Now: geocoded/IP-approximated location search -> nearest stops -> live upcoming arrivals (GTFS-RT TripUpdates) with On Time/Late/Early status - ETA radar screen: live/scheduled bus positions and nearby stops plotted by true bearing/distance around the selected stop, with proximity clustering to avoid stacked icons - :netconfig module granting a narrowly-scoped cleartext exception for RIPTA's HTTP-only realtime feeds (see its build.gradle.kts for details and removal steps) - Renamed the scaffold's com.thelightphone.sample package to com.thelightphone.transit and updated lighttool.toml's id/label - Added tool/README.md with build/run/sideload instructions Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_014sF9rJBmDSbG6LQKf2ua2h --- LICENSE-TRANSIT | 21 + netconfig/build.gradle.kts | 40 ++ netconfig/src/main/AndroidManifest.xml | 9 + .../main/res/xml/network_security_config.xml | 11 + settings.gradle.kts | 2 + tool/README.md | 45 ++ tool/build.gradle.kts | 10 + tool/lighttool.toml | 4 +- .../com/thelightphone/sample/HomeScreen.kt | 139 ----- .../transit/DepartureListScreen.kt | 163 +++++ .../{sample => transit}/DetailScreen.kt | 2 +- .../transit/DirectionSelectionScreen.kt | 153 +++++ .../thelightphone/transit/EtaRadarScreen.kt | 576 ++++++++++++++++++ .../transit/FirstStopSelectionScreen.kt | 190 ++++++ .../com/thelightphone/transit/HomeScreen.kt | 149 +++++ .../transit/LineTypeSelectionScreen.kt | 135 ++++ .../transit/NearbyStopsScreen.kt | 295 +++++++++ .../transit/RouteSelectionScreen.kt | 148 +++++ .../transit/ScheduleLocationScreen.kt | 236 +++++++ .../transit/StopConnectionsScreen.kt | 184 ++++++ .../{sample => transit}/ToolEntryPoint.kt | 2 +- .../thelightphone/transit/TripDetailScreen.kt | 174 ++++++ .../transit/UpcomingArrivalsScreen.kt | 290 +++++++++ .../thelightphone/transit/gtfs/GeoBearing.kt | 13 + .../thelightphone/transit/gtfs/GtfsAgency.kt | 37 ++ .../com/thelightphone/transit/gtfs/GtfsCsv.kt | 82 +++ .../transit/gtfs/GtfsIngestor.kt | 272 +++++++++ .../transit/gtfs/GtfsRealtime.kt | 211 +++++++ .../transit/gtfs/GtfsRepository.kt | 491 +++++++++++++++ .../thelightphone/transit/gtfs/GtfsSchema.kt | 112 ++++ .../thelightphone/transit/gtfs/GtfsTime.kt | 23 + .../transit/gtfs/IpGeolocation.kt | 60 ++ .../transit/gtfs/NominatimGeocoder.kt | 84 +++ 33 files changed, 4220 insertions(+), 143 deletions(-) create mode 100644 LICENSE-TRANSIT create mode 100644 netconfig/build.gradle.kts create mode 100644 netconfig/src/main/AndroidManifest.xml create mode 100644 netconfig/src/main/res/xml/network_security_config.xml create mode 100644 tool/README.md delete mode 100644 tool/src/main/kotlin/com/thelightphone/sample/HomeScreen.kt create mode 100644 tool/src/main/kotlin/com/thelightphone/transit/DepartureListScreen.kt rename tool/src/main/kotlin/com/thelightphone/{sample => transit}/DetailScreen.kt (99%) create mode 100644 tool/src/main/kotlin/com/thelightphone/transit/DirectionSelectionScreen.kt create mode 100644 tool/src/main/kotlin/com/thelightphone/transit/EtaRadarScreen.kt create mode 100644 tool/src/main/kotlin/com/thelightphone/transit/FirstStopSelectionScreen.kt create mode 100644 tool/src/main/kotlin/com/thelightphone/transit/HomeScreen.kt create mode 100644 tool/src/main/kotlin/com/thelightphone/transit/LineTypeSelectionScreen.kt create mode 100644 tool/src/main/kotlin/com/thelightphone/transit/NearbyStopsScreen.kt create mode 100644 tool/src/main/kotlin/com/thelightphone/transit/RouteSelectionScreen.kt create mode 100644 tool/src/main/kotlin/com/thelightphone/transit/ScheduleLocationScreen.kt create mode 100644 tool/src/main/kotlin/com/thelightphone/transit/StopConnectionsScreen.kt rename tool/src/main/kotlin/com/thelightphone/{sample => transit}/ToolEntryPoint.kt (95%) create mode 100644 tool/src/main/kotlin/com/thelightphone/transit/TripDetailScreen.kt create mode 100644 tool/src/main/kotlin/com/thelightphone/transit/UpcomingArrivalsScreen.kt create mode 100644 tool/src/main/kotlin/com/thelightphone/transit/gtfs/GeoBearing.kt create mode 100644 tool/src/main/kotlin/com/thelightphone/transit/gtfs/GtfsAgency.kt create mode 100644 tool/src/main/kotlin/com/thelightphone/transit/gtfs/GtfsCsv.kt create mode 100644 tool/src/main/kotlin/com/thelightphone/transit/gtfs/GtfsIngestor.kt create mode 100644 tool/src/main/kotlin/com/thelightphone/transit/gtfs/GtfsRealtime.kt create mode 100644 tool/src/main/kotlin/com/thelightphone/transit/gtfs/GtfsRepository.kt create mode 100644 tool/src/main/kotlin/com/thelightphone/transit/gtfs/GtfsSchema.kt create mode 100644 tool/src/main/kotlin/com/thelightphone/transit/gtfs/GtfsTime.kt create mode 100644 tool/src/main/kotlin/com/thelightphone/transit/gtfs/IpGeolocation.kt create mode 100644 tool/src/main/kotlin/com/thelightphone/transit/gtfs/NominatimGeocoder.kt diff --git a/LICENSE-TRANSIT b/LICENSE-TRANSIT new file mode 100644 index 00000000..0c0a5b7c --- /dev/null +++ b/LICENSE-TRANSIT @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 CJFData + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/netconfig/build.gradle.kts b/netconfig/build.gradle.kts new file mode 100644 index 00000000..bc134fee --- /dev/null +++ b/netconfig/build.gradle.kts @@ -0,0 +1,40 @@ +// --- BEGIN removable cleartext exception for RIPTA realtime feeds --- +// +// This module exists solely to grant realtime.ripta.com a Network Security Config cleartext +// exception (see src/main/res/xml/network_security_config.xml). RIPTA's realtime TripUpdates/ +// VehiclePositions feeds are served plain-HTTP-only with no HTTPS equivalent, and Android blocks +// cleartext traffic by default — this module's manifest merges the exception into :tool's final +// packaged manifest. +// +// Deliberately does NOT apply the com.thelightphone.light-sdk plugin — that plugin's manifest +// generation has no field for network security config, and hand-editing an +// AndroidManifest.xml in a plugin-applying module is rejected outright. A plain sibling library +// module sidesteps that: the plugin's own dependency validator explicitly exempts same-build +// project dependencies (see LightSdkPlugin.isProjectDependency), and since this module never +// applies the plugin, none of its restrictions apply to it either. Verified against a real forced +// rebuild that the merged attribute survives into :tool's final packaged manifest — confirmed via +// tool/build/intermediates/packaged_manifests/.../AndroidManifest.xml, not just the intermediate +// merge blame log. +// +// TO REMOVE THIS EXCEPTION (restore HTTPS-only enforcement everywhere): +// 1. Delete this module (the netconfig/ directory). +// 2. Remove `include(":netconfig")` from settings.gradle.kts. +// 3. Remove `implementation(project(":netconfig"))` from tool/build.gradle.kts. +// 4. In GtfsAgency.kt, set RIPTA's realtimeTripUpdatesUrl/realtimeVehiclePositionsUrl back to +// null (the original, HTTPS-only-safe state). +// +// UNVERIFIED: whether Light's official build/signing pipeline (builder/) accepts a sibling module +// built this way — only confirmed against local Gradle builds so far. +plugins { + alias(libs.plugins.android.library) +} + +android { + namespace = "com.thelightphone.netconfig" + compileSdk = rootProject.ext["compileSdk"] as Int + + defaultConfig { + minSdk = rootProject.ext["minSdk"] as Int + } +} +// --- END removable cleartext exception for RIPTA realtime feeds --- diff --git a/netconfig/src/main/AndroidManifest.xml b/netconfig/src/main/AndroidManifest.xml new file mode 100644 index 00000000..39835cf5 --- /dev/null +++ b/netconfig/src/main/AndroidManifest.xml @@ -0,0 +1,9 @@ + + + + + diff --git a/netconfig/src/main/res/xml/network_security_config.xml b/netconfig/src/main/res/xml/network_security_config.xml new file mode 100644 index 00000000..c6774ceb --- /dev/null +++ b/netconfig/src/main/res/xml/network_security_config.xml @@ -0,0 +1,11 @@ + + + + + realtime.ripta.com + + diff --git a/settings.gradle.kts b/settings.gradle.kts index 20414827..694db05e 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -41,6 +41,8 @@ include(":sdk:client") include(":sdk:server") include(":sdk:emulator") include(":tool") +// REMOVABLE: see netconfig/build.gradle.kts for what this is and how to fully remove it. +include(":netconfig") include(":examples:ui-demo") project(":examples:ui-demo").projectDir = file("examples/ui-demo") include(":examples:weather") diff --git a/tool/README.md b/tool/README.md new file mode 100644 index 00000000..dbdaf5fe --- /dev/null +++ b/tool/README.md @@ -0,0 +1,45 @@ +# 🚌 Transit — your pocket-sized transit sidekick for the Light Phone III + +Hop on, hop off! Transit turns your Light Phone III into a tiny, delightfully un-distracting transit companion for MBTA and RIPTA. No ads, no infinite scroll — just "when's my bus" and "where the heck is it right now." 🚏✨ + +## 🗺️ What can it do? + +- 🏠 **Pick your agency** — MBTA or RIPTA — and Transit downloads their schedule right onto your phone. +- 📅 **Explore Schedules** — browse by Subway 🚇, Commuter Rail 🚆, or Bus 🚌, pick a route, a direction, and a stop, and see every departure today. +- 🔗 **Connections** — tap any stop along a trip to see what else comes through there next. Great for planning a transfer on the fly. +- 📍 **Leave Now** — type where you are (or let Transit take a quick IP-based guess 🛰️) and get the 20 closest stops, nearest first. +- ⏱️ **Live ETAs** — real-time predictions with On Time / Late / Early badges, whenever the agency's live feed is playing along nicely. +- 🧭 **ETA Radar** — a little live radar screen showing exactly which direction your bus (and nearby stops!) are coming from, right now. + +## 🛠️ Building & running it + +Transit lives inside the [light-sdk](../) monorepo — check the [root README](../README.md) first for one-time setup (GitHub token, Android Studio, etc). Once that's done: + +1. Open the whole `light-sdk` project in Android Studio. +2. Run the `:tool` module on an emulator, or better yet, [the LightOS emulator](../docs/system_app) — that's this app! 🎉 +3. Tap an agency, grab a coffee ☕ while it downloads the schedule, and you're off. + +## 📱 Getting it onto a *real* Light Phone III + +Light's official "build it, sign it, share it" pipeline for community tools isn't quite ready yet — vetting is expected around August/September 2026, with the full sharing platform following in October. So for now, sideloading via ADB is the way, and Light's own docs say that's totally fine for the adventurous! 🤠 + +1. In [`lighttool.toml`](./lighttool.toml), point `serverPackage` at the real LightOS package instead of the emulator: + ```toml + serverPackage = "com.lightos" + ``` +2. Build a debug APK: + ```bash + ./gradlew :tool:assembleDebug + ``` +3. Turn on Developer Options + USB debugging on your Light Phone III (same as any Android device), plug it in, then: + ```bash + adb install -r tool/build/outputs/apk/debug/tool-debug.apk + ``` +4. On the phone, allow "Any tools" in LightOS's tool settings — it'll warn you this one isn't Light-vetted yet, which is expected for a homemade build like this. 🚧 + +That's it — happy transit-ing! 🚏🚌🚆 + +## 🧪 A couple of nerdy notes + +- **RIPTA's live feeds are HTTP-only** (no HTTPS), which Android blocks by default. There's a small, clearly-labeled `:netconfig` module that grants just that one narrow exception — see its own `build.gradle.kts` for exactly what it does and how to remove it if you'd rather stay HTTPS-only everywhere. +- **No device GPS is used anywhere** — the SDK doesn't expose it to tools yet. Nearby-stop and location search are powered by Nominatim (OpenStreetMap) and IP-based geolocation instead. Be kind to their free APIs! 🙏 diff --git a/tool/build.gradle.kts b/tool/build.gradle.kts index 6e9ada0e..d1388c40 100644 --- a/tool/build.gradle.kts +++ b/tool/build.gradle.kts @@ -59,6 +59,16 @@ kotlin { dependencies { implementation(project(":sdk:client")) + // REMOVABLE: grants realtime.ripta.com a cleartext exception so its plain-HTTP-only realtime + // feeds are reachable. See netconfig/build.gradle.kts for the full explanation and removal + // steps (remove this line, the settings.gradle.kts include, and the module itself). + implementation(project(":netconfig")) + // Only the "org.jetbrains.kotlinx:kotlinx-serialization" prefix is on the SDK plugin's + // dependency allow-list, but that check is a startsWith match, so this artifact passes too — + // verified against a live build. No official protobuf/gtfs-realtime-bindings library is + // allow-listed, hence hand-rolling the small subset of the GTFS-RT schema needed (see + // GtfsRealtime.kt) rather than pulling one in. + implementation("org.jetbrains.kotlinx:kotlinx-serialization-protobuf:1.8.1") testImplementation(libs.kotlin.test) ksp(libs.androidx.room.compiler) } diff --git a/tool/lighttool.toml b/tool/lighttool.toml index 169c8696..bbf3a32d 100644 --- a/tool/lighttool.toml +++ b/tool/lighttool.toml @@ -1,6 +1,6 @@ [tool] -id = "com.thelightphone.app" -label = "Light SDK Tool" +id = "com.thelightphone.transit" +label = "Transit" versionCode = 1 versionName = "1.0.0" permissions = ["android.permission.INTERNET"] diff --git a/tool/src/main/kotlin/com/thelightphone/sample/HomeScreen.kt b/tool/src/main/kotlin/com/thelightphone/sample/HomeScreen.kt deleted file mode 100644 index 4ccc6874..00000000 --- a/tool/src/main/kotlin/com/thelightphone/sample/HomeScreen.kt +++ /dev/null @@ -1,139 +0,0 @@ -package com.thelightphone.sample - -import android.util.Log -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import androidx.lifecycle.viewModelScope -import com.thelightphone.sdk.InitialScreen -import com.thelightphone.sdk.LightFileShare -import com.thelightphone.sdk.LightScreen -import com.thelightphone.sdk.LightViewModel -import com.thelightphone.sdk.SealedLightActivity -import com.thelightphone.sdk.SimpleLightScreen -import com.thelightphone.sdk.callRemoteServiceMethod -import com.thelightphone.sdk.ui.LightIcon -import com.thelightphone.sdk.ui.LightIcons -import com.thelightphone.sdk.ui.LightText -import com.thelightphone.sdk.ui.LightTextVariant -import com.thelightphone.sdk.ui.LightTheme -import com.thelightphone.sdk.ui.LightThemeController -import com.thelightphone.sdk.ui.LightThemeTokens -import com.thelightphone.sdk.ui.lightClickable -import com.thelightphone.sdk.shared.LightServiceMethod -import com.thelightphone.sdk.shared.error -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.launch - -class HomeScreenViewModel( - private val fileShare: LightFileShare -) : LightViewModel() { - - val ringtones = MutableStateFlow>(emptyList()) - val status = MutableStateFlow(null) - - override fun onScreenShow(screen: SimpleLightScreen) { - super.onScreenShow(screen) - ringtones.value = fileShare.list("ringtones") - } - - fun selectRingtone(filename: String) { - val uri = fileShare.getUri("ringtones/$filename").toString() - viewModelScope.launch { - status.value = "Setting ringtone..." - val result = callRemoteServiceMethod( - LightServiceMethod.SetRingtone, - LightServiceMethod.SetRingtone.Request(type = 1, uri = uri) - ) - status.value = result.error?.let { - Log.e("HomeScreen", "Unable to set ringtone, error code: ${it.code}") - "Unable to set ringtone!" - } ?: "Ringtone set: $filename" - } - } -} - -@InitialScreen -class HomeScreen(sealedActivity: SealedLightActivity) : LightScreen(sealedActivity) { - - override val viewModelClass: Class - get() = HomeScreenViewModel::class.java - - override fun createViewModel(): HomeScreenViewModel { - return HomeScreenViewModel(lightContext.fileShare) - } - - @Composable - override fun Content() { - val ringtones by viewModel.ringtones.collectAsState() - val status by viewModel.status.collectAsState() - val themeColors by LightThemeController.colors.collectAsState() - - LightTheme(colors = themeColors) { - Column( - modifier = Modifier - .fillMaxSize() - .background(LightThemeTokens.colors.background) - .padding(32.dp) - ) { - LightText( - text = "Ringtones", - variant = LightTextVariant.Heading, - modifier = Modifier.padding(bottom = 16.dp), - ) - - Row(modifier = Modifier.padding(bottom = 24.dp)) { - LightIcon( - icon = LightIcons.SETTINGS, - modifier = Modifier - .padding(end = 16.dp) - .lightClickable { LightThemeController.toggle() }, - ) - LightIcon(icon = LightIcons.CALL, modifier = Modifier.padding(end = 16.dp)) - LightIcon(icon = LightIcons.SEARCH, modifier = Modifier.padding(end = 16.dp)) - LightIcon(icon = LightIcons.TOGGLE_STATE_ON) - } - - status?.let { - LightText( - text = it, - variant = LightTextVariant.Detail, - lighten = true, - modifier = Modifier.padding(bottom = 16.dp), - ) - } - - if (ringtones.isEmpty()) { - LightText( - text = "No ringtones found.", - variant = LightTextVariant.Copy, - lighten = true, - ) - } else { - LazyColumn { - items(ringtones) { filename -> - LightText( - text = filename, - variant = LightTextVariant.Copy, - modifier = Modifier - .fillMaxWidth() - .lightClickable { viewModel.selectRingtone(filename) } - .padding(vertical = 12.dp), - ) - } - } - } - } - } - } -} diff --git a/tool/src/main/kotlin/com/thelightphone/transit/DepartureListScreen.kt b/tool/src/main/kotlin/com/thelightphone/transit/DepartureListScreen.kt new file mode 100644 index 00000000..04bc596a --- /dev/null +++ b/tool/src/main/kotlin/com/thelightphone/transit/DepartureListScreen.kt @@ -0,0 +1,163 @@ +package com.thelightphone.transit + +import android.util.Log +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewModelScope +import com.thelightphone.transit.gtfs.Departure +import com.thelightphone.transit.gtfs.GtfsRepository +import com.thelightphone.transit.gtfs.formatGtfsTime +import com.thelightphone.transit.gtfs.todayForGtfs +import com.thelightphone.sdk.LightScreen +import com.thelightphone.sdk.LightViewModel +import com.thelightphone.sdk.SealedLightActivity +import com.thelightphone.sdk.SimpleLightScreen +import com.thelightphone.sdk.ui.LightText +import com.thelightphone.sdk.ui.LightTextVariant +import com.thelightphone.sdk.ui.LightTheme +import com.thelightphone.sdk.ui.LightThemeController +import com.thelightphone.sdk.ui.LightThemeTokens +import com.thelightphone.sdk.ui.lightClickable +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import java.io.File + +sealed class DepartureListState { + object Loading : DepartureListState() + data class Loaded(val departures: List) : DepartureListState() + data class Error(val message: String) : DepartureListState() +} + +class DepartureListViewModel( + dbFile: File, + private val routeId: String, + private val directionId: Int, + private val stopId: String, +) : LightViewModel() { + + private val repository = GtfsRepository(dbFile) + + private val _state = MutableStateFlow(DepartureListState.Loading) + val state: StateFlow = _state + + override fun onScreenShow(screen: SimpleLightScreen) { + super.onScreenShow(screen) + viewModelScope.launch(Dispatchers.IO) { + _state.value = try { + val today = todayForGtfs() + DepartureListState.Loaded(repository.getDepartures(routeId, directionId, stopId, today)) + } catch (e: Exception) { + Log.e("DepartureListScreen", "Failed to load departures for stop $stopId", e) + DepartureListState.Error("Unable to load departures.") + } + } + } + + override fun onCleared() { + super.onCleared() + repository.close() + } +} + +class DepartureListScreen( + sealedActivity: SealedLightActivity, + private val dbFile: File, + private val routeId: String, + private val routeLabel: String, + private val directionId: Int, + private val directionLabel: String, + private val stopId: String, + private val stopLabel: String, +) : LightScreen(sealedActivity) { + + override val viewModelClass: Class + get() = DepartureListViewModel::class.java + + override fun createViewModel(): DepartureListViewModel = + DepartureListViewModel(dbFile, routeId, directionId, stopId) + + @Composable + override fun Content() { + val state by viewModel.state.collectAsState() + val themeColors by LightThemeController.colors.collectAsState() + + LightTheme(colors = themeColors) { + Column( + modifier = Modifier + .fillMaxSize() + .background(LightThemeTokens.colors.background) + .padding(32.dp) + ) { + LightText( + text = "Departures", + variant = LightTextVariant.Heading, + modifier = Modifier.padding(bottom = 8.dp), + ) + LightText( + text = "$stopLabel - $routeLabel ($directionLabel)", + variant = LightTextVariant.Detail, + lighten = true, + modifier = Modifier.padding(bottom = 16.dp), + ) + + when (val s = state) { + is DepartureListState.Loading -> LightText( + text = "Loading departures...", + variant = LightTextVariant.Copy, + lighten = true, + ) + + is DepartureListState.Error -> LightText( + text = s.message, + variant = LightTextVariant.Copy, + lighten = true, + ) + + is DepartureListState.Loaded -> if (s.departures.isEmpty()) { + LightText( + text = "No departures today.", + variant = LightTextVariant.Copy, + lighten = true, + ) + } else { + LazyColumn(modifier = Modifier.weight(1f)) { + items(s.departures) { departure -> + LightText( + text = formatGtfsTime(departure.departureTime), + variant = LightTextVariant.Copy, + modifier = Modifier + .fillMaxWidth() + .lightClickable { + navigateTo(screenFactory = { activity -> + TripDetailScreen( + activity, + dbFile, + departure.tripId, + departure.stopSequence, + routeLabel, + directionLabel, + ) + }) + } + .padding(vertical = 12.dp), + ) + } + } + } + } + } + } + } +} diff --git a/tool/src/main/kotlin/com/thelightphone/sample/DetailScreen.kt b/tool/src/main/kotlin/com/thelightphone/transit/DetailScreen.kt similarity index 99% rename from tool/src/main/kotlin/com/thelightphone/sample/DetailScreen.kt rename to tool/src/main/kotlin/com/thelightphone/transit/DetailScreen.kt index 441076a9..b81027b0 100644 --- a/tool/src/main/kotlin/com/thelightphone/sample/DetailScreen.kt +++ b/tool/src/main/kotlin/com/thelightphone/transit/DetailScreen.kt @@ -1,4 +1,4 @@ -package com.thelightphone.sample +package com.thelightphone.transit import androidx.compose.foundation.background import androidx.compose.foundation.layout.Column diff --git a/tool/src/main/kotlin/com/thelightphone/transit/DirectionSelectionScreen.kt b/tool/src/main/kotlin/com/thelightphone/transit/DirectionSelectionScreen.kt new file mode 100644 index 00000000..95914dce --- /dev/null +++ b/tool/src/main/kotlin/com/thelightphone/transit/DirectionSelectionScreen.kt @@ -0,0 +1,153 @@ +package com.thelightphone.transit + +import android.util.Log +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewModelScope +import com.thelightphone.transit.gtfs.DirectionOption +import com.thelightphone.transit.gtfs.GtfsRepository +import com.thelightphone.sdk.LightScreen +import com.thelightphone.sdk.LightViewModel +import com.thelightphone.sdk.SealedLightActivity +import com.thelightphone.sdk.SimpleLightScreen +import com.thelightphone.sdk.ui.LightText +import com.thelightphone.sdk.ui.LightTextVariant +import com.thelightphone.sdk.ui.LightTheme +import com.thelightphone.sdk.ui.LightThemeController +import com.thelightphone.sdk.ui.LightThemeTokens +import com.thelightphone.sdk.ui.lightClickable +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import java.io.File + +sealed class DirectionSelectionState { + object Loading : DirectionSelectionState() + data class Loaded(val directions: List) : DirectionSelectionState() + data class Error(val message: String) : DirectionSelectionState() +} + +fun DirectionOption.displayLabel(): String = + headsign?.takeIf { it.isNotBlank() }?.let { "Toward $it" } ?: "Direction $directionId" + +class DirectionSelectionViewModel(dbFile: File, private val routeId: String) : LightViewModel() { + + private val repository = GtfsRepository(dbFile) + + private val _state = MutableStateFlow(DirectionSelectionState.Loading) + val state: StateFlow = _state + + override fun onScreenShow(screen: SimpleLightScreen) { + super.onScreenShow(screen) + viewModelScope.launch(Dispatchers.IO) { + _state.value = try { + DirectionSelectionState.Loaded(repository.getDirections(routeId)) + } catch (e: Exception) { + Log.e("DirectionSelectionScreen", "Failed to load directions for route $routeId", e) + DirectionSelectionState.Error("Unable to load directions.") + } + } + } + + override fun onCleared() { + super.onCleared() + repository.close() + } +} + +class DirectionSelectionScreen( + sealedActivity: SealedLightActivity, + private val dbFile: File, + private val routeId: String, + private val routeLabel: String, +) : LightScreen(sealedActivity) { + + override val viewModelClass: Class + get() = DirectionSelectionViewModel::class.java + + override fun createViewModel(): DirectionSelectionViewModel = DirectionSelectionViewModel(dbFile, routeId) + + @Composable + override fun Content() { + val state by viewModel.state.collectAsState() + val themeColors by LightThemeController.colors.collectAsState() + + LightTheme(colors = themeColors) { + Column( + modifier = Modifier + .fillMaxSize() + .background(LightThemeTokens.colors.background) + .padding(32.dp) + ) { + LightText( + text = "Choose Direction", + variant = LightTextVariant.Heading, + modifier = Modifier.padding(bottom = 8.dp), + ) + LightText( + text = routeLabel, + variant = LightTextVariant.Detail, + lighten = true, + modifier = Modifier.padding(bottom = 16.dp), + ) + + when (val s = state) { + is DirectionSelectionState.Loading -> LightText( + text = "Loading directions...", + variant = LightTextVariant.Copy, + lighten = true, + ) + + is DirectionSelectionState.Error -> LightText( + text = s.message, + variant = LightTextVariant.Copy, + lighten = true, + ) + + is DirectionSelectionState.Loaded -> if (s.directions.isEmpty()) { + LightText( + text = "No directions found.", + variant = LightTextVariant.Copy, + lighten = true, + ) + } else { + LazyColumn(modifier = Modifier.weight(1f)) { + items(s.directions) { direction -> + LightText( + text = direction.displayLabel(), + variant = LightTextVariant.Copy, + modifier = Modifier + .fillMaxWidth() + .lightClickable { + navigateTo(screenFactory = { activity -> + ScheduleLocationScreen( + activity, + dbFile, + routeId, + routeLabel, + direction.directionId, + direction.displayLabel(), + ) + }) + } + .padding(vertical = 12.dp), + ) + } + } + } + } + } + } + } +} diff --git a/tool/src/main/kotlin/com/thelightphone/transit/EtaRadarScreen.kt b/tool/src/main/kotlin/com/thelightphone/transit/EtaRadarScreen.kt new file mode 100644 index 00000000..240e8ff7 --- /dev/null +++ b/tool/src/main/kotlin/com/thelightphone/transit/EtaRadarScreen.kt @@ -0,0 +1,576 @@ +package com.thelightphone.transit + +import android.graphics.Paint +import android.util.Log +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.nativeCanvas +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewModelScope +import com.thelightphone.transit.gtfs.ArrivalStatus +import com.thelightphone.transit.gtfs.GtfsAgency +import com.thelightphone.transit.gtfs.GtfsRealtimeClient +import com.thelightphone.transit.gtfs.GtfsRepository +import com.thelightphone.transit.gtfs.GtfsRtVehicleStatus +import com.thelightphone.transit.gtfs.LineType +import com.thelightphone.transit.gtfs.NominatimGeocoder +import com.thelightphone.transit.gtfs.ScheduledArrival +import com.thelightphone.transit.gtfs.StopLocation +import com.thelightphone.transit.gtfs.bearingDegrees +import com.thelightphone.transit.gtfs.computeArrivalEta +import com.thelightphone.transit.gtfs.currentGtfsTimeOfDay +import com.thelightphone.transit.gtfs.formatGtfsTime +import com.thelightphone.transit.gtfs.haversineMeters +import com.thelightphone.transit.gtfs.todayForGtfs +import com.thelightphone.sdk.LightScreen +import com.thelightphone.sdk.LightViewModel +import com.thelightphone.sdk.SealedLightActivity +import com.thelightphone.sdk.SimpleLightScreen +import com.thelightphone.sdk.ui.LightText +import com.thelightphone.sdk.ui.LightTextVariant +import com.thelightphone.sdk.ui.LightTheme +import com.thelightphone.sdk.ui.LightThemeController +import com.thelightphone.sdk.ui.LightThemeTokens +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import java.io.File +import java.time.Instant +import java.time.LocalDateTime +import java.time.ZoneId +import kotlin.math.cos +import kotlin.math.sin + +private const val POLL_INTERVAL_MS = 15_000L +private const val MAX_DISPLAYED_BUSES = 4 +private const val MAX_SCHEDULED_BUSES = 2 +private const val MAX_DISPLAY_RADIUS_MILES = 3.0 +private const val MAX_DISPLAY_TIME_MINUTES = 30.0 +private const val METERS_PER_MILE = 1609.344 +private const val NEARBY_STOP_LIMIT = 10 +private const val MIN_NEARBY_STOP_RADIUS_FRACTION = 0.25 + +data class BusMarker( + val tripId: String, + val routeLabel: String, + val directionLabel: String, + val lineLabel: String?, + val etaEpochSeconds: Long, + val status: ArrivalStatus?, + val isArrived: Boolean, + /** False when this trip has no VehiclePosition match — position is approximated instead of + * tracked, and the radar draws it grayed out with a clock badge. */ + val isLive: Boolean, + val bearingDegrees: Double, + /** + * Pre-normalized 0..1 (already capped), so rendering doesn't need to know which underlying + * scale produced it: live buses cap on physical distance (MAX_DISPLAY_RADIUS_MILES), + * scheduled-only buses cap on time-until-arrival (MAX_DISPLAY_TIME_MINUTES) instead, since + * there's no live position to measure real distance from. + */ + val radiusFraction: Double, +) + +fun BusMarker.etaDisplay(): String { + val time = LocalDateTime.ofInstant(Instant.ofEpochSecond(etaEpochSeconds), ZoneId.systemDefault()) + return formatGtfsTime("%02d:%02d:00".format(time.hour, time.minute)) +} + +fun BusMarker.statusLabel(): String? = when (val s = status) { + null -> null + ArrivalStatus.OnTime -> "On time" + is ArrivalStatus.Late -> "Late ${(s.seconds / 60).coerceAtLeast(1)}m" + is ArrivalStatus.Early -> "Early ${(s.seconds / 60).coerceAtLeast(1)}m" +} + +data class NearbyStopMarker( + val stopId: String, + val stopName: String?, + val bearingDegrees: Double, + val radiusFraction: Double, +) + +sealed class EtaRadarState { + object Loading : EtaRadarState() + data class Loaded( + val streetContext: String?, + val buses: List, + val nearbyStops: List, + val isOffline: Boolean, + ) : EtaRadarState() + data class Error(val message: String) : EtaRadarState() +} + +class EtaRadarViewModel( + dbFile: File, + private val agency: GtfsAgency, + private val stopId: String, +) : LightViewModel() { + + private val repository = GtfsRepository(dbFile) + private val geocoder = NominatimGeocoder() + + private val _state = MutableStateFlow(EtaRadarState.Loading) + val state: StateFlow = _state + + private var pollJob: Job? = null + + override fun onScreenShow(screen: SimpleLightScreen) { + super.onScreenShow(screen) + pollJob?.cancel() + pollJob = viewModelScope.launch(Dispatchers.IO) { + try { + val stop = repository.getStopLocation(stopId) + if (stop == null) { + _state.value = EtaRadarState.Error("Stop location not found.") + return@launch + } + // Snapshot once: which trips are scheduled for this stop from now on. Live polling + // below only refreshes position/prediction data for these same trips — a trip that + // wasn't scheduled at screen-open time won't appear mid-session. + val scheduledArrivals = repository.getScheduledArrivals(stopId, currentGtfsTimeOfDay(), todayForGtfs()) + val streetContext = try { + geocoder.reverseGeocode(stop.lat, stop.lon) + } catch (e: Exception) { + Log.e("EtaRadarScreen", "Reverse geocoding failed for stop $stopId", e) + null + } + // Nearby stops don't change while the screen is open, so this is computed once too, + // reusing the same ranking function as the Leave Now flow, anchored here at the + // selected stop instead of a geocoded search point. + val nearbyStops = repository.rankStopsByDistance(stop.lat, stop.lon, NEARBY_STOP_LIMIT, excludeStopId = stopId) + .map { nearby -> + val trueFraction = (nearby.distanceMeters / METERS_PER_MILE) + .coerceAtMost(MAX_DISPLAY_RADIUS_MILES) / MAX_DISPLAY_RADIUS_MILES + NearbyStopMarker( + stopId = nearby.stopId, + stopName = nearby.stopName, + bearingDegrees = bearingDegrees(stop.lat, stop.lon, nearby.lat, nearby.lon), + // Floored so a stop genuinely close to the selected one never renders + // stacked on/behind the center pin. + radiusFraction = trueFraction.coerceAtLeast(MIN_NEARBY_STOP_RADIUS_FRACTION), + ) + } + + while (isActive) { + refresh(stop, scheduledArrivals, streetContext, nearbyStops) + delay(POLL_INTERVAL_MS) + } + } catch (e: Exception) { + Log.e("EtaRadarScreen", "Failed to load ETA radar for stop $stopId", e) + _state.value = EtaRadarState.Error("Unable to load bus positions.") + } + } + } + + override fun onScreenHide(screen: SimpleLightScreen) { + super.onScreenHide(screen) + pollJob?.cancel() + pollJob = null + } + + private suspend fun refresh( + stop: StopLocation, + scheduledArrivals: List, + streetContext: String?, + nearbyStops: List, + ) { + val today = todayForGtfs() + + val tripUpdatesFeed = agency.realtimeTripUpdatesUrl?.let { url -> + try { + GtfsRealtimeClient.fetchFeed(url) + } catch (e: Exception) { + Log.e("EtaRadarScreen", "TripUpdates fetch failed for ${agency.displayName}", e) + null + } + } + val vehiclePositionsFeed = agency.realtimeVehiclePositionsUrl?.let { url -> + try { + GtfsRealtimeClient.fetchFeed(url) + } catch (e: Exception) { + Log.e("EtaRadarScreen", "VehiclePositions fetch failed for ${agency.displayName}", e) + null + } + } + + val nowEpochSeconds = System.currentTimeMillis() / 1000 + + val buses = scheduledArrivals.mapNotNull { arrival -> + val rtStopUpdate = tripUpdatesFeed?.tripUpdatesByTripId?.get(arrival.tripId) + ?.updateFor(stopId, arrival.stopSequence) + val eta = computeArrivalEta(arrival.departureTime, today, rtStopUpdate) ?: return@mapNotNull null + + val vehicle = vehiclePositionsFeed?.vehiclePositionsByTripId?.get(arrival.tripId) + if (vehicle != null) { + val position = vehicle.position ?: return@mapNotNull null + + // Already past our stop on this trip -> disappear rather than show it moving away. + val currentSeq = vehicle.currentStopSequence + if (currentSeq != null && currentSeq > arrival.stopSequence) return@mapNotNull null + + val isArrived = vehicle.currentStatus == GtfsRtVehicleStatus.STOPPED_AT && + (currentSeq == arrival.stopSequence || vehicle.stopId == stopId) + + val lat = position.latitude.toDouble() + val lon = position.longitude.toDouble() + val bearing = if (isArrived) 0.0 else bearingDegrees(stop.lat, stop.lon, lat, lon) + val radiusFraction = if (isArrived) { + 0.0 + } else { + val distanceMiles = haversineMeters(stop.lat, stop.lon, lat, lon) / METERS_PER_MILE + distanceMiles.coerceAtMost(MAX_DISPLAY_RADIUS_MILES) / MAX_DISPLAY_RADIUS_MILES + } + + BusMarker( + tripId = arrival.tripId, + routeLabel = arrival.route.displayName, + directionLabel = arrival.direction.displayLabel(), + lineLabel = LineType.forGtfsRouteType(arrival.route.routeType)?.label, + etaEpochSeconds = eta.etaEpochSeconds, + status = eta.status, + isArrived = isArrived, + isLive = true, + bearingDegrees = bearing, + radiusFraction = radiusFraction, + ) + } else { + // No VehiclePosition for this trip: approximate its approach direction from the + // previous stop on the route rather than an actual tracked position, and use + // time-until-arrival in place of physical distance for radial placement. + val previousStop = repository.getPreviousStopLocation(arrival.tripId, arrival.stopSequence) + ?: return@mapNotNull null // first stop of the trip — no previous stop to bear from + val bearing = bearingDegrees(stop.lat, stop.lon, previousStop.lat, previousStop.lon) + val minutesUntilArrival = ((eta.etaEpochSeconds - nowEpochSeconds) / 60.0).coerceAtLeast(0.0) + val radiusFraction = minutesUntilArrival.coerceAtMost(MAX_DISPLAY_TIME_MINUTES) / MAX_DISPLAY_TIME_MINUTES + + BusMarker( + tripId = arrival.tripId, + routeLabel = arrival.route.displayName, + directionLabel = arrival.direction.displayLabel(), + lineLabel = LineType.forGtfsRouteType(arrival.route.routeType)?.label, + etaEpochSeconds = eta.etaEpochSeconds, + status = eta.status, + isArrived = false, + isLive = false, + bearingDegrees = bearing, + radiusFraction = radiusFraction, + ) + } + }.sortedBy { it.etaEpochSeconds } + + // Soonest-first, up to MAX_DISPLAYED_BUSES total, but at most MAX_SCHEDULED_BUSES of those + // may be non-live — an extra scheduled-only candidate is skipped so a later live one can + // still take its slot, rather than crowding the radar with unconfirmed estimates. + val selectedBuses = mutableListOf() + var scheduledCount = 0 + for (bus in buses) { + if (selectedBuses.size >= MAX_DISPLAYED_BUSES) break + if (!bus.isLive) { + if (scheduledCount >= MAX_SCHEDULED_BUSES) continue + scheduledCount++ + } + selectedBuses += bus + } + + _state.value = EtaRadarState.Loaded( + streetContext = streetContext, + buses = selectedBuses, + nearbyStops = nearbyStops, + // Offline means "no live position data at all" — a feed that fetched fine but happens + // to have zero matches among currently-scheduled trips still counts as offline, while + // even one live match means we're genuinely tracking something and shouldn't say so. + isOffline = vehiclePositionsFeed == null || + (selectedBuses.isNotEmpty() && selectedBuses.none { it.isLive }), + ) + } + + override fun onCleared() { + super.onCleared() + repository.close() + geocoder.close() + } +} + +class EtaRadarScreen( + sealedActivity: SealedLightActivity, + private val dbFile: File, + private val agency: GtfsAgency, + private val stopId: String, + private val stopLabel: String, +) : LightScreen(sealedActivity) { + + override val viewModelClass: Class + get() = EtaRadarViewModel::class.java + + override fun createViewModel(): EtaRadarViewModel = EtaRadarViewModel(dbFile, agency, stopId) + + @Composable + override fun Content() { + val state by viewModel.state.collectAsState() + val themeColors by LightThemeController.colors.collectAsState() + + LightTheme(colors = themeColors) { + Column( + modifier = Modifier + .fillMaxSize() + .background(LightThemeTokens.colors.background) + .padding(16.dp) + ) { + LightText( + text = "ETA", + variant = LightTextVariant.Heading, + modifier = Modifier.padding(bottom = 8.dp), + ) + + when (val s = state) { + is EtaRadarState.Loading -> LightText( + text = "Loading...", + variant = LightTextVariant.Copy, + lighten = true, + ) + + is EtaRadarState.Error -> LightText( + text = s.message, + variant = LightTextVariant.Copy, + lighten = true, + ) + + is EtaRadarState.Loaded -> { + if (s.isOffline) { + LightText( + text = "Offline - live positions unavailable", + variant = LightTextVariant.Detail, + lighten = true, + modifier = Modifier.padding(bottom = 8.dp), + ) + } + LightText( + text = "🚏 Nearby stop · 🚌 Live · 🚌🕐 Scheduled", + variant = LightTextVariant.Detail, + lighten = true, + modifier = Modifier.padding(bottom = 8.dp), + ) + RadarCanvas( + stopLabel = stopLabel, + streetContext = s.streetContext, + buses = s.buses, + nearbyStops = s.nearbyStops, + modifier = Modifier.fillMaxSize(), + ) + } + } + } + } + } +} + +/** + * Draws the whole radar entirely on a single Canvas, including text — emoji glyphs and labels are + * drawn via the underlying native android.graphics.Canvas/Paint (Compose's own text APIs are built + * for styled text layout, not single positioned glyphs). Sizing/offset constants below are a first + * pass; verify spacing on an actual device/emulator and adjust, since this can't be rendered here. + */ +@Composable +private fun RadarCanvas( + stopLabel: String, + streetContext: String?, + buses: List, + nearbyStops: List, + modifier: Modifier = Modifier, +) { + val contentColor = LightThemeTokens.colors.content.toArgb() + + Canvas(modifier = modifier) { + val center = Offset(size.width / 2f, size.height / 2f) + val maxRadius = minOf(size.width, size.height) / 2f * 0.8f + val nativeCanvas = drawContext.canvas.nativeCanvas + + val emojiPaint = Paint().apply { + textSize = 56f + textAlign = Paint.Align.CENTER + isAntiAlias = true + } + val labelPaint = Paint().apply { + textSize = 26f + textAlign = Paint.Align.CENTER + isAntiAlias = true + color = contentColor + } + val smallLabelPaint = Paint(labelPaint).apply { + textSize = 22f + alpha = 180 + } + val nearbyStopEmojiPaint = Paint().apply { + textSize = 34f + textAlign = Paint.Align.CENTER + isAntiAlias = true + } + val nearbyStopLabelPaint = Paint(smallLabelPaint).apply { + textSize = 18f + } + + // Fixed north-up compass letters around the edge. + val compassRadius = maxRadius + 28f + listOf("N" to 0.0, "E" to 90.0, "S" to 180.0, "W" to 270.0).forEach { (label, angle) -> + val rad = Math.toRadians(angle) + val x = center.x + compassRadius * sin(rad).toFloat() + val y = center.y - compassRadius * cos(rad).toFloat() + nativeCanvas.drawText(label, x, y, labelPaint) + } + + // Nearby stops drawn first (background layer) so bus markers stay legible on top. Ones + // close enough to overlap are grouped into one icon with aligned name lines underneath, + // same de-stacking approach as the bus markers below. + val stopPoints = nearbyStops.map { stop -> + val rad = Math.toRadians(stop.bearingDegrees) + val r = stop.radiusFraction.toFloat() * maxRadius + RadarPoint(stop, center.x + r * sin(rad).toFloat(), center.y - r * cos(rad).toFloat()) + } + clusterRadarPoints(stopPoints, CLUSTER_THRESHOLD_PX).forEach { cluster -> + val anchor = cluster.first() + nativeCanvas.drawText("🚏", anchor.x, anchor.y, nearbyStopEmojiPaint) + cluster.forEachIndexed { index, point -> + val label = point.item.stopName ?: "Stop ${point.item.stopId}" + nativeCanvas.drawText(label, anchor.x, anchor.y + 22f + index * 18f, nearbyStopLabelPaint) + } + } + + // Center stop pin, name, and street context. + nativeCanvas.drawText("📍", center.x, center.y, emojiPaint) + nativeCanvas.drawText(stopLabel, center.x, center.y + 64f, labelPaint) + streetContext?.let { + nativeCanvas.drawText(it, center.x, center.y + 92f, smallLabelPaint) + } + + val arrivedBuses = buses.filter { it.isArrived } + val enRouteBuses = buses.filterNot { it.isArrived } + + arrivedBuses.forEachIndexed { index, bus -> + val xOffset = (index - (arrivedBuses.size - 1) / 2f) * 110f + val x = center.x + xOffset + val y = center.y - maxRadius * 0.45f + drawBusMarker(nativeCanvas, emojiPaint, labelPaint, smallLabelPaint, bus, x, y) + } + + // Buses whose computed positions would land close enough to visually overlap (e.g. two + // trips approaching from the same direction) are grouped into one icon with aligned ETA + // lines underneath, rather than stacking icons on top of each other. Only far-enough-apart + // buses get their own individually placed icon. + val busPoints = enRouteBuses.map { bus -> + val rad = Math.toRadians(bus.bearingDegrees) + val r = bus.radiusFraction.toFloat() * maxRadius + RadarPoint(bus, center.x + r * sin(rad).toFloat(), center.y - r * cos(rad).toFloat()) + } + clusterRadarPoints(busPoints, CLUSTER_THRESHOLD_PX).forEach { cluster -> + val anchor = cluster.first() + if (cluster.size == 1) { + drawBusMarker(nativeCanvas, emojiPaint, labelPaint, smallLabelPaint, anchor.item, anchor.x, anchor.y) + } else { + drawBusCluster(nativeCanvas, emojiPaint, smallLabelPaint, cluster.map { it.item }, anchor.x, anchor.y) + } + } + } +} + +private const val SCHEDULED_BUS_ALPHA = 130 +private const val CLUSTER_THRESHOLD_PX = 80f + +/** A radar-placed item at its computed screen position, for proximity clustering. */ +private data class RadarPoint(val item: T, val x: Float, val y: Float) + +/** + * Greedily groups points within [thresholdPx] of each other (Euclidean, in the already-computed + * screen positions) into clusters, so visually overlapping markers — bus or stop — can be drawn as + * one icon with an aligned list underneath instead of stacking on top of each other. + */ +private fun clusterRadarPoints(points: List>, thresholdPx: Float): List>> { + val clusters = mutableListOf>>() + points.forEach { point -> + val cluster = clusters.find { existing -> + val anchor = existing.first() + val dx = point.x - anchor.x + val dy = point.y - anchor.y + kotlin.math.sqrt(dx * dx + dy * dy) < thresholdPx + } + if (cluster != null) cluster += point else clusters += mutableListOf(point) + } + return clusters +} + +private fun drawBusMarker( + canvas: android.graphics.Canvas, + emojiPaint: Paint, + labelPaint: Paint, + smallLabelPaint: Paint, + bus: BusMarker, + x: Float, + y: Float, +) { + val alpha = if (bus.isLive) 255 else SCHEDULED_BUS_ALPHA + val busEmojiPaint = Paint(emojiPaint).apply { this.alpha = alpha } + val busLabelPaint = Paint(labelPaint).apply { this.alpha = alpha } + val busSmallLabelPaint = Paint(smallLabelPaint).apply { this.alpha = smallLabelPaint.alpha * alpha / 255 } + + canvas.drawText("🚌", x, y, busEmojiPaint) + if (!bus.isLive) { + // Small clock badge marks this as a scheduled-time estimate, not a tracked live position. + val clockBadgePaint = Paint(emojiPaint).apply { + textSize = emojiPaint.textSize * 0.5f + textAlign = Paint.Align.LEFT + } + canvas.drawText("🕐", x + emojiPaint.textSize * 0.3f, y - emojiPaint.textSize * 0.35f, clockBadgePaint) + } + val routeText = bus.lineLabel?.let { "$it - ${bus.routeLabel}" } ?: bus.routeLabel + canvas.drawText(routeText, x, y + 30f, busSmallLabelPaint) + canvas.drawText(bus.directionLabel, x, y + 54f, busSmallLabelPaint) + canvas.drawText(bus.etaDisplay(), x, y + 78f, busLabelPaint) + bus.statusLabel()?.let { + canvas.drawText(it, x, y + 102f, busSmallLabelPaint) + } +} + +/** + * Draws one icon for a group of buses that would otherwise overlap, with one aligned line per bus + * underneath (route/direction/ETA), instead of stacking multiple icons on top of each other. + */ +private fun drawBusCluster( + canvas: android.graphics.Canvas, + emojiPaint: Paint, + smallLabelPaint: Paint, + buses: List, + x: Float, + y: Float, +) { + val anyLive = buses.any { it.isLive } + val clusterEmojiPaint = Paint(emojiPaint).apply { alpha = if (anyLive) 255 else SCHEDULED_BUS_ALPHA } + canvas.drawText("🚌", x, y, clusterEmojiPaint) + if (!anyLive) { + val clockBadgePaint = Paint(emojiPaint).apply { + textSize = emojiPaint.textSize * 0.5f + textAlign = Paint.Align.LEFT + } + canvas.drawText("🕐", x + emojiPaint.textSize * 0.3f, y - emojiPaint.textSize * 0.35f, clockBadgePaint) + } + buses.sortedBy { it.etaEpochSeconds }.forEachIndexed { index, bus -> + val alpha = if (bus.isLive) 255 else SCHEDULED_BUS_ALPHA + val linePaint = Paint(smallLabelPaint).apply { this.alpha = smallLabelPaint.alpha * alpha / 255 } + val badge = if (bus.isLive) "" else "🕐 " + val routeText = bus.lineLabel?.let { "$it - ${bus.routeLabel}" } ?: bus.routeLabel + val line = "$badge$routeText ${bus.directionLabel}: ${bus.etaDisplay()}" + canvas.drawText(line, x, y + 30f + index * 24f, linePaint) + } +} diff --git a/tool/src/main/kotlin/com/thelightphone/transit/FirstStopSelectionScreen.kt b/tool/src/main/kotlin/com/thelightphone/transit/FirstStopSelectionScreen.kt new file mode 100644 index 00000000..3a5dc692 --- /dev/null +++ b/tool/src/main/kotlin/com/thelightphone/transit/FirstStopSelectionScreen.kt @@ -0,0 +1,190 @@ +package com.thelightphone.transit + +import android.util.Log +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewModelScope +import com.thelightphone.transit.gtfs.GtfsRepository +import com.thelightphone.transit.gtfs.StopOption +import com.thelightphone.transit.gtfs.haversineMeters +import com.thelightphone.sdk.LightScreen +import com.thelightphone.sdk.LightViewModel +import com.thelightphone.sdk.SealedLightActivity +import com.thelightphone.sdk.SimpleLightScreen +import com.thelightphone.sdk.ui.LightText +import com.thelightphone.sdk.ui.LightTextVariant +import com.thelightphone.sdk.ui.LightTheme +import com.thelightphone.sdk.ui.LightThemeController +import com.thelightphone.sdk.ui.LightThemeTokens +import com.thelightphone.sdk.ui.lightClickable +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import java.io.File + +sealed class FirstStopSelectionState { + object Loading : FirstStopSelectionState() + data class Loaded(val stops: List) : FirstStopSelectionState() + data class Error(val message: String) : FirstStopSelectionState() +} + +fun StopOption.displayLabel(): String = stopName?.takeIf { it.isNotBlank() } ?: "Stop $stopId" + +private const val FEET_PER_METER = 3.28084 + +/** Null if this stop has no ingested coordinates — displayed with no distance rather than "0 ft". */ +fun StopOption.distanceFeetLabel(userLat: Double, userLon: Double): String? { + val stopLat = lat ?: return null + val stopLon = lon ?: return null + val feet = haversineMeters(userLat, userLon, stopLat, stopLon) * FEET_PER_METER + return "%.0f ft".format(feet) +} + +class FirstStopSelectionViewModel( + dbFile: File, + private val routeId: String, + private val directionId: Int, +) : LightViewModel() { + + private val repository = GtfsRepository(dbFile) + + private val _state = MutableStateFlow(FirstStopSelectionState.Loading) + val state: StateFlow = _state + + override fun onScreenShow(screen: SimpleLightScreen) { + super.onScreenShow(screen) + viewModelScope.launch(Dispatchers.IO) { + _state.value = try { + FirstStopSelectionState.Loaded(repository.getStops(routeId, directionId)) + } catch (e: Exception) { + Log.e("FirstStopSelectionScreen", "Failed to load first stops for route $routeId", e) + FirstStopSelectionState.Error("Unable to load stops.") + } + } + } + + override fun onCleared() { + super.onCleared() + repository.close() + } +} + +class FirstStopSelectionScreen( + sealedActivity: SealedLightActivity, + private val dbFile: File, + private val routeId: String, + private val routeLabel: String, + private val directionId: Int, + private val directionLabel: String, + private val userLat: Double, + private val userLon: Double, +) : LightScreen(sealedActivity) { + + override val viewModelClass: Class + get() = FirstStopSelectionViewModel::class.java + + override fun createViewModel(): FirstStopSelectionViewModel = + FirstStopSelectionViewModel(dbFile, routeId, directionId) + + @Composable + override fun Content() { + val state by viewModel.state.collectAsState() + val themeColors by LightThemeController.colors.collectAsState() + + LightTheme(colors = themeColors) { + Column( + modifier = Modifier + .fillMaxSize() + .background(LightThemeTokens.colors.background) + .padding(32.dp) + ) { + LightText( + text = "Choose Stop", + variant = LightTextVariant.Heading, + modifier = Modifier.padding(bottom = 8.dp), + ) + LightText( + text = "$routeLabel - $directionLabel", + variant = LightTextVariant.Detail, + lighten = true, + modifier = Modifier.padding(bottom = 16.dp), + ) + + when (val s = state) { + is FirstStopSelectionState.Loading -> LightText( + text = "Loading stops...", + variant = LightTextVariant.Copy, + lighten = true, + ) + + is FirstStopSelectionState.Error -> LightText( + text = s.message, + variant = LightTextVariant.Copy, + lighten = true, + ) + + is FirstStopSelectionState.Loaded -> if (s.stops.isEmpty()) { + LightText( + text = "No stops found.", + variant = LightTextVariant.Copy, + lighten = true, + ) + } else { + LazyColumn(modifier = Modifier.weight(1f)) { + items(s.stops) { stop -> + Row( + modifier = Modifier + .fillMaxWidth() + .lightClickable { + navigateTo(screenFactory = { activity -> + DepartureListScreen( + activity, + dbFile, + routeId, + routeLabel, + directionId, + directionLabel, + stop.stopId, + stop.displayLabel(), + ) + }) + } + .padding(vertical = 12.dp), + verticalAlignment = Alignment.Top, + ) { + LightText( + text = stop.displayLabel(), + variant = LightTextVariant.Copy, + modifier = Modifier + .weight(1f) + .padding(end = 16.dp), + ) + stop.distanceFeetLabel(userLat, userLon)?.let { + LightText( + text = it, + variant = LightTextVariant.Copy, + lighten = true, + ) + } + } + } + } + } + } + } + } + } +} diff --git a/tool/src/main/kotlin/com/thelightphone/transit/HomeScreen.kt b/tool/src/main/kotlin/com/thelightphone/transit/HomeScreen.kt new file mode 100644 index 00000000..40efbb82 --- /dev/null +++ b/tool/src/main/kotlin/com/thelightphone/transit/HomeScreen.kt @@ -0,0 +1,149 @@ +package com.thelightphone.transit + +import android.util.Log +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewModelScope +import com.thelightphone.transit.gtfs.GtfsAgency +import com.thelightphone.transit.gtfs.GtfsIngestStatus +import com.thelightphone.transit.gtfs.GtfsIngestor +import com.thelightphone.transit.gtfs.gtfsDbFile +import com.thelightphone.sdk.InitialScreen +import com.thelightphone.sdk.LightScreen +import com.thelightphone.sdk.LightViewModel +import com.thelightphone.sdk.SealedLightActivity +import com.thelightphone.sdk.ui.LightText +import com.thelightphone.sdk.ui.LightTextVariant +import com.thelightphone.sdk.ui.LightTheme +import com.thelightphone.sdk.ui.LightThemeController +import com.thelightphone.sdk.ui.LightThemeTokens +import com.thelightphone.sdk.ui.lightClickable +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import java.io.File + +class HomeScreenViewModel(filesDir: File) : LightViewModel() { + + private val ingestor = GtfsIngestor(filesDir) + + val selectedAgency = MutableStateFlow(null) + val status = MutableStateFlow(null) + + /** Set once ingestion completes successfully; gates the "Explore Schedules" entry point. */ + val readyAgency = MutableStateFlow(null) + + fun selectAgency(agency: GtfsAgency) { + selectedAgency.value = agency + readyAgency.value = null + Log.d("HomeScreen", "Selected agency: ${agency.displayName}") + + viewModelScope.launch(Dispatchers.IO) { + try { + ingestor.ingest(agency) { ingestStatus -> + status.value = when (ingestStatus) { + GtfsIngestStatus.Downloading -> "Downloading ${agency.displayName} data..." + GtfsIngestStatus.Parsing -> "Parsing ${agency.displayName} data..." + GtfsIngestStatus.Ready -> "Ready" + } + } + readyAgency.value = agency + } catch (e: Exception) { + Log.e("HomeScreen", "GTFS ingestion failed for ${agency.displayName}", e) + status.value = "Unable to load ${agency.displayName} data." + } + } + } +} + +@InitialScreen +class HomeScreen(sealedActivity: SealedLightActivity) : LightScreen(sealedActivity) { + + override val viewModelClass: Class + get() = HomeScreenViewModel::class.java + + override fun createViewModel(): HomeScreenViewModel { + return HomeScreenViewModel(lightContext.filesDir) + } + + @Composable + override fun Content() { + val selectedAgency by viewModel.selectedAgency.collectAsState() + val status by viewModel.status.collectAsState() + val readyAgency by viewModel.readyAgency.collectAsState() + val themeColors by LightThemeController.colors.collectAsState() + + LightTheme(colors = themeColors) { + Column( + modifier = Modifier + .fillMaxSize() + .background(LightThemeTokens.colors.background) + .padding(32.dp) + ) { + LightText( + text = "Choose Transit Agency", + variant = LightTextVariant.Heading, + modifier = Modifier.padding(bottom = 16.dp), + ) + + status?.let { + LightText( + text = it, + variant = LightTextVariant.Detail, + lighten = true, + modifier = Modifier.padding(bottom = 16.dp), + ) + } + + Column { + GtfsAgency.entries.forEach { agency -> + LightText( + text = agency.displayName, + variant = LightTextVariant.Copy, + lighten = agency != selectedAgency, + modifier = Modifier + .fillMaxWidth() + .lightClickable { viewModel.selectAgency(agency) } + .padding(vertical = 12.dp), + ) + } + } + + readyAgency?.let { agency -> + LightText( + text = "Explore Schedules", + variant = LightTextVariant.Copy, + modifier = Modifier + .fillMaxWidth() + .lightClickable { + navigateTo(screenFactory = { activity -> + LineTypeSelectionScreen(activity, gtfsDbFile(lightContext.filesDir, agency)) + }) + } + .padding(top = 24.dp), + ) + LightText( + text = if (agency.realtimeTripUpdatesUrl == null) "Leave Now (Offline)" else "Leave Now", + variant = LightTextVariant.Copy, + modifier = Modifier + .fillMaxWidth() + .lightClickable { + navigateTo(screenFactory = { activity -> + NearbyStopsScreen(activity, gtfsDbFile(lightContext.filesDir, agency), agency) + }) + } + .padding(top = 12.dp), + ) + } + } + } + } +} diff --git a/tool/src/main/kotlin/com/thelightphone/transit/LineTypeSelectionScreen.kt b/tool/src/main/kotlin/com/thelightphone/transit/LineTypeSelectionScreen.kt new file mode 100644 index 00000000..d31e3da0 --- /dev/null +++ b/tool/src/main/kotlin/com/thelightphone/transit/LineTypeSelectionScreen.kt @@ -0,0 +1,135 @@ +package com.thelightphone.transit + +import android.util.Log +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewModelScope +import com.thelightphone.transit.gtfs.GtfsRepository +import com.thelightphone.transit.gtfs.LineType +import com.thelightphone.sdk.LightScreen +import com.thelightphone.sdk.LightViewModel +import com.thelightphone.sdk.SealedLightActivity +import com.thelightphone.sdk.SimpleLightScreen +import com.thelightphone.sdk.ui.LightText +import com.thelightphone.sdk.ui.LightTextVariant +import com.thelightphone.sdk.ui.LightTheme +import com.thelightphone.sdk.ui.LightThemeController +import com.thelightphone.sdk.ui.LightThemeTokens +import com.thelightphone.sdk.ui.lightClickable +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import java.io.File + +sealed class LineTypeSelectionState { + object Loading : LineTypeSelectionState() + data class Loaded(val lineTypes: List) : LineTypeSelectionState() + data class Error(val message: String) : LineTypeSelectionState() +} + +class LineTypeSelectionViewModel(dbFile: File) : LightViewModel() { + + private val repository = GtfsRepository(dbFile) + + private val _state = MutableStateFlow(LineTypeSelectionState.Loading) + val state: StateFlow = _state + + override fun onScreenShow(screen: SimpleLightScreen) { + super.onScreenShow(screen) + viewModelScope.launch(Dispatchers.IO) { + _state.value = try { + LineTypeSelectionState.Loaded(repository.getAvailableLineTypes()) + } catch (e: Exception) { + Log.e("LineTypeSelectionScreen", "Failed to load line types", e) + LineTypeSelectionState.Error("Unable to load lines.") + } + } + } + + override fun onCleared() { + super.onCleared() + repository.close() + } +} + +class LineTypeSelectionScreen( + sealedActivity: SealedLightActivity, + private val dbFile: File, +) : LightScreen(sealedActivity) { + + override val viewModelClass: Class + get() = LineTypeSelectionViewModel::class.java + + override fun createViewModel(): LineTypeSelectionViewModel = LineTypeSelectionViewModel(dbFile) + + @Composable + override fun Content() { + val state by viewModel.state.collectAsState() + val themeColors by LightThemeController.colors.collectAsState() + + LightTheme(colors = themeColors) { + Column( + modifier = Modifier + .fillMaxSize() + .background(LightThemeTokens.colors.background) + .padding(32.dp) + ) { + LightText( + text = "Choose Line", + variant = LightTextVariant.Heading, + modifier = Modifier.padding(bottom = 16.dp), + ) + + when (val s = state) { + is LineTypeSelectionState.Loading -> LightText( + text = "Loading lines...", + variant = LightTextVariant.Copy, + lighten = true, + ) + + is LineTypeSelectionState.Error -> LightText( + text = s.message, + variant = LightTextVariant.Copy, + lighten = true, + ) + + is LineTypeSelectionState.Loaded -> if (s.lineTypes.isEmpty()) { + LightText( + text = "No lines found.", + variant = LightTextVariant.Copy, + lighten = true, + ) + } else { + LazyColumn(modifier = Modifier.weight(1f)) { + items(s.lineTypes) { lineType -> + LightText( + text = lineType.label, + variant = LightTextVariant.Copy, + modifier = Modifier + .fillMaxWidth() + .lightClickable { + navigateTo(screenFactory = { activity -> + RouteSelectionScreen(activity, dbFile, lineType) + }) + } + .padding(vertical = 12.dp), + ) + } + } + } + } + } + } + } +} diff --git a/tool/src/main/kotlin/com/thelightphone/transit/NearbyStopsScreen.kt b/tool/src/main/kotlin/com/thelightphone/transit/NearbyStopsScreen.kt new file mode 100644 index 00000000..bb7492f1 --- /dev/null +++ b/tool/src/main/kotlin/com/thelightphone/transit/NearbyStopsScreen.kt @@ -0,0 +1,295 @@ +package com.thelightphone.transit + +import android.util.Log +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewModelScope +import com.thelightphone.transit.gtfs.GeocodeResult +import com.thelightphone.transit.gtfs.GtfsAgency +import com.thelightphone.transit.gtfs.GtfsRepository +import com.thelightphone.transit.gtfs.IpGeolocator +import com.thelightphone.transit.gtfs.NominatimGeocoder +import com.thelightphone.transit.gtfs.StopWithDistance +import com.thelightphone.sdk.LightScreen +import com.thelightphone.sdk.LightViewModel +import com.thelightphone.sdk.SealedLightActivity +import com.thelightphone.sdk.SimpleLightScreen +import com.thelightphone.sdk.rememberKeyboardOptions +import com.thelightphone.sdk.ui.LightIcons +import com.thelightphone.sdk.ui.LightText +import com.thelightphone.sdk.ui.LightTextInputEditor +import com.thelightphone.sdk.ui.LightTextVariant +import com.thelightphone.sdk.ui.LightTheme +import com.thelightphone.sdk.ui.LightThemeController +import com.thelightphone.sdk.ui.LightThemeTokens +import com.thelightphone.sdk.ui.lightClickable +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import java.io.File + +private const val METERS_PER_MILE = 1609.344 +private const val NEARBY_STOP_LIMIT = 20 + +sealed class NearbyStopsMode { + /** Initial state: resolving an IP-based approximate location to pre-fill the search field. */ + object Locating : NearbyStopsMode() + data class LocationInput(val prefillText: String = "") : NearbyStopsMode() + object Searching : NearbyStopsMode() + data class GeocodeResults(val results: List) : NearbyStopsMode() + data class NearbyStops(val stops: List) : NearbyStopsMode() + data class Error(val message: String) : NearbyStopsMode() +} + +fun StopWithDistance.displayLabel(): String = stopName?.takeIf { it.isNotBlank() } ?: "Stop $stopId" + +fun StopWithDistance.distanceLabel(): String = "%.1f mi".format(distanceMeters / METERS_PER_MILE) + +class NearbyStopsViewModel(dbFile: File) : LightViewModel() { + + private val repository = GtfsRepository(dbFile) + private val geocoder = NominatimGeocoder() + private val ipGeolocator = IpGeolocator() + + private val _mode = MutableStateFlow(NearbyStopsMode.Locating) + val mode: StateFlow = _mode + + override fun onScreenShow(screen: SimpleLightScreen) { + super.onScreenShow(screen) + viewModelScope.launch(Dispatchers.IO) { + val prefill = try { + ipGeolocator.locate().displayName + } catch (e: Exception) { + Log.e("NearbyStopsScreen", "IP geolocation failed, falling back to blank input", e) + "" + } + _mode.value = NearbyStopsMode.LocationInput(prefillText = prefill) + } + } + + fun search(query: CharSequence) { + val text = query.toString().trim() + if (text.isEmpty()) return + _mode.value = NearbyStopsMode.Searching + viewModelScope.launch(Dispatchers.IO) { + _mode.value = try { + val results = geocoder.search(text) + if (results.isEmpty()) { + NearbyStopsMode.Error("No matching location found.") + } else { + NearbyStopsMode.GeocodeResults(results) + } + } catch (e: Exception) { + Log.e("NearbyStopsScreen", "Geocoding failed for '$text'", e) + NearbyStopsMode.Error("Unable to search that location.") + } + } + } + + fun selectGeocodeResult(result: GeocodeResult) { + _mode.value = NearbyStopsMode.Searching + viewModelScope.launch(Dispatchers.IO) { + _mode.value = try { + val ranked = repository.rankStopsByDistance(result.lat, result.lon, NEARBY_STOP_LIMIT) + NearbyStopsMode.NearbyStops(ranked) + } catch (e: Exception) { + Log.e("NearbyStopsScreen", "Failed to rank nearby stops", e) + NearbyStopsMode.Error("Unable to load nearby stops.") + } + } + } + + fun backToInput() { + _mode.value = NearbyStopsMode.LocationInput() + } + + override fun onCleared() { + super.onCleared() + repository.close() + geocoder.close() + ipGeolocator.close() + } +} + +class NearbyStopsScreen( + sealedActivity: SealedLightActivity, + private val dbFile: File, + private val agency: GtfsAgency, +) : LightScreen(sealedActivity) { + + override val viewModelClass: Class + get() = NearbyStopsViewModel::class.java + + override fun createViewModel(): NearbyStopsViewModel = NearbyStopsViewModel(dbFile) + + @Composable + override fun Content() { + val mode by viewModel.mode.collectAsState() + val themeColors by LightThemeController.colors.collectAsState() + val keyboardOptionsFlow = rememberKeyboardOptions() + + when (val m = mode) { + is NearbyStopsMode.LocationInput -> { + val textFieldState = rememberTextFieldState(m.prefillText) + LightTheme(colors = themeColors) { + LightTextInputEditor( + title = "Search Location", + state = textFieldState, + onSubmit = { viewModel.search(it) }, + onBack = { goBack() }, + keyboardOptionsFlow = keyboardOptionsFlow, + submitIcon = LightIcons.SEARCH, + modifier = Modifier.fillMaxSize(), + ) + } + } + + else -> LightTheme(colors = themeColors) { + Column( + modifier = Modifier + .fillMaxSize() + .background(LightThemeTokens.colors.background) + .padding(32.dp) + ) { + LightText( + text = "Nearby Stops", + variant = LightTextVariant.Heading, + modifier = Modifier.padding(bottom = 16.dp), + ) + + when (m) { + is NearbyStopsMode.Locating -> LightText( + text = "Finding your approximate location...", + variant = LightTextVariant.Copy, + lighten = true, + ) + + is NearbyStopsMode.Searching -> LightText( + text = "Searching...", + variant = LightTextVariant.Copy, + lighten = true, + ) + + is NearbyStopsMode.Error -> { + LightText( + text = m.message, + variant = LightTextVariant.Copy, + lighten = true, + modifier = Modifier.padding(bottom = 16.dp), + ) + LightText( + text = "Search Again", + variant = LightTextVariant.Copy, + modifier = Modifier.lightClickable { viewModel.backToInput() }, + ) + } + + is NearbyStopsMode.GeocodeResults -> { + LightText( + text = "Search Again", + variant = LightTextVariant.Copy, + lighten = true, + modifier = Modifier + .lightClickable { viewModel.backToInput() } + .padding(bottom = 16.dp), + ) + LazyColumn(modifier = Modifier.weight(1f)) { + items(m.results) { result -> + LightText( + text = result.displayName, + variant = LightTextVariant.Copy, + modifier = Modifier + .fillMaxWidth() + .lightClickable { viewModel.selectGeocodeResult(result) } + .padding(vertical = 12.dp), + ) + } + } + LightText( + text = "Location search © OpenStreetMap contributors", + variant = LightTextVariant.Detail, + lighten = true, + modifier = Modifier.padding(top = 16.dp), + ) + } + + is NearbyStopsMode.NearbyStops -> { + LightText( + text = "Search Again", + variant = LightTextVariant.Copy, + lighten = true, + modifier = Modifier + .lightClickable { viewModel.backToInput() } + .padding(bottom = 16.dp), + ) + if (m.stops.isEmpty()) { + LightText( + text = "No stops found.", + variant = LightTextVariant.Copy, + lighten = true, + ) + } else { + LazyColumn(modifier = Modifier.weight(1f)) { + items(m.stops) { stop -> + Row( + modifier = Modifier + .fillMaxWidth() + .lightClickable { + navigateTo(screenFactory = { activity -> + UpcomingArrivalsScreen( + activity, + dbFile, + agency, + stop.stopId, + stop.displayLabel(), + ) + }) + } + .padding(vertical = 12.dp), + verticalAlignment = Alignment.Top, + ) { + LightText( + text = stop.displayLabel(), + variant = LightTextVariant.Copy, + modifier = Modifier + .weight(1f) + .padding(end = 16.dp), + ) + LightText( + text = stop.distanceLabel(), + variant = LightTextVariant.Copy, + lighten = true, + ) + } + } + } + } + LightText( + text = "Location search © OpenStreetMap contributors", + variant = LightTextVariant.Detail, + lighten = true, + modifier = Modifier.padding(top = 16.dp), + ) + } + + is NearbyStopsMode.LocationInput -> Unit + } + } + } + } + } +} diff --git a/tool/src/main/kotlin/com/thelightphone/transit/RouteSelectionScreen.kt b/tool/src/main/kotlin/com/thelightphone/transit/RouteSelectionScreen.kt new file mode 100644 index 00000000..1a36d3a6 --- /dev/null +++ b/tool/src/main/kotlin/com/thelightphone/transit/RouteSelectionScreen.kt @@ -0,0 +1,148 @@ +package com.thelightphone.transit + +import android.util.Log +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewModelScope +import com.thelightphone.transit.gtfs.GtfsRepository +import com.thelightphone.transit.gtfs.LineType +import com.thelightphone.transit.gtfs.RouteOption +import com.thelightphone.sdk.LightScreen +import com.thelightphone.sdk.LightViewModel +import com.thelightphone.sdk.SealedLightActivity +import com.thelightphone.sdk.SimpleLightScreen +import com.thelightphone.sdk.ui.LightText +import com.thelightphone.sdk.ui.LightTextVariant +import com.thelightphone.sdk.ui.LightTheme +import com.thelightphone.sdk.ui.LightThemeController +import com.thelightphone.sdk.ui.LightThemeTokens +import com.thelightphone.sdk.ui.lightClickable +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import java.io.File + +sealed class RouteSelectionState { + object Loading : RouteSelectionState() + data class Loaded(val routes: List) : RouteSelectionState() + data class Error(val message: String) : RouteSelectionState() +} + +class RouteSelectionViewModel(dbFile: File, private val lineType: LineType) : LightViewModel() { + + private val repository = GtfsRepository(dbFile) + + private val _state = MutableStateFlow(RouteSelectionState.Loading) + val state: StateFlow = _state + + override fun onScreenShow(screen: SimpleLightScreen) { + super.onScreenShow(screen) + viewModelScope.launch(Dispatchers.IO) { + _state.value = try { + RouteSelectionState.Loaded(repository.getRoutes(lineType)) + } catch (e: Exception) { + Log.e("RouteSelectionScreen", "Failed to load routes", e) + RouteSelectionState.Error("Unable to load routes.") + } + } + } + + override fun onCleared() { + super.onCleared() + repository.close() + } +} + +class RouteSelectionScreen( + sealedActivity: SealedLightActivity, + private val dbFile: File, + private val lineType: LineType, +) : LightScreen(sealedActivity) { + + override val viewModelClass: Class + get() = RouteSelectionViewModel::class.java + + override fun createViewModel(): RouteSelectionViewModel = RouteSelectionViewModel(dbFile, lineType) + + @Composable + override fun Content() { + val state by viewModel.state.collectAsState() + val themeColors by LightThemeController.colors.collectAsState() + + LightTheme(colors = themeColors) { + Column( + modifier = Modifier + .fillMaxSize() + .background(LightThemeTokens.colors.background) + .padding(32.dp) + ) { + LightText( + text = "Choose Route", + variant = LightTextVariant.Heading, + modifier = Modifier.padding(bottom = 8.dp), + ) + LightText( + text = lineType.label, + variant = LightTextVariant.Detail, + lighten = true, + modifier = Modifier.padding(bottom = 16.dp), + ) + + when (val s = state) { + is RouteSelectionState.Loading -> LightText( + text = "Loading routes...", + variant = LightTextVariant.Copy, + lighten = true, + ) + + is RouteSelectionState.Error -> LightText( + text = s.message, + variant = LightTextVariant.Copy, + lighten = true, + ) + + is RouteSelectionState.Loaded -> if (s.routes.isEmpty()) { + LightText( + text = "No routes found.", + variant = LightTextVariant.Copy, + lighten = true, + ) + } else { + LazyColumn(modifier = Modifier.weight(1f)) { + items(s.routes) { route -> + LightText( + text = route.displayName, + variant = LightTextVariant.Copy, + modifier = Modifier + .fillMaxWidth() + .lightClickable { + navigateTo(screenFactory = { activity -> + DirectionSelectionScreen( + activity, + dbFile, + route.routeId, + route.displayName, + ) + }) + } + .padding(vertical = 12.dp), + ) + } + } + } + } + } + } + } +} diff --git a/tool/src/main/kotlin/com/thelightphone/transit/ScheduleLocationScreen.kt b/tool/src/main/kotlin/com/thelightphone/transit/ScheduleLocationScreen.kt new file mode 100644 index 00000000..66c61d39 --- /dev/null +++ b/tool/src/main/kotlin/com/thelightphone/transit/ScheduleLocationScreen.kt @@ -0,0 +1,236 @@ +package com.thelightphone.transit + +import android.util.Log +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewModelScope +import com.thelightphone.transit.gtfs.GeocodeResult +import com.thelightphone.transit.gtfs.IpGeolocator +import com.thelightphone.transit.gtfs.NominatimGeocoder +import com.thelightphone.sdk.LightScreen +import com.thelightphone.sdk.LightViewModel +import com.thelightphone.sdk.SealedLightActivity +import com.thelightphone.sdk.SimpleLightScreen +import com.thelightphone.sdk.rememberKeyboardOptions +import com.thelightphone.sdk.ui.LightIcons +import com.thelightphone.sdk.ui.LightText +import com.thelightphone.sdk.ui.LightTextInputEditor +import com.thelightphone.sdk.ui.LightTextVariant +import com.thelightphone.sdk.ui.LightTheme +import com.thelightphone.sdk.ui.LightThemeController +import com.thelightphone.sdk.ui.LightThemeTokens +import com.thelightphone.sdk.ui.lightClickable +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import java.io.File + +/** + * Same Locating/LocationInput/GeocodeResults/Error state machine as NearbyStopsScreen (IP-prefill, + * editable, Nominatim search) — kept as its own copy rather than a shared abstraction, since what + * happens after a location resolves differs (rank all stops globally vs. hand the point to + * FirstStopSelectionScreen) enough that a callback-based shared controller would add more + * indirection than it saves for two call sites. + */ +sealed class ScheduleLocationMode { + object Locating : ScheduleLocationMode() + data class LocationInput(val prefillText: String = "") : ScheduleLocationMode() + object Searching : ScheduleLocationMode() + data class GeocodeResults(val results: List) : ScheduleLocationMode() + data class Error(val message: String) : ScheduleLocationMode() +} + +class ScheduleLocationViewModel(dbFile: File) : LightViewModel() { + + private val geocoder = NominatimGeocoder() + private val ipGeolocator = IpGeolocator() + + private val _mode = MutableStateFlow(ScheduleLocationMode.Locating) + val mode: StateFlow = _mode + + override fun onScreenShow(screen: SimpleLightScreen) { + super.onScreenShow(screen) + viewModelScope.launch(Dispatchers.IO) { + val prefill = try { + ipGeolocator.locate().displayName + } catch (e: Exception) { + Log.e("ScheduleLocationScreen", "IP geolocation failed, falling back to blank input", e) + "" + } + _mode.value = ScheduleLocationMode.LocationInput(prefillText = prefill) + } + } + + fun search(query: CharSequence) { + val text = query.toString().trim() + if (text.isEmpty()) return + _mode.value = ScheduleLocationMode.Searching + viewModelScope.launch(Dispatchers.IO) { + _mode.value = try { + val results = geocoder.search(text) + if (results.isEmpty()) { + ScheduleLocationMode.Error("No matching location found.") + } else { + ScheduleLocationMode.GeocodeResults(results) + } + } catch (e: Exception) { + Log.e("ScheduleLocationScreen", "Geocoding failed for '$text'", e) + ScheduleLocationMode.Error("Unable to search that location.") + } + } + } + + fun backToInput() { + _mode.value = ScheduleLocationMode.LocationInput() + } + + override fun onCleared() { + super.onCleared() + geocoder.close() + ipGeolocator.close() + } +} + +class ScheduleLocationScreen( + sealedActivity: SealedLightActivity, + private val dbFile: File, + private val routeId: String, + private val routeLabel: String, + private val directionId: Int, + private val directionLabel: String, +) : LightScreen(sealedActivity) { + + override val viewModelClass: Class + get() = ScheduleLocationViewModel::class.java + + override fun createViewModel(): ScheduleLocationViewModel = ScheduleLocationViewModel(dbFile) + + @Composable + override fun Content() { + val mode by viewModel.mode.collectAsState() + val themeColors by LightThemeController.colors.collectAsState() + val keyboardOptionsFlow = rememberKeyboardOptions() + + when (val m = mode) { + is ScheduleLocationMode.LocationInput -> { + val textFieldState = rememberTextFieldState(m.prefillText) + LightTheme(colors = themeColors) { + LightTextInputEditor( + title = "Search Location", + state = textFieldState, + onSubmit = { viewModel.search(it) }, + onBack = { goBack() }, + keyboardOptionsFlow = keyboardOptionsFlow, + submitIcon = LightIcons.SEARCH, + modifier = Modifier.fillMaxSize(), + ) + } + } + + else -> LightTheme(colors = themeColors) { + Column( + modifier = Modifier + .fillMaxSize() + .background(LightThemeTokens.colors.background) + .padding(32.dp) + ) { + LightText( + text = "Search Location", + variant = LightTextVariant.Heading, + modifier = Modifier.padding(bottom = 8.dp), + ) + LightText( + text = "$routeLabel - $directionLabel", + variant = LightTextVariant.Detail, + lighten = true, + modifier = Modifier.padding(bottom = 16.dp), + ) + + when (m) { + is ScheduleLocationMode.Locating -> LightText( + text = "Finding your approximate location...", + variant = LightTextVariant.Copy, + lighten = true, + ) + + is ScheduleLocationMode.Searching -> LightText( + text = "Searching...", + variant = LightTextVariant.Copy, + lighten = true, + ) + + is ScheduleLocationMode.Error -> { + LightText( + text = m.message, + variant = LightTextVariant.Copy, + lighten = true, + modifier = Modifier.padding(bottom = 16.dp), + ) + LightText( + text = "Search Again", + variant = LightTextVariant.Copy, + modifier = Modifier.lightClickable { viewModel.backToInput() }, + ) + } + + is ScheduleLocationMode.GeocodeResults -> { + LightText( + text = "Search Again", + variant = LightTextVariant.Copy, + lighten = true, + modifier = Modifier + .lightClickable { viewModel.backToInput() } + .padding(bottom = 16.dp), + ) + LazyColumn(modifier = Modifier.weight(1f)) { + items(m.results) { result -> + LightText( + text = result.displayName, + variant = LightTextVariant.Copy, + modifier = Modifier + .fillMaxWidth() + .lightClickable { + navigateTo(screenFactory = { activity -> + FirstStopSelectionScreen( + activity, + dbFile, + routeId, + routeLabel, + directionId, + directionLabel, + result.lat, + result.lon, + ) + }) + } + .padding(vertical = 12.dp), + ) + } + } + LightText( + text = "Location search © OpenStreetMap contributors", + variant = LightTextVariant.Detail, + lighten = true, + modifier = Modifier.padding(top = 16.dp), + ) + } + + is ScheduleLocationMode.LocationInput -> Unit + } + } + } + } + } +} diff --git a/tool/src/main/kotlin/com/thelightphone/transit/StopConnectionsScreen.kt b/tool/src/main/kotlin/com/thelightphone/transit/StopConnectionsScreen.kt new file mode 100644 index 00000000..8bf2c2c4 --- /dev/null +++ b/tool/src/main/kotlin/com/thelightphone/transit/StopConnectionsScreen.kt @@ -0,0 +1,184 @@ +package com.thelightphone.transit + +import android.util.Log +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewModelScope +import com.thelightphone.transit.gtfs.GtfsRepository +import com.thelightphone.transit.gtfs.LineType +import com.thelightphone.transit.gtfs.StopConnection +import com.thelightphone.transit.gtfs.formatGtfsTime +import com.thelightphone.transit.gtfs.todayForGtfs +import com.thelightphone.sdk.LightScreen +import com.thelightphone.sdk.LightViewModel +import com.thelightphone.sdk.SealedLightActivity +import com.thelightphone.sdk.SimpleLightScreen +import com.thelightphone.sdk.ui.LightText +import com.thelightphone.sdk.ui.LightTextVariant +import com.thelightphone.sdk.ui.LightTheme +import com.thelightphone.sdk.ui.LightThemeController +import com.thelightphone.sdk.ui.LightThemeTokens +import com.thelightphone.sdk.ui.lightClickable +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import java.io.File + +sealed class StopConnectionsState { + object Loading : StopConnectionsState() + data class Loaded(val connections: List) : StopConnectionsState() + data class Error(val message: String) : StopConnectionsState() +} + +fun StopConnection.displayLabel(): String { + val lineLabel = LineType.forGtfsRouteType(route.routeType)?.label + val routeAndDirection = "${route.displayName} - ${direction.displayLabel()}" + return lineLabel?.let { "$it - $routeAndDirection" } ?: routeAndDirection +} + +class StopConnectionsViewModel( + dbFile: File, + private val stopId: String, + private val afterTime: String, + private val excludeTripId: String, +) : LightViewModel() { + + private val repository = GtfsRepository(dbFile) + + private val _state = MutableStateFlow(StopConnectionsState.Loading) + val state: StateFlow = _state + + override fun onScreenShow(screen: SimpleLightScreen) { + super.onScreenShow(screen) + viewModelScope.launch(Dispatchers.IO) { + _state.value = try { + val today = todayForGtfs() + StopConnectionsState.Loaded( + repository.getNextConnections(stopId, afterTime, excludeTripId, today) + ) + } catch (e: Exception) { + Log.e("StopConnectionsScreen", "Failed to load connections for stop $stopId", e) + StopConnectionsState.Error("Unable to load connections.") + } + } + } + + override fun onCleared() { + super.onCleared() + repository.close() + } +} + +class StopConnectionsScreen( + sealedActivity: SealedLightActivity, + private val dbFile: File, + private val stopId: String, + private val stopLabel: String, + private val afterTime: String, + private val excludeTripId: String, +) : LightScreen(sealedActivity) { + + override val viewModelClass: Class + get() = StopConnectionsViewModel::class.java + + override fun createViewModel(): StopConnectionsViewModel = + StopConnectionsViewModel(dbFile, stopId, afterTime, excludeTripId) + + @Composable + override fun Content() { + val state by viewModel.state.collectAsState() + val themeColors by LightThemeController.colors.collectAsState() + + LightTheme(colors = themeColors) { + Column( + modifier = Modifier + .fillMaxSize() + .background(LightThemeTokens.colors.background) + .padding(32.dp) + ) { + LightText( + text = "Connections", + variant = LightTextVariant.Heading, + modifier = Modifier.padding(bottom = 8.dp), + ) + LightText( + text = "$stopLabel - After ${formatGtfsTime(afterTime)}", + variant = LightTextVariant.Detail, + lighten = true, + modifier = Modifier.padding(bottom = 16.dp), + ) + + when (val s = state) { + is StopConnectionsState.Loading -> LightText( + text = "Loading connections...", + variant = LightTextVariant.Copy, + lighten = true, + ) + + is StopConnectionsState.Error -> LightText( + text = s.message, + variant = LightTextVariant.Copy, + lighten = true, + ) + + is StopConnectionsState.Loaded -> if (s.connections.isEmpty()) { + LightText( + text = "No more connections today.", + variant = LightTextVariant.Copy, + lighten = true, + ) + } else { + LazyColumn(modifier = Modifier.weight(1f)) { + items(s.connections) { connection -> + Row( + modifier = Modifier + .fillMaxWidth() + .lightClickable { + navigateTo(screenFactory = { activity -> + TripDetailScreen( + activity, + dbFile, + connection.tripId, + connection.stopSequence, + connection.route.displayName, + connection.direction.displayLabel(), + ) + }) + } + .padding(vertical = 12.dp), + verticalAlignment = Alignment.Top, + ) { + LightText( + text = connection.displayLabel(), + variant = LightTextVariant.Copy, + modifier = Modifier + .weight(1f) + .padding(end = 16.dp), + ) + LightText( + text = formatGtfsTime(connection.departureTime), + variant = LightTextVariant.Copy, + lighten = true, + ) + } + } + } + } + } + } + } + } +} diff --git a/tool/src/main/kotlin/com/thelightphone/sample/ToolEntryPoint.kt b/tool/src/main/kotlin/com/thelightphone/transit/ToolEntryPoint.kt similarity index 95% rename from tool/src/main/kotlin/com/thelightphone/sample/ToolEntryPoint.kt rename to tool/src/main/kotlin/com/thelightphone/transit/ToolEntryPoint.kt index 1cee2b67..8f425ea7 100644 --- a/tool/src/main/kotlin/com/thelightphone/sample/ToolEntryPoint.kt +++ b/tool/src/main/kotlin/com/thelightphone/transit/ToolEntryPoint.kt @@ -1,4 +1,4 @@ -package com.thelightphone.sample +package com.thelightphone.transit import android.util.Log import com.thelightphone.sdk.EntryPoint diff --git a/tool/src/main/kotlin/com/thelightphone/transit/TripDetailScreen.kt b/tool/src/main/kotlin/com/thelightphone/transit/TripDetailScreen.kt new file mode 100644 index 00000000..ac55dd50 --- /dev/null +++ b/tool/src/main/kotlin/com/thelightphone/transit/TripDetailScreen.kt @@ -0,0 +1,174 @@ +package com.thelightphone.transit + +import android.util.Log +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewModelScope +import com.thelightphone.transit.gtfs.GtfsRepository +import com.thelightphone.transit.gtfs.TripStopRow +import com.thelightphone.transit.gtfs.formatGtfsTime +import com.thelightphone.sdk.LightScreen +import com.thelightphone.sdk.LightViewModel +import com.thelightphone.sdk.SealedLightActivity +import com.thelightphone.sdk.SimpleLightScreen +import com.thelightphone.sdk.ui.LightText +import com.thelightphone.sdk.ui.LightTextVariant +import com.thelightphone.sdk.ui.LightTheme +import com.thelightphone.sdk.ui.LightThemeController +import com.thelightphone.sdk.ui.LightThemeTokens +import com.thelightphone.sdk.ui.lightClickable +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import java.io.File + +sealed class TripDetailState { + object Loading : TripDetailState() + data class Loaded(val stops: List) : TripDetailState() + data class Error(val message: String) : TripDetailState() +} + +class TripDetailViewModel( + dbFile: File, + private val tripId: String, + private val fromStopSequence: Int, +) : LightViewModel() { + + private val repository = GtfsRepository(dbFile) + + private val _state = MutableStateFlow(TripDetailState.Loading) + val state: StateFlow = _state + + override fun onScreenShow(screen: SimpleLightScreen) { + super.onScreenShow(screen) + viewModelScope.launch(Dispatchers.IO) { + _state.value = try { + TripDetailState.Loaded(repository.getTripStops(tripId, fromStopSequence)) + } catch (e: Exception) { + Log.e("TripDetailScreen", "Failed to load stop times for trip $tripId", e) + TripDetailState.Error("Unable to load trip detail.") + } + } + } + + override fun onCleared() { + super.onCleared() + repository.close() + } +} + +class TripDetailScreen( + sealedActivity: SealedLightActivity, + private val dbFile: File, + private val tripId: String, + private val fromStopSequence: Int, + private val routeLabel: String, + private val directionLabel: String, +) : LightScreen(sealedActivity) { + + override val viewModelClass: Class + get() = TripDetailViewModel::class.java + + override fun createViewModel(): TripDetailViewModel = TripDetailViewModel(dbFile, tripId, fromStopSequence) + + @Composable + override fun Content() { + val state by viewModel.state.collectAsState() + val themeColors by LightThemeController.colors.collectAsState() + + LightTheme(colors = themeColors) { + Column( + modifier = Modifier + .fillMaxSize() + .background(LightThemeTokens.colors.background) + .padding(32.dp) + ) { + LightText( + text = "Trip Detail", + variant = LightTextVariant.Heading, + modifier = Modifier.padding(bottom = 8.dp), + ) + LightText( + text = "$routeLabel ($directionLabel)", + variant = LightTextVariant.Detail, + lighten = true, + modifier = Modifier.padding(bottom = 16.dp), + ) + + when (val s = state) { + is TripDetailState.Loading -> LightText( + text = "Loading trip...", + variant = LightTextVariant.Copy, + lighten = true, + ) + + is TripDetailState.Error -> LightText( + text = s.message, + variant = LightTextVariant.Copy, + lighten = true, + ) + + is TripDetailState.Loaded -> if (s.stops.isEmpty()) { + LightText( + text = "No stops found for this trip.", + variant = LightTextVariant.Copy, + lighten = true, + ) + } else { + LazyColumn(modifier = Modifier.weight(1f)) { + items(s.stops) { stop -> + Row( + modifier = Modifier + .fillMaxWidth() + .lightClickable { + val afterTime = stop.arrivalTime ?: stop.departureTime + if (afterTime != null) { + navigateTo(screenFactory = { activity -> + StopConnectionsScreen( + activity, + dbFile, + stop.stopId, + stop.stopName ?: "Stop ${stop.stopId}", + afterTime, + tripId, + ) + }) + } + } + .padding(vertical = 8.dp), + verticalAlignment = Alignment.Top, + ) { + LightText( + text = stop.stopName ?: "Unknown stop", + variant = LightTextVariant.Copy, + modifier = Modifier + .weight(1f) + .padding(end = 16.dp), + ) + LightText( + text = formatGtfsTime(stop.arrivalTime ?: stop.departureTime), + variant = LightTextVariant.Copy, + lighten = true, + ) + } + } + } + } + } + } + } + } +} diff --git a/tool/src/main/kotlin/com/thelightphone/transit/UpcomingArrivalsScreen.kt b/tool/src/main/kotlin/com/thelightphone/transit/UpcomingArrivalsScreen.kt new file mode 100644 index 00000000..b2ed5896 --- /dev/null +++ b/tool/src/main/kotlin/com/thelightphone/transit/UpcomingArrivalsScreen.kt @@ -0,0 +1,290 @@ +package com.thelightphone.transit + +import android.util.Log +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewModelScope +import com.thelightphone.transit.gtfs.ArrivalStatus +import com.thelightphone.transit.gtfs.GtfsAgency +import com.thelightphone.transit.gtfs.GtfsRealtimeClient +import com.thelightphone.transit.gtfs.GtfsRepository +import com.thelightphone.transit.gtfs.LineType +import com.thelightphone.transit.gtfs.computeArrivalEta +import com.thelightphone.transit.gtfs.currentGtfsTimeOfDay +import com.thelightphone.transit.gtfs.formatGtfsTime +import com.thelightphone.transit.gtfs.isStale +import com.thelightphone.transit.gtfs.todayForGtfs +import com.thelightphone.sdk.LightScreen +import com.thelightphone.sdk.LightViewModel +import com.thelightphone.sdk.SealedLightActivity +import com.thelightphone.sdk.SimpleLightScreen +import com.thelightphone.sdk.ui.LightText +import com.thelightphone.sdk.ui.LightTextVariant +import com.thelightphone.sdk.ui.LightTheme +import com.thelightphone.sdk.ui.LightThemeController +import com.thelightphone.sdk.ui.LightThemeTokens +import com.thelightphone.sdk.ui.lightClickable +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import java.io.File +import java.time.Instant +import java.time.LocalDateTime +import java.time.ZoneId + +data class ArrivalRow( + val tripId: String, + val stopSequence: Int, + val routeLabel: String, + val directionLabel: String, + val lineLabel: String?, + val etaEpochSeconds: Long, + val isLive: Boolean, + val status: ArrivalStatus?, +) + +fun ArrivalRow.etaDisplay(): String { + val time = LocalDateTime.ofInstant(Instant.ofEpochSecond(etaEpochSeconds), ZoneId.systemDefault()) + return formatGtfsTime("%02d:%02d:00".format(time.hour, time.minute)) +} + +fun ArrivalRow.statusLabel(): String? = when (val s = status) { + null -> null + ArrivalStatus.OnTime -> "Live - On time" + is ArrivalStatus.Late -> "Live - Late by ${(s.seconds / 60).coerceAtLeast(1)}m" + is ArrivalStatus.Early -> "Live - Early by ${(s.seconds / 60).coerceAtLeast(1)}m" +} + +sealed class UpcomingArrivalsState { + object Loading : UpcomingArrivalsState() + + /** + * [isOffline] is true when there's no live feed connection at all this session — either the + * agency has no [GtfsAgency.realtimeTripUpdatesUrl] (RIPTA) or the fetch itself failed — as + * opposed to a feed that connected fine but simply has no update for one particular trip, + * which is normal and shown per-row with no badge rather than as a screen-wide state. + */ + data class Loaded( + val arrivals: List, + val isOffline: Boolean, + val realtimeStale: Boolean, + ) : UpcomingArrivalsState() + + data class Error(val message: String) : UpcomingArrivalsState() +} + +class UpcomingArrivalsViewModel( + dbFile: File, + private val agency: GtfsAgency, + private val stopId: String, +) : LightViewModel() { + + private val repository = GtfsRepository(dbFile) + + private val _state = MutableStateFlow(UpcomingArrivalsState.Loading) + val state: StateFlow = _state + + override fun onScreenShow(screen: SimpleLightScreen) { + super.onScreenShow(screen) + viewModelScope.launch(Dispatchers.IO) { + _state.value = try { + val today = todayForGtfs() + val scheduled = repository.getScheduledArrivals(stopId, currentGtfsTimeOfDay(), today) + + val feed = agency.realtimeTripUpdatesUrl?.let { url -> + try { + GtfsRealtimeClient.fetchFeed(url) + } catch (e: Exception) { + Log.e("UpcomingArrivalsScreen", "Realtime fetch failed for ${agency.displayName}", e) + null + } + } + + val rows = scheduled.mapNotNull { arrival -> + val rtStopUpdate = feed?.tripUpdatesByTripId?.get(arrival.tripId) + ?.updateFor(stopId, arrival.stopSequence) + val eta = computeArrivalEta(arrival.departureTime, today, rtStopUpdate) ?: return@mapNotNull null + ArrivalRow( + tripId = arrival.tripId, + stopSequence = arrival.stopSequence, + routeLabel = arrival.route.displayName, + directionLabel = arrival.direction.displayLabel(), + lineLabel = LineType.forGtfsRouteType(arrival.route.routeType)?.label, + etaEpochSeconds = eta.etaEpochSeconds, + isLive = eta.isLive, + status = eta.status, + ) + }.sortedBy { it.etaEpochSeconds } + + val stale = feed?.header?.isStale(System.currentTimeMillis() / 1000) ?: false + // Offline means "no live prediction at all" — a feed that fetched fine but simply + // has zero matches among currently-scheduled trips still counts as offline, while + // even one live match means we're genuinely getting live data. + val isOffline = feed == null || (rows.isNotEmpty() && rows.none { it.isLive }) + UpcomingArrivalsState.Loaded(rows, isOffline = isOffline, realtimeStale = stale) + } catch (e: Exception) { + Log.e("UpcomingArrivalsScreen", "Failed to load arrivals for stop $stopId", e) + UpcomingArrivalsState.Error("Unable to load arrivals.") + } + } + } + + override fun onCleared() { + super.onCleared() + repository.close() + } +} + +class UpcomingArrivalsScreen( + sealedActivity: SealedLightActivity, + private val dbFile: File, + private val agency: GtfsAgency, + private val stopId: String, + private val stopLabel: String, +) : LightScreen(sealedActivity) { + + override val viewModelClass: Class + get() = UpcomingArrivalsViewModel::class.java + + override fun createViewModel(): UpcomingArrivalsViewModel = + UpcomingArrivalsViewModel(dbFile, agency, stopId) + + @Composable + override fun Content() { + val state by viewModel.state.collectAsState() + val themeColors by LightThemeController.colors.collectAsState() + + LightTheme(colors = themeColors) { + Column( + modifier = Modifier + .fillMaxSize() + .background(LightThemeTokens.colors.background) + .padding(32.dp) + ) { + LightText( + text = "Upcoming Arrivals", + variant = LightTextVariant.Heading, + modifier = Modifier.padding(bottom = 8.dp), + ) + LightText( + text = stopLabel, + variant = LightTextVariant.Detail, + lighten = true, + modifier = Modifier.padding(bottom = 8.dp), + ) + LightText( + text = "ETA", + variant = LightTextVariant.Copy, + modifier = Modifier + .lightClickable { + navigateTo(screenFactory = { activity -> + EtaRadarScreen(activity, dbFile, agency, stopId, stopLabel) + }) + } + .padding(bottom = 16.dp), + ) + + when (val s = state) { + is UpcomingArrivalsState.Loading -> LightText( + text = "Loading arrivals...", + variant = LightTextVariant.Copy, + lighten = true, + ) + + is UpcomingArrivalsState.Error -> LightText( + text = s.message, + variant = LightTextVariant.Copy, + lighten = true, + ) + + is UpcomingArrivalsState.Loaded -> { + if (s.isOffline) { + LightText( + text = "Offline - showing scheduled times", + variant = LightTextVariant.Detail, + lighten = true, + modifier = Modifier.padding(bottom = 16.dp), + ) + } else if (s.realtimeStale) { + LightText( + text = "Live data may be outdated", + variant = LightTextVariant.Detail, + lighten = true, + modifier = Modifier.padding(bottom = 16.dp), + ) + } + + if (s.arrivals.isEmpty()) { + LightText( + text = "No more arrivals today.", + variant = LightTextVariant.Copy, + lighten = true, + ) + } else { + LazyColumn(modifier = Modifier.weight(1f)) { + items(s.arrivals) { arrival -> + Column( + modifier = Modifier + .fillMaxWidth() + .lightClickable { + navigateTo(screenFactory = { activity -> + TripDetailScreen( + activity, + dbFile, + arrival.tripId, + arrival.stopSequence, + arrival.routeLabel, + arrival.directionLabel, + ) + }) + } + .padding(vertical = 12.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.Top, + ) { + LightText( + text = arrival.lineLabel?.let { "$it - ${arrival.routeLabel} - ${arrival.directionLabel}" } + ?: "${arrival.routeLabel} - ${arrival.directionLabel}", + variant = LightTextVariant.Copy, + modifier = Modifier + .weight(1f) + .padding(end = 16.dp), + ) + LightText( + text = arrival.etaDisplay(), + variant = LightTextVariant.Copy, + lighten = true, + ) + } + arrival.statusLabel()?.let { + LightText( + text = it, + variant = LightTextVariant.Detail, + lighten = true, + ) + } + } + } + } + } + } + } + } + } + } +} diff --git a/tool/src/main/kotlin/com/thelightphone/transit/gtfs/GeoBearing.kt b/tool/src/main/kotlin/com/thelightphone/transit/gtfs/GeoBearing.kt new file mode 100644 index 00000000..4cfc36b6 --- /dev/null +++ b/tool/src/main/kotlin/com/thelightphone/transit/gtfs/GeoBearing.kt @@ -0,0 +1,13 @@ +package com.thelightphone.transit.gtfs + +/** Initial bearing (degrees clockwise from true north, 0-360) from point 1 to point 2. */ +fun bearingDegrees(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double { + val lat1Rad = Math.toRadians(lat1) + val lat2Rad = Math.toRadians(lat2) + val dLonRad = Math.toRadians(lon2 - lon1) + val y = kotlin.math.sin(dLonRad) * kotlin.math.cos(lat2Rad) + val x = kotlin.math.cos(lat1Rad) * kotlin.math.sin(lat2Rad) - + kotlin.math.sin(lat1Rad) * kotlin.math.cos(lat2Rad) * kotlin.math.cos(dLonRad) + val bearing = Math.toDegrees(kotlin.math.atan2(y, x)) + return (bearing + 360) % 360 +} diff --git a/tool/src/main/kotlin/com/thelightphone/transit/gtfs/GtfsAgency.kt b/tool/src/main/kotlin/com/thelightphone/transit/gtfs/GtfsAgency.kt new file mode 100644 index 00000000..5b5285bf --- /dev/null +++ b/tool/src/main/kotlin/com/thelightphone/transit/gtfs/GtfsAgency.kt @@ -0,0 +1,37 @@ +package com.thelightphone.transit.gtfs + +/** + * [realtimeTripUpdatesUrl]/[realtimeVehiclePositionsUrl] are null when an agency has no realtime + * feed reachable at all. Screens treat "null or fetch failed" identically, so adding/removing a + * URL here is the only change a screen-level caller ever needs to make. + * + * RIPTA's realtime service (realtime.ripta.com) is plain-HTTP-only with no HTTPS equivalent for + * either feed, which Android blocks by default. Its URLs below are only reachable because of a + * REMOVABLE cleartext exception — see the :netconfig module (netconfig/build.gradle.kts) for the + * full explanation and exact removal steps. To restore HTTPS-only enforcement everywhere, remove + * that module (per its own instructions) AND set RIPTA's two URLs below back to null. + */ +enum class GtfsAgency( + val id: String, + val displayName: String, + val feedUrl: String, + val realtimeTripUpdatesUrl: String?, + val realtimeVehiclePositionsUrl: String?, +) { + MBTA( + "mbta", + "MBTA", + "https://cdn.mbta.com/MBTA_GTFS.zip", + "https://cdn.mbta.com/realtime/TripUpdates.pb", + "https://cdn.mbta.com/realtime/VehiclePositions.pb", + ), + // REMOVABLE: these two URLs only work because of the :netconfig cleartext exception (see + // class doc above). Set both back to null to restore HTTPS-only enforcement for RIPTA. + RIPTA( + "ripta", + "RIPTA", + "https://ripta.com/RIPTA-GTFS.zip", + "http://realtime.ripta.com:81/api/tripupdates?format=gtfs.proto", + "http://realtime.ripta.com:81/api/vehiclepositions?format=gtfs.proto", + ), +} diff --git a/tool/src/main/kotlin/com/thelightphone/transit/gtfs/GtfsCsv.kt b/tool/src/main/kotlin/com/thelightphone/transit/gtfs/GtfsCsv.kt new file mode 100644 index 00000000..a5af5d43 --- /dev/null +++ b/tool/src/main/kotlin/com/thelightphone/transit/gtfs/GtfsCsv.kt @@ -0,0 +1,82 @@ +package com.thelightphone.transit.gtfs + +import android.database.sqlite.SQLiteStatement +import java.io.BufferedReader + +/** + * Minimal RFC 4180 line splitter: handles quoted fields, embedded commas, and "" as an escaped + * quote. No CSV library is in the SDK's allowed-dependency list, and GTFS fields like + * route_long_name/stop_name routinely contain commas, so a plain split(",") would corrupt rows. + */ +private fun parseCsvLine(line: String): List { + val fields = mutableListOf() + val field = StringBuilder() + var inQuotes = false + var i = 0 + while (i < line.length) { + val c = line[i] + when { + inQuotes -> when { + c == '"' && i + 1 < line.length && line[i + 1] == '"' -> { + field.append('"') + i++ + } + c == '"' -> inQuotes = false + else -> field.append(c) + } + c == '"' -> inQuotes = true + c == ',' -> { + fields.add(field.toString()) + field.clear() + } + else -> field.append(c) + } + i++ + } + fields.add(field.toString()) + return fields +} + +/** + * Maps a GTFS CSV row to column values by header name. GTFS doesn't guarantee column order or + * that optional columns are present, so lookups go by name rather than fixed index. + */ +internal class GtfsCsvHeader(header: List) { + private val columnIndex: Map = header + .mapIndexed { index, name -> name.trim().removePrefix("\uFEFF") to index } + .toMap() + + fun get(row: List, column: String): String? { + val index = columnIndex[column] ?: return null + return row.getOrNull(index)?.trim()?.takeIf { it.isNotEmpty() } + } +} + +/** + * Reads the header line from [reader] then invokes [onRow] for each data row until the current + * zip entry ends. [reader] is never closed here — closing it would close the shared + * ZipInputStream it wraps. + */ +internal inline fun readCsvEntry(reader: BufferedReader, onRow: (GtfsCsvHeader, List) -> Unit) { + val headerLine = reader.readLine() ?: return + val header = GtfsCsvHeader(parseCsvLine(headerLine)) + while (true) { + val line = reader.readLine() ?: break + if (line.isBlank()) continue + onRow(header, parseCsvLine(line)) + } +} + +internal fun SQLiteStatement.bindStringOrNull(index: Int, value: String?) { + if (value == null) bindNull(index) else bindString(index, value) +} + +internal fun SQLiteStatement.bindLongOrNull(index: Int, value: String?) { + val parsed = value?.toLongOrNull() + if (parsed == null) bindNull(index) else bindLong(index, parsed) +} + +internal fun SQLiteStatement.bindDoubleOrNull(index: Int, value: String?) { + val parsed = value?.toDoubleOrNull() + if (parsed == null) bindNull(index) else bindDouble(index, parsed) +} diff --git a/tool/src/main/kotlin/com/thelightphone/transit/gtfs/GtfsIngestor.kt b/tool/src/main/kotlin/com/thelightphone/transit/gtfs/GtfsIngestor.kt new file mode 100644 index 00000000..11273c74 --- /dev/null +++ b/tool/src/main/kotlin/com/thelightphone/transit/gtfs/GtfsIngestor.kt @@ -0,0 +1,272 @@ +package com.thelightphone.transit.gtfs + +import android.database.sqlite.SQLiteDatabase +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.engine.okhttp.OkHttp +import io.ktor.client.request.get +import io.ktor.http.HttpHeaders +import java.io.BufferedReader +import java.io.File +import java.util.zip.ZipInputStream + +enum class GtfsIngestStatus { + Downloading, Parsing, Ready +} + +class GtfsIngestException(message: String, cause: Throwable? = null) : Exception(message, cause) + +private const val MAX_REDIRECTS = 5 + +/** Path to an agency's ingested SQLite database, shared by the ingestor and every query screen. */ +fun gtfsDbFile(filesDir: File, agency: GtfsAgency): File = + File(filesDir, "gtfs/${agency.id}/transit.db") + +/** Downloads, unzips, and bulk-loads an agency's GTFS static feed into a local SQLite database. */ +class GtfsIngestor(private val filesDir: File) { + + suspend fun ingest(agency: GtfsAgency, onStatus: (GtfsIngestStatus) -> Unit) { + val agencyDir = File(filesDir, "gtfs/${agency.id}") + agencyDir.mkdirs() + val zipFile = File(agencyDir, "gtfs.zip") + val dbFile = gtfsDbFile(filesDir, agency) + + onStatus(GtfsIngestStatus.Downloading) + downloadZip(agency.feedUrl, zipFile) + + onStatus(GtfsIngestStatus.Parsing) + val db = openGtfsDatabase(dbFile) + try { + parseAndLoad(zipFile, db) + } finally { + db.close() + } + + onStatus(GtfsIngestStatus.Ready) + } + + /** + * Some feeds (e.g. RIPTA) redirect through a plain-HTTP hop before landing on HTTPS. + * Ktor's HttpRedirect plugin refuses to follow an HTTPS->HTTP downgrade by default (and + * Android blocks cleartext traffic anyway), so it hands back the original redirect + * response instead of an error. Redirects are followed manually here, upgrading any + * http:// hop to https:// rather than ever actually connecting over plain HTTP. + */ + private suspend fun downloadZip(url: String, destination: File) { + val client = HttpClient(OkHttp) { + followRedirects = false + } + try { + var currentUrl = url + repeat(MAX_REDIRECTS + 1) { + val response = client.get(currentUrl) + val status = response.status.value + when { + status in 200..299 -> { + val bytes: ByteArray = response.body() + destination.writeBytes(bytes) + return + } + status in 300..399 -> { + val location = response.headers[HttpHeaders.Location] + ?: throw GtfsIngestException("GTFS download redirected without a Location header") + currentUrl = if (location.startsWith("http://")) { + "https://" + location.removePrefix("http://") + } else { + location + } + } + else -> throw GtfsIngestException("GTFS download failed: HTTP $status") + } + } + throw GtfsIngestException("GTFS download exceeded $MAX_REDIRECTS redirects") + } finally { + client.close() + } + } + + private fun parseAndLoad(zipFile: File, db: SQLiteDatabase) { + db.beginTransaction() + try { + ZipInputStream(zipFile.inputStream()).use { zis -> + var entry = zis.nextEntry + while (entry != null) { + val loader = TABLE_LOADERS[entry.name.substringAfterLast('/')] + if (loader != null) { + val reader = BufferedReader(zis.reader(Charsets.UTF_8)) + loader(db, reader) + } + zis.closeEntry() + entry = zis.nextEntry + } + } + db.setTransactionSuccessful() + } finally { + db.endTransaction() + } + } + + companion object { + private val TABLE_LOADERS: Map Unit> = mapOf( + "routes.txt" to ::loadRoutes, + "trips.txt" to ::loadTrips, + "stops.txt" to ::loadStops, + "stop_times.txt" to ::loadStopTimes, + "calendar.txt" to ::loadCalendar, + "calendar_dates.txt" to ::loadCalendarDates, + ) + } +} + +private fun loadRoutes(db: SQLiteDatabase, reader: BufferedReader) { + db.delete("routes", null, null) + val stmt = db.compileStatement( + """ + INSERT INTO routes + (route_id, agency_id, route_short_name, route_long_name, route_desc, route_type, route_url, route_color, route_text_color) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + """ + ) + readCsvEntry(reader) { header, row -> + val routeId = header.get(row, "route_id") ?: return@readCsvEntry + stmt.clearBindings() + stmt.bindString(1, routeId) + stmt.bindStringOrNull(2, header.get(row, "agency_id")) + stmt.bindStringOrNull(3, header.get(row, "route_short_name")) + stmt.bindStringOrNull(4, header.get(row, "route_long_name")) + stmt.bindStringOrNull(5, header.get(row, "route_desc")) + stmt.bindLongOrNull(6, header.get(row, "route_type")) + stmt.bindStringOrNull(7, header.get(row, "route_url")) + stmt.bindStringOrNull(8, header.get(row, "route_color")) + stmt.bindStringOrNull(9, header.get(row, "route_text_color")) + stmt.executeInsert() + } +} + +private fun loadTrips(db: SQLiteDatabase, reader: BufferedReader) { + db.delete("trips", null, null) + val stmt = db.compileStatement( + """ + INSERT INTO trips + (trip_id, route_id, service_id, trip_headsign, trip_short_name, direction_id, block_id, shape_id, wheelchair_accessible, bikes_allowed) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """ + ) + readCsvEntry(reader) { header, row -> + val tripId = header.get(row, "trip_id") ?: return@readCsvEntry + val routeId = header.get(row, "route_id") ?: return@readCsvEntry + val serviceId = header.get(row, "service_id") ?: return@readCsvEntry + stmt.clearBindings() + stmt.bindString(1, tripId) + stmt.bindString(2, routeId) + stmt.bindString(3, serviceId) + stmt.bindStringOrNull(4, header.get(row, "trip_headsign")) + stmt.bindStringOrNull(5, header.get(row, "trip_short_name")) + stmt.bindLongOrNull(6, header.get(row, "direction_id")) + stmt.bindStringOrNull(7, header.get(row, "block_id")) + stmt.bindStringOrNull(8, header.get(row, "shape_id")) + stmt.bindLongOrNull(9, header.get(row, "wheelchair_accessible")) + stmt.bindLongOrNull(10, header.get(row, "bikes_allowed")) + stmt.executeInsert() + } +} + +private fun loadStops(db: SQLiteDatabase, reader: BufferedReader) { + db.delete("stops", null, null) + val stmt = db.compileStatement( + """ + INSERT INTO stops + (stop_id, stop_code, stop_name, stop_desc, stop_lat, stop_lon, zone_id, stop_url, location_type, parent_station, wheelchair_boarding) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """ + ) + readCsvEntry(reader) { header, row -> + val stopId = header.get(row, "stop_id") ?: return@readCsvEntry + stmt.clearBindings() + stmt.bindString(1, stopId) + stmt.bindStringOrNull(2, header.get(row, "stop_code")) + stmt.bindStringOrNull(3, header.get(row, "stop_name")) + stmt.bindStringOrNull(4, header.get(row, "stop_desc")) + stmt.bindDoubleOrNull(5, header.get(row, "stop_lat")) + stmt.bindDoubleOrNull(6, header.get(row, "stop_lon")) + stmt.bindStringOrNull(7, header.get(row, "zone_id")) + stmt.bindStringOrNull(8, header.get(row, "stop_url")) + stmt.bindLongOrNull(9, header.get(row, "location_type")) + stmt.bindStringOrNull(10, header.get(row, "parent_station")) + stmt.bindLongOrNull(11, header.get(row, "wheelchair_boarding")) + stmt.executeInsert() + } +} + +private fun loadStopTimes(db: SQLiteDatabase, reader: BufferedReader) { + db.delete("stop_times", null, null) + val stmt = db.compileStatement( + """ + INSERT INTO stop_times + (trip_id, stop_sequence, arrival_time, departure_time, stop_id, stop_headsign, pickup_type, drop_off_type, shape_dist_traveled) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + """ + ) + readCsvEntry(reader) { header, row -> + val tripId = header.get(row, "trip_id") ?: return@readCsvEntry + val stopSequence = header.get(row, "stop_sequence")?.toLongOrNull() ?: return@readCsvEntry + val stopId = header.get(row, "stop_id") ?: return@readCsvEntry + stmt.clearBindings() + stmt.bindString(1, tripId) + stmt.bindLong(2, stopSequence) + stmt.bindStringOrNull(3, header.get(row, "arrival_time")) + stmt.bindStringOrNull(4, header.get(row, "departure_time")) + stmt.bindString(5, stopId) + stmt.bindStringOrNull(6, header.get(row, "stop_headsign")) + stmt.bindLongOrNull(7, header.get(row, "pickup_type")) + stmt.bindLongOrNull(8, header.get(row, "drop_off_type")) + stmt.bindDoubleOrNull(9, header.get(row, "shape_dist_traveled")) + stmt.executeInsert() + } +} + +private fun loadCalendar(db: SQLiteDatabase, reader: BufferedReader) { + db.delete("calendar", null, null) + val stmt = db.compileStatement( + """ + INSERT INTO calendar + (service_id, monday, tuesday, wednesday, thursday, friday, saturday, sunday, start_date, end_date) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """ + ) + readCsvEntry(reader) { header, row -> + val serviceId = header.get(row, "service_id") ?: return@readCsvEntry + stmt.clearBindings() + stmt.bindString(1, serviceId) + stmt.bindLongOrNull(2, header.get(row, "monday")) + stmt.bindLongOrNull(3, header.get(row, "tuesday")) + stmt.bindLongOrNull(4, header.get(row, "wednesday")) + stmt.bindLongOrNull(5, header.get(row, "thursday")) + stmt.bindLongOrNull(6, header.get(row, "friday")) + stmt.bindLongOrNull(7, header.get(row, "saturday")) + stmt.bindLongOrNull(8, header.get(row, "sunday")) + stmt.bindStringOrNull(9, header.get(row, "start_date")) + stmt.bindStringOrNull(10, header.get(row, "end_date")) + stmt.executeInsert() + } +} + +private fun loadCalendarDates(db: SQLiteDatabase, reader: BufferedReader) { + db.delete("calendar_dates", null, null) + val stmt = db.compileStatement( + """ + INSERT INTO calendar_dates (service_id, date, exception_type) + VALUES (?, ?, ?) + """ + ) + readCsvEntry(reader) { header, row -> + val serviceId = header.get(row, "service_id") ?: return@readCsvEntry + val date = header.get(row, "date") ?: return@readCsvEntry + val exceptionType = header.get(row, "exception_type")?.toLongOrNull() ?: return@readCsvEntry + stmt.clearBindings() + stmt.bindString(1, serviceId) + stmt.bindString(2, date) + stmt.bindLong(3, exceptionType) + stmt.executeInsert() + } +} diff --git a/tool/src/main/kotlin/com/thelightphone/transit/gtfs/GtfsRealtime.kt b/tool/src/main/kotlin/com/thelightphone/transit/gtfs/GtfsRealtime.kt new file mode 100644 index 00000000..0a731bfc --- /dev/null +++ b/tool/src/main/kotlin/com/thelightphone/transit/gtfs/GtfsRealtime.kt @@ -0,0 +1,211 @@ +@file:OptIn(ExperimentalSerializationApi::class) + +package com.thelightphone.transit.gtfs + +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.engine.okhttp.OkHttp +import io.ktor.client.request.get +import io.ktor.http.HttpHeaders +import java.time.LocalDate +import java.time.ZoneId +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.Serializable +import kotlinx.serialization.protobuf.ProtoBuf +import kotlinx.serialization.protobuf.ProtoNumber + +/** + * Minimal mirror of the GTFS-realtime.proto schema — only the fields this app reads. Field + * numbers match the public spec exactly (verified by hand-decoding a live RIPTA feed byte-for- + * byte during development). No official protobuf/gtfs-realtime-bindings library is on the SDK's + * dependency allow-list, so this is decoded via kotlinx-serialization-protobuf instead, which + * passes the allow-list check on a startsWith-prefix technicality (see build.gradle.kts). + */ +@Serializable +data class GtfsRtFeedMessage( + @ProtoNumber(1) val header: GtfsRtFeedHeader = GtfsRtFeedHeader(), + @ProtoNumber(2) val entity: List = emptyList(), +) { + /** trip_id -> its TripUpdate, for O(1) lookup while merging against the static schedule. */ + val tripUpdatesByTripId: Map by lazy { + entity.mapNotNull { it.tripUpdate }.associateBy { it.trip.tripId } + } + + /** trip_id -> its VehiclePosition, for the ETA radar screen. */ + val vehiclePositionsByTripId: Map by lazy { + entity.mapNotNull { it.vehicle }.associateBy { it.trip.tripId } + } +} + +@Serializable +data class GtfsRtFeedHeader( + @ProtoNumber(1) val gtfsRealtimeVersion: String = "", + @ProtoNumber(2) val incrementality: Int = 0, + @ProtoNumber(3) val timestamp: Long = 0L, +) + +@Serializable +data class GtfsRtFeedEntity( + @ProtoNumber(1) val id: String = "", + @ProtoNumber(3) val tripUpdate: GtfsRtTripUpdate? = null, + @ProtoNumber(4) val vehicle: GtfsRtVehiclePosition? = null, +) + +/** VehiclePosition.current_status values, per the GTFS-realtime spec. */ +object GtfsRtVehicleStatus { + const val INCOMING_AT = 0 + const val STOPPED_AT = 1 + const val IN_TRANSIT_TO = 2 +} + +@Serializable +data class GtfsRtVehiclePosition( + @ProtoNumber(1) val trip: GtfsRtTripDescriptor = GtfsRtTripDescriptor(), + @ProtoNumber(2) val position: GtfsRtPosition? = null, + @ProtoNumber(3) val currentStopSequence: Int? = null, + @ProtoNumber(4) val stopId: String? = null, + @ProtoNumber(5) val currentStatus: Int? = null, +) + +/** lat/lon are proto `float` (4-byte), not `double` — verified against MBTA's live feed bytes. */ +@Serializable +data class GtfsRtPosition( + @ProtoNumber(1) val latitude: Float = 0f, + @ProtoNumber(2) val longitude: Float = 0f, +) + +@Serializable +data class GtfsRtTripUpdate( + @ProtoNumber(1) val trip: GtfsRtTripDescriptor = GtfsRtTripDescriptor(), + @ProtoNumber(2) val stopTimeUpdate: List = emptyList(), +) { + /** Matches by stop_id first (more specific), falling back to stop_sequence. */ + fun updateFor(stopId: String, stopSequence: Int): GtfsRtStopTimeUpdate? = + stopTimeUpdate.find { it.stopId == stopId } + ?: stopTimeUpdate.find { it.stopSequence == stopSequence } +} + +@Serializable +data class GtfsRtTripDescriptor( + @ProtoNumber(1) val tripId: String = "", +) + +@Serializable +data class GtfsRtStopTimeUpdate( + @ProtoNumber(1) val stopSequence: Int? = null, + @ProtoNumber(4) val stopId: String? = null, + @ProtoNumber(2) val arrival: GtfsRtStopTimeEvent? = null, + @ProtoNumber(3) val departure: GtfsRtStopTimeEvent? = null, +) + +@Serializable +data class GtfsRtStopTimeEvent( + @ProtoNumber(1) val delay: Int? = null, + @ProtoNumber(2) val time: Long? = null, +) + +class GtfsRealtimeException(message: String, cause: Throwable? = null) : Exception(message, cause) + +private const val MAX_REDIRECTS = 5 + +/** + * Fetches and decodes a GTFS-RT feed — TripUpdates and VehiclePositions are separate published + * feeds but both decode into this same FeedMessage/FeedEntity wrapper (each entity just populates + * whichever of trip_update/vehicle applies to that feed), so one fetch function covers both. + * Reuses the same manual redirect handling as GtfsIngestor's zip download (some feeds route + * through non-HTTPS hops too), upgrading any http:// redirect target to https:// rather than ever + * connecting over plain HTTP. + */ +object GtfsRealtimeClient { + suspend fun fetchFeed(url: String): GtfsRtFeedMessage { + val client = HttpClient(OkHttp) { + followRedirects = false + } + try { + var currentUrl = url + repeat(MAX_REDIRECTS + 1) { + val response = client.get(currentUrl) + val status = response.status.value + when { + status in 200..299 -> { + val bytes: ByteArray = response.body() + return ProtoBuf.decodeFromByteArray(GtfsRtFeedMessage.serializer(), bytes) + } + status in 300..399 -> { + val location = response.headers[HttpHeaders.Location] + ?: throw GtfsRealtimeException("GTFS-RT redirected without a Location header") + currentUrl = if (location.startsWith("http://")) { + "https://" + location.removePrefix("http://") + } else { + location + } + } + else -> throw GtfsRealtimeException("GTFS-RT fetch failed: HTTP $status") + } + } + throw GtfsRealtimeException("GTFS-RT fetch exceeded $MAX_REDIRECTS redirects") + } finally { + client.close() + } + } +} + +/** Default +/- window (seconds) within which a live prediction still counts as "On time". */ +const val ARRIVAL_STATUS_TOLERANCE_SECONDS = 90L + +/** How stale (seconds) a feed's header timestamp can be before it's flagged to the user. */ +const val REALTIME_STALE_THRESHOLD_SECONDS = 90L + +sealed class ArrivalStatus { + object OnTime : ArrivalStatus() + data class Late(val seconds: Long) : ArrivalStatus() + data class Early(val seconds: Long) : ArrivalStatus() +} + +data class ArrivalEta( + val etaEpochSeconds: Long, + val isLive: Boolean, + val status: ArrivalStatus?, +) + +/** + * Converts a GTFS scheduled "HH:MM:SS" time (hour may exceed 24 for a post-midnight trip on + * [serviceDate]'s service day) to an absolute Unix epoch-seconds instant, for comparison against + * GTFS-RT's absolute timestamps. + */ +fun gtfsTimeToEpochSeconds(rawTime: String, serviceDate: LocalDate): Long? { + val parts = rawTime.split(":") + val hour = parts.getOrNull(0)?.toLongOrNull() ?: return null + val minute = parts.getOrNull(1)?.toLongOrNull() ?: return null + val second = parts.getOrNull(2)?.toLongOrNull() ?: 0L + val midnightEpoch = serviceDate.atStartOfDay(ZoneId.systemDefault()).toEpochSecond() + return midnightEpoch + hour * 3600 + minute * 60 + second +} + +/** + * Combines a static scheduled time with a matching GTFS-RT StopTimeUpdate (if any) into an ETA + * and status. With no realtime match, returns a non-live ETA with a null status — callers should + * render that as "just the scheduled time, no badge" per spec. + */ +fun computeArrivalEta( + scheduledTime: String, + serviceDate: LocalDate, + realtimeUpdate: GtfsRtStopTimeUpdate?, + toleranceSeconds: Long = ARRIVAL_STATUS_TOLERANCE_SECONDS, +): ArrivalEta? { + val scheduledEpoch = gtfsTimeToEpochSeconds(scheduledTime, serviceDate) ?: return null + val event = realtimeUpdate?.departure ?: realtimeUpdate?.arrival + ?: return ArrivalEta(etaEpochSeconds = scheduledEpoch, isLive = false, status = null) + + val predicted = event.time ?: (scheduledEpoch + (event.delay ?: 0)) + val diff = predicted - scheduledEpoch + val status = when { + diff > toleranceSeconds -> ArrivalStatus.Late(diff) + diff < -toleranceSeconds -> ArrivalStatus.Early(-diff) + else -> ArrivalStatus.OnTime + } + return ArrivalEta(etaEpochSeconds = predicted, isLive = true, status = status) +} + +fun GtfsRtFeedHeader.isStale(nowEpochSeconds: Long, thresholdSeconds: Long = REALTIME_STALE_THRESHOLD_SECONDS): Boolean = + timestamp > 0 && (nowEpochSeconds - timestamp) > thresholdSeconds diff --git a/tool/src/main/kotlin/com/thelightphone/transit/gtfs/GtfsRepository.kt b/tool/src/main/kotlin/com/thelightphone/transit/gtfs/GtfsRepository.kt new file mode 100644 index 00000000..3c532f96 --- /dev/null +++ b/tool/src/main/kotlin/com/thelightphone/transit/gtfs/GtfsRepository.kt @@ -0,0 +1,491 @@ +package com.thelightphone.transit.gtfs + +import android.database.Cursor +import android.database.sqlite.SQLiteDatabase +import java.io.File +import java.time.DayOfWeek +import java.time.LocalDate + +data class RouteOption( + val routeId: String, + val shortName: String?, + val longName: String?, + val routeType: Int, +) { + val displayName: String + get() = when { + !shortName.isNullOrBlank() && !longName.isNullOrBlank() -> "$shortName - $longName" + !shortName.isNullOrBlank() -> shortName + !longName.isNullOrBlank() -> longName + else -> routeId + } +} + +/** + * Groups GTFS's numeric `route_type` into the three categories riders think in. Type 0 (tram/ + * light rail, e.g. MBTA's Green Line) is bucketed with Subway rather than broken out separately, + * since that's how riders colloquially refer to it. + */ +enum class LineType(val gtfsRouteTypes: Set, val label: String) { + SUBWAY(setOf(0, 1), "Subway"), + COMMUTER_RAIL(setOf(2), "Commuter Rail"), + BUS(setOf(3), "Bus"); + + companion object { + fun forGtfsRouteType(routeType: Int): LineType? = entries.find { routeType in it.gtfsRouteTypes } + } +} + +data class DirectionOption(val directionId: Int, val headsign: String?) + +data class StopOption( + val stopId: String, + val stopName: String?, + val lat: Double? = null, + val lon: Double? = null, +) + +data class StopWithDistance( + val stopId: String, + val stopName: String?, + val lat: Double, + val lon: Double, + val distanceMeters: Double, +) + +data class Departure( + val tripId: String, + val departureTime: String, + val headsign: String?, + val stopSequence: Int, +) + +data class TripStopRow( + val stopSequence: Int, + val stopId: String, + val stopName: String?, + val arrivalTime: String?, + val departureTime: String?, +) + +data class StopConnection( + val tripId: String, + val stopSequence: Int, + val departureTime: String, + val route: RouteOption, + val direction: DirectionOption, +) + +data class StopLocation(val stopId: String, val stopName: String?, val lat: Double, val lon: Double) + +data class ScheduledArrival( + val tripId: String, + val stopSequence: Int, + val departureTime: String, + val route: RouteOption, + val direction: DirectionOption, +) + +/** + * Read-only access to one agency's ingested GTFS SQLite database. Every method issues a single + * targeted query and returns a small result list — never a full table — so screens query + * directly instead of holding parsed GTFS data in memory. + */ +class GtfsRepository(dbFile: File) { + private val db = SQLiteDatabase.openDatabase(dbFile.path, null, SQLiteDatabase.OPEN_READONLY) + + /** Which of [LineType] have at least one route in this feed — e.g. RIPTA has no subway/rail. */ + fun getAvailableLineTypes(): List = + db.rawQuery("SELECT DISTINCT route_type FROM routes", null).use { cursor -> + val presentTypes = cursor.mapRowsNotNull { getIntOrNull(0) }.toSet() + LineType.entries.filter { it.gtfsRouteTypes.any { type -> type in presentTypes } } + } + + fun getRoutes(lineType: LineType): List { + val placeholders = lineType.gtfsRouteTypes.joinToString(",") { "?" } + return db.rawQuery( + """ + SELECT DISTINCT route_id, route_short_name, route_long_name, route_type + FROM routes + WHERE route_type IN ($placeholders) + ORDER BY route_id + """, + lineType.gtfsRouteTypes.map { it.toString() }.toTypedArray(), + ).use { cursor -> + cursor.mapRows { + RouteOption( + routeId = getString(0), + shortName = getStringOrNull(1), + longName = getStringOrNull(2), + routeType = getInt(3), + ) + } + } + } + + fun getDirections(routeId: String): List = + db.rawQuery( + "SELECT DISTINCT direction_id, trip_headsign FROM trips WHERE route_id = ?", + arrayOf(routeId), + ).use { cursor -> + cursor.mapRowsNotNull { + val directionId = getIntOrNull(0) ?: return@mapRowsNotNull null + DirectionOption(directionId, getStringOrNull(1)) + } + } + + /** + * Every distinct stop served by any trip on [routeId]+[directionId], ordered by each stop's + * earliest stop_sequence across those trips — an approximation of physical route order, + * since GTFS doesn't guarantee stop_sequence numbering is identical across trip variants. + */ + fun getStops(routeId: String, directionId: Int): List = + db.rawQuery( + """ + SELECT st.stop_id, s.stop_name, s.stop_lat, s.stop_lon + FROM trips t + JOIN stop_times st ON st.trip_id = t.trip_id + JOIN stops s ON s.stop_id = st.stop_id + WHERE t.route_id = ? AND t.direction_id = ? + GROUP BY st.stop_id, s.stop_name, s.stop_lat, s.stop_lon + ORDER BY MIN(st.stop_sequence) + """, + arrayOf(routeId, directionId.toString()), + ).use { cursor -> + cursor.mapRows { + StopOption( + stopId = getString(0), + stopName = getStringOrNull(1), + lat = getDoubleOrNull(2), + lon = getDoubleOrNull(3), + ) + } + } + + /** + * Departures for [stopId] on [routeId]+[directionId], restricted to trips whose service_id + * is active on [today]: regularly scheduled per `calendar` (weekday flag + date range) minus + * any `calendar_dates` removal (exception_type 2), plus any `calendar_dates` addition + * (exception_type 1) regardless of the `calendar` row. + * + * [stopId] may occur anywhere in a trip's stop sequence now that stop selection isn't + * restricted to route termini; each result carries the matched stop_sequence so trip detail + * can filter to "from this stop onward" instead of assuming the trip starts there. + */ + fun getDepartures(routeId: String, directionId: Int, stopId: String, today: LocalDate): List { + val todayGtfs = today.toGtfsDateString() + val dayColumn = today.dayOfWeek.toGtfsColumnName() + + val sql = """ + SELECT st.departure_time, t.trip_id, t.trip_headsign, st.stop_sequence + FROM trips t + JOIN stop_times st ON st.trip_id = t.trip_id + WHERE t.route_id = ? AND t.direction_id = ? AND st.stop_id = ? + AND ${activeTodayClause(dayColumn)} + ORDER BY st.departure_time + """.trimIndent() + + return db.rawQuery( + sql, + arrayOf(routeId, directionId.toString(), stopId, todayGtfs, todayGtfs, todayGtfs), + ).use { cursor -> + cursor.mapRows { + Departure( + departureTime = getString(0), + tripId = getString(1), + headsign = getStringOrNull(2), + stopSequence = getInt(3), + ) + } + } + } + + fun getTripStops(tripId: String, fromStopSequence: Int): List = + db.rawQuery( + """ + SELECT st.stop_sequence, st.stop_id, s.stop_name, st.arrival_time, st.departure_time + FROM stop_times st + JOIN stops s ON s.stop_id = st.stop_id + WHERE st.trip_id = ? AND st.stop_sequence >= ? + ORDER BY st.stop_sequence + """, + arrayOf(tripId, fromStopSequence.toString()), + ).use { cursor -> + cursor.mapRows { + TripStopRow( + stopSequence = getInt(0), + stopId = getString(1), + stopName = getStringOrNull(2), + arrivalTime = getStringOrNull(3), + departureTime = getStringOrNull(4), + ) + } + } + + /** + * The next scheduled departures from [stopId] after [afterTime], across every route and + * direction serving that stop (not just the one [excludeTripId] belongs to) — used to show + * connecting service from a stop selected on a trip's detail screen. Restricted to trips + * active on [today] using the same calendar/calendar_dates logic as [getDepartures]. + */ + fun getNextConnections( + stopId: String, + afterTime: String, + excludeTripId: String, + today: LocalDate, + ): List { + val todayGtfs = today.toGtfsDateString() + val dayColumn = today.dayOfWeek.toGtfsColumnName() + + val sql = """ + SELECT st.departure_time, t.trip_id, st.stop_sequence, + r.route_id, r.route_short_name, r.route_long_name, r.route_type, + t.direction_id, t.trip_headsign + FROM trips t + JOIN stop_times st ON st.trip_id = t.trip_id + JOIN routes r ON r.route_id = t.route_id + WHERE st.stop_id = ? AND st.departure_time > ? AND t.trip_id != ? + AND ${activeTodayClause(dayColumn)} + ORDER BY st.departure_time + """.trimIndent() + + return db.rawQuery( + sql, + arrayOf(stopId, afterTime, excludeTripId, todayGtfs, todayGtfs, todayGtfs), + ).use { cursor -> + cursor.mapRowsNotNull { + val directionId = getIntOrNull(7) ?: return@mapRowsNotNull null + StopConnection( + departureTime = getString(0), + tripId = getString(1), + stopSequence = getInt(2), + route = RouteOption( + routeId = getString(3), + shortName = getStringOrNull(4), + longName = getStringOrNull(5), + routeType = getInt(6), + ), + direction = DirectionOption(directionId, getStringOrNull(8)), + ) + } + } + } + + /** Every stop with valid coordinates, for nearest-stop distance ranking. */ + fun getStopsWithLocation(): List = + db.rawQuery( + "SELECT stop_id, stop_name, stop_lat, stop_lon FROM stops WHERE stop_lat IS NOT NULL AND stop_lon IS NOT NULL", + null, + ).use { cursor -> + cursor.mapRows { + StopLocation( + stopId = getString(0), + stopName = getStringOrNull(1), + lat = getDouble(2), + lon = getDouble(3), + ) + } + } + + /** A single stop's coordinates, for the ETA radar screen's bearing/distance math. */ + fun getStopLocation(stopId: String): StopLocation? = + db.rawQuery( + "SELECT stop_id, stop_name, stop_lat, stop_lon FROM stops WHERE stop_id = ? AND stop_lat IS NOT NULL AND stop_lon IS NOT NULL", + arrayOf(stopId), + ).use { cursor -> + cursor.mapRows { + StopLocation( + stopId = getString(0), + stopName = getStringOrNull(1), + lat = getDouble(2), + lon = getDouble(3), + ) + }.firstOrNull() + } + + /** + * Every route+direction scheduled to serve [stopId] at or after [afterTime] today, across + * every route (not just one), active-today-filtered the same way as [getDepartures]. This is + * the static half of the "Leave Now" upcoming-arrivals screen; the caller merges it with + * GTFS-RT predictions where available. + */ + fun getScheduledArrivals(stopId: String, afterTime: String, today: LocalDate): List { + val todayGtfs = today.toGtfsDateString() + val dayColumn = today.dayOfWeek.toGtfsColumnName() + + val sql = """ + SELECT st.departure_time, t.trip_id, st.stop_sequence, + r.route_id, r.route_short_name, r.route_long_name, r.route_type, + t.direction_id, t.trip_headsign + FROM trips t + JOIN stop_times st ON st.trip_id = t.trip_id + JOIN routes r ON r.route_id = t.route_id + WHERE st.stop_id = ? AND st.departure_time >= ? + AND ${activeTodayClause(dayColumn)} + ORDER BY st.departure_time + """.trimIndent() + + return db.rawQuery( + sql, + arrayOf(stopId, afterTime, todayGtfs, todayGtfs, todayGtfs), + ).use { cursor -> + cursor.mapRowsNotNull { + val directionId = getIntOrNull(7) ?: return@mapRowsNotNull null + ScheduledArrival( + departureTime = getString(0), + tripId = getString(1), + stopSequence = getInt(2), + route = RouteOption( + routeId = getString(3), + shortName = getStringOrNull(4), + longName = getStringOrNull(5), + routeType = getInt(6), + ), + direction = DirectionOption(directionId, getStringOrNull(8)), + ) + } + } + } + + /** + * The stop immediately before [beforeStopSequence] in [tripId]'s stop_sequence, if any. Used + * to approximate a scheduled (non-live) bus's approach bearing when no VehiclePosition exists + * for it — see EtaRadarScreen. Returns null for a trip's first stop (nothing precedes it). + */ + fun getPreviousStopLocation(tripId: String, beforeStopSequence: Int): StopLocation? = + db.rawQuery( + """ + SELECT s.stop_id, s.stop_name, s.stop_lat, s.stop_lon + FROM stop_times st + JOIN stops s ON s.stop_id = st.stop_id + WHERE st.trip_id = ? AND st.stop_sequence < ? + AND s.stop_lat IS NOT NULL AND s.stop_lon IS NOT NULL + ORDER BY st.stop_sequence DESC + LIMIT 1 + """, + arrayOf(tripId, beforeStopSequence.toString()), + ).use { cursor -> + cursor.mapRows { + StopLocation( + stopId = getString(0), + stopName = getStringOrNull(1), + lat = getDouble(2), + lon = getDouble(3), + ) + }.firstOrNull() + } + + /** + * Every stop with coordinates, ranked by distance from ([anchorLat], [anchorLon]), nearest + * first, capped at [limit]. Shared by the "Leave Now" nearby-stops flow (anchored at a + * geocoded search point) and the ETA radar's nearby-stops overlay (anchored at the selected + * stop), so both draw from the same ranking logic rather than duplicating it. + */ + fun rankStopsByDistance( + anchorLat: Double, + anchorLon: Double, + limit: Int, + excludeStopId: String? = null, + ): List = + getStopsWithLocation() + .asSequence() + .filter { it.stopId != excludeStopId } + .map { stop -> + StopWithDistance( + stopId = stop.stopId, + stopName = stop.stopName, + lat = stop.lat, + lon = stop.lon, + distanceMeters = haversineMeters(anchorLat, anchorLon, stop.lat, stop.lon), + ) + } + .sortedBy { it.distanceMeters } + .take(limit) + .toList() + + fun close() { + db.close() + } +} + +private const val EARTH_RADIUS_METERS = 6_371_000.0 + +/** Great-circle distance between two lat/lon points, in meters. */ +fun haversineMeters(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double { + val dLat = Math.toRadians(lat2 - lat1) + val dLon = Math.toRadians(lon2 - lon1) + val a = kotlin.math.sin(dLat / 2).let { it * it } + + kotlin.math.cos(Math.toRadians(lat1)) * kotlin.math.cos(Math.toRadians(lat2)) * + kotlin.math.sin(dLon / 2).let { it * it } + val c = 2 * kotlin.math.atan2(kotlin.math.sqrt(a), kotlin.math.sqrt(1 - a)) + return EARTH_RADIUS_METERS * c +} + +/** + * True when a trip's service_id (aliased `t` in the enclosing query) is active on the date bound + * to the three `?` placeholders this fragment introduces: regularly scheduled per `calendar` + * (weekday flag + date range) minus any `calendar_dates` removal (exception_type 2), plus any + * `calendar_dates` addition (exception_type 1) regardless of the `calendar` row. + */ +private fun activeTodayClause(dayColumn: String): String = """ + ( + ( + EXISTS ( + SELECT 1 FROM calendar c + WHERE c.service_id = t.service_id + AND c.$dayColumn = 1 + AND ? BETWEEN c.start_date AND c.end_date + ) + AND NOT EXISTS ( + SELECT 1 FROM calendar_dates cd + WHERE cd.service_id = t.service_id AND cd.date = ? AND cd.exception_type = 2 + ) + ) + OR EXISTS ( + SELECT 1 FROM calendar_dates cd + WHERE cd.service_id = t.service_id AND cd.date = ? AND cd.exception_type = 1 + ) + ) +""".trimIndent() + +fun todayForGtfs(): LocalDate = LocalDate.now() + +/** Current wall-clock time as a GTFS "HH:MM:SS" string, for bounding "departures from now on". */ +fun currentGtfsTimeOfDay(): String { + val now = java.time.LocalTime.now() + return "%02d:%02d:%02d".format(now.hour, now.minute, now.second) +} + +private fun LocalDate.toGtfsDateString(): String = "%04d%02d%02d".format(year, monthValue, dayOfMonth) + +private fun DayOfWeek.toGtfsColumnName(): String = when (this) { + DayOfWeek.MONDAY -> "monday" + DayOfWeek.TUESDAY -> "tuesday" + DayOfWeek.WEDNESDAY -> "wednesday" + DayOfWeek.THURSDAY -> "thursday" + DayOfWeek.FRIDAY -> "friday" + DayOfWeek.SATURDAY -> "saturday" + DayOfWeek.SUNDAY -> "sunday" +} + +private fun Cursor.getStringOrNull(index: Int): String? = if (isNull(index)) null else getString(index) + +private fun Cursor.getIntOrNull(index: Int): Int? = if (isNull(index)) null else getInt(index) + +private fun Cursor.getDoubleOrNull(index: Int): Double? = if (isNull(index)) null else getDouble(index) + +private inline fun Cursor.mapRows(transform: Cursor.() -> T): List { + val results = mutableListOf() + while (moveToNext()) { + results += transform() + } + return results +} + +private inline fun Cursor.mapRowsNotNull(transform: Cursor.() -> T?): List { + val results = mutableListOf() + while (moveToNext()) { + transform()?.let { results += it } + } + return results +} diff --git a/tool/src/main/kotlin/com/thelightphone/transit/gtfs/GtfsSchema.kt b/tool/src/main/kotlin/com/thelightphone/transit/gtfs/GtfsSchema.kt new file mode 100644 index 00000000..85f750bf --- /dev/null +++ b/tool/src/main/kotlin/com/thelightphone/transit/gtfs/GtfsSchema.kt @@ -0,0 +1,112 @@ +package com.thelightphone.transit.gtfs + +import android.database.sqlite.SQLiteDatabase +import java.io.File + +/** + * Mirrors the subset of the GTFS static spec this app ingests. trip_id, route_id, stop_id, and + * service_id are the join/filter keys every later screen uses, so each gets an explicit index + * except where it's already the leading column of a table's primary key. + */ +private object GtfsSchema { + val STATEMENTS = listOf( + """ + CREATE TABLE IF NOT EXISTS routes ( + route_id TEXT PRIMARY KEY, + agency_id TEXT, + route_short_name TEXT, + route_long_name TEXT, + route_desc TEXT, + route_type INTEGER, + route_url TEXT, + route_color TEXT, + route_text_color TEXT + ) + """, + """ + CREATE TABLE IF NOT EXISTS trips ( + trip_id TEXT PRIMARY KEY, + route_id TEXT NOT NULL, + service_id TEXT NOT NULL, + trip_headsign TEXT, + trip_short_name TEXT, + direction_id INTEGER, + block_id TEXT, + shape_id TEXT, + wheelchair_accessible INTEGER, + bikes_allowed INTEGER + ) + """, + "CREATE INDEX IF NOT EXISTS idx_trips_route_id ON trips(route_id)", + "CREATE INDEX IF NOT EXISTS idx_trips_service_id ON trips(service_id)", + """ + CREATE TABLE IF NOT EXISTS stops ( + stop_id TEXT PRIMARY KEY, + stop_code TEXT, + stop_name TEXT, + stop_desc TEXT, + stop_lat REAL, + stop_lon REAL, + zone_id TEXT, + stop_url TEXT, + location_type INTEGER, + parent_station TEXT, + wheelchair_boarding INTEGER + ) + """, + """ + CREATE TABLE IF NOT EXISTS stop_times ( + trip_id TEXT NOT NULL, + stop_sequence INTEGER NOT NULL, + arrival_time TEXT, + departure_time TEXT, + stop_id TEXT NOT NULL, + stop_headsign TEXT, + pickup_type INTEGER, + drop_off_type INTEGER, + shape_dist_traveled REAL, + PRIMARY KEY (trip_id, stop_sequence) + ) WITHOUT ROWID + """, + "CREATE INDEX IF NOT EXISTS idx_stop_times_stop_id ON stop_times(stop_id)", + """ + CREATE TABLE IF NOT EXISTS calendar ( + service_id TEXT PRIMARY KEY, + monday INTEGER, + tuesday INTEGER, + wednesday INTEGER, + thursday INTEGER, + friday INTEGER, + saturday INTEGER, + sunday INTEGER, + start_date TEXT, + end_date TEXT + ) + """, + """ + CREATE TABLE IF NOT EXISTS calendar_dates ( + service_id TEXT NOT NULL, + date TEXT NOT NULL, + exception_type INTEGER NOT NULL, + PRIMARY KEY (service_id, date) + ) WITHOUT ROWID + """, + "CREATE INDEX IF NOT EXISTS idx_calendar_dates_service_id ON calendar_dates(service_id)", + ) +} + +/** + * Opens (creating if needed) the SQLite database backing one agency's ingested GTFS data. + * + * Uses [SQLiteDatabase.openOrCreateDatabase]'s file-based entry point rather than + * [android.database.sqlite.SQLiteOpenHelper] or Room, because both of those require an + * android.content.Context, which isn't reachable from tool code (SealedLightContext keeps + * its Context internal, and importing android.content.Context directly is blocked by the SDK + * build plugin). + */ +fun openGtfsDatabase(dbFile: File): SQLiteDatabase { + dbFile.parentFile?.mkdirs() + val db = SQLiteDatabase.openOrCreateDatabase(dbFile, null) + GtfsSchema.STATEMENTS.forEach { db.execSQL(it) } + return db +} diff --git a/tool/src/main/kotlin/com/thelightphone/transit/gtfs/GtfsTime.kt b/tool/src/main/kotlin/com/thelightphone/transit/gtfs/GtfsTime.kt new file mode 100644 index 00000000..269c7a1a --- /dev/null +++ b/tool/src/main/kotlin/com/thelightphone/transit/gtfs/GtfsTime.kt @@ -0,0 +1,23 @@ +package com.thelightphone.transit.gtfs + +/** + * Formats a raw GTFS "HH:MM:SS" time as a 12-hour "H:MM AM/PM" string for display. + * + * GTFS hours aren't clamped to 0-23 — a trip departing after midnight on its service day is + * written as e.g. "25:15:00" rather than "01:15:00", so the hour is taken mod 24 before + * converting to 12-hour form. Returns [raw] unchanged if it isn't well-formed. + */ +fun formatGtfsTime(raw: String?): String { + if (raw == null) return "--:--" + val parts = raw.split(":") + val rawHour = parts.getOrNull(0)?.toIntOrNull() ?: return raw + val minute = parts.getOrNull(1)?.toIntOrNull() ?: return raw + + val hour24 = rawHour % 24 + val period = if (hour24 < 12) "AM" else "PM" + val hour12 = when (val h = hour24 % 12) { + 0 -> 12 + else -> h + } + return "%d:%02d %s".format(hour12, minute, period) +} diff --git a/tool/src/main/kotlin/com/thelightphone/transit/gtfs/IpGeolocation.kt b/tool/src/main/kotlin/com/thelightphone/transit/gtfs/IpGeolocation.kt new file mode 100644 index 00000000..2df77e6d --- /dev/null +++ b/tool/src/main/kotlin/com/thelightphone/transit/gtfs/IpGeolocation.kt @@ -0,0 +1,60 @@ +package com.thelightphone.transit.gtfs + +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.engine.okhttp.OkHttp +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.client.request.get +import io.ktor.http.isSuccess +import io.ktor.serialization.kotlinx.json.json +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json + +private const val IP_GEOLOCATION_URL = "https://ipwho.is/" + +@Serializable +private data class IpWhoIsResponse( + val success: Boolean = false, + val city: String? = null, + val region: String? = null, + val latitude: Double? = null, + val longitude: Double? = null, +) + +class IpGeolocationException(message: String, cause: Throwable? = null) : Exception(message, cause) + +/** + * Approximates the device's location from its public IP address — no location permission or GPS + * access needed, since it's a plain network request, unlike device GPS which tool code can't + * reach at all (see GtfsRepository/GtfsAgency notes on that). Accuracy is typically city/metro + * level on a mobile carrier network, not street-level, so this is meant to pre-fill a starting + * point for the user to confirm or edit, not to stand in for a real address. + */ +class IpGeolocator { + private val client = HttpClient(OkHttp) { + install(ContentNegotiation) { + json(Json { ignoreUnknownKeys = true }) + } + } + + suspend fun locate(): GeocodeResult { + val response = client.get(IP_GEOLOCATION_URL) + if (!response.status.isSuccess()) { + throw IpGeolocationException("IP geolocation failed: HTTP ${response.status.value}") + } + val body: IpWhoIsResponse = response.body() + val lat = body.latitude + val lon = body.longitude + if (!body.success || lat == null || lon == null) { + throw IpGeolocationException("IP geolocation did not return a location") + } + val displayName = listOfNotNull(body.city, body.region) + .joinToString(", ") + .ifBlank { "Approximate location" } + return GeocodeResult(displayName = displayName, lat = lat, lon = lon) + } + + fun close() { + client.close() + } +} diff --git a/tool/src/main/kotlin/com/thelightphone/transit/gtfs/NominatimGeocoder.kt b/tool/src/main/kotlin/com/thelightphone/transit/gtfs/NominatimGeocoder.kt new file mode 100644 index 00000000..eaf934cb --- /dev/null +++ b/tool/src/main/kotlin/com/thelightphone/transit/gtfs/NominatimGeocoder.kt @@ -0,0 +1,84 @@ +package com.thelightphone.transit.gtfs + +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.engine.okhttp.OkHttp +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.client.request.get +import io.ktor.client.request.header +import io.ktor.client.request.parameter +import io.ktor.http.isSuccess +import io.ktor.serialization.kotlinx.json.json +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json + +private const val NOMINATIM_SEARCH_URL = "https://nominatim.openstreetmap.org/search" +private const val NOMINATIM_REVERSE_URL = "https://nominatim.openstreetmap.org/reverse" +private const val RESULT_LIMIT = 5 + +/** + * Nominatim's usage policy requires a real identifying User-Agent (generic/default client agents + * get blocked) and asks for no more than ~1 request/second — both trivially satisfied by a single + * user-triggered search. Results are OpenStreetMap data (ODbL) and must be attributed in the UI. + */ +private const val NOMINATIM_USER_AGENT = "LightTransitTool/1.0 (+https://github.com/lightphone)" + +@Serializable +private data class NominatimResult( + val lat: String, + val lon: String, + @kotlinx.serialization.SerialName("display_name") val displayName: String, +) + +@Serializable +private data class NominatimAddress(val road: String? = null) + +@Serializable +private data class NominatimReverseResult(val address: NominatimAddress? = null) + +data class GeocodeResult(val displayName: String, val lat: Double, val lon: Double) + +class GeocodeException(message: String, cause: Throwable? = null) : Exception(message, cause) + +class NominatimGeocoder { + private val client = HttpClient(OkHttp) { + install(ContentNegotiation) { + json(Json { ignoreUnknownKeys = true }) + } + } + + suspend fun search(query: String): List { + val response = client.get(NOMINATIM_SEARCH_URL) { + header("User-Agent", NOMINATIM_USER_AGENT) + parameter("q", query) + parameter("format", "jsonv2") + parameter("limit", RESULT_LIMIT) + } + if (!response.status.isSuccess()) { + throw GeocodeException("Location search failed: HTTP ${response.status.value}") + } + val results: List = response.body() + return results.mapNotNull { result -> + val lat = result.lat.toDoubleOrNull() ?: return@mapNotNull null + val lon = result.lon.toDoubleOrNull() ?: return@mapNotNull null + GeocodeResult(displayName = result.displayName, lat = lat, lon = lon) + } + } + + /** Nearby street name for a point, e.g. for labeling a stop pin with its street context. */ + suspend fun reverseGeocode(lat: Double, lon: Double): String? { + val response = client.get(NOMINATIM_REVERSE_URL) { + header("User-Agent", NOMINATIM_USER_AGENT) + parameter("lat", lat) + parameter("lon", lon) + parameter("format", "jsonv2") + } + if (!response.status.isSuccess()) return null + val result: NominatimReverseResult = response.body() + return result.address?.road + } + + fun close() { + client.close() + } +} From e7f2209ffd09a90f83bfe7585e931ff45fe7da46 Mon Sep 17 00:00:00 2001 From: Christian Date: Thu, 30 Jul 2026 06:31:53 -0400 Subject: [PATCH 02/14] Reframe README to lead with what Transit does, not the phone Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_014sF9rJBmDSbG6LQKf2ua2h --- tool/README.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tool/README.md b/tool/README.md index dbdaf5fe..4b1d5403 100644 --- a/tool/README.md +++ b/tool/README.md @@ -1,6 +1,8 @@ -# 🚌 Transit — your pocket-sized transit sidekick for the Light Phone III +# 🚌 Transit -Hop on, hop off! Transit turns your Light Phone III into a tiny, delightfully un-distracting transit companion for MBTA and RIPTA. No ads, no infinite scroll — just "when's my bus" and "where the heck is it right now." 🚏✨ +Transit is a friendly little companion for getting around on public transit. Real schedules, real-time arrivals, live connections at any stop, and a tiny radar screen that shows you exactly which direction your ride is coming from — no ads, no clutter, no infinite scroll. Just "when's my bus," answered nicely. 🚏✨ + +Right now Transit knows its way around **MBTA** and **RIPTA**, with more agencies hopefully hopping aboard down the road. It's built on the [Light SDK](../) for the Light Phone III, so it stays just as calm and un-distracting as the rest of your Light experience. ## 🗺️ What can it do? From 498b5a892748d692bc548c493f6208704c3ebd5a Mon Sep 17 00:00:00 2001 From: CJFData Date: Thu, 30 Jul 2026 06:50:27 -0400 Subject: [PATCH 03/14] Update README.md --- README.md | 85 ++++++++++++++++++++++--------------------------------- 1 file changed, 34 insertions(+), 51 deletions(-) diff --git a/README.md b/README.md index 8c1b89ab..4b1d5403 100644 --- a/README.md +++ b/README.md @@ -1,64 +1,47 @@ -# light-sdk -or: a tool for building Tools +# 🚌 Transit -## tl;dr -This repository contains the scaffolding for building simple tools for the Light Phone III. Included are a library ([:sdk:client](./sdk/client)) and placeholder application ([:tool](./tool)) that depends on it. To create a tool that is fully compatible with LightOS, you must write your application code within the `tool` module, using the primitives provided by the sdk client library. +Transit is a friendly little companion for getting around on public transit. Real schedules, real-time arrivals, live connections at any stop, and a tiny radar screen that shows you exactly which direction your ride is coming from — no ads, no clutter, no infinite scroll. Just "when's my bus," answered nicely. 🚏✨ -You can and should use current Android best practices: Kotlin for all source code, Compose for UI, Coroutines for async programming, and MVVM architecture. **Although this is appears to be a fairly standard Android dev environment, you will quickly find out that we are (gently but broadly) restricting which Android APIs and third-party libraries can be used. This is in an effort to provide a secure and distinctly _light_ experience for our users. These restrictions are _not_ set in stone and should ease up over time. If there is a stable, open-source library that you'd like us to allow, please let us know! More on this later.** +Right now Transit knows its way around **MBTA** and **RIPTA**, with more agencies hopefully hopping aboard down the road. It's built on the [Light SDK](../) for the Light Phone III, so it stays just as calm and un-distracting as the rest of your Light experience. -## IMPORTANT!! July 1, 2026 Update -If you're reading this, welcome! You're early! (in a cool way) -This repo is a work-in-progress and will remain so for a while. Things are going to change _fast_ in the coming weeks. If you're going to start building right away, be sure to `git pull` frequently. -Before you do, though, please be aware that **while we feel good about letting everybody start to explore and build, we are still working on the infrastructure to properly deploy your new tools.** -The currently builds of LightOS in the wild are not yet ready to "play nice" with the tools built here. If you're someone who's already comfortable working with ADB to sideload APKs on your -Light Phone III, you can totally do that with whatever you do here! But we're shooting to make these tools feel as seamless as the ones already available in LightOS, and that's going to take a bit more work. -We're hoping to have an update on that front later this month. In the meantime, the best way to start working is to use an Android emulator running our new [LightOS Emulator](sdk/emulator). The instructions for getting that up and running -are [right here](docs/system_app). +## 🗺️ What can it do? -## Quickstart -### Grabbing a token -We're currently hosting our library builds with GitHub Packages so each artifact can live beside its source. The tradeoff is that you'll need to add a GitHub token with package read access to your local build environment. **We are considering migrating to Maven Central to avoid this requirement when everything goes public.** -For now, you can either add environment variables with your username and token: -``` -GITHUB_ACTOR=your_username -GITHUB_TOKEN=your_token -``` -or you can add them to your `local.properties` file: -``` -gpr.user=your_username -gpr.key=your_token -``` +- 🏠 **Pick your agency** — MBTA or RIPTA — and Transit downloads their schedule right onto your phone. +- 📅 **Explore Schedules** — browse by Subway 🚇, Commuter Rail 🚆, or Bus 🚌, pick a route, a direction, and a stop, and see every departure today. +- 🔗 **Connections** — tap any stop along a trip to see what else comes through there next. Great for planning a transfer on the fly. +- 📍 **Leave Now** — type where you are (or let Transit take a quick IP-based guess 🛰️) and get the 20 closest stops, nearest first. +- ⏱️ **Live ETAs** — real-time predictions with On Time / Late / Early badges, whenever the agency's live feed is playing along nicely. +- 🧭 **ETA Radar** — a little live radar screen showing exactly which direction your bus (and nearby stops!) are coming from, right now. -### Running your Tool -**You can test your tool on any Android device or emulator**, but certain functionality (receiving push notifications, requesting special permissions) can only be tested with: -A) Real Light Phone hardware running LightOS -B) An Android emulator (on your computer) set up to run our LightOS emulator app as a _system app_ ([see advanced instructions](docs/system_app)) +## 🛠️ Building & running it -You can quickly [create an emulator](https://developer.android.com/studio/run/managing-avds) that generally feels like an LPIII by using the following settings: -* 1080 X 1240, 3.92" display -* Android API 34 -* NO Google Play Services installed +Transit lives inside the [light-sdk](../) monorepo — check the [root README](../README.md) first for one-time setup (GitHub token, Android Studio, etc). Once that's done: -### Start Building -1. Fork and/or clone this repository into your local dev environment. -2. Install Android Studio and open this project within it. (IntelliJ IDEA should also work) +1. Open the whole `light-sdk` project in Android Studio. +2. Run the `:tool` module on an emulator, or better yet, [the LightOS emulator](../docs/system_app) — that's this app! 🎉 +3. Tap an agency, grab a coffee ☕ while it downloads the schedule, and you're off. -3. Edit the code in `HomeScreen` and `HomeScreenViewModel` to get started. `Homescreen` surfaces a `@Composable` method named `Content`. This is the UI that is shown when the tool first boots. You'll notice this UI sources data from it's `viewModel` field, which is an instance of `HomeScreenViewModel`. Edit that class with your screen's logic and expose the data to the UI using either Compose `State` or Coroutine `Flow`s. If you want to create a new screen, create a new Screen/ViewModel pair: your screen should extend from `LightScreen` and your VM from `LightScreenViewModel`. Your screen implementation will need: - 1. A direct reference to your ViewModel's class type - 2. A factory method for creating a new instance of your ViewModel. +## 📱 Getting it onto a *real* Light Phone III -Look at `HomeScreen` as an example for how this is done. To navigate to your new screen, use the `navigateTo` function built into `LightScreen` - just pass it a lambda to create an instance of your new screen. Note that the `LightScreen` constructor takes in a `SealedLightActivity`. The lambda is provided an instance of this as a default parameter. +Light's official "build it, sign it, share it" pipeline for community tools isn't quite ready yet — vetting is expected around August/September 2026, with the full sharing platform following in October. So for now, sideloading via ADB is the way, and Light's own docs say that's totally fine for the adventurous! 🤠 -Since LightOS does not use Android system navigation, we provide a back button for you. As long as you use `navigateTo` to move between screens, our back button should work great. If need be, you can override the `onBackPressed` method in your `LightViewModel`. +1. In [`lighttool.toml`](./lighttool.toml), point `serverPackage` at the real LightOS package instead of the emulator: + ```toml + serverPackage = "com.lightos" + ``` +2. Build a debug APK: + ```bash + ./gradlew :tool:assembleDebug + ``` +3. Turn on Developer Options + USB debugging on your Light Phone III (same as any Android device), plug it in, then: + ```bash + adb install -r tool/build/outputs/apk/debug/tool-debug.apk + ``` +4. On the phone, allow "Any tools" in LightOS's tool settings — it'll warn you this one isn't Light-vetted yet, which is expected for a homemade build like this. 🚧 -### Sharing Your Tool -**As of July 1, 2026, there's no "easy" way to share your tool with a Light Phone III user. We're working hard on that. This is how we believe it's going to look.** +That's it — happy transit-ing! 🚏🚌🚆 -Given our relatively limited resources and desire to keep our users safe, we're requiring that all community tools be open source (including our own!). We will be building and signing these tools directly from a publicly available git commit, and we'll be archiving the source at build time. You're free to build and share privately, but LightOS won't let you install tools that are not signed by us without acknowledging privacy and performance risks. We won't block users from performing these "dangerous" sideloads, but we're not going to encourage it either. In the near future, you'll be able to queue up a build of your tool on our servers, and if it follows our guidelines and compiles cleanly, we will hand you back a signed, shareable APK. +## 🧪 A couple of nerdy notes -Once we release a version of LightOS that supports community tools, users will have an option to choose what kind of tools they want to be able to run on their device: -- **Light-approved tools**: These include tools that are either built internally by the Light team, or built by the community and officially tested/signed-off by the Light team. We don't know _exactly_ what that sign-off process is going to look like, but as a heads-up: we're going to be looking pretty hard at whether a submitted tool matches the Light ethos both functionally and aesthetically. We've included a UX/UI library to make this as easy as possible! From a technical standpoint, these approved tools are both signed by us _and_ added to an "allow-list" within LightOS. Phones with this option selected will only install and display tools that meet both criteria. -- **SDK-built tools**: This is a slightly more permissive choice. Phones with this option selected will install and launch any tool that was built and signed by Light. These don't require any manual approval by us (though we can block them in extreme cases). If a user wants to be able to install a tool that was shared locally or somewhere outside of Light's dashboard, but they still want to be confident that it will run well and integrate nicely with LightOS, they might choose this option! -- **Any tools**: A user will have the option to make any APK launchable from LightOS, but they will own the responsibility of getting them un/installed. When a user selects this option, we will be warning them that they are potentially opening their device up to security risks, and in doing so will limit our ability to support them if something goes wrong. - -## [Complete Documentation](./docs) +- **RIPTA's live feeds are HTTP-only** (no HTTPS), which Android blocks by default. There's a small, clearly-labeled `:netconfig` module that grants just that one narrow exception — see its own `build.gradle.kts` for exactly what it does and how to remove it if you'd rather stay HTTPS-only everywhere. +- **No device GPS is used anywhere** — the SDK doesn't expose it to tools yet. Nearby-stop and location search are powered by Nominatim (OpenStreetMap) and IP-based geolocation instead. Be kind to their free APIs! 🙏 From e2d1f4e78cd301ab78524fa46d677d2493c2965f Mon Sep 17 00:00:00 2001 From: CJFData Date: Thu, 30 Jul 2026 22:52:56 -0400 Subject: [PATCH 04/14] Rename app from Transit to Pico Transit Updated the app name from 'Transit' to 'Pico Transit' in README. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 4b1d5403..c3226dfc 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# 🚌 Transit +# 🚌 Pico Transit Transit is a friendly little companion for getting around on public transit. Real schedules, real-time arrivals, live connections at any stop, and a tiny radar screen that shows you exactly which direction your ride is coming from — no ads, no clutter, no infinite scroll. Just "when's my bus," answered nicely. 🚏✨ From 9318fd58b7c454c768a6ce682b7e58bff02a1d20 Mon Sep 17 00:00:00 2001 From: Christian Date: Fri, 31 Jul 2026 01:55:00 -0400 Subject: [PATCH 05/14] Rename ETA Radar screen to Map, drop now-unused GeoBearing helper Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_014sF9rJBmDSbG6LQKf2ua2h --- .../transit/{EtaRadarScreen.kt => MapScreen.kt} | 0 .../com/thelightphone/transit/gtfs/GeoBearing.kt | 13 ------------- 2 files changed, 13 deletions(-) rename tool/src/main/kotlin/com/thelightphone/transit/{EtaRadarScreen.kt => MapScreen.kt} (100%) delete mode 100644 tool/src/main/kotlin/com/thelightphone/transit/gtfs/GeoBearing.kt diff --git a/tool/src/main/kotlin/com/thelightphone/transit/EtaRadarScreen.kt b/tool/src/main/kotlin/com/thelightphone/transit/MapScreen.kt similarity index 100% rename from tool/src/main/kotlin/com/thelightphone/transit/EtaRadarScreen.kt rename to tool/src/main/kotlin/com/thelightphone/transit/MapScreen.kt diff --git a/tool/src/main/kotlin/com/thelightphone/transit/gtfs/GeoBearing.kt b/tool/src/main/kotlin/com/thelightphone/transit/gtfs/GeoBearing.kt deleted file mode 100644 index 4cfc36b6..00000000 --- a/tool/src/main/kotlin/com/thelightphone/transit/gtfs/GeoBearing.kt +++ /dev/null @@ -1,13 +0,0 @@ -package com.thelightphone.transit.gtfs - -/** Initial bearing (degrees clockwise from true north, 0-360) from point 1 to point 2. */ -fun bearingDegrees(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double { - val lat1Rad = Math.toRadians(lat1) - val lat2Rad = Math.toRadians(lat2) - val dLonRad = Math.toRadians(lon2 - lon1) - val y = kotlin.math.sin(dLonRad) * kotlin.math.cos(lat2Rad) - val x = kotlin.math.cos(lat1Rad) * kotlin.math.sin(lat2Rad) - - kotlin.math.sin(lat1Rad) * kotlin.math.cos(lat2Rad) * kotlin.math.cos(dLonRad) - val bearing = Math.toDegrees(kotlin.math.atan2(y, x)) - return (bearing + 360) % 360 -} From b4c00ec466ac636bcfa23fc7040e61b5d53bca38 Mon Sep 17 00:00:00 2001 From: Christian Date: Fri, 31 Jul 2026 01:58:05 -0400 Subject: [PATCH 06/14] Rework Map screen, add back navigation, settings, and default agency - Map screen: dark CARTO tiles, density-based zoom-to-fit, mode-specific vehicle emojis, tap-to-reveal nearby stop labels, per-stop live vehicle cap, optional "Nearby Vehicles" toggle for secondary stops, edge-anchored compass, solid top scrim, and a custom touch handler so edge-swipe-back keeps working alongside marker taps. - Deduplicate GTFS stations via parent_station (shared by Map markers and stop search) instead of a stop_name heuristic, resolving to real child stop_id(s) for schedule lookups. - Add a back button (LightTopBar) to every screen but Home, including a Canvas-overlay treatment for the Map screen. - Add a Settings screen for picking a default agency (DataStore-backed), auto-selected on launch, with conditional GTFS re-download via HEAD/ETag/Last-Modified so a fresh feed isn't always re-fetched. - Rename the app display label to "Pico Transit". - Strip "via ..." clauses from direction labels and label live ETAs explicitly. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_014sF9rJBmDSbG6LQKf2ua2h --- tool/README.md | 12 +- tool/lighttool.toml | 2 +- .../transit/DepartureListScreen.kt | 14 +- .../transit/DirectionSelectionScreen.kt | 24 +- .../transit/FirstStopSelectionScreen.kt | 14 +- .../com/thelightphone/transit/HomeScreen.kt | 136 ++- .../transit/LineTypeSelectionScreen.kt | 15 +- .../com/thelightphone/transit/MapScreen.kt | 822 ++++++++++++------ .../transit/NearbyStopsScreen.kt | 21 +- .../transit/RouteSelectionScreen.kt | 14 +- .../transit/ScheduleLocationScreen.kt | 13 +- .../thelightphone/transit/SettingsScreen.kt | 110 +++ .../transit/StopConnectionsScreen.kt | 14 +- .../thelightphone/transit/TripDetailScreen.kt | 14 +- .../transit/UpcomingArrivalsScreen.kt | 18 +- .../transit/gtfs/AgencyPreferences.kt | 33 + .../transit/gtfs/GtfsIngestor.kt | 83 +- .../transit/gtfs/GtfsRealtime.kt | 42 +- .../transit/gtfs/GtfsRepository.kt | 170 +++- .../thelightphone/transit/gtfs/MapTiles.kt | 215 +++++ .../transit/gtfs/GtfsRepositoryDedupTest.kt | 86 ++ 21 files changed, 1430 insertions(+), 442 deletions(-) create mode 100644 tool/src/main/kotlin/com/thelightphone/transit/SettingsScreen.kt create mode 100644 tool/src/main/kotlin/com/thelightphone/transit/gtfs/AgencyPreferences.kt create mode 100644 tool/src/main/kotlin/com/thelightphone/transit/gtfs/MapTiles.kt create mode 100644 tool/src/test/kotlin/com/thelightphone/transit/gtfs/GtfsRepositoryDedupTest.kt diff --git a/tool/README.md b/tool/README.md index 4b1d5403..58c15e68 100644 --- a/tool/README.md +++ b/tool/README.md @@ -1,21 +1,21 @@ -# 🚌 Transit +# 🚌 Pico Transit -Transit is a friendly little companion for getting around on public transit. Real schedules, real-time arrivals, live connections at any stop, and a tiny radar screen that shows you exactly which direction your ride is coming from — no ads, no clutter, no infinite scroll. Just "when's my bus," answered nicely. 🚏✨ +Pico Transit is a friendly little companion for getting around on public transit. Real schedules, real-time arrivals, live connections at any stop, and a tiny radar screen that shows you exactly which direction your ride is coming from — no ads, no clutter, no infinite scroll. Just "when's my bus," answered nicely. 🚏✨ -Right now Transit knows its way around **MBTA** and **RIPTA**, with more agencies hopefully hopping aboard down the road. It's built on the [Light SDK](../) for the Light Phone III, so it stays just as calm and un-distracting as the rest of your Light experience. +Right now Pico Transit knows its way around **MBTA** and **RIPTA**, with more agencies hopefully hopping aboard down the road. It's built on the [Light SDK](../) for the Light Phone III, so it stays just as calm and un-distracting as the rest of your Light experience. ## 🗺️ What can it do? -- 🏠 **Pick your agency** — MBTA or RIPTA — and Transit downloads their schedule right onto your phone. +- 🏠 **Pick your agency** — MBTA or RIPTA — and Pico Transit downloads their schedule right onto your phone. - 📅 **Explore Schedules** — browse by Subway 🚇, Commuter Rail 🚆, or Bus 🚌, pick a route, a direction, and a stop, and see every departure today. - 🔗 **Connections** — tap any stop along a trip to see what else comes through there next. Great for planning a transfer on the fly. -- 📍 **Leave Now** — type where you are (or let Transit take a quick IP-based guess 🛰️) and get the 20 closest stops, nearest first. +- 📍 **Leave Now** — type where you are (or let Pico Transit take a quick IP-based guess 🛰️) and get the 20 closest stops, nearest first. - ⏱️ **Live ETAs** — real-time predictions with On Time / Late / Early badges, whenever the agency's live feed is playing along nicely. - 🧭 **ETA Radar** — a little live radar screen showing exactly which direction your bus (and nearby stops!) are coming from, right now. ## 🛠️ Building & running it -Transit lives inside the [light-sdk](../) monorepo — check the [root README](../README.md) first for one-time setup (GitHub token, Android Studio, etc). Once that's done: +Pico Transit lives inside the [light-sdk](../) monorepo — check the [root README](../README.md) first for one-time setup (GitHub token, Android Studio, etc). Once that's done: 1. Open the whole `light-sdk` project in Android Studio. 2. Run the `:tool` module on an emulator, or better yet, [the LightOS emulator](../docs/system_app) — that's this app! 🎉 diff --git a/tool/lighttool.toml b/tool/lighttool.toml index bbf3a32d..96805509 100644 --- a/tool/lighttool.toml +++ b/tool/lighttool.toml @@ -1,6 +1,6 @@ [tool] id = "com.thelightphone.transit" -label = "Transit" +label = "Pico Transit" versionCode = 1 versionName = "1.0.0" permissions = ["android.permission.INTERNET"] diff --git a/tool/src/main/kotlin/com/thelightphone/transit/DepartureListScreen.kt b/tool/src/main/kotlin/com/thelightphone/transit/DepartureListScreen.kt index 04bc596a..5211a585 100644 --- a/tool/src/main/kotlin/com/thelightphone/transit/DepartureListScreen.kt +++ b/tool/src/main/kotlin/com/thelightphone/transit/DepartureListScreen.kt @@ -22,11 +22,15 @@ import com.thelightphone.sdk.LightScreen import com.thelightphone.sdk.LightViewModel import com.thelightphone.sdk.SealedLightActivity import com.thelightphone.sdk.SimpleLightScreen +import com.thelightphone.sdk.ui.LightBarButton +import com.thelightphone.sdk.ui.LightIcons import com.thelightphone.sdk.ui.LightText import com.thelightphone.sdk.ui.LightTextVariant import com.thelightphone.sdk.ui.LightTheme import com.thelightphone.sdk.ui.LightThemeController import com.thelightphone.sdk.ui.LightThemeTokens +import com.thelightphone.sdk.ui.LightTopBar +import com.thelightphone.sdk.ui.LightTopBarCenter import com.thelightphone.sdk.ui.lightClickable import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow @@ -98,13 +102,12 @@ class DepartureListScreen( modifier = Modifier .fillMaxSize() .background(LightThemeTokens.colors.background) - .padding(32.dp) ) { - LightText( - text = "Departures", - variant = LightTextVariant.Heading, - modifier = Modifier.padding(bottom = 8.dp), + LightTopBar( + leftButton = LightBarButton.LightIcon(icon = LightIcons.BACK, onClick = { goBack() }), + center = LightTopBarCenter.Text("Departures"), ) + Column(modifier = Modifier.weight(1f).padding(32.dp)) { LightText( text = "$stopLabel - $routeLabel ($directionLabel)", variant = LightTextVariant.Detail, @@ -157,6 +160,7 @@ class DepartureListScreen( } } } + } } } } diff --git a/tool/src/main/kotlin/com/thelightphone/transit/DirectionSelectionScreen.kt b/tool/src/main/kotlin/com/thelightphone/transit/DirectionSelectionScreen.kt index 95914dce..a9cfe747 100644 --- a/tool/src/main/kotlin/com/thelightphone/transit/DirectionSelectionScreen.kt +++ b/tool/src/main/kotlin/com/thelightphone/transit/DirectionSelectionScreen.kt @@ -20,11 +20,15 @@ import com.thelightphone.sdk.LightScreen import com.thelightphone.sdk.LightViewModel import com.thelightphone.sdk.SealedLightActivity import com.thelightphone.sdk.SimpleLightScreen +import com.thelightphone.sdk.ui.LightBarButton +import com.thelightphone.sdk.ui.LightIcons import com.thelightphone.sdk.ui.LightText import com.thelightphone.sdk.ui.LightTextVariant import com.thelightphone.sdk.ui.LightTheme import com.thelightphone.sdk.ui.LightThemeController import com.thelightphone.sdk.ui.LightThemeTokens +import com.thelightphone.sdk.ui.LightTopBar +import com.thelightphone.sdk.ui.LightTopBarCenter import com.thelightphone.sdk.ui.lightClickable import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow @@ -38,8 +42,16 @@ sealed class DirectionSelectionState { data class Error(val message: String) : DirectionSelectionState() } +/** Matches MBTA-style headsigns like "Hospital District via CCRI Lincoln" -- the via-clause is + * routing detail, not part of the destination riders actually look for. */ +private val viaClauseRegex = Regex(""" via .*""", RegexOption.IGNORE_CASE) + fun DirectionOption.displayLabel(): String = - headsign?.takeIf { it.isNotBlank() }?.let { "Toward $it" } ?: "Direction $directionId" + headsign?.takeIf { it.isNotBlank() } + ?.replace(viaClauseRegex, "") + ?.trim() + ?.let { "Toward $it" } + ?: "Direction $directionId" class DirectionSelectionViewModel(dbFile: File, private val routeId: String) : LightViewModel() { @@ -88,13 +100,12 @@ class DirectionSelectionScreen( modifier = Modifier .fillMaxSize() .background(LightThemeTokens.colors.background) - .padding(32.dp) ) { - LightText( - text = "Choose Direction", - variant = LightTextVariant.Heading, - modifier = Modifier.padding(bottom = 8.dp), + LightTopBar( + leftButton = LightBarButton.LightIcon(icon = LightIcons.BACK, onClick = { goBack() }), + center = LightTopBarCenter.Text("Choose Direction"), ) + Column(modifier = Modifier.weight(1f).padding(32.dp)) { LightText( text = routeLabel, variant = LightTextVariant.Detail, @@ -147,6 +158,7 @@ class DirectionSelectionScreen( } } } + } } } } diff --git a/tool/src/main/kotlin/com/thelightphone/transit/FirstStopSelectionScreen.kt b/tool/src/main/kotlin/com/thelightphone/transit/FirstStopSelectionScreen.kt index 3a5dc692..a8c8d948 100644 --- a/tool/src/main/kotlin/com/thelightphone/transit/FirstStopSelectionScreen.kt +++ b/tool/src/main/kotlin/com/thelightphone/transit/FirstStopSelectionScreen.kt @@ -23,11 +23,15 @@ import com.thelightphone.sdk.LightScreen import com.thelightphone.sdk.LightViewModel import com.thelightphone.sdk.SealedLightActivity import com.thelightphone.sdk.SimpleLightScreen +import com.thelightphone.sdk.ui.LightBarButton +import com.thelightphone.sdk.ui.LightIcons import com.thelightphone.sdk.ui.LightText import com.thelightphone.sdk.ui.LightTextVariant import com.thelightphone.sdk.ui.LightTheme import com.thelightphone.sdk.ui.LightThemeController import com.thelightphone.sdk.ui.LightThemeTokens +import com.thelightphone.sdk.ui.LightTopBar +import com.thelightphone.sdk.ui.LightTopBarCenter import com.thelightphone.sdk.ui.lightClickable import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow @@ -109,13 +113,12 @@ class FirstStopSelectionScreen( modifier = Modifier .fillMaxSize() .background(LightThemeTokens.colors.background) - .padding(32.dp) ) { - LightText( - text = "Choose Stop", - variant = LightTextVariant.Heading, - modifier = Modifier.padding(bottom = 8.dp), + LightTopBar( + leftButton = LightBarButton.LightIcon(icon = LightIcons.BACK, onClick = { goBack() }), + center = LightTopBarCenter.Text("Choose Stop"), ) + Column(modifier = Modifier.weight(1f).padding(32.dp)) { LightText( text = "$routeLabel - $directionLabel", variant = LightTextVariant.Detail, @@ -184,6 +187,7 @@ class FirstStopSelectionScreen( } } } + } } } } diff --git a/tool/src/main/kotlin/com/thelightphone/transit/HomeScreen.kt b/tool/src/main/kotlin/com/thelightphone/transit/HomeScreen.kt index 40efbb82..8c92123b 100644 --- a/tool/src/main/kotlin/com/thelightphone/transit/HomeScreen.kt +++ b/tool/src/main/kotlin/com/thelightphone/transit/HomeScreen.kt @@ -2,6 +2,7 @@ package com.thelightphone.transit import android.util.Log import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -9,9 +10,11 @@ import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.lifecycle.viewModelScope +import com.thelightphone.transit.gtfs.AgencyPreferences import com.thelightphone.transit.gtfs.GtfsAgency import com.thelightphone.transit.gtfs.GtfsIngestStatus import com.thelightphone.transit.gtfs.GtfsIngestor @@ -20,6 +23,9 @@ import com.thelightphone.sdk.InitialScreen import com.thelightphone.sdk.LightScreen import com.thelightphone.sdk.LightViewModel import com.thelightphone.sdk.SealedLightActivity +import com.thelightphone.sdk.SimpleLightScreen +import com.thelightphone.sdk.ui.LightIcon +import com.thelightphone.sdk.ui.LightIcons import com.thelightphone.sdk.ui.LightText import com.thelightphone.sdk.ui.LightTextVariant import com.thelightphone.sdk.ui.LightTheme @@ -28,10 +34,14 @@ import com.thelightphone.sdk.ui.LightThemeTokens import com.thelightphone.sdk.ui.lightClickable import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import java.io.File -class HomeScreenViewModel(filesDir: File) : LightViewModel() { +class HomeScreenViewModel( + filesDir: File, + private val preferences: AgencyPreferences, +) : LightViewModel() { private val ingestor = GtfsIngestor(filesDir) @@ -41,6 +51,21 @@ class HomeScreenViewModel(filesDir: File) : LightViewModel() { /** Set once ingestion completes successfully; gates the "Explore Schedules" entry point. */ val readyAgency = MutableStateFlow(null) + override fun onScreenShow(screen: SimpleLightScreen) { + super.onScreenShow(screen) + // A configured default agency skips the manual tap -- the list stays visible/tappable in + // case someone wants a different agency for this session, this just saves the first tap. + // Only checked once per ViewModel (guarded by selectedAgency already being set), so + // returning to HomeScreen from a child screen doesn't re-trigger it. + if (selectedAgency.value != null) return + viewModelScope.launch(Dispatchers.IO) { + val default = preferences.defaultAgencyFlow.first() + if (default != null && selectedAgency.value == null) { + selectAgency(default) + } + } + } + fun selectAgency(agency: GtfsAgency) { selectedAgency.value = agency readyAgency.value = null @@ -50,6 +75,7 @@ class HomeScreenViewModel(filesDir: File) : LightViewModel() { try { ingestor.ingest(agency) { ingestStatus -> status.value = when (ingestStatus) { + GtfsIngestStatus.CheckingForUpdates -> "Checking for updates..." GtfsIngestStatus.Downloading -> "Downloading ${agency.displayName} data..." GtfsIngestStatus.Parsing -> "Parsing ${agency.displayName} data..." GtfsIngestStatus.Ready -> "Ready" @@ -71,7 +97,7 @@ class HomeScreen(sealedActivity: SealedLightActivity) : LightScreen + status?.let { LightText( - text = agency.displayName, + text = it, + variant = LightTextVariant.Detail, + lighten = true, + modifier = Modifier.padding(bottom = 16.dp), + ) + } + + Column { + GtfsAgency.entries.forEach { agency -> + LightText( + text = agency.displayName, + variant = LightTextVariant.Copy, + lighten = agency != selectedAgency, + modifier = Modifier + .fillMaxWidth() + .lightClickable { viewModel.selectAgency(agency) } + .padding(vertical = 12.dp), + ) + } + } + + readyAgency?.let { agency -> + LightText( + text = "Explore Schedules", + variant = LightTextVariant.Copy, + modifier = Modifier + .fillMaxWidth() + .lightClickable { + navigateTo(screenFactory = { activity -> + LineTypeSelectionScreen(activity, gtfsDbFile(lightContext.filesDir, agency)) + }) + } + .padding(top = 24.dp), + ) + LightText( + text = if (agency.realtimeTripUpdatesUrl == null) "Leave Now (Offline)" else "Leave Now", variant = LightTextVariant.Copy, - lighten = agency != selectedAgency, modifier = Modifier .fillMaxWidth() - .lightClickable { viewModel.selectAgency(agency) } - .padding(vertical = 12.dp), + .lightClickable { + navigateTo(screenFactory = { activity -> + NearbyStopsScreen(activity, gtfsDbFile(lightContext.filesDir, agency), agency) + }) + } + .padding(top = 12.dp), ) } } - readyAgency?.let { agency -> - LightText( - text = "Explore Schedules", - variant = LightTextVariant.Copy, - modifier = Modifier - .fillMaxWidth() - .lightClickable { - navigateTo(screenFactory = { activity -> - LineTypeSelectionScreen(activity, gtfsDbFile(lightContext.filesDir, agency)) - }) - } - .padding(top = 24.dp), - ) - LightText( - text = if (agency.realtimeTripUpdatesUrl == null) "Leave Now (Offline)" else "Leave Now", - variant = LightTextVariant.Copy, - modifier = Modifier - .fillMaxWidth() - .lightClickable { - navigateTo(screenFactory = { activity -> - NearbyStopsScreen(activity, gtfsDbFile(lightContext.filesDir, agency), agency) - }) - } - .padding(top = 12.dp), - ) - } + LightIcon( + icon = LightIcons.SETTINGS, + contentDescription = "Settings", + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(16.dp) + .lightClickable { + navigateTo(screenFactory = { activity -> SettingsScreen(activity) }) + }, + ) } } } diff --git a/tool/src/main/kotlin/com/thelightphone/transit/LineTypeSelectionScreen.kt b/tool/src/main/kotlin/com/thelightphone/transit/LineTypeSelectionScreen.kt index d31e3da0..450fc035 100644 --- a/tool/src/main/kotlin/com/thelightphone/transit/LineTypeSelectionScreen.kt +++ b/tool/src/main/kotlin/com/thelightphone/transit/LineTypeSelectionScreen.kt @@ -20,11 +20,15 @@ import com.thelightphone.sdk.LightScreen import com.thelightphone.sdk.LightViewModel import com.thelightphone.sdk.SealedLightActivity import com.thelightphone.sdk.SimpleLightScreen +import com.thelightphone.sdk.ui.LightBarButton +import com.thelightphone.sdk.ui.LightIcons import com.thelightphone.sdk.ui.LightText import com.thelightphone.sdk.ui.LightTextVariant import com.thelightphone.sdk.ui.LightTheme import com.thelightphone.sdk.ui.LightThemeController import com.thelightphone.sdk.ui.LightThemeTokens +import com.thelightphone.sdk.ui.LightTopBar +import com.thelightphone.sdk.ui.LightTopBarCenter import com.thelightphone.sdk.ui.lightClickable import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow @@ -83,14 +87,12 @@ class LineTypeSelectionScreen( modifier = Modifier .fillMaxSize() .background(LightThemeTokens.colors.background) - .padding(32.dp) ) { - LightText( - text = "Choose Line", - variant = LightTextVariant.Heading, - modifier = Modifier.padding(bottom = 16.dp), + LightTopBar( + leftButton = LightBarButton.LightIcon(icon = LightIcons.BACK, onClick = { goBack() }), + center = LightTopBarCenter.Text("Choose Line"), ) - + Column(modifier = Modifier.weight(1f).padding(32.dp)) { when (val s = state) { is LineTypeSelectionState.Loading -> LightText( text = "Loading lines...", @@ -129,6 +131,7 @@ class LineTypeSelectionScreen( } } } + } } } } diff --git a/tool/src/main/kotlin/com/thelightphone/transit/MapScreen.kt b/tool/src/main/kotlin/com/thelightphone/transit/MapScreen.kt index 240e8ff7..decf12b1 100644 --- a/tool/src/main/kotlin/com/thelightphone/transit/MapScreen.kt +++ b/tool/src/main/kotlin/com/thelightphone/transit/MapScreen.kt @@ -4,16 +4,22 @@ import android.graphics.Paint import android.util.Log import androidx.compose.foundation.Canvas import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.waitForUpOrCancellation +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.nativeCanvas import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.unit.dp import androidx.lifecycle.viewModelScope import com.thelightphone.transit.gtfs.ArrivalStatus @@ -22,24 +28,32 @@ import com.thelightphone.transit.gtfs.GtfsRealtimeClient import com.thelightphone.transit.gtfs.GtfsRepository import com.thelightphone.transit.gtfs.GtfsRtVehicleStatus import com.thelightphone.transit.gtfs.LineType +import com.thelightphone.transit.gtfs.MapTiles import com.thelightphone.transit.gtfs.NominatimGeocoder +import com.thelightphone.transit.gtfs.MapTileClient import com.thelightphone.transit.gtfs.ScheduledArrival import com.thelightphone.transit.gtfs.StopLocation -import com.thelightphone.transit.gtfs.bearingDegrees import com.thelightphone.transit.gtfs.computeArrivalEta import com.thelightphone.transit.gtfs.currentGtfsTimeOfDay +import com.thelightphone.transit.gtfs.fitBoundsZoom import com.thelightphone.transit.gtfs.formatGtfsTime -import com.thelightphone.transit.gtfs.haversineMeters +import com.thelightphone.transit.gtfs.metersPerPixel +import com.thelightphone.transit.gtfs.projectRelativeToCenter import com.thelightphone.transit.gtfs.todayForGtfs import com.thelightphone.sdk.LightScreen import com.thelightphone.sdk.LightViewModel import com.thelightphone.sdk.SealedLightActivity import com.thelightphone.sdk.SimpleLightScreen +import com.thelightphone.sdk.ui.LightBarButton +import com.thelightphone.sdk.ui.LightIcons import com.thelightphone.sdk.ui.LightText import com.thelightphone.sdk.ui.LightTextVariant import com.thelightphone.sdk.ui.LightTheme +import com.thelightphone.sdk.ui.LightThemeColors import com.thelightphone.sdk.ui.LightThemeController import com.thelightphone.sdk.ui.LightThemeTokens +import com.thelightphone.sdk.ui.LightTopBar +import com.thelightphone.sdk.ui.gridUnitsAsDp import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.delay @@ -51,42 +65,79 @@ import java.io.File import java.time.Instant import java.time.LocalDateTime import java.time.ZoneId -import kotlin.math.cos -import kotlin.math.sin - -private const val POLL_INTERVAL_MS = 15_000L -private const val MAX_DISPLAYED_BUSES = 4 -private const val MAX_SCHEDULED_BUSES = 2 -private const val MAX_DISPLAY_RADIUS_MILES = 3.0 -private const val MAX_DISPLAY_TIME_MINUTES = 30.0 -private const val METERS_PER_MILE = 1609.344 -private const val NEARBY_STOP_LIMIT = 10 -private const val MIN_NEARBY_STOP_RADIUS_FRACTION = 0.25 +import kotlin.math.sqrt + +// Tunable; a 15-20s floor roughly matched to typical agency GTFS-RT update cadence, not a +// continuous/unbounded poll. +private const val LIVE_VEHICLE_POLL_INTERVAL_MS = 17_000L +// Per active stop, not a shared total -- turning on "Nearby Vehicles" and selecting more stops +// should show more buses, not compete with the primary stop for the same fixed slots. +private const val MAX_DISPLAYED_BUSES_PER_STOP = 4 +// How many of the closest stops (no radius cap) drive the zoom-fit calculation -- see +// fitBoundsZoom. A fixed radius cap here would mean the "farthest of the nearby stops" is almost +// always near that cap in any reasonably dense area, collapsing the fit to look like a fixed zoom +// regardless of true local density (verified empirically against real MBTA stop data). +private const val ZOOM_FIT_NEAREST_STOP_COUNT = 8 +// Matches LightTopBar's own (private) TOPBAR_HEIGHT_UNITS -- the overlaid back button lives at this +// height, so Loading/Error text below it needs the same clearance to avoid sitting behind the icon. +private const val TOPBAR_CLEARANCE_UNITS = 3f + +// The LP3's screen is 1080x1240px at 3x density (see the @Preview(widthDp = 1080 / 3, heightDp = +// 1240 / 3) calls elsewhere in the SDK) — used as the "available half-extent" nearby stops are fit +// within (see fitBoundsZoom) before the Canvas itself is laid out. If the real canvas ends up a +// different size than assumed here, markers and the map background still line up exactly with each +// other (they share one projection); the only effect is more or less margin around the edge than +// intended (tiles are fetched with their own margin on top of this for exactly that reason — see +// MapTileClient). +private const val MAP_TARGET_RADIUS_PIXELS = 420f +// Floor: even an isolated stop with no nearby neighbors stays reasonably close-in, never "a tiny dot +// in a huge area". Ceiling: matches what CARTO/standard XYZ tile pyramids reliably serve -- higher +// risks missing tiles in less-mapped areas. Fallback: used only when there's nothing to fit bounds +// to at all (no other stops exist anywhere nearby). +private const val MIN_ZOOM = 14 +private const val MAX_ZOOM = 19 +private const val FALLBACK_ZOOM = 17 +private const val STOP_HIT_RADIUS_PX = 44f +private const val LABEL_LINE_HEIGHT_PX = 22f +private const val LABEL_OFFSET_PX = 24f + +// Title/legend render as an overlay bar on top of the map itself (matching how the compass letters +// already render directly on the map), rather than in a separate section above it. Solid, not +// translucent, and flush with the top of the screen -- the compass's "N" label is pushed below it +// (see COMPASS_SCRIM_MARGIN_PX) so the two never overlap. +private const val SCRIM_HEIGHT_PX = 180f +private const val OVERLAY_INSET_X = 28f +private const val TITLE_Y = 60f +private const val LEGEND_Y = 104f +private const val ATTRIBUTION_Y = 134f +private const val COMPASS_SCRIM_MARGIN_PX = 24f +private const val TOGGLE_BOTTOM_MARGIN_PX = 24f data class BusMarker( val tripId: String, + /** Which stop this vehicle is inbound to -- the primary stop, or (when "Nearby Vehicles" is on) + * one of the additionally-selected nearby stops. Only the primary stop's arrivals get the + * special "arrived" row above the center pin; everything else always draws at its true + * position, since that row visually means "arrived at the center pin" specifically. */ + val targetStopId: String, val routeLabel: String, val directionLabel: String, val lineLabel: String?, + /** Mode-specific: 🚇 subway/light rail, 🚆 commuter rail, 🚌 bus (also the fallback for any + * unmapped route_type) -- see LineType.emoji, the single source of truth for this mapping. */ + val emoji: String, val etaEpochSeconds: Long, + /** Null just means "no delay/prediction info yet" — a marker only ever exists for a trip with a + * live VehiclePosition match, so this is never a stand-in for stale/non-live data. */ val status: ArrivalStatus?, val isArrived: Boolean, - /** False when this trip has no VehiclePosition match — position is approximated instead of - * tracked, and the radar draws it grayed out with a clock badge. */ - val isLive: Boolean, - val bearingDegrees: Double, - /** - * Pre-normalized 0..1 (already capped), so rendering doesn't need to know which underlying - * scale produced it: live buses cap on physical distance (MAX_DISPLAY_RADIUS_MILES), - * scheduled-only buses cap on time-until-arrival (MAX_DISPLAY_TIME_MINUTES) instead, since - * there's no live position to measure real distance from. - */ - val radiusFraction: Double, + val lat: Double, + val lon: Double, ) fun BusMarker.etaDisplay(): String { val time = LocalDateTime.ofInstant(Instant.ofEpochSecond(etaEpochSeconds), ZoneId.systemDefault()) - return formatGtfsTime("%02d:%02d:00".format(time.hour, time.minute)) + return "ETA: " + formatGtfsTime("%02d:%02d:00".format(time.hour, time.minute)) } fun BusMarker.statusLabel(): String? = when (val s = status) { @@ -99,22 +150,46 @@ fun BusMarker.statusLabel(): String? = when (val s = status) { data class NearbyStopMarker( val stopId: String, val stopName: String?, - val bearingDegrees: Double, - val radiusFraction: Double, + val lat: Double, + val lon: Double, ) -sealed class EtaRadarState { - object Loading : EtaRadarState() +/** Distinguishes *why* no live buses are shown, so the empty state is never ambiguous. */ +enum class LiveFeedStatus { + /** This agency has no VehiclePositions feed configured at all — a permanent condition. */ + NOT_SUPPORTED, + /** The feed exists but this poll's fetch failed. */ + UNAVAILABLE, + /** The feed fetched fine; zero matching buses just means none are out right now. */ + OK, +} + +sealed class MapState { + object Loading : MapState() data class Loaded( val streetContext: String?, + val centerLat: Double, + val centerLon: Double, + val zoom: Int, + val mapTiles: MapTiles?, val buses: List, val nearbyStops: List, - val isOffline: Boolean, - ) : EtaRadarState() - data class Error(val message: String) : EtaRadarState() + val liveFeedStatus: LiveFeedStatus, + ) : MapState() + data class Error(val message: String) : MapState() } -class EtaRadarViewModel( +/** Everything computed once at screen-open that [MapViewModel] needs again to serve an + * out-of-cycle refresh (e.g. the user flipping the Nearby Vehicles toggle mid-poll-interval). */ +private data class LoadedMapContext( + val stop: StopLocation, + val streetContext: String?, + val zoom: Int, + val mapTiles: MapTiles?, + val nearbyStops: List, +) + +class MapViewModel( dbFile: File, private val agency: GtfsAgency, private val stopId: String, @@ -122,11 +197,22 @@ class EtaRadarViewModel( private val repository = GtfsRepository(dbFile) private val geocoder = NominatimGeocoder() + private val tileClient = MapTileClient() - private val _state = MutableStateFlow(EtaRadarState.Loading) - val state: StateFlow = _state + private val _state = MutableStateFlow(MapState.Loading) + val state: StateFlow = _state + + val nearbyVehiclesEnabled = MutableStateFlow(false) + /** Nearby stops the user has tapped open -- always reveals that stop's name label; when + * [nearbyVehiclesEnabled] is also on, an expanded stop additionally contributes its own inbound + * vehicles to the map. */ + val expandedStopIds = MutableStateFlow>(emptySet()) private var pollJob: Job? = null + private var loadedContext: LoadedMapContext? = null + /** Lazily populated as stops become active -- the primary stop's schedule is seeded up front, + * a nearby stop's is fetched the first time it's ever toggled on. */ + private val scheduledArrivalsByStopId = mutableMapOf>() override fun onScreenShow(screen: SimpleLightScreen) { super.onScreenShow(screen) @@ -135,43 +221,62 @@ class EtaRadarViewModel( try { val stop = repository.getStopLocation(stopId) if (stop == null) { - _state.value = EtaRadarState.Error("Stop location not found.") + _state.value = MapState.Error("Stop location not found.") return@launch } // Snapshot once: which trips are scheduled for this stop from now on. Live polling - // below only refreshes position/prediction data for these same trips — a trip that - // wasn't scheduled at screen-open time won't appear mid-session. - val scheduledArrivals = repository.getScheduledArrivals(stopId, currentGtfsTimeOfDay(), todayForGtfs()) + // below only checks these same trips for a live position match — a trip that wasn't + // scheduled at screen-open time won't appear mid-session. + scheduledArrivalsByStopId[stopId] = repository.getScheduledArrivals(stopId, currentGtfsTimeOfDay(), todayForGtfs()) val streetContext = try { geocoder.reverseGeocode(stop.lat, stop.lon) } catch (e: Exception) { - Log.e("EtaRadarScreen", "Reverse geocoding failed for stop $stopId", e) + Log.e("MapScreen", "Reverse geocoding failed for stop $stopId", e) null } - // Nearby stops don't change while the screen is open, so this is computed once too, - // reusing the same ranking function as the Leave Now flow, anchored here at the - // selected stop instead of a geocoded search point. - val nearbyStops = repository.rankStopsByDistance(stop.lat, stop.lon, NEARBY_STOP_LIMIT, excludeStopId = stopId) - .map { nearby -> - val trueFraction = (nearby.distanceMeters / METERS_PER_MILE) - .coerceAtMost(MAX_DISPLAY_RADIUS_MILES) / MAX_DISPLAY_RADIUS_MILES - NearbyStopMarker( - stopId = nearby.stopId, - stopName = nearby.stopName, - bearingDegrees = bearingDegrees(stop.lat, stop.lon, nearby.lat, nearby.lon), - // Floored so a stop genuinely close to the selected one never renders - // stacked on/behind the center pin. - radiusFraction = trueFraction.coerceAtLeast(MIN_NEARBY_STOP_RADIUS_FRACTION), - ) - } + + // Zoom fits the nearest ZOOM_FIT_NEAREST_STOP_COUNT stops (no radius cap) around the + // selected stop (which always stays at dead center) -- a dense cluster naturally + // zooms in more, an isolated stop naturally zooms out, with no per-agency + // special-casing needed. + val nearestForZoom = repository.rankStopsByDistance(stop.lat, stop.lon, ZOOM_FIT_NEAREST_STOP_COUNT, excludeStopId = stopId) + val zoom = fitBoundsZoom( + centerLat = stop.lat, + centerLon = stop.lon, + points = nearestForZoom.map { it.lat to it.lon }, + availableHalfExtentPx = MAP_TARGET_RADIUS_PIXELS, + minZoom = MIN_ZOOM, + maxZoom = MAX_ZOOM, + fallbackZoom = FALLBACK_ZOOM, + ) + + // The map background is a set of still tiles, fetched once — it doesn't need to + // refresh on the same cadence as bus positions. Fetch radius is derived from the + // zoom just chosen (how much ground MAP_TARGET_RADIUS_PIXELS actually covers at that + // zoom) so a dense-hub view (zoomed in tight) doesn't fetch far more tiles than it + // can ever show, and a sparse-area view (zoomed out to the MIN_ZOOM floor) doesn't + // leave the edges of the canvas blank. The nearby-stops query below reuses the same + // radius for exactly the same reason -- what's plotted should match what's visible. + val fetchRadiusMeters = MAP_TARGET_RADIUS_PIXELS * metersPerPixel(stop.lat, zoom) + val mapTiles = try { + tileClient.fetchTilesAround(stop.lat, stop.lon, zoom, fetchRadiusMeters) + } catch (e: Exception) { + Log.e("MapScreen", "Map tile fetch failed for stop $stopId", e) + null + } + val nearbyStops = repository.getStopsWithinRadius( + stop.lat, stop.lon, fetchRadiusMeters, excludeStopId = stopId, + ).map { nearby -> NearbyStopMarker(nearby.stopId, nearby.stopName, nearby.lat, nearby.lon) } + + loadedContext = LoadedMapContext(stop, streetContext, zoom, mapTiles, nearbyStops) while (isActive) { - refresh(stop, scheduledArrivals, streetContext, nearbyStops) - delay(POLL_INTERVAL_MS) + refresh() + delay(LIVE_VEHICLE_POLL_INTERVAL_MS) } } catch (e: Exception) { - Log.e("EtaRadarScreen", "Failed to load ETA radar for stop $stopId", e) - _state.value = EtaRadarState.Error("Unable to load bus positions.") + Log.e("MapScreen", "Failed to load map for stop $stopId", e) + _state.value = MapState.Error("Unable to load bus positions.") } } } @@ -182,119 +287,119 @@ class EtaRadarViewModel( pollJob = null } - private suspend fun refresh( - stop: StopLocation, - scheduledArrivals: List, - streetContext: String?, - nearbyStops: List, - ) { + /** Same tap either way: reveals/hides that stop's name label. Only additionally affects vehicle + * data when [nearbyVehiclesEnabled] is on -- refreshed immediately rather than waiting for the + * next scheduled poll, so the extra vehicles (or their removal) show up right away. */ + fun toggleStopExpanded(stopId: String) { + expandedStopIds.value = expandedStopIds.value.let { if (stopId in it) it - stopId else it + stopId } + if (nearbyVehiclesEnabled.value) refreshNow() + } + + fun toggleNearbyVehicles() { + nearbyVehiclesEnabled.value = !nearbyVehiclesEnabled.value + refreshNow() + } + + private fun refreshNow() { + viewModelScope.launch(Dispatchers.IO) { refresh() } + } + + private suspend fun refresh() { + val context = loadedContext ?: return val today = todayForGtfs() - val tripUpdatesFeed = agency.realtimeTripUpdatesUrl?.let { url -> - try { - GtfsRealtimeClient.fetchFeed(url) - } catch (e: Exception) { - Log.e("EtaRadarScreen", "TripUpdates fetch failed for ${agency.displayName}", e) - null + // Active stops: always the primary one; plus any expanded nearby stops, but only while the + // toggle is on. Filtered against the known nearby-stop set as a safety net against stale ids. + val activeStopIds = buildSet { + add(stopId) + if (nearbyVehiclesEnabled.value) { + val nearbyIds = context.nearbyStops.mapTo(mutableSetOf()) { it.stopId } + addAll(expandedStopIds.value.filter { it in nearbyIds }) + } + } + for (id in activeStopIds) { + if (id !in scheduledArrivalsByStopId) { + scheduledArrivalsByStopId[id] = repository.getScheduledArrivals(id, currentGtfsTimeOfDay(), today) } } - val vehiclePositionsFeed = agency.realtimeVehiclePositionsUrl?.let { url -> + + if (agency.realtimeVehiclePositionsUrl == null) { + _state.value = MapState.Loaded( + context.streetContext, context.stop.lat, context.stop.lon, context.zoom, context.mapTiles, + emptyList(), context.nearbyStops, LiveFeedStatus.NOT_SUPPORTED, + ) + return + } + + val vehiclePositionsFeed = try { + GtfsRealtimeClient.fetchFeed(agency.realtimeVehiclePositionsUrl) + } catch (e: Exception) { + Log.e("MapScreen", "VehiclePositions fetch failed for ${agency.displayName}", e) + null + } + if (vehiclePositionsFeed == null) { + _state.value = MapState.Loaded( + context.streetContext, context.stop.lat, context.stop.lon, context.zoom, context.mapTiles, + emptyList(), context.nearbyStops, LiveFeedStatus.UNAVAILABLE, + ) + return + } + + val tripUpdatesFeed = agency.realtimeTripUpdatesUrl?.let { url -> try { GtfsRealtimeClient.fetchFeed(url) } catch (e: Exception) { - Log.e("EtaRadarScreen", "VehiclePositions fetch failed for ${agency.displayName}", e) + Log.e("MapScreen", "TripUpdates fetch failed for ${agency.displayName}", e) null } } - val nowEpochSeconds = System.currentTimeMillis() / 1000 - - val buses = scheduledArrivals.mapNotNull { arrival -> - val rtStopUpdate = tripUpdatesFeed?.tripUpdatesByTripId?.get(arrival.tripId) - ?.updateFor(stopId, arrival.stopSequence) - val eta = computeArrivalEta(arrival.departureTime, today, rtStopUpdate) ?: return@mapNotNull null - - val vehicle = vehiclePositionsFeed?.vehiclePositionsByTripId?.get(arrival.tripId) - if (vehicle != null) { + // Only trips that are BOTH scheduled to arrive at an active stop AND currently reporting a + // live vehicle position ever become a marker — no scheduled-only approximation. Each active + // stop gets its own MAX_DISPLAYED_BUSES_PER_STOP allotment rather than sharing one total, so + // selecting more stops shows more buses instead of competing for the same fixed slots. + val buses = activeStopIds.flatMap { activeStopId -> + val scheduledArrivals = scheduledArrivalsByStopId[activeStopId] ?: return@flatMap emptyList() + scheduledArrivals.mapNotNull { arrival -> + val vehicle = vehiclePositionsFeed.vehiclePositionsByTripId[arrival.tripId] ?: return@mapNotNull null val position = vehicle.position ?: return@mapNotNull null - // Already past our stop on this trip -> disappear rather than show it moving away. + // Already past this stop on this trip -> disappear rather than show it moving away. val currentSeq = vehicle.currentStopSequence if (currentSeq != null && currentSeq > arrival.stopSequence) return@mapNotNull null + // No real stop_id is ever present on live vehicle data (see GtfsRtVehiclePosition's + // doc) -- current_stop_sequence matching the target stop is the only signal available. val isArrived = vehicle.currentStatus == GtfsRtVehicleStatus.STOPPED_AT && - (currentSeq == arrival.stopSequence || vehicle.stopId == stopId) - - val lat = position.latitude.toDouble() - val lon = position.longitude.toDouble() - val bearing = if (isArrived) 0.0 else bearingDegrees(stop.lat, stop.lon, lat, lon) - val radiusFraction = if (isArrived) { - 0.0 - } else { - val distanceMiles = haversineMeters(stop.lat, stop.lon, lat, lon) / METERS_PER_MILE - distanceMiles.coerceAtMost(MAX_DISPLAY_RADIUS_MILES) / MAX_DISPLAY_RADIUS_MILES - } + currentSeq == arrival.stopSequence - BusMarker( - tripId = arrival.tripId, - routeLabel = arrival.route.displayName, - directionLabel = arrival.direction.displayLabel(), - lineLabel = LineType.forGtfsRouteType(arrival.route.routeType)?.label, - etaEpochSeconds = eta.etaEpochSeconds, - status = eta.status, - isArrived = isArrived, - isLive = true, - bearingDegrees = bearing, - radiusFraction = radiusFraction, - ) - } else { - // No VehiclePosition for this trip: approximate its approach direction from the - // previous stop on the route rather than an actual tracked position, and use - // time-until-arrival in place of physical distance for radial placement. - val previousStop = repository.getPreviousStopLocation(arrival.tripId, arrival.stopSequence) - ?: return@mapNotNull null // first stop of the trip — no previous stop to bear from - val bearing = bearingDegrees(stop.lat, stop.lon, previousStop.lat, previousStop.lon) - val minutesUntilArrival = ((eta.etaEpochSeconds - nowEpochSeconds) / 60.0).coerceAtLeast(0.0) - val radiusFraction = minutesUntilArrival.coerceAtMost(MAX_DISPLAY_TIME_MINUTES) / MAX_DISPLAY_TIME_MINUTES + val rtStopUpdate = tripUpdatesFeed?.tripUpdatesByTripId?.get(arrival.tripId) + ?.updateFor(activeStopId, arrival.stopSequence) + val eta = computeArrivalEta(arrival.departureTime, today, rtStopUpdate) ?: return@mapNotNull null + val lineType = LineType.forGtfsRouteType(arrival.route.routeType) BusMarker( tripId = arrival.tripId, + targetStopId = activeStopId, routeLabel = arrival.route.displayName, directionLabel = arrival.direction.displayLabel(), - lineLabel = LineType.forGtfsRouteType(arrival.route.routeType)?.label, + lineLabel = lineType?.label, + emoji = lineType?.emoji ?: "🚌", etaEpochSeconds = eta.etaEpochSeconds, status = eta.status, - isArrived = false, - isLive = false, - bearingDegrees = bearing, - radiusFraction = radiusFraction, + isArrived = isArrived, + lat = position.latitude.toDouble(), + lon = position.longitude.toDouble(), ) - } - }.sortedBy { it.etaEpochSeconds } - - // Soonest-first, up to MAX_DISPLAYED_BUSES total, but at most MAX_SCHEDULED_BUSES of those - // may be non-live — an extra scheduled-only candidate is skipped so a later live one can - // still take its slot, rather than crowding the radar with unconfirmed estimates. - val selectedBuses = mutableListOf() - var scheduledCount = 0 - for (bus in buses) { - if (selectedBuses.size >= MAX_DISPLAYED_BUSES) break - if (!bus.isLive) { - if (scheduledCount >= MAX_SCHEDULED_BUSES) continue - scheduledCount++ - } - selectedBuses += bus + }.sortedBy { it.etaEpochSeconds }.take(MAX_DISPLAYED_BUSES_PER_STOP) } + // The same live vehicle could be inbound to two selected stops close together on one route + // -- keep only its earliest-ETA entry rather than showing it twice. + val dedupedBuses = buses.groupBy { it.tripId }.values.map { group -> group.minBy { it.etaEpochSeconds } } - _state.value = EtaRadarState.Loaded( - streetContext = streetContext, - buses = selectedBuses, - nearbyStops = nearbyStops, - // Offline means "no live position data at all" — a feed that fetched fine but happens - // to have zero matches among currently-scheduled trips still counts as offline, while - // even one live match means we're genuinely tracking something and shouldn't say so. - isOffline = vehiclePositionsFeed == null || - (selectedBuses.isNotEmpty() && selectedBuses.none { it.isLive }), + _state.value = MapState.Loaded( + context.streetContext, context.stop.lat, context.stop.lon, context.zoom, context.mapTiles, + dedupedBuses, context.nearbyStops, LiveFeedStatus.OK, ) } @@ -302,103 +407,190 @@ class EtaRadarViewModel( super.onCleared() repository.close() geocoder.close() + tileClient.close() } } -class EtaRadarScreen( +class MapScreen( sealedActivity: SealedLightActivity, private val dbFile: File, private val agency: GtfsAgency, private val stopId: String, private val stopLabel: String, -) : LightScreen(sealedActivity) { +) : LightScreen(sealedActivity) { - override val viewModelClass: Class - get() = EtaRadarViewModel::class.java + override val viewModelClass: Class + get() = MapViewModel::class.java - override fun createViewModel(): EtaRadarViewModel = EtaRadarViewModel(dbFile, agency, stopId) + override fun createViewModel(): MapViewModel = MapViewModel(dbFile, agency, stopId) @Composable override fun Content() { val state by viewModel.state.collectAsState() + val nearbyVehiclesEnabled by viewModel.nearbyVehiclesEnabled.collectAsState() + val expandedStopIds by viewModel.expandedStopIds.collectAsState() val themeColors by LightThemeController.colors.collectAsState() LightTheme(colors = themeColors) { - Column( + Box( modifier = Modifier .fillMaxSize() .background(LightThemeTokens.colors.background) - .padding(16.dp) ) { - LightText( - text = "ETA", - variant = LightTextVariant.Heading, - modifier = Modifier.padding(bottom = 8.dp), - ) - + Column(modifier = Modifier.fillMaxSize()) { + // Loading/Error render below the overlaid back button's row (see the LightTopBar + // further down, drawn on top of this Column) rather than at the very top edge, + // so the text never sits behind the icon. + val aboveOverlayPadding = Modifier.padding(top = TOPBAR_CLEARANCE_UNITS.gridUnitsAsDp() + 16.dp, start = 16.dp, end = 16.dp) when (val s = state) { - is EtaRadarState.Loading -> LightText( + is MapState.Loading -> LightText( text = "Loading...", variant = LightTextVariant.Copy, lighten = true, + modifier = aboveOverlayPadding, ) - is EtaRadarState.Error -> LightText( + is MapState.Error -> LightText( text = s.message, variant = LightTextVariant.Copy, lighten = true, + modifier = aboveOverlayPadding, ) - is EtaRadarState.Loaded -> { - if (s.isOffline) { + is MapState.Loaded -> { + // Only a transient, occasionally-appearing banner -- title and legend render + // as an overlay on the map itself (see MapCanvas), same as the compass. + val liveMessage = when { + s.liveFeedStatus == LiveFeedStatus.NOT_SUPPORTED -> + "This agency doesn't provide live vehicle tracking." + s.liveFeedStatus == LiveFeedStatus.UNAVAILABLE -> + "Live positions unavailable right now." + s.liveFeedStatus == LiveFeedStatus.OK && s.buses.isEmpty() -> + "No live vehicles currently tracked for this stop." + else -> null + } + liveMessage?.let { LightText( - text = "Offline - live positions unavailable", + text = it, variant = LightTextVariant.Detail, lighten = true, - modifier = Modifier.padding(bottom = 8.dp), + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), ) } - LightText( - text = "🚏 Nearby stop · 🚌 Live · 🚌🕐 Scheduled", - variant = LightTextVariant.Detail, - lighten = true, - modifier = Modifier.padding(bottom = 8.dp), - ) - RadarCanvas( + MapCanvas( + stopId = stopId, stopLabel = stopLabel, streetContext = s.streetContext, + centerLat = s.centerLat, + centerLon = s.centerLon, + zoom = s.zoom, + mapTiles = s.mapTiles, buses = s.buses, nearbyStops = s.nearbyStops, + expandedStopIds = expandedStopIds, + nearbyVehiclesEnabled = nearbyVehiclesEnabled, + onToggleStop = viewModel::toggleStopExpanded, + onToggleNearbyVehicles = viewModel::toggleNearbyVehicles, modifier = Modifier.fillMaxSize(), ) } } + } + + // Always white, independent of the app's light/dark theme -- forced into the Dark + // palette so the icon reads against the canvas's own always-solid-black top scrim + // (see SCRIM_HEIGHT_PX), the same theme-independent treatment as the rest of that + // overlay (title, legend, compass letters). + LightTheme(colors = LightThemeColors.Dark) { + LightTopBar( + leftButton = LightBarButton.LightIcon(icon = LightIcons.BACK, onClick = { goBack() }), + modifier = Modifier.align(Alignment.TopStart), + ) + } } } } } /** - * Draws the whole radar entirely on a single Canvas, including text — emoji glyphs and labels are + * Draws the whole map entirely on a single Canvas, including text — emoji glyphs and labels are * drawn via the underlying native android.graphics.Canvas/Paint (Compose's own text APIs are built - * for styled text layout, not single positioned glyphs). Sizing/offset constants below are a first - * pass; verify spacing on an actual device/emulator and adjust, since this can't be rendered here. + * for styled text layout, not single positioned glyphs). Every marker is placed at its true + * projected position (clipped to the visible radius only as a fallback for rare far-outliers), with + * no de-overlap/clustering logic for markers themselves — only tapped-open stop labels get basic + * collision handling (see resolveLabelPositions), since those are the only thing users reveal + * on demand and might stack illegibly. + * + * Touch handling is a hand-written gesture loop rather than [androidx.compose.foundation.gestures + * .detectTapGestures] deliberately: that helper unconditionally consumes the down (and up) event for + * *every* touch anywhere in its bounds, even ones that don't land on anything -- confirmed by reading + * its actual implementation. Since this Canvas fills the whole screen, that was swallowing edge + * touches that back-navigation swiping needs to see. Here, nothing is consumed unless a touch + * actually lands on a real marker or the toggle. */ @Composable -private fun RadarCanvas( +private fun MapCanvas( + stopId: String, stopLabel: String, streetContext: String?, + centerLat: Double, + centerLon: Double, + zoom: Int, + mapTiles: MapTiles?, buses: List, nearbyStops: List, + expandedStopIds: Set, + nearbyVehiclesEnabled: Boolean, + onToggleStop: (String) -> Unit, + onToggleNearbyVehicles: () -> Unit, modifier: Modifier = Modifier, ) { val contentColor = LightThemeTokens.colors.content.toArgb() - Canvas(modifier = modifier) { + Canvas( + modifier = modifier + .pointerInput(nearbyStops, centerLat, centerLon, zoom, nearbyVehiclesEnabled) { + awaitEachGesture { + val down = awaitFirstDown(requireUnconsumed = false) + val center = Offset(size.width / 2f, size.height / 2f) + val maxRadius = minOf(size.width, size.height) / 2f * 0.8f + + val hitStop = nearbyStops.firstOrNull { stop -> + val rel = projectRelativeToCenter(centerLat, centerLon, stop.lat, stop.lon, zoom) + val point = clipToRadius(Offset(center.x + rel.x, center.y + rel.y), center, maxRadius) + val dx = down.position.x - point.x + val dy = down.position.y - point.y + sqrt(dx * dx + dy * dy) < STOP_HIT_RADIUS_PX + } + val toggleBounds = toggleHitRect(size.height.toFloat()) + val hitToggle = toggleBounds.contains(down.position) + + if (hitStop == null && !hitToggle) { + // Not on anything we handle -- leave the event completely untouched so + // ancestor/system gestures (like edge-swipe back) still see it normally. + return@awaitEachGesture + } + down.consume() + val up = waitForUpOrCancellation() + if (up != null) { + up.consume() + if (hitToggle) onToggleNearbyVehicles() else hitStop?.let { onToggleStop(it.stopId) } + } + } + } + ) { val center = Offset(size.width / 2f, size.height / 2f) val maxRadius = minOf(size.width, size.height) / 2f * 0.8f val nativeCanvas = drawContext.canvas.nativeCanvas + // Map background: each fetched tile drawn independently at its own screen offset, so the + // selected stop's projected pixel sits exactly at the canvas center (the same projection + // markers use below) and a handful of missing tiles just leave that patch blank. + mapTiles?.tiles?.forEach { tile -> + val offset = mapTiles.screenOffset(tile.tileX, tile.tileY) + nativeCanvas.drawBitmap(tile.bitmap, center.x + offset.x, center.y + offset.y, null) + } + val emojiPaint = Paint().apply { textSize = 56f textAlign = Paint.Align.CENTER @@ -419,96 +611,199 @@ private fun RadarCanvas( textAlign = Paint.Align.CENTER isAntiAlias = true } - val nearbyStopLabelPaint = Paint(smallLabelPaint).apply { - textSize = 18f + // Always-white fill + black outline, independent of theme -- legible against both dark and + // light patches of map, rather than the theme-dependent (and previously too-small) styling. + val nearbyStopLabelPaint = Paint().apply { + textSize = 26f + textAlign = Paint.Align.CENTER + isAntiAlias = true + color = android.graphics.Color.WHITE } - - // Fixed north-up compass letters around the edge. - val compassRadius = maxRadius + 28f - listOf("N" to 0.0, "E" to 90.0, "S" to 180.0, "W" to 270.0).forEach { (label, angle) -> - val rad = Math.toRadians(angle) - val x = center.x + compassRadius * sin(rad).toFloat() - val y = center.y - compassRadius * cos(rad).toFloat() - nativeCanvas.drawText(label, x, y, labelPaint) + val nearbyStopLabelOutlinePaint = Paint(nearbyStopLabelPaint).apply { + style = Paint.Style.STROKE + strokeWidth = 5f + color = android.graphics.Color.BLACK } - // Nearby stops drawn first (background layer) so bus markers stay legible on top. Ones - // close enough to overlap are grouped into one icon with aligned name lines underneath, - // same de-stacking approach as the bus markers below. + // Title/legend/attribution render as an overlay directly on the map -- a solid (not + // translucent) black bar flush with the top of the screen, so the text stays legible + // regardless of what's underneath, same idea as the compass letters already drawing + // straight onto the map. Text here is always light, independent of the app's light/dark + // theme, since the bar itself is always solid black. + val scrimPaint = Paint().apply { + color = android.graphics.Color.BLACK + } + nativeCanvas.drawRect(0f, 0f, size.width, SCRIM_HEIGHT_PX, scrimPaint) + val overlayTitlePaint = Paint().apply { + textSize = 34f + textAlign = Paint.Align.LEFT + isAntiAlias = true + color = android.graphics.Color.WHITE + } + val overlayLegendPaint = Paint(overlayTitlePaint).apply { + textSize = 22f + alpha = 220 + } + val overlayAttributionPaint = Paint(overlayTitlePaint).apply { + textSize = 16f + alpha = 160 + } + nativeCanvas.drawText("🗺️ Map", OVERLAY_INSET_X, TITLE_Y, overlayTitlePaint) + val legendText = "📍 Selected stop · 🚏 Nearby stop · 🚌 Bus · 🚇 Subway/Light Rail · 🚆 Commuter Rail" + val maxLegendWidthPx = size.width - 2 * OVERLAY_INSET_X + val legendWidthPx = overlayLegendPaint.measureText(legendText) + // Five items is a lot to fit on one line at a fixed size -- shrink just enough to fit + // rather than risk it running off the edge of the bar. + if (legendWidthPx > maxLegendWidthPx) { + overlayLegendPaint.textSize *= maxLegendWidthPx / legendWidthPx + } + nativeCanvas.drawText(legendText, OVERLAY_INSET_X, LEGEND_Y, overlayLegendPaint) + nativeCanvas.drawText("© OpenStreetMap contributors © CARTO", OVERLAY_INSET_X, ATTRIBUTION_Y, overlayAttributionPaint) + + // Fixed north-up compass letters, each anchored a matching margin from its own screen edge + // (top/bottom/right/left) rather than a shared circle around center -- accurate since the + // map background is itself a north-up Mercator projection. N's margin is measured from the + // bottom of the solid top bar so it never sits under it; S/E/W use that same margin value + // from their own edge for consistent spacing on all four sides. + val compassEdgeMarginPx = SCRIM_HEIGHT_PX + COMPASS_SCRIM_MARGIN_PX + nativeCanvas.drawText("N", center.x, compassEdgeMarginPx, labelPaint) + nativeCanvas.drawText("S", center.x, size.height - compassEdgeMarginPx, labelPaint) + nativeCanvas.drawText("E", size.width - compassEdgeMarginPx, center.y, labelPaint) + nativeCanvas.drawText("W", compassEdgeMarginPx, center.y, labelPaint) + + // Nearby Vehicles toggle -- bottom-left corner, clear of the mid-edge compass letters. + val toggleText = if (nearbyVehiclesEnabled) "🚌 Nearby Vehicles: On" else "🚌 Nearby Vehicles: Off" + val toggleY = size.height - TOGGLE_BOTTOM_MARGIN_PX + val toggleOutlinePaint = Paint(nearbyStopLabelOutlinePaint).apply { + textSize = 22f + textAlign = Paint.Align.LEFT + } + val togglePaint = Paint(nearbyStopLabelPaint).apply { + textSize = 22f + textAlign = Paint.Align.LEFT + } + nativeCanvas.drawText(toggleText, OVERLAY_INSET_X, toggleY, toggleOutlinePaint) + nativeCanvas.drawText(toggleText, OVERLAY_INSET_X, toggleY, togglePaint) + + // Nearby stops: icon-only by default, drawn as a background layer so bus markers stay + // legible on top; a tap on a stop toggles its own name label on/off, independent per stop + // (and, when the toggle above is on, also toggles whether it contributes vehicles). + // Markers themselves are never grouped/clustered -- only the expanded labels below get + // basic collision handling, since those are the only thing that can visually stack. val stopPoints = nearbyStops.map { stop -> - val rad = Math.toRadians(stop.bearingDegrees) - val r = stop.radiusFraction.toFloat() * maxRadius - RadarPoint(stop, center.x + r * sin(rad).toFloat(), center.y - r * cos(rad).toFloat()) + val rel = projectRelativeToCenter(centerLat, centerLon, stop.lat, stop.lon, zoom) + val point = clipToRadius(Offset(center.x + rel.x, center.y + rel.y), center, maxRadius) + stop to point } - clusterRadarPoints(stopPoints, CLUSTER_THRESHOLD_PX).forEach { cluster -> - val anchor = cluster.first() - nativeCanvas.drawText("🚏", anchor.x, anchor.y, nearbyStopEmojiPaint) - cluster.forEachIndexed { index, point -> - val label = point.item.stopName ?: "Stop ${point.item.stopId}" - nativeCanvas.drawText(label, anchor.x, anchor.y + 22f + index * 18f, nearbyStopLabelPaint) + stopPoints.forEach { (_, point) -> nativeCanvas.drawText("🚏", point.x, point.y, nearbyStopEmojiPaint) } + + val expandedLabels = stopPoints + .filter { (stop, _) -> stop.stopId in expandedStopIds } + .map { (stop, point) -> + val text = stop.stopName ?: "Stop ${stop.stopId}" + LabelBox( + key = stop.stopId, + text = text, + centerX = point.x, + initialY = point.y + LABEL_OFFSET_PX, + width = nearbyStopLabelPaint.measureText(text), + ) } + resolveLabelPositions(expandedLabels).forEach { (key, y) -> + val box = expandedLabels.first { it.key == key } + nativeCanvas.drawText(box.text, box.centerX, y, nearbyStopLabelOutlinePaint) + nativeCanvas.drawText(box.text, box.centerX, y, nearbyStopLabelPaint) } - // Center stop pin, name, and street context. + // Center stop pin, name, and street context — always pinned dead center. nativeCanvas.drawText("📍", center.x, center.y, emojiPaint) nativeCanvas.drawText(stopLabel, center.x, center.y + 64f, labelPaint) streetContext?.let { nativeCanvas.drawText(it, center.x, center.y + 92f, smallLabelPaint) } - val arrivedBuses = buses.filter { it.isArrived } - val enRouteBuses = buses.filterNot { it.isArrived } + // Only the PRIMARY stop's arrived buses get the special row above the center pin -- that + // row visually means "arrived here", which would be misleading for a secondary stop's bus. + val arrivedAtPrimary = buses.filter { it.isArrived && it.targetStopId == stopId } + val everythingElse = buses.filterNot { it.isArrived && it.targetStopId == stopId } - arrivedBuses.forEachIndexed { index, bus -> - val xOffset = (index - (arrivedBuses.size - 1) / 2f) * 110f + arrivedAtPrimary.forEachIndexed { index, bus -> + val xOffset = (index - (arrivedAtPrimary.size - 1) / 2f) * 110f val x = center.x + xOffset val y = center.y - maxRadius * 0.45f drawBusMarker(nativeCanvas, emojiPaint, labelPaint, smallLabelPaint, bus, x, y) } - // Buses whose computed positions would land close enough to visually overlap (e.g. two - // trips approaching from the same direction) are grouped into one icon with aligned ETA - // lines underneath, rather than stacking icons on top of each other. Only far-enough-apart - // buses get their own individually placed icon. - val busPoints = enRouteBuses.map { bus -> - val rad = Math.toRadians(bus.bearingDegrees) - val r = bus.radiusFraction.toFloat() * maxRadius - RadarPoint(bus, center.x + r * sin(rad).toFloat(), center.y - r * cos(rad).toFloat()) - } - clusterRadarPoints(busPoints, CLUSTER_THRESHOLD_PX).forEach { cluster -> - val anchor = cluster.first() - if (cluster.size == 1) { - drawBusMarker(nativeCanvas, emojiPaint, labelPaint, smallLabelPaint, anchor.item, anchor.x, anchor.y) - } else { - drawBusCluster(nativeCanvas, emojiPaint, smallLabelPaint, cluster.map { it.item }, anchor.x, anchor.y) - } + // Every other bus drawn individually at its true projected position — clipped to the + // visible radius (preserving true direction) only as a fallback for rare far-outliers, with + // no grouping/de-overlap logic. + everythingElse.forEach { bus -> + val rel = projectRelativeToCenter(centerLat, centerLon, bus.lat, bus.lon, zoom) + val clipped = clipToRadius(Offset(center.x + rel.x, center.y + rel.y), center, maxRadius) + drawBusMarker(nativeCanvas, emojiPaint, labelPaint, smallLabelPaint, bus, clipped.x, clipped.y) } } } -private const val SCHEDULED_BUS_ALPHA = 130 -private const val CLUSTER_THRESHOLD_PX = 80f +/** Hit-test bounds for the Nearby Vehicles toggle text, matching where it's drawn. Rough but + * generous -- a fixed-width box wide enough for either "On"/"Off" state of the label. */ +private fun toggleHitRect(canvasHeight: Float): androidx.compose.ui.geometry.Rect { + val toggleY = canvasHeight - TOGGLE_BOTTOM_MARGIN_PX + return androidx.compose.ui.geometry.Rect( + left = OVERLAY_INSET_X - 12f, + top = toggleY - 32f, + right = OVERLAY_INSET_X + 320f, + bottom = toggleY + 12f, + ) +} + +/** Clamps [point] to within [maxRadius] of [center], preserving true direction — the fallback used + * for markers whose real projected position falls outside the map's legible radius. */ +private fun clipToRadius(point: Offset, center: Offset, maxRadius: Float): Offset { + val dx = point.x - center.x + val dy = point.y - center.y + val dist = sqrt(dx * dx + dy * dy) + if (dist <= maxRadius || dist == 0f) return point + val scale = maxRadius / dist + return Offset(center.x + dx * scale, center.y + dy * scale) +} -/** A radar-placed item at its computed screen position, for proximity clustering. */ -private data class RadarPoint(val item: T, val x: Float, val y: Float) +/** A tapped-open stop label's intended (pre-collision) position and measured size. */ +private data class LabelBox(val key: String, val text: String, val centerX: Float, val initialY: Float, val width: Float) /** - * Greedily groups points within [thresholdPx] of each other (Euclidean, in the already-computed - * screen positions) into clusters, so visually overlapping markers — bus or stop — can be drawn as - * one icon with an aligned list underneath instead of stacking on top of each other. + * Basic collision handling for the (usually few) simultaneously-expanded stop labels: placed + * top-to-bottom, each label that would overlap an already-placed one is pushed straight down below + * it. Only ever moves label text, never the markers themselves. Returns each label's key mapped to + * its resolved baseline Y. */ -private fun clusterRadarPoints(points: List>, thresholdPx: Float): List>> { - val clusters = mutableListOf>>() - points.forEach { point -> - val cluster = clusters.find { existing -> - val anchor = existing.first() - val dx = point.x - anchor.x - val dy = point.y - anchor.y - kotlin.math.sqrt(dx * dx + dy * dy) < thresholdPx +private fun resolveLabelPositions(labels: List): Map { + data class PlacedBox(val left: Float, val right: Float, val top: Float, val bottom: Float) + + val result = mutableMapOf() + val placed = mutableListOf() + for (label in labels.sortedBy { it.initialY }) { + var y = label.initialY + val left = label.centerX - label.width / 2f + val right = label.centerX + label.width / 2f + var moved = true + while (moved) { + moved = false + val top = y - LABEL_LINE_HEIGHT_PX + val bottom = y + for (box in placed) { + val overlapsX = left < box.right && right > box.left + val overlapsY = top < box.bottom && bottom > box.top + if (overlapsX && overlapsY) { + y = box.bottom + LABEL_LINE_HEIGHT_PX + moved = true + } + } } - if (cluster != null) cluster += point else clusters += mutableListOf(point) + placed += PlacedBox(left, right, y - LABEL_LINE_HEIGHT_PX, y) + result[label.key] = y } - return clusters + return result } private fun drawBusMarker( @@ -520,57 +815,12 @@ private fun drawBusMarker( x: Float, y: Float, ) { - val alpha = if (bus.isLive) 255 else SCHEDULED_BUS_ALPHA - val busEmojiPaint = Paint(emojiPaint).apply { this.alpha = alpha } - val busLabelPaint = Paint(labelPaint).apply { this.alpha = alpha } - val busSmallLabelPaint = Paint(smallLabelPaint).apply { this.alpha = smallLabelPaint.alpha * alpha / 255 } - - canvas.drawText("🚌", x, y, busEmojiPaint) - if (!bus.isLive) { - // Small clock badge marks this as a scheduled-time estimate, not a tracked live position. - val clockBadgePaint = Paint(emojiPaint).apply { - textSize = emojiPaint.textSize * 0.5f - textAlign = Paint.Align.LEFT - } - canvas.drawText("🕐", x + emojiPaint.textSize * 0.3f, y - emojiPaint.textSize * 0.35f, clockBadgePaint) - } + canvas.drawText(bus.emoji, x, y, emojiPaint) val routeText = bus.lineLabel?.let { "$it - ${bus.routeLabel}" } ?: bus.routeLabel - canvas.drawText(routeText, x, y + 30f, busSmallLabelPaint) - canvas.drawText(bus.directionLabel, x, y + 54f, busSmallLabelPaint) - canvas.drawText(bus.etaDisplay(), x, y + 78f, busLabelPaint) + canvas.drawText(routeText, x, y + 30f, smallLabelPaint) + canvas.drawText(bus.directionLabel, x, y + 54f, smallLabelPaint) + canvas.drawText(bus.etaDisplay(), x, y + 78f, labelPaint) bus.statusLabel()?.let { - canvas.drawText(it, x, y + 102f, busSmallLabelPaint) - } -} - -/** - * Draws one icon for a group of buses that would otherwise overlap, with one aligned line per bus - * underneath (route/direction/ETA), instead of stacking multiple icons on top of each other. - */ -private fun drawBusCluster( - canvas: android.graphics.Canvas, - emojiPaint: Paint, - smallLabelPaint: Paint, - buses: List, - x: Float, - y: Float, -) { - val anyLive = buses.any { it.isLive } - val clusterEmojiPaint = Paint(emojiPaint).apply { alpha = if (anyLive) 255 else SCHEDULED_BUS_ALPHA } - canvas.drawText("🚌", x, y, clusterEmojiPaint) - if (!anyLive) { - val clockBadgePaint = Paint(emojiPaint).apply { - textSize = emojiPaint.textSize * 0.5f - textAlign = Paint.Align.LEFT - } - canvas.drawText("🕐", x + emojiPaint.textSize * 0.3f, y - emojiPaint.textSize * 0.35f, clockBadgePaint) - } - buses.sortedBy { it.etaEpochSeconds }.forEachIndexed { index, bus -> - val alpha = if (bus.isLive) 255 else SCHEDULED_BUS_ALPHA - val linePaint = Paint(smallLabelPaint).apply { this.alpha = smallLabelPaint.alpha * alpha / 255 } - val badge = if (bus.isLive) "" else "🕐 " - val routeText = bus.lineLabel?.let { "$it - ${bus.routeLabel}" } ?: bus.routeLabel - val line = "$badge$routeText ${bus.directionLabel}: ${bus.etaDisplay()}" - canvas.drawText(line, x, y + 30f + index * 24f, linePaint) + canvas.drawText(it, x, y + 102f, smallLabelPaint) } } diff --git a/tool/src/main/kotlin/com/thelightphone/transit/NearbyStopsScreen.kt b/tool/src/main/kotlin/com/thelightphone/transit/NearbyStopsScreen.kt index bb7492f1..377328c5 100644 --- a/tool/src/main/kotlin/com/thelightphone/transit/NearbyStopsScreen.kt +++ b/tool/src/main/kotlin/com/thelightphone/transit/NearbyStopsScreen.kt @@ -28,6 +28,7 @@ import com.thelightphone.sdk.LightViewModel import com.thelightphone.sdk.SealedLightActivity import com.thelightphone.sdk.SimpleLightScreen import com.thelightphone.sdk.rememberKeyboardOptions +import com.thelightphone.sdk.ui.LightBarButton import com.thelightphone.sdk.ui.LightIcons import com.thelightphone.sdk.ui.LightText import com.thelightphone.sdk.ui.LightTextInputEditor @@ -35,6 +36,8 @@ import com.thelightphone.sdk.ui.LightTextVariant import com.thelightphone.sdk.ui.LightTheme import com.thelightphone.sdk.ui.LightThemeController import com.thelightphone.sdk.ui.LightThemeTokens +import com.thelightphone.sdk.ui.LightTopBar +import com.thelightphone.sdk.ui.LightTopBarCenter import com.thelightphone.sdk.ui.lightClickable import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow @@ -163,14 +166,12 @@ class NearbyStopsScreen( modifier = Modifier .fillMaxSize() .background(LightThemeTokens.colors.background) - .padding(32.dp) ) { - LightText( - text = "Nearby Stops", - variant = LightTextVariant.Heading, - modifier = Modifier.padding(bottom = 16.dp), + LightTopBar( + leftButton = LightBarButton.LightIcon(icon = LightIcons.BACK, onClick = { goBack() }), + center = LightTopBarCenter.Text("Nearby Stops"), ) - + Column(modifier = Modifier.weight(1f).padding(32.dp)) { when (m) { is NearbyStopsMode.Locating -> LightText( text = "Finding your approximate location...", @@ -250,11 +251,16 @@ class NearbyStopsScreen( .fillMaxWidth() .lightClickable { navigateTo(screenFactory = { activity -> + // A deduplicated station's own stopId (see + // GtfsRepository.groupStationsByParent) + // typically has no stop_times of its own -- + // a real child platform id is what schedule + // lookups need. Label stays the station's. UpcomingArrivalsScreen( activity, dbFile, agency, - stop.stopId, + stop.memberStopIds.first(), stop.displayLabel(), ) }) @@ -288,6 +294,7 @@ class NearbyStopsScreen( is NearbyStopsMode.LocationInput -> Unit } + } } } } diff --git a/tool/src/main/kotlin/com/thelightphone/transit/RouteSelectionScreen.kt b/tool/src/main/kotlin/com/thelightphone/transit/RouteSelectionScreen.kt index 1a36d3a6..e4305beb 100644 --- a/tool/src/main/kotlin/com/thelightphone/transit/RouteSelectionScreen.kt +++ b/tool/src/main/kotlin/com/thelightphone/transit/RouteSelectionScreen.kt @@ -21,11 +21,15 @@ import com.thelightphone.sdk.LightScreen import com.thelightphone.sdk.LightViewModel import com.thelightphone.sdk.SealedLightActivity import com.thelightphone.sdk.SimpleLightScreen +import com.thelightphone.sdk.ui.LightBarButton +import com.thelightphone.sdk.ui.LightIcons import com.thelightphone.sdk.ui.LightText import com.thelightphone.sdk.ui.LightTextVariant import com.thelightphone.sdk.ui.LightTheme import com.thelightphone.sdk.ui.LightThemeController import com.thelightphone.sdk.ui.LightThemeTokens +import com.thelightphone.sdk.ui.LightTopBar +import com.thelightphone.sdk.ui.LightTopBarCenter import com.thelightphone.sdk.ui.lightClickable import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow @@ -85,13 +89,12 @@ class RouteSelectionScreen( modifier = Modifier .fillMaxSize() .background(LightThemeTokens.colors.background) - .padding(32.dp) ) { - LightText( - text = "Choose Route", - variant = LightTextVariant.Heading, - modifier = Modifier.padding(bottom = 8.dp), + LightTopBar( + leftButton = LightBarButton.LightIcon(icon = LightIcons.BACK, onClick = { goBack() }), + center = LightTopBarCenter.Text("Choose Route"), ) + Column(modifier = Modifier.weight(1f).padding(32.dp)) { LightText( text = lineType.label, variant = LightTextVariant.Detail, @@ -142,6 +145,7 @@ class RouteSelectionScreen( } } } + } } } } diff --git a/tool/src/main/kotlin/com/thelightphone/transit/ScheduleLocationScreen.kt b/tool/src/main/kotlin/com/thelightphone/transit/ScheduleLocationScreen.kt index 66c61d39..2c27a4ed 100644 --- a/tool/src/main/kotlin/com/thelightphone/transit/ScheduleLocationScreen.kt +++ b/tool/src/main/kotlin/com/thelightphone/transit/ScheduleLocationScreen.kt @@ -23,6 +23,7 @@ import com.thelightphone.sdk.LightViewModel import com.thelightphone.sdk.SealedLightActivity import com.thelightphone.sdk.SimpleLightScreen import com.thelightphone.sdk.rememberKeyboardOptions +import com.thelightphone.sdk.ui.LightBarButton import com.thelightphone.sdk.ui.LightIcons import com.thelightphone.sdk.ui.LightText import com.thelightphone.sdk.ui.LightTextInputEditor @@ -30,6 +31,8 @@ import com.thelightphone.sdk.ui.LightTextVariant import com.thelightphone.sdk.ui.LightTheme import com.thelightphone.sdk.ui.LightThemeController import com.thelightphone.sdk.ui.LightThemeTokens +import com.thelightphone.sdk.ui.LightTopBar +import com.thelightphone.sdk.ui.LightTopBarCenter import com.thelightphone.sdk.ui.lightClickable import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow @@ -144,13 +147,12 @@ class ScheduleLocationScreen( modifier = Modifier .fillMaxSize() .background(LightThemeTokens.colors.background) - .padding(32.dp) ) { - LightText( - text = "Search Location", - variant = LightTextVariant.Heading, - modifier = Modifier.padding(bottom = 8.dp), + LightTopBar( + leftButton = LightBarButton.LightIcon(icon = LightIcons.BACK, onClick = { goBack() }), + center = LightTopBarCenter.Text("Search Location"), ) + Column(modifier = Modifier.weight(1f).padding(32.dp)) { LightText( text = "$routeLabel - $directionLabel", variant = LightTextVariant.Detail, @@ -229,6 +231,7 @@ class ScheduleLocationScreen( is ScheduleLocationMode.LocationInput -> Unit } + } } } } diff --git a/tool/src/main/kotlin/com/thelightphone/transit/SettingsScreen.kt b/tool/src/main/kotlin/com/thelightphone/transit/SettingsScreen.kt new file mode 100644 index 00000000..c7c5330a --- /dev/null +++ b/tool/src/main/kotlin/com/thelightphone/transit/SettingsScreen.kt @@ -0,0 +1,110 @@ +package com.thelightphone.transit + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewModelScope +import com.thelightphone.transit.gtfs.AgencyPreferences +import com.thelightphone.transit.gtfs.GtfsAgency +import com.thelightphone.sdk.LightScreen +import com.thelightphone.sdk.LightViewModel +import com.thelightphone.sdk.SealedLightActivity +import com.thelightphone.sdk.ui.LightBarButton +import com.thelightphone.sdk.ui.LightIcons +import com.thelightphone.sdk.ui.LightText +import com.thelightphone.sdk.ui.LightTextVariant +import com.thelightphone.sdk.ui.LightTheme +import com.thelightphone.sdk.ui.LightThemeController +import com.thelightphone.sdk.ui.LightThemeTokens +import com.thelightphone.sdk.ui.LightTopBar +import com.thelightphone.sdk.ui.LightTopBarCenter +import com.thelightphone.sdk.ui.lightClickable +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch + +class SettingsViewModel(private val preferences: AgencyPreferences) : LightViewModel() { + + val defaultAgency: StateFlow + get() = _defaultAgency + private val _defaultAgency = MutableStateFlow(null) + + init { + viewModelScope.launch { + preferences.defaultAgencyFlow.collect { _defaultAgency.value = it } + } + } + + /** Tapping the already-selected default clears it back to "no default", so there's a way to + * turn the auto-skip back off, not just switch which agency it points at. */ + fun toggleDefaultAgency(agency: GtfsAgency) { + viewModelScope.launch { + preferences.setDefaultAgency(if (defaultAgency.value == agency) null else agency) + } + } +} + +class SettingsScreen( + sealedActivity: SealedLightActivity, +) : LightScreen(sealedActivity) { + + override val viewModelClass: Class + get() = SettingsViewModel::class.java + + override fun createViewModel(): SettingsViewModel = + SettingsViewModel(AgencyPreferences(lightContext.dataStore)) + + @Composable + override fun Content() { + val defaultAgency by viewModel.defaultAgency.collectAsState() + val themeColors by LightThemeController.colors.collectAsState() + + LightTheme(colors = themeColors) { + Column( + modifier = Modifier + .fillMaxSize() + .background(LightThemeTokens.colors.background) + ) { + LightTopBar( + leftButton = LightBarButton.LightIcon(icon = LightIcons.BACK, onClick = { goBack() }), + center = LightTopBarCenter.Text("Settings"), + ) + Column(modifier = Modifier.weight(1f).padding(32.dp)) { + LightText( + text = "Default agency", + variant = LightTextVariant.Copy, + lighten = true, + modifier = Modifier.padding(bottom = 8.dp), + ) + LightText( + text = "Tap your agency to skip picking it every launch. Tap it again to turn that off.", + variant = LightTextVariant.Detail, + lighten = true, + modifier = Modifier.padding(bottom = 16.dp), + ) + + Column { + GtfsAgency.entries.forEach { agency -> + LightText( + text = if (agency == defaultAgency) "${agency.displayName} (default)" else agency.displayName, + variant = LightTextVariant.Copy, + lighten = agency != defaultAgency, + modifier = Modifier + .fillMaxWidth() + .lightClickable { viewModel.toggleDefaultAgency(agency) } + .padding(vertical = 12.dp), + ) + } + } + } + } + } + } +} diff --git a/tool/src/main/kotlin/com/thelightphone/transit/StopConnectionsScreen.kt b/tool/src/main/kotlin/com/thelightphone/transit/StopConnectionsScreen.kt index 8bf2c2c4..f6f5f4ef 100644 --- a/tool/src/main/kotlin/com/thelightphone/transit/StopConnectionsScreen.kt +++ b/tool/src/main/kotlin/com/thelightphone/transit/StopConnectionsScreen.kt @@ -25,11 +25,15 @@ import com.thelightphone.sdk.LightScreen import com.thelightphone.sdk.LightViewModel import com.thelightphone.sdk.SealedLightActivity import com.thelightphone.sdk.SimpleLightScreen +import com.thelightphone.sdk.ui.LightBarButton +import com.thelightphone.sdk.ui.LightIcons import com.thelightphone.sdk.ui.LightText import com.thelightphone.sdk.ui.LightTextVariant import com.thelightphone.sdk.ui.LightTheme import com.thelightphone.sdk.ui.LightThemeController import com.thelightphone.sdk.ui.LightThemeTokens +import com.thelightphone.sdk.ui.LightTopBar +import com.thelightphone.sdk.ui.LightTopBarCenter import com.thelightphone.sdk.ui.lightClickable import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow @@ -107,13 +111,12 @@ class StopConnectionsScreen( modifier = Modifier .fillMaxSize() .background(LightThemeTokens.colors.background) - .padding(32.dp) ) { - LightText( - text = "Connections", - variant = LightTextVariant.Heading, - modifier = Modifier.padding(bottom = 8.dp), + LightTopBar( + leftButton = LightBarButton.LightIcon(icon = LightIcons.BACK, onClick = { goBack() }), + center = LightTopBarCenter.Text("Connections"), ) + Column(modifier = Modifier.weight(1f).padding(32.dp)) { LightText( text = "$stopLabel - After ${formatGtfsTime(afterTime)}", variant = LightTextVariant.Detail, @@ -178,6 +181,7 @@ class StopConnectionsScreen( } } } + } } } } diff --git a/tool/src/main/kotlin/com/thelightphone/transit/TripDetailScreen.kt b/tool/src/main/kotlin/com/thelightphone/transit/TripDetailScreen.kt index ac55dd50..31229794 100644 --- a/tool/src/main/kotlin/com/thelightphone/transit/TripDetailScreen.kt +++ b/tool/src/main/kotlin/com/thelightphone/transit/TripDetailScreen.kt @@ -23,11 +23,15 @@ import com.thelightphone.sdk.LightScreen import com.thelightphone.sdk.LightViewModel import com.thelightphone.sdk.SealedLightActivity import com.thelightphone.sdk.SimpleLightScreen +import com.thelightphone.sdk.ui.LightBarButton +import com.thelightphone.sdk.ui.LightIcons import com.thelightphone.sdk.ui.LightText import com.thelightphone.sdk.ui.LightTextVariant import com.thelightphone.sdk.ui.LightTheme import com.thelightphone.sdk.ui.LightThemeController import com.thelightphone.sdk.ui.LightThemeTokens +import com.thelightphone.sdk.ui.LightTopBar +import com.thelightphone.sdk.ui.LightTopBarCenter import com.thelightphone.sdk.ui.lightClickable import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow @@ -94,13 +98,12 @@ class TripDetailScreen( modifier = Modifier .fillMaxSize() .background(LightThemeTokens.colors.background) - .padding(32.dp) ) { - LightText( - text = "Trip Detail", - variant = LightTextVariant.Heading, - modifier = Modifier.padding(bottom = 8.dp), + LightTopBar( + leftButton = LightBarButton.LightIcon(icon = LightIcons.BACK, onClick = { goBack() }), + center = LightTopBarCenter.Text("Trip Detail"), ) + Column(modifier = Modifier.weight(1f).padding(32.dp)) { LightText( text = "$routeLabel ($directionLabel)", variant = LightTextVariant.Detail, @@ -168,6 +171,7 @@ class TripDetailScreen( } } } + } } } } diff --git a/tool/src/main/kotlin/com/thelightphone/transit/UpcomingArrivalsScreen.kt b/tool/src/main/kotlin/com/thelightphone/transit/UpcomingArrivalsScreen.kt index b2ed5896..4fd2bec4 100644 --- a/tool/src/main/kotlin/com/thelightphone/transit/UpcomingArrivalsScreen.kt +++ b/tool/src/main/kotlin/com/thelightphone/transit/UpcomingArrivalsScreen.kt @@ -30,11 +30,15 @@ import com.thelightphone.sdk.LightScreen import com.thelightphone.sdk.LightViewModel import com.thelightphone.sdk.SealedLightActivity import com.thelightphone.sdk.SimpleLightScreen +import com.thelightphone.sdk.ui.LightBarButton +import com.thelightphone.sdk.ui.LightIcons import com.thelightphone.sdk.ui.LightText import com.thelightphone.sdk.ui.LightTextVariant import com.thelightphone.sdk.ui.LightTheme import com.thelightphone.sdk.ui.LightThemeController import com.thelightphone.sdk.ui.LightThemeTokens +import com.thelightphone.sdk.ui.LightTopBar +import com.thelightphone.sdk.ui.LightTopBarCenter import com.thelightphone.sdk.ui.lightClickable import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow @@ -172,13 +176,12 @@ class UpcomingArrivalsScreen( modifier = Modifier .fillMaxSize() .background(LightThemeTokens.colors.background) - .padding(32.dp) ) { - LightText( - text = "Upcoming Arrivals", - variant = LightTextVariant.Heading, - modifier = Modifier.padding(bottom = 8.dp), + LightTopBar( + leftButton = LightBarButton.LightIcon(icon = LightIcons.BACK, onClick = { goBack() }), + center = LightTopBarCenter.Text("Upcoming Arrivals"), ) + Column(modifier = Modifier.weight(1f).padding(32.dp)) { LightText( text = stopLabel, variant = LightTextVariant.Detail, @@ -186,12 +189,12 @@ class UpcomingArrivalsScreen( modifier = Modifier.padding(bottom = 8.dp), ) LightText( - text = "ETA", + text = "Map", variant = LightTextVariant.Copy, modifier = Modifier .lightClickable { navigateTo(screenFactory = { activity -> - EtaRadarScreen(activity, dbFile, agency, stopId, stopLabel) + MapScreen(activity, dbFile, agency, stopId, stopLabel) }) } .padding(bottom = 16.dp), @@ -284,6 +287,7 @@ class UpcomingArrivalsScreen( } } } + } } } } diff --git a/tool/src/main/kotlin/com/thelightphone/transit/gtfs/AgencyPreferences.kt b/tool/src/main/kotlin/com/thelightphone/transit/gtfs/AgencyPreferences.kt new file mode 100644 index 00000000..34abd14f --- /dev/null +++ b/tool/src/main/kotlin/com/thelightphone/transit/gtfs/AgencyPreferences.kt @@ -0,0 +1,33 @@ +package com.thelightphone.transit.gtfs + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +private val defaultAgencyKey = stringPreferencesKey("DEFAULT_AGENCY") + +/** + * The user's default agency (Settings screen), persisted via the SDK's Preferences DataStore -- + * the same mechanism LightPushManager uses elsewhere in the SDK for simple key-value settings. + * When set, HomeScreen skips the manual agency-tap on launch and goes straight into that agency's + * data. + */ +class AgencyPreferences(private val dataStore: DataStore) { + + val defaultAgencyFlow: Flow = dataStore.data.map { prefs -> + prefs[defaultAgencyKey]?.let { id -> GtfsAgency.entries.find { it.id == id } } + } + + suspend fun setDefaultAgency(agency: GtfsAgency?) { + dataStore.edit { prefs -> + if (agency == null) { + prefs.remove(defaultAgencyKey) + } else { + prefs[defaultAgencyKey] = agency.id + } + } + } +} diff --git a/tool/src/main/kotlin/com/thelightphone/transit/gtfs/GtfsIngestor.kt b/tool/src/main/kotlin/com/thelightphone/transit/gtfs/GtfsIngestor.kt index 11273c74..bdec6d76 100644 --- a/tool/src/main/kotlin/com/thelightphone/transit/gtfs/GtfsIngestor.kt +++ b/tool/src/main/kotlin/com/thelightphone/transit/gtfs/GtfsIngestor.kt @@ -1,17 +1,19 @@ package com.thelightphone.transit.gtfs import android.database.sqlite.SQLiteDatabase +import android.util.Log import io.ktor.client.HttpClient import io.ktor.client.call.body import io.ktor.client.engine.okhttp.OkHttp import io.ktor.client.request.get +import io.ktor.client.request.head import io.ktor.http.HttpHeaders import java.io.BufferedReader import java.io.File import java.util.zip.ZipInputStream enum class GtfsIngestStatus { - Downloading, Parsing, Ready + CheckingForUpdates, Downloading, Parsing, Ready } class GtfsIngestException(message: String, cause: Throwable? = null) : Exception(message, cause) @@ -22,19 +24,50 @@ private const val MAX_REDIRECTS = 5 fun gtfsDbFile(filesDir: File, agency: GtfsAgency): File = File(filesDir, "gtfs/${agency.id}/transit.db") +/** ETag/Last-Modified as last seen for a successfully-downloaded feed -- either may be blank if the + * server didn't send that particular header. */ +private data class FeedMeta(val etag: String, val lastModified: String) { + fun isEmpty() = etag.isBlank() && lastModified.isBlank() +} + /** Downloads, unzips, and bulk-loads an agency's GTFS static feed into a local SQLite database. */ class GtfsIngestor(private val filesDir: File) { + /** + * Re-downloads only when the feed has actually changed: a HEAD request's ETag/Last-Modified is + * compared against what was stored alongside the cached database from the last successful + * download. Unchanged -> the existing SQLite database is used as-is, no network transfer beyond + * the HEAD request. Changed, or nothing cached yet, or the check itself is inconclusive -> falls + * back to a full re-download, since that's always safe (just not always necessary). + */ suspend fun ingest(agency: GtfsAgency, onStatus: (GtfsIngestStatus) -> Unit) { val agencyDir = File(filesDir, "gtfs/${agency.id}") agencyDir.mkdirs() val zipFile = File(agencyDir, "gtfs.zip") val dbFile = gtfsDbFile(filesDir, agency) + val metaFile = File(agencyDir, "feed_meta.txt") + + onStatus(GtfsIngestStatus.CheckingForUpdates) + val cachedMeta = readFeedMeta(metaFile) + val remoteMeta = try { + checkForUpdate(agency.feedUrl) + } catch (e: Exception) { + Log.e("GtfsIngestor", "Feed update check failed for ${agency.displayName}, redownloading to be safe", e) + null + } + + val upToDate = dbFile.exists() && cachedMeta != null && remoteMeta != null && + !cachedMeta.isEmpty() && cachedMeta == remoteMeta + if (upToDate) { + onStatus(GtfsIngestStatus.Ready) + return + } onStatus(GtfsIngestStatus.Downloading) downloadZip(agency.feedUrl, zipFile) onStatus(GtfsIngestStatus.Parsing) + dbFile.delete() val db = openGtfsDatabase(dbFile) try { parseAndLoad(zipFile, db) @@ -42,9 +75,57 @@ class GtfsIngestor(private val filesDir: File) { db.close() } + remoteMeta?.let { writeFeedMeta(metaFile, it) } onStatus(GtfsIngestStatus.Ready) } + /** A HEAD request's ETag/Last-Modified, or null if the check couldn't be completed -- follows + * the same manual redirect handling as [downloadZip], since a HEAD to the same URL can hit the + * same http hop. */ + private suspend fun checkForUpdate(url: String): FeedMeta? { + val client = HttpClient(OkHttp) { + followRedirects = false + } + try { + var currentUrl = url + repeat(MAX_REDIRECTS + 1) { + val response = client.head(currentUrl) + val status = response.status.value + when { + status in 200..299 -> { + return FeedMeta( + etag = response.headers[HttpHeaders.ETag].orEmpty(), + lastModified = response.headers[HttpHeaders.LastModified].orEmpty(), + ) + } + status in 300..399 -> { + val location = response.headers[HttpHeaders.Location] ?: return null + currentUrl = if (location.startsWith("http://")) { + "https://" + location.removePrefix("http://") + } else { + location + } + } + else -> return null + } + } + return null + } finally { + client.close() + } + } + + private fun readFeedMeta(file: File): FeedMeta? { + if (!file.exists()) return null + val lines = file.readLines() + if (lines.size < 2) return null + return FeedMeta(etag = lines[0], lastModified = lines[1]) + } + + private fun writeFeedMeta(file: File, meta: FeedMeta) { + file.writeText("${meta.etag}\n${meta.lastModified}\n") + } + /** * Some feeds (e.g. RIPTA) redirect through a plain-HTTP hop before landing on HTTPS. * Ktor's HttpRedirect plugin refuses to follow an HTTPS->HTTP downgrade by default (and diff --git a/tool/src/main/kotlin/com/thelightphone/transit/gtfs/GtfsRealtime.kt b/tool/src/main/kotlin/com/thelightphone/transit/gtfs/GtfsRealtime.kt index 0a731bfc..4c587c87 100644 --- a/tool/src/main/kotlin/com/thelightphone/transit/gtfs/GtfsRealtime.kt +++ b/tool/src/main/kotlin/com/thelightphone/transit/gtfs/GtfsRealtime.kt @@ -58,20 +58,47 @@ object GtfsRtVehicleStatus { const val IN_TRANSIT_TO = 2 } +/** + * Field numbers here were re-verified by hand-decoding real live bytes from *both* MBTA and RIPTA + * (not just assumed from the public spec, which turned out to be wrong for field 4): 3 and 4 were + * confirmed as genuinely distinct fields by finding MBTA messages where both appear at once with + * clearly different value ranges (3 = larger, sequence-like numbers; 4 = a small 0-2 enum-like + * range). Neither agency's live feed ever sends a `stop_id` string at the top level — the field the + * public spec places at 4 — so that property doesn't exist here at all; declaring it as a String + * there is what caused every previous crash (a real varint on the wire, decoded as a string). + * Fields 7/8 (a bare vehicle-number string and a [GtfsRtVehicleDescriptor]) are unused by this app + * but still declared, since an undeclared field previously faulted the whole decode instead of + * being skipped. + */ @Serializable data class GtfsRtVehiclePosition( @ProtoNumber(1) val trip: GtfsRtTripDescriptor = GtfsRtTripDescriptor(), @ProtoNumber(2) val position: GtfsRtPosition? = null, @ProtoNumber(3) val currentStopSequence: Int? = null, - @ProtoNumber(4) val stopId: String? = null, - @ProtoNumber(5) val currentStatus: Int? = null, + @ProtoNumber(4) val currentStatus: Int? = null, + @ProtoNumber(5) val timestamp: Long? = null, + @ProtoNumber(7) val vehicleNumber: String? = null, + @ProtoNumber(8) val vehicle: GtfsRtVehicleDescriptor? = null, +) + +@Serializable +data class GtfsRtVehicleDescriptor( + @ProtoNumber(1) val id: String? = null, + @ProtoNumber(2) val label: String? = null, + @ProtoNumber(3) val licensePlate: String? = null, ) -/** lat/lon are proto `float` (4-byte), not `double` — verified against MBTA's live feed bytes. */ +/** + * lat/lon are proto `float` (4-byte), not `double` — verified against MBTA's live feed bytes. + * bearing/speed declared for the same reason as [GtfsRtVehiclePosition]'s trailing fields — RIPTA's + * feed includes them, and an undeclared field faulted the whole decode instead of being skipped. + */ @Serializable data class GtfsRtPosition( @ProtoNumber(1) val latitude: Float = 0f, @ProtoNumber(2) val longitude: Float = 0f, + @ProtoNumber(3) val bearing: Float? = null, + @ProtoNumber(5) val speed: Float? = null, ) @Serializable @@ -85,9 +112,18 @@ data class GtfsRtTripUpdate( ?: stopTimeUpdate.find { it.stopSequence == stopSequence } } +/** + * RIPTA's feed also sends start_time/start_date/route_id here (verified by hand-decoding RIPTA's + * real feed bytes) — declared even though unused, since an undeclared field in a *nested* message + * desynced the decoder's byte position for everything decoded after it, corrupting the rest of the + * enclosing VehiclePosition/TripUpdate rather than just being harmlessly skipped. + */ @Serializable data class GtfsRtTripDescriptor( @ProtoNumber(1) val tripId: String = "", + @ProtoNumber(2) val startTime: String? = null, + @ProtoNumber(3) val startDate: String? = null, + @ProtoNumber(5) val routeId: String? = null, ) @Serializable diff --git a/tool/src/main/kotlin/com/thelightphone/transit/gtfs/GtfsRepository.kt b/tool/src/main/kotlin/com/thelightphone/transit/gtfs/GtfsRepository.kt index 3c532f96..8e7848db 100644 --- a/tool/src/main/kotlin/com/thelightphone/transit/gtfs/GtfsRepository.kt +++ b/tool/src/main/kotlin/com/thelightphone/transit/gtfs/GtfsRepository.kt @@ -26,10 +26,10 @@ data class RouteOption( * light rail, e.g. MBTA's Green Line) is bucketed with Subway rather than broken out separately, * since that's how riders colloquially refer to it. */ -enum class LineType(val gtfsRouteTypes: Set, val label: String) { - SUBWAY(setOf(0, 1), "Subway"), - COMMUTER_RAIL(setOf(2), "Commuter Rail"), - BUS(setOf(3), "Bus"); +enum class LineType(val gtfsRouteTypes: Set, val label: String, val emoji: String) { + SUBWAY(setOf(0, 1), "Subway", "🚇"), + COMMUTER_RAIL(setOf(2), "Commuter Rail", "🚆"), + BUS(setOf(3), "Bus", "🚌"); companion object { fun forGtfsRouteType(routeType: Int): LineType? = entries.find { routeType in it.gtfsRouteTypes } @@ -51,6 +51,8 @@ data class StopWithDistance( val lat: Double, val lon: Double, val distanceMeters: Double, + /** See [StopLocation.memberStopIds]. */ + val memberStopIds: List, ) data class Departure( @@ -76,7 +78,19 @@ data class StopConnection( val direction: DirectionOption, ) -data class StopLocation(val stopId: String, val stopName: String?, val lat: Double, val lon: Double) +data class StopLocation( + val stopId: String, + val stopName: String?, + val lat: Double, + val lon: Double, + /** + * The real, queryable stop_id(s) this location represents. For a plain standalone stop this is + * just its own id. For a deduplicated GTFS station (see [groupStationsByParent]) this is every + * child platform/entrance stop_id grouped under it -- stations themselves typically have no + * stop_times of their own, so schedule/arrival lookups need a real child id, not the station's. + */ + val memberStopIds: List, +) data class ScheduledArrival( val tripId: String, @@ -271,23 +285,33 @@ class GtfsRepository(dbFile: File) { } } - /** Every stop with valid coordinates, for nearest-stop distance ranking. */ + /** + * Every stop with valid coordinates, for nearest-stop distance ranking -- deduplicated per GTFS + * station grouping (see [groupStationsByParent]), shared by both [rankStopsByDistance] (the + * stop search flow) and [getStopsWithinRadius] (Map screen markers) so a physical station with + * several platform-level stop_ids appears once, not once per platform. + */ fun getStopsWithLocation(): List = db.rawQuery( - "SELECT stop_id, stop_name, stop_lat, stop_lon FROM stops WHERE stop_lat IS NOT NULL AND stop_lon IS NOT NULL", + "SELECT stop_id, stop_name, stop_lat, stop_lon, parent_station FROM stops " + + "WHERE stop_lat IS NOT NULL AND stop_lon IS NOT NULL", null, ).use { cursor -> - cursor.mapRows { - StopLocation( + val rows = cursor.mapRows { + RawStopRow( stopId = getString(0), stopName = getStringOrNull(1), lat = getDouble(2), lon = getDouble(3), + parentStation = getStringOrNull(4), ) } + groupStationsByParent(rows) } - /** A single stop's coordinates, for the ETA radar screen's bearing/distance math. */ + /** A single stop's coordinates, for the ETA radar screen's bearing/distance math. Looked up + * directly by id -- not deduplicated, since callers already have a specific, resolved stop_id + * in hand (e.g. the one a schedule/arrival was actually looked up for). */ fun getStopLocation(stopId: String): StopLocation? = db.rawQuery( "SELECT stop_id, stop_name, stop_lat, stop_lon FROM stops WHERE stop_id = ? AND stop_lat IS NOT NULL AND stop_lon IS NOT NULL", @@ -299,6 +323,7 @@ class GtfsRepository(dbFile: File) { stopName = getStringOrNull(1), lat = getDouble(2), lon = getDouble(3), + memberStopIds = listOf(getString(0)), ) }.firstOrNull() } @@ -347,39 +372,10 @@ class GtfsRepository(dbFile: File) { } } - /** - * The stop immediately before [beforeStopSequence] in [tripId]'s stop_sequence, if any. Used - * to approximate a scheduled (non-live) bus's approach bearing when no VehiclePosition exists - * for it — see EtaRadarScreen. Returns null for a trip's first stop (nothing precedes it). - */ - fun getPreviousStopLocation(tripId: String, beforeStopSequence: Int): StopLocation? = - db.rawQuery( - """ - SELECT s.stop_id, s.stop_name, s.stop_lat, s.stop_lon - FROM stop_times st - JOIN stops s ON s.stop_id = st.stop_id - WHERE st.trip_id = ? AND st.stop_sequence < ? - AND s.stop_lat IS NOT NULL AND s.stop_lon IS NOT NULL - ORDER BY st.stop_sequence DESC - LIMIT 1 - """, - arrayOf(tripId, beforeStopSequence.toString()), - ).use { cursor -> - cursor.mapRows { - StopLocation( - stopId = getString(0), - stopName = getStringOrNull(1), - lat = getDouble(2), - lon = getDouble(3), - ) - }.firstOrNull() - } - /** * Every stop with coordinates, ranked by distance from ([anchorLat], [anchorLon]), nearest - * first, capped at [limit]. Shared by the "Leave Now" nearby-stops flow (anchored at a - * geocoded search point) and the ETA radar's nearby-stops overlay (anchored at the selected - * stop), so both draw from the same ranking logic rather than duplicating it. + * first, capped at [limit]. Used by the "Leave Now" nearby-stops flow, anchored at a geocoded + * search point. */ fun rankStopsByDistance( anchorLat: Double, @@ -389,7 +385,7 @@ class GtfsRepository(dbFile: File) { ): List = getStopsWithLocation() .asSequence() - .filter { it.stopId != excludeStopId } + .filter { excludeStopId == null || excludeStopId !in it.memberStopIds } .map { stop -> StopWithDistance( stopId = stop.stopId, @@ -397,17 +393,107 @@ class GtfsRepository(dbFile: File) { lat = stop.lat, lon = stop.lon, distanceMeters = haversineMeters(anchorLat, anchorLon, stop.lat, stop.lon), + memberStopIds = stop.memberStopIds, ) } .sortedBy { it.distanceMeters } .take(limit) .toList() + /** + * Every stop with coordinates that's actually within [radiusMeters] of ([anchorLat], + * [anchorLon]) — true geographic containment, not a "nearest N" ranking, so what's returned + * matches exactly what's visible on the map rather than an arbitrary top-K cutoff. Used by + * MapScreen to plot nearby stops at their real positions. [maxResults] is only a safety cap for + * unusually stop-dense areas, not the intended selection method. + */ + fun getStopsWithinRadius( + anchorLat: Double, + anchorLon: Double, + radiusMeters: Double, + excludeStopId: String? = null, + maxResults: Int = 60, + ): List = + getStopsWithLocation() + .asSequence() + .filter { excludeStopId == null || excludeStopId !in it.memberStopIds } + .map { stop -> + StopWithDistance( + stopId = stop.stopId, + stopName = stop.stopName, + lat = stop.lat, + lon = stop.lon, + distanceMeters = haversineMeters(anchorLat, anchorLon, stop.lat, stop.lon), + memberStopIds = stop.memberStopIds, + ) + } + .filter { it.distanceMeters <= radiusMeters } + .sortedBy { it.distanceMeters } + .take(maxResults) + .toList() + fun close() { db.close() } } +/** One `stops` row, exactly as needed for [groupStationsByParent]. Internal rather than private + * so the grouping logic (pure data transformation, no Android/SQLite dependency) is unit-testable + * without needing a real database. */ +internal data class RawStopRow( + val stopId: String, + val stopName: String?, + val lat: Double, + val lon: Double, + val parentStation: String?, +) + +/** + * Groups GTFS child platforms/entrances (`location_type=0` rows with a populated `parent_station`) + * under their parent station (`location_type=1`) record, per the GTFS spec, so a single physical + * station with several platform-level stop_ids is represented once instead of once per platform. + * Only the `parent_station` linkage matters here, not `location_type` itself -- a row with no + * `parent_station` is its own representative regardless of whether it's a true station or a simple + * standalone stop, and either way any rows pointing at it via `parent_station` get folded into it. + * + * If a `parent_station` value doesn't resolve to any row with coordinates (a missing station record, + * or one without lat/lon), the first child (by stop_id, for determinism) is promoted to represent + * the group instead of silently dropping those stops from the results. + */ +internal fun groupStationsByParent(rows: List): List { + val (withParent, withoutParent) = rows.partition { !it.parentStation.isNullOrBlank() } + val childrenByParent = withParent.groupBy { it.parentStation!! } + + val result = mutableListOf() + val claimedParentIds = mutableSetOf() + + withoutParent.forEach { row -> + val childIds = childrenByParent[row.stopId]?.map { it.stopId }?.sorted() + result += StopLocation( + stopId = row.stopId, + stopName = row.stopName, + lat = row.lat, + lon = row.lon, + memberStopIds = childIds ?: listOf(row.stopId), + ) + claimedParentIds += row.stopId + } + + childrenByParent.forEach { (parentId, children) -> + if (parentId in claimedParentIds) return@forEach + val representative = children.minBy { it.stopId } + result += StopLocation( + stopId = representative.stopId, + stopName = representative.stopName, + lat = representative.lat, + lon = representative.lon, + memberStopIds = children.map { it.stopId }.sorted(), + ) + } + + return result +} + private const val EARTH_RADIUS_METERS = 6_371_000.0 /** Great-circle distance between two lat/lon points, in meters. */ diff --git a/tool/src/main/kotlin/com/thelightphone/transit/gtfs/MapTiles.kt b/tool/src/main/kotlin/com/thelightphone/transit/gtfs/MapTiles.kt new file mode 100644 index 00000000..cb43e39b --- /dev/null +++ b/tool/src/main/kotlin/com/thelightphone/transit/gtfs/MapTiles.kt @@ -0,0 +1,215 @@ +package com.thelightphone.transit.gtfs + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.util.Log +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.engine.okhttp.OkHttp +import io.ktor.client.request.get +import io.ktor.client.request.header +import io.ktor.http.isSuccess +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import kotlin.math.PI +import kotlin.math.abs +import kotlin.math.ceil +import kotlin.math.cos +import kotlin.math.floor +import kotlin.math.ln +import kotlin.math.max +import kotlin.math.min +import kotlin.math.pow +import kotlin.math.tan + +/** + * Standard Web Mercator "slippy map" tile math (see the OSM wiki's "Slippy map tilenames" page) — + * shared by both the background tile fetch and marker placement so everything drawn on the map + * screen comes from one consistent, real-world-accurate coordinate system. + */ +private const val TILE_SIZE = 256.0 +private const val EARTH_CIRCUMFERENCE_METERS = 40_075_016.686 + +/** Fractional (x, y) tile coordinates for (lat, lon) at the given integer zoom. */ +fun lonLatToTileFraction(lat: Double, lon: Double, zoom: Int): Pair { + val n = 2.0.pow(zoom) + val x = (lon + 180.0) / 360.0 * n + val latRad = Math.toRadians(lat) + val y = (1.0 - ln(tan(latRad) + 1.0 / cos(latRad)) / PI) / 2.0 * n + return x to y +} + +/** Ground resolution (meters/pixel) at [lat] and integer [zoom]. */ +fun metersPerPixel(lat: Double, zoom: Int): Double { + val latRad = Math.toRadians(lat) + return (EARTH_CIRCUMFERENCE_METERS * cos(latRad)) / (TILE_SIZE * 2.0.pow(zoom)) +} + +/** + * The integer zoom that fits every (lat, lon) in [points] within [availableHalfExtentPx] of + * (centerLat, centerLon) in both axes, clamped to [minZoom]..[maxZoom]. Unlike a textbook + * "fitBounds" (which re-centers on the bounding box's own centroid), this fits the box around a + * *fixed* center point — the map screen's selected stop must always render at dead center, so each + * point's offset is measured from (centerLat, centerLon) directly, not from the box's own middle. + * A dense cluster of nearby points naturally yields a high (zoomed-in) result; a lone distant point + * naturally yields a low one — no per-agency density special-casing needed. + * + * Falls back to [fallbackZoom] when [points] is empty or every point is ~coincident with the + * center, since the fit-to-bounds formula is undefined for a zero-size box. + */ +fun fitBoundsZoom( + centerLat: Double, + centerLon: Double, + points: List>, + availableHalfExtentPx: Float, + minZoom: Int, + maxZoom: Int, + fallbackZoom: Int, +): Int { + // Computed at zoom 0 (n = 1) as a zoom-independent baseline -- offsets at any real zoom z are + // this baseline scaled by 2^z, since both x and the Mercator-projected y are linear in n. + val (centerFracX, centerFracY) = lonLatToTileFraction(centerLat, centerLon, 0) + var maxDx = 0.0 + var maxDy = 0.0 + for ((lat, lon) in points) { + val (fracX, fracY) = lonLatToTileFraction(lat, lon, 0) + maxDx = max(maxDx, abs(fracX - centerFracX)) + maxDy = max(maxDy, abs(fracY - centerFracY)) + } + + val epsilon = 1e-9 + if (maxDx < epsilon && maxDy < epsilon) { + return fallbackZoom.coerceIn(minZoom, maxZoom) + } + + val zForX = if (maxDx > epsilon) ln(availableHalfExtentPx / (maxDx * TILE_SIZE)) / ln(2.0) else Double.POSITIVE_INFINITY + val zForY = if (maxDy > epsilon) ln(availableHalfExtentPx / (maxDy * TILE_SIZE)) / ln(2.0) else Double.POSITIVE_INFINITY + return floor(min(zForX, zForY)).toInt().coerceIn(minZoom, maxZoom) +} + +data class PixelPoint(val x: Float, val y: Float) + +/** + * Pixel offset of (lat, lon) relative to (centerLat, centerLon) at the given zoom — the same + * projection tile placement uses, but usable on its own for marker placement even when a tile + * failed to fetch, so bearing/distance-accurate placement never depends on the tile network call + * succeeding. + */ +fun projectRelativeToCenter(centerLat: Double, centerLon: Double, lat: Double, lon: Double, zoom: Int): PixelPoint { + val (centerX, centerY) = lonLatToTileFraction(centerLat, centerLon, zoom) + val (pointX, pointY) = lonLatToTileFraction(lat, lon, zoom) + return PixelPoint( + x = ((pointX - centerX) * TILE_SIZE).toFloat(), + y = ((pointY - centerY) * TILE_SIZE).toFloat(), + ) +} + +/** One fetched map tile and its integer tile coordinates at the map's zoom level. */ +data class FetchedTile(val tileX: Int, val tileY: Int, val bitmap: Bitmap) + +/** + * Every tile fetched to cover the requested area around one center point, plus the fractional tile + * coordinates of that center point. Each tile is drawn independently at its own screen offset via + * [screenOffset] — there's no pre-stitched composite bitmap, so a handful of individually-failed + * tiles just leave that one patch blank instead of requiring a grid size guessed to be "big enough" + * up front. + */ +data class MapTiles( + val zoom: Int, + val centerFracX: Double, + val centerFracY: Double, + val tiles: List, +) { + /** This tile's pixel offset relative to the center point, at the map's zoom level. */ + fun screenOffset(tileX: Int, tileY: Int): PixelPoint = PixelPoint( + x = ((tileX - centerFracX) * TILE_SIZE).toFloat(), + y = ((tileY - centerFracY) * TILE_SIZE).toFloat(), + ) +} + +/** + * A small in-process LRU of decoded tile bitmaps, keyed by "z/x/y", shared by every [MapTileClient] + * instance and screen visit in this app session — revisiting the Map screen for the same or a + * nearby stop reuses tiles already downloaded instead of refetching them. Capacity is a tile count, + * not a byte budget, since every raster tile is the same 256x256 size. + */ +private object TileCache { + private const val MAX_ENTRIES = 300 + private val entries = object : LinkedHashMap(16, 0.75f, true) { + override fun removeEldestEntry(eldest: MutableMap.MutableEntry) = size > MAX_ENTRIES + } + + @Synchronized + fun get(key: String): Bitmap? = entries[key] + + @Synchronized + fun put(key: String, bitmap: Bitmap) { + entries[key] = bitmap + } +} + +/** + * Fetches individual raster tiles from CARTO's free Dark Matter basemap (built on OpenStreetMap + * data), caching decoded bitmaps in [TileCache]. A real, descriptive User-Agent is sent on every + * request, and on-screen "© OpenStreetMap contributors © CARTO" attribution is required wherever + * these tiles are displayed — see MapScreen's Content(). + */ +class MapTileClient { + private val client = HttpClient(OkHttp) + + companion object { + private const val TILE_BASE_URL = "https://a.basemaps.cartocdn.com/dark_all" + private const val USER_AGENT = "LightTransitTool/1.0 (+https://github.com/lightphone)" + // Fetched area is this much larger than the target radius, so the real device canvas (whose + // exact size isn't known yet when tiles are requested) ends up comfortably inside the + // fetched area rather than right at its edge. + private const val COVERAGE_MARGIN = 1.3 + } + + /** + * Every tile needed to cover a [targetRadiusMeters] circle around (lat, lon) at [zoom], fetched + * concurrently. Individual tile failures are logged and simply omitted from the result — never + * fail the whole map for one bad tile. + */ + suspend fun fetchTilesAround(lat: Double, lon: Double, zoom: Int, targetRadiusMeters: Double): MapTiles = coroutineScope { + val (centerFracX, centerFracY) = lonLatToTileFraction(lat, lon, zoom) + val radiusPixels = targetRadiusMeters / metersPerPixel(lat, zoom) * COVERAGE_MARGIN + val radiusTiles = ceil(radiusPixels / TILE_SIZE).toInt().coerceAtLeast(1) + val centerTileX = floor(centerFracX).toInt() + val centerTileY = floor(centerFracY).toInt() + + val tileCoords = buildList { + for (tileX in (centerTileX - radiusTiles)..(centerTileX + radiusTiles)) { + for (tileY in (centerTileY - radiusTiles)..(centerTileY + radiusTiles)) { + add(tileX to tileY) + } + } + } + val tiles = tileCoords + .map { (tileX, tileY) -> async { fetchTile(tileX, tileY, zoom)?.let { FetchedTile(tileX, tileY, it) } } } + .mapNotNull { it.await() } + + MapTiles(zoom, centerFracX, centerFracY, tiles) + } + + private suspend fun fetchTile(x: Int, y: Int, zoom: Int): Bitmap? { + val key = "$zoom/$x/$y" + TileCache.get(key)?.let { return it } + + return try { + val response = client.get("$TILE_BASE_URL/$zoom/$x/$y.png") { + header("User-Agent", USER_AGENT) + } + if (!response.status.isSuccess()) return null + val bytes: ByteArray = response.body() + BitmapFactory.decodeByteArray(bytes, 0, bytes.size)?.also { TileCache.put(key, it) } + } catch (e: Exception) { + Log.e("MapTileClient", "Tile fetch failed for $key", e) + null + } + } + + fun close() { + client.close() + } +} diff --git a/tool/src/test/kotlin/com/thelightphone/transit/gtfs/GtfsRepositoryDedupTest.kt b/tool/src/test/kotlin/com/thelightphone/transit/gtfs/GtfsRepositoryDedupTest.kt new file mode 100644 index 00000000..78b252e1 --- /dev/null +++ b/tool/src/test/kotlin/com/thelightphone/transit/gtfs/GtfsRepositoryDedupTest.kt @@ -0,0 +1,86 @@ +package com.thelightphone.transit.gtfs + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class GtfsRepositoryDedupTest { + + @Test + fun groupsRealSouthStationPlatformsUnderTheStationRecord() { + // Real rows from MBTA's static GTFS stops.txt: the "South Station" parent (location_type=1) + // plus its six real child platform/entrance stop_ids (location_type=0, parent_station= + // place-sstat), verified by hand-downloading the feed during development. + val rows = listOf( + RawStopRow("place-sstat", "South Station", 42.352271, -71.055242, parentStation = null), + RawStopRow("70079", "South Station", 42.352271, -71.055242, parentStation = "place-sstat"), + RawStopRow("70080", "South Station", 42.352271, -71.055242, parentStation = "place-sstat"), + RawStopRow("74611", "South Station", 42.352271, -71.055242, parentStation = "place-sstat"), + RawStopRow("74617", "South Station", 42.352271, -71.055242, parentStation = "place-sstat"), + RawStopRow("84611", "South Station", 42.352271, -71.055242, parentStation = "place-sstat"), + RawStopRow("NEC-2287", "South Station", 42.352271, -71.055242, parentStation = "place-sstat"), + ) + + val result = groupStationsByParent(rows) + + assertEquals(1, result.size, "six platforms + their parent should collapse to one entry") + val station = result.single() + assertEquals("place-sstat", station.stopId, "the parent station record represents the group") + assertEquals("South Station", station.stopName) + assertEquals( + listOf("70079", "70080", "74611", "74617", "84611", "NEC-2287").sorted(), + station.memberStopIds, + "member ids must be every real child platform, for schedule lookups", + ) + } + + @Test + fun standaloneStopWithNoParentPassesThroughUnchanged() { + val rows = listOf( + RawStopRow("1234", "Some Bus Stop", 42.0, -71.0, parentStation = null), + ) + + val result = groupStationsByParent(rows) + + assertEquals(1, result.size) + val stop = result.single() + assertEquals("1234", stop.stopId) + assertEquals(listOf("1234"), stop.memberStopIds) + } + + @Test + fun fallsBackToFirstChildWhenTheParentRecordItselfIsMissing() { + // parent_station points at "place-ghost", but no row with that stop_id exists (e.g. the + // station record has no coordinates and got filtered out upstream by the SQL WHERE clause). + val rows = listOf( + RawStopRow("child-b", "Ghost Stop", 42.1, -71.1, parentStation = "place-ghost"), + RawStopRow("child-a", "Ghost Stop", 42.1, -71.1, parentStation = "place-ghost"), + ) + + val result = groupStationsByParent(rows) + + assertEquals(1, result.size) + val stop = result.single() + assertEquals("child-a", stop.stopId, "lowest stop_id chosen deterministically as fallback representative") + assertEquals(listOf("child-a", "child-b"), stop.memberStopIds) + } + + @Test + fun multipleDistinctStationsEachGroupSeparately() { + val rows = listOf( + RawStopRow("place-sstat", "South Station", 42.35, -71.05, parentStation = null), + RawStopRow("70079", "South Station", 42.35, -71.05, parentStation = "place-sstat"), + RawStopRow("place-north", "North Station", 42.36, -71.06, parentStation = null), + RawStopRow("70200", "North Station", 42.36, -71.06, parentStation = "place-north"), + RawStopRow("70201", "North Station", 42.36, -71.06, parentStation = "place-north"), + RawStopRow("99999", "Unrelated Bus Stop", 42.40, -71.10, parentStation = null), + ) + + val result = groupStationsByParent(rows) + + assertEquals(3, result.size) + assertTrue(result.any { it.stopId == "place-sstat" && it.memberStopIds == listOf("70079") }) + assertTrue(result.any { it.stopId == "place-north" && it.memberStopIds == listOf("70200", "70201") }) + assertTrue(result.any { it.stopId == "99999" && it.memberStopIds == listOf("99999") }) + } +} From 0dac4ab9392108189fd06db9a6278ffc01ee23a1 Mon Sep 17 00:00:00 2001 From: Christian Date: Fri, 31 Jul 2026 02:39:43 -0400 Subject: [PATCH 07/14] Update READMEs to match the current Map/Settings features, drop dead scaffold screen - Replace the stale "ETA Radar" description with what the Map screen actually does now (tap-to-reveal nearby stops, mode-specific vehicle emojis, live vehicles at true position, the Nearby Vehicles toggle). - Document the Settings/default-agency feature, which wasn't mentioned anywhere. - Make the root README consistently say "Pico Transit" instead of "Transit" in its body. - Remove DetailScreen.kt, an unused SDK-scaffold leftover that's never instantiated anywhere in the app. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_014sF9rJBmDSbG6LQKf2ua2h --- README.md | 14 ++- tool/README.md | 6 +- .../com/thelightphone/transit/DetailScreen.kt | 118 ------------------ 3 files changed, 12 insertions(+), 126 deletions(-) delete mode 100644 tool/src/main/kotlin/com/thelightphone/transit/DetailScreen.kt diff --git a/README.md b/README.md index c3226dfc..50700d0a 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,22 @@ # 🚌 Pico Transit -Transit is a friendly little companion for getting around on public transit. Real schedules, real-time arrivals, live connections at any stop, and a tiny radar screen that shows you exactly which direction your ride is coming from — no ads, no clutter, no infinite scroll. Just "when's my bus," answered nicely. 🚏✨ +Pico Transit is a friendly little companion for getting around on public transit. Real schedules, real-time arrivals, live connections at any stop, and a live map that shows exactly where your ride actually is — no ads, no clutter, no infinite scroll. Just "when's my bus," answered nicely. 🚏✨ -Right now Transit knows its way around **MBTA** and **RIPTA**, with more agencies hopefully hopping aboard down the road. It's built on the [Light SDK](../) for the Light Phone III, so it stays just as calm and un-distracting as the rest of your Light experience. +Right now Pico Transit knows its way around **MBTA** and **RIPTA**, with more agencies hopefully hopping aboard down the road. It's built on the [Light SDK](../) for the Light Phone III, so it stays just as calm and un-distracting as the rest of your Light experience. ## 🗺️ What can it do? -- 🏠 **Pick your agency** — MBTA or RIPTA — and Transit downloads their schedule right onto your phone. +- 🏠 **Pick your agency** — MBTA or RIPTA — and Pico Transit downloads their schedule right onto your phone. +- ⚙️ **Settings** — set a default agency once and Pico Transit skips the picker on every future launch, only re-downloading the schedule when the agency's feed has actually changed. - 📅 **Explore Schedules** — browse by Subway 🚇, Commuter Rail 🚆, or Bus 🚌, pick a route, a direction, and a stop, and see every departure today. - 🔗 **Connections** — tap any stop along a trip to see what else comes through there next. Great for planning a transfer on the fly. -- 📍 **Leave Now** — type where you are (or let Transit take a quick IP-based guess 🛰️) and get the 20 closest stops, nearest first. +- 📍 **Leave Now** — type where you are (or let Pico Transit take a quick IP-based guess 🛰️) and get the 20 closest stops, nearest first. - ⏱️ **Live ETAs** — real-time predictions with On Time / Late / Early badges, whenever the agency's live feed is playing along nicely. -- 🧭 **ETA Radar** — a little live radar screen showing exactly which direction your bus (and nearby stops!) are coming from, right now. +- 🗺️ **Map** — your stop, pinned on a live map, with nearby stops you can tap to reveal their names. Live vehicles show up right where they actually are, with an emoji for their mode (🚇 subway/light rail, 🚆 commuter rail, 🚌 bus) — flip on "Nearby Vehicles" to also see what's inbound to those other stops, not just yours. ## 🛠️ Building & running it -Transit lives inside the [light-sdk](../) monorepo — check the [root README](../README.md) first for one-time setup (GitHub token, Android Studio, etc). Once that's done: +Pico Transit lives inside the [light-sdk](../) monorepo — check the [root README](../README.md) first for one-time setup (GitHub token, Android Studio, etc). Once that's done: 1. Open the whole `light-sdk` project in Android Studio. 2. Run the `:tool` module on an emulator, or better yet, [the LightOS emulator](../docs/system_app) — that's this app! 🎉 @@ -45,3 +46,4 @@ That's it — happy transit-ing! 🚏🚌🚆 - **RIPTA's live feeds are HTTP-only** (no HTTPS), which Android blocks by default. There's a small, clearly-labeled `:netconfig` module that grants just that one narrow exception — see its own `build.gradle.kts` for exactly what it does and how to remove it if you'd rather stay HTTPS-only everywhere. - **No device GPS is used anywhere** — the SDK doesn't expose it to tools yet. Nearby-stop and location search are powered by Nominatim (OpenStreetMap) and IP-based geolocation instead. Be kind to their free APIs! 🙏 +- **Stations are deduplicated using GTFS's `parent_station`** — a big station with several platforms (subway entrances, commuter rail tracks, etc.) shows up as one marker/entry, not one per platform, while still resolving to the right platform's `stop_id` under the hood for schedule lookups. diff --git a/tool/README.md b/tool/README.md index 58c15e68..50700d0a 100644 --- a/tool/README.md +++ b/tool/README.md @@ -1,17 +1,18 @@ # 🚌 Pico Transit -Pico Transit is a friendly little companion for getting around on public transit. Real schedules, real-time arrivals, live connections at any stop, and a tiny radar screen that shows you exactly which direction your ride is coming from — no ads, no clutter, no infinite scroll. Just "when's my bus," answered nicely. 🚏✨ +Pico Transit is a friendly little companion for getting around on public transit. Real schedules, real-time arrivals, live connections at any stop, and a live map that shows exactly where your ride actually is — no ads, no clutter, no infinite scroll. Just "when's my bus," answered nicely. 🚏✨ Right now Pico Transit knows its way around **MBTA** and **RIPTA**, with more agencies hopefully hopping aboard down the road. It's built on the [Light SDK](../) for the Light Phone III, so it stays just as calm and un-distracting as the rest of your Light experience. ## 🗺️ What can it do? - 🏠 **Pick your agency** — MBTA or RIPTA — and Pico Transit downloads their schedule right onto your phone. +- ⚙️ **Settings** — set a default agency once and Pico Transit skips the picker on every future launch, only re-downloading the schedule when the agency's feed has actually changed. - 📅 **Explore Schedules** — browse by Subway 🚇, Commuter Rail 🚆, or Bus 🚌, pick a route, a direction, and a stop, and see every departure today. - 🔗 **Connections** — tap any stop along a trip to see what else comes through there next. Great for planning a transfer on the fly. - 📍 **Leave Now** — type where you are (or let Pico Transit take a quick IP-based guess 🛰️) and get the 20 closest stops, nearest first. - ⏱️ **Live ETAs** — real-time predictions with On Time / Late / Early badges, whenever the agency's live feed is playing along nicely. -- 🧭 **ETA Radar** — a little live radar screen showing exactly which direction your bus (and nearby stops!) are coming from, right now. +- 🗺️ **Map** — your stop, pinned on a live map, with nearby stops you can tap to reveal their names. Live vehicles show up right where they actually are, with an emoji for their mode (🚇 subway/light rail, 🚆 commuter rail, 🚌 bus) — flip on "Nearby Vehicles" to also see what's inbound to those other stops, not just yours. ## 🛠️ Building & running it @@ -45,3 +46,4 @@ That's it — happy transit-ing! 🚏🚌🚆 - **RIPTA's live feeds are HTTP-only** (no HTTPS), which Android blocks by default. There's a small, clearly-labeled `:netconfig` module that grants just that one narrow exception — see its own `build.gradle.kts` for exactly what it does and how to remove it if you'd rather stay HTTPS-only everywhere. - **No device GPS is used anywhere** — the SDK doesn't expose it to tools yet. Nearby-stop and location search are powered by Nominatim (OpenStreetMap) and IP-based geolocation instead. Be kind to their free APIs! 🙏 +- **Stations are deduplicated using GTFS's `parent_station`** — a big station with several platforms (subway entrances, commuter rail tracks, etc.) shows up as one marker/entry, not one per platform, while still resolving to the right platform's `stop_id` under the hood for schedule lookups. diff --git a/tool/src/main/kotlin/com/thelightphone/transit/DetailScreen.kt b/tool/src/main/kotlin/com/thelightphone/transit/DetailScreen.kt deleted file mode 100644 index b81027b0..00000000 --- a/tool/src/main/kotlin/com/thelightphone/transit/DetailScreen.kt +++ /dev/null @@ -1,118 +0,0 @@ -package com.thelightphone.transit - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.unit.dp -import androidx.lifecycle.viewModelScope -import com.thelightphone.sdk.LightScreen -import com.thelightphone.sdk.LightViewModel -import com.thelightphone.sdk.SealedLightActivity -import com.thelightphone.sdk.SimpleLightScreen -import com.thelightphone.sdk.ui.lightClickable -import io.ktor.client.HttpClient -import io.ktor.client.call.body -import io.ktor.client.engine.okhttp.OkHttp -import io.ktor.client.plugins.contentnegotiation.ContentNegotiation -import io.ktor.client.request.get -import io.ktor.serialization.kotlinx.json.json -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.launch -import kotlinx.serialization.Serializable -import kotlinx.serialization.json.Json - -@Serializable -data class TimezoneResponse( - val timeZone: String = "", - val currentLocalTime: String = "", -) - -class DetailViewModel : LightViewModel() { - sealed class State { - object Loading : State() - data class Time(val timeToDisplay: String) : State() - data class Error(val message: String) : State() - } - - private val client = HttpClient(OkHttp) { - install(ContentNegotiation) { - json(Json { ignoreUnknownKeys = true }) - } - } - - private val _state = MutableStateFlow(State.Loading) - val state: StateFlow = _state - - override fun onScreenShow(screen: SimpleLightScreen) { - super.onScreenShow(screen) - _state.value = State.Loading - viewModelScope.launch(Dispatchers.IO) { - delay(500) - _state.value = try { - val response: TimezoneResponse = client - .get("https://timeapi.io/api/timezone/zone?timeZone=Europe/London") - .body() - State.Time(response.currentLocalTime) - } catch (e: Exception) { - State.Error(e.message ?: "Unknown error") - } - } - } - - override fun onCleared() { - super.onCleared() - client.close() - } -} - -class DetailScreen(sealedActivity: SealedLightActivity) : - LightScreen(sealedActivity) { - - override val viewModelClass: Class - get() = DetailViewModel::class.java - - override fun createViewModel() = DetailViewModel() - - @Composable - override fun Content() { - val state by viewModel.state.collectAsState() - - Column( - modifier = Modifier - .fillMaxSize() - .background(Color.White) - .padding(32.dp) - ) { - Text( - text = "London Time", - style = MaterialTheme.typography.headlineMedium, - ) - Text( - text = when (val s = state) { - is DetailViewModel.State.Loading -> "Loading..." - is DetailViewModel.State.Time -> s.timeToDisplay - is DetailViewModel.State.Error -> "Error: ${s.message}" - }, - style = MaterialTheme.typography.bodyLarge, - modifier = Modifier.padding(top = 16.dp), - ) - Text( - text = "Go Back", - modifier = Modifier - .padding(top = 16.dp) - .lightClickable { goBack() }, - ) - } - } -} From baee1e3d8f7d59bacfdf4e215575537fd993126c Mon Sep 17 00:00:00 2001 From: Christian Date: Fri, 31 Jul 2026 09:45:41 -0400 Subject: [PATCH 08/14] Sort Choose Stop by distance, surface nearby connections, live trip vehicle, dark map option - Choose Stop (Explore Schedules) now sorts by real distance to you, matching the distance already shown per row, instead of route/stop_sequence order. - Connections now also lists the next 3 departures at any stop within 100 feet of the current one (route, direction, distance, time), skipping any route+direction already offered at the current stop. - Trip Detail shows a live mode emoji next to whichever stop the trip's vehicle is currently at or closest to, polling VehiclePositions every 10s. Derives the agency from the db file path (GtfsAgency.forDbFile) rather than threading it through every screen between here and where it was selected. - Map screen switched to CARTO's brighter Voyager tiles by default for street legibility, with all text drawn directly on the map (compass, center label, bus markers) given the same white-fill/black-outline treatment already used for nearby-stop labels, so it stays legible over both styles. - Added a Settings option to pick Dark Matter over Voyager instead, persisted the same way as the default agency. Also switched Settings to LightScrollView so a growing settings list doesn't run off the bottom of the screen (found while testing this addition). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_014sF9rJBmDSbG6LQKf2ua2h --- .../transit/FirstStopSelectionScreen.kt | 13 +- .../com/thelightphone/transit/MapScreen.kt | 58 ++++++-- .../thelightphone/transit/SettingsScreen.kt | 60 ++++++++- .../transit/StopConnectionsScreen.kt | 125 +++++++++++++----- .../thelightphone/transit/TripDetailScreen.kt | 74 ++++++++++- .../thelightphone/transit/gtfs/GtfsAgency.kt | 14 ++ .../transit/gtfs/GtfsRepository.kt | 10 ++ .../transit/gtfs/MapPreferences.kt | 24 ++++ .../thelightphone/transit/gtfs/MapTiles.kt | 36 +++-- 9 files changed, 346 insertions(+), 68 deletions(-) create mode 100644 tool/src/main/kotlin/com/thelightphone/transit/gtfs/MapPreferences.kt diff --git a/tool/src/main/kotlin/com/thelightphone/transit/FirstStopSelectionScreen.kt b/tool/src/main/kotlin/com/thelightphone/transit/FirstStopSelectionScreen.kt index a8c8d948..6e12a16f 100644 --- a/tool/src/main/kotlin/com/thelightphone/transit/FirstStopSelectionScreen.kt +++ b/tool/src/main/kotlin/com/thelightphone/transit/FirstStopSelectionScreen.kt @@ -61,6 +61,8 @@ class FirstStopSelectionViewModel( dbFile: File, private val routeId: String, private val directionId: Int, + private val userLat: Double, + private val userLon: Double, ) : LightViewModel() { private val repository = GtfsRepository(dbFile) @@ -72,7 +74,14 @@ class FirstStopSelectionViewModel( super.onScreenShow(screen) viewModelScope.launch(Dispatchers.IO) { _state.value = try { - FirstStopSelectionState.Loaded(repository.getStops(routeId, directionId)) + // Nearest-to-you first, not route/stop_sequence order -- the distance already shown + // per row (see distanceFeetLabel) should match what the list order implies. + val stops = repository.getStops(routeId, directionId).sortedBy { stop -> + val lat = stop.lat + val lon = stop.lon + if (lat != null && lon != null) haversineMeters(userLat, userLon, lat, lon) else Double.MAX_VALUE + } + FirstStopSelectionState.Loaded(stops) } catch (e: Exception) { Log.e("FirstStopSelectionScreen", "Failed to load first stops for route $routeId", e) FirstStopSelectionState.Error("Unable to load stops.") @@ -101,7 +110,7 @@ class FirstStopSelectionScreen( get() = FirstStopSelectionViewModel::class.java override fun createViewModel(): FirstStopSelectionViewModel = - FirstStopSelectionViewModel(dbFile, routeId, directionId) + FirstStopSelectionViewModel(dbFile, routeId, directionId, userLat, userLon) @Composable override fun Content() { diff --git a/tool/src/main/kotlin/com/thelightphone/transit/MapScreen.kt b/tool/src/main/kotlin/com/thelightphone/transit/MapScreen.kt index decf12b1..39b60759 100644 --- a/tool/src/main/kotlin/com/thelightphone/transit/MapScreen.kt +++ b/tool/src/main/kotlin/com/thelightphone/transit/MapScreen.kt @@ -28,6 +28,7 @@ import com.thelightphone.transit.gtfs.GtfsRealtimeClient import com.thelightphone.transit.gtfs.GtfsRepository import com.thelightphone.transit.gtfs.GtfsRtVehicleStatus import com.thelightphone.transit.gtfs.LineType +import com.thelightphone.transit.gtfs.MapPreferences import com.thelightphone.transit.gtfs.MapTiles import com.thelightphone.transit.gtfs.NominatimGeocoder import com.thelightphone.transit.gtfs.MapTileClient @@ -59,6 +60,7 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import java.io.File @@ -67,9 +69,10 @@ import java.time.LocalDateTime import java.time.ZoneId import kotlin.math.sqrt -// Tunable; a 15-20s floor roughly matched to typical agency GTFS-RT update cadence, not a -// continuous/unbounded poll. -private const val LIVE_VEHICLE_POLL_INTERVAL_MS = 17_000L +// Tunable. Faster than agencies typically refresh their own feed (~15-20s), so some polls just +// re-fetch the same data -- a deliberate trade of a few extra requests for lower latency once a +// feed does update, not a continuous/unbounded poll. +private const val LIVE_VEHICLE_POLL_INTERVAL_MS = 10_000L // Per active stop, not a shared total -- turning on "Nearby Vehicles" and selecting more stops // should show more buses, not compete with the primary stop for the same fixed slots. private const val MAX_DISPLAYED_BUSES_PER_STOP = 4 @@ -193,6 +196,7 @@ class MapViewModel( dbFile: File, private val agency: GtfsAgency, private val stopId: String, + private val mapPreferences: MapPreferences, ) : LightViewModel() { private val repository = GtfsRepository(dbFile) @@ -224,6 +228,9 @@ class MapViewModel( _state.value = MapState.Error("Stop location not found.") return@launch } + // Read once at screen-open, same as HomeScreen's default-agency read -- Settings + // isn't shown at the same time as this screen, so there's no need to react live. + val darkMode = mapPreferences.darkMapEnabledFlow.first() // Snapshot once: which trips are scheduled for this stop from now on. Live polling // below only checks these same trips for a live position match — a trip that wasn't // scheduled at screen-open time won't appear mid-session. @@ -259,7 +266,7 @@ class MapViewModel( // radius for exactly the same reason -- what's plotted should match what's visible. val fetchRadiusMeters = MAP_TARGET_RADIUS_PIXELS * metersPerPixel(stop.lat, zoom) val mapTiles = try { - tileClient.fetchTilesAround(stop.lat, stop.lon, zoom, fetchRadiusMeters) + tileClient.fetchTilesAround(stop.lat, stop.lon, zoom, fetchRadiusMeters, darkMode) } catch (e: Exception) { Log.e("MapScreen", "Map tile fetch failed for stop $stopId", e) null @@ -422,7 +429,8 @@ class MapScreen( override val viewModelClass: Class get() = MapViewModel::class.java - override fun createViewModel(): MapViewModel = MapViewModel(dbFile, agency, stopId) + override fun createViewModel(): MapViewModel = + MapViewModel(dbFile, agency, stopId, MapPreferences(lightContext.dataStore)) @Composable override fun Content() { @@ -545,7 +553,6 @@ private fun MapCanvas( onToggleNearbyVehicles: () -> Unit, modifier: Modifier = Modifier, ) { - val contentColor = LightThemeTokens.colors.content.toArgb() Canvas( modifier = modifier @@ -596,16 +603,28 @@ private fun MapCanvas( textAlign = Paint.Align.CENTER isAntiAlias = true } + // Always white fill + black outline, independent of theme -- Voyager's imagery ranges from + // near-white streets to colored parks/water, so any single fixed color would go illegible + // over some patch of the map. Same treatment nearbyStopLabelPaint below already used. val labelPaint = Paint().apply { textSize = 26f textAlign = Paint.Align.CENTER isAntiAlias = true - color = contentColor + color = android.graphics.Color.WHITE + } + val labelOutlinePaint = Paint(labelPaint).apply { + style = Paint.Style.STROKE + strokeWidth = 5f + color = android.graphics.Color.BLACK } val smallLabelPaint = Paint(labelPaint).apply { textSize = 22f alpha = 180 } + val smallLabelOutlinePaint = Paint(labelOutlinePaint).apply { + textSize = 22f + strokeWidth = 4f + } val nearbyStopEmojiPaint = Paint().apply { textSize = 34f textAlign = Paint.Align.CENTER @@ -666,10 +685,15 @@ private fun MapCanvas( // bottom of the solid top bar so it never sits under it; S/E/W use that same margin value // from their own edge for consistent spacing on all four sides. val compassEdgeMarginPx = SCRIM_HEIGHT_PX + COMPASS_SCRIM_MARGIN_PX - nativeCanvas.drawText("N", center.x, compassEdgeMarginPx, labelPaint) - nativeCanvas.drawText("S", center.x, size.height - compassEdgeMarginPx, labelPaint) - nativeCanvas.drawText("E", size.width - compassEdgeMarginPx, center.y, labelPaint) - nativeCanvas.drawText("W", compassEdgeMarginPx, center.y, labelPaint) + listOf( + "N" to Offset(center.x, compassEdgeMarginPx), + "S" to Offset(center.x, size.height - compassEdgeMarginPx), + "E" to Offset(size.width - compassEdgeMarginPx, center.y), + "W" to Offset(compassEdgeMarginPx, center.y), + ).forEach { (letter, point) -> + nativeCanvas.drawText(letter, point.x, point.y, labelOutlinePaint) + nativeCanvas.drawText(letter, point.x, point.y, labelPaint) + } // Nearby Vehicles toggle -- bottom-left corner, clear of the mid-edge compass letters. val toggleText = if (nearbyVehiclesEnabled) "🚌 Nearby Vehicles: On" else "🚌 Nearby Vehicles: Off" @@ -717,8 +741,10 @@ private fun MapCanvas( // Center stop pin, name, and street context — always pinned dead center. nativeCanvas.drawText("📍", center.x, center.y, emojiPaint) + nativeCanvas.drawText(stopLabel, center.x, center.y + 64f, labelOutlinePaint) nativeCanvas.drawText(stopLabel, center.x, center.y + 64f, labelPaint) streetContext?.let { + nativeCanvas.drawText(it, center.x, center.y + 92f, smallLabelOutlinePaint) nativeCanvas.drawText(it, center.x, center.y + 92f, smallLabelPaint) } @@ -731,7 +757,7 @@ private fun MapCanvas( val xOffset = (index - (arrivedAtPrimary.size - 1) / 2f) * 110f val x = center.x + xOffset val y = center.y - maxRadius * 0.45f - drawBusMarker(nativeCanvas, emojiPaint, labelPaint, smallLabelPaint, bus, x, y) + drawBusMarker(nativeCanvas, emojiPaint, labelPaint, labelOutlinePaint, smallLabelPaint, smallLabelOutlinePaint, bus, x, y) } // Every other bus drawn individually at its true projected position — clipped to the @@ -740,7 +766,7 @@ private fun MapCanvas( everythingElse.forEach { bus -> val rel = projectRelativeToCenter(centerLat, centerLon, bus.lat, bus.lon, zoom) val clipped = clipToRadius(Offset(center.x + rel.x, center.y + rel.y), center, maxRadius) - drawBusMarker(nativeCanvas, emojiPaint, labelPaint, smallLabelPaint, bus, clipped.x, clipped.y) + drawBusMarker(nativeCanvas, emojiPaint, labelPaint, labelOutlinePaint, smallLabelPaint, smallLabelOutlinePaint, bus, clipped.x, clipped.y) } } } @@ -810,17 +836,23 @@ private fun drawBusMarker( canvas: android.graphics.Canvas, emojiPaint: Paint, labelPaint: Paint, + labelOutlinePaint: Paint, smallLabelPaint: Paint, + smallLabelOutlinePaint: Paint, bus: BusMarker, x: Float, y: Float, ) { canvas.drawText(bus.emoji, x, y, emojiPaint) val routeText = bus.lineLabel?.let { "$it - ${bus.routeLabel}" } ?: bus.routeLabel + canvas.drawText(routeText, x, y + 30f, smallLabelOutlinePaint) canvas.drawText(routeText, x, y + 30f, smallLabelPaint) + canvas.drawText(bus.directionLabel, x, y + 54f, smallLabelOutlinePaint) canvas.drawText(bus.directionLabel, x, y + 54f, smallLabelPaint) + canvas.drawText(bus.etaDisplay(), x, y + 78f, labelOutlinePaint) canvas.drawText(bus.etaDisplay(), x, y + 78f, labelPaint) bus.statusLabel()?.let { + canvas.drawText(it, x, y + 102f, smallLabelOutlinePaint) canvas.drawText(it, x, y + 102f, smallLabelPaint) } } diff --git a/tool/src/main/kotlin/com/thelightphone/transit/SettingsScreen.kt b/tool/src/main/kotlin/com/thelightphone/transit/SettingsScreen.kt index c7c5330a..b154d57a 100644 --- a/tool/src/main/kotlin/com/thelightphone/transit/SettingsScreen.kt +++ b/tool/src/main/kotlin/com/thelightphone/transit/SettingsScreen.kt @@ -13,11 +13,13 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.viewModelScope import com.thelightphone.transit.gtfs.AgencyPreferences import com.thelightphone.transit.gtfs.GtfsAgency +import com.thelightphone.transit.gtfs.MapPreferences import com.thelightphone.sdk.LightScreen import com.thelightphone.sdk.LightViewModel import com.thelightphone.sdk.SealedLightActivity import com.thelightphone.sdk.ui.LightBarButton import com.thelightphone.sdk.ui.LightIcons +import com.thelightphone.sdk.ui.LightScrollView import com.thelightphone.sdk.ui.LightText import com.thelightphone.sdk.ui.LightTextVariant import com.thelightphone.sdk.ui.LightTheme @@ -30,15 +32,25 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch -class SettingsViewModel(private val preferences: AgencyPreferences) : LightViewModel() { +class SettingsViewModel( + private val agencyPreferences: AgencyPreferences, + private val mapPreferences: MapPreferences, +) : LightViewModel() { val defaultAgency: StateFlow get() = _defaultAgency private val _defaultAgency = MutableStateFlow(null) + val darkMapEnabled: StateFlow + get() = _darkMapEnabled + private val _darkMapEnabled = MutableStateFlow(false) + init { viewModelScope.launch { - preferences.defaultAgencyFlow.collect { _defaultAgency.value = it } + agencyPreferences.defaultAgencyFlow.collect { _defaultAgency.value = it } + } + viewModelScope.launch { + mapPreferences.darkMapEnabledFlow.collect { _darkMapEnabled.value = it } } } @@ -46,9 +58,13 @@ class SettingsViewModel(private val preferences: AgencyPreferences) : LightViewM * turn the auto-skip back off, not just switch which agency it points at. */ fun toggleDefaultAgency(agency: GtfsAgency) { viewModelScope.launch { - preferences.setDefaultAgency(if (defaultAgency.value == agency) null else agency) + agencyPreferences.setDefaultAgency(if (defaultAgency.value == agency) null else agency) } } + + fun setDarkMapEnabled(enabled: Boolean) { + viewModelScope.launch { mapPreferences.setDarkMapEnabled(enabled) } + } } class SettingsScreen( @@ -59,11 +75,12 @@ class SettingsScreen( get() = SettingsViewModel::class.java override fun createViewModel(): SettingsViewModel = - SettingsViewModel(AgencyPreferences(lightContext.dataStore)) + SettingsViewModel(AgencyPreferences(lightContext.dataStore), MapPreferences(lightContext.dataStore)) @Composable override fun Content() { val defaultAgency by viewModel.defaultAgency.collectAsState() + val darkMapEnabled by viewModel.darkMapEnabled.collectAsState() val themeColors by LightThemeController.colors.collectAsState() LightTheme(colors = themeColors) { @@ -76,7 +93,7 @@ class SettingsScreen( leftButton = LightBarButton.LightIcon(icon = LightIcons.BACK, onClick = { goBack() }), center = LightTopBarCenter.Text("Settings"), ) - Column(modifier = Modifier.weight(1f).padding(32.dp)) { + LightScrollView(modifier = Modifier.weight(1f).padding(32.dp)) { LightText( text = "Default agency", variant = LightTextVariant.Copy, @@ -103,6 +120,39 @@ class SettingsScreen( ) } } + + LightText( + text = "Map style", + variant = LightTextVariant.Copy, + lighten = true, + modifier = Modifier.padding(top = 24.dp, bottom = 8.dp), + ) + LightText( + text = "Choose which map tiles the Map screen uses.", + variant = LightTextVariant.Detail, + lighten = true, + modifier = Modifier.padding(bottom = 16.dp), + ) + Column { + LightText( + text = if (!darkMapEnabled) "Light (current)" else "Light", + variant = LightTextVariant.Copy, + lighten = darkMapEnabled, + modifier = Modifier + .fillMaxWidth() + .lightClickable { viewModel.setDarkMapEnabled(false) } + .padding(vertical = 12.dp), + ) + LightText( + text = if (darkMapEnabled) "Dark (current)" else "Dark", + variant = LightTextVariant.Copy, + lighten = !darkMapEnabled, + modifier = Modifier + .fillMaxWidth() + .lightClickable { viewModel.setDarkMapEnabled(true) } + .padding(vertical = 12.dp), + ) + } } } } diff --git a/tool/src/main/kotlin/com/thelightphone/transit/StopConnectionsScreen.kt b/tool/src/main/kotlin/com/thelightphone/transit/StopConnectionsScreen.kt index f6f5f4ef..78309db2 100644 --- a/tool/src/main/kotlin/com/thelightphone/transit/StopConnectionsScreen.kt +++ b/tool/src/main/kotlin/com/thelightphone/transit/StopConnectionsScreen.kt @@ -41,12 +41,33 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch import java.io.File +/** 100 feet -- close enough to be "the same corner", not a separate trip to walk to. */ +private const val NEARBY_STOP_RADIUS_METERS = 30.48 +/** Enough to show real variety at each nearby stop without turning this into a second full + * schedule dump for every one of them. */ +private const val NEARBY_STOP_CONNECTIONS_LIMIT = 3 +private const val FEET_PER_METER = 3.28084 + sealed class StopConnectionsState { object Loading : StopConnectionsState() - data class Loaded(val connections: List) : StopConnectionsState() + data class Loaded( + val connections: List, + val nearbyStops: List, + ) : StopConnectionsState() data class Error(val message: String) : StopConnectionsState() } +/** A nearby stop's next few departures, already filtered to exclude any route+direction the + * current stop itself already offers -- these are meant to surface options you wouldn't otherwise + * see from here, not duplicate what's already on screen. */ +data class NearbyStopConnections( + val stopName: String, + val distanceMeters: Double, + val connections: List, +) + +fun NearbyStopConnections.distanceFeetLabel(): String = "%.0f ft".format(distanceMeters * FEET_PER_METER) + fun StopConnection.displayLabel(): String { val lineLabel = LineType.forGtfsRouteType(route.routeType)?.label val routeAndDirection = "${route.displayName} - ${direction.displayLabel()}" @@ -70,9 +91,31 @@ class StopConnectionsViewModel( viewModelScope.launch(Dispatchers.IO) { _state.value = try { val today = todayForGtfs() - StopConnectionsState.Loaded( - repository.getNextConnections(stopId, afterTime, excludeTripId, today) - ) + val connections = repository.getNextConnections(stopId, afterTime, excludeTripId, today) + // What's already offered right here -- a nearby stop repeating one of these isn't + // telling you anything new, so it's left out of that stop's list entirely. + val servedHere = connections.mapTo(mutableSetOf()) { it.route.routeId to it.direction.directionId } + + val here = repository.getStopLocation(stopId) + val nearbyStops = if (here == null) { + emptyList() + } else { + repository.getStopsWithinRadius(here.lat, here.lon, NEARBY_STOP_RADIUS_METERS, excludeStopId = stopId) + .mapNotNull { nearby -> + val nearbyConnections = nearby.memberStopIds + .flatMap { repository.getNextConnections(it, afterTime, excludeTripId, today) } + .filter { (it.route.routeId to it.direction.directionId) !in servedHere } + .sortedBy { it.departureTime } + .take(NEARBY_STOP_CONNECTIONS_LIMIT) + if (nearbyConnections.isEmpty()) return@mapNotNull null + NearbyStopConnections( + stopName = nearby.stopName ?: "Stop ${nearby.stopId}", + distanceMeters = nearby.distanceMeters, + connections = nearbyConnections, + ) + } + } + StopConnectionsState.Loaded(connections, nearbyStops) } catch (e: Exception) { Log.e("StopConnectionsScreen", "Failed to load connections for stop $stopId", e) StopConnectionsState.Error("Unable to load connections.") @@ -101,6 +144,41 @@ class StopConnectionsScreen( override fun createViewModel(): StopConnectionsViewModel = StopConnectionsViewModel(dbFile, stopId, afterTime, excludeTripId) + @Composable + private fun ConnectionRow(connection: StopConnection) { + Row( + modifier = Modifier + .fillMaxWidth() + .lightClickable { + navigateTo(screenFactory = { activity -> + TripDetailScreen( + activity, + dbFile, + connection.tripId, + connection.stopSequence, + connection.route.displayName, + connection.direction.displayLabel(), + ) + }) + } + .padding(vertical = 12.dp), + verticalAlignment = Alignment.Top, + ) { + LightText( + text = connection.displayLabel(), + variant = LightTextVariant.Copy, + modifier = Modifier + .weight(1f) + .padding(end = 16.dp), + ) + LightText( + text = formatGtfsTime(connection.departureTime), + variant = LightTextVariant.Copy, + lighten = true, + ) + } + } + @Composable override fun Content() { val state by viewModel.state.collectAsState() @@ -137,7 +215,7 @@ class StopConnectionsScreen( lighten = true, ) - is StopConnectionsState.Loaded -> if (s.connections.isEmpty()) { + is StopConnectionsState.Loaded -> if (s.connections.isEmpty() && s.nearbyStops.isEmpty()) { LightText( text = "No more connections today.", variant = LightTextVariant.Copy, @@ -146,37 +224,20 @@ class StopConnectionsScreen( } else { LazyColumn(modifier = Modifier.weight(1f)) { items(s.connections) { connection -> - Row( - modifier = Modifier - .fillMaxWidth() - .lightClickable { - navigateTo(screenFactory = { activity -> - TripDetailScreen( - activity, - dbFile, - connection.tripId, - connection.stopSequence, - connection.route.displayName, - connection.direction.displayLabel(), - ) - }) - } - .padding(vertical = 12.dp), - verticalAlignment = Alignment.Top, - ) { - LightText( - text = connection.displayLabel(), - variant = LightTextVariant.Copy, - modifier = Modifier - .weight(1f) - .padding(end = 16.dp), - ) + ConnectionRow(connection) + } + s.nearbyStops.forEach { nearby -> + item { LightText( - text = formatGtfsTime(connection.departureTime), - variant = LightTextVariant.Copy, + text = "${nearby.stopName} - ${nearby.distanceFeetLabel()}", + variant = LightTextVariant.Detail, lighten = true, + modifier = Modifier.padding(top = 20.dp, bottom = 4.dp), ) } + items(nearby.connections) { connection -> + ConnectionRow(connection) + } } } } diff --git a/tool/src/main/kotlin/com/thelightphone/transit/TripDetailScreen.kt b/tool/src/main/kotlin/com/thelightphone/transit/TripDetailScreen.kt index 31229794..2a8e9d95 100644 --- a/tool/src/main/kotlin/com/thelightphone/transit/TripDetailScreen.kt +++ b/tool/src/main/kotlin/com/thelightphone/transit/TripDetailScreen.kt @@ -16,7 +16,10 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.lifecycle.viewModelScope +import com.thelightphone.transit.gtfs.GtfsAgency +import com.thelightphone.transit.gtfs.GtfsRealtimeClient import com.thelightphone.transit.gtfs.GtfsRepository +import com.thelightphone.transit.gtfs.LineType import com.thelightphone.transit.gtfs.TripStopRow import com.thelightphone.transit.gtfs.formatGtfsTime import com.thelightphone.sdk.LightScreen @@ -34,14 +37,34 @@ import com.thelightphone.sdk.ui.LightTopBar import com.thelightphone.sdk.ui.LightTopBarCenter import com.thelightphone.sdk.ui.lightClickable import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import java.io.File +// Matches MapScreen's own polling cadence -- see its own comment on this same constant. +private const val LIVE_VEHICLE_POLL_INTERVAL_MS = 10_000L + sealed class TripDetailState { object Loading : TripDetailState() - data class Loaded(val stops: List) : TripDetailState() + + /** + * [liveEmoji]/[liveAtStopSequence] are null together whenever there's no live position to show + * (no realtime feed for this agency, this trip isn't currently reporting one, or the vehicle's + * current_stop_sequence doesn't match any row still in [stops]). Per the GTFS-realtime spec, + * current_stop_sequence means "at, arriving at, or en route to" that stop regardless of + * current_status -- so matching it against a row's stopSequence alone is enough to place the + * emoji at the stop the vehicle is closest to, including while it's between stops. + */ + data class Loaded( + val stops: List, + val liveEmoji: String?, + val liveAtStopSequence: Int?, + ) : TripDetailState() + data class Error(val message: String) : TripDetailState() } @@ -52,22 +75,56 @@ class TripDetailViewModel( ) : LightViewModel() { private val repository = GtfsRepository(dbFile) + // See GtfsAgency.forDbFile -- recovered from the db path rather than threaded through every + // screen between here and wherever the agency was originally selected. + private val agency = GtfsAgency.forDbFile(dbFile) private val _state = MutableStateFlow(TripDetailState.Loading) val state: StateFlow = _state + private var pollJob: Job? = null + override fun onScreenShow(screen: SimpleLightScreen) { super.onScreenShow(screen) - viewModelScope.launch(Dispatchers.IO) { - _state.value = try { - TripDetailState.Loaded(repository.getTripStops(tripId, fromStopSequence)) + pollJob?.cancel() + pollJob = viewModelScope.launch(Dispatchers.IO) { + try { + val stops = repository.getTripStops(tripId, fromStopSequence) + val emoji = repository.getRouteTypeForTrip(tripId)?.let { LineType.forGtfsRouteType(it)?.emoji } ?: "🚌" + val vehiclePositionsUrl = agency?.realtimeVehiclePositionsUrl + if (stops.isEmpty() || vehiclePositionsUrl == null) { + _state.value = TripDetailState.Loaded(stops, liveEmoji = null, liveAtStopSequence = null) + return@launch + } + + while (isActive) { + val liveStopSequence = try { + GtfsRealtimeClient.fetchFeed(vehiclePositionsUrl) + .vehiclePositionsByTripId[tripId]?.currentStopSequence + } catch (e: Exception) { + Log.e("TripDetailScreen", "VehiclePositions fetch failed for trip $tripId", e) + null + } + _state.value = TripDetailState.Loaded( + stops, + liveEmoji = emoji, + liveAtStopSequence = liveStopSequence, + ) + delay(LIVE_VEHICLE_POLL_INTERVAL_MS) + } } catch (e: Exception) { Log.e("TripDetailScreen", "Failed to load stop times for trip $tripId", e) - TripDetailState.Error("Unable to load trip detail.") + _state.value = TripDetailState.Error("Unable to load trip detail.") } } } + override fun onScreenHide(screen: SimpleLightScreen) { + super.onScreenHide(screen) + pollJob?.cancel() + pollJob = null + } + override fun onCleared() { super.onCleared() repository.close() @@ -154,6 +211,13 @@ class TripDetailScreen( .padding(vertical = 8.dp), verticalAlignment = Alignment.Top, ) { + if (s.liveEmoji != null && stop.stopSequence == s.liveAtStopSequence) { + LightText( + text = s.liveEmoji, + variant = LightTextVariant.Copy, + modifier = Modifier.padding(end = 8.dp), + ) + } LightText( text = stop.stopName ?: "Unknown stop", variant = LightTextVariant.Copy, diff --git a/tool/src/main/kotlin/com/thelightphone/transit/gtfs/GtfsAgency.kt b/tool/src/main/kotlin/com/thelightphone/transit/gtfs/GtfsAgency.kt index 5b5285bf..41fba1a1 100644 --- a/tool/src/main/kotlin/com/thelightphone/transit/gtfs/GtfsAgency.kt +++ b/tool/src/main/kotlin/com/thelightphone/transit/gtfs/GtfsAgency.kt @@ -1,5 +1,7 @@ package com.thelightphone.transit.gtfs +import java.io.File + /** * [realtimeTripUpdatesUrl]/[realtimeVehiclePositionsUrl] are null when an agency has no realtime * feed reachable at all. Screens treat "null or fetch failed" identically, so adding/removing a @@ -34,4 +36,16 @@ enum class GtfsAgency( "http://realtime.ripta.com:81/api/tripupdates?format=gtfs.proto", "http://realtime.ripta.com:81/api/vehiclepositions?format=gtfs.proto", ), + ; + + companion object { + /** + * Recovers which agency a screen's [dbFile] belongs to, from the same "gtfs/{id}/transit.db" + * path convention [gtfsDbFile] builds it with — so a screen only needs to carry [dbFile] (which + * it already does, to run any query at all) to know which agency's live feeds to poll, rather + * than needing `agency` threaded through as a second, separate parameter everywhere. Driven + * entirely by [id], so it stays correct with no changes needed if a third agency is added later. + */ + fun forDbFile(dbFile: File): GtfsAgency? = entries.find { it.id == dbFile.parentFile?.name } + } } diff --git a/tool/src/main/kotlin/com/thelightphone/transit/gtfs/GtfsRepository.kt b/tool/src/main/kotlin/com/thelightphone/transit/gtfs/GtfsRepository.kt index 8e7848db..a3480e6a 100644 --- a/tool/src/main/kotlin/com/thelightphone/transit/gtfs/GtfsRepository.kt +++ b/tool/src/main/kotlin/com/thelightphone/transit/gtfs/GtfsRepository.kt @@ -214,6 +214,16 @@ class GtfsRepository(dbFile: File) { } } + /** A trip's route_type, for picking its live-vehicle emoji (see [LineType]) on the Trip Detail + * screen -- a trip belongs to exactly one route, so this is a single-value lookup, not a list. */ + fun getRouteTypeForTrip(tripId: String): Int? = + db.rawQuery( + "SELECT r.route_type FROM trips t JOIN routes r ON r.route_id = t.route_id WHERE t.trip_id = ?", + arrayOf(tripId), + ).use { cursor -> + cursor.mapRows { getInt(0) }.firstOrNull() + } + fun getTripStops(tripId: String, fromStopSequence: Int): List = db.rawQuery( """ diff --git a/tool/src/main/kotlin/com/thelightphone/transit/gtfs/MapPreferences.kt b/tool/src/main/kotlin/com/thelightphone/transit/gtfs/MapPreferences.kt new file mode 100644 index 00000000..98b36c09 --- /dev/null +++ b/tool/src/main/kotlin/com/thelightphone/transit/gtfs/MapPreferences.kt @@ -0,0 +1,24 @@ +package com.thelightphone.transit.gtfs + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +private val darkMapKey = booleanPreferencesKey("DARK_MAP_ENABLED") + +/** + * The user's Map screen tile style (Settings screen), persisted the same way [AgencyPreferences] + * persists the default agency. When enabled, the Map screen fetches CARTO's Dark Matter tiles + * instead of its default (Voyager, brighter/more legible) tiles -- see [MapTileClient]. + */ +class MapPreferences(private val dataStore: DataStore) { + + val darkMapEnabledFlow: Flow = dataStore.data.map { prefs -> prefs[darkMapKey] ?: false } + + suspend fun setDarkMapEnabled(enabled: Boolean) { + dataStore.edit { prefs -> prefs[darkMapKey] = enabled } + } +} diff --git a/tool/src/main/kotlin/com/thelightphone/transit/gtfs/MapTiles.kt b/tool/src/main/kotlin/com/thelightphone/transit/gtfs/MapTiles.kt index cb43e39b..2eca5e30 100644 --- a/tool/src/main/kotlin/com/thelightphone/transit/gtfs/MapTiles.kt +++ b/tool/src/main/kotlin/com/thelightphone/transit/gtfs/MapTiles.kt @@ -149,16 +149,20 @@ private object TileCache { } /** - * Fetches individual raster tiles from CARTO's free Dark Matter basemap (built on OpenStreetMap - * data), caching decoded bitmaps in [TileCache]. A real, descriptive User-Agent is sent on every - * request, and on-screen "© OpenStreetMap contributors © CARTO" attribution is required wherever - * these tiles are displayed — see MapScreen's Content(). + * Fetches individual raster tiles from one of CARTO's free basemaps (built on OpenStreetMap data), + * caching decoded bitmaps in [TileCache]. Voyager is the default -- chosen specifically for + * street-name legibility, since its labels and road contours stay readable at the small sizes this + * screen renders at, which the darker "Dark Matter" style didn't -- but Dark Matter remains + * available as an opt-in (see MapPreferences) for anyone who prefers it. A real, descriptive + * User-Agent is sent on every request, and on-screen "© OpenStreetMap contributors © CARTO" + * attribution is required wherever these tiles are displayed — see MapScreen's Content(). */ class MapTileClient { private val client = HttpClient(OkHttp) companion object { - private const val TILE_BASE_URL = "https://a.basemaps.cartocdn.com/dark_all" + private const val VOYAGER_BASE_URL = "https://a.basemaps.cartocdn.com/rastertiles/voyager" + private const val DARK_BASE_URL = "https://a.basemaps.cartocdn.com/dark_all" private const val USER_AGENT = "LightTransitTool/1.0 (+https://github.com/lightphone)" // Fetched area is this much larger than the target radius, so the real device canvas (whose // exact size isn't known yet when tiles are requested) ends up comfortably inside the @@ -169,9 +173,17 @@ class MapTileClient { /** * Every tile needed to cover a [targetRadiusMeters] circle around (lat, lon) at [zoom], fetched * concurrently. Individual tile failures are logged and simply omitted from the result — never - * fail the whole map for one bad tile. + * fail the whole map for one bad tile. [darkMode] selects Dark Matter over the default Voyager + * style; both are cached independently (see [fetchTile]) so switching styles never serves a + * stale tile from the other one. */ - suspend fun fetchTilesAround(lat: Double, lon: Double, zoom: Int, targetRadiusMeters: Double): MapTiles = coroutineScope { + suspend fun fetchTilesAround( + lat: Double, + lon: Double, + zoom: Int, + targetRadiusMeters: Double, + darkMode: Boolean = false, + ): MapTiles = coroutineScope { val (centerFracX, centerFracY) = lonLatToTileFraction(lat, lon, zoom) val radiusPixels = targetRadiusMeters / metersPerPixel(lat, zoom) * COVERAGE_MARGIN val radiusTiles = ceil(radiusPixels / TILE_SIZE).toInt().coerceAtLeast(1) @@ -186,18 +198,20 @@ class MapTileClient { } } val tiles = tileCoords - .map { (tileX, tileY) -> async { fetchTile(tileX, tileY, zoom)?.let { FetchedTile(tileX, tileY, it) } } } + .map { (tileX, tileY) -> async { fetchTile(tileX, tileY, zoom, darkMode)?.let { FetchedTile(tileX, tileY, it) } } } .mapNotNull { it.await() } MapTiles(zoom, centerFracX, centerFracY, tiles) } - private suspend fun fetchTile(x: Int, y: Int, zoom: Int): Bitmap? { - val key = "$zoom/$x/$y" + private suspend fun fetchTile(x: Int, y: Int, zoom: Int, darkMode: Boolean): Bitmap? { + val style = if (darkMode) "dark" else "voyager" + val key = "$style/$zoom/$x/$y" TileCache.get(key)?.let { return it } + val baseUrl = if (darkMode) DARK_BASE_URL else VOYAGER_BASE_URL return try { - val response = client.get("$TILE_BASE_URL/$zoom/$x/$y.png") { + val response = client.get("$baseUrl/$zoom/$x/$y.png") { header("User-Agent", USER_AGENT) } if (!response.status.isSuccess()) return null From 3e30a5b4c18556b964b75cc30cf1fc566f922c5f Mon Sep 17 00:00:00 2001 From: Christian Date: Fri, 31 Jul 2026 09:59:50 -0400 Subject: [PATCH 09/14] Add full name to Pico Transit's license, reference it from both READMEs LICENSE-TRANSIT already existed (added in the first Transit commit) but was never linked to from anywhere, so nothing made clear it separately covers tool/ under its own copyright rather than the root LICENSE. Also add Christian Ferreira's full name alongside the CJFData handle. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_014sF9rJBmDSbG6LQKf2ua2h --- LICENSE-TRANSIT | 2 +- README.md | 4 ++++ tool/README.md | 4 ++++ 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/LICENSE-TRANSIT b/LICENSE-TRANSIT index 0c0a5b7c..fcf2a60e 100644 --- a/LICENSE-TRANSIT +++ b/LICENSE-TRANSIT @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2026 CJFData +Copyright (c) 2026 Christian Ferreira (CJFData) Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 50700d0a..c4f8ae76 100644 --- a/README.md +++ b/README.md @@ -47,3 +47,7 @@ That's it — happy transit-ing! 🚏🚌🚆 - **RIPTA's live feeds are HTTP-only** (no HTTPS), which Android blocks by default. There's a small, clearly-labeled `:netconfig` module that grants just that one narrow exception — see its own `build.gradle.kts` for exactly what it does and how to remove it if you'd rather stay HTTPS-only everywhere. - **No device GPS is used anywhere** — the SDK doesn't expose it to tools yet. Nearby-stop and location search are powered by Nominatim (OpenStreetMap) and IP-based geolocation instead. Be kind to their free APIs! 🙏 - **Stations are deduplicated using GTFS's `parent_station`** — a big station with several platforms (subway entrances, commuter rail tracks, etc.) shows up as one marker/entry, not one per platform, while still resolving to the right platform's `stop_id` under the hood for schedule lookups. + +## 📄 License + +The [`tool/`](tool/) directory (Pico Transit itself) is licensed separately from the rest of the monorepo — see [`LICENSE-TRANSIT`](LICENSE-TRANSIT) (MIT, © Christian Ferreira / CJFData). The rest of `light-sdk` remains under its own [`LICENSE`](LICENSE) (MIT, © The Light Phone). diff --git a/tool/README.md b/tool/README.md index 50700d0a..2bdc911f 100644 --- a/tool/README.md +++ b/tool/README.md @@ -47,3 +47,7 @@ That's it — happy transit-ing! 🚏🚌🚆 - **RIPTA's live feeds are HTTP-only** (no HTTPS), which Android blocks by default. There's a small, clearly-labeled `:netconfig` module that grants just that one narrow exception — see its own `build.gradle.kts` for exactly what it does and how to remove it if you'd rather stay HTTPS-only everywhere. - **No device GPS is used anywhere** — the SDK doesn't expose it to tools yet. Nearby-stop and location search are powered by Nominatim (OpenStreetMap) and IP-based geolocation instead. Be kind to their free APIs! 🙏 - **Stations are deduplicated using GTFS's `parent_station`** — a big station with several platforms (subway entrances, commuter rail tracks, etc.) shows up as one marker/entry, not one per platform, while still resolving to the right platform's `stop_id` under the hood for schedule lookups. + +## 📄 License + +This `tool/` directory (Pico Transit itself) is licensed separately from the rest of the monorepo — see [`LICENSE-TRANSIT`](../LICENSE-TRANSIT) (MIT, © Christian Ferreira / CJFData). The rest of `light-sdk` remains under its own [`LICENSE`](../LICENSE) (MIT, © The Light Phone). From 3c7a79d17920f10118ff041ca40245d02fc304ad Mon Sep 17 00:00:00 2001 From: CJFData Date: Sat, 1 Aug 2026 01:50:42 -0400 Subject: [PATCH 10/14] Revise bus inquiry phrasing in README Updated the phrasing from 'when's my bus' to 'where's my bus' for clarity. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index c4f8ae76..62ca16ad 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # 🚌 Pico Transit -Pico Transit is a friendly little companion for getting around on public transit. Real schedules, real-time arrivals, live connections at any stop, and a live map that shows exactly where your ride actually is — no ads, no clutter, no infinite scroll. Just "when's my bus," answered nicely. 🚏✨ +Pico Transit is a friendly little companion for getting around on public transit. Real schedules, real-time arrivals, live connections at any stop, and a live map that shows exactly where your ride actually is — no ads, no clutter, no infinite scroll. Just "where's my bus," answered nicely. 🚏✨ Right now Pico Transit knows its way around **MBTA** and **RIPTA**, with more agencies hopefully hopping aboard down the road. It's built on the [Light SDK](../) for the Light Phone III, so it stays just as calm and un-distracting as the rest of your Light experience. From c602a4df49eea5b82dc0edb56ff105ff2780d6d3 Mon Sep 17 00:00:00 2001 From: CJFData Date: Sat, 1 Aug 2026 01:51:28 -0400 Subject: [PATCH 11/14] Update README for Pico Transit to indicate ALPHA TESTING --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 62ca16ad..6523c735 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# 🚌 Pico Transit +# 🚌 Pico Transit (ALPHA TESTING) Pico Transit is a friendly little companion for getting around on public transit. Real schedules, real-time arrivals, live connections at any stop, and a live map that shows exactly where your ride actually is — no ads, no clutter, no infinite scroll. Just "where's my bus," answered nicely. 🚏✨ From c6162205e91282d871bc8d04e841ac489b00bc52 Mon Sep 17 00:00:00 2001 From: Christian Date: Sat, 1 Aug 2026 03:20:48 -0400 Subject: [PATCH 12/14] Rework Map screen icons/labels, add tap-and-hold arrivals shortcut - Replace emoji markers with SDK LightIcons for stops/vehicles, fix the back button being drawn over by unclipped Canvas content, tighten the fit-to-bounds zoom range, and shrink the top scrim now that it only needs to cover the attribution line. - Fix tapped-stop and vehicle labels clipping at the screen edge by flipping to the opposite side of their icon when needed (shared resolveLabelSide helper), and stop them overlapping their own icon. - Shorten vehicle trip descriptions to " - Toward " now that the mode icon replaces the old text prefix; same icon now replaces the "Bus -" prefix on Upcoming Arrivals. - Tint solid marker/vehicle/toggle icons black when Light map tiles are selected, so they stay legible against bright tiles. - Add a Settings toggle ("Tap and hold a stop") that lets long-pressing any stop marker on the Map screen jump straight to its Upcoming Arrivals, off by default. - Rename the Upcoming Arrivals "Map" link to show the stop name instead, relabel the stop-name line "Selected stop", and enlarge the map icon. Co-Authored-By: Claude Sonnet 5 --- .../com/thelightphone/transit/MapScreen.kt | 443 +++++++++++++----- .../thelightphone/transit/SettingsScreen.kt | 45 ++ .../thelightphone/transit/TripDetailScreen.kt | 84 +++- .../transit/UpcomingArrivalsScreen.kt | 27 +- .../transit/gtfs/MapPreferences.kt | 10 + .../thelightphone/transit/gtfs/MapTiles.kt | 20 +- 6 files changed, 477 insertions(+), 152 deletions(-) diff --git a/tool/src/main/kotlin/com/thelightphone/transit/MapScreen.kt b/tool/src/main/kotlin/com/thelightphone/transit/MapScreen.kt index 39b60759..325c313c 100644 --- a/tool/src/main/kotlin/com/thelightphone/transit/MapScreen.kt +++ b/tool/src/main/kotlin/com/thelightphone/transit/MapScreen.kt @@ -1,5 +1,6 @@ package com.thelightphone.transit +import android.graphics.Bitmap import android.graphics.Paint import android.util.Log import androidx.compose.foundation.Canvas @@ -7,20 +8,30 @@ import androidx.compose.foundation.background import androidx.compose.foundation.gestures.awaitEachGesture import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.gestures.waitForUpOrCancellation -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding +import androidx.compose.ui.draw.clipToBounds import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asAndroidBitmap +import androidx.compose.ui.graphics.drawscope.CanvasDrawScope import androidx.compose.ui.graphics.nativeCanvas import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp +import androidx.compose.ui.graphics.Canvas as ComposeCanvas import androidx.lifecycle.viewModelScope import com.thelightphone.transit.gtfs.ArrivalStatus import com.thelightphone.transit.gtfs.GtfsAgency @@ -46,15 +57,14 @@ import com.thelightphone.sdk.LightViewModel import com.thelightphone.sdk.SealedLightActivity import com.thelightphone.sdk.SimpleLightScreen import com.thelightphone.sdk.ui.LightBarButton +import com.thelightphone.sdk.ui.LightIconConfiguration import com.thelightphone.sdk.ui.LightIcons import com.thelightphone.sdk.ui.LightText import com.thelightphone.sdk.ui.LightTextVariant import com.thelightphone.sdk.ui.LightTheme -import com.thelightphone.sdk.ui.LightThemeColors import com.thelightphone.sdk.ui.LightThemeController import com.thelightphone.sdk.ui.LightThemeTokens import com.thelightphone.sdk.ui.LightTopBar -import com.thelightphone.sdk.ui.gridUnitsAsDp import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.delay @@ -63,6 +73,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.isActive import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull import java.io.File import java.time.Instant import java.time.LocalDateTime @@ -81,9 +92,6 @@ private const val MAX_DISPLAYED_BUSES_PER_STOP = 4 // always near that cap in any reasonably dense area, collapsing the fit to look like a fixed zoom // regardless of true local density (verified empirically against real MBTA stop data). private const val ZOOM_FIT_NEAREST_STOP_COUNT = 8 -// Matches LightTopBar's own (private) TOPBAR_HEIGHT_UNITS -- the overlaid back button lives at this -// height, so Loading/Error text below it needs the same clearance to avoid sitting behind the icon. -private const val TOPBAR_CLEARANCE_UNITS = 3f // The LP3's screen is 1080x1240px at 3x density (see the @Preview(widthDp = 1080 / 3, heightDp = // 1240 / 3) calls elsewhere in the SDK) — used as the "available half-extent" nearby stops are fit @@ -93,26 +101,51 @@ private const val TOPBAR_CLEARANCE_UNITS = 3f // intended (tiles are fetched with their own margin on top of this for exactly that reason — see // MapTileClient). private const val MAP_TARGET_RADIUS_PIXELS = 420f -// Floor: even an isolated stop with no nearby neighbors stays reasonably close-in, never "a tiny dot -// in a huge area". Ceiling: matches what CARTO/standard XYZ tile pyramids reliably serve -- higher -// risks missing tiles in less-mapped areas. Fallback: used only when there's nothing to fit bounds -// to at all (no other stops exist anywhere nearby). -private const val MIN_ZOOM = 14 -private const val MAX_ZOOM = 19 -private const val FALLBACK_ZOOM = 17 +// Floor: even a sparse/isolated area never zooms out past this, so nearby stops don't visually +// collapse into an unreadable cluster. Ceiling: never zooms in tighter than this either, so there's +// always at least a block or two of surrounding streets left for orientation. Fallback: used only +// when there's nothing to fit bounds to at all (no other stops exist anywhere nearby) -- rarely +// reached in practice since MIN_BOUNDING_BOX_MILES below already keeps the box away from zero-size. +private const val MIN_ZOOM = 17 +private const val MAX_ZOOM = 20 +private const val FALLBACK_ZOOM = 20 +// Treat the nearby-stop cluster's bounding box as at least this wide in each dimension before +// computing zoom, so a real-world-tiny cluster (e.g. two platforms 30ft apart) doesn't zoom in +// absurdly tight just because the points themselves happen to be that close together. +private const val MIN_BOUNDING_BOX_MILES = 0.15 private const val STOP_HIT_RADIUS_PX = 44f private const val LABEL_LINE_HEIGHT_PX = 22f -private const val LABEL_OFFSET_PX = 24f - -// Title/legend render as an overlay bar on top of the map itself (matching how the compass letters +// Gap between a tapped stop's marker and its expanded name label, which renders to the marker's +// right (anchored at the marker's own base) rather than centered under it, so the label text never +// overlaps the icon itself. +private const val LABEL_GAP_PX = 12f + +// Vector icon sizes (real pixels, matching this Canvas's own coordinate space -- not dp/grid units). +// Nearby-stop markers must stay visibly smaller than the center marker despite sharing the same +// icon shape (see LightIcons.DIRECTIONS_ARRIVAL usage below), since size is the only thing distinguishing them. +private const val CENTER_MARKER_ICON_PX = 64 +private const val NEARBY_MARKER_ICON_PX = 40 +// Close to NEARBY_MARKER_ICON_PX rather than matching CENTER_MARKER_ICON_PX -- vehicles are +// transient/moving, not a fixed place to orient by, so they shouldn't visually compete with the +// selected stop's own pin, but still read as slightly more prominent than a plain nearby stop. +private const val VEHICLE_MARKER_ICON_PX = 46 +// Matches the on-canvas size of the "No live vehicles..."-style status message (LightTextVariant +// .Detail, rendered outside the canvas by Content()) -- measured against a real render of that text +// so the toggle reads at the same size as the status line right above it, rather than notably smaller. +private const val TOGGLE_ICON_PX = 60 +private const val TOGGLE_TEXT_SIZE_PX = 40f +private const val TOGGLE_TEXT_GAP_PX = 12f +private const val TOGGLE_TEXT = "Track Tapped Stops" + +// Attribution renders as an overlay bar on top of the map itself (matching how the compass letters // already render directly on the map), rather than in a separate section above it. Solid, not // translucent, and flush with the top of the screen -- the compass's "N" label is pushed below it -// (see COMPASS_SCRIM_MARGIN_PX) so the two never overlap. -private const val SCRIM_HEIGHT_PX = 180f +// (see COMPASS_SCRIM_MARGIN_PX) so the two never overlap. The real LightTopBar (back button) sits +// above this in normal layout flow, and any live-feed status message renders above the canvas too +// (see Content()), so this scrim only needs to cover the attribution line itself. +private const val SCRIM_HEIGHT_PX = 40f private const val OVERLAY_INSET_X = 28f -private const val TITLE_Y = 60f -private const val LEGEND_Y = 104f -private const val ATTRIBUTION_Y = 134f +private const val ATTRIBUTION_Y = 26f private const val COMPASS_SCRIM_MARGIN_PX = 24f private const val TOGGLE_BOTTOM_MARGIN_PX = 24f @@ -123,12 +156,14 @@ data class BusMarker( * special "arrived" row above the center pin; everything else always draws at its true * position, since that row visually means "arrived at the center pin" specifically. */ val targetStopId: String, + /** Short route identifier only (e.g. "R"), not the full "shortName - longName" combo -- the + * marker icon already conveys the mode (bus/subway/rail), so the label just needs enough to + * tell same-mode routes apart. See [tripDescription]. */ val routeLabel: String, val directionLabel: String, - val lineLabel: String?, - /** Mode-specific: 🚇 subway/light rail, 🚆 commuter rail, 🚌 bus (also the fallback for any - * unmapped route_type) -- see LineType.emoji, the single source of truth for this mapping. */ - val emoji: String, + /** Mode-specific: subway/light rail, commuter rail, bus (also the fallback for any unmapped + * route_type) -- see [vehicleIconFor], the single source of truth for this mapping. */ + val vehicleIcon: LightIconConfiguration, val etaEpochSeconds: Long, /** Null just means "no delay/prediction info yet" — a marker only ever exists for a trip with a * live VehiclePosition match, so this is never a stand-in for stale/non-live data. */ @@ -138,6 +173,18 @@ data class BusMarker( val lon: Double, ) +/** Maps a route's [LineType] to the SDK icon for its vehicle marker -- BUS is also the fallback + * for a null (unmapped route_type) LineType, same as the emoji fallback this replaced. */ +fun LineType?.toVehicleIcon(): LightIconConfiguration = when (this) { + LineType.SUBWAY -> LightIcons.DIRECTIONS_SUBWAY + LineType.COMMUTER_RAIL -> LightIcons.DIRECTIONS_TRAIN + LineType.BUS, null -> LightIcons.DIRECTIONS_BUS +} + +/** e.g. "R · Toward Pawtucket-Central Falls Transit Center" -- no mode prefix (the icon already + * shows bus/subway/rail) and no long route name, just enough to tell same-mode routes apart. */ +fun BusMarker.tripDescription(): String = "$routeLabel · $directionLabel" + fun BusMarker.etaDisplay(): String { val time = LocalDateTime.ofInstant(Instant.ofEpochSecond(etaEpochSeconds), ZoneId.systemDefault()) return "ETA: " + formatGtfsTime("%02d:%02d:00".format(time.hour, time.minute)) @@ -178,6 +225,10 @@ sealed class MapState { val buses: List, val nearbyStops: List, val liveFeedStatus: LiveFeedStatus, + /** Settings screen toggle (off by default) -- see MapPreferences.tapHoldArrivalsEnabledFlow. */ + val tapHoldArrivalsEnabled: Boolean, + /** Settings screen's Map style toggle -- see MapPreferences.darkMapEnabledFlow. */ + val darkMapEnabled: Boolean, ) : MapState() data class Error(val message: String) : MapState() } @@ -190,6 +241,8 @@ private data class LoadedMapContext( val zoom: Int, val mapTiles: MapTiles?, val nearbyStops: List, + val tapHoldArrivalsEnabled: Boolean, + val darkMapEnabled: Boolean, ) class MapViewModel( @@ -231,6 +284,7 @@ class MapViewModel( // Read once at screen-open, same as HomeScreen's default-agency read -- Settings // isn't shown at the same time as this screen, so there's no need to react live. val darkMode = mapPreferences.darkMapEnabledFlow.first() + val tapHoldArrivalsEnabled = mapPreferences.tapHoldArrivalsEnabledFlow.first() // Snapshot once: which trips are scheduled for this stop from now on. Live polling // below only checks these same trips for a live position match — a trip that wasn't // scheduled at screen-open time won't appear mid-session. @@ -255,6 +309,7 @@ class MapViewModel( minZoom = MIN_ZOOM, maxZoom = MAX_ZOOM, fallbackZoom = FALLBACK_ZOOM, + minBoundingBoxMiles = MIN_BOUNDING_BOX_MILES, ) // The map background is a set of still tiles, fetched once — it doesn't need to @@ -275,7 +330,7 @@ class MapViewModel( stop.lat, stop.lon, fetchRadiusMeters, excludeStopId = stopId, ).map { nearby -> NearbyStopMarker(nearby.stopId, nearby.stopName, nearby.lat, nearby.lon) } - loadedContext = LoadedMapContext(stop, streetContext, zoom, mapTiles, nearbyStops) + loadedContext = LoadedMapContext(stop, streetContext, zoom, mapTiles, nearbyStops, tapHoldArrivalsEnabled, darkMode) while (isActive) { refresh() @@ -334,6 +389,7 @@ class MapViewModel( _state.value = MapState.Loaded( context.streetContext, context.stop.lat, context.stop.lon, context.zoom, context.mapTiles, emptyList(), context.nearbyStops, LiveFeedStatus.NOT_SUPPORTED, + context.tapHoldArrivalsEnabled, context.darkMapEnabled, ) return } @@ -348,6 +404,7 @@ class MapViewModel( _state.value = MapState.Loaded( context.streetContext, context.stop.lat, context.stop.lon, context.zoom, context.mapTiles, emptyList(), context.nearbyStops, LiveFeedStatus.UNAVAILABLE, + context.tapHoldArrivalsEnabled, context.darkMapEnabled, ) return } @@ -388,10 +445,9 @@ class MapViewModel( BusMarker( tripId = arrival.tripId, targetStopId = activeStopId, - routeLabel = arrival.route.displayName, + routeLabel = arrival.route.shortName?.takeIf { it.isNotBlank() } ?: arrival.route.displayName, directionLabel = arrival.direction.displayLabel(), - lineLabel = lineType?.label, - emoji = lineType?.emoji ?: "🚌", + vehicleIcon = lineType.toVehicleIcon(), etaEpochSeconds = eta.etaEpochSeconds, status = eta.status, isArrived = isArrived, @@ -407,6 +463,7 @@ class MapViewModel( _state.value = MapState.Loaded( context.streetContext, context.stop.lat, context.stop.lon, context.zoom, context.mapTiles, dedupedBuses, context.nearbyStops, LiveFeedStatus.OK, + context.tapHoldArrivalsEnabled, context.darkMapEnabled, ) } @@ -440,29 +497,34 @@ class MapScreen( val themeColors by LightThemeController.colors.collectAsState() LightTheme(colors = themeColors) { - Box( + Column( modifier = Modifier .fillMaxSize() .background(LightThemeTokens.colors.background) ) { - Column(modifier = Modifier.fillMaxSize()) { - // Loading/Error render below the overlaid back button's row (see the LightTopBar - // further down, drawn on top of this Column) rather than at the very top edge, - // so the text never sits behind the icon. - val aboveOverlayPadding = Modifier.padding(top = TOPBAR_CLEARANCE_UNITS.gridUnitsAsDp() + 16.dp, start = 16.dp, end = 16.dp) + // A real LightTopBar in normal layout flow -- same pattern LightQrCodeScanner uses + // for a back button over full-bleed content, rather than absolutely overlaying one + // on top of the Canvas. That approach only ever colliding-by-luck depended on the + // Canvas's own title text staying clear of wherever the icon happened to sit; this + // way there's nothing else placed in the icon's row for it to collide with, by + // construction. No title text here -- the Map screen doesn't need to say "Map". + LightTopBar( + leftButton = LightBarButton.LightIcon(icon = LightIcons.BACK, onClick = { goBack() }), + ) + Column(modifier = Modifier.weight(1f)) { when (val s = state) { is MapState.Loading -> LightText( text = "Loading...", variant = LightTextVariant.Copy, lighten = true, - modifier = aboveOverlayPadding, + modifier = Modifier.padding(16.dp), ) is MapState.Error -> LightText( text = s.message, variant = LightTextVariant.Copy, lighten = true, - modifier = aboveOverlayPadding, + modifier = Modifier.padding(16.dp), ) is MapState.Loaded -> { @@ -497,29 +559,54 @@ class MapScreen( nearbyStops = s.nearbyStops, expandedStopIds = expandedStopIds, nearbyVehiclesEnabled = nearbyVehiclesEnabled, + tapHoldArrivalsEnabled = s.tapHoldArrivalsEnabled, + darkMapEnabled = s.darkMapEnabled, onToggleStop = viewModel::toggleStopExpanded, onToggleNearbyVehicles = viewModel::toggleNearbyVehicles, + onStopLongPressed = { longPressStopId, longPressStopLabel -> + navigateTo(screenFactory = { activity -> + UpcomingArrivalsScreen(activity, dbFile, agency, longPressStopId, longPressStopLabel) + }) + }, modifier = Modifier.fillMaxSize(), ) } } } - - // Always white, independent of the app's light/dark theme -- forced into the Dark - // palette so the icon reads against the canvas's own always-solid-black top scrim - // (see SCRIM_HEIGHT_PX), the same theme-independent treatment as the rest of that - // overlay (title, legend, compass letters). - LightTheme(colors = LightThemeColors.Dark) { - LightTopBar( - leftButton = LightBarButton.LightIcon(icon = LightIcons.BACK, onClick = { goBack() }), - modifier = Modifier.align(Alignment.TopStart), - ) - } } } } } +/** + * Rasterizes an SDK vector icon to a plain [Bitmap] once (cached for this icon+size+tint for as + * long as this composable stays alive), for drawing directly via the native Canvas -- which draws + * bitmaps, not Compose vector assets. Built entirely from Compose's own resource/drawing APIs + * ([painterResource], [CanvasDrawScope]) rather than a raw [android.content.Context] lookup, since + * tool code can't hold one directly (see the SDK's own build-time source restrictions). Every icon + * in [LightIcons] ships solid white in its own resource (see the "_white" drawable naming) -- [tint] + * recolors it via [ColorFilter.tint] since there's no separate dark-mode drawable to fall back on, + * needed so these solid (non-outlined) glyphs stay legible against Light map tiles the same way the + * white-fill/black-outline label text already is against either tile style. + */ +@Composable +private fun rememberIconBitmap(icon: LightIconConfiguration, sizePx: Int, tint: Color): Bitmap { + val painter = painterResource(icon.drawableResource) + val colorFilter = remember(tint) { ColorFilter.tint(tint) } + return remember(painter, sizePx, tint) { + val imageBitmap = ImageBitmap(sizePx, sizePx) + CanvasDrawScope().draw( + density = Density(1f), + layoutDirection = LayoutDirection.Ltr, + canvas = ComposeCanvas(imageBitmap), + size = Size(sizePx.toFloat(), sizePx.toFloat()), + ) { + with(painter) { draw(size = size, colorFilter = colorFilter) } + } + imageBitmap.asAndroidBitmap() + } +} + /** * Draws the whole map entirely on a single Canvas, including text — emoji glyphs and labels are * drawn via the underlying native android.graphics.Canvas/Paint (Compose's own text APIs are built @@ -549,14 +636,32 @@ private fun MapCanvas( nearbyStops: List, expandedStopIds: Set, nearbyVehiclesEnabled: Boolean, + tapHoldArrivalsEnabled: Boolean, + /** Settings screen's Map style toggle -- Light tiles are bright, so solid icon glyphs need to + * render dark (black) to stay legible over them; Dark tiles keep the original white glyphs. */ + darkMapEnabled: Boolean, onToggleStop: (String) -> Unit, onToggleNearbyVehicles: () -> Unit, + /** Fires for a long press on a stop marker (the center stop or any nearby one) when + * [tapHoldArrivalsEnabled] is on -- see the Settings screen's "Tap and hold a stop" toggle. */ + onStopLongPressed: (stopId: String, stopLabel: String) -> Unit, modifier: Modifier = Modifier, ) { + // Loaded once per icon+size+tint (remember only works at composition time, not inside the + // Canvas draw-phase lambda below), then just referenced as a plain Bitmap during drawing. + val iconTint = if (darkMapEnabled) Color.White else Color.Black + val centerMarkerBitmap = rememberIconBitmap(LightIcons.DIRECTIONS_ARRIVAL, CENTER_MARKER_ICON_PX, iconTint) + val nearbyMarkerBitmap = rememberIconBitmap(LightIcons.DIRECTIONS_ARRIVAL, NEARBY_MARKER_ICON_PX, iconTint) + val busIconBitmap = rememberIconBitmap(LightIcons.DIRECTIONS_BUS, VEHICLE_MARKER_ICON_PX, iconTint) + val subwayIconBitmap = rememberIconBitmap(LightIcons.DIRECTIONS_SUBWAY, VEHICLE_MARKER_ICON_PX, iconTint) + val trainIconBitmap = rememberIconBitmap(LightIcons.DIRECTIONS_TRAIN, VEHICLE_MARKER_ICON_PX, iconTint) + val toggleOffBitmap = rememberIconBitmap(LightIcons.TOGGLE_STATE_OFF, TOGGLE_ICON_PX, iconTint) + val toggleOnBitmap = rememberIconBitmap(LightIcons.TOGGLE_STATE_ON, TOGGLE_ICON_PX, iconTint) Canvas( modifier = modifier - .pointerInput(nearbyStops, centerLat, centerLon, zoom, nearbyVehiclesEnabled) { + .clipToBounds() + .pointerInput(nearbyStops, centerLat, centerLon, zoom, nearbyVehiclesEnabled, tapHoldArrivalsEnabled) { awaitEachGesture { val down = awaitFirstDown(requireUnconsumed = false) val center = Offset(size.width / 2f, size.height / 2f) @@ -569,15 +674,41 @@ private fun MapCanvas( val dy = down.position.y - point.y sqrt(dx * dx + dy * dy) < STOP_HIT_RADIUS_PX } + val hitCenter = run { + val dx = down.position.x - center.x + val dy = down.position.y - center.y + sqrt(dx * dx + dy * dy) < STOP_HIT_RADIUS_PX + } val toggleBounds = toggleHitRect(size.height.toFloat()) val hitToggle = toggleBounds.contains(down.position) - if (hitStop == null && !hitToggle) { + if (hitStop == null && !hitCenter && !hitToggle) { // Not on anything we handle -- leave the event completely untouched so // ancestor/system gestures (like edge-swipe back) still see it normally. return@awaitEachGesture } down.consume() + + // Tap-and-hold on a stop marker (the center stop or any nearby one) jumps to its + // upcoming arrivals -- opt-in via Settings ("Tap and hold a stop"), since it's an + // extra gesture layered on top of the tap-to-toggle behavior below. Fires as soon + // as the hold threshold is reached rather than waiting for release, matching how + // a long press reads everywhere else on Android. + if (tapHoldArrivalsEnabled && (hitStop != null || hitCenter)) { + val shortTapUp = withTimeoutOrNull(viewConfiguration.longPressTimeoutMillis) { waitForUpOrCancellation() } + if (shortTapUp == null) { + if (hitStop != null) { + onStopLongPressed(hitStop.stopId, hitStop.stopName ?: "Stop ${hitStop.stopId}") + } else { + onStopLongPressed(stopId, stopLabel) + } + } else { + shortTapUp.consume() + hitStop?.let { onToggleStop(it.stopId) } + } + return@awaitEachGesture + } + val up = waitForUpOrCancellation() if (up != null) { up.consume() @@ -598,11 +729,6 @@ private fun MapCanvas( nativeCanvas.drawBitmap(tile.bitmap, center.x + offset.x, center.y + offset.y, null) } - val emojiPaint = Paint().apply { - textSize = 56f - textAlign = Paint.Align.CENTER - isAntiAlias = true - } // Always white fill + black outline, independent of theme -- Voyager's imagery ranges from // near-white streets to colored parks/water, so any single fixed color would go illegible // over some patch of the map. Same treatment nearbyStopLabelPaint below already used. @@ -625,16 +751,19 @@ private fun MapCanvas( textSize = 22f strokeWidth = 4f } - val nearbyStopEmojiPaint = Paint().apply { - textSize = 34f - textAlign = Paint.Align.CENTER - isAntiAlias = true - } + // Left-aligned copies for vehicle marker text, which -- like tapped stop labels -- renders + // to the right of its icon rather than centered under it (see drawBusMarker). + val vehicleLabelPaint = Paint(labelPaint).apply { textAlign = Paint.Align.LEFT } + val vehicleLabelOutlinePaint = Paint(labelOutlinePaint).apply { textAlign = Paint.Align.LEFT } + val vehicleSmallLabelPaint = Paint(smallLabelPaint).apply { textAlign = Paint.Align.LEFT } + val vehicleSmallLabelOutlinePaint = Paint(smallLabelOutlinePaint).apply { textAlign = Paint.Align.LEFT } // Always-white fill + black outline, independent of theme -- legible against both dark and // light patches of map, rather than the theme-dependent (and previously too-small) styling. + // Left-aligned (rather than centered) since expanded stop labels now render to the right of + // their marker -- see the expandedLabels block below. val nearbyStopLabelPaint = Paint().apply { textSize = 26f - textAlign = Paint.Align.CENTER + textAlign = Paint.Align.LEFT isAntiAlias = true color = android.graphics.Color.WHITE } @@ -644,39 +773,22 @@ private fun MapCanvas( color = android.graphics.Color.BLACK } - // Title/legend/attribution render as an overlay directly on the map -- a solid (not - // translucent) black bar flush with the top of the screen, so the text stays legible - // regardless of what's underneath, same idea as the compass letters already drawing - // straight onto the map. Text here is always light, independent of the app's light/dark - // theme, since the bar itself is always solid black. + // Attribution renders as an overlay directly on the map -- a solid (not translucent) black + // bar flush with the top of the screen, so the text stays legible regardless of what's + // underneath, same idea as the compass letters already drawing straight onto the map. Text + // here is always light, independent of the app's light/dark theme, since the bar itself is + // always solid black. val scrimPaint = Paint().apply { color = android.graphics.Color.BLACK } nativeCanvas.drawRect(0f, 0f, size.width, SCRIM_HEIGHT_PX, scrimPaint) - val overlayTitlePaint = Paint().apply { - textSize = 34f + val overlayAttributionPaint = Paint().apply { + textSize = 16f textAlign = Paint.Align.LEFT isAntiAlias = true color = android.graphics.Color.WHITE - } - val overlayLegendPaint = Paint(overlayTitlePaint).apply { - textSize = 22f - alpha = 220 - } - val overlayAttributionPaint = Paint(overlayTitlePaint).apply { - textSize = 16f alpha = 160 } - nativeCanvas.drawText("🗺️ Map", OVERLAY_INSET_X, TITLE_Y, overlayTitlePaint) - val legendText = "📍 Selected stop · 🚏 Nearby stop · 🚌 Bus · 🚇 Subway/Light Rail · 🚆 Commuter Rail" - val maxLegendWidthPx = size.width - 2 * OVERLAY_INSET_X - val legendWidthPx = overlayLegendPaint.measureText(legendText) - // Five items is a lot to fit on one line at a fixed size -- shrink just enough to fit - // rather than risk it running off the edge of the bar. - if (legendWidthPx > maxLegendWidthPx) { - overlayLegendPaint.textSize *= maxLegendWidthPx / legendWidthPx - } - nativeCanvas.drawText(legendText, OVERLAY_INSET_X, LEGEND_Y, overlayLegendPaint) nativeCanvas.drawText("© OpenStreetMap contributors © CARTO", OVERLAY_INSET_X, ATTRIBUTION_Y, overlayAttributionPaint) // Fixed north-up compass letters, each anchored a matching margin from its own screen edge @@ -695,19 +807,23 @@ private fun MapCanvas( nativeCanvas.drawText(letter, point.x, point.y, labelPaint) } - // Nearby Vehicles toggle -- bottom-left corner, clear of the mid-edge compass letters. - val toggleText = if (nearbyVehiclesEnabled) "🚌 Nearby Vehicles: On" else "🚌 Nearby Vehicles: Off" + // "Track Tapped Stops" toggle -- bottom-left corner, clear of the mid-edge compass letters. + // Icon shows the on/off state; the label alongside it stays since the icon alone (a thin + // slider-knob glyph) is too subtle at this size to read unambiguously on its own. Sized to + // match the on-canvas "No live vehicles..."-style status message rather than the much + // smaller size this used previously. val toggleY = size.height - TOGGLE_BOTTOM_MARGIN_PX val toggleOutlinePaint = Paint(nearbyStopLabelOutlinePaint).apply { - textSize = 22f - textAlign = Paint.Align.LEFT + textSize = TOGGLE_TEXT_SIZE_PX } val togglePaint = Paint(nearbyStopLabelPaint).apply { - textSize = 22f - textAlign = Paint.Align.LEFT + textSize = TOGGLE_TEXT_SIZE_PX } - nativeCanvas.drawText(toggleText, OVERLAY_INSET_X, toggleY, toggleOutlinePaint) - nativeCanvas.drawText(toggleText, OVERLAY_INSET_X, toggleY, togglePaint) + val toggleIconBitmap = if (nearbyVehiclesEnabled) toggleOnBitmap else toggleOffBitmap + val toggleTextX = OVERLAY_INSET_X + TOGGLE_ICON_PX + TOGGLE_TEXT_GAP_PX + nativeCanvas.drawBitmap(toggleIconBitmap, OVERLAY_INSET_X, toggleY - TOGGLE_ICON_PX / 2f - 6f, null) + nativeCanvas.drawText(TOGGLE_TEXT, toggleTextX, toggleY, toggleOutlinePaint) + nativeCanvas.drawText(TOGGLE_TEXT, toggleTextX, toggleY, togglePaint) // Nearby stops: icon-only by default, drawn as a background layer so bus markers stay // legible on top; a tap on a stop toggles its own name label on/off, independent per stop @@ -719,28 +835,49 @@ private fun MapCanvas( val point = clipToRadius(Offset(center.x + rel.x, center.y + rel.y), center, maxRadius) stop to point } - stopPoints.forEach { (_, point) -> nativeCanvas.drawText("🚏", point.x, point.y, nearbyStopEmojiPaint) } + stopPoints.forEach { (_, point) -> + nativeCanvas.drawBitmap( + nearbyMarkerBitmap, + point.x - NEARBY_MARKER_ICON_PX / 2f, + point.y - NEARBY_MARKER_ICON_PX / 2f, + null, + ) + } + // Anchored to the right of the marker's own base (its bottom edge, where the pin visually + // touches the map) rather than centered underneath it, so the label text never overlaps the + // icon itself -- flips to the marker's left instead when the label would otherwise clip off + // the canvas's right edge (see resolveLabelSide). val expandedLabels = stopPoints .filter { (stop, _) -> stop.stopId in expandedStopIds } .map { (stop, point) -> val text = stop.stopName ?: "Stop ${stop.stopId}" + val width = nearbyStopLabelPaint.measureText(text) + val side = resolveLabelSide(point.x, NEARBY_MARKER_ICON_PX.toFloat(), width, size.width) LabelBox( key = stop.stopId, text = text, - centerX = point.x, - initialY = point.y + LABEL_OFFSET_PX, - width = nearbyStopLabelPaint.measureText(text), + anchorX = side.anchorX(point.x, NEARBY_MARKER_ICON_PX.toFloat()), + align = side.paintAlign(), + initialY = point.y + NEARBY_MARKER_ICON_PX / 2f, + width = width, ) } resolveLabelPositions(expandedLabels).forEach { (key, y) -> val box = expandedLabels.first { it.key == key } - nativeCanvas.drawText(box.text, box.centerX, y, nearbyStopLabelOutlinePaint) - nativeCanvas.drawText(box.text, box.centerX, y, nearbyStopLabelPaint) + nearbyStopLabelOutlinePaint.textAlign = box.align + nearbyStopLabelPaint.textAlign = box.align + nativeCanvas.drawText(box.text, box.anchorX, y, nearbyStopLabelOutlinePaint) + nativeCanvas.drawText(box.text, box.anchorX, y, nearbyStopLabelPaint) } // Center stop pin, name, and street context — always pinned dead center. - nativeCanvas.drawText("📍", center.x, center.y, emojiPaint) + nativeCanvas.drawBitmap( + centerMarkerBitmap, + center.x - CENTER_MARKER_ICON_PX / 2f, + center.y - CENTER_MARKER_ICON_PX / 2f, + null, + ) nativeCanvas.drawText(stopLabel, center.x, center.y + 64f, labelOutlinePaint) nativeCanvas.drawText(stopLabel, center.x, center.y + 64f, labelPaint) streetContext?.let { @@ -753,11 +890,17 @@ private fun MapCanvas( val arrivedAtPrimary = buses.filter { it.isArrived && it.targetStopId == stopId } val everythingElse = buses.filterNot { it.isArrived && it.targetStopId == stopId } + fun vehicleIconBitmapFor(bus: BusMarker): Bitmap = when (bus.vehicleIcon) { + LightIcons.DIRECTIONS_SUBWAY -> subwayIconBitmap + LightIcons.DIRECTIONS_TRAIN -> trainIconBitmap + else -> busIconBitmap + } + arrivedAtPrimary.forEachIndexed { index, bus -> val xOffset = (index - (arrivedAtPrimary.size - 1) / 2f) * 110f val x = center.x + xOffset val y = center.y - maxRadius * 0.45f - drawBusMarker(nativeCanvas, emojiPaint, labelPaint, labelOutlinePaint, smallLabelPaint, smallLabelOutlinePaint, bus, x, y) + drawBusMarker(nativeCanvas, vehicleIconBitmapFor(bus), vehicleLabelPaint, vehicleLabelOutlinePaint, vehicleSmallLabelPaint, vehicleSmallLabelOutlinePaint, bus, x, y, size.width) } // Every other bus drawn individually at its true projected position — clipped to the @@ -766,20 +909,23 @@ private fun MapCanvas( everythingElse.forEach { bus -> val rel = projectRelativeToCenter(centerLat, centerLon, bus.lat, bus.lon, zoom) val clipped = clipToRadius(Offset(center.x + rel.x, center.y + rel.y), center, maxRadius) - drawBusMarker(nativeCanvas, emojiPaint, labelPaint, labelOutlinePaint, smallLabelPaint, smallLabelOutlinePaint, bus, clipped.x, clipped.y) + drawBusMarker(nativeCanvas, vehicleIconBitmapFor(bus), vehicleLabelPaint, vehicleLabelOutlinePaint, vehicleSmallLabelPaint, vehicleSmallLabelOutlinePaint, bus, clipped.x, clipped.y, size.width) } } } -/** Hit-test bounds for the Nearby Vehicles toggle text, matching where it's drawn. Rough but - * generous -- a fixed-width box wide enough for either "On"/"Off" state of the label. */ +/** Hit-test bounds for the "Track Tapped Stops" toggle, matching where it's drawn -- measured + * against the real label text and current text size rather than a guessed fixed width, so this + * can't quietly drift out of sync with the toggle's own size again. */ private fun toggleHitRect(canvasHeight: Float): androidx.compose.ui.geometry.Rect { val toggleY = canvasHeight - TOGGLE_BOTTOM_MARGIN_PX + val textWidth = Paint().apply { textSize = TOGGLE_TEXT_SIZE_PX }.measureText(TOGGLE_TEXT) + val textStartX = OVERLAY_INSET_X + TOGGLE_ICON_PX + TOGGLE_TEXT_GAP_PX return androidx.compose.ui.geometry.Rect( left = OVERLAY_INSET_X - 12f, - top = toggleY - 32f, - right = OVERLAY_INSET_X + 320f, - bottom = toggleY + 12f, + top = toggleY - TOGGLE_TEXT_SIZE_PX, + right = textStartX + textWidth + 12f, + bottom = toggleY + 16f, ) } @@ -794,8 +940,38 @@ private fun clipToRadius(point: Offset, center: Offset, maxRadius: Float): Offse return Offset(center.x + dx * scale, center.y + dy * scale) } -/** A tapped-open stop label's intended (pre-collision) position and measured size. */ -private data class LabelBox(val key: String, val text: String, val centerX: Float, val initialY: Float, val width: Float) +/** Which side of its icon a label renders on. */ +private enum class LabelSide { RIGHT, LEFT } + +/** + * Right-of-icon is the default anchor for every label on this map (offset from the icon's own + * right edge by LABEL_GAP_PX) -- but flips to the icon's left when the label would otherwise run + * past the canvas's right edge (e.g. a vehicle marker near the map's right side). Shared by both + * tapped-stop labels and vehicle labels so this edge-avoidance logic isn't duplicated (and can't + * drift out of sync) between the two call sites. + */ +private fun resolveLabelSide(iconCenterX: Float, iconSizePx: Float, textWidth: Float, canvasWidth: Float): LabelSide { + val rightAnchorX = iconCenterX + iconSizePx / 2f + LABEL_GAP_PX + val wouldClipRight = rightAnchorX + textWidth > canvasWidth - OVERLAY_INSET_X + return if (wouldClipRight) LabelSide.LEFT else LabelSide.RIGHT +} + +/** The x to pass to [android.graphics.Canvas.drawText], paired with the [Paint.Align] it must be + * drawn with -- RIGHT-side labels are left-aligned starting at the icon's right edge; LEFT-side + * (flipped) labels are right-aligned ending at the icon's left edge. */ +private fun LabelSide.anchorX(iconCenterX: Float, iconSizePx: Float): Float = when (this) { + LabelSide.RIGHT -> iconCenterX + iconSizePx / 2f + LABEL_GAP_PX + LabelSide.LEFT -> iconCenterX - iconSizePx / 2f - LABEL_GAP_PX +} + +private fun LabelSide.paintAlign(): Paint.Align = when (this) { + LabelSide.RIGHT -> Paint.Align.LEFT + LabelSide.LEFT -> Paint.Align.RIGHT +} + +/** A tapped-open stop label's intended (pre-collision) position and measured size. [anchorX]/[align] + * together fully describe where the text draws -- see [LabelSide.anchorX]/[LabelSide.paintAlign]. */ +private data class LabelBox(val key: String, val text: String, val anchorX: Float, val align: Paint.Align, val initialY: Float, val width: Float) /** * Basic collision handling for the (usually few) simultaneously-expanded stop labels: placed @@ -810,8 +986,8 @@ private fun resolveLabelPositions(labels: List): Map { val placed = mutableListOf() for (label in labels.sortedBy { it.initialY }) { var y = label.initialY - val left = label.centerX - label.width / 2f - val right = label.centerX + label.width / 2f + val left = if (label.align == Paint.Align.LEFT) label.anchorX else label.anchorX - label.width + val right = if (label.align == Paint.Align.LEFT) label.anchorX + label.width else label.anchorX var moved = true while (moved) { moved = false @@ -834,7 +1010,7 @@ private fun resolveLabelPositions(labels: List): Map { private fun drawBusMarker( canvas: android.graphics.Canvas, - emojiPaint: Paint, + vehicleIconBitmap: Bitmap, labelPaint: Paint, labelOutlinePaint: Paint, smallLabelPaint: Paint, @@ -842,17 +1018,36 @@ private fun drawBusMarker( bus: BusMarker, x: Float, y: Float, + canvasWidth: Float, ) { - canvas.drawText(bus.emoji, x, y, emojiPaint) - val routeText = bus.lineLabel?.let { "$it - ${bus.routeLabel}" } ?: bus.routeLabel - canvas.drawText(routeText, x, y + 30f, smallLabelOutlinePaint) - canvas.drawText(routeText, x, y + 30f, smallLabelPaint) - canvas.drawText(bus.directionLabel, x, y + 54f, smallLabelOutlinePaint) - canvas.drawText(bus.directionLabel, x, y + 54f, smallLabelPaint) - canvas.drawText(bus.etaDisplay(), x, y + 78f, labelOutlinePaint) - canvas.drawText(bus.etaDisplay(), x, y + 78f, labelPaint) - bus.statusLabel()?.let { - canvas.drawText(it, x, y + 102f, smallLabelOutlinePaint) - canvas.drawText(it, x, y + 102f, smallLabelPaint) + canvas.drawBitmap(vehicleIconBitmap, x - VEHICLE_MARKER_ICON_PX / 2f, y - VEHICLE_MARKER_ICON_PX / 2f, null) + // Anchored to the right of the icon's own base, the same offset-from-icon approach used for + // tapped stop labels (see the expandedLabels block above) -- flips to the icon's left instead + // when the label would otherwise clip off the canvas's right edge (see resolveLabelSide). All + // three lines share one side decision (based on the widest of them) so they stay aligned with + // each other rather than each flipping independently. + val tripText = bus.tripDescription() + val etaText = bus.etaDisplay() + val statusText = bus.statusLabel() + val widestTextPx = maxOf( + smallLabelPaint.measureText(tripText), + labelPaint.measureText(etaText), + statusText?.let { smallLabelPaint.measureText(it) } ?: 0f, + ) + val side = resolveLabelSide(x, VEHICLE_MARKER_ICON_PX.toFloat(), widestTextPx, canvasWidth) + val anchorX = side.anchorX(x, VEHICLE_MARKER_ICON_PX.toFloat()) + val align = side.paintAlign() + labelPaint.textAlign = align + labelOutlinePaint.textAlign = align + smallLabelPaint.textAlign = align + smallLabelOutlinePaint.textAlign = align + val baseY = y + VEHICLE_MARKER_ICON_PX / 2f + canvas.drawText(tripText, anchorX, baseY, smallLabelOutlinePaint) + canvas.drawText(tripText, anchorX, baseY, smallLabelPaint) + canvas.drawText(etaText, anchorX, baseY + 24f, labelOutlinePaint) + canvas.drawText(etaText, anchorX, baseY + 24f, labelPaint) + statusText?.let { + canvas.drawText(it, anchorX, baseY + 48f, smallLabelOutlinePaint) + canvas.drawText(it, anchorX, baseY + 48f, smallLabelPaint) } } diff --git a/tool/src/main/kotlin/com/thelightphone/transit/SettingsScreen.kt b/tool/src/main/kotlin/com/thelightphone/transit/SettingsScreen.kt index b154d57a..2f13e9a6 100644 --- a/tool/src/main/kotlin/com/thelightphone/transit/SettingsScreen.kt +++ b/tool/src/main/kotlin/com/thelightphone/transit/SettingsScreen.kt @@ -45,6 +45,10 @@ class SettingsViewModel( get() = _darkMapEnabled private val _darkMapEnabled = MutableStateFlow(false) + val tapHoldArrivalsEnabled: StateFlow + get() = _tapHoldArrivalsEnabled + private val _tapHoldArrivalsEnabled = MutableStateFlow(false) + init { viewModelScope.launch { agencyPreferences.defaultAgencyFlow.collect { _defaultAgency.value = it } @@ -52,6 +56,9 @@ class SettingsViewModel( viewModelScope.launch { mapPreferences.darkMapEnabledFlow.collect { _darkMapEnabled.value = it } } + viewModelScope.launch { + mapPreferences.tapHoldArrivalsEnabledFlow.collect { _tapHoldArrivalsEnabled.value = it } + } } /** Tapping the already-selected default clears it back to "no default", so there's a way to @@ -65,6 +72,10 @@ class SettingsViewModel( fun setDarkMapEnabled(enabled: Boolean) { viewModelScope.launch { mapPreferences.setDarkMapEnabled(enabled) } } + + fun setTapHoldArrivalsEnabled(enabled: Boolean) { + viewModelScope.launch { mapPreferences.setTapHoldArrivalsEnabled(enabled) } + } } class SettingsScreen( @@ -81,6 +92,7 @@ class SettingsScreen( override fun Content() { val defaultAgency by viewModel.defaultAgency.collectAsState() val darkMapEnabled by viewModel.darkMapEnabled.collectAsState() + val tapHoldArrivalsEnabled by viewModel.tapHoldArrivalsEnabled.collectAsState() val themeColors by LightThemeController.colors.collectAsState() LightTheme(colors = themeColors) { @@ -153,6 +165,39 @@ class SettingsScreen( .padding(vertical = 12.dp), ) } + + LightText( + text = "Tap and hold a stop", + variant = LightTextVariant.Copy, + lighten = true, + modifier = Modifier.padding(top = 24.dp, bottom = 8.dp), + ) + LightText( + text = "When on, tap and hold any stop on the Map screen to jump straight to its upcoming arrivals.", + variant = LightTextVariant.Detail, + lighten = true, + modifier = Modifier.padding(bottom = 16.dp), + ) + Column { + LightText( + text = if (tapHoldArrivalsEnabled) "On (current)" else "On", + variant = LightTextVariant.Copy, + lighten = !tapHoldArrivalsEnabled, + modifier = Modifier + .fillMaxWidth() + .lightClickable { viewModel.setTapHoldArrivalsEnabled(true) } + .padding(vertical = 12.dp), + ) + LightText( + text = if (!tapHoldArrivalsEnabled) "Off (current)" else "Off", + variant = LightTextVariant.Copy, + lighten = tapHoldArrivalsEnabled, + modifier = Modifier + .fillMaxWidth() + .lightClickable { viewModel.setTapHoldArrivalsEnabled(false) } + .padding(vertical = 12.dp), + ) + } } } } diff --git a/tool/src/main/kotlin/com/thelightphone/transit/TripDetailScreen.kt b/tool/src/main/kotlin/com/thelightphone/transit/TripDetailScreen.kt index 2a8e9d95..e3e1e303 100644 --- a/tool/src/main/kotlin/com/thelightphone/transit/TripDetailScreen.kt +++ b/tool/src/main/kotlin/com/thelightphone/transit/TripDetailScreen.kt @@ -16,12 +16,15 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.lifecycle.viewModelScope +import com.thelightphone.transit.gtfs.ArrivalStatus import com.thelightphone.transit.gtfs.GtfsAgency import com.thelightphone.transit.gtfs.GtfsRealtimeClient import com.thelightphone.transit.gtfs.GtfsRepository import com.thelightphone.transit.gtfs.LineType import com.thelightphone.transit.gtfs.TripStopRow +import com.thelightphone.transit.gtfs.computeArrivalEta import com.thelightphone.transit.gtfs.formatGtfsTime +import com.thelightphone.transit.gtfs.todayForGtfs import com.thelightphone.sdk.LightScreen import com.thelightphone.sdk.LightViewModel import com.thelightphone.sdk.SealedLightActivity @@ -58,16 +61,28 @@ sealed class TripDetailState { * current_stop_sequence means "at, arriving at, or en route to" that stop regardless of * current_status -- so matching it against a row's stopSequence alone is enough to place the * emoji at the stop the vehicle is closest to, including while it's between stops. + * + * [liveStatus] is the same On Time/Late/Early comparison the other live screens show, computed + * against the matched stop's own scheduled time -- null whenever there's no TripUpdates + * prediction for it yet (a live position with no matching prediction is a real, normal case, + * not an error). */ data class Loaded( val stops: List, val liveEmoji: String?, val liveAtStopSequence: Int?, + val liveStatus: ArrivalStatus?, ) : TripDetailState() data class Error(val message: String) : TripDetailState() } +fun ArrivalStatus.label(): String = when (this) { + ArrivalStatus.OnTime -> "On time" + is ArrivalStatus.Late -> "Late by ${(seconds / 60).coerceAtLeast(1)}m" + is ArrivalStatus.Early -> "Early by ${(seconds / 60).coerceAtLeast(1)}m" +} + class TripDetailViewModel( dbFile: File, private val tripId: String, @@ -93,9 +108,11 @@ class TripDetailViewModel( val emoji = repository.getRouteTypeForTrip(tripId)?.let { LineType.forGtfsRouteType(it)?.emoji } ?: "🚌" val vehiclePositionsUrl = agency?.realtimeVehiclePositionsUrl if (stops.isEmpty() || vehiclePositionsUrl == null) { - _state.value = TripDetailState.Loaded(stops, liveEmoji = null, liveAtStopSequence = null) + _state.value = TripDetailState.Loaded(stops, liveEmoji = null, liveAtStopSequence = null, liveStatus = null) return@launch } + val tripUpdatesUrl = agency.realtimeTripUpdatesUrl + val today = todayForGtfs() while (isActive) { val liveStopSequence = try { @@ -105,10 +122,29 @@ class TripDetailViewModel( Log.e("TripDetailScreen", "VehiclePositions fetch failed for trip $tripId", e) null } + + // Only worth fetching TripUpdates at all once we actually have a stop to check + // a prediction against. + val matchedStop = liveStopSequence?.let { seq -> stops.find { it.stopSequence == seq } } + val liveStatus = matchedStop?.let { stop -> + val scheduledTime = stop.departureTime ?: stop.arrivalTime ?: return@let null + val rtStopUpdate = tripUpdatesUrl?.let { url -> + try { + GtfsRealtimeClient.fetchFeed(url).tripUpdatesByTripId[tripId] + ?.updateFor(stop.stopId, stop.stopSequence) + } catch (e: Exception) { + Log.e("TripDetailScreen", "TripUpdates fetch failed for trip $tripId", e) + null + } + } + computeArrivalEta(scheduledTime, today, rtStopUpdate)?.status + } + _state.value = TripDetailState.Loaded( stops, liveEmoji = emoji, liveAtStopSequence = liveStopSequence, + liveStatus = liveStatus, ) delay(LIVE_VEHICLE_POLL_INTERVAL_MS) } @@ -190,7 +226,8 @@ class TripDetailScreen( } else { LazyColumn(modifier = Modifier.weight(1f)) { items(s.stops) { stop -> - Row( + val isLive = s.liveEmoji != null && stop.stopSequence == s.liveAtStopSequence + Column( modifier = Modifier .fillMaxWidth() .lightClickable { @@ -209,27 +246,38 @@ class TripDetailScreen( } } .padding(vertical = 8.dp), - verticalAlignment = Alignment.Top, ) { - if (s.liveEmoji != null && stop.stopSequence == s.liveAtStopSequence) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.Top, + ) { + if (isLive) { + LightText( + text = s.liveEmoji, + variant = LightTextVariant.Copy, + modifier = Modifier.padding(end = 8.dp), + ) + } + LightText( + text = stop.stopName ?: "Unknown stop", + variant = LightTextVariant.Copy, + modifier = Modifier + .weight(1f) + .padding(end = 16.dp), + ) LightText( - text = s.liveEmoji, + text = formatGtfsTime(stop.arrivalTime ?: stop.departureTime), variant = LightTextVariant.Copy, - modifier = Modifier.padding(end = 8.dp), + lighten = true, + ) + } + if (isLive) { + LightText( + text = s.liveStatus?.label() ?: "Live", + variant = LightTextVariant.Detail, + lighten = true, ) } - LightText( - text = stop.stopName ?: "Unknown stop", - variant = LightTextVariant.Copy, - modifier = Modifier - .weight(1f) - .padding(end = 16.dp), - ) - LightText( - text = formatGtfsTime(stop.arrivalTime ?: stop.departureTime), - variant = LightTextVariant.Copy, - lighten = true, - ) } } } diff --git a/tool/src/main/kotlin/com/thelightphone/transit/UpcomingArrivalsScreen.kt b/tool/src/main/kotlin/com/thelightphone/transit/UpcomingArrivalsScreen.kt index 4fd2bec4..276d967f 100644 --- a/tool/src/main/kotlin/com/thelightphone/transit/UpcomingArrivalsScreen.kt +++ b/tool/src/main/kotlin/com/thelightphone/transit/UpcomingArrivalsScreen.kt @@ -31,6 +31,7 @@ import com.thelightphone.sdk.LightViewModel import com.thelightphone.sdk.SealedLightActivity import com.thelightphone.sdk.SimpleLightScreen import com.thelightphone.sdk.ui.LightBarButton +import com.thelightphone.sdk.ui.LightIcon import com.thelightphone.sdk.ui.LightIcons import com.thelightphone.sdk.ui.LightText import com.thelightphone.sdk.ui.LightTextVariant @@ -54,7 +55,9 @@ data class ArrivalRow( val stopSequence: Int, val routeLabel: String, val directionLabel: String, - val lineLabel: String?, + /** Mode icon shown in place of the old "Bus -"/"Subway -"/"Commuter Rail -" text prefix -- see + * [toVehicleIcon] (MapScreen.kt), the single source of truth for this mapping. */ + val lineType: LineType?, val etaEpochSeconds: Long, val isLive: Boolean, val status: ArrivalStatus?, @@ -126,7 +129,7 @@ class UpcomingArrivalsViewModel( stopSequence = arrival.stopSequence, routeLabel = arrival.route.displayName, directionLabel = arrival.direction.displayLabel(), - lineLabel = LineType.forGtfsRouteType(arrival.route.routeType)?.label, + lineType = LineType.forGtfsRouteType(arrival.route.routeType), etaEpochSeconds = eta.etaEpochSeconds, isLive = eta.isLive, status = eta.status, @@ -183,14 +186,13 @@ class UpcomingArrivalsScreen( ) Column(modifier = Modifier.weight(1f).padding(32.dp)) { LightText( - text = stopLabel, + text = "Selected stop", variant = LightTextVariant.Detail, lighten = true, modifier = Modifier.padding(bottom = 8.dp), ) - LightText( - text = "Map", - variant = LightTextVariant.Copy, + Row( + verticalAlignment = Alignment.CenterVertically, modifier = Modifier .lightClickable { navigateTo(screenFactory = { activity -> @@ -198,7 +200,10 @@ class UpcomingArrivalsScreen( }) } .padding(bottom = 16.dp), - ) + ) { + LightIcon(icon = LightIcons.MAP, size = 1.4f, modifier = Modifier.padding(end = 8.dp)) + LightText(text = stopLabel, variant = LightTextVariant.Copy) + } when (val s = state) { is UpcomingArrivalsState.Loading -> LightText( @@ -260,9 +265,13 @@ class UpcomingArrivalsScreen( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.Top, ) { + LightIcon( + icon = arrival.lineType.toVehicleIcon(), + size = 1.2f, + modifier = Modifier.padding(end = 8.dp), + ) LightText( - text = arrival.lineLabel?.let { "$it - ${arrival.routeLabel} - ${arrival.directionLabel}" } - ?: "${arrival.routeLabel} - ${arrival.directionLabel}", + text = "${arrival.routeLabel} - ${arrival.directionLabel}", variant = LightTextVariant.Copy, modifier = Modifier .weight(1f) diff --git a/tool/src/main/kotlin/com/thelightphone/transit/gtfs/MapPreferences.kt b/tool/src/main/kotlin/com/thelightphone/transit/gtfs/MapPreferences.kt index 98b36c09..eb78ad6c 100644 --- a/tool/src/main/kotlin/com/thelightphone/transit/gtfs/MapPreferences.kt +++ b/tool/src/main/kotlin/com/thelightphone/transit/gtfs/MapPreferences.kt @@ -8,6 +8,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map private val darkMapKey = booleanPreferencesKey("DARK_MAP_ENABLED") +private val tapHoldArrivalsKey = booleanPreferencesKey("MAP_TAP_HOLD_ARRIVALS_ENABLED") /** * The user's Map screen tile style (Settings screen), persisted the same way [AgencyPreferences] @@ -21,4 +22,13 @@ class MapPreferences(private val dataStore: DataStore) { suspend fun setDarkMapEnabled(enabled: Boolean) { dataStore.edit { prefs -> prefs[darkMapKey] = enabled } } + + /** Off by default -- tap-and-hold on a stop marker is an extra gesture on top of the Map + * screen's existing tap-to-expand behavior, so it's opt-in rather than silently changing what + * a long press on the map already does for existing users. */ + val tapHoldArrivalsEnabledFlow: Flow = dataStore.data.map { prefs -> prefs[tapHoldArrivalsKey] ?: false } + + suspend fun setTapHoldArrivalsEnabled(enabled: Boolean) { + dataStore.edit { prefs -> prefs[tapHoldArrivalsKey] = enabled } + } } diff --git a/tool/src/main/kotlin/com/thelightphone/transit/gtfs/MapTiles.kt b/tool/src/main/kotlin/com/thelightphone/transit/gtfs/MapTiles.kt index 2eca5e30..122a8316 100644 --- a/tool/src/main/kotlin/com/thelightphone/transit/gtfs/MapTiles.kt +++ b/tool/src/main/kotlin/com/thelightphone/transit/gtfs/MapTiles.kt @@ -29,6 +29,7 @@ import kotlin.math.tan */ private const val TILE_SIZE = 256.0 private const val EARTH_CIRCUMFERENCE_METERS = 40_075_016.686 +private const val METERS_PER_MILE = 1609.344 /** Fractional (x, y) tile coordinates for (lat, lon) at the given integer zoom. */ fun lonLatToTileFraction(lat: Double, lon: Double, zoom: Int): Pair { @@ -55,7 +56,13 @@ fun metersPerPixel(lat: Double, zoom: Int): Double { * naturally yields a low one — no per-agency density special-casing needed. * * Falls back to [fallbackZoom] when [points] is empty or every point is ~coincident with the - * center, since the fit-to-bounds formula is undefined for a zero-size box. + * center, since the fit-to-bounds formula is undefined for a zero-size box -- though in practice + * that branch is now rarely reached once [minBoundingBoxMiles] is positive, since the floor below + * already keeps the box away from zero-size on its own. + * + * [minBoundingBoxMiles] treats the bounding box as at least this wide in each dimension before + * computing zoom, so a real-world-tiny cluster (e.g. two platforms 30ft apart) doesn't compute an + * absurdly tight zoom just because the *points* happen to be that close together. */ fun fitBoundsZoom( centerLat: Double, @@ -65,6 +72,7 @@ fun fitBoundsZoom( minZoom: Int, maxZoom: Int, fallbackZoom: Int, + minBoundingBoxMiles: Double = 0.0, ): Int { // Computed at zoom 0 (n = 1) as a zoom-independent baseline -- offsets at any real zoom z are // this baseline scaled by 2^z, since both x and the Mercator-projected y are linear in n. @@ -77,6 +85,16 @@ fun fitBoundsZoom( maxDy = max(maxDy, abs(fracY - centerFracY)) } + // 1 unit of this function's zoom-0 tile-fraction is EARTH_CIRCUMFERENCE_METERS*cos(lat) real + // meters at ANY zoom (the zoom cancels out of that ratio, the same relationship metersPerPixel + // uses) -- so a real-world minimum can be converted into this same unit and floored in directly. + if (minBoundingBoxMiles > 0.0) { + val minHalfExtentMeters = (minBoundingBoxMiles * METERS_PER_MILE) / 2.0 + val minHalfExtentFraction = minHalfExtentMeters / (EARTH_CIRCUMFERENCE_METERS * cos(Math.toRadians(centerLat))) + maxDx = max(maxDx, minHalfExtentFraction) + maxDy = max(maxDy, minHalfExtentFraction) + } + val epsilon = 1e-9 if (maxDx < epsilon && maxDy < epsilon) { return fallbackZoom.coerceIn(minZoom, maxZoom) From 71d10b0d553fd5b82d35ff696463fe363b02a312 Mon Sep 17 00:00:00 2001 From: Christian Date: Sat, 1 Aug 2026 03:39:20 -0400 Subject: [PATCH 13/14] Switch PR Check workflow from pull_request_target to pull_request pull_request_target grants privileged repo secrets/token access to workflows triggered by fork PRs -- a guardrail GitHub blocks by default for exactly that reason (the "pwn request" risk). This repo is solo-maintained and isn't accepting outside fork contributions, so that privileged-access trigger was never actually needed here; the standard pull_request event checks out and runs against PR code just as well without it. Co-Authored-By: Claude Sonnet 5 --- .github/workflows/pr-check.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml index 9c8f1c26..847e565b 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -1,7 +1,7 @@ name: PR Check on: - pull_request_target: + pull_request: branches: [main] permissions: From 5444312d2ee1cbb07a3c6475f752b4528dbb53a2 Mon Sep 17 00:00:00 2001 From: Christian Date: Sat, 1 Aug 2026 20:38:34 -0400 Subject: [PATCH 14/14] Add trip boarding/tracking, app-wide navigation, and Settings/About overhaul - Board/alight a trip from Trip Detail; HomeScreen shows live route, ETA, and stops remaining, plus an optional progress bar, while boarded - "You've reached your stop" detection now also runs from HomeScreen, not just Trip Detail, via a shared checkReachedAlightStop - Every screen gets a "Current Trip" header shortcut and a "Home" footer button that pops back to HomeScreen however many screens deep - Warn (delete-icon indicator) when boarding a different trip would end tracking of the one already in progress - Fix Map screen vehicle clutter: a station's platforms now share one display allotment instead of each platform getting its own - Move "Track Tapped Stops" into Settings; unify every Settings toggle on the SDK's toggle-state icons instead of a two-row On/Off list - Station-aware Connections screen (unions all platforms, not just one) - New About screen: full icon/mode legend, boarding behavior, attribution - Fix two crash-prone unguarded DB queries (UpcomingArrivals, StopConnections) surfaced by rapid back-navigation - Drop dead ScheduleLocationScreen; update both READMEs to match Co-Authored-By: Claude Sonnet 5 --- README.md | 14 +- tool/README.md | 24 +- .../thelightphone/transit/BackToHomeFooter.kt | 48 ++ .../transit/CurrentTripTopBarButton.kt | 43 ++ .../transit/DepartureListScreen.kt | 4 + .../transit/DirectionSelectionScreen.kt | 6 +- .../transit/FirstStopSelectionScreen.kt | 57 +- .../com/thelightphone/transit/HomeScreen.kt | 655 ++++++++++++++++-- .../com/thelightphone/transit/InfoScreen.kt | 251 +++++++ .../transit/LineTypeSelectionScreen.kt | 21 +- .../com/thelightphone/transit/MapScreen.kt | 417 ++++++++--- .../thelightphone/transit/MapStationScreen.kt | 271 ++++++++ .../transit/NearbyStopsScreen.kt | 102 ++- .../transit/ReachedStopFeature.kt | 77 ++ .../transit/RouteSelectionScreen.kt | 4 + .../transit/ScheduleLocationScreen.kt | 239 ------- .../thelightphone/transit/SettingsScreen.kt | 149 +++- .../transit/StationListScreen.kt | 170 +++++ .../transit/StopConnectionsScreen.kt | 55 +- .../thelightphone/transit/TripDetailScreen.kt | 267 ++++++- .../transit/UpcomingArrivalsScreen.kt | 104 ++- .../transit/gtfs/BoardedTripPreferences.kt | 109 +++ .../transit/gtfs/GtfsRepository.kt | 235 ++++++- .../transit/gtfs/MapPreferences.kt | 22 + .../transit/gtfs/GtfsRepositoryDedupTest.kt | 81 ++- 25 files changed, 2817 insertions(+), 608 deletions(-) create mode 100644 tool/src/main/kotlin/com/thelightphone/transit/BackToHomeFooter.kt create mode 100644 tool/src/main/kotlin/com/thelightphone/transit/CurrentTripTopBarButton.kt create mode 100644 tool/src/main/kotlin/com/thelightphone/transit/InfoScreen.kt create mode 100644 tool/src/main/kotlin/com/thelightphone/transit/MapStationScreen.kt create mode 100644 tool/src/main/kotlin/com/thelightphone/transit/ReachedStopFeature.kt delete mode 100644 tool/src/main/kotlin/com/thelightphone/transit/ScheduleLocationScreen.kt create mode 100644 tool/src/main/kotlin/com/thelightphone/transit/StationListScreen.kt create mode 100644 tool/src/main/kotlin/com/thelightphone/transit/gtfs/BoardedTripPreferences.kt diff --git a/README.md b/README.md index c4f8ae76..5086b7f7 100644 --- a/README.md +++ b/README.md @@ -7,12 +7,17 @@ Right now Pico Transit knows its way around **MBTA** and **RIPTA**, with more ag ## 🗺️ What can it do? - 🏠 **Pick your agency** — MBTA or RIPTA — and Pico Transit downloads their schedule right onto your phone. -- ⚙️ **Settings** — set a default agency once and Pico Transit skips the picker on every future launch, only re-downloading the schedule when the agency's feed has actually changed. +- ⚙️ **Settings** — a default agency to skip the picker, light/dark map tiles, and on/off toggles (tap-and-hold a stop to jump to its arrivals, double-tap a station to zoom into its platforms, track tapped-open stops' own vehicles on the map, and the home screen's trip progress bar). - 📅 **Explore Schedules** — browse by Subway 🚇, Commuter Rail 🚆, or Bus 🚌, pick a route, a direction, and a stop, and see every departure today. -- 🔗 **Connections** — tap any stop along a trip to see what else comes through there next. Great for planning a transfer on the fly. -- 📍 **Leave Now** — type where you are (or let Pico Transit take a quick IP-based guess 🛰️) and get the 20 closest stops, nearest first. +- 🔗 **Connections** — tap any stop along a trip to see what else comes through there next, across every platform of a station, not just the one your trip happened to use. Great for planning a transfer on the fly. +- 📍 **Leave Now** — type where you are (or let Pico Transit take a quick IP-based guess 🛰️) and get the closest stops, nearest first — it remembers your last search, so ducking into a stop's arrivals and back doesn't make you search all over again. - ⏱️ **Live ETAs** — real-time predictions with On Time / Late / Early badges, whenever the agency's live feed is playing along nicely. -- 🗺️ **Map** — your stop, pinned on a live map, with nearby stops you can tap to reveal their names. Live vehicles show up right where they actually are, with an emoji for their mode (🚇 subway/light rail, 🚆 commuter rail, 🚌 bus) — flip on "Nearby Vehicles" to also see what's inbound to those other stops, not just yours. +- 🗺️ **Map** — your stop, pinned on a live map, with nearby stops you can tap to reveal their names. Live vehicles show up right where they actually are, with a matching icon for their mode (subway/light rail, commuter rail, bus). +- 🚉 **Stations** — browse every real multi-platform station an agency has, and open a zoomed-in map of just that station's own platforms. +- ▶️ **Board a trip** — from any Trip Detail screen, tap Play to make it your current trip. Tap a stop along the way to mark where you're getting off — reach it, and Pico Transit throws a little "You've reached your stop! 🎉" celebration and jumps you to that stop's upcoming arrivals, whether you were looking at the trip or just sitting on the home screen. +- 🚦 **Home screen trip status** — while a trip is boarded, the home screen swaps its agency picker for your route, live ETA, and stops remaining, plus an optional progress bar with a little vehicle marker crawling from your boarding stop toward your alight stop. +- ↩️ **Jump back anytime** — a Play icon shows up in the corner of every screen while a trip's boarded, one tap from wherever you are back to its live tracking; a plain circle in the footer does the same for the home screen itself. +- ℹ️ **About** — a full legend of every icon and mode Pico Transit uses, reachable right from the home screen. ## 🛠️ Building & running it @@ -47,6 +52,7 @@ That's it — happy transit-ing! 🚏🚌🚆 - **RIPTA's live feeds are HTTP-only** (no HTTPS), which Android blocks by default. There's a small, clearly-labeled `:netconfig` module that grants just that one narrow exception — see its own `build.gradle.kts` for exactly what it does and how to remove it if you'd rather stay HTTPS-only everywhere. - **No device GPS is used anywhere** — the SDK doesn't expose it to tools yet. Nearby-stop and location search are powered by Nominatim (OpenStreetMap) and IP-based geolocation instead. Be kind to their free APIs! 🙏 - **Stations are deduplicated using GTFS's `parent_station`** — a big station with several platforms (subway entrances, commuter rail tracks, etc.) shows up as one marker/entry, not one per platform, while still resolving to the right platform's `stop_id` under the hood for schedule lookups. +- **Boarding a trip is a saved reference, not a background tracker** — Pico Transit never polls a live feed while the app itself isn't open. "You've reached your stop" detection only runs while Trip Detail or the home screen is actually visible and polling, the same way every other bit of live tracking in the app works. ## 📄 License diff --git a/tool/README.md b/tool/README.md index 2bdc911f..58b7e213 100644 --- a/tool/README.md +++ b/tool/README.md @@ -7,12 +7,27 @@ Right now Pico Transit knows its way around **MBTA** and **RIPTA**, with more ag ## 🗺️ What can it do? - 🏠 **Pick your agency** — MBTA or RIPTA — and Pico Transit downloads their schedule right onto your phone. -- ⚙️ **Settings** — set a default agency once and Pico Transit skips the picker on every future launch, only re-downloading the schedule when the agency's feed has actually changed. +- ⚙️ **Settings** — a default agency to skip the picker, light/dark map tiles, and on/off toggles (tap-and-hold a stop to jump to its arrivals, double-tap a station to zoom into its platforms, track tapped-open stops' own vehicles on the map, and the home screen's trip progress bar). - 📅 **Explore Schedules** — browse by Subway 🚇, Commuter Rail 🚆, or Bus 🚌, pick a route, a direction, and a stop, and see every departure today. -- 🔗 **Connections** — tap any stop along a trip to see what else comes through there next. Great for planning a transfer on the fly. -- 📍 **Leave Now** — type where you are (or let Pico Transit take a quick IP-based guess 🛰️) and get the 20 closest stops, nearest first. +- 🔗 **Connections** — tap any stop along a trip to see what else comes through there next, across every platform of a station, not just the one your trip happened to use. Great for planning a transfer on the fly. +- 📍 **Leave Now** — type where you are (or let Pico Transit take a quick IP-based guess 🛰️) and get the closest stops, nearest first — it remembers your last search, so ducking into a stop's arrivals and back doesn't make you search all over again. - ⏱️ **Live ETAs** — real-time predictions with On Time / Late / Early badges, whenever the agency's live feed is playing along nicely. -- 🗺️ **Map** — your stop, pinned on a live map, with nearby stops you can tap to reveal their names. Live vehicles show up right where they actually are, with an emoji for their mode (🚇 subway/light rail, 🚆 commuter rail, 🚌 bus) — flip on "Nearby Vehicles" to also see what's inbound to those other stops, not just yours. +- 🗺️ **Map** — your stop, pinned on a live map, with nearby stops you can tap to reveal their names. Live vehicles show up right where they actually are, with a matching icon for their mode (subway/light rail, commuter rail, bus). +- 🚉 **Stations** — browse every real multi-platform station an agency has, and open a zoomed-in map of just that station's own platforms. +- ▶️ **Board a trip** — from any Trip Detail screen, tap Play to make it your current trip. Tap a stop along the way to mark where you're getting off — reach it, and Pico Transit throws a little "You've reached your stop! 🎉" celebration and jumps you to that stop's upcoming arrivals, whether you were looking at the trip or just sitting on the home screen. +- 🚦 **Home screen trip status** — while a trip is boarded, the home screen swaps its agency picker for your route, live ETA, and stops remaining, plus an optional progress bar with a little vehicle marker crawling from your boarding stop toward your alight stop. +- ↩️ **Jump back anytime** — a Play icon shows up in the corner of every screen while a trip's boarded, one tap from wherever you are back to its live tracking; a plain circle in the footer does the same for the home screen itself. +- ℹ️ **About** — a full legend of every icon and mode Pico Transit uses, reachable right from the home screen. + +## 🧭 Modes + +All three modes help you get to the same place: trip details, ready to board. Once you've found your trip, press play to board and tap the stop where you'll get off. You can return to the Home screen anytime for a clean, simple view of your progress and ETA. + +Once boarded, tap the play button (top-right corner) anytime to jump back to your active trip's details — even while you're browsing for your next boarding. To stop tracking, return to trip details via the play indicator, then tap the stop button. If you open a different trip while already tracking one and tap play there, a small X appears next to the play indicator — that's your signal you're about to cancel your current trip and switch to following this new one instead. + +- 📅 **Schedule** — Start here to plan ahead. Pick a route, direction, stop, and departure time to lock in your trip. +- 📍 **Explore** — Start by entering a known location — search by address, landmark, or commercial place. Nominatim translates that into a location and finds the closest stops nearby. Tap a stop to see upcoming arrivals and ETAs. Tap any arrival to view trip details. +- 🚉 **Station** — For agencies with transfer stations, start by selecting your station. Tap any platform or gate on the map to see its name. Tap and hold a platform or gate to see all upcoming arrivals there, or tap and hold the station name above the map to see arrivals for the whole station. Select a trip to view its details. ## 🛠️ Building & running it @@ -47,6 +62,7 @@ That's it — happy transit-ing! 🚏🚌🚆 - **RIPTA's live feeds are HTTP-only** (no HTTPS), which Android blocks by default. There's a small, clearly-labeled `:netconfig` module that grants just that one narrow exception — see its own `build.gradle.kts` for exactly what it does and how to remove it if you'd rather stay HTTPS-only everywhere. - **No device GPS is used anywhere** — the SDK doesn't expose it to tools yet. Nearby-stop and location search are powered by Nominatim (OpenStreetMap) and IP-based geolocation instead. Be kind to their free APIs! 🙏 - **Stations are deduplicated using GTFS's `parent_station`** — a big station with several platforms (subway entrances, commuter rail tracks, etc.) shows up as one marker/entry, not one per platform, while still resolving to the right platform's `stop_id` under the hood for schedule lookups. +- **Boarding a trip is a saved reference, not a background tracker** — Pico Transit never polls a live feed while the app itself isn't open. "You've reached your stop" detection only runs while Trip Detail or the home screen is actually visible and polling, the same way every other bit of live tracking in the app works. ## 📄 License diff --git a/tool/src/main/kotlin/com/thelightphone/transit/BackToHomeFooter.kt b/tool/src/main/kotlin/com/thelightphone/transit/BackToHomeFooter.kt new file mode 100644 index 00000000..65970ee0 --- /dev/null +++ b/tool/src/main/kotlin/com/thelightphone/transit/BackToHomeFooter.kt @@ -0,0 +1,48 @@ +package com.thelightphone.transit + +import androidx.compose.runtime.Composable +import com.thelightphone.sdk.ui.LightBarButton +import com.thelightphone.sdk.ui.LightBottomBar +import com.thelightphone.sdk.ui.LightIcons +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +// A real, if small, pause between each pop -- not for visual pacing but so a screen just popped +// mid-query (its onScreenShow coroutine still fetching when goBack() closes its repository out +// from under it) gets a real chance to observe cancellation before the NEXT pop even starts. Popping +// every screen in one tight, zero-delay loop is what originally surfaced that race (a screen's +// unguarded DB call outside its own try/catch threw once its connection pool was closed mid-query +// and crashed the whole app) -- this doesn't eliminate an unguarded call elsewhere still being +// possible, only makes the race far less likely to actually land within one loop's lifetime. +private const val POP_STEP_DELAY_MS = 80L + +/** + * Every non-Home screen's own footer -- a single circular button that jumps straight back to + * HomeScreen, however many screens deep the rider currently is (HomeScreen itself keeps its own + * full footer: settings/info icons + the daily message, unrelated to this). [onGoBackOnce] is each + * screen's own `goBack()` -- can't be called from here directly since it's a `SimpleLightScreen` + * instance method, not something a free-floating composable has access to; this owns the "keep + * popping until Home shows up" loop (with a small delay between each pop, see + * [POP_STEP_DELAY_MS]) so every call site doesn't have to repeat it. See [HomeVisibility]'s own doc + * comment for why the loop's stopping condition is safe without any "pop to root" primitive in the + * SDK. + */ +@Composable +fun BackToHomeFooter(onGoBackOnce: () -> Unit) { + LightBottomBar( + items = listOf( + LightBarButton.LightIcon( + icon = LightIcons.CIRCLE, + contentDescription = "Home", + onClick = { + HomeVisibility.scope.launch { + while (!HomeVisibility.isVisible.value) { + onGoBackOnce() + delay(POP_STEP_DELAY_MS) + } + } + }, + ), + ), + ) +} diff --git a/tool/src/main/kotlin/com/thelightphone/transit/CurrentTripTopBarButton.kt b/tool/src/main/kotlin/com/thelightphone/transit/CurrentTripTopBarButton.kt new file mode 100644 index 00000000..94ff0ee4 --- /dev/null +++ b/tool/src/main/kotlin/com/thelightphone/transit/CurrentTripTopBarButton.kt @@ -0,0 +1,43 @@ +package com.thelightphone.transit + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import com.thelightphone.transit.gtfs.BoardedTripPreferences +import com.thelightphone.transit.gtfs.gtfsDbFile +import com.thelightphone.sdk.ui.LightBarButton +import com.thelightphone.sdk.ui.LightIcons +import java.io.File + +/** + * The "Current Trip" entry point every screen's own [com.thelightphone.sdk.ui.LightTopBar] shows in + * its rightButton slot -- present whenever there's a boarded trip (see [BoardedTripPreferences]), + * gone otherwise, so it's always one tap away from wherever the rider happens to be in the app, not + * just HomeScreen's own dedicated (vehicle-icon + Play) entry. Called from within a screen's own + * Content() (needs [dataStore]/[filesDir] from its own `lightContext`, both protected there) rather + * than as a receiver/extension, since [com.thelightphone.sdk.SimpleLightScreen.navigateTo] can't be + * called from outside that class either -- [onOpenTripDetail] is the caller's own + * `navigateTo { TripDetailScreen(...) }` wrapper. + */ +@Composable +fun currentTripTopBarButton( + dataStore: DataStore, + filesDir: File, + onOpenTripDetail: (dbFile: File, tripId: String, fromStopSequence: Int, routeLabel: String, directionLabel: String) -> Unit, +): LightBarButton? { + val boardedTrip by remember(dataStore) { BoardedTripPreferences(dataStore).boardedTripFlow }.collectAsState(initial = null) + return boardedTrip?.let { trip -> + LightBarButton.LightIcon( + icon = LightIcons.PLAY, + contentDescription = "Current Trip", + onClick = { + onOpenTripDetail( + gtfsDbFile(filesDir, trip.agency), trip.tripId, trip.fromStopSequence, trip.routeLabel, trip.directionLabel, + ) + }, + ) + } +} diff --git a/tool/src/main/kotlin/com/thelightphone/transit/DepartureListScreen.kt b/tool/src/main/kotlin/com/thelightphone/transit/DepartureListScreen.kt index 5211a585..3b07209b 100644 --- a/tool/src/main/kotlin/com/thelightphone/transit/DepartureListScreen.kt +++ b/tool/src/main/kotlin/com/thelightphone/transit/DepartureListScreen.kt @@ -106,6 +106,9 @@ class DepartureListScreen( LightTopBar( leftButton = LightBarButton.LightIcon(icon = LightIcons.BACK, onClick = { goBack() }), center = LightTopBarCenter.Text("Departures"), + rightButton = currentTripTopBarButton(lightContext.dataStore, lightContext.filesDir) { dbFile, tripId, fromStopSequence, routeLabel, directionLabel -> + navigateTo(screenFactory = { activity -> TripDetailScreen(activity, dbFile, tripId, fromStopSequence, routeLabel, directionLabel) }) + }, ) Column(modifier = Modifier.weight(1f).padding(32.dp)) { LightText( @@ -161,6 +164,7 @@ class DepartureListScreen( } } } + BackToHomeFooter(onGoBackOnce = { goBack() }) } } } diff --git a/tool/src/main/kotlin/com/thelightphone/transit/DirectionSelectionScreen.kt b/tool/src/main/kotlin/com/thelightphone/transit/DirectionSelectionScreen.kt index a9cfe747..f347347d 100644 --- a/tool/src/main/kotlin/com/thelightphone/transit/DirectionSelectionScreen.kt +++ b/tool/src/main/kotlin/com/thelightphone/transit/DirectionSelectionScreen.kt @@ -104,6 +104,9 @@ class DirectionSelectionScreen( LightTopBar( leftButton = LightBarButton.LightIcon(icon = LightIcons.BACK, onClick = { goBack() }), center = LightTopBarCenter.Text("Choose Direction"), + rightButton = currentTripTopBarButton(lightContext.dataStore, lightContext.filesDir) { dbFile, tripId, fromStopSequence, routeLabel, directionLabel -> + navigateTo(screenFactory = { activity -> TripDetailScreen(activity, dbFile, tripId, fromStopSequence, routeLabel, directionLabel) }) + }, ) Column(modifier = Modifier.weight(1f).padding(32.dp)) { LightText( @@ -142,7 +145,7 @@ class DirectionSelectionScreen( .fillMaxWidth() .lightClickable { navigateTo(screenFactory = { activity -> - ScheduleLocationScreen( + FirstStopSelectionScreen( activity, dbFile, routeId, @@ -159,6 +162,7 @@ class DirectionSelectionScreen( } } } + BackToHomeFooter(onGoBackOnce = { goBack() }) } } } diff --git a/tool/src/main/kotlin/com/thelightphone/transit/FirstStopSelectionScreen.kt b/tool/src/main/kotlin/com/thelightphone/transit/FirstStopSelectionScreen.kt index 6e12a16f..7765a23d 100644 --- a/tool/src/main/kotlin/com/thelightphone/transit/FirstStopSelectionScreen.kt +++ b/tool/src/main/kotlin/com/thelightphone/transit/FirstStopSelectionScreen.kt @@ -3,7 +3,6 @@ package com.thelightphone.transit import android.util.Log import androidx.compose.foundation.background import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -12,13 +11,11 @@ import androidx.compose.foundation.lazy.items import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.lifecycle.viewModelScope import com.thelightphone.transit.gtfs.GtfsRepository import com.thelightphone.transit.gtfs.StopOption -import com.thelightphone.transit.gtfs.haversineMeters import com.thelightphone.sdk.LightScreen import com.thelightphone.sdk.LightViewModel import com.thelightphone.sdk.SealedLightActivity @@ -47,22 +44,10 @@ sealed class FirstStopSelectionState { fun StopOption.displayLabel(): String = stopName?.takeIf { it.isNotBlank() } ?: "Stop $stopId" -private const val FEET_PER_METER = 3.28084 - -/** Null if this stop has no ingested coordinates — displayed with no distance rather than "0 ft". */ -fun StopOption.distanceFeetLabel(userLat: Double, userLon: Double): String? { - val stopLat = lat ?: return null - val stopLon = lon ?: return null - val feet = haversineMeters(userLat, userLon, stopLat, stopLon) * FEET_PER_METER - return "%.0f ft".format(feet) -} - class FirstStopSelectionViewModel( dbFile: File, private val routeId: String, private val directionId: Int, - private val userLat: Double, - private val userLon: Double, ) : LightViewModel() { private val repository = GtfsRepository(dbFile) @@ -74,14 +59,10 @@ class FirstStopSelectionViewModel( super.onScreenShow(screen) viewModelScope.launch(Dispatchers.IO) { _state.value = try { - // Nearest-to-you first, not route/stop_sequence order -- the distance already shown - // per row (see distanceFeetLabel) should match what the list order implies. - val stops = repository.getStops(routeId, directionId).sortedBy { stop -> - val lat = stop.lat - val lon = stop.lon - if (lat != null && lon != null) haversineMeters(userLat, userLon, lat, lon) else Double.MAX_VALUE - } - FirstStopSelectionState.Loaded(stops) + // Already in physical route order (see GtfsRepository.getStops's own doc comment) -- + // no location needed to pick a stop along a route, same as choosing a route or + // direction isn't location-based either. + FirstStopSelectionState.Loaded(repository.getStops(routeId, directionId)) } catch (e: Exception) { Log.e("FirstStopSelectionScreen", "Failed to load first stops for route $routeId", e) FirstStopSelectionState.Error("Unable to load stops.") @@ -102,15 +83,13 @@ class FirstStopSelectionScreen( private val routeLabel: String, private val directionId: Int, private val directionLabel: String, - private val userLat: Double, - private val userLon: Double, ) : LightScreen(sealedActivity) { override val viewModelClass: Class get() = FirstStopSelectionViewModel::class.java override fun createViewModel(): FirstStopSelectionViewModel = - FirstStopSelectionViewModel(dbFile, routeId, directionId, userLat, userLon) + FirstStopSelectionViewModel(dbFile, routeId, directionId) @Composable override fun Content() { @@ -126,6 +105,9 @@ class FirstStopSelectionScreen( LightTopBar( leftButton = LightBarButton.LightIcon(icon = LightIcons.BACK, onClick = { goBack() }), center = LightTopBarCenter.Text("Choose Stop"), + rightButton = currentTripTopBarButton(lightContext.dataStore, lightContext.filesDir) { dbFile, tripId, fromStopSequence, routeLabel, directionLabel -> + navigateTo(screenFactory = { activity -> TripDetailScreen(activity, dbFile, tripId, fromStopSequence, routeLabel, directionLabel) }) + }, ) Column(modifier = Modifier.weight(1f).padding(32.dp)) { LightText( @@ -157,7 +139,9 @@ class FirstStopSelectionScreen( } else { LazyColumn(modifier = Modifier.weight(1f)) { items(s.stops) { stop -> - Row( + LightText( + text = stop.displayLabel(), + variant = LightTextVariant.Copy, modifier = Modifier .fillMaxWidth() .lightClickable { @@ -175,28 +159,13 @@ class FirstStopSelectionScreen( }) } .padding(vertical = 12.dp), - verticalAlignment = Alignment.Top, - ) { - LightText( - text = stop.displayLabel(), - variant = LightTextVariant.Copy, - modifier = Modifier - .weight(1f) - .padding(end = 16.dp), - ) - stop.distanceFeetLabel(userLat, userLon)?.let { - LightText( - text = it, - variant = LightTextVariant.Copy, - lighten = true, - ) - } - } + ) } } } } } + BackToHomeFooter(onGoBackOnce = { goBack() }) } } } diff --git a/tool/src/main/kotlin/com/thelightphone/transit/HomeScreen.kt b/tool/src/main/kotlin/com/thelightphone/transit/HomeScreen.kt index 8c92123b..8003ab4d 100644 --- a/tool/src/main/kotlin/com/thelightphone/transit/HomeScreen.kt +++ b/tool/src/main/kotlin/com/thelightphone/transit/HomeScreen.kt @@ -1,64 +1,321 @@ package com.thelightphone.transit import android.util.Log +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.rotate import androidx.compose.ui.unit.dp import androidx.lifecycle.viewModelScope import com.thelightphone.transit.gtfs.AgencyPreferences +import com.thelightphone.transit.gtfs.BoardedTrip +import com.thelightphone.transit.gtfs.BoardedTripPreferences import com.thelightphone.transit.gtfs.GtfsAgency import com.thelightphone.transit.gtfs.GtfsIngestStatus import com.thelightphone.transit.gtfs.GtfsIngestor +import com.thelightphone.transit.gtfs.GtfsRealtimeClient +import com.thelightphone.transit.gtfs.GtfsRepository +import com.thelightphone.transit.gtfs.TripStopRow +import com.thelightphone.transit.gtfs.computeArrivalEta +import com.thelightphone.transit.gtfs.formatGtfsTime import com.thelightphone.transit.gtfs.gtfsDbFile +import com.thelightphone.transit.gtfs.todayForGtfs import com.thelightphone.sdk.InitialScreen import com.thelightphone.sdk.LightScreen import com.thelightphone.sdk.LightViewModel import com.thelightphone.sdk.SealedLightActivity import com.thelightphone.sdk.SimpleLightScreen +import com.thelightphone.sdk.ui.LightBarButton +import com.thelightphone.sdk.ui.LightBottomBar import com.thelightphone.sdk.ui.LightIcon import com.thelightphone.sdk.ui.LightIcons +import com.thelightphone.sdk.ui.LightProgressBar import com.thelightphone.sdk.ui.LightText import com.thelightphone.sdk.ui.LightTextVariant import com.thelightphone.sdk.ui.LightTheme import com.thelightphone.sdk.ui.LightThemeController import com.thelightphone.sdk.ui.LightThemeTokens +import com.thelightphone.sdk.ui.gridUnitsAsDp import com.thelightphone.sdk.ui.lightClickable +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.first +import kotlinx.coroutines.isActive import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull import java.io.File +import java.time.Instant +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.ZoneId + +// One agency-row icon's footprint -- also used as a blank placeholder's size for the "already +// cached" state, so every agency name lines up at the same x position whether or not it has an +// icon next to it. +private const val AGENCY_ICON_SIZE = 1f + +/** + * A friendly, low-stakes message shown at the bottom of the home screen -- picked deterministically + * by calendar day (not randomly) so it stays the same across every visit in a day and only changes + * once daily, like a small daily ritual rather than a slot machine. + */ +private val DAILY_MESSAGES = listOf( + // Transit-themed + "Buses beat traffic. You beat the crowd. 🚌✨", + "One ride, zero emissions guilt. 🌱🚏", + "Transit day! Your commute, your planet. 🌍🚆", + "Skip the parking hunt — hop a bus! 🎯🚌", + "Every ride you take is a tree's thank-you. 🌳💚", + "Rails not roadblocks today. 🚈☀️", + "You + transit = fewer cars, cleaner air. 🌤️🚏", + "Ride smart, arrive relaxed. 🚌😌", + "Public transit: your daily eco win. 🌱🚉", + "Someone else drives, you just vibe. 🎧🚆", + "Small ride, big difference. 🌍🚌", + "Transit today, cleaner tomorrow. ✨🚏", + "Buses: the original carpool. 🚌🤝", + "Hop on — the planet says thanks. 🌎💛", + "Fewer cars, more sky. Nice work. ☁️🚈", + "Riding today keeps the air a little brighter. 🌤️🚌", + "You made a great choice this morning. 🌅🚏", + "Transit riders make cities breathe easier. 🌬️🚆", + "Zero traffic stress, all aboard energy. 🚌🎉", + "Good for you, good for the block. 🏙️💚", + // General positive + "Today's a good day to try. 🌱✨", + "You've got this. 💪🌤️", + "Small steps still count as progress. 🐾✨", + "Be kind to yourself today. 💛", + "Little wins add up. 🌟", + "You're doing better than you think. 🌷", + "Take a breath — you're okay. 🌬️💚", + "Progress, not perfection. 🌱🎯", + "Someone's glad you exist today. 💛", + "Rest is productive too. 🌙✨", + "You made it this far — nice. 🌟", + "Today's a fresh page. 📖🌤️", + "Be proud of small things too. 🌸", + "You're allowed to go slow. 🐢💚", + "Good things are still coming. 🌅", + "You matter more than you know. 💛", + "Kindness looks good on you. 🌷✨", + "One step at a time works fine. 👣🌿", + "You're not behind, you're on your way. 🛤️🌟", + "Today counts, even the quiet parts. 🌙", + "Give yourself the grace you'd give a friend. 💛", + "You're capable of more than you feel. 💪🌤️", + "Simple moments matter too. ☕✨", + "You showed up — that's enough. 🌱", + "Better days are built one hour at a time. ⏳🌸", + "You're allowed to be proud of you. 🌟", + "Small joys are still joys. 🍃💛", + "Keep going — it's working. 🌤️🌱", + "You're worth the effort you give others. 💐", + "Today is enough, just as it is. 🌙✨", + // Light sass + "You're welcome, gridlock. 😏🚌", + "Cars stuck. You: not stuck. 😌✨", + "Congrats on skipping the parking drama. 💅🎯", + "Look at you, being efficient. 💁‍♀️🚏", + "Not to brag, but you're basically eco-royalty. 👑🌱", + "Traffic's problem, not yours today. 😏🚆", + "Main character energy: took the bus. 🎬🚌", + "You out here saving the planet, no big deal. 💁🌍", +) + +private fun dailyMessage(): String { + val dayOfYear = LocalDate.now().dayOfYear + return DAILY_MESSAGES[dayOfYear % DAILY_MESSAGES.size] +} + +/** + * Whether HomeScreen is the currently visible screen -- lets [BackToHomeFooter] on every other + * screen pop straight back to it (see that file's own doc comment) without any "pop to root" + * primitive in the SDK itself: since [SimpleLightScreen.goBack] already reveals (and calls + * onScreenShow on) whatever screen is next down the stack before returning, a caller can just call + * it in a loop and stop the instant this flips true. + * + * [scope] backs that same loop -- deliberately NOT `rememberCoroutineScope()` at the call site, + * since popping the CURRENT screen tears down its own composition (and cancels anything scoped to + * it) partway through the loop, before it's had a chance to pop the rest of the way to Home. This + * app-process-lifetime scope survives exactly that kind of screen teardown. + */ +object HomeVisibility { + val isVisible = MutableStateFlow(false) + val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) +} + +// Matches every other live-polling screen's own cadence (see MapScreen/TripDetailScreen's +// identical constant) -- HomeScreen only ever runs this while a trip is actually boarded. +private const val LIVE_VEHICLE_POLL_INTERVAL_MS = 10_000L + +/** + * The boarded trip's live progress toward its designated alight stop, shown in place of "Choose + * Transit Agency" on HomeScreen while a trip is boarded. [stopsRemaining]/[etaEpochSeconds] are + * null whenever there's nothing live to show yet (trip not yet reporting a position, or the rider + * hasn't designated an alight stop on Trip Detail) -- [headingSubtitle] falls back to guidance text + * rather than a blank line in that case. + */ +data class ActiveTripStatus( + val routeLabel: String, + val alightStopName: String?, + val etaEpochSeconds: Long?, + /** Count of stops between the vehicle's current/next stop (inclusive) and the alight stop + * (exclusive) -- see [HomeScreenViewModel.refreshActiveTripStatus] for the exact computation. + * Null whenever there's no live vehicle position to compute it from. */ + val stopsRemaining: Int?, + /** How far along the vehicle is between the boarding stop (0f) and the alight stop (1f), by + * stop_sequence position -- drives the progress bar's marker (Settings screen's "Trip progress + * bar" toggle). Null under the same conditions as [stopsRemaining]. */ + val progressFraction: Float?, +) + +private fun Long.asClockTime(): String { + val time = LocalDateTime.ofInstant(Instant.ofEpochSecond(this), ZoneId.systemDefault()) + return formatGtfsTime("%02d:%02d:00".format(time.hour, time.minute)) +} + +fun ActiveTripStatus.headingSubtitle(): String { + val stopsToDest = when { + stopsRemaining == null -> null + stopsRemaining <= 0 -> alightStopName?.let { "Arriving at $it" } ?: "Arriving" + else -> { + val stopsWord = if (stopsRemaining == 1) "stop" else "stops" + alightStopName?.let { "$stopsRemaining $stopsWord to $it" } ?: "$stopsRemaining $stopsWord away" + } + } + val etaText = etaEpochSeconds?.let { "ETA ${it.asClockTime()}" } + return listOfNotNull(stopsToDest, etaText).joinToString(" · ").ifBlank { + if (alightStopName != null) { + "Boarded" + } else { + "Boarded · tap a stop on Trip Detail to set where you're getting off" + } + } +} class HomeScreenViewModel( - filesDir: File, + private val filesDir: File, private val preferences: AgencyPreferences, + private val boardedTripPreferences: BoardedTripPreferences, ) : LightViewModel() { private val ingestor = GtfsIngestor(filesDir) + /** The trip the rider is currently on (if any), independent of whichever agency is selected + * above -- see BoardedTripPreferences' own doc comment for why this is a saved reference back + * to Trip Detail rather than a background tracker. Collected for this ViewModel's whole + * lifetime so it reflects a Board/Alight tap made on Trip Detail immediately upon returning here. */ + val boardedTrip = MutableStateFlow(null) + + /** Live progress toward the boarded trip's alight stop -- see [ActiveTripStatus]'s own doc + * comment. Null whenever nothing's boarded; otherwise refreshed by [tripStatusPollJob] below. */ + val activeTripStatus = MutableStateFlow(null) + + /** Settings screen's "Trip progress bar" toggle (on by default) -- see + * BoardedTripPreferences.progressBarVisibleFlow. */ + val progressBarVisible = MutableStateFlow(true) + + /** One-shot signal: non-null exactly when the rider has just dismissed the "you've arrived" + * modal for the boarded trip's alight stop while HomeScreen was the visible screen -- mirrors + * TripDetailViewModel's own identical field (see the shared checkReachedAlightStop). Content() + * observes this to navigate to that stop's own Upcoming Arrivals, then calls + * [clearReachedAlightStop] to consume it. Carries the agency alongside the stop (rather than + * reading [boardedTrip] fresh when this fires) since [checkReachedAlightStop] already clears + * [boardedTrip] to null before the modal even shows -- by the time it's dismissed, that flow + * has long since gone null. */ + val reachedAlightStop = MutableStateFlow?>(null) + + /** Wakes the trip-status poll loop early on a boarded-trip change (board/alight/alight-stop + * tap made on Trip Detail) rather than waiting out the rest of the current poll interval -- + * same conflated-trigger pattern MapScreen's own refreshTrigger uses. */ + private val tripStatusRefreshTrigger = Channel(Channel.CONFLATED) + private var tripStatusPollJob: Job? = null + + init { + viewModelScope.launch { + boardedTripPreferences.boardedTripFlow.collect { + boardedTrip.value = it + if (it == null) activeTripStatus.value = null + tripStatusRefreshTrigger.trySend(Unit) + } + } + viewModelScope.launch { + boardedTripPreferences.progressBarVisibleFlow.collect { progressBarVisible.value = it } + } + } + + fun clearReachedAlightStop() { + reachedAlightStop.value = null + } + val selectedAgency = MutableStateFlow(null) + /** Error text only now -- per-agency ready/syncing state renders as an icon next to each + * agency's name instead (see [cachedAgencies]/[syncingAgency]). */ val status = MutableStateFlow(null) - /** Set once ingestion completes successfully; gates the "Explore Schedules" entry point. */ + /** Set once ingestion completes successfully; gates the "Schedule"/"Explore" entry points. */ val readyAgency = MutableStateFlow(null) + /** Agencies with a GTFS schedule already downloaded/cached on disk -- checked once at + * screen-open, then grown as each agency's own ingest completes. An agency in this set (and + * not currently in [syncingAgency]) shows no icon at all next to its name. */ + val cachedAgencies = MutableStateFlow>(emptySet()) + + /** The one agency (if any) currently checking for updates/downloading/parsing right now -- + * shows a spinning sync icon next to that agency's name only. */ + val syncingAgency = MutableStateFlow(null) + + /** Whether [readyAgency] has any real, qualifying multi-platform stations at all (see + * GtfsRepository.getAllStations) -- an agency with none (e.g. RIPTA, which has no grouped + * stations in its GTFS feed) shows no "Station" entry point rather than one that always opens + * an empty list. Reset to false the moment a new agency is selected so a stale true from the + * previous agency can't flash the link before this agency's own check completes. */ + val agencyHasStations = MutableStateFlow(false) + override fun onScreenShow(screen: SimpleLightScreen) { super.onScreenShow(screen) + HomeVisibility.isVisible.value = true + tripStatusPollJob?.cancel() + tripStatusPollJob = viewModelScope.launch(Dispatchers.IO) { + while (isActive) { + if (boardedTrip.value != null) refreshActiveTripStatus() + withTimeoutOrNull(LIVE_VEHICLE_POLL_INTERVAL_MS) { tripStatusRefreshTrigger.receive() } + } + } // A configured default agency skips the manual tap -- the list stays visible/tappable in // case someone wants a different agency for this session, this just saves the first tap. // Only checked once per ViewModel (guarded by selectedAgency already being set), so // returning to HomeScreen from a child screen doesn't re-trigger it. if (selectedAgency.value != null) return viewModelScope.launch(Dispatchers.IO) { + cachedAgencies.value = GtfsAgency.entries.filterTo(mutableSetOf()) { gtfsDbFile(filesDir, it).exists() } val default = preferences.defaultAgencyFlow.first() if (default != null && selectedAgency.value == null) { selectAgency(default) @@ -66,24 +323,119 @@ class HomeScreenViewModel( } } + override fun onScreenHide(screen: SimpleLightScreen) { + super.onScreenHide(screen) + HomeVisibility.isVisible.value = false + tripStatusPollJob?.cancel() + tripStatusPollJob = null + } + + /** + * Fetches this agency's live vehicle position (and, if available, a live TripUpdates + * prediction) for the boarded trip and recomputes [activeTripStatus] -- mirrors the same + * live-position/ETA pattern MapScreen and TripDetailScreen already use. No-ops (leaving the + * previous status in place) on any fetch/lookup failure, same as those screens' own polling. + */ + private suspend fun refreshActiveTripStatus() { + val trip = boardedTrip.value ?: return + val dbFile = gtfsDbFile(filesDir, trip.agency) + val repository = GtfsRepository(dbFile) + try { + val alightStopId = trip.alightStopId + val alightStop = alightStopId?.let { id -> + repository.getTripStops(trip.tripId, trip.fromStopSequence).let { stops -> + val stop = stops.find { it.stopId == id } ?: return@let null + stop to stops + } + } + + if (alightStopId == null || alightStop == null) { + activeTripStatus.value = ActiveTripStatus( + routeLabel = trip.routeLabel, alightStopName = null, etaEpochSeconds = null, + stopsRemaining = null, progressFraction = null, + ) + return + } + val (stop, stops) = alightStop + + val vehiclePositionsUrl = trip.agency.realtimeVehiclePositionsUrl + val vehicle = vehiclePositionsUrl?.let { url -> + try { + GtfsRealtimeClient.fetchFeed(url).vehiclePositionsByTripId[trip.tripId] + } catch (e: Exception) { + Log.e("HomeScreen", "VehiclePositions fetch failed for ${trip.agency.displayName}", e) + null + } + } + val currentSeq = vehicle?.currentStopSequence + val stopsRemaining = currentSeq?.let { seq -> stops.count { it.stopSequence in seq until stop.stopSequence } } + // Boarding stop (0f) to alight stop (1f) -- guards against a same-sequence divide (the + // rider designated their own boarding stop as the alight stop too) by leaving it null + // rather than producing a NaN/Infinity fraction. + val progressFraction = if (currentSeq != null && stop.stopSequence != trip.fromStopSequence) { + ((currentSeq - trip.fromStopSequence).toFloat() / (stop.stopSequence - trip.fromStopSequence).toFloat()) + .coerceIn(0f, 1f) + } else { + null + } + + val today = todayForGtfs() + val rtStopUpdate = trip.agency.realtimeTripUpdatesUrl?.let { url -> + try { + GtfsRealtimeClient.fetchFeed(url).tripUpdatesByTripId[trip.tripId]?.updateFor(stop.stopId, stop.stopSequence) + } catch (e: Exception) { + Log.e("HomeScreen", "TripUpdates fetch failed for ${trip.agency.displayName}", e) + null + } + } + val scheduledTime = stop.arrivalTime ?: stop.departureTime + val eta = scheduledTime?.let { computeArrivalEta(it, today, rtStopUpdate) } + + activeTripStatus.value = ActiveTripStatus( + routeLabel = trip.routeLabel, + alightStopName = stop.stopName, + etaEpochSeconds = eta?.etaEpochSeconds, + stopsRemaining = stopsRemaining, + progressFraction = progressFraction, + ) + + // Checked here (not just from Trip Detail's own poll) so the "you've arrived" moment + // fires even if the rider's just sitting on HomeScreen rather than Trip Detail -- see + // the shared checkReachedAlightStop's own doc comment. + checkReachedAlightStop(trip, stops, currentSeq, boardedTripPreferences) { reachedAlightStop.value = trip.agency to it } + } catch (e: Exception) { + Log.e("HomeScreen", "Failed to refresh active trip status for ${trip.tripId}", e) + } finally { + repository.close() + } + } + fun selectAgency(agency: GtfsAgency) { selectedAgency.value = agency readyAgency.value = null + status.value = null + syncingAgency.value = agency + agencyHasStations.value = false Log.d("HomeScreen", "Selected agency: ${agency.displayName}") viewModelScope.launch(Dispatchers.IO) { try { ingestor.ingest(agency) { ingestStatus -> - status.value = when (ingestStatus) { - GtfsIngestStatus.CheckingForUpdates -> "Checking for updates..." - GtfsIngestStatus.Downloading -> "Downloading ${agency.displayName} data..." - GtfsIngestStatus.Parsing -> "Parsing ${agency.displayName} data..." - GtfsIngestStatus.Ready -> "Ready" + if (ingestStatus == GtfsIngestStatus.Ready) { + syncingAgency.value = null + cachedAgencies.value = cachedAgencies.value + agency } } readyAgency.value = agency + val stationRepo = GtfsRepository(gtfsDbFile(filesDir, agency)) + try { + agencyHasStations.value = stationRepo.getAllStations().isNotEmpty() + } finally { + stationRepo.close() + } } catch (e: Exception) { Log.e("HomeScreen", "GTFS ingestion failed for ${agency.displayName}", e) + syncingAgency.value = null status.value = "Unable to load ${agency.displayName} data." } } @@ -97,7 +449,11 @@ class HomeScreen(sealedActivity: SealedLightActivity) : LightScreen + UpcomingArrivalsScreen(activity, gtfsDbFile(lightContext.filesDir, agency), agency, listOf(stop.stopId), stop.stopName ?: "Stop ${stop.stopId}") + }) + viewModel.clearReachedAlightStop() + } + LightTheme(colors = themeColors) { - Box( + Column( modifier = Modifier .fillMaxSize() .background(LightThemeTokens.colors.background) + ) { + // Current Trip lives here (top-right corner) rather than in the bottom icon row -- + // it's the one entry that's about something already in progress, not a mode to pick, + // so it reads more like a status indicator than another menu item. Built by hand + // (matching LightTopBar's own height/padding) rather than via LightTopBar's + // rightButton slot, which only accepts a single button -- this needs two icons + // together (vehicle type, then Play). + Row( + modifier = Modifier + .fillMaxWidth() + .height(3f.gridUnitsAsDp()) + .padding(horizontal = 1f.gridUnitsAsDp()), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, + ) { + boardedTrip?.let { boarded -> + LightIcon( + icon = boarded.lineType.toVehicleIcon(), + size = 2f, + contentDescription = "Vehicle type", + modifier = Modifier.padding(end = 8.dp), + ) + LightIcon( + icon = LightIcons.PLAY, + size = 2f, + contentDescription = "Current Trip", + modifier = Modifier.lightClickable { + navigateTo(screenFactory = { activity -> + TripDetailScreen( + activity, + gtfsDbFile(lightContext.filesDir, boarded.agency), + boarded.tripId, + boarded.fromStopSequence, + boarded.routeLabel, + boarded.directionLabel, + ) + }) + }, + ) + } + } + Box( + modifier = Modifier + .weight(1f) + .fillMaxWidth() ) { Column( modifier = Modifier .fillMaxSize() .padding(32.dp) ) { - LightText( - text = "Choose Transit Agency", - variant = LightTextVariant.Heading, - modifier = Modifier.padding(bottom = 16.dp), - ) + // While a trip is boarded, this becomes an active-trip status instead of the + // agency-picker heading -- everything below (agency list, mode icons, daily + // message) stays exactly as-is underneath either way. Reverts back the moment + // the trip is alighted (boardedTrip/activeTripStatus both go null together). + if (boardedTrip != null) { + LightText( + text = activeTripStatus?.routeLabel ?: boardedTrip?.routeLabel ?: "Current Trip", + variant = LightTextVariant.Heading, + modifier = Modifier.padding(bottom = 4.dp), + ) + LightText( + text = activeTripStatus?.headingSubtitle() ?: "Boarded", + variant = LightTextVariant.Detail, + lighten = true, + modifier = Modifier.padding(bottom = 16.dp), + ) + // Boarding stop (left) to alight stop (right), with a vehicle-type marker + // positioned at the live stop_sequence progress between them -- see + // ActiveTripStatus.progressFraction. Settings screen toggle (on by default); + // 0f (marker at the start) whenever there's no live position yet rather than + // hiding the bar entirely, so its layout doesn't jump once one arrives. + if (progressBarVisible) { + BoxWithConstraints( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 16.dp), + ) { + LightProgressBar(colors = LightThemeTokens.colors, progress = activeTripStatus?.progressFraction ?: 0f) + val markerSize = 1.4f + val trackWidth = maxWidth - markerSize.gridUnitsAsDp() + LightIcon( + icon = boardedTrip?.lineType.toVehicleIcon(), + size = markerSize, + contentDescription = "Vehicle position", + modifier = Modifier.offset(x = trackWidth * (activeTripStatus?.progressFraction ?: 0f)), + ) + } + } + } else { + LightText( + text = "Choose Transit Agency", + variant = LightTextVariant.Heading, + modifier = Modifier.padding(bottom = 16.dp), + ) + } + // Only ever populated by an ingest failure now -- per-agency ready/syncing + // state is the icon next to each agency's name below, not a screen-wide banner. status?.let { LightText( text = it, @@ -133,58 +594,146 @@ class HomeScreen(sealedActivity: SealedLightActivity) : LightScreen