FINAL-YEAR PROJECT · Showcase May 2026

Adaptive pacing intelligence, engineered.

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.

Kotlin Jetpack Compose Clean Architecture Strava API · OAuth 2.0 Room Retrofit
Smart Marathon Runner — Home screen
GREEN · On target pace
FAST_STARTER detected
6'25 /km · 4h30 goal
18weeks
Novice training plan
3segments
Per-run evaluation
4patterns
Detection categories
±10sec
Pace tolerance

Generic pace targets
don't teach anyone to pace.

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.

The Problem

Beginners don't fail because they run too slowly. They fail because they run unevenly.

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.

The Solution

Split the run in thirds. Evaluate each. Detect patterns across weeks. Adapt.

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.

The algorithm,
in two passes.

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.

Pass 1 — Segment Evaluation

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.

Pass 2 — Pattern Detection

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.

Engineering Notes

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.

Sample run · Long Run · 16 km · goal 4h30
Early
6'15
target 7'41 · −86s
Middle
6'32
target 7'22 · −50s
Late
7'05
target 7'02 · +3s
GREEN ≤ 10s ORANGE 11–20s RED > 20s
Pattern outcomes → coaching advice
CONSISTENT
Maintain current strategy across all segments.
FAST_STARTER
Start 10–15s slower than target; hold reserve.
FADER
Hold back earlier; maintain effort late.
INCONSISTENT
Focus on steady effort throughout.

Seven screens.
One coherent experience.

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.

Feature · 01

Strava OAuth,
done properly.

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.

OAuth 2.0 Retrofit EncryptedSharedPreferences Deep Links
Login screen
Feature · 02

Marathon goal →
pace targets, instantly.

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.

Riegel formula DatePicker StateFlow SharedPreferences
Setup training screen
Feature · 03 · Core

Feedback that explains,
not just scores.

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.

Kotlin Coroutines async / awaitAll Splits API Pattern Engine
Training feedback screen
Feature · 04

Training gaps,
handled honestly.

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.

DetectTrainingGapUseCase Adaptive Recovery
Training gap detected
Feature · 05

Eighteen weeks,
at a glance.

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.

LazyVerticalGrid Calendar alignment Shared pace evaluator
Performance Journey calendar
Feature · 06

Per-km breakdown,
device-agnostic.

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.

Strava Splits API Truncation logic Universal compatibility
Lap analysis screen
Feature · 07

The app
explains itself.

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.

In-app documentation Onboarding continuity
How It Works screen

Clean Architecture,
enforced at the module level.

Three layers, strict unidirectional dependencies. Presentation talks to Domain. Domain talks to Data. Nothing crosses back. Every public type is testable in isolation.

Layer · 01

Presentation

Jetpack Compose screens. ViewModels exposing StateFlow. Zero business logic.
  • FeedbackScreenDynamic.kt
  • PerformanceHistoryScreen.kt
  • LapAnalysisScreen.kt
  • FeedbackViewModel.kt
  • HomeViewModel.kt
  • AppNavHost.kt
Layer · 02

Domain

Use cases. Calculators. Pure Kotlin — no Android dependencies.
  • AnalyzePerformanceUseCase.kt
  • GeneratePaceRecommendationUseCase.kt
  • DetectTrainingGapUseCase.kt
  • SyncActivitiesUseCase.kt
  • RiegelCalculator.kt
  • PaceTargets.kt
Layer · 03

Data

Retrofit services. Room DAOs. Token storage. External boundary only.
  • StravaApiService.kt
  • StravaRemoteDataSource.kt
  • ActivityDao.kt
  • ActivityRepository.kt
  • TokenManager.kt
  • MarathonTrainingSchedule.kt
Data Flow
Unidirectional

UI events bubble up to the ViewModel, domain operations return immutable state, state flows down to Compose. No two-way binding.

Persistence
Room + SharedPreferences

Activities live in Room (queryable, indexed). Training plan + tokens in encrypted SharedPreferences (small, key-value, fast).

Concurrency
Structured coroutines

viewModelScope everywhere. Parallel splits via async/awaitAll. Cancellation respected on navigation.

Deliberate choices,
not a checklist.

Each library earns its place. No dependency lives here because it's trendy — each one solves a concrete problem the platform doesn't.

Language & Runtime
Kotlin

First-class Android language. Null safety eliminates a whole class of runtime crashes. Coroutines replace callback hell with structured concurrency.

Kotlin 1.9JVM 17
UI
Jetpack Compose

Declarative UI, state-driven recomposition. Replaces the XML + Activity/Fragment stack with composable functions and remembered state.

ComposeMaterial 3Navigation
Architecture
ViewModel + StateFlow

ViewModels survive configuration changes. StateFlow emits immutable snapshots; Compose subscribes via collectAsState. Testable, predictable.

ViewModelStateFlowcollectAsState
Networking
Retrofit + OkHttp

Type-safe HTTP client with coroutine support. Interceptors handle auth injection and token refresh transparently.

Retrofit 2OkHttpMoshi
Persistence
Room Database

Compile-time verified SQL. DAOs return Flow for reactive queries. @Upsert pattern for idempotent activity sync.

RoomSQLiteKSP
Security
EncryptedSharedPreferences

AES-256-GCM-backed storage for OAuth tokens. Key material managed by AndroidKeyStore — never leaves the secure hardware.

AndroidX SecurityKeyStore
API
Strava v3

OAuth 2.0 with refresh-token rotation. Activities + Splits endpoints. Token lifecycle handled in a single abstraction.

OAuth 2.0RESTJSON
Concurrency
Kotlin Coroutines

viewModelScope, async/awaitAll for parallel fetches, structured cancellation on navigation. No leaked jobs.

CoroutinesFlowDispatchers
Tooling & Delivery
Gradle · Firebase · Git

Gradle KTS build config. Firebase App Distribution for beta delivery. Dokka for generated API documentation. Feature-branch Git workflow.

GradleFirebaseDokkaGitHub

Eight decisions
worth defending.

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.

Data Source

splits_metric over laps

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.

Auth

Transparent token refresh

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.

Sync Scope

Date-bounded sync

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.

Target Model

Percentage offsets, not Riegel predictions

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.

Tolerance

±10 seconds, not ±N%

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.

Concurrency

Parallel splits, single await

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.

UX Rule

Unplanned runs aren't penalised

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.

Feedback Window

3-week horizon

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.