A SwiftUI app for following buses and other public transport in Sweden, built on Trafiklab's Realtime APIs and ResRobot.
Kyytiin is the app's user-facing name; the repository and Xcode project keep the OnBoard name.
OnBoard is also an example of an iOS architecture driven by SwiftUI: the app uses only Apple frameworks — SwiftUI, SwiftData, Observation, CoreLocation, and Foundation — and has no external dependencies. Every dependency (network, storage, location) is a small protocol injected through the SwiftUI environment, so the same views run in previews, unit tests, and UI tests.
- Nearby — finds stops around your location (ResRobot Nearby Stops) and shows upcoming departures for a chosen stop (Timetables).
- Favorites — saves stops and journeys persistently with SwiftData: the currently live trips (their saved end date not yet passed) are listed at the top of the Favourites tab, the stops follow, and finished trips are shunted to the end, removable with one tap.
- Search — searches stops by name (Stop Lookup) and follows a vehicle's live trip between stops (Trips).
The app is organized into feature folders, each owning one screen:
OnBoard/
├── MyApp.swift # App entry point; builds and injects dependencies
├── MainView.swift # Tab layout; owns and publishes shared models
├── Api/ # Trafiklab client, secrets, canned service mocks
├── DesignSystem/ # Shared UI components (pills, buttons, screen states)
├── Network/ # NetworkProtocol, LiveNetwork, MockNetwork
├── Location/ # Location authorization and permission UI
├── Nearby/ # NearbyModel + NearbyView
├── Favorites/ # FavoritesModel + FavoritesView (stops + saved trips)
├── TripFavorites/ # TripFavoritesModel (saved-trip status & cleanup)
├── Search/ # SearchModel + SearchView
├── StopDetails/ # StopDetailsModel + StopDetailsView
├── RouteDetails/ # RouteDetailsModel + RouteDetailsView
└── About/ # AboutView (attribution for Trafiklab / Samtrafiken)
The visual language (the storyboard's Ink/Paper/Panel neutral ramp, the magenta
brand color, and the green/yellow/red status colors) lives in
Assets.xcassets as color sets, each with a light and dark appearance so dark
mode comes for free. The build setting
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES makes
Xcode generate the Color accessors automatically (.ink, .panel,
.statusGreen, …), so there is no hand-written Color extension — adding
a color means adding a .colorset in the catalog and using the generated
symbol, nothing else.
On top of the tokens, OnBoard/DesignSystem/ holds the shared UI
components — StatusPill, the .primary/.text button styles (the primary
button renders as an accent-tinted Liquid Glass capsule on iOS 26),
MessageScreen, UnavailableScreen, and LoadingIndicator — extracted
from the storyboard's repeated patterns so screens don't re-style them by
hand. See CONTRIBUTING.md's "Design System" section for usage.
The core patterns:
- Observable models: each screen's state lives in a
@MainActor @Observable final classmodel that owns one load lifecycle and surfaces errors as afailure: String?. A model'sinittakes no dependencies; load/mutate methods receive them (loadStops(network:latitude:longitude:)), so tests inject mocks directly. - Environment injection:
NetworkProtocolis published with.environment(\.network, network)and models with.environment(model); views read them with@Environment(\.network)and@Environment(Model.self). The environment defaults toLiveNetwork, so views render in previews without wiring. - Protocol-oriented dependencies:
LiveNetwork(URLSession) is the production transport;MockNetworkandMockTrafiklabServiceprovide handler registration, request tracking, and canned responses for tests and previews. - API client built per call — a documented trade-off: each model constructs
its own
Trafiklabclient inside the load method (let api = Trafiklab(network: network)). That keepsTrafiklabout of the model's initializer and the environment, so a test injects a mock by passing aNetworkProtocolalone. The cost: there is no shared place for cross-cutting concerns. If your app needs those, invert the injection: make the API client itself the protocol injected through the environment (@Entry var api: any TrafiklabAPI) and keep the transport a detail of its live implementation. - SwiftData persistence: the app entry point builds the
ModelContainerand injects it with.modelContainer(container); views hand the environment'sModelContextto their model's load/mutate methods. Each feature persists one aggregate root (e.g.StoredFavoriteswith a cascading relationship to[Favorite]). A container that fails to open crashes at startup by choice — the app has no working persistence without it, and a loud failure beats silently dropped writes. An app that can rebuild its store (or migrate a stale schema) should catch and recreate the container there instead; that policy is a product decision, so it is written down rather than inherited. - Presentation on the domain type: display logic (transport mode icons,
labels, parsed dates) lives in computed properties on the value types via
Type+Feature.swiftextensions next to their consumers, so it is shared instead of duplicated per screen.
UI tests substitute mocks through launch arguments (--mock-network,
--skip-location-permission, --mock-storage) checked in MyApp, which keeps
the entry point self-contained.
The point of this layout is that SwiftUI is the framework: no Combine publishers, no coordinator objects, no third-party state containers. The constraints and what they buy:
- No external dependencies — the whole dependency surface is three
protocols (
NetworkProtocol,LocationManager) and SwiftData. Nothing to version, nothing to audit, and the same code teaches you the platform primitives an MVVM/TCA layer would otherwise wrap. @Observablemodels overObservableObject/@Published— granular tracking means a screen re-renders only for the properties its body reads, and models stay plain Swift (no@Publishedwrappers, noobjectWillChange).- Dependencies on methods, not
init— a model'sinittakes nothing, so constructing one in a view (@State var model = Model()) or a test (Model()) never needs a container or factory; the load call receives the transport (loadStops(network:…)), which is also the seam a test injects through. - The environment is the composition root's delivery mechanism — the app entry point is the only place that decides which implementations exist; everything downstream reads an abstraction. Views get previews for free because the environment's defaults are the live implementations, and previews/tests override exactly the values they need.
- Feature folders over type folders — a screen's model, view, and
presentation extensions live together, so adding a feature is adding a
folder, and deleting one removes everything it owns. Only cross-cutting
infrastructure (
Network/,Location/,Api/) sits outside. - A mock's job is fidelity of the bytes, not of the data model —
MockTrafiklabServicewent through several rounds of simplification, and each one removed code that was re-implementing something the app already had. The learnings that shaped it:- Fixtures are multiline JSON strings in the endpoint's wire shape — they read like captured API responses, so you can paste a real response in verbatim. Don't configure the mock with hand-built API model values; that makes the mock a second (drifting) implementation of the endpoint.
- The serving path never decodes or re-encodes: the mock picks a string by
dictionary key, joins the chosen strings into the response envelope, and
hands over
Data(body.utf8). The model under test then runs its real productionCodabledecode on exactly the fixture bytes — a broken fixture surfaces as a model failure, which is where you want it. - Key dictionaries by the thing you look up (
stopGroupsByName), even if a value (the group's name) repeats inside the JSON. A key lookup replaces any scanning or range-searching inside JSON strings, which is fragile and only ever half-correct. - Dictionaries are unordered, so a fixture dictionary needs an explicit, documented order for what it serves (the mock serves name-sorted groups and says so where it deviates from the real API's busiest-first order).
- Resolve the moving parts once at creation: relative timestamp markers
(
"now","now±n") become absolute timestamps when the service is built, so previews and UI tests show fresh times with no per-request recomputation. - Validate fixtures loudly at creation — a malformed fixture traps with the fixture named instead of silently serving an empty response later.
- Parse request paths with small regex captures
(
url.path.firstMatch(of: #/departures/([^/]+)/#)) rather than split-and-index arithmetic, and preferString.replacing(_:with:)with a capture-group regex over a manual scan-and-rebuild loop for textual substitution; both read as the endpoint's shape rather than as index bookkeeping.
The checklist, in the order we'd apply it to a new app:
- Model each dependency as a small protocol with one live implementation
(
LiveNetwork) and no globals. If you can't name a second implementation you'd want (mock, preview stub, cache-decorated), you don't need the protocol yet. - Publish it with
@EntryonEnvironmentValues, defaulting to the live implementation, so views and previews render without wiring:@Entry var network: NetworkProtocol = LiveNetwork(). - One
@MainActor @Observable final classper screen,init()taking nothing, a single load lifecycle per fetch, errors surfaced asfailure: String?rather than thrown. - Load in
.task/.task(id:), never in the model'sinitor a factory, so SwiftUI owns the async lifetime and cancels it for you. - Inject shared, cross-screen models (
FavoritesModel) from the owning view with@State+.environment(model), read with@Environment(Model.self). - Persist through the environment's
ModelContextpassed into load/mutate methods, one aggregate root per feature, created on demand. - Serve tests/previews from a feature-specific mock service
(
MockTrafiklabService) configured by JSON strings in each endpoint's wire shape (one per stop, one per area id / trip key / stop-group name), served by joining the chosen strings into the response envelope — never converted into the API's Swift models, so the app's realCodabledecode path is what parses them. Keep a bareMockNetworkonly for request-shape and invalid-input assertions. - Drive UI tests with string launch arguments checked in the app entry
point's
make*()factories (--mock-network), so the harness is the same code path production uses.
flowchart TD
MyApp["MyApp (composition root)\nbuilds LiveNetwork, ModelContainer,\nLocationAuthorization"] -->|".environment(\.network)"| ENV
MyApp -->|".modelContainer"| ENV["SwiftUI environment"]
MyApp -->|".environment(model)"| ENV
ENV -->|"@Environment(\.network)"| VIEW["Screen view\n.task { await model.load(network: …) }"]
ENV -->|"@Environment(Model.self)"| VIEW
VIEW -->|"load(network:)"| MODEL["@MainActor @Observable model\nfailure / isLoading / rows"]
MODEL -->|"builds client from transport"| API["Trafiklab client\n(endpoints + Codable types)"]
API -->|"data(for:)"| NET["NetworkProtocol"]
NET --> LIVE["LiveNetwork (URLSession)"]
NET --> MOCK["MockTrafiklabService / MockNetwork\n(unit tests, previews, UI tests via --mock-network)"]
MODEL -->|"mutate(context:)"| STORE["SwiftData ModelContext\n(StoredFavorites aggregate)"]
The app reads its Trafiklab API keys from a Secrets.env file bundled with the
build. The file is git-ignored, so it is not part of the repository:
- Register at developer.trafiklab.se and create one key per product: "Trafiklab Realtime APIs" (Stop Lookup, Timetables, Trips) and "ResRobot v2.1" (Nearby Stops).
- Create
OnBoard/Secrets.envwith your keys:TRAFIKLAB_REALTIME_KEY=your-realtime-key TRAFIKLAB_RESROBOT_KEY=your-resrobot-key - Build and run. Xcode's file-system-synchronized target bundles the file into the app automatically.
Without the file every screen shows its load failure instead of silently sending empty keys.