Shuttle Tracker - Android

Documentation for the Shuttle Tracker Android app.

View the Project on GitHub wtg/Shuttle-Tracker-Android

Architecture

A quick map of the codebase so you know where to look add code. This app is written in Kotlin with Jetpack Compose for the UI, Hilt for dependency injection, and follows an MVVM pattern (Model / View / ViewModel). It’s a single-Activity app - everything you see is a Compose screen, not a separate Activity.

Everything lives under the package edu.rpi.shuttletracker, at app/src/main/java/edu/rpi/shuttletracker/.

The big picture

app/            entry point, DI setup, navigation
core/           small helpers shared by everything (network results, theme, UI bits)
data/           models, the API client, the repository, and saved user settings
feature/        one folder per screen/flow (map, schedule, etas, settings, setup)
background/     notifications and Firebase push handling
widget/         the home screen widget (Jetpack Glance)

How data flows

Loading data (network → screen):

Backend API
    ↓
data/remote/            ShuttleApi + RetrofitShuttleRemoteDataSource makes the HTTP call
    ↓
data/mapper/             turns the raw DTO into a data/models/ type
    ↓
data/repository/         ShuttleRepository wraps the result in a NetworkResult
    ↓
feature/<name>/<Name>ViewModel.kt    updates its UiState
    ↓
feature/<name>/<Name>Screen.kt       Compose reads UiState and redraws

Handling a user action (screen → network):

feature/<name>/<Name>Screen.kt       user taps something
    ↓
feature/<name>/<Name>ViewModel.kt    the tap calls a function on the ViewModel
    ↓
data/repository/                     ShuttleRepository makes the call
    ↓
data/remote/                         the actual HTTP request
    ↓
Backend API

Everything below walks through each of those layers in more detail.

app/ - where the app starts

data/ - where information comes from

This is the single source of truth every feature reads from. Nothing in feature/ should talk to the network or DataStore directly - it goes through here.

Because ShuttleRepository and UserPreferences are interfaces, tests can swap in a fake instead of hitting the network or disk - see Testing below.

feature/ - one folder per screen

Every screen or self-contained flow gets its own folder under feature/, e.g. feature/map/, feature/schedule/, feature/etas/, feature/settings/, feature/setup/. Each one follows the same shape:

feature/<name>/
  <Name>Screen.kt       the composable that navigation points to
  <Name>ViewModel.kt     a @HiltViewModel holding that screen's state
  components/            smaller composables used only by this feature
  utils/                 plain Kotlin helper functions (no Compose/Android types)

<Name>Screen.kt is a @Composable with a viewModel: <Name>ViewModel = hiltViewModel() default parameter. Hilt supplies the real ViewModel at runtime; tests construct the ViewModel by hand and pass it in instead.

<Name>ViewModel.kt owns a private MutableStateFlow<XyzUiState> and exposes it publicly as a read-only StateFlow. XyzUiState is a small data class at the bottom of the same file holding everything that screen needs to render (a list of vehicles, a loading flag, an error, etc). The View never mutates state directly - it calls a function on the ViewModel, and the ViewModel updates the flow, which Compose automatically re-renders from.

@HiltViewModel
class ExampleViewModel @Inject constructor(
    private val shuttleRepository: ShuttleRepository,
) : ViewModel() {
    private val _uiState = MutableStateFlow(ExampleUiState())
    val uiState: StateFlow<ExampleUiState> = _uiState.asStateFlow()

    fun doSomething() {
        _uiState.update { it.copy(/* ... */) }
    }
}

data class ExampleUiState(/* ... */)

components/ holds smaller @Composable functions used only within that feature (cards, rows, sheets). utils/ holds plain Kotlin functions with no Android or Compose dependency - this is deliberate, so that logic (parsing, filtering, math) can be unit tested directly on the JVM without an emulator. See feature/map/utils/FakeShuttleUtils.kt or feature/etas/utils/EtaUtils.kt for examples.

Current features

background/ - work that isn’t a screen

widget/ - the home screen widget

Built with Jetpack Glance, which renders to RemoteViews in the launcher’s process - no ViewModel, no StateFlow. Data is fetched separately and the widget just renders whatever’s already stored.

Like the map and ETAs tab, the widget shows fake shuttle data in dev mode when there’s nothing live to show.

core/ - shared building blocks

Testing

Run unit tests with ./gradlew testDebugUnitTest.

Formatting

ktlint runs as a pre-commit hook and will block a commit that isn’t formatted correctly. Run ./gradlew ktlintFormat to auto-fix most issues before committing.

Adding a new screen

A quick checklist, based on how the existing features are built:

  1. Create feature/<name>/ with <Name>Screen.kt and <Name>ViewModel.kt.
  2. Give the ViewModel a <Name>UiState data class and a MutableStateFlow/StateFlow pair, like the example above.
  3. Inject ShuttleRepository and/or UserPreferences if the screen needs data.
  4. Add a route for it in app/navigation/AppNavigation.kt (or, if it’s a tab rather than its own destination, wire it into feature/map/MapsScreen.kt’s bottom navigation the way ETAs and Schedule are).
  5. Add unit tests for the ViewModel under app/src/test/, using the existing fakes in testing/fakes/.