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

32 KiB
Raw Blame History

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):

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:

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:

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):

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):

@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:

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):

    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:

    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):

    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:

    /// 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:

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.

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 Texts (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:

@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 Texts.
  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 labels 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 Texts.

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.