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