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.
This commit is contained in:
2026-08-24 15:53:00 -05:00
parent 04127d7237
commit 2d118b46a2
10 changed files with 1306 additions and 198 deletions

View File

@@ -0,0 +1,661 @@
# 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.

View File

@@ -1,4 +1,4 @@
/// UI-04: the in-memory, authoritative HUD layout during an editing session. /// UI-04/FB-03: the in-memory, authoritative HUD layout during an editing session.
/// ///
/// Seeded from `Config` once, and written back to it only on [persist] -- not on every /// Seeded from `Config` once, and written back to it only on [persist] -- not on every
/// drag/resize frame, which would hammer `SharedPreferences` mid-drag (the ticket's own /// drag/resize frame, which would hammer `SharedPreferences` mid-drag (the ticket's own
@@ -26,24 +26,47 @@ class HudLayoutController extends StateNotifier<Map<HudMetric, HudWidgetLayout>>
final Config? _config; final Config? _config;
void updatePosition(HudMetric metric, double x, double y) { /// "Reject and no-op" is the collision rule for drag/resize -- simplest correct
/// behaviour, and it's equivalent to "revert to last valid" here since `state` is
/// never mutated until the candidate passes the check. A drag/resize that would
/// overlap another visible widget simply has no effect for that gesture; the widget
/// stays at its last valid position.
void updatePosition(HudMetric metric, int col, int row) {
final current = state[metric]; final current = state[metric];
if (current == null) return; if (current == null) return;
state = {...state, metric: current.copyWith(x: x, y: y).clamped()}; final candidate = current.copyWith(col: col, row: row).clampedToGrid();
if (_overlapsAnyOther(metric, candidate)) return;
state = {...state, metric: candidate};
} }
void updateSize(HudMetric metric, double width, double height) { void updateSize(HudMetric metric, int colSpan, int rowSpan) {
final current = state[metric]; final current = state[metric];
if (current == null) return; if (current == null) return;
state = {...state, metric: current.copyWith(width: width, height: height).clamped()}; 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));
/// A metric turned on for the first time (no meaningfully-placed prior layout) gets /// A metric turned on for the first time (no meaningfully-placed prior layout) gets
/// [HudWidgetLayout.defaultFor] rather than whatever stale position it held from /// [HudWidgetLayout.defaultFor] rather than whatever stale position it held from
/// before it was last turned off -- the ticket's own acceptance criterion. A metric /// before it was last turned off. A metric that already has a real saved position
/// that already has a real saved position keeps it. /// keeps it -- unless that slot is now occupied by another visible widget (the
/// "goes crazy" bug this ticket exists to fix), in which case it's placed in the
/// next free grid cell instead of popping up on top of the occupier.
void setVisible(HudMetric metric, bool visible) { void setVisible(HudMetric metric, bool visible) {
final current = state[metric] ?? HudWidgetLayout.defaultFor(metric); var current = state[metric] ?? HudWidgetLayout.defaultFor(metric);
if (visible && _overlapsAnyOther(metric, current.copyWith(visible: true))) {
current = HudWidgetLayout.nextFreeSlot(
metric,
occupied: state,
colSpan: current.colSpan,
rowSpan: current.rowSpan,
);
}
state = {...state, metric: current.copyWith(visible: visible)}; state = {...state, metric: current.copyWith(visible: visible)};
} }

View File

