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

Replace free-form fractional HUD widget positioning with a 4x8 int grid
(col/row/colSpan/rowSpan), reject drag/resize moves that would overlap
another visible widget, and re-place a re-enabled metric into the next
free grid slot instead of stacking it on an occupier. Drag/resize now
track a local pixel offset during the gesture and only snap/commit once
at gesture end. HUD widget text is wrapped in FittedBox(scaleDown) so it
never overflows and stays centered at any grid size.
This commit is contained in:
2026-08-24 15:53:00 -05:00
parent 04127d7237
commit 2d118b46a2
10 changed files with 1306 additions and 198 deletions

View File

@@ -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
/// 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;
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];
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];
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
/// [HudWidgetLayout.defaultFor] rather than whatever stale position it held from
/// before it was last turned off -- the ticket's own acceptance criterion. A metric
/// that already has a real saved position keeps it.
/// before it was last turned off. A metric that already has a real saved position
/// 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) {
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)};
}

View File

@@ -1,94 +1,110 @@
/// UI-04: 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
/// available HUD area rather than absolute pixels: a layout saved on one device or
/// orientation still makes sense on another.
/// FB-03: one telemetry widget's position, size, and visibility on the customizable
/// HUD -- an Android-home-screen-style snapped grid (see the ticket's Design section
/// for why: free-form fractional positioning had no concept of a sibling widget, so
/// toggling widgets on/off could stack one directly on top of another).
library;
import 'hud_metric.dart';
/// Legibility floor and a sane ceiling -- a widget must never shrink to the point its
/// own number is unreadable, or grow to the point it swallows the whole HUD.
const double hudMinWidthFraction = 0.20;
const double hudMaxWidthFraction = 0.70;
const double hudMinHeightFraction = 0.08;
const double hudMaxHeightFraction = 0.40;
/// Fixed grid spanning the HUD area -- matches the previous 4-column default-row
/// assumption and gives ample vertical room for the 8-metric list.
const int hudGridColumns = 4;
const int hudGridRows = 8;
/// 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 {
const HudWidgetLayout({
required this.metric,
required this.x,
required this.y,
required this.width,
required this.height,
required this.col,
required this.row,
required this.colSpan,
required this.rowSpan,
required this.visible,
});
final HudMetric metric;
/// Top-left corner, as a fraction of the HUD area's width/height.
final double x;
final double y;
final double width;
final double height;
/// Top-left cell, in grid coordinates (0-based).
final int col;
final int row;
/// How many cells wide/tall this widget spans.
final int colSpan;
final int rowSpan;
final bool visible;
HudWidgetLayout copyWith({
double? x,
double? y,
double? width,
double? height,
int? col,
int? row,
int? colSpan,
int? rowSpan,
bool? visible,
}) => HudWidgetLayout(
metric: metric,
x: x ?? this.x,
y: y ?? this.y,
width: width ?? this.width,
height: height ?? this.height,
col: col ?? this.col,
row: row ?? this.row,
colSpan: colSpan ?? this.colSpan,
rowSpan: rowSpan ?? this.rowSpan,
visible: visible ?? this.visible,
);
/// Corrects a drag/resize result that ended outside the allowed area back to the
/// nearest valid position/size -- clamped to the [hudMinWidthFraction]/
/// [hudMaxWidthFraction] etc. bounds first (size), then positioned so it can never
/// sit even partially outside the 0.0-1.0 HUD area (position), in that order: a
/// resize that would push a widget off-screen should shrink it back on-screen, not
/// silently reposition it out from under the rider's finger.
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);
/// A *self-contained* bounds check with no awareness of siblings -- corrects a
/// drag/resize result that ended outside the grid back to the nearest valid
/// position/size, span first (so a resize that would push a widget off-grid shrinks
/// it back on-grid rather than silently repositioning it), then position.
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,
x: clampedX,
y: clampedY,
width: clampedWidth,
height: clampedHeight,
col: clampedCol,
row: clampedRow,
colSpan: clampedColSpan,
rowSpan: clampedRowSpan,
visible: visible,
);
}
Map<String, dynamic> toJson() => {
'x': x,
'y': y,
'width': width,
'height': height,
'col': col,
'row': row,
'colSpan': colSpan,
'rowSpan': rowSpan,
'visible': visible,
};
/// 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
/// recognise should degrade to a sane default, not crash Settings on launch.
/// an old saved layout (pre-grid, with `x`/`y`/`width`/`height` keys) or one with
/// 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) {
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(),
col: (json['col'] as num).toInt(),
row: (json['row'] as num).toInt(),
colSpan: (json['colSpan'] as num).toInt(),
rowSpan: (json['rowSpan'] as num).toInt(),
visible: json['visible'] as bool,
).clamped();
).clampedToGrid();
} catch (_) {
return HudWidgetLayout.defaultFor(metric);
}
@@ -98,24 +114,54 @@ class HudWidgetLayout {
/// 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
/// 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) {
const columns = 4;
const cellWidth = 0.22;
const cellHeight = 0.12;
const gap = 0.02;
const rowSpan = 2;
final index = HudMetric.values.indexOf(metric);
final row = index ~/ columns;
final row = (index ~/ columns) * rowSpan;
final col = index % columns;
return HudWidgetLayout(
metric: metric,
x: 0.02 + col * (cellWidth + gap),
y: 0.06 + row * (cellHeight + gap),
width: cellWidth,
height: cellHeight,
col: col,
row: row,
colSpan: 1,
rowSpan: rowSpan,
// 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.
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
/// [HudWidgetLayout]'s fractions against whatever pixel area it's given, draggable and
/// resizable only while [editing] is true.
/// UI-04/FB-03: one telemetry widget on the customizable HUD -- positioned/sized from a
/// [HudWidgetLayout]'s grid cell against whatever pixel area it's given, draggable and
/// 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 --
/// "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 Widget child;
/// Fractional x/y, already relative to [areaSize] -- not yet clamped; the caller
/// (`HudLayoutController.updatePosition`) owns clamping so there is exactly one
/// place that logic lives.
final void Function(double x, double y) onMoved;
final void Function(double width, double height) onResized;
/// Final snapped grid coordinates -- called exactly once, at gesture end, not on
/// every frame of movement (a local, uncommitted pixel offset tracks the live drag;
/// see [_DraggableResizableHudWidgetState]). The caller
/// (`HudLayoutController.updatePosition`) owns collision/bounds checking so there is
/// exactly one place that logic lives.
final void Function(int col, int row) onMoved;
final void Function(int colSpan, int rowSpan) onResized;
@override
State<DraggableResizableHudWidget> createState() =>
@@ -50,13 +55,27 @@ class DraggableResizableHudWidget extends StatefulWidget {
class _DraggableResizableHudWidgetState extends State<DraggableResizableHudWidget> {
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
Widget build(BuildContext context) {
final layout = widget.layout;
final left = layout.x * widget.areaSize.width;
final top = layout.y * widget.areaSize.height;
final width = layout.width * widget.areaSize.width;
final height = layout.height * widget.areaSize.height;
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;
Widget card = AnimatedScale(
scale: _grabbed ? 1.05 : 1.0,
@@ -71,13 +90,23 @@ class _DraggableResizableHudWidgetState extends State<DraggableResizableHudWidge
setState(() => _grabbed = true);
},
onLongPressMoveUpdate: (details) {
widget.onMoved(
layout.x + details.offsetFromOrigin.dx / widget.areaSize.width,
layout.y + details.offsetFromOrigin.dy / widget.areaSize.height,
);
setState(() => _dragOffset = details.offsetFromOrigin);
},
onLongPressEnd: (_) => setState(() => _grabbed = false),
onLongPressCancel: () => setState(() => _grabbed = false),
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;
}),
child: card,
);
} else {
@@ -109,10 +138,23 @@ class _DraggableResizableHudWidgetState extends State<DraggableResizableHudWidge
bottom: -8,
child: GestureDetector(
onPanUpdate: (details) {
widget.onResized(
layout.width + details.delta.dx / widget.areaSize.width,
layout.height + details.delta.dy / widget.areaSize.height,
);
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);
},
child: Container(
key: const Key('hud-resize-handle'),

View File

@@ -64,8 +64,9 @@ class _HudEditOverlayState extends ConsumerState<HudEditOverlay> {
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),
onMoved: (col, row) => notifier.updatePosition(entry.key, col, row),
onResized: (colSpan, rowSpan) =>
notifier.updateSize(entry.key, colSpan, rowSpan),
child: widget.metricBuilder(context, entry.key),
),
if (_editing)

View File

@@ -321,27 +321,33 @@ class _HudMetricValue extends StatelessWidget {
@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),
// FB-03: FittedBox owns sizing here -- structurally impossible to overflow, so
// maxLines/overflow are dropped from both Texts. fontSize: 10/18 below are now
// just the "reference" size FittedBox scales down from at small widget sizes; the
// ratio between label and value size is preserved automatically as it scales.
// Note: BoxFit.scaleDown never enlarges past that reference size, so a widget
// resized to the grid's maximum span still shows 10/18px text, just with more
// empty space around it -- not larger text filling the space.
return FittedBox(
fit: BoxFit.scaleDown,
child: Column(
mainAxisSize: MainAxisSize.min,
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),
),
),
],
),
);
}
}