Started at the end of May 2025

What is it?

  • Platform: Android
  • Language: Kotlin
  • UI Toolkit: Jetpack Compose

A lot of people seem to think that the official Pop app for the Tyne and Wear Metro system isn’t as good as it could be. So, combining my special interest in trains with my programming skills, I decided to learn something new and make my own!

Just to be abundantly clear: this project is in no way affiliated, endorsed, or otherwise connected to Nexus. I am doing this purely as a hobby.

I started last month at the time of writing, and in that time I have developed a lot of new skills:

  • The Kotlin programming language
  • Android development practices
  • General software development architectures
    • Unidirectional data flow
    • Separation of data layer and view layer
  • Explored multiple map rendering libraries, including:
    • osmdroid
    • Google Maps
    • MapLibre
  • The Jetpack Compose library
  • How REST APIs work and how to use Nexus’s API
  • Material Design Guidelines
  • How to turn parse KML and JSON data

Do note that it is far from done and that some icon assets are taken directly from Nexus currently. This is an issue that I would definitely sort out before releasing this as a download, if I ever get to that point.

A screenshot showing a screen where one half is a map with a station icon and the other half is the schedule for Northumberland Park, as shown on the map. A screenshot showing the search functionality, with stations listed in alphabetical order with the ability to search them, along with icons that show travel links. A screenshot showing the parking information feature. It is currently displaying the Bank Foot Metro car park, with 31 available spaces out of 62 total spaces. A screenshot showing the train information capability. This is currently a work in progress. It shows the train's running number, destination, and when/where it was last seen.

Current capabilities

  • Displays a map of the Tyne and Wear Metro system, drawing each line on a MapLibre map.
  • Allows you to search stations and pinpoint them on the map.
  • Shows live locations of trains currently running, updated from Nexus’s API every ten seconds.
  • Shows stations along the lines.
  • Displays the zone, travel links, and car parks of selected station.
  • Shows car park information of the stations that have them.
  • Allows you to see information about a specific train on the tracks (WIP)

Tapping a station reveals a schedule window showing information for approaching trains:

  • Destination
  • Line colour
  • Train colour
  • Platform
  • Due time
  • An approximate description of location

This is not an embedded webview of the official Nexus metro map. It is a reimplementation using the MapLibre Native Android library. I had to use an AndroidView composable and some scope gymnastics in order to make that work.

@Composable
fun metroMapView(
    stations: MutableMap<String, Station>,
    trains: Map<String, Train>,
    onShowBottomSheetChange: (Boolean) -> Unit,
    onSelectedStationIdChange: (String) -> Unit,
    onShowTrainSheetChange: (Boolean) -> Unit,
    onSelectedTrainIdChange: (String) -> Unit
): MapView {
    val context = LocalContext.current

    MapLibre.getInstance(context)
    val mapView: MapView by remember { mutableStateOf(MapView(context)) }
    mapView.onCreate(null)

    var trainSymbolManager: SymbolManager? by remember { mutableStateOf(null) }

    AndroidView(
        factory = { context ->
            val mapStyle = Style.Builder().fromUri("https://tiles.openfreemap.org/styles/liberty")
            mapStyle.withImage(
                "station_icon",
                ContextCompat.getDrawable(context, R.drawable.metro_smol)!!
            )
            mapStyle.withImage(
                "train_icon",
                ContextCompat.getDrawable(context, R.drawable.train_icon)!!
            )

            val metroLineSource = GeoJsonSource(
                id = "metro_line_source",
                geoJson = String(context.resources.openRawResource(R.raw.metromap2).readBytes()),
            )

            // Add lines ---------------------------------------------------------------------------
            mapStyle.withSource(metroLineSource)

            mapStyle.withLayer(
                LineLayer("metro_lines_yellow", "metro_line_source")
                    .withProperties(
                        PropertyFactory.lineColor(context.getColor(R.color.metro_yellow)),
                        PropertyFactory.lineWidth(4f),
                        PropertyFactory.lineCap(LINE_CAP_ROUND)
                    )
                    .withFilter(eq(get("Line"), "Y"))
            )

            mapStyle.withLayer(
                LineLayer("metro_lines_green", "metro_line_source")
                    .withProperties(
                        PropertyFactory.lineColor(context.getColor(R.color.metro_green)),
                        PropertyFactory.lineWidth(4f),
                        PropertyFactory.lineCap(LINE_CAP_ROUND)
                    )
                    .withFilter(eq(get("Line"), "G"))
            )
            // -------------------------------------------------------------------------------------

           [...]

            mapView

trainSymbolManager is important as that allows me to alter the positions of the train as the API is pinged. This is done in the update argument of AndroidView:

 update = {
    if (trainSymbolManager == null) return@AndroidView

    // Update trains

    val unprocessedTrains = trains.values.toMutableSet()

    // Iterate through all the icons on the map
    for (symbol in trainSymbolManager!!.annotations.valueIterator()) {

        // Get the train id associated with the symbol in its data
        val symbolId = symbol.data?.asJsonObject["id"]?.asString?.removeSurrounding("\"")

        // Remove associated train from the set, as it is being processed
        unprocessedTrains.remove(trains[symbolId])

        // If no train matching this icon found, remove from map.
        if (!trains.contains(symbolId)) {
            trainSymbolManager!!.delete(symbol)
            continue
        }

        // If train still present, move to new location
        val valueAnimator = ValueAnimator.ofObject(LatLngEvaluator(), symbol.latLng, trains[symbolId]?.latLng)
        valueAnimator.duration = 1000
        valueAnimator.interpolator = AccelerateDecelerateInterpolator()
        valueAnimator.addUpdateListener { animation ->
            symbol.latLng = animation.animatedValue as LatLng
            trainSymbolManager!!.update(symbol)
        }
        valueAnimator.start()
    }

    // Create new icons for trains that are still in the set (new trains)
    for (train in unprocessedTrains) {
        trainSymbolManager!!.create(
            SymbolOptions()
                .withLatLng(train.latLng)
                .withIconImage("train_icon")
                .withData(JsonParser.parseString("{\"id\": \"${train.id}\"}"))
        )
    }
}

The other options on the navigation bar are currently placeholder. In fact, the car parks button doesn’t need to be there at all as that’s implemented in the map! I could maybe keep it there for ease of access later on, though that could risk some UI clutter as the same feature would be in different parts of the app.

The biggest stability risk to the app currently are undocumented API changes. Nexus’s API is not officially meant for public use, despite its API key being in plaintext on the Metro website, so any changes they make to the API could cause the app to crash. I am exploring ways to reduce the risk of this happening so that it can be at least somewhat usable in the real world outside my own personal use, though try-catch statements are realistically all I can do currently.

Credits