@@ -1,94 +1,110 @@
/// UI-04: one telemetry widget's position, size, and visibility on the customizable /// FB-03: 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 /// HUD -- an Android-home-screen-style snapped grid (see the ticket's Design section
/// available HUD area rather than absolute pixels: a layout saved on one device or /// for why: free-form fractional positioning had no concept of a sibling widget, so
/// orientation still makes sense on another. /// toggling widgets on/off could stack one directly on top of another).
library; library;
import 'hud_metric.dart'; import 'hud_metric.dart';
/// Legibility floor and a sane ceiling -- a widget must never shrink to the point its /// Fixed grid spanning the HUD area -- matches the previous 4-column default-row
/// own number is unreadable, or grow to the point it swallows the whole HUD. /// assumption and gives ample vertical room for the 8-metric list.
const double hudMinWidthFraction = 0.20; const int hudGridColumns = 4;
const double hudMaxWidthFraction = 0.70; const int hudGridRows = 8;
const double hudMinHeightFraction = 0.08;
const double hudMaxHeightFraction = 0.40; /// Legibility floor and a sane ceiling, in grid cells -- a widget must never shrink to
/// the point its own number is unreadable, or grow to the point it swallows the whole
/// HUD.
const int hudMinSpan = 1;
const int hudMaxColSpan = 4;
const int hudMaxRowSpan = 3;
/// True if rectangles [a] and [b] (both in grid-cell coordinates) overlap -- edges that
/// merely touch do not count as overlapping.
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;
}
class HudWidgetLayout { class HudWidgetLayout {
const HudWidgetLayout({ const HudWidgetLayout({
required this.metric, required this.metric,
required this.x, required this.col,
required this.y, required this.row,
required this.width, required this.colSpan,
required this.height, required this.rowSpan,
required this.visible, required this.visible,
}); });
final HudMetric metric; final HudMetric metric;
/// Top-left corner, as a fraction of the HUD area's width/height. /// Top-left cell, in grid coordinates (0-based).
final double x; final int col;
final double y; final int row;
final double width;
final double height; /// How many cells wide/tall this widget spans.
final int colSpan;
final int rowSpan;
final bool visible; final bool visible;
HudWidgetLayout copyWith({ HudWidgetLayout copyWith({
double? x, int? col,
double? y, int? row,
double? width, int? colSpan,
double? height, int? rowSpan,
bool? visible, bool? visible,
}) => HudWidgetLayout( }) => HudWidgetLayout(
metric: metric, metric: metric,
x: x ?? this.x, col: col ?? this.col,
y: y ?? this.y, row: row ?? this.row,
width: width ?? this.width, colSpan: colSpan ?? this.colSpan,
height: height ?? this.height, rowSpan: rowSpan ?? this.rowSpan,
visible: visible ?? this.visible, visible: visible ?? this.visible,
); );
/// Corrects a drag/resize result that ended outside the allowed area back to the /// A *self-contained* bounds check with no awareness of siblings -- corrects a
/// nearest valid position/size -- clamped to the [hudMinWidthFraction]/ /// drag/resize result that ended outside the grid back to the nearest valid
/// [hudMaxWidthFraction] etc. bounds first (size), then positioned so it can never /// position/size, span first (so a resize that would push a widget off-grid shrinks
/// sit even partially outside the 0.0-1.0 HUD area (position), in that order: a /// it back on-grid rather than silently repositioning it), then position.
/// resize that would push a widget off-screen should shrink it back on-screen, not HudWidgetLayout clampedToGrid() {
/// silently reposition it out from under the rider's finger. final clampedColSpan = colSpan.clamp(hudMinSpan, hudMaxColSpan);
HudWidgetLayout clamped() { final clampedRowSpan = rowSpan.clamp(hudMinSpan, hudMaxRowSpan);
final clampedWidth = width.clamp(hudMinWidthFraction, hudMaxWidthFraction); final clampedCol = col.clamp(0, hudGridColumns - clampedColSpan);
final clampedHeight = height.clamp(hudMinHeightFraction, hudMaxHeightFraction); final clampedRow = row.clamp(0, hudGridRows - clampedRowSpan);
final clampedX = x.clamp(0.0, 1.0 - clampedWidth);
final clampedY = y.clamp(0.0, 1.0 - clampedHeight);
return HudWidgetLayout( return HudWidgetLayout(
metric: metric, metric: metric,
x: clampedX, col: clampedCol,
y: clampedY, row: clampedRow,
width: clampedWidth, colSpan: clampedColSpan,
height: clampedHeight, rowSpan: clampedRowSpan,
visible: visible, visible: visible,
); );
} }
Map<String, dynamic> toJson() => { Map<String, dynamic> toJson() => {
'x': x, 'col': col,
'y': y, 'row': row,
'width': width, 'colSpan': colSpan,
'height': height, 'rowSpan': rowSpan,
'visible': visible, 'visible': visible,
}; };
/// Falls back to [defaultFor] rather than throwing on a malformed/partial entry -- /// 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 /// an old saved layout (pre-grid, with `x`/`y`/`width`/`height` keys) or one with
/// recognise should degrade to a sane default, not crash Settings on launch. /// 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) { static HudWidgetLayout fromJson(HudMetric metric, Map<String, dynamic> json) {
try { try {
return HudWidgetLayout( return HudWidgetLayout(
metric: metric, metric: metric,
x: (json['x'] as num).toDouble(), col: (json['col'] as num).toInt(),
y: (json['y'] as num).toDouble(), row: (json['row'] as num).toInt(),
width: (json['width'] as num).toDouble(), colSpan: (json['colSpan'] as num).toInt(),
height: (json['height'] as num).toDouble(), rowSpan: (json['rowSpan'] as num).toInt(),
visible: json['visible'] as bool, visible: json['visible'] as bool,
).clamped(); ).clampedToGrid();
} catch (_) { } catch (_) {
return HudWidgetLayout.defaultFor(metric); return HudWidgetLayout.defaultFor(metric);
} }
@@ -98,24 +114,54 @@ class HudWidgetLayout {
/// before the rider customises anything, and a metric toggled on for the first time /// 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 /// (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 /// 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. /// many metrics fit in the first row, with the rest continuing below it. Purely a
/// function of the metric's own index -- these 8 fixed slots never overlap each
/// other by construction, so no runtime state is needed here.
factory HudWidgetLayout.defaultFor(HudMetric metric) { factory HudWidgetLayout.defaultFor(HudMetric metric) {
const columns = 4; const columns = 4;
const cellWidth = 0.22; const rowSpan = 2;
const cellHeight = 0.12;
const gap = 0.02;
final index = HudMetric.values.indexOf(metric); final index = HudMetric.values.indexOf(metric);
final row = index ~/ columns; final row = (index ~/ columns) * rowSpan;
final col = index % columns; final col = index % columns;
return HudWidgetLayout( return HudWidgetLayout(
metric: metric, metric: metric,
x: 0.02 + col * (cellWidth + gap), col: col,
y: 0.06 + row * (cellHeight + gap), row: row,
width: cellWidth, colSpan: 1,
height: cellHeight, rowSpan: rowSpan,
// The Map HUD mockup's own fixed row is Speed/Avg Speed/Dist/Time -- the first // The Map HUD mockup's own fixed row is Speed/Avg Speed/Dist/Time -- the first
// four enum values are ordered to match, so only those start visible. // four enum values are ordered to match, so only those start visible.
visible: index < columns, visible: index < columns,
); );
} }
/// Scans row-major from (0,0) for the first [colSpan]x[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,
}) {
HudWidgetLayout 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);
}
} }

View File

