diff --git a/docs/feedback/FB-08-hud-reflow-on-hide.md b/docs/feedback/FB-08-hud-reflow-on-hide.md new file mode 100644 index 0000000..e62dd73 --- /dev/null +++ b/docs/feedback/FB-08-hud-reflow-on-hide.md @@ -0,0 +1,157 @@ +# FB-08 — HUD widgets reflow to fill the gap when one is hidden + +**Depends on** — · **Size** M · **Status** Done + +## Goal +Hiding a HUD metric must resize the remaining widgets in its row to fill the empty +space. Today hiding a metric leaves an empty gap in the grid. + +## Context +Direct user feedback (`docs/FEEDBACK.md`): + +> When disabling some statistic during the HUD sessions, they should resize +> themselves, instead of leaving a gap by default. + +`lib/src/hud/hud_layout_controller.dart`, `setVisible` (full method): + +```dart +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)}; +} +``` + +This method only ever changes the toggled metric's own entry. No other metric's `col`, +`row`, or `colSpan` ever changes as a side effect. When a metric in a row of four is +hidden, the other three keep their exact original `col`/`colSpan` — the grid cell the +hidden metric used to occupy simply renders nothing, showing as blank space in the HUD. + +`HudWidgetLayout` (`lib/src/hud/hud_widget_layout.dart`) already has everything this +ticket needs to build a row-reflow function: `col`, `row`, `colSpan`, `rowSpan` (all +`int`), `copyWith`, and `hudRectsOverlap`. `hudGridColumns` is `4`. + +## Design +- Add a private helper to `HudLayoutController`: + ```dart + /// Spreads every visible widget in [row] evenly across the full grid width, + /// left to right in their existing column order. A widget whose row has no other + /// visible member is left alone -- there is nothing to redistribute. + Map _reflowRow( + Map layouts, + int row, + ) { + final members = layouts.values.where((l) => l.visible && l.row == row).toList() + ..sort((a, b) => a.col.compareTo(b.col)); + if (members.length < 2) return layouts; + final baseSpan = hudGridColumns ~/ members.length; + final extra = hudGridColumns % members.length; + final result = Map.from(layouts); + var col = 0; + for (var i = 0; i < members.length; i++) { + final span = baseSpan + (i < extra ? 1 : 0); + result[members[i].metric] = members[i].copyWith(col: col, colSpan: span); + col += span; + } + return result; + } + ``` + A row of 4 becomes 4 equal columns (unchanged from today). A row of 3 becomes + columns of width `[2, 1, 1]` (`4 ~/ 3 = 1`, remainder `1` goes to the first member). + A row of 2 becomes `[2, 2]`. A row of 1 becomes `[4]` — a single remaining widget + fills the whole row. +- Call `_reflowRow` from `setVisible`, after computing the toggled metric's own new + entry, using that metric's **row before the toggle** when hiding, and its **row + after placement** when showing (the row a newly-shown metric lands in via + `defaultFor`/`nextFreeSlot`): + ```dart + 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, + ); + } + final updated = {...state, metric: current.copyWith(visible: visible)}; + state = _reflowRow(updated, current.row); + } + ``` +- Reflow only touches the toggled metric's own row. A different row's widgets never + move when a metric in another row is hidden or shown. +- Reflow applies to every visible widget currently in that row, whether it is at its + default position or one the rider dragged there manually. This ticket does not track + "was this widget moved by the rider" — the row is always kept evenly filled, by + design, since the feedback asks for the row to "resize themselves" whenever a metric + in it is hidden, not only in the untouched-default case. +- A drag or a resize (as opposed to a visibility toggle) does **not** trigger reflow — + `updatePosition`/`updateSize` are unchanged. Reflow is scoped to `setVisible` only. + +## Implementation +1. Add `_reflowRow` to `HudLayoutController`. +2. Call `_reflowRow` at the end of `setVisible`, passing the toggled metric's row. +3. Run `flutter analyze` and `flutter test`. + +## Acceptance criteria +- [ ] Hiding one metric out of four in the same row resizes the remaining three to + fill the row evenly — no empty gap. +- [ ] Hiding a metric that is alone in its row leaves every other row untouched. +- [ ] Showing a hidden metric back reflows its landing row to make room for it, + shrinking the row's other members evenly. +- [ ] A metric in a different row from the one just toggled never moves. +- [ ] `flutter analyze` clean, `flutter test` green, test count only goes up. + +## Tests +In `test/hud_layout_controller_test.dart` (alongside the existing `setVisible` tests): +- Hiding one of four same-row, equally-sized metrics leaves the remaining three each + spanning `hudGridColumns ~/ 3` or one more (matching the `baseSpan`/`extra` split), + covering the full row width with no gap (assert the sum of `colSpan` across the + remaining visible row members equals `hudGridColumns`). +- Hiding a metric whose row has no other visible member leaves every other metric's + `col`/`row`/`colSpan` exactly unchanged (assert full equality against the pre-toggle + state for every other metric). +- Showing a previously-hidden metric back into a row with existing members reflows + that row to fit it — assert the sum of `colSpan` across the row (including the + newly-shown metric) still equals `hudGridColumns`. +- Toggling a metric in one row leaves a different row's own members' `col`/`colSpan` + unchanged. + +## Risks +None significant — this only changes `col`/`colSpan` values within a single row at the +moment of a visibility toggle; it does not touch persistence format, the drag/resize +collision logic, or any other file. + +## Out of scope +FB-09's title-driven default sizing — this ticket does not change what colSpan a +metric starts at, only how a row's members redistribute space among themselves when +one of them is hidden or shown. Sequence FB-09 after this ticket merges, since both +touch `HudLayoutController`/`HudWidgetLayout` and a large simultaneous diff in both is +harder to review and merge than one after the other. + +## Outcome +Implemented `_reflowRow` in `lib/src/hud/hud_layout_controller.dart` exactly as +specified in the Design section, and wired it into `setVisible` using the toggled +metric's `current.row` (its row before the toggle when hiding, its row after +`defaultFor`/`nextFreeSlot` placement when showing) — no deviation from the design. + +Added four tests to `test/hud_layout_controller_test.dart` covering the four +acceptance-criteria scenarios: hiding one of four same-row metrics reflows the +remaining three to fill the row with no gap; hiding a metric alone in its row leaves +every other metric's `col`/`row`/`colSpan` untouched; showing a previously-hidden +metric into a row with an existing member reflows that row so their `colSpan`s sum to +`hudGridColumns`; and toggling a metric in one row leaves a different row's members' +`col`/`colSpan` unchanged. + +`flutter analyze` stayed clean (only pre-existing, unrelated info-level lint notices in +`lib/src/crash/crash_reporter.dart` and `lib/src/tiles/map_connectivity.dart`, neither +touched by this ticket). `flutter test` is green with a final count of **416 tests** +(412 baseline + 4 new), no regressions. diff --git a/lib/src/hud/hud_layout_controller.dart b/lib/src/hud/hud_layout_controller.dart index 187e849..bb93283 100644 --- a/lib/src/hud/hud_layout_controller.dart +++ b/lib/src/hud/hud_layout_controller.dart @@ -67,7 +67,30 @@ class HudLayoutController extends StateNotifier> rowSpan: current.rowSpan, ); } - state = {...state, metric: current.copyWith(visible: visible)}; + final updated = {...state, metric: current.copyWith(visible: visible)}; + state = _reflowRow(updated, current.row); + } + + /// Spreads every visible widget in [row] evenly across the full grid width, + /// left to right in their existing column order. A widget whose row has no other + /// visible member is left alone -- there is nothing to redistribute. + Map _reflowRow( + Map layouts, + int row, + ) { + final members = layouts.values.where((l) => l.visible && l.row == row).toList() + ..sort((a, b) => a.col.compareTo(b.col)); + if (members.length < 2) return layouts; + final baseSpan = hudGridColumns ~/ members.length; + final extra = hudGridColumns % members.length; + final result = Map.from(layouts); + var col = 0; + for (var i = 0; i < members.length; i++) { + final span = baseSpan + (i < extra ? 1 : 0); + result[members[i].metric] = members[i].copyWith(col: col, colSpan: span); + col += span; + } + return result; } Future persist() async => _config?.setHudLayout(state); diff --git a/test/hud_layout_controller_test.dart b/test/hud_layout_controller_test.dart index 453839f..afb7bd8 100644 --- a/test/hud_layout_controller_test.dart +++ b/test/hud_layout_controller_test.dart @@ -110,6 +110,80 @@ void main() { } }); + test('hiding one of four same-row metrics reflows the remaining three to fill ' + 'the row with no gap', () async { + final controller = HudLayoutController(await freshConfig()); + + controller.setVisible(HudMetric.avgSpeed, false); + + final remaining = [HudMetric.speed, HudMetric.distance, HudMetric.elapsedTime] + .map((m) => controller.state[m]!) + .toList() + ..sort((a, b) => a.col.compareTo(b.col)); + final totalSpan = remaining.fold(0, (sum, l) => sum + l.colSpan); + expect(totalSpan, hudGridColumns); + // baseSpan = 4 ~/ 3 = 1, extra = 1 -- the first (leftmost) member absorbs it. + expect(remaining[0].colSpan, 2); + expect(remaining[1].colSpan, 1); + expect(remaining[2].colSpan, 1); + expect(controller.state[HudMetric.avgSpeed]!.visible, isFalse); + }); + + test('hiding a metric whose row has no other visible member leaves every ' + 'other metric untouched', () async { + final controller = HudLayoutController(await freshConfig()); + // Row 4 is unused by any default layout -- move speed there alone. + controller.updatePosition(HudMetric.speed, 0, 4); + + final others = { + for (final m in HudMetric.values.where((m) => m != HudMetric.speed)) + m: controller.state[m]!, + }; + + controller.setVisible(HudMetric.speed, false); + + for (final entry in others.entries) { + final after = controller.state[entry.key]!; + expect(after.col, entry.value.col, reason: '${entry.key} col changed'); + expect(after.row, entry.value.row, reason: '${entry.key} row changed'); + expect(after.colSpan, entry.value.colSpan, + reason: '${entry.key} colSpan changed'); + } + }); + + test('showing a previously-hidden metric back into a row with existing ' + 'members reflows that row to fit it', () async { + final controller = HudLayoutController(await freshConfig()); + + // maxSpeed and movingTime both default to row 2, hidden, at adjacent columns. + controller.setVisible(HudMetric.maxSpeed, true); + controller.setVisible(HudMetric.movingTime, true); + + final maxSpeed = controller.state[HudMetric.maxSpeed]!; + final movingTime = controller.state[HudMetric.movingTime]!; + expect(maxSpeed.row, movingTime.row, + reason: 'test setup assumes both land in the same row'); + expect(maxSpeed.colSpan + movingTime.colSpan, hudGridColumns); + }); + + test('toggling a metric in one row leaves a different row\'s members ' + 'untouched', () async { + final controller = HudLayoutController(await freshConfig()); + final row2Before = { + for (final m in [HudMetric.maxSpeed, HudMetric.movingTime, HudMetric.elevationGain]) + m: controller.state[m]!, + }; + + controller.setVisible(HudMetric.avgSpeed, false); + + for (final entry in row2Before.entries) { + final after = controller.state[entry.key]!; + expect(after.col, entry.value.col, reason: '${entry.key} col changed'); + expect(after.colSpan, entry.value.colSpan, + reason: '${entry.key} colSpan changed'); + } + }); + test('persist writes the current state to Config', () async { final config = await freshConfig(); final controller = HudLayoutController(config);