Native Wear OS showcase app built for the Strava RFP. The RFP covers activity metrics, segments, and maps & navigation; this codebase implements the maps slice only — a list of routes fetched from a mocked Strava API and tapping a route to render its polyline on Google Maps for Wear OS.
Google Maps SDK for Wear OS requires an API key. For the showcase the key is
intentionally committed in plaintext inside
AndroidManifest.xml (the com.google.android.geo.API_KEY
<meta-data> element) so the project builds and runs out of the box without any
local setup.
Run target is a Wear OS 3+ image (API 30+). Google Maps requires a Wear OS image with Google Play Services.
The app is written in Kotlin using Jetpack Compose for Wear OS. It follows MVI on
top of Android architecture components (ViewModel, StateFlow) and a layered
domain / data / presentation split per feature. Koin is used for dependency injection.
Remote data is currently mocked from bundled JSON fixtures whose DTO shapes mirror
the real Strava REST API. Coroutines and Flow are used for asynchronous operations and
for communicating between the ViewModel and View layers.
Some of the important libraries used on the project are:
- Android Architecture components for app architecture
- Jetpack Compose for Wear OS (
androidx.wear.compose:compose-material3) for UI - Navigation 3 (
androidx.navigation3+androidx.wear.compose:compose-navigation3) for navigation - Google Maps SDK for Wear OS (
play-services-maps) +android-maps-utilsfor polyline decoding - Coroutines & Flow for asynchronous and reactive operations
- Koin for DI
kotlinx.serializationfor parsing the mocked Strava JSON fixtures
Versions are pinned in gradle/libs.versions.toml.
The Gradle wrapper is the best option to build the project.
./gradlew :app:assembleDebug # debug APK
./gradlew :app:installDebug # install on a connected Wear OS device / emulator
./gradlew :app:lint # lint (strict — lint.xml is in :app)Single Gradle module (:app), feature-first package layout. Shared
infrastructure lives under core/, every feature lives under feature/<name>/
with its own domain/, data/, presentation/ subpackages. This mirrors the
multi-module feature convention used on larger Ackee projects, collapsed into
packages because the showcase ships as a single module.
Rules of the road:
core/is shared infrastructure consumed by every feature. No feature-specific code lives here.- Each feature lives in
feature/<name>/and owns its fulldomain/,data/,presentation/stack plus its own Koin module and nav destinations. No centralnav/package — the feature owns its destinations. domain/holds the canonical models and repository interfaces. No@Serializable, no DTOs.data/owns DTOs and JSON parsing. DTOs map to domain via aMapper.presentation/owns ViewModels, Compose screens and the per-feature nav destination. Never importsdata/directly.- Screens are stateless composables receiving
State+ lambdas; the ViewModel owns mutable state viaStateFlow<State>. - User actions flow through a single
fun onIntent(intent: <Feature>Intent)entry point on the ViewModel (MVI). The screen takesonIntent: (<Feature>Intent) -> Unitand dispatches concrete<Feature>Intentdata objects/classes. - One-shot side effects (navigation, snackbars) flow through a
SharedFlow<Event>collected by the screen withLaunchedEffect. - IDs are value classes:
@JvmInline value class Id(val value: Long)on the domain model.
We use Koin for dependency injection. Each feature exposes a <feature>Module = module { ... }
and all modules are aggregated in core/di/AppModule.kt, started from
StravaApplication.onCreate. ViewModels use the explicit-lambda form so wiring is visible:
Inside Compose: koinViewModel<T>() / koinViewModel { parametersOf(id) }.
Mocked, but DTO shapes must exactly match the real Strava REST API so the mock can be swapped for a Ktor client later without touching domain or UI.
References:
- GET /routes/{id}
- Playground — useful for capturing real example responses
Conventions:
- One DTO file per resource (
RouteDto.kt), one@Serializable data classper Strava resource. Names mirror Strava's docs verbatim. - Field naming: Kotlin camelCase +
@SerialName("snake_case")to keep Kotlin idiom on our side and a faithful wire format that survives the swap. - Mock payloads are bundled as JSON under
app/src/main/assets/routes/(one file per route + anindex.jsonfor the list). Parsed once at app start — no hand-written Kotlin literal DTOs. - Polylines in Strava responses are Google encoded polylines (
map.summary_polyline). They are decoded withPolyUtil.decodeat the data-layer boundary; the UI only ever sees decodedList<LatLng>.
The Strava brand does not map cleanly onto Material3 roles, so we define our own
design system in core/presentation/theme/ instead of customising
androidx.wear.compose.material3.MaterialTheme.
For static analysis of the Kotlin source code detekt is used. You can run it locally by running
./gradlew detekt --rerun-tasks
in the root folder. The default ruleset is applied on top of project-specific overrides
in detekt-config.yml. The detekt-formatting and detekt-rules-compose
plugins are pulled in so Compose-specific rules (stable parameters, hoisted state, …) are
checked alongside the default formatting rules.