Adds ambientPositionProvider (never calls LocationSource.stop(), only the idempotent start()) and wires it into ShellScaffold whenever no trip is active, so the persistent background map centers and follows the device's live GPS position at street-level zoom instead of sitting at (0,0)/zoom 2 until a recording starts. RideMap gains an ambientPosition param that the existing chase-camera/pan-cancel/location-marker logic falls back to whenever there are no recorded points, with recorded points always taking priority. Also brings in the docs/feedback ticket set (FB-01..FB-05, README, FEEDBACK.md) that this worktree's branch point predated. Adds 10 tests (374 -> 384): RideMap-level ambient centering/chase/pan-cancel/ marker coverage in test/ride_map_test.dart, plus shell-wiring and provider-level (never-calls-stop, mapEnabledProvider-off) coverage in test/widget_test.dart. flutter analyze remains clean.
28 KiB
FB-03 — Grid-snapped HUD widgets with auto-fit, centered text
Depends on — · Size L · Status Not started
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()replacesclamped()— a self-contained bounds check with no awareness of siblings (kept as its own method becausefromJsonanddefaultFor/nextFreeSlotall 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: keepdefaultFor(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
defaultFortest 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
- Rewrite
lib/src/hud/hud_widget_layout.dart: grid constants,col/row/colSpan/rowSpanfields,clampedToGrid(),hudRectsOverlap(), updatedtoJson/fromJson, updateddefaultFor, newnextFreeSlot. - Rewrite
lib/src/hud/hud_layout_controller.dart:updatePosition/updateSizetake ints and reject-on-collision;setVisibleusesnextFreeSlotwhen the metric's current slot collides. - Rewrite
DraggableResizableHudWidget: local pixel-offset drag state, snap-on-end, updated callback signatures, pixel math fromareaSize / hudGridColumns/hudGridRows. - Update
HudEditOverlay's two callback closures to the new int signatures. - Wrap
_HudMetricValue'sColumninFittedBox(fit: BoxFit.scaleDown), dropmaxLines/overflowon its twoTexts. flutter analyze, fix any resulting type errors elsewhere that referenced the oldx/y/width/heightfields (grep for.x/.y/.width/.heightonHudWidgetLayoutinstances acrosslib/andtest/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/heightkeys) loads without crashing — falls back todefaultForper metric, exactly asfromJson's existing try/catch already guarantees. flutter analyzeclean,flutter testgreen, 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/visiblelayout 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, viahudRectsOverlap) 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 oldclampedtests: 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 ofoccupiedvisible layouts that collide with a metric's owndefaultForposition, returns some other rect that doesn't collide with any of them; given an empty/all-hiddenoccupiedmap, returns the same rectdefaultForwould 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/updateSizeupdate only the given metric, still true.- New:
updatePositionto a cell already occupied by another visible metric is a no-op (state for the target metric is unchanged). - New:
updateSizethat would make a widget overlap a sibling is a no-op. setVisible(false)thensetVisible(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 resultingstate[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.