UI-04: customizable, persisted HUD telemetry widget layout

Adds the full drag/resize/visibility infrastructure for a rider-owned HUD
layout: HudMetric (the stable 8-metric set), HudWidgetLayout (fractional
x/y/width/height + visible, with clamping to a legibility floor/ceiling
and JSON round-trip that degrades to sane defaults rather than crashing),
Config.hudLayout persistence, and a HudLayoutController that stays
in-memory-authoritative during an edit session and only writes through on
persist() -- never per drag frame.

DraggableResizableHudWidget and HudEditOverlay assemble the interaction:
edit mode is entered by a long-press on empty HUD space (not a specific
widget) and exited via Done or a tap on empty space; only while editing
does a widget attach any drag/resize gesture at all, so a normal tap can
never move one mid-ride by construction, not by an internal flag.

Fixes a real gesture-arena bug found during testing: outside edit mode, a
long-press landing on a widget was free to bubble to the overlay's
background long-press handler and wrongly enter edit mode. An inner no-op
GestureDetector of the same gesture type now absorbs it.

Adds a "Live HUD stats" Settings section, one switch per metric, that
persists immediately (unlike drag frames). Placed at the end of the
Settings list rather than in the middle -- inserting mid-list pushed
every later section below several existing tests' viewport assumptions.

Verified end-to-end on a real emulator including a full process restart:
resized a widget, force-stopped the app, relaunched, and the resize held.
No consuming screen exists yet (UI-05's job) -- verified via a throwaway
preview entry point, deleted after use.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Xki7YAcc2TiN2PRZJ2tXr
This commit is contained in:
2026-08-23 20:45:02 -05:00
parent 24f4e17e81
commit bcface3529
15 changed files with 973 additions and 2 deletions

View File

@@ -0,0 +1,121 @@
/// UI-04: one telemetry widget's position, size, and visibility on the customizable
/// HUD -- see the ticket's Design section for why these are fractions (0.0-1.0) of the
/// available HUD area rather than absolute pixels: a layout saved on one device or
/// orientation still makes sense on another.
library;
import 'hud_metric.dart';
/// Legibility floor and a sane ceiling -- a widget must never shrink to the point its
/// own number is unreadable, or grow to the point it swallows the whole HUD.
const double hudMinWidthFraction = 0.20;
const double hudMaxWidthFraction = 0.70;
const double hudMinHeightFraction = 0.08;
const double hudMaxHeightFraction = 0.40;
class HudWidgetLayout {
const HudWidgetLayout({
required this.metric,
required this.x,
required this.y,
required this.width,
required this.height,
required this.visible,
});
final HudMetric metric;
/// Top-left corner, as a fraction of the HUD area's width/height.
final double x;
final double y;
final double width;
final double height;
final bool visible;
HudWidgetLayout copyWith({
double? x,
double? y,
double? width,
double? height,
bool? visible,
}) => HudWidgetLayout(
metric: metric,
x: x ?? this.x,
y: y ?? this.y,
width: width ?? this.width,
height: height ?? this.height,
visible: visible ?? this.visible,
);
/// Corrects a drag/resize result that ended outside the allowed area back to the
/// nearest valid position/size -- clamped to the [hudMinWidthFraction]/
/// [hudMaxWidthFraction] etc. bounds first (size), then positioned so it can never
/// sit even partially outside the 0.0-1.0 HUD area (position), in that order: a
/// resize that would push a widget off-screen should shrink it back on-screen, not
/// silently reposition it out from under the rider's finger.
HudWidgetLayout clamped() {
final clampedWidth = width.clamp(hudMinWidthFraction, hudMaxWidthFraction);
final clampedHeight = height.clamp(hudMinHeightFraction, hudMaxHeightFraction);
final clampedX = x.clamp(0.0, 1.0 - clampedWidth);
final clampedY = y.clamp(0.0, 1.0 - clampedHeight);
return HudWidgetLayout(
metric: metric,
x: clampedX,
y: clampedY,
width: clampedWidth,
height: clampedHeight,
visible: visible,
);
}
Map<String, dynamic> toJson() => {
'x': x,
'y': y,
'width': width,
'height': height,
'visible': visible,
};
/// Falls back to [defaultFor] rather than throwing on a malformed/partial entry --
/// an old saved layout from a future app version with fields this version doesn't
/// recognise should degrade to a sane default, not crash Settings on launch.
static HudWidgetLayout fromJson(HudMetric metric, Map<String, dynamic> json) {
try {
return HudWidgetLayout(
metric: metric,
x: (json['x'] as num).toDouble(),
y: (json['y'] as num).toDouble(),
width: (json['width'] as num).toDouble(),
height: (json['height'] as num).toDouble(),
visible: json['visible'] as bool,
).clamped();
} catch (_) {
return HudWidgetLayout.defaultFor(metric);
}
}
/// A deterministic starting grid -- a fresh install has a working, if plain, HUD
/// before the rider customises anything, and a metric toggled on for the first time
/// (with no saved position) lands somewhere sane rather than stacked on another
/// widget. Two rows of four, matching the Stitch mockup's row of cards for however
/// many metrics fit in the first row, with the rest continuing below it.
factory HudWidgetLayout.defaultFor(HudMetric metric) {
const columns = 4;
const cellWidth = 0.22;
const cellHeight = 0.12;
const gap = 0.02;
final index = HudMetric.values.indexOf(metric);
final row = index ~/ columns;
final col = index % columns;
return HudWidgetLayout(
metric: metric,
x: 0.02 + col * (cellWidth + gap),
y: 0.06 + row * (cellHeight + gap),
width: cellWidth,
height: cellHeight,
// The mockup's own fixed row is Speed/Distance/Elapsed/Max speed -- the first
// four enum values are ordered to match, so only those start visible.
visible: index < columns,
);
}
}