@@ -1,6 +1,9 @@
/// UI-04: one telemetry widget on the customizable HUD -- positioned/sized from a /// UI-04/FB-03: one telemetry widget on the customizable HUD -- positioned/sized from a
/// [HudWidgetLayout]'s fractions against whatever pixel area it's given, draggable and /// [HudWidgetLayout]'s grid cell against whatever pixel area it's given, draggable and
/// resizable only while [editing] is true. /// resizable only while [editing] is true. Drag/resize snap to whole grid cells on
/// gesture end (see [_DraggableResizableHudWidgetState]'s local pixel-offset fields);
/// during the gesture itself the widget tracks the finger/handle continuously in
/// pixels for a smooth feel.
/// ///
/// Deliberately does not attach any drag gesture at all when [editing] is false -- /// Deliberately does not attach any drag gesture at all when [editing] is false --
/// "outside edit mode, a normal tap never moves a widget" is guaranteed structurally /// "outside edit mode, a normal tap never moves a widget" is guaranteed structurally
@@ -36,11 +39,13 @@ class DraggableResizableHudWidget extends StatefulWidget {
final bool editing; final bool editing;
final Widget child; final Widget child;
/// Fractional x/y, already relative to [areaSize] -- not yet clamped; the caller /// Final snapped grid coordinates -- called exactly once, at gesture end, not on
/// (`HudLayoutController.updatePosition`) owns clamping so there is exactly one /// every frame of movement (a local, uncommitted pixel offset tracks the live drag;
/// place that logic lives. /// see [_DraggableResizableHudWidgetState]). The caller
final void Function(double x, double y) onMoved; /// (`HudLayoutController.updatePosition`) owns collision/bounds checking so there is
final void Function(double width, double height) onResized; /// exactly one place that logic lives.
final void Function(int col, int row) onMoved;
final void Function(int colSpan, int rowSpan) onResized;
@override @override
State<DraggableResizableHudWidget> createState() => State<DraggableResizableHudWidget> createState() =>
@@ -50,13 +55,27 @@ class DraggableResizableHudWidget extends StatefulWidget {
class _DraggableResizableHudWidgetState extends State<DraggableResizableHudWidget> { class _DraggableResizableHudWidgetState extends State<DraggableResizableHudWidget> {
bool _grabbed = false; bool _grabbed = false;
/// Pixels, uncommitted, live only during a move gesture -- painted on top of the
/// last-committed grid position so the drag tracks the finger smoothly, then reset
/// to zero the instant the gesture ends (whether or not the parent accepted the
/// resulting snapped position), which is what makes the widget visually "snap back"
/// on a rejected move with no separate accept/reject callback needed.
Offset _dragOffset = Offset.zero;
/// Pixels, uncommitted, live only during a resize gesture -- same idea as
/// [_dragOffset].
Size _resizeDelta = Size.zero;
double get _cellWidth => widget.areaSize.width / hudGridColumns;
double get _cellHeight => widget.areaSize.height / hudGridRows;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final layout = widget.layout; final layout = widget.layout;
final left = layout.x * widget.areaSize.width; final left = layout.col * _cellWidth + _dragOffset.dx;
final top = layout.y * widget.areaSize.height; final top = layout.row * _cellHeight + _dragOffset.dy;
final width = layout.width * widget.areaSize.width; final width = layout.colSpan * _cellWidth + _resizeDelta.width;
final height = layout.height * widget.areaSize.height; final height = layout.rowSpan * _cellHeight + _resizeDelta.height;
Widget card = AnimatedScale( Widget card = AnimatedScale(
scale: _grabbed ? 1.05 : 1.0, scale: _grabbed ? 1.05 : 1.0,
@@ -71,13 +90,23 @@ class _DraggableResizableHudWidgetState extends State<DraggableResizableHudWidge
setState(() => _grabbed = true); setState(() => _grabbed = true);
}, },
onLongPressMoveUpdate: (details) { onLongPressMoveUpdate: (details) {
widget.onMoved( setState(() => _dragOffset = details.offsetFromOrigin);
layout.x + details.offsetFromOrigin.dx / widget.areaSize.width,
layout.y + details.offsetFromOrigin.dy / widget.areaSize.height,
);
}, },
onLongPressEnd: (_) => setState(() => _grabbed = false), onLongPressEnd: (_) {
onLongPressCancel: () => setState(() => _grabbed = false), 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;
}),
child: card, child: card,
); );
} else { } else {
@@ -109,10 +138,23 @@ class _DraggableResizableHudWidgetState extends State<DraggableResizableHudWidge
bottom: -8, bottom: -8,
child: GestureDetector( child: GestureDetector(
onPanUpdate: (details) { onPanUpdate: (details) {
widget.onResized( setState(() {
layout.width + details.delta.dx / widget.areaSize.width, _resizeDelta = Size(
layout.height + details.delta.dy / widget.areaSize.height, _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);
}, },
child: Container( child: Container(
key: const Key('hud-resize-handle'), key: const Key('hud-resize-handle'),

View File

@@ -64,8 +64,9 @@ class _HudEditOverlayState extends ConsumerState<HudEditOverlay> {
layout: entry.value, layout: entry.value,
areaSize: areaSize, areaSize: areaSize,
editing: _editing, editing: _editing,
onMoved: (x, y) => notifier.updatePosition(entry.key, x, y), onMoved: (col, row) => notifier.updatePosition(entry.key, col, row),
onResized: (w, h) => notifier.updateSize(entry.key, w, h), onResized: (colSpan, rowSpan) =>
notifier.updateSize(entry.key, colSpan, rowSpan),
child: widget.metricBuilder(context, entry.key), child: widget.metricBuilder(context, entry.key),
), ),
if (_editing) if (_editing)

View File

@@ -321,14 +321,21 @@ class _HudMetricValue extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme; final colors = Theme.of(context).colorScheme;
return Column( // FB-03: FittedBox owns sizing here -- structurally impossible to overflow, so
// maxLines/overflow are dropped from both Texts. fontSize: 10/18 below are now
// just the "reference" size FittedBox scales down from at small widget sizes; the
// ratio between label and value size is preserved automatically as it scales.
// Note: BoxFit.scaleDown never enlarges past that reference size, so a widget
// resized to the grid's maximum span still shows 10/18px text, just with more
// empty space around it -- not larger text filling the space.
return FittedBox(
fit: BoxFit.scaleDown,
child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Text( Text(
metric.label.toUpperCase(), metric.label.toUpperCase(),
style: TextStyle(fontSize: 10, letterSpacing: 1, color: colors.onSurfaceVariant), style: TextStyle(fontSize: 10, letterSpacing: 1, color: colors.onSurfaceVariant),
maxLines: 1,
overflow: TextOverflow.ellipsis,
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
@@ -338,10 +345,9 @@ class _HudMetricValue extends StatelessWidget {
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: _valueColor(colors), color: _valueColor(colors),
), ),
maxLines: 1,
overflow: TextOverflow.ellipsis,
), ),
], ],
),
); );
} }
} }

