# V3-03 — Distance and speed units **Phase** Foundations · **Depends on** V3-02 · **Size** S · **Status** Done ## Goal Imperial as well as metric, chosen once and applied everywhere. ## Context Everything is hardcoded metric: `formatDistance` switches m/km at 1000, `formatSpeed` prints km/h, elevation prints metres. Fine in Canada, useless to anyone in the US or UK. Cheap, and the kind of thing that makes an app feel unfinished when missing. ## Design A `UnitSystem` enum (`metric`, `imperial`) in `Config`, defaulting from the device locale on first launch. **Conversion belongs in formatting only.** Storage stays SI — metres, km/h, metres of altitude — forever. Converting at the storage layer would corrupt every existing ride and break the parity harness. | Value | Metric | Imperial | |---|---|---| | Distance | m / km | ft / mi | | Speed | km/h | mph | | Elevation | m | ft | ## Implementation 1. `UnitSystem` in `Config`; default from `Platform.localeName` 2. Extend `ui/format.dart` — every formatter takes the unit system 3. Thread it through: record screen, trips list, trip detail, charts, map legend 4. Exports stay SI regardless. GPX is metres by specification; changing that breaks consumers. ## Acceptance criteria - [ ] Switching units updates every screen immediately - [ ] Stored values are unchanged — verified by exporting before and after - [ ] GPX/GeoJSON output is byte-identical across the two settings - [ ] First launch picks a sensible default from the locale ## Tests - Formatter tests for both systems, including the m→km and ft→mi boundaries - **A test asserting export output does not change with the unit setting** - Widget test toggling units and checking a rendered label ## Risks The obvious trap is converting too deep in the stack. Guard it with the export test. ## Out of scope Temperature, pace (min/km) — pace is arguably right for running, revisit after V3-01. ## Outcome Built ahead of V3-02 in execution order, despite the ticket table listing it as depending on V3-02 — the Settings screen needed something real to control, and the formatting/`Config` plumbing itself has zero dependency on a screen existing. Both are done; the numbering is unchanged. `UnitSystem` lives in `domain/models.dart`, not `ui/format.dart` as first drafted — `Config` (a data/preferences-layer class) needed the enum too, and having it depend on `ui/` read backwards. Moved to the domain layer alongside `Activity`, which every other cross-cutting preference-like enum in this codebase already does. Reactivity reuses the exact pattern `mapEnabledProvider` already established — `unitSystemProvider`, a `StateProvider` seeded from `Config` once and then read/written directly by the UI — rather than introducing the heavier `ConfigNotifier` class the ticket's implementation notes proposed. `Config` mutates its own backing `SharedPreferences` in place, so a widget re-assigning the same `Config` instance to `configProvider` was never going to notify anything; this sidesteps that without a new abstraction. Threaded through all three screens plus the speed histogram's bucket labels, which convert-and-round for display (`formatSpeedRangeLabel`) without changing how `speedHistogram` itself bins — binning stays km/h always, matching the invariant that storage and computation never see the display unit. Export was the one place explicitly *not* touched: `gpx()`/`geoJson()` take no `UnitSystem` parameter at all, which is a stronger guarantee than validating one. One real finding: `config_test.dart`'s locale-default test deliberately does not assert a specific value, because it cannot know the test runner's own locale — and that caution was immediately vindicated. `settings_screen_test.dart` first asserted a fresh `Config` defaults to metric and failed, because this dev machine's own locale resolves to a region in the imperial set. Fixed by seeding an explicit value before asserting, the same technique already used elsewhere for exactly this reason. 22 tests: 13 `format_test.dart`, 9 `config_test.dart`.