V3-02 + V3-03: settings screen, and metric/imperial units

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>
This commit is contained in:
2026-08-17 14:23:43 -05:00
parent 6f1bfc753a
commit 37fba4784a
15 changed files with 912 additions and 30 deletions

View File

@@ -1,6 +1,6 @@
# V3-02 — Settings screen
**Phase** Foundations · **Depends on** nothing (pairs with V3-01, V3-03) · **Size** S · **Status** Not started
**Phase** Foundations · **Depends on** nothing (pairs with V3-01, V3-03) · **Size** S · **Status** Done
## Goal
One place for the preferences that currently have nowhere to live.
@@ -47,3 +47,40 @@ without introducing a second source of truth is the only subtle part.
## Out of scope
Account settings (v4). Theme selection (V3-16).
## Outcome
"Move the map toggle off trip detail" (implementation step 3) turned out to be moot —
`mapEnabledProvider` already existed and drove `RideMap`'s visibility, but **no widget
anywhere ever offered a control to change it**. There was nothing to move. Settings adds
the first one.
No `ConfigNotifier` was built — see V3-03's outcome for why the existing
`mapEnabledProvider`-style `StateProvider` pattern covers reactivity without it, and
`unitSystemProvider` was added the same way, in `providers.dart`, ahead of this ticket.
Two implementation-step items shipped differently than drafted, both to avoid adding a
dependency disproportionate to an `S`-sized settings screen:
- **No `package_info_plus`.** The About section's version string is a static literal
matching `pubspec.yaml`'s `1.0.0+1`, not a live package lookup. Fine today; would need
revisiting if the version ever needs to be authoritative from inside the running app
rather than copied by hand.
- **No privacy-policy link.** None is published yet (see `docs/LAUNCH.md`) and linking
to one that does not exist would be worse than omitting it. Shows a plain note instead.
Licences are still free: Flutter's built-in `showLicensePage` needed no new dependency.
Endpoint validation accepts `http://` and `https://` with a non-empty host, and treats
an **empty** field as valid — that is how upload gets disabled, not an error state. A
non-empty invalid value is rejected with inline `errorText` and never reaches `Config`;
proven by a widget test that types garbage, taps Save, and asserts `Config.uploadEndpoint`
is still empty afterwards.
`uploaderProvider` needed an explicit `ref.invalidate()` after saving the endpoint, for
the identical reason `unitSystemProvider` needed its own `StateProvider` rather than
reading through `configProvider` directly — a `Config` write never changes the `Config`
instance Riverpod is watching, so nothing downstream rebuilds unless told to.
10 tests: 9 in `settings_screen_test.dart`, 1 confirming the record screen's settings
button is genuinely wired (not just present) in `widget_test.dart`.

View File

@@ -1,6 +1,6 @@
# V3-03 — Distance and speed units
**Phase** Foundations · **Depends on** V3-02 · **Size** S · **Status** Not started
**Phase** Foundations · **Depends on** V3-02 · **Size** S · **Status** Done
## Goal
Imperial as well as metric, chosen once and applied everywhere.
@@ -48,3 +48,40 @@ The obvious trap is converting too deep in the stack. Guard it with the export t
## 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`.