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: diff --git a/LICENSE-TRANSIT b/LICENSE-TRANSIT new file mode 100644 index 00000000..fcf2a60e --- /dev/null +++ b/LICENSE-TRANSIT @@ -0,0 +1,21 @@ +MIT License + +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 +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/README.md b/README.md index 8c1b89ab..42648d8e 100644 --- a/README.md +++ b/README.md @@ -1,64 +1,59 @@ -# light-sdk -or: a tool for building Tools +# 🚌 Pico Transit (ALPHA TESTING) -## 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. +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. 🚏✨ -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 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. -## 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 Pico Transit downloads their schedule right onto your phone. +- βš™οΈ **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, 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 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. -### 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 +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: -### 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. +- **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. -## [Complete Documentation](./docs) +## πŸ“„ 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/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..58b7e213 --- /dev/null +++ b/tool/README.md @@ -0,0 +1,69 @@ +# 🚌 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. 🚏✨ + +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** β€” 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, 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 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 + +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! πŸŽ‰ +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! πŸ™ +- **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 + +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). 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..96805509 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 = "Pico Transit" versionCode = 1 versionName = "1.0.0" permissions = ["android.permission.INTERNET"] diff --git a/tool/src/main/kotlin/com/thelightphone/sample/DetailScreen.kt b/tool/src/main/kotlin/com/thelightphone/sample/DetailScreen.kt deleted file mode 100644 index 441076a9..00000000 --- a/tool/src/main/kotlin/com/thelightphone/sample/DetailScreen.kt +++ /dev/null @@ -1,118 +0,0 @@ -package com.thelightphone.sample - -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() }, - ) - } - } -} 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/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 new file mode 100644 index 00000000..3b07209b --- /dev/null +++ b/tool/src/main/kotlin/com/thelightphone/transit/DepartureListScreen.kt @@ -0,0 +1,171 @@ +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.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 +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) + ) { + 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( + 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), + ) + } + } + } + } + } + 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 new file mode 100644 index 00000000..f347347d --- /dev/null +++ b/tool/src/main/kotlin/com/thelightphone/transit/DirectionSelectionScreen.kt @@ -0,0 +1,169 @@ +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.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 +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() +} + +/** 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() } + ?.replace(viaClauseRegex, "") + ?.trim() + ?.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) + ) { + 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( + 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 -> + FirstStopSelectionScreen( + activity, + dbFile, + routeId, + routeLabel, + direction.directionId, + direction.displayLabel(), + ) + }) + } + .padding(vertical = 12.dp), + ) + } + } + } + } + } + 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 new file mode 100644 index 00000000..7765a23d --- /dev/null +++ b/tool/src/main/kotlin/com/thelightphone/transit/FirstStopSelectionScreen.kt @@ -0,0 +1,172 @@ +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.StopOption +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 +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" + +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 { + // 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.") + } + } + } + + 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, +) : 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) + ) { + 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( + 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 -> + LightText( + text = stop.displayLabel(), + variant = LightTextVariant.Copy, + modifier = Modifier + .fillMaxWidth() + .lightClickable { + navigateTo(screenFactory = { activity -> + DepartureListScreen( + activity, + dbFile, + routeId, + routeLabel, + directionId, + directionLabel, + stop.stopId, + stop.displayLabel(), + ) + }) + } + .padding(vertical = 12.dp), + ) + } + } + } + } + } + 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 new file mode 100644 index 00000000..8003ab4d --- /dev/null +++ b/tool/src/main/kotlin/com/thelightphone/transit/HomeScreen.kt @@ -0,0 +1,740 @@ +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( + 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 "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) + } + } + } + + 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 -> + 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." + } + } + } +} + +@InitialScreen +class HomeScreen(sealedActivity: SealedLightActivity) : LightScreen(sealedActivity) { + + override val viewModelClass: Class + get() = HomeScreenViewModel::class.java + + override fun createViewModel(): HomeScreenViewModel { + return HomeScreenViewModel( + lightContext.filesDir, + AgencyPreferences(lightContext.dataStore), + BoardedTripPreferences(lightContext.dataStore), + ) + } + + @Composable + override fun Content() { + val selectedAgency by viewModel.selectedAgency.collectAsState() + val status by viewModel.status.collectAsState() + val readyAgency by viewModel.readyAgency.collectAsState() + val cachedAgencies by viewModel.cachedAgencies.collectAsState() + val syncingAgency by viewModel.syncingAgency.collectAsState() + val agencyHasStations by viewModel.agencyHasStations.collectAsState() + val boardedTrip by viewModel.boardedTrip.collectAsState() + val activeTripStatus by viewModel.activeTripStatus.collectAsState() + val progressBarVisible by viewModel.progressBarVisible.collectAsState() + val reachedAlightStop by viewModel.reachedAlightStop.collectAsState() + val themeColors by LightThemeController.colors.collectAsState() + + // Fires once the "you've arrived" modal has been dismissed while HomeScreen was the + // visible screen (manually or by timeout) -- see ReachedStopModal/checkReachedAlightStop. + // Navigates to that stop's own Upcoming Arrivals, matching Trip Detail's identical handling. + LaunchedEffect(reachedAlightStop) { + val (agency, stop) = reachedAlightStop ?: return@LaunchedEffect + navigateTo(screenFactory = { activity -> + UpcomingArrivalsScreen(activity, gtfsDbFile(lightContext.filesDir, agency), agency, listOf(stop.stopId), stop.stopName ?: "Stop ${stop.stopId}") + }) + viewModel.clearReachedAlightStop() + } + + LightTheme(colors = themeColors) { + 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) + ) { + // 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, + variant = LightTextVariant.Detail, + lighten = true, + modifier = Modifier.padding(bottom = 16.dp), + ) + } + + // Hidden entirely while a trip is boarded -- the active-trip status above (and + // its progress bar) takes this space instead. Reappears the moment the trip is + // alighted, same as the heading reverting above. + if (boardedTrip == null) { + Column { + GtfsAgency.entries.forEach { agency -> + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .lightClickable { viewModel.selectAgency(agency) } + .padding(vertical = 12.dp), + ) { + when { + agency == syncingAgency -> { + val infiniteTransition = rememberInfiniteTransition(label = "agencySync") + val angle by infiniteTransition.animateFloat( + initialValue = 0f, + targetValue = 360f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 1000, easing = LinearEasing), + repeatMode = RepeatMode.Restart, + ), + label = "agencySyncAngle", + ) + LightIcon( + icon = LightIcons.REFRESH, + size = AGENCY_ICON_SIZE, + contentDescription = "Checking for updates", + modifier = Modifier + .padding(end = 8.dp) + .rotate(angle), + ) + } + agency !in cachedAgencies -> { + LightIcon( + icon = LightIcons.DOWNLOAD_ARROW, + size = AGENCY_ICON_SIZE, + contentDescription = "Download", + modifier = Modifier.padding(end = 8.dp), + ) + } + else -> { + Spacer( + modifier = Modifier.size( + width = AGENCY_ICON_SIZE.gridUnitsAsDp() + 8.dp, + height = AGENCY_ICON_SIZE.gridUnitsAsDp(), + ), + ) + } + } + LightText( + text = agency.displayName, + variant = LightTextVariant.Copy, + lighten = agency != selectedAgency, + underline = agency == selectedAgency, + ) + } + } + } + } + + } + + // Message sits directly above the icon row (not at the very bottom edge) so + // nothing competes for the same space -- matches LightBottomBar's own "matching + // LightOS ActionBar" convention (see its doc comment) rather than the previous + // ad-hoc Alignment.BottomStart/BottomEnd pair. + Column(modifier = Modifier.align(Alignment.BottomCenter).fillMaxWidth()) { + LightText( + text = dailyMessage(), + variant = LightTextVariant.Detail, + lighten = true, + modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp), + ) + // Settings, About first, then whichever of Schedule/Station/Explore are + // actually reachable right now (built, not a fixed-size list with null + // placeholders) -- Current Trip lives in the top-right corner instead (see + // LightTopBar above), so this row's own worst case is 5, always exactly one + // LightBottomBar row (which hard-caps at 5 items). + val bottomBarItems = buildList { + add( + LightBarButton.LightIcon( + icon = LightIcons.SETTINGS, + contentDescription = "Settings", + onClick = { + navigateTo(screenFactory = { activity -> SettingsScreen(activity) }) + }, + ), + ) + add( + LightBarButton.LightIcon( + icon = LightIcons.ELLIPSES, + contentDescription = "About", + onClick = { + navigateTo(screenFactory = { activity -> InfoScreen(activity) }) + }, + ), + ) + readyAgency?.let { agency -> + add( + LightBarButton.LightIcon( + icon = LightIcons.LIST, + contentDescription = "Schedule", + onClick = { + navigateTo(screenFactory = { activity -> + LineTypeSelectionScreen(activity, gtfsDbFile(lightContext.filesDir, agency)) + }) + }, + ), + ) + if (agencyHasStations) { + add( + LightBarButton.LightIcon( + icon = LightIcons.DIRECTIONS_MIDDLE_FORK, + contentDescription = "Station", + onClick = { + navigateTo(screenFactory = { activity -> + StationListScreen(activity, gtfsDbFile(lightContext.filesDir, agency), agency) + }) + }, + ), + ) + } + add( + LightBarButton.LightIcon( + icon = LightIcons.DIRECTIONS_PEDESTRIAN, + contentDescription = if (agency.realtimeTripUpdatesUrl == null) "Explore (Offline)" else "Explore", + onClick = { + navigateTo(screenFactory = { activity -> + NearbyStopsScreen(activity, gtfsDbFile(lightContext.filesDir, agency), agency) + }) + }, + ), + ) + } + } + LightBottomBar(items = bottomBarItems) + } + } + } + } + } +} diff --git a/tool/src/main/kotlin/com/thelightphone/transit/InfoScreen.kt b/tool/src/main/kotlin/com/thelightphone/transit/InfoScreen.kt new file mode 100644 index 00000000..cb5a14f6 --- /dev/null +++ b/tool/src/main/kotlin/com/thelightphone/transit/InfoScreen.kt @@ -0,0 +1,251 @@ +package com.thelightphone.transit + +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.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 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.LightIcon +import com.thelightphone.sdk.ui.LightIconConfiguration +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 +import com.thelightphone.sdk.ui.LightThemeController +import com.thelightphone.sdk.ui.LightThemeTokens +import com.thelightphone.sdk.ui.LightTopBar +import com.thelightphone.sdk.ui.LightTopBarCenter + +private data class IconLegendEntry(val icon: LightIconConfiguration, val label: String) + +/** Every icon this app actually draws on the map/HomeScreen that a rider would need explained -- + * kept as a single list so it can't quietly drift out of sync with what MapScreen/HomeScreen use + * (verified against a full grep of every LightIcons.* reference in this package). Chrome-only icons + * (back, settings, search, this screen's own entry point) aren't map/data indicators, so they're + * left out per the same reasoning that excluded them from the request this screen was built for. */ +private val ICON_LEGEND = listOf( + IconLegendEntry(LightIcons.DIRECTIONS_SUBWAY, "Subway / Light Rail vehicle"), + IconLegendEntry(LightIcons.DIRECTIONS_BUS, "Bus vehicle"), + IconLegendEntry(LightIcons.DIRECTIONS_TRAIN, "Commuter Rail vehicle"), + IconLegendEntry(LightIcons.DIRECTIONS_MIDDLE_FORK, "Multi-platform station (tap to see all its platforms)"), + IconLegendEntry(LightIcons.DIRECTIONS_ARRIVAL, "Selected stop (large) / nearby stop (small) on the map"), + IconLegendEntry(LightIcons.DOWNLOAD_ARROW, "Agency schedule not yet downloaded"), + IconLegendEntry(LightIcons.REFRESH, "Checking for schedule updates"), +) + +/** HomeScreen's own bottom icon rows -- kept as a single list for the same reason as [ICON_LEGEND], + * verified against HomeScreen.kt's own LightBottomBar item lists. */ +private val MENU_ICON_LEGEND = listOf( + IconLegendEntry(LightIcons.ELLIPSES, "About (this screen)"), + IconLegendEntry(LightIcons.SETTINGS, "Settings"), + IconLegendEntry(LightIcons.LIST, "Schedule -- browse today's static route schedule"), + IconLegendEntry(LightIcons.DIRECTIONS_PEDESTRIAN, "Explore -- find nearby stops and live upcoming arrivals"), + IconLegendEntry(LightIcons.DIRECTIONS_MIDDLE_FORK, "Station -- browse a transit authority's multi-platform stations"), + IconLegendEntry(LightIcons.PLAY, "Play -- on a Trip Detail screen, boards that trip. Everywhere else, shown once a trip's boarded, to jump back to its live tracking"), + IconLegendEntry(LightIcons.STOP, "Alight -- Trip Detail's header, shown in place of Play while that trip is the one you've boarded; taps end tracking"), + IconLegendEntry(LightIcons.DELETE, "Trip switch warning -- Trip Detail's header, shown next to Play when a DIFFERENT trip is already boarded; boarding this one ends tracking of that one"), + IconLegendEntry(LightIcons.CIRCLE, "Home -- every other screen's own footer button; jumps back to HomeScreen"), +) + +/** Settings screen's own on/off toggles -- all four (Tap and hold a stop, Double-tap to open a + * station, Track tapped stops, Trip progress bar) render as one of these two icons next to their + * label, per SettingsScreen's own ToggleRow. */ +private val TOGGLE_ICON_LEGEND = listOf( + IconLegendEntry(LightIcons.TOGGLE_STATE_ON, "Setting is on -- tap the row to turn it off"), + IconLegendEntry(LightIcons.TOGGLE_STATE_OFF, "Setting is off -- tap the row to turn it on"), +) + +class InfoScreenViewModel : LightViewModel() + +/** HomeScreen's info/about entry point -- no dedicated "about screen" template exists anywhere in + * the SDK (checked), so this follows the same LightTopBar+LightScrollView+LightText convention + * SettingsScreen already established elsewhere in this app. */ +class InfoScreen(sealedActivity: SealedLightActivity) : LightScreen(sealedActivity) { + + override val viewModelClass: Class + get() = InfoScreenViewModel::class.java + + override fun createViewModel(): InfoScreenViewModel = InfoScreenViewModel() + + @Composable + override fun Content() { + 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("About"), + rightButton = currentTripTopBarButton(lightContext.dataStore, lightContext.filesDir) { dbFile, tripId, fromStopSequence, routeLabel, directionLabel -> + navigateTo(screenFactory = { activity -> TripDetailScreen(activity, dbFile, tripId, fromStopSequence, routeLabel, directionLabel) }) + }, + ) + LightScrollView(modifier = Modifier.weight(1f).padding(32.dp)) { + LightText( + text = "Pico Transit", + variant = LightTextVariant.Heading, + modifier = Modifier.padding(bottom = 8.dp), + ) + LightText( + text = "A transit companion for the Light Phone III -- live arrivals, " + + "schedules, and station maps.", + variant = LightTextVariant.Copy, + lighten = true, + modifier = Modifier.padding(bottom = 24.dp), + ) + + LightText( + text = "Supported Agencies", + variant = LightTextVariant.Copy, + modifier = Modifier.padding(bottom = 8.dp), + ) + LightText( + text = GtfsAgency.entries.joinToString(", ") { it.displayName }, + variant = LightTextVariant.Detail, + lighten = true, + modifier = Modifier.padding(bottom = 24.dp), + ) + + LightText( + text = "Modes", + variant = LightTextVariant.Copy, + modifier = Modifier.padding(bottom = 8.dp), + ) + ModeEntry( + name = "Schedule", + description = "Browse today's static route schedule: route, then direction, " + + "then stop, then departure times, then the full trip's stop list.", + ) + ModeEntry( + name = "Explore", + description = "Find nearby stops by your current location and see live " + + "upcoming arrivals with status -- on time, early, or late.", + ) + ModeEntry( + name = "Station", + description = "View a zoomed-in map of a multi-platform station's individual " + + "platforms.", + ) + + LightText( + text = "Boarding a trip", + variant = LightTextVariant.Copy, + modifier = Modifier.padding(bottom = 8.dp), + ) + LightText( + text = "Board a trip from its Trip Detail screen (Play/Stop icon, top right) to " + + "make it your current trip. While boarded, HomeScreen replaces \"Choose " + + "Transit Agency\" with your route, live ETA, and stops remaining to your " + + "designated alight stop, and hides the agency list until you alight. If " + + "the \"Trip progress bar\" setting is on, a bar with a vehicle-type marker " + + "also shows live progress between your boarding and alight stops. Tap a " + + "stop on Trip Detail to set (or clear) it as your alight stop -- reaching " + + "it shows a \"You've reached your stop!\" message and ends tracking " + + "automatically, whether you're on Trip Detail or HomeScreen at the time.", + variant = LightTextVariant.Detail, + lighten = true, + modifier = Modifier.padding(bottom = 24.dp), + ) + + LightText( + text = "Menu Icons", + variant = LightTextVariant.Copy, + modifier = Modifier.padding(top = 16.dp, bottom = 8.dp), + ) + MENU_ICON_LEGEND.forEach { entry -> + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(vertical = 6.dp), + ) { + LightIcon(icon = entry.icon, size = 1f, modifier = Modifier.padding(end = 12.dp)) + LightText(text = entry.label, variant = LightTextVariant.Detail, lighten = true) + } + } + + LightText( + text = "Map Icons", + variant = LightTextVariant.Copy, + modifier = Modifier.padding(top = 16.dp, bottom = 8.dp), + ) + ICON_LEGEND.forEach { entry -> + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(vertical = 6.dp), + ) { + LightIcon(icon = entry.icon, size = 1f, modifier = Modifier.padding(end = 12.dp)) + LightText(text = entry.label, variant = LightTextVariant.Detail, lighten = true) + } + } + + LightText( + text = "Settings Toggles", + variant = LightTextVariant.Copy, + modifier = Modifier.padding(top = 16.dp, bottom = 8.dp), + ) + LightText( + text = "\"Track tapped stops\" (a tapped-open stop on the Map screen also " + + "contributes its own live vehicles) lives here now, alongside \"Tap and " + + "hold a stop\", \"Double-tap to open a station\", and \"Trip progress bar\".", + variant = LightTextVariant.Detail, + lighten = true, + modifier = Modifier.padding(bottom = 8.dp), + ) + TOGGLE_ICON_LEGEND.forEach { entry -> + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(vertical = 6.dp), + ) { + LightIcon(icon = entry.icon, size = 1f, modifier = Modifier.padding(end = 12.dp)) + LightText(text = entry.label, variant = LightTextVariant.Detail, lighten = true) + } + } + + LightText( + text = "Map tiles Β© OpenStreetMap contributors Β© CARTO", + variant = LightTextVariant.Detail, + lighten = true, + modifier = Modifier.padding(top = 24.dp), + ) + + LightText( + text = "Made by Christian Ferreira Β· github.com/CJFData", + variant = LightTextVariant.Detail, + lighten = true, + modifier = Modifier.padding(top = 24.dp), + ) + LightText( + text = "If Pico Transit helps you catch your bus, consider buying me a " + + "coffee β˜• -- buymeacoffee.com/cjfdata", + variant = LightTextVariant.Detail, + lighten = true, + modifier = Modifier.padding(top = 8.dp), + ) + } + } + } + } +} + +@Composable +private fun ModeEntry(name: String, description: String) { + Column(modifier = Modifier.padding(bottom = 12.dp)) { + LightText(text = name, variant = LightTextVariant.Copy) + LightText(text = description, variant = LightTextVariant.Detail, lighten = true) + } +} 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..9e6c1324 --- /dev/null +++ b/tool/src/main/kotlin/com/thelightphone/transit/LineTypeSelectionScreen.kt @@ -0,0 +1,151 @@ +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.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.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.LightTopBar +import com.thelightphone.sdk.ui.LightTopBarCenter +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) + ) { + LightTopBar( + leftButton = LightBarButton.LightIcon(icon = LightIcons.BACK, onClick = { goBack() }), + center = LightTopBarCenter.Text("Choose Line"), + 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)) { + 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 -> + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .lightClickable { + navigateTo(screenFactory = { activity -> + RouteSelectionScreen(activity, dbFile, lineType) + }) + } + .padding(vertical = 12.dp), + ) { + LightIcon( + icon = lineType.toVehicleIcon(), + size = 1.2f, + modifier = Modifier.padding(end = 12.dp), + ) + LightText(text = lineType.label, variant = LightTextVariant.Copy) + } + } + } + } + } + } + BackToHomeFooter(onGoBackOnce = { goBack() }) + } + } + } +} diff --git a/tool/src/main/kotlin/com/thelightphone/transit/MapScreen.kt b/tool/src/main/kotlin/com/thelightphone/transit/MapScreen.kt new file mode 100644 index 00000000..79579d0b --- /dev/null +++ b/tool/src/main/kotlin/com/thelightphone/transit/MapScreen.kt @@ -0,0 +1,1252 @@ +package com.thelightphone.transit + +import android.graphics.Bitmap +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.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.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.AwaitPointerEventScope +import androidx.compose.ui.input.pointer.PointerInputChange +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 +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 +import com.thelightphone.transit.gtfs.ScheduledArrival +import com.thelightphone.transit.gtfs.StopLocation +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.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.LightThemeController +import com.thelightphone.sdk.ui.LightThemeTokens +import com.thelightphone.sdk.ui.LightTopBar +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +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 +import java.time.ZoneId +import kotlin.math.sqrt + +// 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 +// A vehicle reporting STOPPED_AT (e.g. dwelling at a layover/terminal) with a predicted arrival +// farther out than this is excluded entirely rather than left to the sort+cap above -- otherwise a +// stop with a thin real candidate pool ends up padding its display with static, not-actually- +// arriving-soon vehicles just because a slot was open. Doesn't apply to a vehicle that's actually +// stopped AT the target stop itself (see isArrived in refresh()) or to any vehicle still moving +// (IN_TRANSIT_TO/INCOMING_AT), however far away -- those are still meaningful to show. +private const val DWELLING_FAR_ETA_THRESHOLD_SECONDS = 15 * 60L +// Widens the SQL prefilter behind scheduledArrivalsByStopId backward by this much (see +// GtfsRepository.getScheduledArrivals's graceSeconds param) -- without it, a trip whose scheduled +// departure ticks past while this screen is open drops out of the candidate list permanently, even +// if the live feed shows the vehicle still dwelling right at the stop. The live-position-based +// inclusion/exclusion logic in refresh() (not this window) makes the real call on whether a +// candidate this wide is still actually relevant. +private const val SCHEDULED_ARRIVALS_GRACE_PERIOD_SECONDS = 10 * 60 +// 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 + +// 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 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 +// 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 + +// 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. 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 ATTRIBUTION_Y = 26f +private const val COMPASS_SCRIM_MARGIN_PX = 24f + +// Map-Station mode's scrim is taller than the plain attribution bar (SCRIM_HEIGHT_PX above) since it +// also carries the station's own name -- tap-and-hold on that name (see MapCanvas's scrimTitle +// param) opens the whole station's Upcoming Arrivals, the same way tap-hold on a stop marker does. +private const val STATION_SCRIM_HEIGHT_PX = 76f +private const val STATION_TITLE_TEXT_SIZE_PX = 30f +private const val STATION_TITLE_Y = 44f +private const val STATION_ATTRIBUTION_Y = 68f + +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, + /** 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, + /** 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. */ + val status: ArrivalStatus?, + val isArrived: Boolean, + val lat: Double, + 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)) +} + +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 lat: Double, + val lon: Double, + /** See [StopLocation.isStation]. Gates whether double-tapping this marker opens its Station + * sub-map (see MapPreferences.doubleTapStationEnabledFlow). */ + val isStation: Boolean = false, + /** Every platform stop_id this marker's station represents -- see [StopLocation.memberStopIds]. + * Only meaningful when [isStation] is true; a plain stop is just its own id. */ + val memberStopIds: List = emptyList(), +) + +/** 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 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, + /** Settings screen toggle (off by default) -- see MapPreferences.doubleTapStationEnabledFlow. */ + val doubleTapStationEnabled: Boolean, + /** Non-null only when the centered/selected stop is itself a qualifying multi-platform + * station (see [StopLocation.isStation]) -- gates double-tap-to-open-Station for the center + * pin the same way [NearbyStopMarker.isStation] does for a nearby one. */ + val centerStation: StopLocation?, + ) : MapState() + data class Error(val message: String) : MapState() +} + +/** 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, + val tapHoldArrivalsEnabled: Boolean, + val darkMapEnabled: Boolean, + val doubleTapStationEnabled: Boolean, + val centerStation: StopLocation?, +) + +class MapViewModel( + dbFile: File, + private val agency: GtfsAgency, + private val stopId: String, + private val mapPreferences: MapPreferences, +) : LightViewModel() { + + private val repository = GtfsRepository(dbFile) + private val geocoder = NominatimGeocoder() + private val tileClient = MapTileClient() + + 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>() + + /** Wakes the single poll loop below early (see [refreshNow]) instead of spawning a second, + * concurrent [refresh] call -- CONFLATED so a burst of toggles between poll ticks only wakes it + * once, and so it's never possible for two refreshes to race and have a stale one clobber a + * fresher one's state write. */ + private val refreshTrigger = Channel(Channel.CONFLATED) + + 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 = 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() + val tapHoldArrivalsEnabled = mapPreferences.tapHoldArrivalsEnabledFlow.first() + val doubleTapStationEnabled = mapPreferences.doubleTapStationEnabledFlow.first() + // "Track Tapped Stops" -- now controlled from the Settings screen (see + // MapPreferences.trackTappedStopsEnabledFlow) rather than a toggle drawn on this + // canvas, so it's just another one-shot Settings read like the three above it. + nearbyVehiclesEnabled.value = mapPreferences.trackTappedStopsEnabledFlow.first() + // Reuses the exact same station-detection query every other screen does (search + // results, trip detail) -- non-null only if the selected stop is itself a real, + // qualifying multi-platform station. + val centerStation = repository.getStationContaining(stopId) + // When the selected stop is itself a station, [stopId] is only ONE of its member + // platforms (whichever one the caller happened to pass in) -- every platform has its + // own distinct stop_id and its own scheduled trips (e.g. South Station's Commuter + // Rail, Silver Line, and Red Line platforms are all different stop_ids), so vehicles + // for the OTHER platforms/modes would never be considered at all if only [stopId]'s + // own schedule were snapshotted here. Same union-across-member-platforms approach + // already used by Upcoming Arrivals and Map-Station mode. + val primaryStopIds = centerStation?.memberStopIds ?: listOf(stopId) + // Snapshot once: which trips are scheduled for this stop from now on (plus a grace + // window backward -- see SCHEDULED_ARRIVALS_GRACE_PERIOD_SECONDS). 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. + for (id in primaryStopIds) { + scheduledArrivalsByStopId[id] = repository.getScheduledArrivals( + id, currentGtfsTimeOfDay(), todayForGtfs(), SCHEDULED_ARRIVALS_GRACE_PERIOD_SECONDS, + ) + } + val streetContext = try { + geocoder.reverseGeocode(stop.lat, stop.lon) + } catch (e: Exception) { + Log.e("MapScreen", "Reverse geocoding failed for stop $stopId", e) + null + } + + // 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, + minBoundingBoxMiles = MIN_BOUNDING_BOX_MILES, + ) + + // 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, darkMode) + } 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, nearby.isStation, nearby.memberStopIds) + } + + loadedContext = LoadedMapContext( + stop, streetContext, zoom, mapTiles, nearbyStops, + tapHoldArrivalsEnabled, darkMode, doubleTapStationEnabled, centerStation, + ) + + while (isActive) { + refresh() + // Waits out the full interval unless refreshTrigger fires first (see + // [refreshNow]) -- either way, this is the only place refresh() is ever called + // from, so there's exactly one live-position fetch in flight at a time. + withTimeoutOrNull(LIVE_VEHICLE_POLL_INTERVAL_MS) { refreshTrigger.receive() } + } + } catch (e: Exception) { + Log.e("MapScreen", "Failed to load map for stop $stopId", e) + _state.value = MapState.Error("Unable to load bus positions.") + } + } + } + + override fun onScreenHide(screen: SimpleLightScreen) { + super.onScreenHide(screen) + pollJob?.cancel() + pollJob = null + } + + /** 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() + } + + /** Wakes the single poll loop in [onScreenShow] early rather than running its own separate + * [refresh] -- see [refreshTrigger]. */ + private fun refreshNow() { + refreshTrigger.trySend(Unit) + } + + private suspend fun refresh() { + val context = loadedContext ?: return + val today = todayForGtfs() + val nowEpochSeconds = System.currentTimeMillis() / 1000 + + // Active stops: always the primary one -- every member platform when it's a station (see + // primaryStopIds in onScreenShow, same reasoning), not just the single id the screen was + // opened with -- 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 { + addAll(context.centerStation?.memberStopIds ?: listOf(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, SCHEDULED_ARRIVALS_GRACE_PERIOD_SECONDS, + ) + } + } + + 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, + context.tapHoldArrivalsEnabled, context.darkMapEnabled, context.doubleTapStationEnabled, context.centerStation, + ) + 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, + context.tapHoldArrivalsEnabled, context.darkMapEnabled, context.doubleTapStationEnabled, context.centerStation, + ) + return + } + + val tripUpdatesFeed = agency.realtimeTripUpdatesUrl?.let { url -> + try { + GtfsRealtimeClient.fetchFeed(url) + } catch (e: Exception) { + Log.e("MapScreen", "TripUpdates fetch failed for ${agency.displayName}", e) + 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. + // Real coordinates for each active stop, for the distance-tiebreaker sort below -- every + // primary/member platform shares the center stop's own single lat/lon (StopLocation doesn't + // carry per-platform coordinates for a grouped station), each nearby stop uses its own. + val stopLocationsById = buildMap { + (context.centerStation?.memberStopIds ?: listOf(stopId)).forEach { put(it, context.stop.lat to context.stop.lon) } + context.nearbyStops.forEach { put(it.stopId, it.lat to it.lon) } + } + + fun candidatesFor(activeStopId: String): List { + val scheduledArrivals = scheduledArrivalsByStopId[activeStopId] ?: return emptyList() + return scheduledArrivals.mapNotNull { arrival -> + val vehicle = vehiclePositionsFeed.vehiclePositionsByTripId[arrival.tripId] ?: return@mapNotNull null + val position = vehicle.position ?: 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. Takes priority over both departed checks below -- a vehicle can report + // STOPPED_AT here even after its predicted departure time has technically ticked + // past (real-world dwell time is naturally a bit fuzzy). + val currentSeq = vehicle.currentStopSequence + val isArrived = vehicle.currentStatus == GtfsRtVehicleStatus.STOPPED_AT && currentSeq == arrival.stopSequence + + val rtStopUpdate = tripUpdatesFeed?.tripUpdatesByTripId?.get(arrival.tripId) + ?.updateFor(activeStopId, arrival.stopSequence) + val eta = computeArrivalEta(arrival.departureTime, today, rtStopUpdate) ?: return@mapNotNull null + + // Departed: either its own GPS-based progress has moved past this stop, or (a + // fallback for when current_stop_sequence is stale/missing) its predicted/actual + // departure time here has already passed. Either signal alone means it's no longer + // relevant to someone waiting here -- unless it's still literally AT this stop + // (isArrived above), which overrides both, since dwelling a little past a predicted + // departure time doesn't mean it's actually gone. + val hasDeparted = !isArrived && ( + (currentSeq != null && currentSeq > arrival.stopSequence) || eta.etaEpochSeconds < nowEpochSeconds + ) + if (hasDeparted) return@mapNotNull null + + // Dwelling somewhere other than this stop (e.g. a layover/terminal) with a distant + // predicted arrival isn't meaningfully "coming soon" -- excluded so a stop with a + // thin real candidate pool doesn't get padded with static vehicles just because a + // display slot was open (see DWELLING_FAR_ETA_THRESHOLD_SECONDS). A vehicle that's + // actually moving, however far away, is untouched by this check -- and one already + // confirmed AT this stop above never reaches here regardless of its predicted time. + val isDwellingFar = vehicle.currentStatus == GtfsRtVehicleStatus.STOPPED_AT && !isArrived && + eta.etaEpochSeconds - nowEpochSeconds > DWELLING_FAR_ETA_THRESHOLD_SECONDS + if (isDwellingFar) return@mapNotNull null + + val lineType = LineType.forGtfsRouteType(arrival.route.routeType) + BusMarker( + tripId = arrival.tripId, + targetStopId = activeStopId, + routeLabel = arrival.route.shortName?.takeIf { it.isNotBlank() } ?: arrival.route.displayName, + directionLabel = arrival.direction.displayLabel(), + vehicleIcon = lineType.toVehicleIcon(), + etaEpochSeconds = eta.etaEpochSeconds, + status = eta.status, + isArrived = isArrived, + lat = position.latitude.toDouble(), + lon = position.longitude.toDouble(), + ) + } + } + + // Soonest predicted time first, rounded to the nearest minute so genuinely-close + // predictions (or ones sharing a coarse schedule-only timestamp, i.e. stale/missing live + // data) tie and fall through to the distance tiebreaker instead of an arbitrary few-second + // gap always winning outright. + val busComparator = compareBy { Math.round(it.etaEpochSeconds / 60.0) } + .thenBy { bus -> + val (stopLat, stopLon) = stopLocationsById[bus.targetStopId] ?: return@thenBy Double.MAX_VALUE + haversineMeters(stopLat, stopLon, bus.lat, bus.lon) + } + + // The primary stop -- its own single id, or every platform of a station when centered on + // one (see primaryStopIds) -- shares ONE display allotment across all its platforms, since + // they're conceptually the same place; a big hub with a dozen platforms otherwise ends up + // with a dozen platforms' worth of markers just from being centered there. Each separately + // expanded NEARBY stop still gets its own independent allotment -- that's the existing, + // deliberate "select more stops to show more buses" behavior MAX_DISPLAYED_BUSES_PER_STOP's + // own doc comment describes for Nearby Vehicles. + val primaryStopIds = context.centerStation?.memberStopIds?.toSet() ?: setOf(stopId) + val primaryBuses = activeStopIds.filter { it in primaryStopIds } + .flatMap { candidatesFor(it) } + .sortedWith(busComparator) + .take(MAX_DISPLAYED_BUSES_PER_STOP) + val nearbyBuses = activeStopIds.filter { it !in primaryStopIds } + .flatMap { activeStopId -> candidatesFor(activeStopId).sortedWith(busComparator).take(MAX_DISPLAYED_BUSES_PER_STOP) } + val buses = primaryBuses + nearbyBuses + // 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 = MapState.Loaded( + context.streetContext, context.stop.lat, context.stop.lon, context.zoom, context.mapTiles, + dedupedBuses, context.nearbyStops, LiveFeedStatus.OK, + context.tapHoldArrivalsEnabled, context.darkMapEnabled, context.doubleTapStationEnabled, context.centerStation, + ) + } + + override fun onCleared() { + super.onCleared() + repository.close() + geocoder.close() + tileClient.close() + } +} + +class MapScreen( + 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() = MapViewModel::class.java + + override fun createViewModel(): MapViewModel = + MapViewModel(dbFile, agency, stopId, MapPreferences(lightContext.dataStore)) + + @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( + modifier = Modifier + .fillMaxSize() + .background(LightThemeTokens.colors.background) + ) { + // 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() }), + 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)) { + when (val s = state) { + is MapState.Loading -> LightText( + text = "Loading...", + variant = LightTextVariant.Copy, + lighten = true, + modifier = Modifier.padding(16.dp), + ) + + is MapState.Error -> LightText( + text = s.message, + variant = LightTextVariant.Copy, + lighten = true, + modifier = Modifier.padding(16.dp), + ) + + 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 = it, + variant = LightTextVariant.Detail, + lighten = true, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + ) + } + 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, + tapHoldArrivalsEnabled = s.tapHoldArrivalsEnabled, + darkMapEnabled = s.darkMapEnabled, + onToggleStop = viewModel::toggleStopExpanded, + onStopLongPressed = { longPressStopId, longPressStopLabel -> + navigateTo(screenFactory = { activity -> + UpcomingArrivalsScreen(activity, dbFile, agency, listOf(longPressStopId), longPressStopLabel) + }) + }, + doubleTapStationEnabled = s.doubleTapStationEnabled, + centerIsStation = s.centerStation != null, + centerStationMemberIds = s.centerStation?.memberStopIds ?: emptyList(), + onOpenStation = { memberStopIds, stationName -> + navigateTo(screenFactory = { activity -> + MapStationScreen(activity, dbFile, agency, memberStopIds, stationName) + }) + }, + modifier = Modifier.fillMaxSize(), + ) + } + } + } + BackToHomeFooter(onGoBackOnce = { goBack() }) + } + } + } +} + +/** + * 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 + * 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. + */ +@Composable +internal fun MapCanvas( + stopId: String, + stopLabel: String, + streetContext: String?, + centerLat: Double, + centerLon: Double, + zoom: Int, + mapTiles: MapTiles?, + buses: List, + 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, + /** 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, + /** Settings screen's "Double-tap to open a station" toggle -- gates whether double-tapping a + * station marker (center or nearby) opens Map-Station mode at all. */ + doubleTapStationEnabled: Boolean = false, + /** Whether the CENTER stop itself qualifies as a station -- see [StopLocation.isStation]. Nearby + * stations are already carried on each [NearbyStopMarker] directly. */ + centerIsStation: Boolean = false, + centerStationMemberIds: List = emptyList(), + /** Fires when a station marker (center or nearby) is double-tapped with [doubleTapStationEnabled] + * on -- the caller navigates to Map-Station mode with the tapped station's platform ids/name. */ + onOpenStation: (memberStopIds: List, stationName: String) -> Unit = { _, _ -> }, + /** False only for Map-Station mode itself, which has no single "center" stop -- every platform + * renders as an equal nearby-style pin instead (see MapStationScreen). */ + showCenterPin: Boolean = true, + /** Non-null only in Map-Station mode: the station's own name, drawn in a taller scrim bar in + * place of the plain attribution-only bar, making it visually clear you're zoomed into a + * station rather than the main map. */ + scrimTitle: String? = null, + /** Fires on a long press of [scrimTitle] when [tapHoldArrivalsEnabled] is on -- opens Upcoming + * Arrivals for the WHOLE station (all platforms), distinct from long-pressing a single platform + * pin (see [onStopLongPressed]). */ + onScrimTitleLongPressed: (() -> Unit)? = null, + 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) + + Canvas( + modifier = modifier + .clipToBounds() + .pointerInput( + nearbyStops, centerLat, centerLon, zoom, tapHoldArrivalsEnabled, + doubleTapStationEnabled, centerIsStation, showCenterPin, scrimTitle, + ) { + 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 scrimHeightPx = if (scrimTitle != null) STATION_SCRIM_HEIGHT_PX else SCRIM_HEIGHT_PX + + 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 + } + // Map-Station mode has no center pin at all -- gated so a stray tap near the + // canvas's geometric center can never spuriously register as "hit" there. + val hitCenter = showCenterPin && run { + val dx = down.position.x - center.x + val dy = down.position.y - center.y + sqrt(dx * dx + dy * dy) < STOP_HIT_RADIUS_PX + } + // The station name drawn in Map-Station mode's taller scrim bar -- tap-and-hold + // it for the whole station's arrivals (see onScrimTitleLongPressed). + val hitScrimTitle = scrimTitle != null && down.position.y < scrimHeightPx + + if (hitStop == null && !hitCenter && !hitScrimTitle) { + // 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() + + // Resolved once so both the tap-hold-enabled and tap-hold-disabled paths below + // (whichever ends up handling a completed double-tap) agree on exactly what + // "the station under this touch" means -- a nearby marker that's itself a + // station, or the center stop when it's a station (Map-Station mode itself never + // has stationed hits here, since showCenterPin is false and none of its platform + // markers are themselves stations). + val tappedStationMemberIds: List? = when { + hitStop != null && hitStop.isStation -> hitStop.memberStopIds + hitCenter && centerIsStation -> centerStationMemberIds + else -> null + } + val tappedStationLabel = hitStop?.takeIf { it.isStation }?.stopName ?: stopLabel + + // 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. The same toggle also gates + // tap-hold on the Map-Station scrim title. + if (tapHoldArrivalsEnabled && (hitStop != null || hitCenter || hitScrimTitle)) { + val shortTapUp = withTimeoutOrNull(viewConfiguration.longPressTimeoutMillis) { waitForUpOrCancellation() } + if (shortTapUp == null) { + when { + hitScrimTitle -> onScrimTitleLongPressed?.invoke() + hitStop != null -> onStopLongPressed(hitStop.stopId, hitStop.stopName ?: "Stop ${hitStop.stopId}") + else -> onStopLongPressed(stopId, stopLabel) + } + } else { + shortTapUp.consume() + if (doubleTapStationEnabled && tappedStationMemberIds != null) { + val secondDown = awaitSecondTapDown(shortTapUp) + if (secondDown != null) { + secondDown.consume() + waitForUpOrCancellation()?.consume() + onOpenStation(tappedStationMemberIds, tappedStationLabel) + return@awaitEachGesture + } + } + hitStop?.let { onToggleStop(it.stopId) } + } + return@awaitEachGesture + } + + val up = waitForUpOrCancellation() + if (up != null) { + up.consume() + if (doubleTapStationEnabled && tappedStationMemberIds != null) { + val secondDown = awaitSecondTapDown(up) + if (secondDown != null) { + secondDown.consume() + waitForUpOrCancellation()?.consume() + onOpenStation(tappedStationMemberIds, tappedStationLabel) + return@awaitEachGesture + } + } + 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) + } + + // 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 = 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 + } + // 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.LEFT + isAntiAlias = true + color = android.graphics.Color.WHITE + } + val nearbyStopLabelOutlinePaint = Paint(nearbyStopLabelPaint).apply { + style = Paint.Style.STROKE + strokeWidth = 5f + color = android.graphics.Color.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 scrimHeightPx = if (scrimTitle != null) STATION_SCRIM_HEIGHT_PX else SCRIM_HEIGHT_PX + val scrimPaint = Paint().apply { + color = android.graphics.Color.BLACK + } + nativeCanvas.drawRect(0f, 0f, size.width, scrimHeightPx, scrimPaint) + val overlayAttributionPaint = Paint().apply { + textSize = 16f + textAlign = Paint.Align.LEFT + isAntiAlias = true + color = android.graphics.Color.WHITE + alpha = 160 + } + if (scrimTitle != null) { + // Map-Station mode: the station's own name takes the scrim's primary line, making it + // clear you're zoomed into a station rather than the main map -- tap-and-hold it (see + // onScrimTitleLongPressed) for the whole station's arrivals. Attribution moves down to a + // second line rather than being dropped, since it's still required either way. + val titlePaint = Paint().apply { + textSize = STATION_TITLE_TEXT_SIZE_PX + textAlign = Paint.Align.LEFT + isAntiAlias = true + color = android.graphics.Color.WHITE + } + nativeCanvas.drawText(scrimTitle, OVERLAY_INSET_X, STATION_TITLE_Y, titlePaint) + nativeCanvas.drawText("Β© OpenStreetMap contributors Β© CARTO", OVERLAY_INSET_X, STATION_ATTRIBUTION_Y, overlayAttributionPaint) + } else { + 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 = scrimHeightPx + COMPASS_SCRIM_MARGIN_PX + 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 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 Settings screen's "Track Tapped Stops" toggle 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 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 + } + 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, + 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 } + 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. Skipped entirely in + // Map-Station mode (showCenterPin = false), where every platform renders as an equal + // nearby-style pin instead and the station's own name lives in the scrim (see scrimTitle). + if (showCenterPin) { + 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 { + nativeCanvas.drawText(it, center.x, center.y + 92f, smallLabelOutlinePaint) + nativeCanvas.drawText(it, center.x, center.y + 92f, smallLabelPaint) + } + } + + // 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. + // When the center is itself a station, every member platform counts as "here" (e.g. a + // Commuter Rail vehicle stopped at South Station's own Commuter Rail platform), not just + // literal [stopId] (which is only one specific platform's id -- see centerStationMemberIds). + val primaryStopIds = if (centerIsStation) centerStationMemberIds else listOf(stopId) + val arrivedAtPrimary = buses.filter { it.isArrived && it.targetStopId in primaryStopIds } + val everythingElse = buses.filterNot { it.isArrived && it.targetStopId in primaryStopIds } + + 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, vehicleIconBitmapFor(bus), vehicleLabelPaint, vehicleLabelOutlinePaint, vehicleSmallLabelPaint, vehicleSmallLabelOutlinePaint, bus, x, y, size.width) + } + + // 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, vehicleIconBitmapFor(bus), vehicleLabelPaint, vehicleLabelOutlinePaint, vehicleSmallLabelPaint, vehicleSmallLabelOutlinePaint, bus, clipped.x, clipped.y, size.width) + } + } +} + +/** + * Waits for a second tap-down following [firstUp], within the platform's own double-tap window -- + * mirrors Compose Foundation's own [androidx.compose.foundation.gestures.detectTapGestures] double-tap + * detection (read directly from its source, since this hand-written gesture loop can't just delegate + * to that helper -- see MapCanvas's own doc comment on why). Returns null if no second down arrives in + * time, meaning the original tap should be treated as a single tap instead. + */ +private suspend fun AwaitPointerEventScope.awaitSecondTapDown(firstUp: PointerInputChange): PointerInputChange? = + withTimeoutOrNull(viewConfiguration.doubleTapTimeoutMillis) { + var secondDown: PointerInputChange + do { + secondDown = awaitFirstDown() + } while (secondDown.uptimeMillis < firstUp.uptimeMillis + viewConfiguration.doubleTapMinTimeMillis) + secondDown + } + +/** 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) +} + +/** 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 + * 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 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 = 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 + 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 + } + } + } + placed += PlacedBox(left, right, y - LABEL_LINE_HEIGHT_PX, y) + result[label.key] = y + } + return result +} + +private fun drawBusMarker( + canvas: android.graphics.Canvas, + vehicleIconBitmap: Bitmap, + labelPaint: Paint, + labelOutlinePaint: Paint, + smallLabelPaint: Paint, + smallLabelOutlinePaint: Paint, + bus: BusMarker, + x: Float, + y: Float, + canvasWidth: Float, +) { + 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/MapStationScreen.kt b/tool/src/main/kotlin/com/thelightphone/transit/MapStationScreen.kt new file mode 100644 index 00000000..7053c47f --- /dev/null +++ b/tool/src/main/kotlin/com/thelightphone/transit/MapStationScreen.kt @@ -0,0 +1,271 @@ +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.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.GtfsRepository +import com.thelightphone.transit.gtfs.MapPreferences +import com.thelightphone.transit.gtfs.MapTileClient +import com.thelightphone.transit.gtfs.MapTiles +import com.thelightphone.transit.gtfs.fitBoundsZoom +import com.thelightphone.transit.gtfs.metersPerPixel +import com.thelightphone.transit.gtfs.platformLabelFromStopDesc +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 kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import java.io.File + +// A station's real platforms can be literally co-located (verified against real MBTA South Station +// data -- every platform shares identical lat/lon), so this floor is far smaller than the main Map +// screen's MIN_BOUNDING_BOX_MILES; otherwise every station would render at the same fixed zoom +// regardless of how spread out (or not) its platforms actually are. +private const val STATION_MIN_BOUNDING_BOX_MILES = 0.02 +// Deliberately larger than the main Map screen's own MAP_TARGET_RADIUS_PIXELS (420f): that value +// reserves margin for a center pin/label/street-context block this screen never draws (see +// showCenterPin = false), and the main map's fit also has to leave room to breathe around a whole +// neighborhood of *unrelated* nearby stops -- here every point being fit is itself a platform the +// rider actually cares about, so it's fine (in fact preferable, per explicit product feedback) to +// fill more of the frame with them. Pushed close to a real device's own half-width (see MapScreen's +// own comment on the LP3's 1080x1240px canvas) while still leaving room for the compass letters. +private const val STATION_ZOOM_TARGET_RADIUS_PIXELS = 500f +private const val STATION_MIN_ZOOM = 17 +// Same ceiling as the main Map screen's own MAX_ZOOM -- the CARTO tile server this app fetches from +// (see MapTileClient) isn't verified to serve anything past this, so this stays the hard cap even +// though the fit-to-bounds formula above will now usually reach it for a real station's platform +// cluster (unlike before, where the main map's own smaller target radius rarely got close). +private const val STATION_MAX_ZOOM = 20 +private const val STATION_FALLBACK_ZOOM = 20 + +sealed class MapStationState { + object Loading : MapStationState() + data class Loaded( + val centerLat: Double, + val centerLon: Double, + val zoom: Int, + val mapTiles: MapTiles?, + val platforms: List, + val tapHoldArrivalsEnabled: Boolean, + val darkMapEnabled: Boolean, + ) : MapStationState() + data class Error(val message: String) : MapStationState() +} + +class MapStationViewModel( + dbFile: File, + private val memberStopIds: List, + private val mapPreferences: MapPreferences, +) : LightViewModel() { + + private val repository = GtfsRepository(dbFile) + private val tileClient = MapTileClient() + + private val _state = MutableStateFlow(MapStationState.Loading) + val state: StateFlow = _state + + /** Mirrors MapViewModel's own expanded-label state -- a tap on a platform reveals/hides its own + * name label, same as a nearby-stop marker on the main Map screen. No vehicle-tracking side + * effect here, since Map-Station mode never shows live vehicles of its own (this screen never + * passes a `nearbyVehiclesEnabled = true` to MapCanvas). */ + val expandedStopIds = MutableStateFlow>(emptySet()) + + private var loadJob: Job? = null + + override fun onScreenShow(screen: SimpleLightScreen) { + super.onScreenShow(screen) + loadJob?.cancel() + loadJob = viewModelScope.launch(Dispatchers.IO) { + try { + val platforms = memberStopIds.mapNotNull { repository.getStopLocation(it) } + if (platforms.isEmpty()) { + _state.value = MapStationState.Error("Station platforms not found.") + return@launch + } + val centerLat = platforms.map { it.lat }.average() + val centerLon = platforms.map { it.lon }.average() + val darkMode = mapPreferences.darkMapEnabledFlow.first() + val tapHoldArrivalsEnabled = mapPreferences.tapHoldArrivalsEnabledFlow.first() + + val zoom = fitBoundsZoom( + centerLat = centerLat, + centerLon = centerLon, + points = platforms.map { it.lat to it.lon }, + availableHalfExtentPx = STATION_ZOOM_TARGET_RADIUS_PIXELS, + minZoom = STATION_MIN_ZOOM, + maxZoom = STATION_MAX_ZOOM, + fallbackZoom = STATION_FALLBACK_ZOOM, + minBoundingBoxMiles = STATION_MIN_BOUNDING_BOX_MILES, + ) + val fetchRadiusMeters = STATION_ZOOM_TARGET_RADIUS_PIXELS * metersPerPixel(centerLat, zoom) + val mapTiles = try { + tileClient.fetchTilesAround(centerLat, centerLon, zoom, fetchRadiusMeters, darkMode) + } catch (e: Exception) { + Log.e("MapStationScreen", "Map tile fetch failed for platforms $memberStopIds", e) + null + } + + // Each platform's own label (e.g. "Track 1"), not prefixed with the station name -- + // same real-South-Station-verified technique Upcoming Arrivals already uses for + // platform disambiguation (see GtfsRepository.platformLabelFromStopDesc). Falls back + // to the platform's own stop_name when stop_desc has nothing more specific. + val descriptions = repository.getStopDescriptions(memberStopIds) + val platformMarkers = platforms.map { platform -> + val label = platformLabelFromStopDesc(descriptions[platform.stopId]) ?: platform.stopName + NearbyStopMarker( + stopId = platform.stopId, + stopName = label, + lat = platform.lat, + lon = platform.lon, + isStation = false, + memberStopIds = listOf(platform.stopId), + ) + } + + _state.value = MapStationState.Loaded( + centerLat, centerLon, zoom, mapTiles, platformMarkers, tapHoldArrivalsEnabled, darkMode, + ) + } catch (e: Exception) { + Log.e("MapStationScreen", "Failed to load station map for platforms $memberStopIds", e) + _state.value = MapStationState.Error("Unable to load station map.") + } + } + } + + override fun onScreenHide(screen: SimpleLightScreen) { + super.onScreenHide(screen) + loadJob?.cancel() + loadJob = null + } + + fun toggleStopExpanded(stopId: String) { + expandedStopIds.value = expandedStopIds.value.let { if (stopId in it) it - stopId else it + stopId } + } + + override fun onCleared() { + super.onCleared() + repository.close() + tileClient.close() + } +} + +/** + * A zoomed-in sub-map of just one multi-platform station's own platforms ("Map-Station mode"), + * reached by double-tapping a station marker on the main Map screen (see MapScreen's + * doubleTapStationEnabled/onOpenStation). Reuses the exact same [MapCanvas] the main Map screen + * draws with -- same tiles/pin style/tap-to-reveal-label behavior -- just with no privileged center + * pin (every platform is an equal small pin), the station's name in the scrim instead, and no live + * vehicle tracking of its own. + */ +class MapStationScreen( + sealedActivity: SealedLightActivity, + private val dbFile: File, + private val agency: GtfsAgency, + private val memberStopIds: List, + private val stationLabel: String, +) : LightScreen(sealedActivity) { + + override val viewModelClass: Class + get() = MapStationViewModel::class.java + + override fun createViewModel(): MapStationViewModel = + MapStationViewModel(dbFile, memberStopIds, MapPreferences(lightContext.dataStore)) + + @Composable + override fun Content() { + val state by viewModel.state.collectAsState() + val expandedStopIds by viewModel.expandedStopIds.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() }), + 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)) { + when (val s = state) { + is MapStationState.Loading -> LightText( + text = "Loading...", + variant = LightTextVariant.Copy, + lighten = true, + modifier = Modifier.padding(16.dp), + ) + + is MapStationState.Error -> LightText( + text = s.message, + variant = LightTextVariant.Copy, + lighten = true, + modifier = Modifier.padding(16.dp), + ) + + is MapStationState.Loaded -> MapCanvas( + // Inert placeholders: hitCenter can never fire (see MapCanvas's own gating on + // showCenterPin), so these are never actually read for a hit-test. + stopId = memberStopIds.firstOrNull() ?: "", + stopLabel = stationLabel, + streetContext = null, + centerLat = s.centerLat, + centerLon = s.centerLon, + zoom = s.zoom, + mapTiles = s.mapTiles, + buses = emptyList(), + nearbyStops = s.platforms, + expandedStopIds = expandedStopIds, + nearbyVehiclesEnabled = false, + tapHoldArrivalsEnabled = s.tapHoldArrivalsEnabled, + darkMapEnabled = s.darkMapEnabled, + onToggleStop = viewModel::toggleStopExpanded, + onStopLongPressed = { platformStopId, platformLabel -> + navigateTo(screenFactory = { activity -> + UpcomingArrivalsScreen(activity, dbFile, agency, listOf(platformStopId), platformLabel) + }) + }, + doubleTapStationEnabled = false, + centerIsStation = false, + centerStationMemberIds = emptyList(), + onOpenStation = { _, _ -> }, + showCenterPin = false, + scrimTitle = stationLabel, + onScrimTitleLongPressed = { + navigateTo(screenFactory = { activity -> + UpcomingArrivalsScreen(activity, dbFile, agency, memberStopIds, stationLabel) + }) + }, + modifier = Modifier.fillMaxSize(), + ) + } + } + BackToHomeFooter(onGoBackOnce = { goBack() }) + } + } + } +} 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..74ddd8b9 --- /dev/null +++ b/tool/src/main/kotlin/com/thelightphone/transit/NearbyStopsScreen.kt @@ -0,0 +1,348 @@ +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.LightBarButton +import com.thelightphone.sdk.ui.LightIcon +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.LightTopBar +import com.thelightphone.sdk.ui.LightTopBarCenter +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) + // Only the very first time this screen becomes visible -- onScreenShow fires again on + // returning here from a child screen (e.g. backing out of Upcoming Arrivals), and without + // this guard that re-ran IP geolocation and reset straight back to the search prompt, + // discarding whatever search results/stop list was already on screen. + if (_mode.value !is NearbyStopsMode.Locating) return + 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) + ) { + LightTopBar( + leftButton = LightBarButton.LightIcon(icon = LightIcons.BACK, onClick = { goBack() }), + center = LightTopBarCenter.Text("Nearby Stops"), + 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)) { + 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), + ) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.lightClickable { viewModel.backToInput() }, + ) { + LightIcon( + icon = LightIcons.SEARCH, + size = 1f, + modifier = Modifier.padding(end = 8.dp), + ) + LightText(text = "Search Again", variant = LightTextVariant.Copy) + } + } + + is NearbyStopsMode.GeocodeResults -> { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .lightClickable { viewModel.backToInput() } + .padding(bottom = 16.dp), + ) { + LightIcon( + icon = LightIcons.SEARCH, + size = 1f, + modifier = Modifier.padding(end = 8.dp), + ) + LightText(text = "Search Again", variant = LightTextVariant.Copy, lighten = true) + } + 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 -> { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .lightClickable { viewModel.backToInput() } + .padding(bottom = 16.dp), + ) { + LightIcon( + icon = LightIcons.SEARCH, + size = 1f, + modifier = Modifier.padding(end = 8.dp), + ) + LightText(text = "Search Again", variant = LightTextVariant.Copy, lighten = true) + } + 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 -> + // The full member list, not just one child + // platform -- a deduplicated station (see + // GtfsRepository.groupStationsByParent) can + // have several platforms each serving + // different lines (e.g. South Station), and + // arrivals need to be unioned across all of + // them, not just whichever one happened to be + // the dedup representative. + UpcomingArrivalsScreen( + activity, + dbFile, + agency, + stop.memberStopIds, + stop.displayLabel(), + ) + }) + } + .padding(vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + // Weighted so a long stop name wraps within its own + // share of the row instead of first greedily measuring + // against the row's *full* width (Compose's default for + // an unweighted Text) and only then discovering there's + // no room left for the distance label -- that's what was + // pushing distanceLabel() off the right edge. + Row( + modifier = Modifier.weight(1f), + verticalAlignment = Alignment.CenterVertically, + ) { + LightText( + text = stop.displayLabel(), + variant = LightTextVariant.Copy, + ) + if (stop.isStation) { + LightIcon( + icon = LightIcons.DIRECTIONS_MIDDLE_FORK, + size = 1.2f, + contentDescription = "Transfer station", + modifier = Modifier.padding(start = 8.dp), + ) + } + } + LightText( + text = stop.distanceLabel(), + variant = LightTextVariant.Copy, + lighten = true, + modifier = Modifier.padding(start = 16.dp), + ) + } + } + } + } + LightText( + text = "Location search Β© OpenStreetMap contributors", + variant = LightTextVariant.Detail, + lighten = true, + modifier = Modifier.padding(top = 16.dp), + ) + } + + is NearbyStopsMode.LocationInput -> Unit + } + } + BackToHomeFooter(onGoBackOnce = { goBack() }) + } + } + } + } +} diff --git a/tool/src/main/kotlin/com/thelightphone/transit/ReachedStopFeature.kt b/tool/src/main/kotlin/com/thelightphone/transit/ReachedStopFeature.kt new file mode 100644 index 00000000..3aaa40a2 --- /dev/null +++ b/tool/src/main/kotlin/com/thelightphone/transit/ReachedStopFeature.kt @@ -0,0 +1,77 @@ +package com.thelightphone.transit + +import androidx.compose.runtime.Composable +import com.thelightphone.transit.gtfs.BoardedTrip +import com.thelightphone.transit.gtfs.BoardedTripPreferences +import com.thelightphone.transit.gtfs.TripStopRow +import com.thelightphone.sdk.ui.LightFullscreenModal +import com.thelightphone.sdk.ui.LightModal +import com.thelightphone.sdk.ui.LightModalManager +import kotlin.time.Duration.Companion.seconds +import kotlinx.coroutines.CompletableDeferred + +// Long enough that a rider glancing at their phone has time to notice and read it, short enough +// that it doesn't sit forever if they don't tap Close -- either way, the caller navigates once +// it's gone (see TripDetailScreen/HomeScreen's own reachedAlightStop handling). +val REACHED_STOP_MODAL_DURATION = 15.seconds + +/** The dismissable "you've arrived" celebration -- reuses the SDK's own full-screen modal template + * rather than custom UI. Manual close (tap) and auto-expiry ([REACHED_STOP_MODAL_DURATION]) both + * end up calling [onDismissed] exactly once -- LightModalManager only invokes [onExpired] when + * [awaitDismiss] *didn't* already win the race, so there's no risk of double-firing the navigation + * it triggers. */ +class ReachedStopModal( + stopName: String, + private val onDismissed: () -> Unit, +) : LightModal { + private val message = "You've reached your stop! πŸŽ‰\n$stopName" + private val dismissSignal = CompletableDeferred() + + @Composable + override fun Content() { + LightFullscreenModal(message = message, onClose = { dismiss() }) + } + + override val onExpired: () -> Unit = { onDismissed() } + + override fun dismiss() { + if (dismissSignal.complete(Unit)) onDismissed() + } + + override suspend fun awaitDismiss() { + dismissSignal.await() + } +} + +/** + * Reached (or passed) [boardedTrip]'s designated alight stop -- ends boarded status immediately + * (matches "the stop they just alighted at" from the feature spec, and self-guards against + * re-triggering the modal on a later poll, since the boarded trip will no longer match once this + * runs) and shows the celebration via [LightModalManager], which draws on top of whatever screen + * is current. Shared between [com.thelightphone.transit.TripDetailViewModel] (only checks while + * showing THIS trip's own detail screen) and [com.thelightphone.transit.HomeScreenViewModel] + * (checks regardless of which trip's detail screen, if any, is open) -- either way, this only ever + * runs while ONE of those two screens is actually visible and polling, matching + * [BoardedTripPreferences]'s own doc comment on why there's no background equivalent. + */ +suspend fun checkReachedAlightStop( + boardedTrip: BoardedTrip, + stops: List, + liveStopSequence: Int?, + boardedTripPreferences: BoardedTripPreferences, + onReached: (TripStopRow) -> Unit, +) { + if (liveStopSequence == null) return + val alightStopId = boardedTrip.alightStopId ?: return + val alightStop = stops.find { it.stopId == alightStopId } ?: return + if (liveStopSequence < alightStop.stopSequence) return + + boardedTripPreferences.alight() + LightModalManager.show( + modal = ReachedStopModal( + stopName = alightStop.stopName ?: "your stop", + onDismissed = { onReached(alightStop) }, + ), + duration = REACHED_STOP_MODAL_DURATION, + ) +} 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..9c3d8359 --- /dev/null +++ b/tool/src/main/kotlin/com/thelightphone/transit/RouteSelectionScreen.kt @@ -0,0 +1,156 @@ +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.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 +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) + ) { + LightTopBar( + leftButton = LightBarButton.LightIcon(icon = LightIcons.BACK, onClick = { goBack() }), + center = LightTopBarCenter.Text("Choose Route"), + 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( + 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), + ) + } + } + } + } + } + BackToHomeFooter(onGoBackOnce = { goBack() }) + } + } + } +} 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..4ecad279 --- /dev/null +++ b/tool/src/main/kotlin/com/thelightphone/transit/SettingsScreen.kt @@ -0,0 +1,304 @@ +package com.thelightphone.transit + +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.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.BoardedTripPreferences +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.LightIcon +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 +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 agencyPreferences: AgencyPreferences, + private val mapPreferences: MapPreferences, + private val boardedTripPreferences: BoardedTripPreferences, +) : LightViewModel() { + + val defaultAgency: StateFlow + get() = _defaultAgency + private val _defaultAgency = MutableStateFlow(null) + + val darkMapEnabled: StateFlow + get() = _darkMapEnabled + private val _darkMapEnabled = MutableStateFlow(false) + + val tapHoldArrivalsEnabled: StateFlow + get() = _tapHoldArrivalsEnabled + private val _tapHoldArrivalsEnabled = MutableStateFlow(false) + + val doubleTapStationEnabled: StateFlow + get() = _doubleTapStationEnabled + private val _doubleTapStationEnabled = MutableStateFlow(false) + + val trackTappedStopsEnabled: StateFlow + get() = _trackTappedStopsEnabled + private val _trackTappedStopsEnabled = MutableStateFlow(false) + + val progressBarVisible: StateFlow + get() = _progressBarVisible + private val _progressBarVisible = MutableStateFlow(true) + + init { + viewModelScope.launch { + agencyPreferences.defaultAgencyFlow.collect { _defaultAgency.value = it } + } + viewModelScope.launch { + mapPreferences.darkMapEnabledFlow.collect { _darkMapEnabled.value = it } + } + viewModelScope.launch { + mapPreferences.tapHoldArrivalsEnabledFlow.collect { _tapHoldArrivalsEnabled.value = it } + } + viewModelScope.launch { + mapPreferences.doubleTapStationEnabledFlow.collect { _doubleTapStationEnabled.value = it } + } + viewModelScope.launch { + mapPreferences.trackTappedStopsEnabledFlow.collect { _trackTappedStopsEnabled.value = it } + } + viewModelScope.launch { + boardedTripPreferences.progressBarVisibleFlow.collect { _progressBarVisible.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 { + agencyPreferences.setDefaultAgency(if (defaultAgency.value == agency) null else agency) + } + } + + fun setDarkMapEnabled(enabled: Boolean) { + viewModelScope.launch { mapPreferences.setDarkMapEnabled(enabled) } + } + + fun setTapHoldArrivalsEnabled(enabled: Boolean) { + viewModelScope.launch { mapPreferences.setTapHoldArrivalsEnabled(enabled) } + } + + fun setDoubleTapStationEnabled(enabled: Boolean) { + viewModelScope.launch { mapPreferences.setDoubleTapStationEnabled(enabled) } + } + + fun setTrackTappedStopsEnabled(enabled: Boolean) { + viewModelScope.launch { mapPreferences.setTrackTappedStopsEnabled(enabled) } + } + + fun setProgressBarVisible(visible: Boolean) { + viewModelScope.launch { boardedTripPreferences.setProgressBarVisible(visible) } + } +} + +class SettingsScreen( + sealedActivity: SealedLightActivity, +) : LightScreen(sealedActivity) { + + override val viewModelClass: Class + get() = SettingsViewModel::class.java + + override fun createViewModel(): SettingsViewModel = SettingsViewModel( + AgencyPreferences(lightContext.dataStore), + MapPreferences(lightContext.dataStore), + BoardedTripPreferences(lightContext.dataStore), + ) + + /** Every on/off setting on this screen renders as one tappable row using the SDK's own + * toggle-state icon, replacing the old two-separate-rows "On"/"Off" list this screen used to + * use for [SettingsViewModel.tapHoldArrivalsEnabled]/[SettingsViewModel.doubleTapStationEnabled]. */ + @Composable + private fun ToggleRow(label: String, enabled: Boolean, onToggle: (Boolean) -> Unit) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .lightClickable { onToggle(!enabled) } + .padding(vertical = 12.dp), + ) { + LightIcon( + icon = if (enabled) LightIcons.TOGGLE_STATE_ON else LightIcons.TOGGLE_STATE_OFF, + size = 1.2f, + contentDescription = if (enabled) "On" else "Off", + modifier = Modifier.padding(end = 12.dp), + ) + LightText(text = label, variant = LightTextVariant.Copy) + } + } + + @Composable + override fun Content() { + val defaultAgency by viewModel.defaultAgency.collectAsState() + val darkMapEnabled by viewModel.darkMapEnabled.collectAsState() + val tapHoldArrivalsEnabled by viewModel.tapHoldArrivalsEnabled.collectAsState() + val doubleTapStationEnabled by viewModel.doubleTapStationEnabled.collectAsState() + val trackTappedStopsEnabled by viewModel.trackTappedStopsEnabled.collectAsState() + val progressBarVisible by viewModel.progressBarVisible.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"), + rightButton = currentTripTopBarButton(lightContext.dataStore, lightContext.filesDir) { dbFile, tripId, fromStopSequence, routeLabel, directionLabel -> + navigateTo(screenFactory = { activity -> TripDetailScreen(activity, dbFile, tripId, fromStopSequence, routeLabel, directionLabel) }) + }, + ) + LightScrollView(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 = agency.displayName, + variant = LightTextVariant.Copy, + lighten = agency != defaultAgency, + underline = agency == defaultAgency, + modifier = Modifier + .fillMaxWidth() + .lightClickable { viewModel.toggleDefaultAgency(agency) } + .padding(vertical = 12.dp), + ) + } + } + + 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 = "Light", + variant = LightTextVariant.Copy, + lighten = darkMapEnabled, + underline = !darkMapEnabled, + modifier = Modifier + .fillMaxWidth() + .lightClickable { viewModel.setDarkMapEnabled(false) } + .padding(vertical = 12.dp), + ) + LightText( + text = "Dark", + variant = LightTextVariant.Copy, + lighten = !darkMapEnabled, + underline = darkMapEnabled, + modifier = Modifier + .fillMaxWidth() + .lightClickable { viewModel.setDarkMapEnabled(true) } + .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), + ) + ToggleRow("Tap and hold a stop", tapHoldArrivalsEnabled, viewModel::setTapHoldArrivalsEnabled) + + LightText( + text = "Double-tap to open a station", + variant = LightTextVariant.Copy, + lighten = true, + modifier = Modifier.padding(top = 24.dp, bottom = 8.dp), + ) + LightText( + text = "When on, double-tap a multi-platform station on the Map screen to open a " + + "zoomed-in view of just its platforms.", + variant = LightTextVariant.Detail, + lighten = true, + modifier = Modifier.padding(bottom = 16.dp), + ) + ToggleRow("Double-tap to open a station", doubleTapStationEnabled, viewModel::setDoubleTapStationEnabled) + + LightText( + text = "Track tapped stops", + variant = LightTextVariant.Copy, + lighten = true, + modifier = Modifier.padding(top = 24.dp, bottom = 8.dp), + ) + LightText( + text = "When on, a nearby stop you've tapped open on the Map screen also contributes its " + + "own live vehicles to the map, not just its name label.", + variant = LightTextVariant.Detail, + lighten = true, + modifier = Modifier.padding(bottom = 16.dp), + ) + ToggleRow("Track tapped stops", trackTappedStopsEnabled, viewModel::setTrackTappedStopsEnabled) + + LightText( + text = "Trip progress bar", + variant = LightTextVariant.Copy, + lighten = true, + modifier = Modifier.padding(top = 24.dp, bottom = 8.dp), + ) + LightText( + text = "When on, HomeScreen shows a progress bar from your boarding stop to your alight " + + "stop while a trip is boarded.", + variant = LightTextVariant.Detail, + lighten = true, + modifier = Modifier.padding(bottom = 16.dp), + ) + ToggleRow("Trip progress bar", progressBarVisible, viewModel::setProgressBarVisible) + } + BackToHomeFooter(onGoBackOnce = { goBack() }) + } + } + } +} diff --git a/tool/src/main/kotlin/com/thelightphone/transit/StationListScreen.kt b/tool/src/main/kotlin/com/thelightphone/transit/StationListScreen.kt new file mode 100644 index 00000000..a23164ee --- /dev/null +++ b/tool/src/main/kotlin/com/thelightphone/transit/StationListScreen.kt @@ -0,0 +1,170 @@ +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.GtfsAgency +import com.thelightphone.transit.gtfs.GtfsRepository +import com.thelightphone.transit.gtfs.StopLocation +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.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.LightTopBar +import com.thelightphone.sdk.ui.LightTopBarCenter +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 + +fun StopLocation.displayLabel(): String = stopName?.takeIf { it.isNotBlank() } ?: "Station $stopId" + +sealed class StationListState { + object Loading : StationListState() + data class Loaded(val stations: List) : StationListState() + data class Error(val message: String) : StationListState() +} + +class StationListViewModel(dbFile: File) : LightViewModel() { + + private val repository = GtfsRepository(dbFile) + + private val _state = MutableStateFlow(StationListState.Loading) + val state: StateFlow = _state + + override fun onScreenShow(screen: SimpleLightScreen) { + super.onScreenShow(screen) + viewModelScope.launch(Dispatchers.IO) { + _state.value = try { + StationListState.Loaded(repository.getAllStations()) + } catch (e: Exception) { + Log.e("StationListScreen", "Failed to load stations", e) + StationListState.Error("Unable to load stations.") + } + } + } + + override fun onCleared() { + super.onCleared() + repository.close() + } +} + +/** + * HomeScreen's "Station" entry point: every real multi-platform station this agency has, listed + * directly (see GtfsRepository.getAllStations) -- unlike "Explore", this never asks the rider to + * search a location first, since a rider looking for a specific station already knows its name. + * Tapping one opens its Map-Station sub-map directly (see MapStationScreen). + */ +class StationListScreen( + sealedActivity: SealedLightActivity, + private val dbFile: File, + private val agency: GtfsAgency, +) : LightScreen(sealedActivity) { + + override val viewModelClass: Class + get() = StationListViewModel::class.java + + override fun createViewModel(): StationListViewModel = StationListViewModel(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) + ) { + LightTopBar( + leftButton = LightBarButton.LightIcon(icon = LightIcons.BACK, onClick = { goBack() }), + center = LightTopBarCenter.Text("Stations"), + 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)) { + when (val s = state) { + is StationListState.Loading -> LightText( + text = "Loading stations...", + variant = LightTextVariant.Copy, + lighten = true, + ) + + is StationListState.Error -> LightText( + text = s.message, + variant = LightTextVariant.Copy, + lighten = true, + ) + + is StationListState.Loaded -> if (s.stations.isEmpty()) { + LightText( + text = "No stations found.", + variant = LightTextVariant.Copy, + lighten = true, + ) + } else { + LazyColumn(modifier = Modifier.weight(1f)) { + items(s.stations) { station -> + Row( + modifier = Modifier + .fillMaxWidth() + .lightClickable { + navigateTo(screenFactory = { activity -> + MapStationScreen(activity, dbFile, agency, station.memberStopIds, station.displayLabel()) + }) + } + .padding(vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + // Weighted so a long station name wraps within its own bounded + // share of the row, leaving guaranteed room for the icon, instead + // of first greedily measuring against the row's full width -- see + // NearbyStopsScreen's identical fix for the same Compose behavior. + LightText( + text = station.displayLabel(), + variant = LightTextVariant.Copy, + modifier = Modifier.weight(1f, fill = false), + ) + LightIcon( + icon = LightIcons.DIRECTIONS_MIDDLE_FORK, + size = 1.2f, + contentDescription = "Transfer station", + modifier = Modifier.padding(start = 8.dp), + ) + } + } + } + } + } + } + BackToHomeFooter(onGoBackOnce = { goBack() }) + } + } + } +} 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..28b44f65 --- /dev/null +++ b/tool/src/main/kotlin/com/thelightphone/transit/StopConnectionsScreen.kt @@ -0,0 +1,286 @@ +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.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 +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 +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, + 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()}" + val base = lineLabel?.let { "$it - $routeAndDirection" } ?: routeAndDirection + return platformLabel?.let { "$base - $it" } ?: base +} + +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 + + /** Whether [stopId] is itself a real, qualifying multi-platform station -- see + * GtfsRepository.getStationContaining, the same single source of truth Upcoming Arrivals' own + * transfer icon uses. */ + val isStation = MutableStateFlow(false) + + override fun onScreenShow(screen: SimpleLightScreen) { + super.onScreenShow(screen) + viewModelScope.launch(Dispatchers.IO) { + // Inside the same try/catch as the rest of this block (not a separate unguarded call + // before it) so a screen popped mid-query -- e.g. several rapid-fire goBack() calls in + // a row, like BackToHomeFooter's "jump to Home" loop -- can't crash the app just + // because this repository got closed out from under an in-flight query on the way out. + _state.value = try { + // A trip only ever stops at one platform of a station, so stopId here is just that + // one platform -- resolve its full station (if any) so connections are listed + // across every platform actually serving it, not just the one this particular trip + // happened to use. Same pattern as UpcomingArrivalsViewModel/ + // GtfsRepository.getScheduledArrivals(stopIds). + val station = repository.getStationContaining(stopId) + isStation.value = station != null + val stopIds = station?.memberStopIds ?: listOf(stopId) + + val today = todayForGtfs() + val connections = repository.getNextConnections(stopIds, 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 = repository.getNextConnections(nearby.memberStopIds, 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.") + } + } + } + + 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 + 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() + val isStation by viewModel.isStation.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("Connections"), + 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)) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(bottom = 16.dp), + ) { + LightText( + text = "$stopLabel - After ${formatGtfsTime(afterTime)}", + variant = LightTextVariant.Detail, + lighten = true, + modifier = Modifier.weight(1f, fill = false), + ) + if (isStation) { + LightIcon( + icon = LightIcons.DIRECTIONS_MIDDLE_FORK, + size = 1.2f, + contentDescription = "Transfer station", + modifier = Modifier.padding(start = 8.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() && s.nearbyStops.isEmpty()) { + LightText( + text = "No more connections today.", + variant = LightTextVariant.Copy, + lighten = true, + ) + } else { + LazyColumn(modifier = Modifier.weight(1f)) { + items(s.connections) { connection -> + ConnectionRow(connection) + } + s.nearbyStops.forEach { nearby -> + item { + LightText( + 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) + } + } + } + } + } + } + BackToHomeFooter(onGoBackOnce = { goBack() }) + } + } + } +} 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..25c6ce58 --- /dev/null +++ b/tool/src/main/kotlin/com/thelightphone/transit/TripDetailScreen.kt @@ -0,0 +1,483 @@ +package com.thelightphone.transit + +import android.util.Log +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +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.height +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.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.input.pointer.pointerInput +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewModelScope +import com.thelightphone.transit.gtfs.ArrivalStatus +import com.thelightphone.transit.gtfs.BoardedTrip +import com.thelightphone.transit.gtfs.BoardedTripPreferences +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 +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 +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.gridUnitsAsDp +import com.thelightphone.sdk.ui.lightClickable +import java.io.File +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 + +// 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() + + /** + * [liveAtStopSequence] is non-null only when there's a 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] would each make it null). 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 + * live indicator at the stop the vehicle is closest to, including while it's between stops. This + * trip's own vehicle type (for that indicator's icon) is tracked separately -- see + * [TripDetailViewModel.lineType], since it's known as soon as the trip loads, independent of + * whether a live position happens to be available right now. + * + * [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 liveAtStopSequence: Int?, + val liveStatus: ArrivalStatus?, + /** Every stop_id along this trip that's part of a real, qualifying multi-platform station + * (see GtfsRepository.getMultiPlatformStationStopIds) -- shows the transfer icon next to + * that row, always, independent of the Map screen's double-tap-to-station setting. */ + val stationStopIds: Set, + ) : 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( + private val dbFile: File, + private val tripId: String, + private val fromStopSequence: Int, + private val routeLabel: String, + private val directionLabel: String, + private val boardedTripPreferences: BoardedTripPreferences, +) : 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 + + /** This trip's own vehicle mode -- set once as soon as the trip's route loads, independent of + * live-position availability (see [TripDetailState.Loaded]'s own doc comment). Used for both + * the live-row icon and the Board/Alight row's vehicle-type icon, and threaded into + * [BoardedTrip] so HomeScreen can show it too without its own repository lookup. */ + val lineType = MutableStateFlow(null) + + /** Collected for this ViewModel's whole lifetime (not just while visible) so Board/Alight/ + * alight-stop taps reflect immediately, and so the poll loop below can check it every cycle. */ + val boardedTrip = MutableStateFlow(null) + + /** One-shot signal: non-null exactly when the rider has just dismissed the "you've arrived" + * modal for this trip's designated alight stop -- Content() observes this to navigate to that + * stop's Upcoming Arrivals, then calls [clearReachedAlightStop] to consume it. */ + val reachedAlightStop = MutableStateFlow(null) + + private var pollJob: Job? = null + + init { + viewModelScope.launch { boardedTripPreferences.boardedTripFlow.collect { boardedTrip.value = it } } + } + + override fun onScreenShow(screen: SimpleLightScreen) { + super.onScreenShow(screen) + pollJob?.cancel() + pollJob = viewModelScope.launch(Dispatchers.IO) { + try { + val stops = repository.getTripStops(tripId, fromStopSequence) + lineType.value = repository.getRouteTypeForTrip(tripId)?.let { LineType.forGtfsRouteType(it) } + val stationStopIds = repository.getMultiPlatformStationStopIds() + val vehiclePositionsUrl = agency?.realtimeVehiclePositionsUrl + if (stops.isEmpty() || vehiclePositionsUrl == null) { + _state.value = TripDetailState.Loaded( + stops, liveAtStopSequence = null, liveStatus = null, stationStopIds = stationStopIds, + ) + return@launch + } + val tripUpdatesUrl = agency.realtimeTripUpdatesUrl + val today = todayForGtfs() + + 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 + } + + // 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, + liveAtStopSequence = liveStopSequence, + liveStatus = liveStatus, + stationStopIds = stationStopIds, + ) + + checkReachedAlightStop(stops, liveStopSequence) + + delay(LIVE_VEHICLE_POLL_INTERVAL_MS) + } + } catch (e: Exception) { + Log.e("TripDetailScreen", "Failed to load stop times for trip $tripId", e) + _state.value = TripDetailState.Error("Unable to load trip detail.") + } + } + } + + /** Only checks while THIS trip is the boarded one -- see the shared [checkReachedAlightStop] + * (used identically by HomeScreenViewModel) for the actual reached/alight/celebration logic. */ + private suspend fun checkReachedAlightStop(stops: List, liveStopSequence: Int?) { + val boarded = boardedTrip.value ?: return + if (boarded.tripId != tripId) return + checkReachedAlightStop(boarded, stops, liveStopSequence, boardedTripPreferences) { reachedAlightStop.value = it } + } + + fun clearReachedAlightStop() { + reachedAlightStop.value = null + } + + override fun onScreenHide(screen: SimpleLightScreen) { + super.onScreenHide(screen) + pollJob?.cancel() + pollJob = null + } + + fun board() { + val currentAgency = agency ?: return + viewModelScope.launch { + boardedTripPreferences.board(tripId, currentAgency, fromStopSequence, routeLabel, directionLabel, lineType.value) + } + } + + fun alight() { + viewModelScope.launch { boardedTripPreferences.alight() } + } + + /** Only meaningful while this screen's own trip is the boarded one. Tapping the already- + * designated stop clears it, same toggle convention as Settings' default-agency row. */ + fun toggleAlightStop(stopId: String) { + val boarded = boardedTrip.value ?: return + if (boarded.tripId != tripId) return + viewModelScope.launch { + boardedTripPreferences.setAlightStop(if (boarded.alightStopId == stopId) null else stopId) + } + } + + 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, routeLabel, directionLabel, BoardedTripPreferences(lightContext.dataStore), + ) + + @Composable + override fun Content() { + val state by viewModel.state.collectAsState() + val lineType by viewModel.lineType.collectAsState() + val boardedTrip by viewModel.boardedTrip.collectAsState() + val reachedAlightStop by viewModel.reachedAlightStop.collectAsState() + val themeColors by LightThemeController.colors.collectAsState() + + // Fires once the "you've arrived" modal has been dismissed (manually or by timeout) -- + // see ReachedStopModal. Navigates to that stop's own Upcoming Arrivals, matching "the stop + // they just alighted at" from the feature spec. + LaunchedEffect(reachedAlightStop) { + val stop = reachedAlightStop ?: return@LaunchedEffect + val agency = GtfsAgency.forDbFile(dbFile) + if (agency != null) { + navigateTo(screenFactory = { activity -> + UpcomingArrivalsScreen(activity, dbFile, agency, listOf(stop.stopId), stop.stopName ?: "Stop ${stop.stopId}") + }) + } + viewModel.clearReachedAlightStop() + } + + val isBoardedHere = boardedTrip?.tripId == tripId + val alightStopId = boardedTrip?.takeIf { it.tripId == tripId }?.alightStopId + val vehicleIcon = lineType.toVehicleIcon() + + LightTheme(colors = themeColors) { + Column( + modifier = Modifier + .fillMaxSize() + .background(LightThemeTokens.colors.background) + ) { + Box { + // This screen's own Board/Alight toggle -- Play when THIS trip isn't the + // boarded one (tap to board it), Stop when it is (tap to alight). Distinct + // from every other screen's currentTripTopBarButton "return to trip" button + // (which never changes state, just navigates) -- Trip Detail always shows its + // own toggle here instead, even while a DIFFERENT trip is currently boarded + // elsewhere. No rightButton here (LightTopBar only takes one) since a trip + // switch also needs its own warning icon alongside the toggle -- a plain Row + // stacked on top instead, matching HomeScreen's own two-icon corner. + LightTopBar( + leftButton = LightBarButton.LightIcon(icon = LightIcons.BACK, onClick = { goBack() }), + center = LightTopBarCenter.Text("Trip Detail"), + ) + Row( + modifier = Modifier + .fillMaxWidth() + .height(3f.gridUnitsAsDp()) + .padding(horizontal = 1f.gridUnitsAsDp()), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, + ) { + // A different trip is already boarded -- tapping Play here would end that + // one and start tracking this one instead, so it's flagged before the tap + // rather than silently swapping. + if (boardedTrip != null && !isBoardedHere) { + LightIcon( + icon = LightIcons.DELETE, + size = 1.2f, + contentDescription = "Boarding this trip will end tracking of your current trip", + modifier = Modifier.padding(end = 8.dp), + ) + } + LightIcon( + icon = if (isBoardedHere) LightIcons.STOP else LightIcons.PLAY, + size = 1.2f, + contentDescription = if (isBoardedHere) "Alight" else "Board", + modifier = Modifier.lightClickable { + if (isBoardedHere) viewModel.alight() else viewModel.board() + }, + ) + } + } + Column(modifier = Modifier.weight(1f).padding(32.dp)) { + LightText( + text = "$routeLabel ($directionLabel)", + variant = LightTextVariant.Detail, + lighten = true, + modifier = Modifier.padding(bottom = 16.dp), + ) + + if (isBoardedHere) { + LightText( + text = "Tap a stop below to mark where you're getting off. Tap and hold a " + + "stop to see its connections.", + 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 -> + val isLive = s.liveAtStopSequence != null && stop.stopSequence == s.liveAtStopSequence + + fun openConnections() { + val afterTime = stop.arrivalTime ?: stop.departureTime + if (afterTime != null) { + navigateTo(screenFactory = { activity -> + StopConnectionsScreen( + activity, + dbFile, + stop.stopId, + stop.stopName ?: "Stop ${stop.stopId}", + afterTime, + tripId, + ) + }) + } + } + + Column( + modifier = Modifier + .fillMaxWidth() + .let { base -> + if (isBoardedHere) { + // While boarded, a short tap designates/clears this + // stop as the alight stop instead -- tap-and-hold is + // still the way to reach its connections. + // detectTapGestures specifically (not a hand-rolled + // awaitEachGesture loop, which an earlier version of + // this used) -- that version's unconditional + // awaitFirstDown().consume() claimed every touch + // starting on a row, including the start of a swipe, + // before the LazyColumn's own scroll gesture ever got + // a chance to recognize the drag. detectTapGestures + // respects touch slop, so a real swipe still falls + // through to the list's own scrolling. + base.pointerInput(stop.stopId) { + detectTapGestures( + onTap = { viewModel.toggleAlightStop(stop.stopId) }, + onLongPress = { openConnections() }, + ) + } + } else { + base.lightClickable { openConnections() } + } + } + .padding(vertical = 8.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.Top, + ) { + if (isLive) { + LightIcon( + icon = vehicleIcon, + size = 1.2f, + contentDescription = "Live vehicle", + modifier = Modifier.padding(end = 8.dp), + ) + } + // Weighted so a long stop name wraps within its own share of + // the row instead of first greedily measuring against the + // row's full width and only then discovering there's no room + // left for the trailing time -- see NearbyStopsScreen's + // identical fix for the same underlying Compose behavior. + Row( + modifier = Modifier.weight(1f), + verticalAlignment = Alignment.Top, + ) { + LightText( + text = stop.stopName ?: "Unknown stop", + variant = LightTextVariant.Copy, + underline = stop.stopId == alightStopId, + ) + if (stop.stopId in s.stationStopIds) { + LightIcon( + icon = LightIcons.DIRECTIONS_MIDDLE_FORK, + size = 1.2f, + contentDescription = "Transfer station", + modifier = Modifier.padding(start = 8.dp), + ) + } + } + LightText( + text = formatGtfsTime(stop.arrivalTime ?: stop.departureTime), + variant = LightTextVariant.Copy, + lighten = true, + modifier = Modifier.padding(start = 16.dp), + ) + } + if (isLive) { + LightText( + text = s.liveStatus?.label() ?: "Live", + variant = LightTextVariant.Detail, + lighten = true, + ) + } + } + } + } + } + } + } + BackToHomeFooter(onGoBackOnce = { goBack() }) + } + } + } +} 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..0b087ac7 --- /dev/null +++ b/tool/src/main/kotlin/com/thelightphone/transit/UpcomingArrivalsScreen.kt @@ -0,0 +1,369 @@ +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.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 +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 +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, + /** 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?, + /** This arrival's own platform within the station, only populated when the selected stop is an + * actual multi-platform grouped station (e.g. "Track 1", "Ashmont/Braintree") -- see + * GtfsRepository.getScheduledArrivals(stopIds: List, ...). Null for a plain stop. */ + val platformLabel: String?, +) + +fun ArrivalRow.etaDisplay(): String { + val time = LocalDateTime.ofInstant(Instant.ofEpochSecond(etaEpochSeconds), ZoneId.systemDefault()) + return formatGtfsTime("%02d:%02d:00".format(time.hour, time.minute)) +} + +/** e.g. "Red Line - Toward Alewife - Alewife" or "Commuter Rail - Toward Providence - Track 1" -- + * the platform is only appended when this arrival actually came from a grouped multi-platform + * station ([ArrivalRow.platformLabel] non-null); a plain stop's line is unchanged. */ +fun ArrivalRow.routeAndDirectionLabel(): String { + val base = "$routeLabel - $directionLabel" + return platformLabel?.let { "$base - $it" } ?: base +} + +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, + /** Every child platform stop_id belonging to the selected stop -- more than one entry means + * this is a real multi-platform grouped station (see GtfsRepository.groupStationsByParent), in + * which case arrivals across every platform are unioned and each is labeled with its own + * platform. A plain stop is just its own single-element list. */ + private val stopIds: List, +) : LightViewModel() { + + private val repository = GtfsRepository(dbFile) + + private val _state = MutableStateFlow(UpcomingArrivalsState.Loading) + val state: StateFlow = _state + + /** Whether the selected stop is itself a real, qualifying multi-platform station -- see + * GtfsRepository.getStationContaining, the same single source of truth every other screen's + * transfer icon uses. Resolved via [stopIds]'s first entry rather than checking its length, + * since a caller may pass just one representative id even for a station (e.g. the Map screen's + * tap-and-hold shortcut). Kept separate from [state] so the icon can render as soon as this + * quick lookup resolves, without waiting on the (network-bound) arrivals fetch below. */ + val isStation = MutableStateFlow(false) + + override fun onScreenShow(screen: SimpleLightScreen) { + super.onScreenShow(screen) + viewModelScope.launch(Dispatchers.IO) { + // Own try/catch (not folded into the state one below) so a screen popped mid-query -- + // e.g. several rapid-fire goBack() calls in a row, like BackToHomeFooter's "jump to + // Home" loop -- can't crash the app just because this repository got closed out from + // under an in-flight query on the way out. Same reasoning applies to every DB call in + // this coroutine, not just this one, but this is the one call that used to sit outside + // any try/catch at all. + try { + isStation.value = repository.getStationContaining(stopIds.first()) != null + } catch (e: Exception) { + Log.e("UpcomingArrivalsScreen", "getStationContaining failed for stops $stopIds", e) + } + _state.value = try { + val today = todayForGtfs() + val scheduled = repository.getScheduledArrivals(stopIds, 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 -> + // Matched against this specific arrival's own platform (arrival.stopId), not + // just whichever platform the screen was originally opened for -- a grouped + // station's live predictions are per-platform, same as its static schedule. + val rtStopUpdate = feed?.tripUpdatesByTripId?.get(arrival.tripId) + ?.updateFor(arrival.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(), + lineType = LineType.forGtfsRouteType(arrival.route.routeType), + etaEpochSeconds = eta.etaEpochSeconds, + isLive = eta.isLive, + status = eta.status, + platformLabel = arrival.platformLabel, + ) + }.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 stops $stopIds", 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 stopIds: List, + private val stopLabel: String, +) : LightScreen(sealedActivity) { + + override val viewModelClass: Class + get() = UpcomingArrivalsViewModel::class.java + + override fun createViewModel(): UpcomingArrivalsViewModel = + UpcomingArrivalsViewModel(dbFile, agency, stopIds) + + @Composable + override fun Content() { + val state by viewModel.state.collectAsState() + val isStation by viewModel.isStation.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("Upcoming Arrivals"), + 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( + text = "Selected stop", + variant = LightTextVariant.Detail, + lighten = true, + modifier = Modifier.padding(bottom = 8.dp), + ) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .lightClickable { + navigateTo(screenFactory = { activity -> + MapScreen(activity, dbFile, agency, stopIds.first(), stopLabel) + }) + } + .padding(bottom = 16.dp), + ) { + LightIcon(icon = LightIcons.MAP, size = 1.4f, modifier = Modifier.padding(end = 8.dp)) + // Weighted so a long stop name wraps within its own bounded share of the row, + // leaving guaranteed room for the trailing icon -- see NearbyStopsScreen's + // identical fix for the same underlying Compose behavior. + LightText( + text = stopLabel, + variant = LightTextVariant.Copy, + modifier = Modifier.weight(1f, fill = false), + ) + if (isStation) { + LightIcon( + icon = LightIcons.DIRECTIONS_MIDDLE_FORK, + size = 1.2f, + contentDescription = "Transfer station", + modifier = Modifier.padding(start = 8.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, + ) { + LightIcon( + icon = arrival.lineType.toVehicleIcon(), + size = 1.2f, + modifier = Modifier.padding(end = 8.dp), + ) + // Status renders directly under the route/direction text + // it describes (same weighted column) rather than as a + // sibling of the whole Row -- otherwise it lines up flush + // with the mode icon's left edge instead of the text. + Column( + modifier = Modifier + .weight(1f) + .padding(end = 16.dp), + ) { + LightText( + text = arrival.routeAndDirectionLabel(), + variant = LightTextVariant.Copy, + ) + arrival.statusLabel()?.let { + LightText( + text = it, + variant = LightTextVariant.Detail, + lighten = true, + ) + } + } + LightText( + text = arrival.etaDisplay(), + variant = LightTextVariant.Copy, + lighten = true, + ) + } + } + } + } + } + } + } + } + BackToHomeFooter(onGoBackOnce = { goBack() }) + } + } + } +} 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/BoardedTripPreferences.kt b/tool/src/main/kotlin/com/thelightphone/transit/gtfs/BoardedTripPreferences.kt new file mode 100644 index 00000000..0adb85a0 --- /dev/null +++ b/tool/src/main/kotlin/com/thelightphone/transit/gtfs/BoardedTripPreferences.kt @@ -0,0 +1,109 @@ +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 androidx.datastore.preferences.core.intPreferencesKey +import androidx.datastore.preferences.core.stringPreferencesKey +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +private val boardedTripIdKey = stringPreferencesKey("BOARDED_TRIP_ID") +private val boardedAgencyKey = stringPreferencesKey("BOARDED_AGENCY") +private val boardedFromStopSequenceKey = intPreferencesKey("BOARDED_FROM_STOP_SEQUENCE") +private val boardedRouteLabelKey = stringPreferencesKey("BOARDED_ROUTE_LABEL") +private val boardedDirectionLabelKey = stringPreferencesKey("BOARDED_DIRECTION_LABEL") +private val boardedLineTypeKey = stringPreferencesKey("BOARDED_LINE_TYPE") +private val boardedAlightStopIdKey = stringPreferencesKey("BOARDED_ALIGHT_STOP_ID") +private val progressBarVisibleKey = booleanPreferencesKey("TRIP_PROGRESS_BAR_VISIBLE") + +/** Everything needed to reopen TripDetailScreen for the trip the rider is currently on, from + * anywhere in the app (see HomeScreen's "Current Trip" entry) -- not just the trip id, since + * reopening that screen needs the exact same constructor arguments the original navigation used. */ +data class BoardedTrip( + val tripId: String, + val agency: GtfsAgency, + val fromStopSequence: Int, + val routeLabel: String, + val directionLabel: String, + /** This trip's own vehicle mode (bus/subway/commuter rail) -- null only if the route's + * route_type couldn't be resolved. Carried here (not re-looked-up) so HomeScreen can show the + * matching vehicle icon next to the Current Trip entry without opening its own repository + * connection just for this. */ + val lineType: LineType?, + /** The stop the rider tapped to mark as where they're getting off -- null until they've + * chosen one. See TripDetailScreen's tap-to-designate / tap-hold-for-connections gesture. */ + val alightStopId: String?, +) + +/** + * Tracks "the current trip" (Trip Detail's Board/Alight feature) -- persisted via the SDK's + * Preferences DataStore, the same mechanism [AgencyPreferences] uses, so it survives navigating + * away and even an app restart. Deliberately a saved *reference* back to Trip Detail rather than a + * background tracker: real-time "have we reached the alight stop" detection only ever happens + * while that screen is open (its existing live-position poll), matching how live tracking already + * works everywhere else in this app -- there's no SDK mechanism for meaningfully-real-time + * background work (WorkManager's periodic floor is 15 minutes, confirmed via LightWork.kt). + */ +class BoardedTripPreferences(private val dataStore: DataStore) { + + val boardedTripFlow: Flow = dataStore.data.map { prefs -> + val tripId = prefs[boardedTripIdKey] ?: return@map null + val agency = prefs[boardedAgencyKey]?.let { id -> GtfsAgency.entries.find { it.id == id } } ?: return@map null + val fromStopSequence = prefs[boardedFromStopSequenceKey] ?: return@map null + BoardedTrip( + tripId = tripId, + agency = agency, + fromStopSequence = fromStopSequence, + routeLabel = prefs[boardedRouteLabelKey] ?: "", + directionLabel = prefs[boardedDirectionLabelKey] ?: "", + lineType = prefs[boardedLineTypeKey]?.let { name -> LineType.entries.find { it.name == name } }, + alightStopId = prefs[boardedAlightStopIdKey], + ) + } + + suspend fun board( + tripId: String, + agency: GtfsAgency, + fromStopSequence: Int, + routeLabel: String, + directionLabel: String, + lineType: LineType?, + ) { + dataStore.edit { prefs -> + prefs[boardedTripIdKey] = tripId + prefs[boardedAgencyKey] = agency.id + prefs[boardedFromStopSequenceKey] = fromStopSequence + prefs[boardedRouteLabelKey] = routeLabel + prefs[boardedDirectionLabelKey] = directionLabel + if (lineType == null) prefs.remove(boardedLineTypeKey) else prefs[boardedLineTypeKey] = lineType.name + prefs.remove(boardedAlightStopIdKey) + } + } + + suspend fun alight() { + dataStore.edit { prefs -> + prefs.remove(boardedTripIdKey) + prefs.remove(boardedAgencyKey) + prefs.remove(boardedFromStopSequenceKey) + prefs.remove(boardedRouteLabelKey) + prefs.remove(boardedDirectionLabelKey) + prefs.remove(boardedLineTypeKey) + prefs.remove(boardedAlightStopIdKey) + } + } + + suspend fun setAlightStop(stopId: String?) { + dataStore.edit { prefs -> + if (stopId == null) prefs.remove(boardedAlightStopIdKey) else prefs[boardedAlightStopIdKey] = stopId + } + } + + /** On by default -- HomeScreen's board-to-alight progress bar (Settings screen toggle). */ + val progressBarVisibleFlow: Flow = dataStore.data.map { prefs -> prefs[progressBarVisibleKey] ?: true } + + suspend fun setProgressBarVisible(visible: Boolean) { + dataStore.edit { prefs -> prefs[progressBarVisibleKey] = visible } + } +} 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..41fba1a1 --- /dev/null +++ b/tool/src/main/kotlin/com/thelightphone/transit/gtfs/GtfsAgency.kt @@ -0,0 +1,51 @@ +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 + * 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", + ), + ; + + 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/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..bdec6d76 --- /dev/null +++ b/tool/src/main/kotlin/com/thelightphone/transit/gtfs/GtfsIngestor.kt @@ -0,0 +1,353 @@ +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 { + CheckingForUpdates, 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") + +/** 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) + } finally { + 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 + * 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..4c587c87 --- /dev/null +++ b/tool/src/main/kotlin/com/thelightphone/transit/gtfs/GtfsRealtime.kt @@ -0,0 +1,247 @@ +@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 +} + +/** + * 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 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. + * 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 +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 } +} + +/** + * 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 +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..46f741da --- /dev/null +++ b/tool/src/main/kotlin/com/thelightphone/transit/gtfs/GtfsRepository.kt @@ -0,0 +1,772 @@ +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, 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 } + } +} + +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, + /** See [StopLocation.memberStopIds]. */ + val memberStopIds: List, + /** See [StopLocation.isStation]. */ + val isStation: Boolean, +) + +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, + /** This connection's own platform within the station, only populated when the query spanned + * an actual multi-platform grouped station (e.g. "Track 1", "Ashmont/Braintree") -- see + * GtfsRepository.getNextConnections(stopIds: List, ...). Null for a plain stop. */ + val platformLabel: String?, + val route: RouteOption, + val direction: DirectionOption, +) + +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, + /** + * True only when [stopId] is a real GTFS Station record (`location_type=1`) with 2 or more + * child platforms/entrances grouped under it via `parent_station` -- see + * [groupStationsByParent]. A stop where several routes merely happen to converge, but with no + * such parent record, does NOT qualify, regardless of how many routes serve it. Powers the + * Station sub-map feature (search-result/trip-detail transfer icon, double-tap-to-zoom on the + * Map screen). + */ + val isStation: Boolean, +) + +data class ScheduledArrival( + val tripId: String, + /** The specific child platform stop_id this arrival was actually found at -- for a grouped + * multi-platform station (see [groupStationsByParent]), this may differ between rows even + * though they're all "the same station" from the rider's perspective. Live/RT prediction + * lookups must match against this, not whichever stop_id the caller originally asked about. */ + val stopId: String, + val stopSequence: Int, + val departureTime: String, + val route: RouteOption, + val direction: DirectionOption, + /** This platform's own identifying label within a multi-platform station (e.g. "Track 1", + * "Ashmont/Braintree"), derived from the child stop's own stop_desc -- see + * [platformLabelFromStopDesc]. Null for a plain, non-grouped stop lookup, where there's nothing + * more specific to show than the stop's own name. */ + val platformLabel: String? = null, +) + +/** + * 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), + ) + } + } + } + + /** 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( + """ + 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 across every id in [stopIds] after [afterTime], across every + * route and direction serving those stops (not just the one [excludeTripId] belongs to) -- + * used to show connecting service from a stop selected on a trip's detail screen. When + * [stopIds] is a real multi-platform station's full [StopLocation.memberStopIds] (see + * [getStationContaining]), this unions every platform's schedule the same way + * [getScheduledArrivals]'s list overload does, tagging each result with the specific platform + * it was found at ([StopConnection.platformLabel]) -- only populated when [stopIds] has more + * than one entry. A plain single stop just passes a one-element list. Restricted to trips + * active on [today] using the same calendar/calendar_dates logic as [getDepartures]. + */ + fun getNextConnections( + stopIds: List, + afterTime: String, + excludeTripId: String, + today: LocalDate, + ): List { + if (stopIds.isEmpty()) return emptyList() + val todayGtfs = today.toGtfsDateString() + val dayColumn = today.dayOfWeek.toGtfsColumnName() + val placeholders = stopIds.joinToString(",") { "?" } + val isGrouped = stopIds.size > 1 + + val sql = """ + SELECT st.departure_time, t.trip_id, st.stop_sequence, s.stop_desc, + 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 + JOIN stops s ON s.stop_id = st.stop_id + WHERE st.stop_id IN ($placeholders) AND st.departure_time > ? AND t.trip_id != ? + AND ${activeTodayClause(dayColumn)} + ORDER BY st.departure_time + """.trimIndent() + + return db.rawQuery( + sql, + (stopIds + listOf(afterTime, excludeTripId, todayGtfs, todayGtfs, todayGtfs)).toTypedArray(), + ).use { cursor -> + cursor.mapRowsNotNull { + val directionId = getIntOrNull(8) ?: return@mapRowsNotNull null + StopConnection( + departureTime = getString(0), + tripId = getString(1), + stopSequence = getInt(2), + platformLabel = if (isGrouped) platformLabelFromStopDesc(getStringOrNull(3)) else null, + route = RouteOption( + routeId = getString(4), + shortName = getStringOrNull(5), + longName = getStringOrNull(6), + routeType = getInt(7), + ), + direction = DirectionOption(directionId, getStringOrNull(9)), + ) + } + } + } + + /** + * 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, parent_station, location_type FROM stops " + + "WHERE stop_lat IS NOT NULL AND stop_lon IS NOT NULL", + null, + ).use { cursor -> + val rows = cursor.mapRows { + RawStopRow( + stopId = getString(0), + stopName = getStringOrNull(1), + lat = getDouble(2), + lon = getDouble(3), + parentStation = getStringOrNull(4), + locationType = getIntOrNull(5), + ) + } + groupStationsByParent(rows) + } + + /** 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", + arrayOf(stopId), + ).use { cursor -> + cursor.mapRows { + StopLocation( + stopId = getString(0), + stopName = getStringOrNull(1), + lat = getDouble(2), + lon = getDouble(3), + memberStopIds = listOf(getString(0)), + isStation = false, + ) + }.firstOrNull() + } + + /** + * Resolves [stopId] to its full station group if it belongs to one -- whether [stopId] is the + * station's own representative id or one of its member platforms. Used by the Map screen's + * double-tap-to-open-Station gesture so it behaves identically whether the tapped marker is the + * currently centered/selected stop or a nearby one -- both call this same function rather than + * two separate detection paths. Null if [stopId] isn't part of any qualifying station. + */ + fun getStationContaining(stopId: String): StopLocation? = + getStopsWithLocation().firstOrNull { it.isStation && (it.stopId == stopId || stopId in it.memberStopIds) } + + /** + * Every stop_id that's part of a real, qualifying multi-platform station -- the station's own + * representative id plus every one of its child platform ids (see [StopLocation.isStation]). + * Computed once from the same grouping used everywhere else (not a separate detection method), + * so a caller checking many individual stop_ids (e.g. Trip Detail's stop-by-stop list) can test + * cheap set membership per row instead of re-querying per row. + */ + fun getMultiPlatformStationStopIds(): Set = + getStopsWithLocation().filter { it.isStation }.flatMapTo(mutableSetOf()) { it.memberStopIds + it.stopId } + + /** Every real, qualifying multi-platform station this agency has (see [StopLocation.isStation]), + * alphabetically by name -- powers the HomeScreen's direct "Station" browse list, which lists + * every station up front rather than asking the rider to search a location first. */ + fun getAllStations(): List = + getStopsWithLocation().filter { it.isStation }.sortedBy { it.stopName ?: it.stopId } + + /** stop_desc for every given stop_id, keyed by stop_id -- used to derive each platform's own + * label within a station (see [platformLabelFromStopDesc]) for screens that already have + * individual platform stop_ids in hand, rather than going through the unioned arrivals query + * that already carries this (see the other [getScheduledArrivals] overload). */ + fun getStopDescriptions(stopIds: List): Map { + if (stopIds.isEmpty()) return emptyMap() + val placeholders = stopIds.joinToString(",") { "?" } + return db.rawQuery( + "SELECT stop_id, stop_desc FROM stops WHERE stop_id IN ($placeholders)", + stopIds.toTypedArray(), + ).use { cursor -> cursor.mapRows { getString(0) to getStringOrNull(1) }.toMap() } + } + + /** + * Every route+direction scheduled to serve [stopId] at or after [afterTime] today (minus + * [graceSeconds], if given), 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. + * + * [graceSeconds] exists for callers that keep polling live vehicle data against this same + * candidate list over time (see MapScreen's own SCHEDULED_ARRIVALS_GRACE_PERIOD_SECONDS): a + * plain `departure_time >= afterTime` filter permanently drops a trip the moment its scheduled + * time ticks past, even if the *live* feed shows the vehicle still dwelling right at the stop -- + * once dropped from this list, no later poll (querying a still-later "afterTime") can ever bring + * it back. Widening the window backward keeps recently-scheduled trips as candidates so the + * live-position-based inclusion/exclusion logic downstream (not this query) gets to make the + * real call on whether they're still relevant. Zero by default, so a plain one-shot snapshot + * caller (e.g. Upcoming Arrivals, which has no live-position data to make that downstream call + * with) keeps its exact existing behavior. + */ + fun getScheduledArrivals(stopId: String, afterTime: String, today: LocalDate, graceSeconds: Int = 0): List { + val todayGtfs = today.toGtfsDateString() + val dayColumn = today.dayOfWeek.toGtfsColumnName() + val effectiveAfterTime = if (graceSeconds > 0) subtractSecondsFromGtfsTime(afterTime, graceSeconds) else afterTime + + 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, effectiveAfterTime, todayGtfs, todayGtfs, todayGtfs), + ).use { cursor -> + cursor.mapRowsNotNull { + val directionId = getIntOrNull(7) ?: return@mapRowsNotNull null + ScheduledArrival( + departureTime = getString(0), + tripId = getString(1), + stopId = stopId, + stopSequence = getInt(2), + route = RouteOption( + routeId = getString(3), + shortName = getStringOrNull(4), + longName = getStringOrNull(5), + routeType = getInt(6), + ), + direction = DirectionOption(directionId, getStringOrNull(8)), + ) + } + } + } + + /** + * Union of [getScheduledArrivals] across every id in [stopIds] -- for a deduplicated + * multi-platform station (see [groupStationsByParent]), a single representative stop_id's own + * schedule is only one of several platforms actually serving it; this looks up every child + * platform grouped under the same station and merges their schedules into one chronological + * list. Each result is tagged with the specific platform it was found at + * ([ScheduledArrival.stopId]/[ScheduledArrival.platformLabel]) so the UI can tell riders which + * platform each arrival uses -- [platformLabel] is only populated when [stopIds] has more than + * one entry (an actual grouped station), since a plain single-platform stop has nothing more + * specific to show than its own name. + */ + fun getScheduledArrivals(stopIds: List, afterTime: String, today: LocalDate): List { + if (stopIds.isEmpty()) return emptyList() + val todayGtfs = today.toGtfsDateString() + val dayColumn = today.dayOfWeek.toGtfsColumnName() + val placeholders = stopIds.joinToString(",") { "?" } + val isGrouped = stopIds.size > 1 + + val sql = """ + SELECT st.departure_time, t.trip_id, st.stop_sequence, st.stop_id, s.stop_desc, + 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 + JOIN stops s ON s.stop_id = st.stop_id + WHERE st.stop_id IN ($placeholders) AND st.departure_time >= ? + AND ${activeTodayClause(dayColumn)} + ORDER BY st.departure_time + """.trimIndent() + + return db.rawQuery( + sql, + (stopIds + listOf(afterTime, todayGtfs, todayGtfs, todayGtfs)).toTypedArray(), + ).use { cursor -> + cursor.mapRowsNotNull { + val directionId = getIntOrNull(9) ?: return@mapRowsNotNull null + ScheduledArrival( + departureTime = getString(0), + tripId = getString(1), + stopId = getString(3), + stopSequence = getInt(2), + platformLabel = if (isGrouped) platformLabelFromStopDesc(getStringOrNull(4)) else null, + route = RouteOption( + routeId = getString(5), + shortName = getStringOrNull(6), + longName = getStringOrNull(7), + routeType = getInt(8), + ), + direction = DirectionOption(directionId, getStringOrNull(10)), + ) + } + } + } + + /** + * Every stop with coordinates, ranked by distance from ([anchorLat], [anchorLon]), nearest + * first, capped at [limit]. Used by the "Leave Now" nearby-stops flow, anchored at a geocoded + * search point. + */ + fun rankStopsByDistance( + anchorLat: Double, + anchorLon: Double, + limit: Int, + excludeStopId: String? = null, + ): 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, + isStation = stop.isStation, + ) + } + .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, + isStation = stop.isStation, + ) + } + .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?, + /** GTFS `location_type` -- 1 means this row is itself a real Station record. Only relevant for + * [StopLocation.isStation]; the grouping itself still keys purely on `parent_station`. */ + val locationType: Int? = null, +) + +/** + * 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. + * Grouping itself only depends on the `parent_station` linkage -- a row with no `parent_station` is + * its own representative regardless of its own `location_type`, and either way any rows pointing at + * it via `parent_station` get folded into it. `location_type` only matters separately for + * [StopLocation.isStation] (see below). + * + * 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 -- this fallback case is never + * `isStation`, since by definition there's no real Station record backing it (see [isStation]'s own + * doc for why this distinction matters for the Station sub-map feature). + */ +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 children = childrenByParent[row.stopId] + val childIds = children?.map { it.stopId }?.sorted() + result += StopLocation( + stopId = row.stopId, + stopName = row.stopName, + lat = row.lat, + lon = row.lon, + memberStopIds = childIds ?: listOf(row.stopId), + isStation = row.locationType == 1 && (children?.size ?: 0) >= 2, + ) + 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(), + isStation = false, + ) + } + + return result +} + +/** + * A child platform's own identifying label within a multi-platform GTFS station, derived from its + * stop_desc (e.g. "South Station - Commuter Rail - Track 1" -> "Track 1", "South Station - Red + * Line - Alewife" -> "Alewife"). stop_name is deliberately not used for this -- verified against + * real MBTA data, every platform under a station shares the exact same stop_name as the parent + * station itself, so it can't distinguish anything; stop_desc's last " - "-delimited segment + * reliably names just that one platform. Null when there's no such segment to extract (a plain, + * non-grouped stop, or one with a blank/single-segment desc) -- internal rather than private so + * it's unit-testable without a real database, same as [groupStationsByParent]. + */ +internal fun platformLabelFromStopDesc(stopDesc: String?): String? { + if (stopDesc.isNullOrBlank()) return null + val lastSeparator = stopDesc.lastIndexOf(" - ") + if (lastSeparator == -1) return null + return stopDesc.substring(lastSeparator + 3).trim().takeIf { it.isNotBlank() } +} + +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) +} + +/** Subtracts [seconds] from a GTFS "HH:MM:SS" time string -- see [GtfsRepository.getScheduledArrivals]'s + * graceSeconds param. Clamped at "00:00:00" rather than going negative; a query a few minutes wide + * near midnight pulling in nothing extra (there's essentially never real service exactly then) is a + * fine trade for not having to represent a negative GTFS time. */ +private fun subtractSecondsFromGtfsTime(time: String, seconds: Int): String { + val parts = time.split(":") + val hour = parts.getOrNull(0)?.toIntOrNull() ?: return time + val minute = parts.getOrNull(1)?.toIntOrNull() ?: return time + val second = parts.getOrNull(2)?.toIntOrNull() ?: 0 + val totalSeconds = (hour * 3600 + minute * 60 + second - seconds).coerceAtLeast(0) + return "%02d:%02d:%02d".format(totalSeconds / 3600, (totalSeconds % 3600) / 60, totalSeconds % 60) +} + +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/MapPreferences.kt b/tool/src/main/kotlin/com/thelightphone/transit/gtfs/MapPreferences.kt new file mode 100644 index 00000000..67aa9b21 --- /dev/null +++ b/tool/src/main/kotlin/com/thelightphone/transit/gtfs/MapPreferences.kt @@ -0,0 +1,56 @@ +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") +private val tapHoldArrivalsKey = booleanPreferencesKey("MAP_TAP_HOLD_ARRIVALS_ENABLED") +private val doubleTapStationKey = booleanPreferencesKey("MAP_DOUBLE_TAP_STATION_ENABLED") +private val trackTappedStopsKey = booleanPreferencesKey("MAP_TRACK_TAPPED_STOPS_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 } + } + + /** 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 } + } + + /** Off by default, same reasoning as [tapHoldArrivalsEnabledFlow] -- double-tapping a station + * marker to open its Station sub-map is an extra gesture layered on top of the Map screen's + * existing tap/tap-hold behavior, so it's opt-in. */ + val doubleTapStationEnabledFlow: Flow = dataStore.data.map { prefs -> prefs[doubleTapStationKey] ?: false } + + suspend fun setDoubleTapStationEnabled(enabled: Boolean) { + dataStore.edit { prefs -> prefs[doubleTapStationKey] = enabled } + } + + /** Off by default, same reasoning as [tapHoldArrivalsEnabledFlow] -- expanding a nearby stop + * to also track ITS vehicles is an extra opt-in on top of the Map screen's default (just the + * selected stop's own vehicles). Controlled from the Settings screen rather than a toggle + * drawn directly on the Map canvas -- see [com.thelightphone.transit.MapViewModel.nearbyVehiclesEnabled], + * which now just reads this persisted value instead of owning its own in-memory one. */ + val trackTappedStopsEnabledFlow: Flow = dataStore.data.map { prefs -> prefs[trackTappedStopsKey] ?: false } + + suspend fun setTrackTappedStopsEnabled(enabled: Boolean) { + dataStore.edit { prefs -> prefs[trackTappedStopsKey] = 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 new file mode 100644 index 00000000..122a8316 --- /dev/null +++ b/tool/src/main/kotlin/com/thelightphone/transit/gtfs/MapTiles.kt @@ -0,0 +1,247 @@ +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 +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 { + 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 -- 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, + centerLon: Double, + points: List>, + availableHalfExtentPx: Float, + 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. + 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)) + } + + // 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) + } + + 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 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 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 + // 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. [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, + 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) + 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, darkMode)?.let { FetchedTile(tileX, tileY, it) } } } + .mapNotNull { it.await() } + + MapTiles(zoom, centerFracX, centerFracY, tiles) + } + + 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("$baseUrl/$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/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() + } +} 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..8ce68fae --- /dev/null +++ b/tool/src/test/kotlin/com/thelightphone/transit/gtfs/GtfsRepositoryDedupTest.kt @@ -0,0 +1,153 @@ +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, locationType = 1), + RawStopRow("70079", "South Station", 42.352271, -71.055242, parentStation = "place-sstat", locationType = 0), + RawStopRow("70080", "South Station", 42.352271, -71.055242, parentStation = "place-sstat", locationType = 0), + RawStopRow("74611", "South Station", 42.352271, -71.055242, parentStation = "place-sstat", locationType = 0), + RawStopRow("74617", "South Station", 42.352271, -71.055242, parentStation = "place-sstat", locationType = 0), + RawStopRow("84611", "South Station", 42.352271, -71.055242, parentStation = "place-sstat", locationType = 0), + RawStopRow("NEC-2287", "South Station", 42.352271, -71.055242, parentStation = "place-sstat", locationType = 0), + ) + + 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", + ) + assertTrue(station.isStation, "a real location_type=1 record with 6 children must qualify as a station") + } + + @Test + fun stopWithManyConvergingRoutesButNoStationRecordIsNotAStation() { + // A stop many routes happen to converge at, but with no location_type=1 parent record + // backing it -- per the Station sub-map rule, this must never qualify, no matter how many + // routes serve it. + val rows = listOf( + RawStopRow("busy-stop", "Busy Corner", 42.0, -71.0, parentStation = null, locationType = 0), + ) + + val result = groupStationsByParent(rows) + + assertTrue(!result.single().isStation) + } + + @Test + fun stationRecordWithOnlyOneChildDoesNotQualify() { + // A real location_type=1 record, but only a single child platform -- the rule requires 2+. + val rows = listOf( + RawStopRow("place-solo", "Lonely Station", 42.0, -71.0, parentStation = null, locationType = 1), + RawStopRow("child-1", "Lonely Station", 42.0, -71.0, parentStation = "place-solo", locationType = 0), + ) + + val result = groupStationsByParent(rows) + + assertTrue(!result.single().isStation) + } + + @Test + fun fallbackPromotedRepresentativeIsNeverAStation() { + // Same missing-parent-record scenario as fallsBackToFirstChildWhenTheParentRecordItselfIsMissing + // below -- even with 2+ children, there's no real Station record to back it, so it must not + // qualify as a station regardless of how many children point at the missing parent. + val rows = listOf( + RawStopRow("child-b", "Ghost Stop", 42.1, -71.1, parentStation = "place-ghost", locationType = 0), + RawStopRow("child-a", "Ghost Stop", 42.1, -71.1, parentStation = "place-ghost", locationType = 0), + ) + + val result = groupStationsByParent(rows) + + assertTrue(!result.single().isStation) + } + + @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") }) + } + + @Test + fun platformLabelExtractsTheLastSegmentOfRealSouthStationStopDescs() { + // Real stop_desc values from MBTA's static GTFS stops.txt for South Station's own child + // platforms -- stop_name is identical ("South Station") across every one of these and can't + // distinguish them, but stop_desc's last " - "-delimited segment names the specific platform. + assertEquals("Ashmont/Braintree", platformLabelFromStopDesc("South Station - Red Line - Ashmont/Braintree")) + assertEquals("Alewife", platformLabelFromStopDesc("South Station - Red Line - Alewife")) + assertEquals( + "SL2/SL3 Design Center/Chelsea", + platformLabelFromStopDesc("South Station - Silver Line - SL2/SL3 Design Center/Chelsea"), + ) + assertEquals("Track 1", platformLabelFromStopDesc("South Station - Commuter Rail - Track 1")) + assertEquals("Track 13", platformLabelFromStopDesc("South Station - Commuter Rail - Track 13")) + } + + @Test + fun platformLabelFallsBackToNullWhenTheresNothingMoreSpecificToExtract() { + assertEquals(null, platformLabelFromStopDesc(null)) + assertEquals(null, platformLabelFromStopDesc("")) + assertEquals(null, platformLabelFromStopDesc(" ")) + // A single segment, no " - " separator at all -- nothing to split off as a platform. + assertEquals(null, platformLabelFromStopDesc("South Station")) + } +}