View File

@@ -96,10 +96,10 @@ void main() {
...config.hudLayout, ...config.hudLayout,
HudMetric.speed: const HudWidgetLayout( HudMetric.speed: const HudWidgetLayout(
metric: HudMetric.speed, metric: HudMetric.speed,
x: 0.33, col: 2,
y: 0.44, row: 3,
width: 0.25, colSpan: 2,
height: 0.1, rowSpan: 1,
visible: true, visible: true,
), ),
}; };
@@ -107,12 +107,27 @@ void main() {
await config.setHudLayout(edited); await config.setHudLayout(edited);
final reloaded = config.hudLayout; final reloaded = config.hudLayout;
expect(reloaded[HudMetric.speed]!.x, 0.33); expect(reloaded[HudMetric.speed]!.col, 2);
expect(reloaded[HudMetric.speed]!.y, 0.44); expect(reloaded[HudMetric.speed]!.row, 3);
}); });
test('a metric missing from a saved layout (e.g. one added in a later app ' test('a metric missing from a saved layout (e.g. one added in a later app '
'version) falls back to its default rather than being omitted', () async { 'version) falls back to its default rather than being omitted', () async {
SharedPreferences.setMockInitialValues({
'hud_layout': '{"speed": {"col": 1, "row": 1, "colSpan": 1, "rowSpan": 2, '
'"visible": true}}',
});
final config = await Config.load();
final layout = config.hudLayout;
expect(layout.keys.toSet(), HudMetric.values.toSet());
final expectedDefault = HudWidgetLayout.defaultFor(HudMetric.distance);
expect(layout[HudMetric.distance]!.col, expectedDefault.col);
expect(layout[HudMetric.distance]!.visible, expectedDefault.visible);
});
test('an old-shaped (pre-grid) saved layout falls back to defaults rather than '
'crashing', () async {
SharedPreferences.setMockInitialValues({ SharedPreferences.setMockInitialValues({
'hud_layout': '{"speed": {"x": 0.1, "y": 0.1, "width": 0.2, "height": 0.1, ' 'hud_layout': '{"speed": {"x": 0.1, "y": 0.1, "width": 0.2, "height": 0.1, '
'"visible": true}}', '"visible": true}}',
@@ -121,9 +136,8 @@ void main() {
final layout = config.hudLayout; final layout = config.hudLayout;
expect(layout.keys.toSet(), HudMetric.values.toSet()); expect(layout.keys.toSet(), HudMetric.values.toSet());
final expectedDefault = HudWidgetLayout.defaultFor(HudMetric.distance); final expectedDefault = HudWidgetLayout.defaultFor(HudMetric.speed);
expect(layout[HudMetric.distance]!.x, expectedDefault.x); expect(layout[HudMetric.speed]!.col, expectedDefault.col);
expect(layout[HudMetric.distance]!.visible, expectedDefault.visible);
}); });
test('corrupted stored JSON falls back to defaults rather than crashing', test('corrupted stored JSON falls back to defaults rather than crashing',

View File

@@ -5,9 +5,26 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:rippr/src/app/providers.dart'; import 'package:rippr/src/app/providers.dart';
import 'package:rippr/src/config/config.dart'; import 'package:rippr/src/config/config.dart';
import 'package:rippr/src/hud/hud_metric.dart'; import 'package:rippr/src/hud/hud_metric.dart';
import 'package:rippr/src/hud/hud_widget_layout.dart';
import 'package:rippr/src/ui/components/draggable_resizable_hud_widget.dart';
import 'package:rippr/src/ui/components/hud_edit_overlay.dart'; import 'package:rippr/src/ui/components/hud_edit_overlay.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
/// A representative HUD child -- structurally the same FittedBox(label + value)
/// shape as `record_screen.dart`'s private `_HudMetricValue`, which can't be
/// referenced directly from outside its library.
Widget _representativeHudChild() => const FittedBox(
fit: BoxFit.scaleDown,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text('SPEED', style: TextStyle(fontSize: 10, letterSpacing: 1)),
SizedBox(height: 4),
Text('12.3 km/h', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
],
),
);
/// UI-04: drag/toggle behaviour of the customizable HUD, simulated via `TestGesture` /// UI-04: drag/toggle behaviour of the customizable HUD, simulated via `TestGesture`
/// the same way other drag interactions in this codebase are tested (see /// the same way other drag interactions in this codebase are tested (see
/// `route_planner_screen_test.dart`'s waypoint-drag coverage). /// `route_planner_screen_test.dart`'s waypoint-drag coverage).
@@ -71,7 +88,9 @@ void main() {
final speedFinder = find.text('Speed'); final speedFinder = find.text('Speed');
final gesture = await tester.startGesture(tester.getCenter(speedFinder)); final gesture = await tester.startGesture(tester.getCenter(speedFinder));
await tester.pump(kLongPressTimeout + kPressTimeout); await tester.pump(kLongPressTimeout + kPressTimeout);
await gesture.moveBy(const Offset(40, 60)); // Large enough to guarantee at least one grid cell of movement in both axes
// regardless of the test surface's exact pixel size.
await gesture.moveBy(const Offset(300, 300));
await tester.pump(); await tester.pump();
await gesture.up(); await gesture.up();
await tester.pumpAndSettle(); await tester.pumpAndSettle();
@@ -80,8 +99,8 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
final after = config.hudLayout[HudMetric.speed]!; final after = config.hudLayout[HudMetric.speed]!;
expect(after.x, isNot(before.x)); expect(after.col, isNot(before.col));
expect(after.y, isNot(before.y)); expect(after.row, isNot(before.row));
}); });
testWidgets('a drag while not editing does not move the widget', (tester) async { testWidgets('a drag while not editing does not move the widget', (tester) async {
@@ -99,8 +118,8 @@ void main() {
expect(find.byKey(const Key('hud-edit-done')), findsNothing); expect(find.byKey(const Key('hud-edit-done')), findsNothing);
final after = config.hudLayout[HudMetric.speed]!; final after = config.hudLayout[HudMetric.speed]!;
expect(after.x, before.x); expect(after.col, before.col);
expect(after.y, before.y); expect(after.row, before.row);
}); });
testWidgets('only visible metrics render a widget', (tester) async { testWidgets('only visible metrics render a widget', (tester) async {
@@ -110,4 +129,138 @@ void main() {
expect(find.text('Speed'), findsOneWidget, reason: 'visible by default'); expect(find.text('Speed'), findsOneWidget, reason: 'visible by default');
expect(find.text('Max speed'), findsNothing, reason: 'hidden by default'); expect(find.text('Max speed'), findsNothing, reason: 'hidden by default');
}); });
testWidgets('a drag ending over a cell occupied by another visible widget leaves '
'the dragged widget\'s rendered position unchanged', (tester) async {
await tester.pumpWidget(host());
await tester.pumpAndSettle();
await tester.longPress(find.byKey(const Key('hud-edit-background')));
await tester.pumpAndSettle();
final speedFinder = find.byKey(const ValueKey(HudMetric.speed));
final before = tester.getTopLeft(speedFinder);
// Speed (col 0, row 0) dragged exactly one cell right lands squarely on
// avgSpeed's (col 1, row 0) cell -- both share the same row-0 default rowSpan, so
// this is a genuine, not merely edge-touching, overlap.
final gesture = await tester.startGesture(tester.getCenter(find.text('Speed')));
await tester.pump(kLongPressTimeout + kPressTimeout);
await gesture.moveBy(Offset(tester.view.physicalSize.width / tester.view.devicePixelRatio / hudGridColumns, 0));
await tester.pump();
await gesture.up();
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('hud-edit-done')));
await tester.pumpAndSettle();
expect(config.hudLayout[HudMetric.speed]!.col, 0,
reason: 'the rejected move must not change the persisted grid cell either');
expect(tester.getTopLeft(speedFinder), before);
});
testWidgets('a resize past the point of colliding with a sibling leaves the widget '
'at its pre-gesture size', (tester) async {
await tester.pumpWidget(host());
await tester.pumpAndSettle();
await tester.longPress(find.byKey(const Key('hud-edit-background')));
await tester.pumpAndSettle();
final speedFinder = find.byKey(const ValueKey(HudMetric.speed));
final beforeSize = tester.getSize(speedFinder);
final handleFinder = find.descendant(
of: speedFinder,
matching: find.byKey(const Key('hud-resize-handle')),
);
// Speed defaults to colSpan 1 at col 0; growing it across the full grid width
// would swallow avgSpeed/distance/elapsedTime's row-0 slots.
final cellWidth = tester.view.physicalSize.width / tester.view.devicePixelRatio / hudGridColumns;
final gesture = await tester.startGesture(tester.getCenter(handleFinder));
await gesture.moveBy(Offset(cellWidth * hudGridColumns, 0));
await tester.pump();
await gesture.up();
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('hud-edit-done')));
await tester.pumpAndSettle();
expect(config.hudLayout[HudMetric.speed]!.colSpan, 1,
reason: 'the rejected resize must not change the persisted span either');
expect(tester.getSize(speedFinder), beforeSize);
});
testWidgets('HUD widget text never overflows at the smallest (1x1) allowed size',
(tester) async {
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: SizedBox(
width: 400,
height: 400,
child: Stack(
children: [
DraggableResizableHudWidget(
layout: const HudWidgetLayout(
metric: HudMetric.speed,
col: 0,
row: 0,
colSpan: hudMinSpan,
rowSpan: hudMinSpan,
visible: true,
),
areaSize: const Size(400, 400),
editing: false,
onMoved: (_, _) {},
onResized: (_, _) {},
child: _representativeHudChild(),
),
],
),
),
),
),
);
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
});
testWidgets(
'HUD widget text never overflows at the largest (max col/row span) allowed size',
(tester) async {
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: SizedBox(
width: 400,
height: 400,
child: Stack(
children: [
DraggableResizableHudWidget(
layout: const HudWidgetLayout(
metric: HudMetric.speed,
col: 0,
row: 0,
colSpan: hudMaxColSpan,
rowSpan: hudMaxRowSpan,
visible: true,
),
areaSize: const Size(400, 400),
editing: false,
onMoved: (_, _) {},
onResized: (_, _) {},
child: _representativeHudChild(),
),
],
),
),
),
),
);
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
});
} }

