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.
168 lines
5.9 KiB
Dart
168 lines
5.9 KiB
Dart
/// 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';
|
|
|
|
/// 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.col,
|
|
required this.row,
|
|
required this.colSpan,
|
|
required this.rowSpan,
|
|
required this.visible,
|
|
});
|
|
|
|
final HudMetric metric;
|
|
|
|
/// 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({
|
|
int? col,
|
|
int? row,
|
|
int? colSpan,
|
|
int? rowSpan,
|
|
bool? visible,
|
|
}) => HudWidgetLayout(
|
|
metric: metric,
|
|
col: col ?? this.col,
|
|
row: row ?? this.row,
|
|
colSpan: colSpan ?? this.colSpan,
|
|
rowSpan: rowSpan ?? this.rowSpan,
|
|
visible: visible ?? this.visible,
|
|
);
|
|
|
|
/// 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,
|
|
col: clampedCol,
|
|
row: clampedRow,
|
|
colSpan: clampedColSpan,
|
|
rowSpan: clampedRowSpan,
|
|
visible: visible,
|
|
);
|
|
}
|
|
|
|
Map<String, dynamic> toJson() => {
|
|
'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 (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,
|
|
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,
|
|
).clampedToGrid();
|
|
} catch (_) {
|
|
return HudWidgetLayout.defaultFor(metric);
|
|
}
|
|
}
|
|
|
|
/// A deterministic starting grid -- a fresh install has a working, if plain, HUD
|
|
/// 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. 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 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,
|
|
// 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);
|
|
}
|
|
}
|