Files
rippr/docs/feedback/FB-03-grid-snapped-hud-widgets.md
uhryniuk 2d118b46a2 FB-03: grid-snapped HUD widgets with auto-fit, centered text
Replace free-form fractional HUD widget positioning with a 4x8 int grid
(col/row/colSpan/rowSpan), reject drag/resize moves that would overlap
another visible widget, and re-place a re-enabled metric into the next
free grid slot instead of stacking it on an occupier. Drag/resize now
track a local pixel offset during the gesture and only snap/commit once
at gesture end. HUD widget text is wrapped in FittedBox(scaleDown) so it
never overflows and stays centered at any grid size.
2026-08-24 15:53:00 -05:00

662 lines
32 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# FB-03 — Grid-snapped HUD widgets with auto-fit, centered text
**Depends on** — · **Size** L · **Status** Done
## Goal
Rework the customizable HUD telemetry widgets (built in UI-04) from free-form fractional
positioning into an Android-home-screen-style snapped grid — drag and resize stick to
grid cells, a placement that would overlap another widget is rejected rather than
silently stacking — and make each widget's label/value text scale to fill its own
current size instead of a fixed font that can overflow when small or look lost when
large.
## Context
Direct user feedback (`docs/FEEDBACK.md`, "Map Page" section):
> Speed and other widgets should only appear when recording starts. When toggling off
> different statistics, the flow and arrangement of the widgets goes crazy. This should
> be "drag and drop" but they stick to a grid much like the home screen on android, and
> they can be resizable like android widgets too. Make sure the font and values are
> centered in the widgets as well, and scale to the size of the widget (no over or
> underflow).
(The "only appear when recording starts" half is FB-02, already scoped separately —
this ticket is the grid/drag/resize/text-fit half.)
### Today's model — free-form fractions, no grid at all
`lib/src/hud/hud_widget_layout.dart` (full file, 122 lines):
```dart
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;
final double x; // top-left, fraction of the HUD area
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 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};
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);
}
}
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,
visible: index < columns, // only the first 4 (Speed/Avg Speed/Dist/Time) start visible
);
}
}
```
`HudMetric.values` order (`lib/src/hud/hud_metric.dart`, load-bearing — `defaultFor` uses
this indexing directly): `speed, avgSpeed, distance, elapsedTime, maxSpeed, movingTime,
elevationGain, pointsCaptured` (8 total; first 4 start visible).
`lib/src/hud/hud_layout_controller.dart` (full file, 52 lines) — the in-memory,
authoritative layout during an edit session, persisted to `Config` only on exit:
```dart
class HudLayoutController extends StateNotifier<Map<HudMetric, HudWidgetLayout>> {
HudLayoutController(this._config)
: super(_config?.hudLayout ?? {for (final m in HudMetric.values) m: HudWidgetLayout.defaultFor(m)});
final Config? _config;
void updatePosition(HudMetric metric, double x, double y) {
final current = state[metric];
if (current == null) return;
state = {...state, metric: current.copyWith(x: x, y: y).clamped()};
}
void updateSize(HudMetric metric, double width, double height) {
final current = state[metric];
if (current == null) return;
state = {...state, metric: current.copyWith(width: width, height: height).clamped()};
}
void setVisible(HudMetric metric, bool visible) {
final current = state[metric] ?? HudWidgetLayout.defaultFor(metric);
state = {...state, metric: current.copyWith(visible: visible)};
}
Future<void> persist() async => _config?.setHudLayout(state);
}
```
**Why "toggling widgets goes crazy" happens today**: `Config.hudLayout`'s getter
(`lib/src/config/config.dart` ~lines 95-113) always returns an entry for *every*
`HudMetric`, falling back to `HudWidgetLayout.defaultFor` for any metric never
explicitly saved — so `setVisible`'s `state[metric] ?? defaultFor(metric)` fallback is
essentially dead code; every metric already has a stored layout the moment the
controller exists. That stored layout is whatever it was the last time that metric was
visible (or its untouched `defaultFor` position if it's never been shown/moved). Nothing
about `setVisible`, `defaultFor`, or `clamped()` is aware of what other widgets
currently occupy — `clamped()` only keeps a single widget's own rectangle inside the
0.0–1.0 area, it has no concept of a sibling. So: drag widget A to overlap where widget
B's (currently hidden) default position sits, then re-enable B in Settings — B pops up
directly on top of A. Resize A larger (up to `hudMaxWidthFraction = 0.70`) and it can
swallow B's or C's default slot the same way. This reads as "the whole layout goes
crazy" even though, strictly, no *other* widget's own stored position ever actually
changes — the illusion is caused by newly-visible widgets landing on top of
already-visible ones with zero collision awareness.
### Drag/resize gesture wiring (continuous, per-frame, into shared state)
`lib/src/ui/components/draggable_resizable_hud_widget.dart` (`_DraggableResizableHudWidgetState.build`,
~lines 54-132) computes pixel position/size from `widget.layout`'s fractions × `widget.areaSize`
every build, and the drag/resize gesture handlers call the parent's callbacks **on every
frame of movement**, not just at gesture end:
```dart
onLongPressMoveUpdate: (details) {
widget.onMoved(
layout.x + details.offsetFromOrigin.dx / widget.areaSize.width,
layout.y + details.offsetFromOrigin.dy / widget.areaSize.height,
);
},
...
onPanUpdate: (details) { // the resize handle
widget.onResized(
layout.width + details.delta.dx / widget.areaSize.width,
layout.height + details.delta.dy / widget.areaSize.height,
);
},
```
`onMoved`/`onResized` are wired straight to `HudLayoutController.updatePosition`/
`updateSize` in `lib/src/ui/components/hud_edit_overlay.dart` (~lines 62-69):
```dart
DraggableResizableHudWidget(
key: ValueKey(entry.key),
layout: entry.value,
areaSize: areaSize,
editing: _editing,
onMoved: (x, y) => notifier.updatePosition(entry.key, x, y),
onResized: (w, h) => notifier.updateSize(entry.key, w, h),
child: widget.metricBuilder(context, entry.key),
),
```
So every pixel of drag movement recomputes and commits a new fraction to the shared
`StateNotifier` (though `persist()` to `SharedPreferences` still only happens on exit —
that part is fine and unchanged). This continuous-commit model does not translate
directly to a grid: an integer `col`/`row` can't represent "40% of the way toward the
next cell," so this ticket changes drag/resize to track a **local, uncommitted pixel
offset** during the gesture and only calls the parent callback once, with final snapped
grid coordinates, at gesture end. See Design below.
### Fixed-size text, no auto-fit
`lib/src/ui/record/record_screen.dart`, `_HudMetricValue.build()` (~lines 321-346):
```dart
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
metric.label.toUpperCase(),
style: TextStyle(fontSize: 10, letterSpacing: 1, color: colors.onSurfaceVariant),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Text(
_value,
style: monoDigits.copyWith(fontSize: 18, fontWeight: FontWeight.bold, color: _valueColor(colors)),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
);
}
```
Font sizes are hardcoded regardless of the widget's actual current size — at the
smallest allowed size this can ellipsize; at the largest allowed size the same small
text looks lost in a big glass panel. `DraggableResizableHudWidget` just centers
whatever child it's given (`GlassPanel(child: Center(child: widget.child))`,
~line 64) — no text-scaling logic exists anywhere in this chain.
### Persistence — no migration needed
`HudWidgetLayout.fromJson`'s existing `try { ... } catch (_) { return
HudWidgetLayout.defaultFor(metric); }` already handles a field-shape change for free: an
old saved JSON blob has `x`/`y`/`width`/`height` keys (doubles); this ticket's new
`fromJson` reads `col`/`row`/`colSpan`/`rowSpan` (ints) and will throw on old data
(missing key, or a cast failure), falling back to `defaultFor` automatically — no schema
version, no explicit migration code. Confirmed reasonable: this is local
`SharedPreferences`, not a shared/synced format, and there's no production data to
preserve.
## Design
### Grid model
Fixed **4 columns × 8 rows** spanning the HUD area (matches the current 4-column
default-row assumption and gives ample vertical room for the 8-metric list). Replace
`HudWidgetLayout`'s `x`/`y`/`width`/`height` doubles with:
```dart
const int hudGridColumns = 4;
const int hudGridRows = 8;
const int hudMinSpan = 1;
const int hudMaxColSpan = 4;
const int hudMaxRowSpan = 3;
class HudWidgetLayout {
const HudWidgetLayout({
required this.metric,
required this.col,
required this.row,
required this.colSpan,
required this.rowSpan,
required this.visible,
});
final HudMetric metric;
final int col;
final int row;
final int colSpan;
final int rowSpan;
final bool visible;
...
}
```
- **`clampedToGrid()`** replaces `clamped()` — a *self-contained* bounds check with no
awareness of siblings (kept as its own method because `fromJson` and
`defaultFor`/`nextFreeSlot` all still need "is this rectangle even inside the grid"
independent of collision):
```dart
HudWidgetLayout clampedToGrid() {
final clampedColSpan = colSpan.clamp(hudMinSpan, hudMaxColSpan);
final clampedRowSpan = rowSpan.clamp(hudMinSpan, hudMaxRowSpan);
final clampedCol = col.clamp(0, hudGridColumns - clampedColSpan);
final clampedRow = row.clamp(0, hudGridRows - clampedRowSpan);
return HudWidgetLayout(metric: metric, col: clampedCol, row: clampedRow,
colSpan: clampedColSpan, rowSpan: clampedRowSpan, visible: visible);
}
```
- **Collision helper**, a static/top-level function usable by both the layout file and
the controller:
```dart
bool hudRectsOverlap(HudWidgetLayout a, HudWidgetLayout b) {
final aColEnd = a.col + a.colSpan;
final aRowEnd = a.row + a.rowSpan;
final bColEnd = b.col + b.colSpan;
final bRowEnd = b.row + b.rowSpan;
return a.col < bColEnd && aColEnd > b.col && a.row < bRowEnd && aRowEnd > b.row;
}
```
- **`defaultFor`/`nextFreeSlot`**: keep `defaultFor(metric)` for the *static* initial
layout (unchanged conceptually — still purely a function of the metric's own index,
no runtime state needed, since the 8 fixed default slots below never overlap each
other by construction):
```dart
factory HudWidgetLayout.defaultFor(HudMetric metric) {
const columns = 4;
const rowSpan = 2;
final index = HudMetric.values.indexOf(metric);
final row = (index ~/ columns) * rowSpan;
final col = index % columns;
return HudWidgetLayout(
metric: metric, col: col, row: row, colSpan: 1, rowSpan: rowSpan,
visible: index < columns,
);
}
```
(Speed/AvgSpeed/Distance/ElapsedTime → row 0, cols 0-3, visible; MaxSpeed/MovingTime/
ElevationGain/PointsCaptured → row 2, cols 0-3, hidden — 8 distinct, non-overlapping
slots, same invariant the existing `defaultFor` test already checks.)
Add a **new** function for the actual re-enable-without-collision fix:
```dart
/// Scans row-major from (0,0) for the first colSpan×rowSpan slot that doesn't
/// overlap any `visible` entry in [occupied]. Falls back to (0,0) unconditionally
/// if the grid is fully packed -- a stacked default is better than a crash or an
/// exception the rider can't do anything about.
static HudWidgetLayout nextFreeSlot(
HudMetric metric, {
required Map<HudMetric, HudWidgetLayout> occupied,
int colSpan = 1,
int rowSpan = 2,
}) {
final candidate = (int col, int row) => HudWidgetLayout(
metric: metric, col: col, row: row, colSpan: colSpan, rowSpan: rowSpan, visible: true,
);
for (var row = 0; row <= hudGridRows - rowSpan; row++) {
for (var col = 0; col <= hudGridColumns - colSpan; col++) {
final c = candidate(col, row);
final overlapsAny = occupied.values
.where((l) => l.visible && l.metric != metric)
.any((other) => hudRectsOverlap(c, other));
if (!overlapsAny) return c;
}
}
return candidate(0, 0);
}
```
### Controller — collision-aware position/size updates, free-slot re-enable
`lib/src/hud/hud_layout_controller.dart`:
```dart
void updatePosition(HudMetric metric, int col, int row) {
final current = state[metric];
if (current == null) return;
final candidate = current.copyWith(col: col, row: row).clampedToGrid();
if (_overlapsAnyOther(metric, candidate)) return; // reject: no-op, widget stays put
state = {...state, metric: candidate};
}
void updateSize(HudMetric metric, int colSpan, int rowSpan) {
final current = state[metric];
if (current == null) return;
final candidate = current.copyWith(colSpan: colSpan, rowSpan: rowSpan).clampedToGrid();
if (_overlapsAnyOther(metric, candidate)) return;
state = {...state, metric: candidate};
}
bool _overlapsAnyOther(HudMetric metric, HudWidgetLayout candidate) => state.values
.where((l) => l.visible && l.metric != metric)
.any((other) => hudRectsOverlap(candidate, other));
void setVisible(HudMetric metric, bool visible) {
var current = state[metric] ?? HudWidgetLayout.defaultFor(metric);
if (visible && _overlapsAnyOther(metric, current.copyWith(visible: true))) {
// The metric's own saved/default slot is now occupied by something else (the
// "goes crazy" bug this ticket exists to fix) -- find a genuinely free one
// instead of popping up on top of whatever's there.
current = HudWidgetLayout.nextFreeSlot(
metric, occupied: state, colSpan: current.colSpan, rowSpan: current.rowSpan,
);
}
state = {...state, metric: current.copyWith(visible: visible)};
}
```
**"Reject and no-op" is the collision rule for drag/resize** — simplest correct
behavior, and it's the same operation as "revert to last valid" here since `state` is
never mutated until the candidate passes the check (there's nothing to revert *from*,
the old value was never overwritten). A drag/resize that would overlap another visible
widget simply has no effect for that frame/gesture; the widget stays at its last valid
position. `setVisible` gets the one exception — it actively finds a free slot rather
than rejecting, since "the metric just doesn't turn on" would be a much worse user
experience than briefly landing somewhere else on the grid.
### Drag/resize gesture — snap on gesture end, not live
`lib/src/ui/components/draggable_resizable_hud_widget.dart`: track a **local, transient
pixel offset** in `_DraggableResizableHudWidgetState` during the gesture (not committed
to the controller), paint the widget at `layout position (from grid) + local offset`
so the drag still feels smooth and continuous under the finger — then on
`onLongPressEnd`/`onPanEnd`, convert the final raw pixel position/size to the nearest
grid cell and call `widget.onMoved`/`onResized` exactly once with the snapped integer
coordinates, then reset the local offset to zero.
```dart
class _DraggableResizableHudWidgetState extends State<DraggableResizableHudWidget> {
bool _grabbed = false;
Offset _dragOffset = Offset.zero; // pixels, uncommitted, live during a move gesture
Size _resizeDelta = Size.zero; // pixels, uncommitted, live during a resize gesture
double get _cellWidth => widget.areaSize.width / hudGridColumns;
double get _cellHeight => widget.areaSize.height / hudGridRows;
@override
Widget build(BuildContext context) {
final layout = widget.layout;
final left = layout.col * _cellWidth + _dragOffset.dx;
final top = layout.row * _cellHeight + _dragOffset.dy;
final width = layout.colSpan * _cellWidth + _resizeDelta.width;
final height = layout.rowSpan * _cellHeight + _resizeDelta.height;
...
onLongPressMoveUpdate: (details) => setState(() => _dragOffset = details.offsetFromOrigin),
onLongPressEnd: (_) {
final snappedCol = ((layout.col * _cellWidth + _dragOffset.dx) / _cellWidth).round();
final snappedRow = ((layout.row * _cellHeight + _dragOffset.dy) / _cellHeight).round();
widget.onMoved(snappedCol, snappedRow);
setState(() { _grabbed = false; _dragOffset = Offset.zero; });
},
onLongPressCancel: () => setState(() { _grabbed = false; _dragOffset = Offset.zero; }),
...
// resize handle:
onPanUpdate: (details) => setState(() =>
_resizeDelta = Size(_resizeDelta.width + details.delta.dx, _resizeDelta.height + details.delta.dy)),
onPanEnd: (_) {
final snappedColSpan = ((layout.colSpan * _cellWidth + _resizeDelta.width) / _cellWidth).round();
final snappedRowSpan = ((layout.rowSpan * _cellHeight + _resizeDelta.height) / _cellHeight).round();
widget.onResized(snappedColSpan, snappedRowSpan);
setState(() => _resizeDelta = Size.zero);
},
```
If the parent rejects the move/resize (collision), the widget simply rebuilds at its
unchanged `layout` — since `_dragOffset`/`_resizeDelta` are reset to zero right after
calling `onMoved`/`onResized` regardless of whether the controller accepted it, the
widget visually snaps back to wherever it actually ended up (its last accepted grid
cell) the instant the gesture ends. No separate "was it accepted?" callback needed.
`widget.onMoved`/`onResized` function signatures change to `void Function(int col, int
row)` / `void Function(int colSpan, int rowSpan)`.
`lib/src/ui/components/hud_edit_overlay.dart`: update the two callback wire-ups
(`onMoved: (col, row) => notifier.updatePosition(entry.key, col, row)`, `onResized:
(colSpan, rowSpan) => notifier.updateSize(entry.key, colSpan, rowSpan)`) — no other
change needed in this file; `areaSize` is still the raw pixel `Size` from its own
`LayoutBuilder`, cell-size division now happens inside
`DraggableResizableHudWidget` as shown above.
### Text auto-fit — `FittedBox`, not a computed font size
`lib/src/ui/record/record_screen.dart`, `_HudMetricValue.build()`: wrap the existing
label+value `Column` in `FittedBox(fit: BoxFit.scaleDown)`, drop `maxLines`/
`TextOverflow.ellipsis` on both `Text`s (structurally impossible to overflow once
`FittedBox` owns sizing), and keep the existing `fontSize: 10`/`18` values as the
"reference" size `FittedBox` scales down from — the ratio between label and value size
is preserved automatically as it scales:
```dart
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
return FittedBox(
fit: BoxFit.scaleDown,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
metric.label.toUpperCase(),
style: TextStyle(fontSize: 10, letterSpacing: 1, color: colors.onSurfaceVariant),
),
const SizedBox(height: 4),
Text(
_value,
style: monoDigits.copyWith(fontSize: 18, fontWeight: FontWeight.bold, color: _valueColor(colors)),
),
],
),
);
}
```
`FittedBox` only scales *down* (`BoxFit.scaleDown` never enlarges past the reference
size) — so a widget resized to the grid's maximum span (4 cols × 3 rows) shows text at
its natural 10/18px reference size, not blown up to fill the space. That's an
intentional, reasonable simplification for this ticket (matching "no overflow" exactly
as asked; "looks proportionally larger in a bigger widget" is a nice-to-have, not named
in the feedback) — call this trade-off out plainly in the Outcome section rather than
silently under-delivering on it.
`DraggableResizableHudWidget`'s own centering (`GlassPanel(child: Center(child:
widget.child))`) already satisfies "centered" — no change needed there.
## Implementation
1. Rewrite `lib/src/hud/hud_widget_layout.dart`: grid constants, `col`/`row`/`colSpan`/
`rowSpan` fields, `clampedToGrid()`, `hudRectsOverlap()`, updated `toJson`/
`fromJson`, updated `defaultFor`, new `nextFreeSlot`.
2. Rewrite `lib/src/hud/hud_layout_controller.dart`: `updatePosition`/`updateSize` take
ints and reject-on-collision; `setVisible` uses `nextFreeSlot` when the metric's
current slot collides.
3. Rewrite `DraggableResizableHudWidget`: local pixel-offset drag state, snap-on-end,
updated callback signatures, pixel math from `areaSize / hudGridColumns` /
`hudGridRows`.
4. Update `HudEditOverlay`'s two callback closures to the new int signatures.
5. Wrap `_HudMetricValue`'s `Column` in `FittedBox(fit: BoxFit.scaleDown)`, drop
`maxLines`/`overflow` on its two `Text`s.
6. `flutter analyze`, fix any resulting type errors elsewhere that referenced the old
`x`/`y`/`width`/`height` fields (grep for `.x`/`.y`/`.width`/`.height` on
`HudWidgetLayout` instances across `lib/` and `test/` to be thorough).
## Acceptance criteria
- [ ] Dragging a HUD widget in edit mode snaps to a grid cell on release; the widget
tracks the finger smoothly during the drag itself (no per-frame jump/snap while
still moving).
- [ ] Resizing via the handle snaps to whole grid cells on release, same smooth-during-
drag behavior.
- [ ] Dragging or resizing a widget onto a cell already occupied by another *visible*
widget rejects the move — the widget returns to its last valid position/size,
no crash, no silent overlap.
- [ ] Re-enabling a hidden metric whose saved/default slot is now occupied by another
visible widget places it in the next free grid cell instead of stacking on top of
the occupier — this is the concrete fix for the "goes crazy" feedback.
- [ ] Re-enabling a hidden metric whose slot is still free keeps its exact prior
position (unchanged from today's guarantee, still worth re-verifying under the
new model).
- [ ] HUD widget text (label + value) never overflows or gets ellipsized at any allowed
grid size, and stays visually centered within its widget, at both the smallest
(`1×1`) and largest (`hudMaxColSpan × hudMaxRowSpan`) allowed sizes.
- [ ] An old-shaped saved layout (pre-this-ticket JSON with `x`/`y`/`width`/`height`
keys) loads without crashing — falls back to `defaultFor` per metric, exactly as
`fromJson`'s existing try/catch already guarantees.
- [ ] `flutter analyze` clean, `flutter test` green, test count only goes up.
## Tests
`test/hud_widget_layout_test.dart` needs a full rewrite for the new field names/types —
keep the same test *intents*, translated to grid coordinates:
- JSON round trip (encode/decode a `col`/`row`/`colSpan`/`rowSpan`/`visible` layout
exactly).
- A malformed/missing-field JSON entry falls back to `defaultFor` (unchanged intent,
new field names in the malformed input).
- `defaultFor`: every metric gets a distinct (non-overlapping, via `hudRectsOverlap`)
default rect; the first four metrics start visible, the rest hidden (unchanged
intent).
- `clampedToGrid`: a position/span pushed outside `[0, hudGridColumns)`/
`[0, hudGridRows)` or `[hudMinSpan, hudMaxColSpan/RowSpan]` is corrected back inside
(parallel structure to the old `clamped` tests: past the right/bottom edge, past the
left/top edge, below the legibility floor, above the ceiling, shrink-before-reposition,
already-valid-is-unchanged).
- New: `hudRectsOverlap` — two identical rects overlap; two rects sharing only an edge
(touching, not overlapping) do not; two disjoint rects don't; a rect fully containing
another does.
- New: `nextFreeSlot` — given a set of `occupied` visible layouts that collide with a
metric's own `defaultFor` position, returns some other rect that doesn't collide with
any of them; given an empty/all-hidden `occupied` map, returns the same rect
`defaultFor` would have (or at least a valid non-colliding one at `(0,0)`).
`test/hud_layout_controller_test.dart` needs a parallel rewrite for the new int
signatures and the new collision behavior:
- `updatePosition`/`updateSize` update only the given metric, still true.
- New: `updatePosition` to a cell already occupied by another visible metric is a
no-op (state for the target metric is unchanged).
- New: `updateSize` that would make a widget overlap a sibling is a no-op.
- `setVisible(false)` then `setVisible(true)` restores the last position **when that
position is still free** (keep this test, using coordinates guaranteed not to
collide with anything else default-visible).
- New: `setVisible(true)` when the metric's last position now collides with another
visible widget places it somewhere else instead (assert the resulting `state[metric]`
doesn't overlap anything, not a specific coordinate).
- `persist`/pre-persist-not-visible-to-Config tests carry over unchanged in spirit
(just using int fields).
Widget-level: extend or add to `test/hud_edit_overlay_test.dart` — a drag gesture ending
over an occupied cell leaves the dragged widget's rendered position unchanged from
before the gesture; a resize past the point of colliding with a sibling leaves the
widget at its pre-gesture size. Also add a widget test putting `_HudMetricValue` (or a
representative HUD child) inside a very small `DraggableResizableHudWidget` (1×1 grid
cell) and a very large one (`hudMaxColSpan`×`hudMaxRowSpan`) and asserting no
`RenderFlex overflowed` exception/error is recorded by `FlutterError.onError` during
the pump (the standard way to assert "no overflow" in a widget test — check
`test/widget_test.dart`/other existing tests in this repo for the established pattern,
if any, otherwise use `tester.takeException()` after pumping and assert it's `null`).
## Risks
- `FittedBox(fit: BoxFit.scaleDown)` never enlarges text past its reference size, so a
widget resized to the grid's maximum span shows the same 10/18px reference text as a
1×1 widget, just with more empty space around it — not larger text filling the space.
This satisfies "no overflow" and "centered" exactly as asked; "text should grow to
fill a bigger widget" is not explicitly requested and is treated as future work, not a
silent gap — document this trade-off in the Outcome section.
- Existing saved HUD layouts (from anyone who ran a build before this ticket) reset to
defaults the first time they're loaded post-update, per the fromJson fallback. No
user-facing warning is added for this — acceptable given there's no real user base
yet; flag it anyway in Outcome for completeness.
## Out of scope
FB-02 (hiding the idle Speed panel — separate ticket, this one only touches the
recording-state HUD widgets themselves). Making widget text grow to actually fill a
larger grid span (see Risks). Any change to which metrics exist or their default
visibility set — `HudMetric`'s own enum and `label`s are untouched.
## Outcome
Implemented exactly per the Design section above, no deviations from the approach:
- `lib/src/hud/hud_widget_layout.dart` rewritten with the 4x8 int grid
(`hudGridColumns`/`hudGridRows`), `col`/`row`/`colSpan`/`rowSpan` fields,
`clampedToGrid()`, top-level `hudRectsOverlap()`, updated `toJson`/`fromJson` (now
reading `col`/`row`/`colSpan`/`rowSpan` — an old `x`/`y`/`width`/`height` blob throws
in the try/catch and falls back to `defaultFor`, no migration code needed, as
predicted), an unchanged-in-spirit `defaultFor`, and the new `nextFreeSlot` scan.
- `lib/src/hud/hud_layout_controller.dart` rewritten: `updatePosition`/`updateSize`
take ints, clamp, and reject-on-collision (no-op, no revert needed since state is
never mutated until the candidate passes); `setVisible` calls `nextFreeSlot` only
when the metric's own slot now collides with another visible widget.
- `lib/src/ui/components/draggable_resizable_hud_widget.dart` rewritten: local
`_dragOffset`/`_resizeDelta` pixel state tracks the live gesture smoothly; snapping
to the nearest grid cell and calling `onMoved`/`onResized` exactly once happens on
`onLongPressEnd`/`onPanEnd`. Both offsets reset to zero right after that call
regardless of whether the controller accepted the move, which is what makes a
rejected move/resize visually snap back with no separate accept/reject callback.
Callback signatures changed to `void Function(int, int)`.
- `lib/src/ui/components/hud_edit_overlay.dart`: the two callback closures updated to
the new int signatures; no other change needed.
- `lib/src/ui/record/record_screen.dart`'s `_HudMetricValue.build()`: wrapped in
`FittedBox(fit: BoxFit.scaleDown)`, `maxLines`/`overflow` dropped from both `Text`s.
**Confirmed trade-off** (the one the ticket flagged in advance): `BoxFit.scaleDown`
never enlarges past the reference size, so a widget resized to `hudMaxColSpan` x
`hudMaxRowSpan` renders the same 10px/18px text as a 1x1 widget — just with more empty
space around it, still centered via `DraggableResizableHudWidget`'s existing
`Center`. Verified directly with the new "largest allowed size" widget test (a 4x3
`DraggableResizableHudWidget` at 400x400 pixels records no overflow exception, same as
the 1x1 case) — text-scaling-with-widget-size remains explicit future work, not a
silent gap.
**Confirmed migration behavior**: an old-shaped saved layout (pre-this-ticket JSON with
`x`/`y`/`width`/`height` keys) loads without crashing and falls back to `defaultFor`
per metric — covered directly by a new `hud_widget_layout_test.dart` case and a new
`config_test.dart` case using literal old-shaped JSON. No user-facing warning was
added, per the ticket's own Risks section (no real user base yet to protect).
**Tests**: `test/hud_widget_layout_test.dart` and `test/hud_layout_controller_test.dart`
were fully rewritten for the int grid fields, per the ticket's Tests section (JSON
round trip, malformed/old-shaped-JSON fallback, `defaultFor` non-overlap and
visibility, `clampedToGrid` edge/floor/ceiling/shrink-before-reposition cases,
`hudRectsOverlap` touching/disjoint/containing/partial cases, `nextFreeSlot` colliding
and empty/hidden-occupied cases, controller collision-rejection for both
`updatePosition` and `updateSize`, and `setVisible`'s free-slot fallback). Also updated:
`test/config_test.dart` (its `HudWidgetLayout` literal and JSON fixtures used the old
field names — updated to the grid fields, plus one new case for old-shaped JSON) and
`test/hud_edit_overlay_test.dart` (its `.x`/`.y` assertions became `.col`/`.row`; added
the two ticket-requested widget tests for a collision-rejected drag and a
collision-rejected resize, plus two more building `DraggableResizableHudWidget`
directly at `1x1` and `hudMaxColSpan x hudMaxRowSpan` to assert `tester.takeException()`
is `null` — the established no-overflow pattern already used in
`settings_screen_test.dart`).
Final count: **392 tests passing** (up from the 374 baseline), `flutter analyze` clean.