Merge FB-03: grid-snapped HUD widgets with auto-fit text

# Conflicts:
#	docs/feedback/FB-03-grid-snapped-hud-widgets.md
#	lib/src/ui/record/record_screen.dart
This commit is contained in:
2026-08-24 16:04:33 -05:00
10 changed files with 708 additions and 202 deletions

View File

@@ -1,6 +1,6 @@
# FB-03 — Grid-snapped HUD widgets with auto-fit, centered text # FB-03 — Grid-snapped HUD widgets with auto-fit, centered text
**Depends on** — · **Size** L · **Status** Not started **Depends on** — · **Size** L · **Status** Done
## Goal ## Goal
Rework the customizable HUD telemetry widgets (built in UI-04) from free-form fractional Rework the customizable HUD telemetry widgets (built in UI-04) from free-form fractional
@@ -601,3 +601,61 @@ FB-02 (hiding the idle Speed panel — separate ticket, this one only touches th
recording-state HUD widgets themselves). Making widget text grow to actually fill a 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 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. 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

@@ -328,31 +328,38 @@ 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
mainAxisSize: MainAxisSize.min, // maxLines/overflow are dropped from both Texts. fontSize: 10/18 below (still
children: [ // scaled by V3-05/FB-02's mounted-mode [scale]) are now just the "reference" size
Text( // FittedBox scales down from at small widget sizes; the ratio between label and
metric.label.toUpperCase(), // value size is preserved automatically as it scales. Note: BoxFit.scaleDown never
style: TextStyle( // enlarges past that reference size, so a widget resized to the grid's maximum
fontSize: 10 * scale, // span still shows the same reference text, just with more empty space around it
letterSpacing: 1, // -- not larger text filling the space.
color: colors.onSurfaceVariant, return FittedBox(
fit: BoxFit.scaleDown,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
metric.label.toUpperCase(),
style: TextStyle(
fontSize: 10 * scale,
letterSpacing: 1,
color: colors.onSurfaceVariant,
),
), ),
maxLines: 1, const SizedBox(height: 4),
overflow: TextOverflow.ellipsis, Text(
), _value,
const SizedBox(height: 4), style: monoDigits.copyWith(
Text( fontSize: 18 * scale,
_value, fontWeight: FontWeight.bold,
style: monoDigits.copyWith( color: _valueColor(colors),
fontSize: 18 * scale, ),
fontWeight: FontWeight.bold,
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);
}); });
}); });
} }