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
198 lines
12 KiB
Markdown
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.
|