Files
rippr/lib/src/hud/hud_layout_controller.dart
uhryniuk 2d118b46a2 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.
2026-08-24 15:53:00 -05:00

75 lines
3.4 KiB
Dart

/// 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
/// named risk). A drag in progress only ever touches this in-memory state; [persist] is
/// what a `HudEditOverlay` calls when the rider exits edit mode.
library;
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../config/config.dart';
import 'hud_metric.dart';
import 'hud_widget_layout.dart';
class HudLayoutController extends StateNotifier<Map<HudMetric, HudWidgetLayout>> {
// `Config?`, not `Config` -- at app boot `configProvider` is briefly null (loaded
// asynchronously post-first-frame, same as every other Config-seeded provider in
// this app). Defaults stand in until it resolves; [persist] silently no-ops rather
// than blocking, since editing the HUD before Config has loaded isn't a real path a
// rider can reach in practice.
HudLayoutController(this._config)
: super(
_config?.hudLayout ??
{for (final m in HudMetric.values) m: HudWidgetLayout.defaultFor(m)},
);
final Config? _config;
/// "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;
final candidate = current.copyWith(col: col, row: row).clampedToGrid();
if (_overlapsAnyOther(metric, candidate)) return;
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));
/// 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. 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) {
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)};
}
Future<void> persist() async => _config?.setHudLayout(state);
}