/// 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 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 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 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); } }