Files
samplez/rippr-flutter-src/docs/ui-redesign/UI-04-customizable-hud-widgets.md
uhryniuk 587f75eab4 Refresh the Flutter snapshot: UI-01 through UI-09, plus a fresh installable APK
Full UI redesign pass complete: persistent tab shell with an always-visible
background map, Modern Professional Dark theme, monochrome dark map tiles,
offline skeleton map, a shared GlassPanel/FloatingPill component kit,
customizable HUD telemetry widgets, and the Map HUD / Plan & Route Planning /
Rides History screen rebuilds. 374 tests passing, up from 316.

The APK is a fresh release build (debug-signed, no release signing config
exists yet).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Xki7YAcc2TiN2PRZJ2tXr
2026-08-24 14:41:35 -05:00

198 lines
12 KiB
Markdown

# 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.