UI-04: customizable, persisted HUD telemetry widget layout

Adds the full drag/resize/visibility infrastructure for a rider-owned HUD
layout: HudMetric (the stable 8-metric set), HudWidgetLayout (fractional
x/y/width/height + visible, with clamping to a legibility floor/ceiling
and JSON round-trip that degrades to sane defaults rather than crashing),
Config.hudLayout persistence, and a HudLayoutController that stays
in-memory-authoritative during an edit session and only writes through on
persist() -- never per drag frame.

DraggableResizableHudWidget and HudEditOverlay assemble the interaction:
edit mode is entered by a long-press on empty HUD space (not a specific
widget) and exited via Done or a tap on empty space; only while editing
does a widget attach any drag/resize gesture at all, so a normal tap can
never move one mid-ride by construction, not by an internal flag.

Fixes a real gesture-arena bug found during testing: outside edit mode, a
long-press landing on a widget was free to bubble to the overlay's
background long-press handler and wrongly enter edit mode. An inner no-op
GestureDetector of the same gesture type now absorbs it.

Adds a "Live HUD stats" Settings section, one switch per metric, that
persists immediately (unlike drag frames). Placed at the end of the
Settings list rather than in the middle -- inserting mid-list pushed
every later section below several existing tests' viewport assumptions.

Verified end-to-end on a real emulator including a full process restart:
resized a widget, force-stopped the app, relaunched, and the resize held.
No consuming screen exists yet (UI-05's job) -- verified via a throwaway
preview entry point, deleted after use.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Xki7YAcc2TiN2PRZJ2tXr
This commit is contained in:
2026-08-23 20:45:02 -05:00
parent 24f4e17e81
commit bcface3529
15 changed files with 973 additions and 2 deletions

View File

@@ -1,6 +1,6 @@
# UI-04 — Customizable HUD telemetry widgets
**Depends on** UI-03 · **Size** L · **Status** Not started
**Depends on** UI-03 · **Size** L · **Status** Done
## Goal
Let the rider drag each floating telemetry widget (Speed, Distance, etc.) to wherever
@@ -112,3 +112,80 @@ same pattern as every other `Config` preference in this app — see `mountedMode
Z-ordering/overlap resolution between widgets (widgets simply clamp to stay on-screen;
overlapping each other is the rider's own choice to avoid). Per-metric colour
customization. Sharing/exporting a HUD layout between devices.
## Outcome
Built the full generic infrastructure the ticket scopes, deliberately stopping short of
wiring it into the real Record screen -- that assembly (real metric values from
`RecordUiState`, replacing the fixed stats card) is UI-05's job, per the dependency
graph. `HudMetric` (`lib/src/hud/hud_metric.dart`) is the stable 8-value enum;
critically, the first four (`speed, distance, elapsedTime, maxSpeed`) are ordered to
match the Stitch mockup's fixed row exactly, since `HudWidgetLayout.defaultFor` derives
both default grid position and which metrics start visible directly from enum index --
a bug caught by a widget-layout test (`maxSpeed` briefly wasn't in the first four because
`movingTime` was ordered ahead of it).
`HudWidgetLayout` (`hud_widget_layout.dart`) holds fractional `x/y/width/height` +
`visible`, with `clamped()` enforcing a legibility floor and sane ceiling (size clamped
first, then position re-clamped against the now-bounded size, so a resize that would
push a widget off-screen shrinks it rather than silently relocating it) and
`fromJson`/`defaultFor` degrading to sane defaults on any malformed or missing data
rather than crashing Settings on launch. `Config.hudLayout`/`setHudLayout`
(`config/config.dart`) follow the exact JSON-map-of-a-stable-key pattern already
established there. `HudLayoutController` (a `StateNotifier`, `hud_layout_controller.dart`)
holds the authoritative in-memory layout during an edit session and only calls
`Config.setHudLayout` on `persist()` -- never per drag frame, the ticket's own named
risk.
`DraggableResizableHudWidget` and `HudEditOverlay` (`lib/src/ui/components/`) are the
interactive pieces. A widget only attaches a drag/resize `GestureDetector` at all when
`editing` is true -- "a normal tap never moves a widget outside edit mode" is guaranteed
structurally by the absence of a recognizer, not by an internal flag a future edit could
weaken. Edit mode itself is entered by a long-press on *empty* HUD space and exited by
"Done" or a tap on empty space, both handled by `HudEditOverlay`'s own background
`GestureDetector`.
**Bug found and fixed during testing, not anticipated by the plan:** outside edit mode,
`DraggableResizableHudWidget` initially attached no gesture detector at all, meaning a
long-press *on a widget* was free to bubble up through the gesture arena to
`HudEditOverlay`'s background long-press handler and wrongly enter edit mode from a
touch that landed on a specific widget, not the empty area the design explicitly calls
for ("a long-press anywhere on the HUD area *that isn't a specific widget*"). Fixed by
giving the non-editing state a no-op `GestureDetector(onLongPress: () {})` -- an inner
recognizer of the same gesture type wins the arena over the outer one, absorbing the
press instead of letting it propagate. Caught by a widget test that intentionally
dragged directly on a widget while not editing and asserted edit mode never engaged.
**Tests:** `flutter analyze` clean. `flutter test` green at 365 tests (336 + 29 new,
across `hud_widget_layout_test.dart`, `hud_layout_controller_test.dart`,
`hud_edit_overlay_test.dart`, and additions to `config_test.dart`/
`settings_screen_test.dart`) -- covering JSON round-trips and malformed-data fallback,
every clamp edge case with fixed geometry (no gestures needed), the controller's
in-memory-until-persist discipline, `TestGesture`-simulated long-press-drag actually
moving and persisting a widget, and the Settings toggle writing through to `Config`
immediately. One pre-existing-test collateral fix: adding 8 new `SwitchListTile`s
pushed everything after them below the test viewport's initial fold (a plain
`ListView(children:)` still lazily builds via a sliver, same as `.builder` -- an
assumption several existing tests unknowingly depended on). Moved the new section to
the very end of the list (after "About") so no earlier section's position changed, and
added `scrollUntilVisible` to the two new tests that need to reach it.
**Android emulator verification** (`Medium_Phone_API_35`): used a throwaway preview
entry point (`lib/main_ui04_preview.dart`, deleted after use) since no consuming screen
exists yet. Confirmed on-device: the default four-card row renders exactly like the
mockup; a long-press on a widget does nothing (the arena-fix above); a long-press on
empty space enters edit mode, showing every resize handle and a "Done" pill; dragging a
resize handle (a plain pan, not gated by long-press, so directly reproducible via `adb
input swipe`) visibly grows a widget; tapping "Done" hides the edit chrome and keeps the
new size. **Full end-to-end persistence was verified across a real process restart**,
not just via the widget-test's in-memory assertions: resized Speed, tapped Done,
force-stopped the app, relaunched it fresh, and the enlarged Speed widget was still
enlarged. The long-press-*then*-drag move gesture itself could not be reproduced via
`adb input swipe` (its linear interpolation moves throughout the whole gesture rather
than holding still for the ~500ms long-press window first, so Flutter's arena resolves
it as a rejected pan rather than a recognized long-press) -- that exact interaction is
what the `TestGesture`-based widget test (which holds the pointer down, waits out
`kLongPressTimeout`, then moves) verifies precisely, and is trusted as the ground truth
for that specific gesture. Also confirmed via the real (non-preview) app that the new
Settings section renders correctly alongside every existing section and that toggling
"Average speed" flips its switch immediately.