Files
rippr/docs/ui-redesign/UI-04-customizable-hud-widgets.md
uhryniuk fa6b1b8ef2 UI-05: rebuild Record screen as the Map HUD (design north star)
Replaces the scrolling stats-card layout with the full-bleed Map HUD:
UI-04's draggable/resizable HUD widgets float over UI-01's shared
background map (full opacity on this tab), and the old Pause/Stop/Resume/
Discard buttons become full-bleed, icon-only segments matching the
mockup's tertiary-container/error-container colors exactly. The idle
state keeps its prior compact layout deliberately -- HUD widgets only
appear once a ride exists, preserving the existing "a resting screen
must not look like a ride going nowhere" guarantee rather than
reinterpreting it.

State-machine logic (ticker, wakelock, speed subscription, error
handling) is untouched; every pre-existing record-screen test passed
unchanged against the rebuilt screen. Added tests that actually tap
Start/Pause/Stop and verify engine state changes, confirm HUD widgets
render over the map rather than replacing it, and verify the
mounted-mode speed digit's real contrast ratio against GlassPanel's
translucent surface specifically (the ticket's own named risk).

Corrects a UI-04 mistake found while implementing this ticket: the HUD's
default four metrics were ordered Speed/Distance/Elapsed/Max Speed, a
guess made before reading the actual Map HUD mockup HTML closely. The
real fixed row is Speed/Avg Speed/Dist/Time -- reordered HudMetric to
match and updated every test that asserted the old order.

Adds PulsingLocationMarker (UI-03) to RideMap's live usage via a new
showLocationMarker flag, and explicit tertiaryContainer/errorContainer
tokens to ripprColors so the control bar matches the design system's
literal values rather than an auto-derived tonal palette.

Verified end-to-end on a real emulator: Start, Pause, Resume, and Stop
all correctly drive the trip state machine with the full live map behind
everything. A lengthy false alarm during this verification (taps
appearing to do nothing) turned out to be a screenshot-scale
mis-measurement on the verification side, not an app defect -- resolved
by sampling pixel colors directly from the raw screenshot to find the
control bar's true on-screen position.

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

12 KiB

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