Merge FB-08: reflow HUD row widgets on hide/show

# Conflicts:
#	docs/feedback/FB-08-hud-reflow-on-hide.md
This commit is contained in:
2026-08-25 00:13:10 -05:00
3 changed files with 118 additions and 2 deletions

View File

@@ -1,6 +1,6 @@
# FB-08 — HUD widgets reflow to fill the gap when one is hidden
**Depends on** — · **Size** M · **Status** Not started
**Depends on** — · **Size** M · **Status** Done
## Goal
Hiding a HUD metric must resize the remaining widgets in its row to fill the empty
@@ -136,3 +136,22 @@ metric starts at, only how a row's members redistribute space among themselves w
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.

View File

@@ -67,7 +67,30 @@ class HudLayoutController extends StateNotifier<Map<HudMetric, HudWidgetLayout>>
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);

View File

@@ -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 {
final config = await freshConfig();
final controller = HudLayoutController(config);