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
This commit is contained in:
@@ -0,0 +1,197 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user