An Android application that transforms raw Strava activity data into per-segment pacing feedback for beginner marathon runners. It divides every run into thirds, compares each segment against progressive pace targets, detects multi-week pacing patterns, and generates personalised coaching advice.
Commercial running apps give you a single number — a target pace for the whole run. That tells a beginner nothing about how to distribute their effort, when they drifted, or why the same run kept failing. Smart Marathon Runner treats pacing as a time-series problem, not a scalar one.
Most novice marathoners start too fast, blow up in the middle, and limp home. Apps that display "5'40 /km" as a single target hide that failure mode entirely — the average pace can look correct while every segment is wrong.
Post-run, the runner gets no structured feedback. No pattern detection. No coaching advice. Just another line on a graph.
Every run is divided into Early / Middle / Late segments using Strava's per-km splits. Each segment is compared against a progressive target derived from the runner's marathon goal, classified GREEN / ORANGE / RED with a ±10s tolerance.
A pattern engine analyses the last three weeks: fast-starter, fader, consistent, or inconsistent — and the next workout card adapts its pace-strategy hints accordingly.
A per-run evaluator feeds into a multi-run pattern detector. The output isn't a number — it's a coaching decision the next workout card is built around.
Marathon pace is derived from the goal time (mp = goal_seconds / 42.195).
Each session type is mapped to a pace category: Recovery (flat, MP +30%), General Aerobic and
Long Run (progressive +20/+15/+10%). The run is split in
thirds using splits_metric, partial kilometres under 900m are filtered, each segment's
average pace is compared to its target with a ±10s tolerance.
Across the last 3 weeks of evaluated runs (min. 3 required), the recommendation engine looks for dominance: ≥50% fast early thirds flags fast-starter; ≥50% slow late thirds flags fader; ≥70% overall green marks consistent. The next workout card rewrites its pace-strategy hints per segment based on the diagnosis.
All activity splits are fetched in parallel via async/awaitAll — reducing load
time from O(n) to the slowest single request. OAuth token refresh is transparent: expired
access tokens trigger a silent refresh before any API call, keeping the UI stateless of
auth concerns.
Every screen is built with Jetpack Compose, backed by a ViewModel with StateFlow, and driven by pure domain logic. Below, the full flow — from onboarding to lap-level analysis.
A welcome screen that's intentionally quiet: one call-to-action, one job. Tapping Connect with Strava launches the OAuth 2.0 authorisation flow, captures the auth code via a deep-link redirect, exchanges it server-side for access and refresh tokens, and stores both in encrypted SharedPreferences.
Access tokens expire every six hours. A silent refresh layer wraps every authenticated API call — the user never re-logs-in.
A slider drives goal time (3h30 to 6h00, 15-minute steps). The rendered pace updates in real time, rounded to the nearest 5 seconds to match coaching convention. A start-date selector (preset or custom) snaps to the Monday of that week — the entire 18-week calendar and sync window aligns from there.
Optional biometric fields (sex / age / weight / height) are stored for future personalisation without blocking onboarding.
The centrepiece: every recent run is broken into Early / Middle / Late with per-segment delta vs. target. Cards are colour-coded by the worst segment. The Next Workout header adapts — showing today's planned run, the pace strategy per third, and the pattern-driven coaching advice above it.
All splits load in parallel. Loading an 8-run feedback screen takes roughly the time of one API call, not eight.
If the runner misses days, the app doesn't pretend the plan still applies. A dedicated use case compares the latest activity date to today and classifies the gap: NORMAL (0–3d), MODERATE (4–7d, repeat current week), CRITICAL (8d+, step back one week).
The warning appears as a banner above the next-workout card — dismissable, but unmissable on load.
The Performance Journey renders all 126 days of the plan in a 7-column grid, each cell colour-coded by the pace evaluation of that day's run. Rest days stay grey; on-track days go green; drift shows amber; off-target shows red. The current day carries a blue border.
A live counter at the top aggregates the month so far. No click-throughs needed — the whole season is readable in one glance.
The Lap Analysis screen displays pace, heart rate, and elevation per kilometre from the
most recent run. It uses Strava's splits_metric
— a deliberate choice over laps. LAPs
are device-dependent (Garmin sends them via FIT; Samsung GPX often omits them entirely).
Splits are universally computed by Strava regardless of watch brand.
A first-class How It Works surface documents the training logic in-app: the 18-week structure, the weekly schedule pattern, how feedback colours are decided, and how pattern detection drives the next workout. Built so the user never has to leave the app to understand what it's telling them.
Three layers, strict unidirectional dependencies. Presentation talks to Domain. Domain talks to Data. Nothing crosses back. Every public type is testable in isolation.
UI events bubble up to the ViewModel, domain operations return immutable state, state flows down to Compose. No two-way binding.
Activities live in Room (queryable, indexed). Training plan + tokens in encrypted SharedPreferences (small, key-value, fast).
viewModelScope everywhere. Parallel splits via async/awaitAll. Cancellation respected on navigation.
Each library earns its place. No dependency lives here because it's trendy — each one solves a concrete problem the platform doesn't.
First-class Android language. Null safety eliminates a whole class of runtime crashes. Coroutines replace callback hell with structured concurrency.
Declarative UI, state-driven recomposition. Replaces the XML + Activity/Fragment stack with composable functions and remembered state.
ViewModels survive configuration changes. StateFlow emits immutable snapshots; Compose subscribes via collectAsState. Testable, predictable.
Type-safe HTTP client with coroutine support. Interceptors handle auth injection and token refresh transparently.
Compile-time verified SQL. DAOs return Flow for reactive queries. @Upsert pattern for idempotent activity sync.
AES-256-GCM-backed storage for OAuth tokens. Key material managed by AndroidKeyStore — never leaves the secure hardware.
OAuth 2.0 with refresh-token rotation. Activities + Splits endpoints. Token lifecycle handled in a single abstraction.
viewModelScope, async/awaitAll for parallel fetches, structured cancellation on navigation. No leaked jobs.
Gradle KTS build config. Firebase App Distribution for beta delivery. Dokka for generated API documentation. Feature-branch Git workflow.
Every project has a handful of choices that shape everything downstream. These are mine, with the rationale that wouldn't fit into a commit message.
Strava's laps endpoint reflects whatever the GPS watch
recorded — Garmin sends FIT-formatted laps; Samsung GPX often has none. Using
splits_metric gives consistent 1-km segments computed
server-side by Strava. Works for every device the user could bring.
Strava access tokens expire every six hours. Instead of surfacing re-login UI, an
interceptor checks expires_at on every call, uses the
stored refresh token to fetch a new access token, updates encrypted storage, and replays
the original request. The UI never knows auth happened.
An earlier build fetched the user's entire Strava history on every launch. For runners
with a long history, that meant hundreds of activities and real crash risk. Now sync is
always scoped to the training plan's start period via an after=
query parameter. Predictable payload size, predictable memory.
Riegel's formula is great for predicting finish times, awkward for per-segment targets. Expressing targets as percentages of marathon pace (MP +30%, +20%, +15%, etc.) maps cleanly to coaching terminology and scales naturally when the runner adjusts their goal.
Percentage-based tolerances punish slower runners (a 6% margin on an 8'00 pace is 29 seconds — too forgiving; on 4'30 pace it's 16 seconds — too tight). Fixed seconds is how actual coaches talk about pacing, and it treats all runners the same.
Loading feedback for eight recent runs would be 8×1.5s sequentially. Wrapping each
activity's splits call in async { } and collecting with
awaitAll() collapses it to the slowest single request.
Structured cancellation means navigating away kills the whole tree cleanly.
If the user runs on a rest day, the activity still shows up — in a neutral grey card with no pace evaluation. It's excluded from pattern detection. The app respects that training plans are guides, not jails.
The feedback screen shows at most 21 days, respecting the user's selected sync period. Enough to detect stable patterns (min. 3 runs), short enough to reflect current form — not last month's.