# UI-04 — Customizable HUD telemetry widgets **Depends on** UI-03 · **Size** L · **Status** Done ## Goal Let the rider drag each floating telemetry widget (Speed, Distance, etc.) to wherever they want it on screen, resize it, and choose in Settings which metrics appear live at all — a persisted, personal HUD layout rather than a fixed one. ## Context The Stitch Map HUD mockup has a fixed horizontal row of four telemetry cards with one interaction: tap a card to temporarily expand it, tap again (or tap another) to collapse it back. That's a reasonable default, but by explicit instruction the actual goal is further than that: **the rider owns the layout.** Hold-and-drag to reposition, resize by dragging a handle, and a Settings screen listing every available metric with a toggle for whether it's shown at all. This supersedes the mockup's tap-to-expand interaction rather than coexisting with it — see Design. ## Design **Data model.** A `HudWidgetLayout` per metric: ``` { metricId, x, y, width, height, visible } ``` `x`/`y`/`width`/`height` stored as fractions of the available HUD area (0.0-1.0), not absolute pixels, so a layout saved on one device/orientation still makes sense on another. `metricId` is a stable enum (`speed`, `avgSpeed`, `distance`, `elapsedTime`, `maxSpeed`, `elevationGain`, ...) — the same set `RecordUiState`/`Trip` already expose, not a new data source. **Available metrics vs. visible metrics.** Every metric the app already tracks is a candidate; `visible` (toggled in Settings, see below) controls whether it currently has a HUD widget at all. A metric toggled off entirely has no position/size to speak of until turned back on, at which point it gets a sane default slot (not wherever it happened to be last, unless a position was already saved). **Interaction:** - **Move:** long-press-and-drag anywhere on a widget's `GlassPanel` body. A brief haptic and a subtle scale-up on press-start signal "this is now grabbable," matching the weight of a real physical action rather than an accidental tap. - **Resize:** a small drag handle in one corner, visible only while a widget is in "edit mode" (see below), not in every-day use — a resize handle sitting on screen permanently during a live ride is visual noise the rider doesn't need mid-ride. - **Edit mode:** entered explicitly (e.g. a long-press anywhere on the HUD area that isn't a specific widget, or a dedicated "Edit HUD" toggle from Settings/a long-press on empty HUD space) rather than every telemetry widget always being draggable — an always-draggable widget risks an accidental drag mid-ride when the rider meant to tap it for something else. Exiting edit mode (tap "Done", or tap empty space) persists the layout. - **Constraints:** every widget clamps to stay fully within the safe HUD area on drag/ resize end — never under the bottom nav bar, never off-screen, never smaller than a legibility floor (the number must stay readable) or larger than some sane ceiling. **Settings integration.** A new "Live HUD stats" section: a checkbox/switch per available metric, controlling `visible`. Order in the list is stable (doesn't reflect current HUD position) so it's easy to scan. **Persistence.** One `Config` field (JSON-encoded map of `metricId` → `HudWidgetLayout`), same pattern as every other `Config` preference in this app — see `mountedMode`, `crashReportingEnabled` for the shape to follow. ## Implementation 1. `HudWidgetLayout` model + JSON encode/decode, with defaults for every known metric (a sensible starting grid, e.g. the Stitch mockup's horizontal row) so a fresh install has a working, if plain, HUD before the rider customizes anything. 2. `Config.hudLayout` getter/setter, following the established `Config` pattern. 3. `HudLayoutController` (or a `Riverpod` `StateNotifier`) holding the current layout in memory, seeded from `Config`, written back to `Config` on every edit-mode exit — not on every drag frame, to avoid hammering `SharedPreferences` mid-drag. 4. `DraggableResizableHudWidget`: wraps a telemetry `GlassPanel`, handles the long-press- to-grab gesture, the resize handle, and the clamp-on-release logic. 5. `HudEditOverlay`: the edit-mode chrome (resize handles, a "Done" affordance, maybe a subtle grid/snap guide) shown only while editing. 6. Settings section: "Live HUD stats," one row per metric with a switch bound to `visible`. ## Acceptance criteria - [ ] A telemetry widget can be dragged to a new position and the new position survives an app restart - [ ] A telemetry widget can be resized within sane min/max bounds - [ ] Dragging or resizing never leaves a widget partially off-screen or behind the nav bar - [ ] Toggling a metric off in Settings removes its HUD widget immediately; toggling it back on restores it (at its last saved position if one exists, otherwise a default) - [ ] Outside of edit mode, a normal tap on a telemetry widget does not move it (no accidental drags during a ride) - [ ] Layout is per-install (`Config`), not per-ride ## Tests - Unit: `HudWidgetLayout` JSON round-trips exactly; unknown/missing metric ids on load fall back to defaults rather than crashing - Unit: clamp logic — a drag/resize ending outside allowed bounds is corrected to the nearest valid position/size, exercised with fixed geometry inputs (no real gestures needed to test the math) - Widget: drag gesture moves a widget and the moved position is what gets persisted (simulate via `TestGesture`, matching how other drag interactions in this codebase are tested) - Widget: toggling a metric's Settings switch adds/removes its HUD widget - Widget: a tap outside edit mode does not trigger a move ## Risks - **Scope creep toward a general-purpose layout editor.** Keep the interaction minimal: drag, resize, toggle visibility. No z-ordering, no custom widget shapes, no per-metric color customization — those are separate future tickets if wanted, not this one. - Persisting on every drag frame would thrash `SharedPreferences`; persist only on edit-mode exit, keep in-memory state authoritative during an active drag. - This ticket removes the Stitch mockup's tap-to-expand interaction rather than layering on top of it — a widget the rider has manually resized should not also silently resize itself on tap, which would fight the rider's own choice. ## Out of scope 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 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. **Correction (UI-05):** this ticket originally ordered those first four as `speed, distance, elapsedTime, maxSpeed` -- a guess made without having read the actual Map HUD mockup HTML closely yet. Implementing UI-05 against that same HTML directly showed the real fixed row is Speed/**Avg Speed**/Dist/Time. The enum was reordered to `speed, avgSpeed, distance, elapsedTime, maxSpeed, movingTime, elevationGain, pointsCaptured` and every test here that had asserted the old order was updated alongside it -- see UI-05's own Outcome for the full account. `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.