A GPS run/cycling app built around one idea: every route is a race with your own personal best. You run against a "ghost" of your PB, and the live gap — AHEAD or BEHIND — is the hero metric of the whole experience.
Flutter app → EngineService (facade) → Rust engine (FFI)
↘ FakeEngineService (deterministic, for dev)
- Record runs — a state machine driving
preparing → GPS acquiring → ready → running ⇄ paused → finishing → completed, with live distance, pace, elapsed time and the PB gap. - Ghost racing — every route carries a PB; run against its ghost live on the map, and finish the run to see where you were faster or slower.
- Route map — self-contained painter (no map SDK): the route, the travelled portion, the YOU and PB-ghost markers, a follow camera with recenter.
- Route library — course cards with PB/average/last stats, a performance chart of every attempt, and attempt history.
- Results & history — run-complete interstitial with NEW PERSONAL BEST detection, a splits breakdown, activity detail, and home history.
- Background recording — wall-clock timing and persisted run snapshots, so an interrupted run survives process death and resumes where it left off.
- Polish — phase transitions, haptics, WCAG-AA contrast and semantics labels, error/empty/loading states, and repaint isolation.
- Developer diagnostics — a
DEV_TOOLSgated readout of the live engine / GPS / track / route / ghost / persistence state, plus one-tap export of the current run as a raw-GPS fixture for regression replay.
| Path | What it is |
|---|---|
app/ |
The Flutter client (lib/, test/) |
rust/gps-engine/ |
The Rust engine: GPX → track → route → ghost pipeline |
specification/ |
Plans: app implementation, UI/UX, engine, datasets |
Cargo.toml |
Cargo workspace root for the Rust crate |
The app consumes the engine only through the EngineService abstraction, so
it never talks to raw FFI — the native Rust engine is selected automatically
when its library is present, and the fake is the fallback, without touching
UI code.
A static landing page lives in docs/ and is published at
https://davidneurieder.github.io/AgainstYesterday/.
# Flutter client
cd app
flutter pub get
flutter run
# Rust engine
cargo test
cargo run --example analyzeCI (/.github/workflows/ci.yml) runs cargo fmt --check, cargo clippy -D warnings, cargo test --all-features, cargo doc --no-deps, Flutter's
flutter analyze + flutter test, a cross-compile of the engine cdylib for
each Android ABI (asserting the FFI symbols are exported), and the on-device E2E
suite on a headless Android emulator (flutter test integration_test).
The engine is selected at build time from USE_RUST_ENGINE:
- not set (default) — auto: the native Rust engine is used when its
library loads, otherwise the deterministic
FakeEngineService(the dev/demo fallback, also used in tests); =true— require the Rust engine (a missing library becomes a startup error);=false— always the fake.
The Android cdylib is cross-compiled per ABI and packaged as a native library
by app/tool/build_rust_engine_android.sh (NDK clang linker, API 24), which
installs it into app/android/app/src/main/jniLibs/<abi>/ — where Gradle
picks it up and the bare name libgps_engine.so resolves on the device. That
is also why make install and make build-release run it first:
./app/tool/build_rust_engine_android.sh # arm64-v8a + x86_64
cd app && flutter build apk --releaseThe binaries are gitignored build artifacts; rerun the script after a fresh
checkout. To force the fake (demonstration/test builds), use
--dart-define=USE_RUST_ENGINE=false. On host workloads point the build at a
library with --dart-define=GPS_ENGINE_LIB=/path/to/libgps_engine.so; when
empty the app opens the bare name libgps_engine.so. Add
--dart-define=DEV_TOOLS=true to any of these builds to expose the M15
diagnostics entry point.
A plain flutter run/flutter test replays the deterministic demo timeline
(roughly the ~4.8 km "River Loop" walkable on the host — the app's catalog
itself stays empty until the user builds routes), so the flow is explorable
with no phone. To record actual device fixes, build with USE_DEVICE_GPS:
cd app && flutter build apk --debug --dart-define=USE_DEVICE_GPS=truemake build, make install and make build-release pass the define for you,
so the installed app races real GPS while hosts and the emulator E2E keep the
deterministic timeline. Device mode reads 1 Hz fixes through the geolocator
plugin (the app requests ACCESS_FINE_LOCATION on Android and uses
NSLocationWhenInUseUsageDescription on iOS): live position, distance, pace
and the raw-fix buffer all come from the receiver, a real run on a recognised
route snaps onto that route's geometry for the ghost gap, and an unrecognised
line accumulates ground distance as a new route. Movement is gated on the
ground actually covered — a running fix must clear a 0.5 m/s floor (measured
between fixes, or a receiver speed backed by matching displacement), so a
parked phone doesn't count moving time, drift distance, or show a phantom
cruise pace ("— /km") from a stale cached speed. Refusals (services off or
permission denied) surface as the recoverable recording ERROR state. The
diagnostics screen's GPS section prints which source is live.
Build with the diagnostics entry point enabled (M15):
cd app
flutter run --dart-define=DEV_TOOLS=trueA small bug-report button floats at the top right of every tab and opens the diagnostics screen (gated in both the shell and the router): the active engine implementation, the live GPS fix, track / route / ghost readouts, and the persisted recovery snapshot. Export run as fixture JSON serializes the raw fixes retained for the in-flight run (or the most recent saved run) in the M15 fixture schema and copies it to the clipboard after a privacy confirmation:
{"schema_version":1,
"route":[{"lat":..,"lon":..}],
"fixes":[{"timestamp_ms":..,"latitude":..,"longitude":..,
"accuracy_m":..,"altitude_m":..,"speed_mps":..,"bearing_deg":..}]}Raw fixes are the receiver's own observations, captured before any processing
and persisted with the completed activity; sensor fields the device never
reported are emitted as explicit null — never invented — and a recording
with no route geometry refuses export rather than substituting demo data.
Paste the document into rust/gps-engine/tests/fixtures/ and drive it through
the Rust pipeline (GpsTrace::from_json → process → invariants, see
tests/gps_torture.rs) — a real-device GPS bug becomes a permanent regression
test. On the device side the same readout answers "why did the ghost jump?"
without guessing.
cd app && flutter analyze && flutter test # Flutter: 201 tests
cargo test # Rust: 204 tests + property cases
# the same Flutter suite against the real Rust engine over FFI
cargo build --release # produces target/release/libgps_engine.so
cd app && flutter test --dart-define=USE_RUST_ENGINE=true \
--dart-define=GPS_ENGINE_LIB=../target/release/libgps_engine.soThe Flutter tests run headlessly with fake_async, an in-memory store, and
the deterministic fake engine — no phone, GPS chip, or network needed. The
Rust suite additionally replays five checked-in raw-GPS fixtures
(tests/fixtures/clean_loop.json, gps_jitter.json, gps_jump.json,
gps_dropout.json, out_and_back.json) through the filter/quality pipeline —
cargo run --example generate_fixtures regenerates them — and asserts the
trace round-trips through its own JSON. tests/gps_schema.rs pins the fixture
schema (schema_version, explicit null sensor fields, rejection of newer
versions) against the exact document the Flutter exporter emits, and
tests/gps_pipeline.rs drives a fixture end-to-end from raw fixes to a ghost
snapshot.
Milestones M1–M29 are implemented (see CHANGELOG.md). The app
ships with an empty catalog — no pre-recorded routes or demo history — and
records a free run until the user builds routes by finishing runs. On the
host/tests the deterministic fake GPS timeline (the ~4.8 km "River Loop"
fixture) keeps the whole loop explorable with no phone; a USE_DEVICE_GPS=true
build records the phone receiver's real fixes instead.
M15 added the raw-GPS quality model, checked-in replay fixtures, ghost
geometry invariants and continuity-aware matching on the Rust side, plus the
developer diagnostics screen on the app side. It also retains the raw fixes
through the recording lifecycle (exported, never fabricated) and gates the
diagnostics route, not just its entry button.
M16–M29 closed the loop the plan asks for: record, race, result, PB alerts,
entrance motion, responsive layouts, settings, history, consistent error
screens, a WCAG-checked palette and the three-screen first-launch intro —
with main() now persisting settings, routes, activities and snapshots to
the device's application documents directory across restarts.
AGPL-3.0-or-later for the whole repository — the gps-engine crate and the
Flutter app alike. The full text is in LICENSE; the Rust workspace
declares the SPDX id in Cargo.toml and the app in pubspec.yaml, and every
source file carries a SPDX-License-Identifier: AGPL-3.0-or-later header.