/// 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> { // `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 persist() async => _config?.setHudLayout(state); }