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,127 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:rippr/src/hud/hud_metric.dart';
import 'package:rippr/src/hud/hud_widget_layout.dart';
/// UI-04: the JSON round-trip and clamp math are exercised directly with fixed inputs
/// -- no real gestures needed to test either, per the ticket's own Tests section.
void main() {
group('JSON round trip', () {
test('encodes and decodes exactly', () {
const original = HudWidgetLayout(
metric: HudMetric.speed,
x: 0.1,
y: 0.2,
width: 0.3,
height: 0.15,
visible: true,
);
final decoded = HudWidgetLayout.fromJson(HudMetric.speed, original.toJson());
expect(decoded.x, original.x);
expect(decoded.y, original.y);
expect(decoded.width, original.width);
expect(decoded.height, original.height);
expect(decoded.visible, original.visible);
});
test('a malformed entry falls back to the default rather than crashing', () {
final decoded = HudWidgetLayout.fromJson(HudMetric.distance, {'x': 'not a number'});
final default_ = HudWidgetLayout.defaultFor(HudMetric.distance);
expect(decoded.x, default_.x);
expect(decoded.width, default_.width);
});
test('a missing field falls back to the default rather than crashing', () {
final decoded = HudWidgetLayout.fromJson(HudMetric.maxSpeed, {'x': 0.1, 'y': 0.1});
final default_ = HudWidgetLayout.defaultFor(HudMetric.maxSpeed);
expect(decoded.x, default_.x);
});
});
group('defaultFor', () {
test('every metric gets a distinct default position', () {
final positions = HudMetric.values
.map(HudWidgetLayout.defaultFor)
.map((l) => '${l.x},${l.y}')
.toSet();
expect(positions.length, HudMetric.values.length,
reason: 'no two metrics should default to overlapping positions');
});
test('the first four metrics (the mockup\'s fixed row) start visible', () {
expect(HudWidgetLayout.defaultFor(HudMetric.speed).visible, isTrue);
expect(HudWidgetLayout.defaultFor(HudMetric.distance).visible, isTrue);
expect(HudWidgetLayout.defaultFor(HudMetric.elapsedTime).visible, isTrue);
expect(HudWidgetLayout.defaultFor(HudMetric.maxSpeed).visible, isTrue);
});
test('metrics beyond the fixed row start hidden', () {
expect(HudWidgetLayout.defaultFor(HudMetric.avgSpeed).visible, isFalse);
expect(HudWidgetLayout.defaultFor(HudMetric.elevationGain).visible, isFalse);
});
test('every default is already within the valid clamp bounds', () {
for (final metric in HudMetric.values) {
final layout = HudWidgetLayout.defaultFor(metric);
expect(layout.clamped().x, layout.x);
expect(layout.clamped().y, layout.y);
expect(layout.clamped().width, layout.width);
expect(layout.clamped().height, layout.height);
}
});
});
group('clamped', () {
const base = HudWidgetLayout(
metric: HudMetric.speed,
x: 0.5,
y: 0.5,
width: 0.3,
height: 0.15,
visible: true,
);
test('a position dragged past the right/bottom edge is pulled back on-screen', () {
final result = base.copyWith(x: 1.5, y: 1.5).clamped();
expect(result.x, 1.0 - base.width);
expect(result.y, 1.0 - base.height);
});
test('a position dragged past the left/top edge is pulled back on-screen', () {
final result = base.copyWith(x: -0.5, y: -0.5).clamped();
expect(result.x, 0.0);
expect(result.y, 0.0);
});
test('a resize below the legibility floor is corrected up to the minimum', () {
final result = base.copyWith(width: 0.01, height: 0.01).clamped();
expect(result.width, hudMinWidthFraction);
expect(result.height, hudMinHeightFraction);
});
test('a resize above the sane ceiling is corrected down to the maximum', () {
final result = base.copyWith(width: 5.0, height: 5.0).clamped();
expect(result.width, hudMaxWidthFraction);
expect(result.height, hudMaxHeightFraction);
});
test('shrinking to fit happens before repositioning, so a widget resized past '
'the edge shrinks rather than silently relocates', () {
final result = base.copyWith(x: 0.9, width: 0.7).clamped();
expect(result.width, hudMaxWidthFraction);
// x itself is still clamped against the (now-bounded) width so the widget
// never sits partially off-screen either.
expect(result.x + result.width, lessThanOrEqualTo(1.0));
});
test('an already-valid layout is unchanged', () {
expect(base.clamped().x, base.x);
expect(base.clamped().y, base.y);
expect(base.clamped().width, base.width);
expect(base.clamped().height, base.height);
});
});
}