An iPhone app that tracks Adelaide Metro buses, trams and trains in real time.
It exists to answer one question: do I need to leave now?
Adelaide Metro's own app tells you when a bus is scheduled. Standing at a stop in the rain, that is the wrong number. I wanted the one that matters: where the bus actually is right now, how late it actually is, and whether I can still make it on foot.
So I built it. It's a personal project — one person, no company behind it, no accounts, no ads, no backend to pay for. Everything happens on your phone, talking straight to Adelaide Metro's public data feeds.
Departures · Follow the bus · Everything moving, right now
Real screenshots. Real Adelaide data. Nothing mocked up.
See where the bus actually is. Every tracked vehicle on the network appears on the map as its route number with a heading arrow, sliding between GPS updates. Tap one and you follow it: its speed, how many seconds ago it reported, and every stop still ahead of it.
Know if you'll make it. Each departure shows a countdown and a clock time, because "5 min" and "4:21 pm" answer different questions. If you're too far away to catch it on foot, the app says so plainly instead of letting you jog for nothing.
Know when it's late. Delays come from the live feed and are colour-coded: on time, running early, running late. If a bus is recovering from a delay, the number counts down with it rather than freezing on a stale figure.
Your stops first. The app opens on the stops you've starred, sorted by how close you are, re-sorting as you move. Each shows the next three departures, so most of the time you're done before you've tapped anything.
Alerts that don't nag. Diversions and closures are listed and filtered by mode, and the tab badge counts alerts you haven't seen — so a fortnight of roadworks doesn't leave a permanent red dot on your screen.
It looks like it belongs on iOS 26. Built in SwiftUI against the current design language, so the search field, the feed-age chip and the locate button are real Liquid Glass — they pick up and refract the map moving underneath them instead of sitting on a flat grey slab. Nothing here is a custom re-skin of Apple's controls.
Buses that aren't being tracked still show up. When the feed loses a vehicle, the app estimates where it should be from the timetable and draws it faintly, rather than pretending the service doesn't exist.
![]() |
![]() |
![]() |
![]() |
![]() |
| Favourites Nearest first |
Any stop Walk time included |
9,171 stops Thinned, never clustered |
Alerts Badged by unseen |
Yours Tune what shows |
There's no App Store listing — publishing needs a paid Apple developer account and this is a free side project. You install it by building it yourself, which takes about ten minutes and costs nothing.
| A Mac | Any Mac running macOS Tahoe or later |
| Xcode 26 or newer | Free from the Mac App Store. It's a big download — start it now |
| An iPhone on iOS 26 | Plus its USB cable, for the first install |
| An Apple ID | The free one you already have is fine. No paid developer account needed |
Open Terminal and paste this in, one block:
git clone https://github.com/RatterAU/AdelaideTransit.git
cd AdelaideTransit/AdelaideTransitThe .xcodeproj isn't stored in this repo — it's generated from project.yml, so it has to be built once:
brew install xcodegen # only needed the first time
xcodegen generate
open AdelaideTransit.xcodeprojNo Homebrew? Install it from brew.sh first, then run the above.
Xcode is now open. In the left sidebar click the blue AdelaideTransit project icon at the very top, then:
- Select the AdelaideTransit target → the Signing & Capabilities tab
- Tick Automatically manage signing
- Team → Add an Account… → sign in with your Apple ID → pick it from the dropdown
- Change Bundle Identifier to something unique to you, e.g.
com.yourname.adelaidetransit
Step 4 matters: bundle IDs are global, so the default one won't be available to your account.
- Plug your iPhone in and unlock it. Tap Trust if asked
- In Xcode's toolbar, click the device dropdown (next to the app name) and pick your iPhone
- Press ⌘R, or the ▶ button
The first build takes a few minutes.
The first launch will fail with "Untrusted Developer". That's expected. On your iPhone:
Settings → General → VPN & Device Management → your Apple ID → Trust
Open the app again and it works.
Note
First launch downloads the whole timetable (~30 MB) and files it away on your phone. Give it a minute on Wi-Fi. After that, screens open instantly and only live positions need the network.
Important
With a free Apple ID, the app stops opening after 7 days. This is Apple's rule, not the app's — free signing certificates expire weekly. To keep using it, plug the phone in and press ⌘R again; your favourites and settings survive. A paid Apple Developer account extends this to a year.
If something goes wrong
| Problem | Fix |
|---|---|
xcodegen: command not found |
brew install xcodegen, then reopen Terminal |
| "Failed to register bundle identifier" | Someone already used that bundle ID. Change it to something more unique in Signing & Capabilities |
| "Untrusted Developer" on launch | Step 5 above — the trust setting on the iPhone |
| App won't open after a week | Expected on a free account. Rebuild with ⌘R |
| Xcode offers to "Update to recommended settings" | Decline. Project settings live in project.yml, and accepting makes the prompt come back forever |
| Build errors after you add a file | Run xcodegen generate again |
Running the tests
xcodebuild -project AdelaideTransit.xcodeproj -scheme AdelaideTransit \
-destination 'platform=iOS Simulator,name=iPhone 17 Pro' test65 tests, Swift Testing. project.yml is the source of truth for the project — run xcodegen generate after adding any file, and never hand-edit the .xcodeproj.
Two feeds, one junction. The timetable arrives once a day as a big ZIP and goes into local SQLite. The live picture arrives every 15 seconds as three protobuf feeds. They're combined in exactly one place, Merge, which is pure code with tests around it — no view ever does this arithmetic itself.
flowchart LR
ZIP["GTFS timetable ZIP<br/><i>~30 MB, daily</i>"] --> ING[Ingest]
ING --> NET[("network.sqlite<br/><i>disposable cache</i>")]
USR[("user.sqlite<br/><i>your favourites</i>")] --> STORE
NET --> STORE(["GTFSStore<br/><i>the only SQL surface</i>"])
TU[trip_updates] --> ENG
VP[vehicle_positions] --> ENG
AL[alerts] --> ENG["RealtimeEngine<br/><i>polls in step with the feed</i>"]
STORE --> MERGE{{"Merge<br/><i>pure · tested</i>"}}
ENG --> MERGE
MERGE --> UI["Map · Stop · Trip<br/>Favourites · Alerts"]
Three decisions carry most of the weight:
- Your favourites live in a separate database file from the timetable. They were one file once, and a routine timetable update wiped every starred stop. Now ingest can replace the whole network database and never touch yours.
- A new timetable is validated before it replaces the old one, and the old file is moved aside rather than deleted. A half-finished download is still perfectly valid SQLite, which is exactly what makes it dangerous.
- All the schedule/real-time reconciliation is in one tested file. Delays, cancellations, service-day maths, "is this actually live", estimated positions for untracked buses — one place, so there's one place to be wrong.
Things the feed doesn't tell you, that I had to measure
Most of the app's design came from poking at the real feeds rather than trusting the spec.
| What's actually true | What the app does about it |
|---|---|
| The feed publishes every 15 seconds and is readable ~5 s after its own timestamp. Polling faster returns byte-identical data | The poller locks onto the feed's own clock instead of ticking on its own schedule, so a refresh lands just after new data exists |
No ETag, no Last-Modified, and gzip is ignored — every poll is a full, uncompressed download |
Pre-fetching happens only on unconstrained Wi-Fi, never on cellular or Low Data |
| Nothing reports which stop a bus is at — 0 of 627 vehicles carry that field | Position is inferred from which stops have dropped out of the trip's update list: the lowest one still published is where the bus is. Held for 790 of 792 trips over five minutes |
| Predicted arrival times are absolute, and a delay figure is never sent — 14,098 out of 14,098 | Delay is computed, and stops without their own prediction are interpolated between the nearest known ones. That's what lets a recovering bus tick 6 min late → 3, instead of freezing |
| No occupancy data at all | There's no crowding UI, rather than an invented one |
| 14,895 of 31,379 trips continue under a different route number (a 502 becomes a 110 at Grenfell St) | The app follows the vehicle through the change — matched by position rather than stop ID, because an interchange is a dozen stops with different IDs |
Why the map stays smooth with 9,171 stops
- Stops are thinned, not clustered: the busiest stop in each grid square survives, and the grid is anchored to fixed coordinates. Clustering re-partitions on every pan, which turns a drag into endless merge/split churn. Starred stops always survive the thinning.
- The map view does no unconditional work on redraw — it's called on every frame and every pan tick, so everything it does is gated on something having actually changed.
- Departure queries are bounded in SQL. The naive version read an entire service day for a stop served by 62 routes and threw nearly all of it away.
- Search is debounced and cancellable, because it's an unindexed substring scan over every stop and out-of-order replies used to leave the wrong list on screen.
Source layout
App/ App entry, tab router, theme + shared formatting
Data/
GTFS/ Zip reader, ingest, SQLite schema + migrations, queries
Models/ Departure, LiveVehicle, StopSummary
Realtime/ Poll scheduler, protobuf decoding, HTML stripping, Merge
Features/
Map/ MKMapView bridge, stop sheet, trip detail, walk estimates
Favourites/ Starred stops, live-sorted
Alerts/ Service alerts
Settings/ Windows, thresholds, clock format
Tests/ Swift Testing
Your location never leaves your phone. It's used for centring the map, sorting stops by distance and estimating walk times — nothing else. There is no account, no backend, no analytics and no third-party SDK. The only network traffic is to Adelaide Metro's public feeds and to Apple for maps and walking directions.
Timetable and real-time data © Department for Infrastructure and Transport, South Australia, published as open GTFS feeds. This project is not affiliated with or endorsed by Adelaide Metro.
Code is MIT licensed — see LICENSE.
Built in Adelaide 🇦🇺