View File

@@ -2,6 +2,7 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:rippr/src/config/config.dart'; import 'package:rippr/src/config/config.dart';
import 'package:rippr/src/hud/hud_layout_controller.dart'; import 'package:rippr/src/hud/hud_layout_controller.dart';
import 'package:rippr/src/hud/hud_metric.dart'; import 'package:rippr/src/hud/hud_metric.dart';
import 'package:rippr/src/hud/hud_widget_layout.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
void main() { void main() {
@@ -11,59 +12,123 @@ void main() {
} }
group('HudLayoutController', () { group('HudLayoutController', () {
test('updatePosition updates only the given metric, clamped', () async { test('updatePosition updates only the given metric, clamped to the grid', () async {
final controller = HudLayoutController(await freshConfig()); final controller = HudLayoutController(await freshConfig());
final before = controller.state[HudMetric.distance]!; final before = controller.state[HudMetric.distance]!;
controller.updatePosition(HudMetric.speed, 5.0, 5.0); // Row 4 is unused by any default layout, so this can't collide.
controller.updatePosition(HudMetric.speed, 0, 4);
expect(controller.state[HudMetric.speed]!.x, lessThanOrEqualTo(1.0)); expect(controller.state[HudMetric.speed]!.row, 4);
expect(controller.state[HudMetric.distance]!.x, before.x, expect(controller.state[HudMetric.distance]!.col, before.col,
reason: 'moving one metric must not disturb another'); reason: 'moving one metric must not disturb another');
expect(controller.state[HudMetric.distance]!.row, before.row);
});
test('updatePosition clamps a candidate that lands outside the grid', () async {
final controller = HudLayoutController(await freshConfig());
controller.updatePosition(HudMetric.speed, 99, 99);
final speed = controller.state[HudMetric.speed]!;
expect(speed.col, lessThanOrEqualTo(hudGridColumns - speed.colSpan));
expect(speed.row, lessThanOrEqualTo(hudGridRows - speed.rowSpan));
}); });
test('updateSize clamps to the legibility floor/ceiling', () async { test('updateSize clamps to the legibility floor/ceiling', () async {
final controller = HudLayoutController(await freshConfig()); final controller = HudLayoutController(await freshConfig());
controller.updateSize(HudMetric.speed, 0.0, 0.0); controller.updateSize(HudMetric.speed, 0, 0);
expect(controller.state[HudMetric.speed]!.width, greaterThan(0.0)); expect(controller.state[HudMetric.speed]!.colSpan, hudMinSpan);
expect(controller.state[HudMetric.speed]!.height, greaterThan(0.0)); expect(controller.state[HudMetric.speed]!.rowSpan, hudMinSpan);
});
test('updatePosition onto a cell occupied by another visible metric is a no-op', () async {
final controller = HudLayoutController(await freshConfig());
final before = controller.state[HudMetric.speed]!;
final avgSpeed = controller.state[HudMetric.avgSpeed]!;
// avgSpeed is visible by default -- try to drag speed directly on top of it.
controller.updatePosition(HudMetric.speed, avgSpeed.col, avgSpeed.row);
expect(controller.state[HudMetric.speed]!.col, before.col);
expect(controller.state[HudMetric.speed]!.row, before.row);
});
test('updateSize that would overlap a sibling is a no-op', () async {
final controller = HudLayoutController(await freshConfig());
final before = controller.state[HudMetric.speed]!;
// Growing speed (default col 0, row 0) to span all 4 columns would swallow
// avgSpeed/distance/elapsedTime's row-0 slots.
controller.updateSize(HudMetric.speed, hudGridColumns, before.rowSpan);
expect(controller.state[HudMetric.speed]!.colSpan, before.colSpan);
expect(controller.state[HudMetric.speed]!.rowSpan, before.rowSpan);
}); });
test('setVisible(false) then setVisible(true) restores the last saved position, ' test('setVisible(false) then setVisible(true) restores the last saved position, '
'not a fresh default', () async { 'when that position is still free', () async {
final controller = HudLayoutController(await freshConfig()); final controller = HudLayoutController(await freshConfig());
controller.updatePosition(HudMetric.avgSpeed, 0.6, 0.6); // Row 4 is unused by any default layout, so this can't collide with anything.
final movedX = controller.state[HudMetric.avgSpeed]!.x; controller.updatePosition(HudMetric.avgSpeed, 0, 4);
final moved = controller.state[HudMetric.avgSpeed]!;
controller.setVisible(HudMetric.avgSpeed, false); controller.setVisible(HudMetric.avgSpeed, false);
expect(controller.state[HudMetric.avgSpeed]!.visible, isFalse); expect(controller.state[HudMetric.avgSpeed]!.visible, isFalse);
controller.setVisible(HudMetric.avgSpeed, true); controller.setVisible(HudMetric.avgSpeed, true);
expect(controller.state[HudMetric.avgSpeed]!.visible, isTrue); expect(controller.state[HudMetric.avgSpeed]!.visible, isTrue);
expect(controller.state[HudMetric.avgSpeed]!.x, movedX, expect(controller.state[HudMetric.avgSpeed]!.col, moved.col,
reason: 'a metric with a real saved position keeps it when re-enabled'); reason: 'a metric with a real saved position keeps it when re-enabled');
expect(controller.state[HudMetric.avgSpeed]!.row, moved.row);
});
test('setVisible(true) when the metric\'s last position now collides with '
'another visible widget places it somewhere else instead', () async {
final controller = HudLayoutController(await freshConfig());
final maxSpeedDefault = controller.state[HudMetric.maxSpeed]!;
// maxSpeed starts hidden at its default slot; move a currently-visible metric
// onto that exact slot so it's occupied by the time maxSpeed is re-enabled.
controller.updatePosition(
HudMetric.speed,
maxSpeedDefault.col,
maxSpeedDefault.row,
);
controller.setVisible(HudMetric.maxSpeed, true);
final maxSpeed = controller.state[HudMetric.maxSpeed]!;
expect(maxSpeed.visible, isTrue);
final others = controller.state.values.where(
(l) => l.visible && l.metric != HudMetric.maxSpeed,
);
for (final other in others) {
expect(hudRectsOverlap(maxSpeed, other), isFalse,
reason: 'the re-enabled metric must not stack on any other visible widget');
}
}); });
test('persist writes the current state to Config', () async { test('persist writes the current state to Config', () async {
final config = await freshConfig(); final config = await freshConfig();
final controller = HudLayoutController(config); final controller = HudLayoutController(config);
controller.updatePosition(HudMetric.speed, 0.4, 0.4); controller.updatePosition(HudMetric.speed, 0, 4);
await controller.persist(); await controller.persist();
final reloaded = config.hudLayout; final reloaded = config.hudLayout;
expect(reloaded[HudMetric.speed]!.x, 0.4); expect(reloaded[HudMetric.speed]!.row, 4);
}); });
test('edits before persist are not visible to a fresh read of Config', () async { test('edits before persist are not visible to a fresh read of Config', () async {
final config = await freshConfig(); final config = await freshConfig();
final controller = HudLayoutController(config); final controller = HudLayoutController(config);
controller.updatePosition(HudMetric.speed, 0.4, 0.4); controller.updatePosition(HudMetric.speed, 0, 4);
// No persist() call yet. // No persist() call yet.
final reloaded = config.hudLayout; final reloaded = config.hudLayout;
expect(reloaded[HudMetric.speed]!.x, isNot(0.4), expect(reloaded[HudMetric.speed]!.row, isNot(4),
reason: 'a drag in progress must only touch in-memory state -- ' reason: 'a drag in progress must only touch in-memory state -- '
'persisting every frame is the ticket\'s own named risk'); 'persisting every frame is the ticket\'s own named risk');
}); });

View File

@@ -2,53 +2,69 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:rippr/src/hud/hud_metric.dart'; import 'package:rippr/src/hud/hud_metric.dart';
import 'package:rippr/src/hud/hud_widget_layout.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 /// FB-03: the JSON round-trip, grid-clamp math, overlap check, and free-slot search
/// -- no real gestures needed to test either, per the ticket's own Tests section. /// are all exercised directly with fixed inputs -- no real gestures needed to test
/// any of these, per the ticket's own Tests section.
void main() { void main() {
group('JSON round trip', () { group('JSON round trip', () {
test('encodes and decodes exactly', () { test('encodes and decodes exactly', () {
const original = HudWidgetLayout( const original = HudWidgetLayout(
metric: HudMetric.speed, metric: HudMetric.speed,
x: 0.1, col: 1,
y: 0.2, row: 2,
width: 0.3, colSpan: 3,
height: 0.15, rowSpan: 2,
visible: true, visible: true,
); );
final decoded = HudWidgetLayout.fromJson(HudMetric.speed, original.toJson()); final decoded = HudWidgetLayout.fromJson(HudMetric.speed, original.toJson());
expect(decoded.x, original.x); expect(decoded.col, original.col);
expect(decoded.y, original.y); expect(decoded.row, original.row);
expect(decoded.width, original.width); expect(decoded.colSpan, original.colSpan);
expect(decoded.height, original.height); expect(decoded.rowSpan, original.rowSpan);
expect(decoded.visible, original.visible); expect(decoded.visible, original.visible);
}); });
test('a malformed entry falls back to the default rather than crashing', () { test('a malformed entry falls back to the default rather than crashing', () {
final decoded = HudWidgetLayout.fromJson(HudMetric.distance, {'x': 'not a number'}); final decoded = HudWidgetLayout.fromJson(HudMetric.distance, {'col': 'not a number'});
final default_ = HudWidgetLayout.defaultFor(HudMetric.distance); final default_ = HudWidgetLayout.defaultFor(HudMetric.distance);
expect(decoded.x, default_.x); expect(decoded.col, default_.col);
expect(decoded.width, default_.width); expect(decoded.colSpan, default_.colSpan);
}); });
test('a missing field falls back to the default rather than crashing', () { 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 decoded = HudWidgetLayout.fromJson(HudMetric.maxSpeed, {'col': 1, 'row': 1});
final default_ = HudWidgetLayout.defaultFor(HudMetric.maxSpeed); final default_ = HudWidgetLayout.defaultFor(HudMetric.maxSpeed);
expect(decoded.x, default_.x); expect(decoded.col, default_.col);
});
test('an old-shaped (pre-grid) saved layout falls back to the default rather '
'than crashing', () {
final decoded = HudWidgetLayout.fromJson(
HudMetric.speed,
{'x': 0.1, 'y': 0.2, 'width': 0.3, 'height': 0.15, 'visible': true},
);
final default_ = HudWidgetLayout.defaultFor(HudMetric.speed);
expect(decoded.col, default_.col);
expect(decoded.row, default_.row);
expect(decoded.colSpan, default_.colSpan);
expect(decoded.rowSpan, default_.rowSpan);
}); });
}); });
group('defaultFor', () { group('defaultFor', () {
test('every metric gets a distinct default position', () { test('every metric gets a distinct, non-overlapping default rect', () {
final positions = HudMetric.values final layouts = HudMetric.values.map(HudWidgetLayout.defaultFor).toList();
.map(HudWidgetLayout.defaultFor) for (var i = 0; i < layouts.length; i++) {
.map((l) => '${l.x},${l.y}') for (var j = i + 1; j < layouts.length; j++) {
.toSet(); expect(hudRectsOverlap(layouts[i], layouts[j]), isFalse,
expect(positions.length, HudMetric.values.length, reason: 'no two metrics should default to overlapping rects');
reason: 'no two metrics should default to overlapping positions'); }
}
}); });
test('the first four metrics (the Map HUD mockup\'s fixed row: Speed/Avg Speed/' test('the first four metrics (the Map HUD mockup\'s fixed row: Speed/Avg Speed/'
@@ -64,65 +80,146 @@ void main() {
expect(HudWidgetLayout.defaultFor(HudMetric.elevationGain).visible, isFalse); expect(HudWidgetLayout.defaultFor(HudMetric.elevationGain).visible, isFalse);
}); });
test('every default is already within the valid clamp bounds', () { test('every default is already within the valid grid bounds', () {
for (final metric in HudMetric.values) { for (final metric in HudMetric.values) {
final layout = HudWidgetLayout.defaultFor(metric); final layout = HudWidgetLayout.defaultFor(metric);
expect(layout.clamped().x, layout.x); expect(layout.clampedToGrid().col, layout.col);
expect(layout.clamped().y, layout.y); expect(layout.clampedToGrid().row, layout.row);
expect(layout.clamped().width, layout.width); expect(layout.clampedToGrid().colSpan, layout.colSpan);
expect(layout.clamped().height, layout.height); expect(layout.clampedToGrid().rowSpan, layout.rowSpan);
} }
}); });
}); });
group('clamped', () { group('clampedToGrid', () {
const base = HudWidgetLayout( const base = HudWidgetLayout(
metric: HudMetric.speed, metric: HudMetric.speed,
x: 0.5, col: 1,
y: 0.5, row: 1,
width: 0.3, colSpan: 2,
height: 0.15, rowSpan: 2,
visible: true, visible: true,
); );
test('a position dragged past the right/bottom edge is pulled back on-screen', () { test('a position dragged past the right/bottom edge is pulled back onto the '
final result = base.copyWith(x: 1.5, y: 1.5).clamped(); 'grid', () {
expect(result.x, 1.0 - base.width); final result = base.copyWith(col: 20, row: 20).clampedToGrid();
expect(result.y, 1.0 - base.height); expect(result.col, hudGridColumns - base.colSpan);
expect(result.row, hudGridRows - base.rowSpan);
}); });
test('a position dragged past the left/top edge is pulled back on-screen', () { test('a position dragged past the left/top edge is pulled back onto the grid', () {
final result = base.copyWith(x: -0.5, y: -0.5).clamped(); final result = base.copyWith(col: -5, row: -5).clampedToGrid();
expect(result.x, 0.0); expect(result.col, 0);
expect(result.y, 0.0); expect(result.row, 0);
}); });
test('a resize below the legibility floor is corrected up to the minimum', () { test('a resize below the legibility floor is corrected up to the minimum', () {
final result = base.copyWith(width: 0.01, height: 0.01).clamped(); final result = base.copyWith(colSpan: 0, rowSpan: 0).clampedToGrid();
expect(result.width, hudMinWidthFraction); expect(result.colSpan, hudMinSpan);
expect(result.height, hudMinHeightFraction); expect(result.rowSpan, hudMinSpan);
}); });
test('a resize above the sane ceiling is corrected down to the maximum', () { test('a resize above the sane ceiling is corrected down to the maximum', () {
final result = base.copyWith(width: 5.0, height: 5.0).clamped(); final result = base.copyWith(colSpan: 99, rowSpan: 99).clampedToGrid();
expect(result.width, hudMaxWidthFraction); expect(result.colSpan, hudMaxColSpan);
expect(result.height, hudMaxHeightFraction); expect(result.rowSpan, hudMaxRowSpan);
}); });
test('shrinking to fit happens before repositioning, so a widget resized past ' test('shrinking to fit happens before repositioning, so a widget resized past '
'the edge shrinks rather than silently relocates', () { 'the edge shrinks rather than silently relocates', () {
final result = base.copyWith(x: 0.9, width: 0.7).clamped(); final result = base.copyWith(col: 3, colSpan: 4).clampedToGrid();
expect(result.width, hudMaxWidthFraction); expect(result.colSpan, hudMaxColSpan);
// x itself is still clamped against the (now-bounded) width so the widget // col itself is still clamped against the (now-bounded) span so the widget
// never sits partially off-screen either. // never sits even partially off the grid either.
expect(result.x + result.width, lessThanOrEqualTo(1.0)); expect(result.col + result.colSpan, lessThanOrEqualTo(hudGridColumns));
}); });
test('an already-valid layout is unchanged', () { test('an already-valid layout is unchanged', () {
expect(base.clamped().x, base.x); expect(base.clampedToGrid().col, base.col);
expect(base.clamped().y, base.y); expect(base.clampedToGrid().row, base.row);
expect(base.clamped().width, base.width); expect(base.clampedToGrid().colSpan, base.colSpan);
expect(base.clamped().height, base.height); expect(base.clampedToGrid().rowSpan, base.rowSpan);
});
});
group('hudRectsOverlap', () {
const a = HudWidgetLayout(
metric: HudMetric.speed,
col: 1,
row: 1,
colSpan: 2,
rowSpan: 2,
visible: true,
);
test('two identical rects overlap', () {
expect(hudRectsOverlap(a, a), isTrue);
});
test('two rects sharing only an edge (touching, not overlapping) do not overlap', () {
final rightNeighbor = a.copyWith(col: a.col + a.colSpan);
expect(hudRectsOverlap(a, rightNeighbor), isFalse);
final belowNeighbor = a.copyWith(row: a.row + a.rowSpan);
expect(hudRectsOverlap(a, belowNeighbor), isFalse);
});
test('two disjoint rects do not overlap', () {
final far = a.copyWith(col: a.col + a.colSpan + 3, row: a.row + a.rowSpan + 3);
expect(hudRectsOverlap(a, far), isFalse);
});
test('a rect fully containing another overlaps', () {
const big = HudWidgetLayout(
metric: HudMetric.distance,
col: 0,
row: 0,
colSpan: 4,
rowSpan: 4,
visible: true,
);
expect(hudRectsOverlap(big, a), isTrue);
});
test('two partially-overlapping rects overlap', () {
final overlapping = a.copyWith(col: a.col + 1, row: a.row + 1);
expect(hudRectsOverlap(a, overlapping), isTrue);
});
});
group('nextFreeSlot', () {
test('returns a rect that does not collide with any occupied visible layout, '
'when the metric\'s own default position is taken', () {
final occupier = HudWidgetLayout.defaultFor(HudMetric.speed);
final occupied = {HudMetric.speed: occupier};
final result = HudWidgetLayout.nextFreeSlot(
HudMetric.avgSpeed,
occupied: occupied,
colSpan: occupier.colSpan,
rowSpan: occupier.rowSpan,
);
expect(hudRectsOverlap(result, occupier), isFalse);
});
test('returns the same rect defaultFor would have (or another valid one) when '
'occupied is empty', () {
final result = HudWidgetLayout.nextFreeSlot(HudMetric.speed, occupied: {});
expect(result.col, 0);
expect(result.row, 0);
});
test('returns the same rect defaultFor would have (or another valid one) when '
'occupied is all-hidden', () {
final hidden = HudWidgetLayout.defaultFor(HudMetric.speed).copyWith(visible: false);
final result = HudWidgetLayout.nextFreeSlot(
HudMetric.avgSpeed,
occupied: {HudMetric.speed: hidden},
);
expect(result.col, 0);
expect(result.row, 0);
}); });
}); });
} }