FB-08: reflow HUD row widgets when a metric is hidden or shown
setVisible now calls a new _reflowRow helper on the toggled metric's row, spreading the remaining (or newly added) visible widgets evenly across the 4-column grid instead of leaving a blank cell where a hidden metric used to be. Reflow is scoped to setVisible only; drag/resize behavior is unchanged.
This commit is contained in:
157
docs/feedback/FB-08-hud-reflow-on-hide.md
Normal file
157
docs/feedback/FB-08-hud-reflow-on-hide.md
Normal file
@@ -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<HudMetric, HudWidgetLayout> _reflowRow(
|
||||||
|
Map<HudMetric, HudWidgetLayout> 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<HudMetric, HudWidgetLayout>.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.
|
||||||
@@ -67,7 +67,30 @@ class HudLayoutController extends StateNotifier<Map<HudMetric, HudWidgetLayout>>
|
|||||||
rowSpan: current.rowSpan,
|
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<HudMetric, HudWidgetLayout> _reflowRow(
|
||||||
|
Map<HudMetric, HudWidgetLayout> 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<HudMetric, HudWidgetLayout>.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<void> persist() async => _config?.setHudLayout(state);
|
Future<void> persist() async => _config?.setHudLayout(state);
|
||||||
|
|||||||
@@ -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<int>(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 {
|
test('persist writes the current state to Config', () async {
|
||||||
final config = await freshConfig();
|
final config = await freshConfig();
|
||||||
final controller = HudLayoutController(config);
|
final controller = HudLayoutController(config);
|
||||||
|
|||||||
Reference in New Issue
Block a user