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 and 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>/           a ViewModel updates shared or screen-specific state
    ↓
feature/<name>/           Compose reads that state and redraws

Handling a user action (screen → network):

feature/<name>/           user taps something
    ↓
feature/<name>/           a callback reaches the owning 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/. A feature can contain:

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

Screen files contain the feature’s @Composable entry point. Screens that own a ViewModel usually accept it as a defaulted hiltViewModel() parameter; simpler screens can receive state and callbacks from a parent instead.

ViewModels expose read-only state, commonly a StateFlow backed by a private MutableStateFlow or derived from repository and preference flows. The UI never mutates that state directly: it calls a ViewModel function, then Compose redraws from the updated state.

@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 includes simulated shuttles when both developer options and the fake-shuttles toggle are enabled.

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/.