Built together because V3-02's settings screen needed something real for V3-03's units to control -- executed in reverse of the ticket numbering, but both tickets are independently complete. V3-03: UnitSystem (metric/imperial) lives in domain/models.dart alongside Activity, defaulting from Platform.localeName on first launch. Storage stays SI everywhere -- conversion happens only in ui/format.dart, at the last possible moment, which is now documented as the file's central invariant. Threaded through all three screens plus the speed histogram's bucket labels, which relabel for display without changing how speedHistogram itself bins. Export was deliberately left untouched: gpx()/geoJson() take no UnitSystem parameter at all, a stronger guarantee than validating one would be. V3-02: SettingsScreen reachable from the record screen. Map render toggle (nothing previously exposed mapEnabledProvider to the user, despite it existing since T15 -- there was nothing to "move off trip detail" as drafted), unit selector, upload endpoint with http(s) validation, a copyable device id, and a licences page via Flutter's built-in showLicensePage. Skipped package_info_plus (static version string instead) and a privacy-policy link (none published yet) as disproportionate to an S-sized ticket. Neither ticket needed the ConfigNotifier the implementation notes proposed: unitSystemProvider reuses the exact StateProvider-seeded-from-Config pattern mapEnabledProvider already established, since Config mutates its own backing SharedPreferences in place and re-assigning the same instance would never notify a watcher anyway. A real locale-dependent flake was caught, not just anticipated: a test asserting a fresh Config defaults to metric failed, because this machine's own locale resolves to a region in the imperial set. Fixed by seeding an explicit value before asserting, and config_test.dart's locale test was written from the start to only prove the fallback path doesn't throw, not to assert which value it returns. 221 tests (211 -> 221): 13 format_test, 9 config_test, 9 settings_screen_test, 1 confirming the record screen's settings button is genuinely wired. Analyze clean. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
4.0 KiB
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
UnitSysteminConfig; default fromPlatform.localeName- Extend
ui/format.dart— every formatter takes the unit system - Thread it through: record screen, trips list, trip detail, charts, map legend
- 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<UnitSystem> 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.