Merge FB-09: HUD default sizing follows the title

This commit is contained in:
2026-08-25 00:29:07 -05:00
7 changed files with 382 additions and 65 deletions

View File

@@ -1,6 +1,6 @@
# FB-09 — HUD default sizing follows the title, not a fixed uniform cell
**Depends on** FB-08 (same files, sequence after it merges) · **Size** L · **Status** Not started
**Depends on** FB-08 (same files, sequence after it merges) · **Size** L · **Status** Done
## Goal
A HUD widget's title must always fit at a fixed, readable size. A HUD widget's value
@@ -258,3 +258,71 @@ merged, and re-run FB-08's own tests to confirm the two features compose correct
`colSpan` — if `_reflowRow`'s even split would push a widget below its own minimum,
that is a real interaction between the two tickets worth checking explicitly and
documenting in this ticket's Outcome section).
## Outcome
Implemented exactly as designed, with no changes to the Design section's approach:
- `minColSpanForLabel(String label)` added to `hud_widget_layout.dart`, verbatim.
- `HudWidgetLayout.defaultFor` reworked into the left-to-right row-packing factory
from the Design section, verbatim.
- `draggable_resizable_hud_widget.dart`'s resize handle `onPanEnd` now clamps
`snappedColSpan` up to `minColSpanForLabel(layout.metric.label)` (floor) and down to
`hudMaxColSpan` (ceiling), instead of the generic `hudMinSpan` floor the grid-level
clamp (`clampedToGrid`, still called downstream in `HudLayoutController`) applies to
every other span.
- `_HudMetricValue.build()` in `record_screen.dart` now renders the title as a plain,
unwrapped `Text` (with `maxLines: 1`/`TextOverflow.ellipsis` restored as a safety
net) at its fixed reference size, and wraps only the value `Text` in
`Flexible(child: FittedBox(fit: BoxFit.scaleDown, ...))`.
**Risk #1 (four even columns no longer even) — confirmed, as predicted, not a
regression.** With real label lengths, the default row-0 packing is now: Speed (span
1), Average speed (span 2), Distance (span 1) filling all 4 columns — Elapsed time (a
fourth default-visible metric, still marked `visible: true` by the unchanged
`index < 4` rule) wraps to row 2 alongside Max speed, rather than sharing row 0 with
the other three. This is the ticket's own predicted, intended outcome of sizing by
title rather than by a fixed 4-per-row assumption, not a bug. (Note: the worked
example table in the Design section above lists slightly different character counts
for "Average speed"/"Elevation gain"/"Points captured" than their actual `String`
lengths in `hud_metric.dart` — the real lengths are 13/14/15, one less than the
table's 14/15/16 — which changes "Elevation gain" from the table's predicted span-3 to
an actual span-2. This doesn't affect correctness of the implemented function, which
matches the Design section's code verbatim; it only means the packing result differs
slightly from the table's worked example. No grid-bounds issue results: the last
default row (`pointsCaptured` alone) lands at `row: 6`, `rowSpan: 2`, exactly at
`hudGridRows`'s (8) boundary.)
**Risk #2 / FB-08 interaction (named in Out of scope) — confirmed real, left
unfixed per the ticket's own scope.** Checked directly: `_reflowRow`'s even split
divides `hudGridColumns` (4) across however many visible members now share a row,
with no awareness of any member's `minColSpanForLabel`. Simulating every membership
count `_reflowRow` ever handles (2, 3, or 4 -- it no-ops below 2) against every real
`HudMetric` label shows the even split pushes a member below its own title-driven
minimum in *every* case where that member's label needs more than 1 column:
- 2 members -> both get span 2 -- too small for "Points captured" (needs 3).
- 3 members -> spans of 2/1/1 -- too small for any member needing 2 (e.g. "Average
speed", "Elapsed time", "Moving time", "Elevation gain") unless it happens to be the
one member that lands on span 2, and always too small for "Points captured".
- 4 members -> all get span 1 -- too small for any label longer than "Speed",
"Distance", or "Max speed".
This is a real, reproducible gap between FB-08's `_reflowRow` and this ticket's
`minColSpanForLabel`, but per this ticket's own "Out of scope" section it is
deliberately left unfixed here — `_reflowRow` is FB-08's function, and changing its
even-split algorithm to respect a per-label minimum (e.g. giving longer-labeled
members first claim on any spare columns, falling back to letting a row simply not
fill exactly 4 columns when it can't be split evenly and legibly) is a follow-up
ticket's work, not this one's.
Test suite: `flutter analyze` stays at the same 4 pre-existing, unrelated info-level
issues as the pre-FB-09 baseline (none in files this ticket touches). `flutter test`
went from 426 to **434** tests, all green (8 net new: 5 in
`hud_widget_layout_test.dart` for `minColSpanForLabel`/`defaultFor`'s new invariants,
3 in `hud_edit_overlay_test.dart` for the resize-floor clamp, the longest-label
minimum-width no-overflow case, and the fixed-title/flexible-value split). Two
pre-existing `hud_layout_controller_test.dart` tests (FB-08's own reflow tests) had to
be updated, not because `_reflowRow` broke, but because they asserted on *which
specific row* certain metrics defaulted into -- an assumption that no longer holds now
that `defaultFor` packs by title width instead of a fixed 4-per-row index. Both were
rewritten to explicitly position their metrics into a shared row via
`updatePosition`/`updateSize` before exercising `_reflowRow`, so they test the reflow
behavior itself rather than an incidental default-layout coincidence.

View File

@@ -20,6 +20,17 @@ const int hudMaxRowSpan = 3;
/// True if rectangles [a] and [b] (both in grid-cell coordinates) overlap -- edges that
/// merely touch do not count as overlapping.
/// A label under 10 characters fits one column. A label under 15 characters needs
/// two. Anything longer needs three. This is a character-count proxy for "how much
/// horizontal room this title needs," not an exact pixel measurement -- `defaultFor`
/// runs with no `BuildContext` and cannot measure real text width. Adjust these
/// thresholds directly if a future label reads too cramped or too loose in practice.
int minColSpanForLabel(String label) {
if (label.length < 10) return 1;
if (label.length < 15) return 2;
return 3;
}
bool hudRectsOverlap(HudWidgetLayout a, HudWidgetLayout b) {
final aColEnd = a.col + a.colSpan;
final aRowEnd = a.row + a.rowSpan;
@@ -113,27 +124,42 @@ class HudWidgetLayout {
/// A deterministic starting grid -- a fresh install has a working, if plain, HUD
/// before the rider customises anything, and a metric toggled on for the first time
/// (with no saved position) lands somewhere sane rather than stacked on another
/// widget. Two rows of four, matching the Stitch mockup's row of cards for however
/// many metrics fit in the first row, with the rest continuing below it. Purely a
/// function of the metric's own index -- these 8 fixed slots never overlap each
/// other by construction, so no runtime state is needed here.
/// widget. FB-09: rather than a fixed "always 4 per row, colSpan 1" assumption, this
/// packs metrics left-to-right with each one's own title-driven width
/// ([minColSpanForLabel]), wrapping to a new row when the current one would
/// overflow. Purely a function of the metric's own index/label -- these slots never
/// overlap each other by construction, so no runtime state is needed here.
factory HudWidgetLayout.defaultFor(HudMetric metric) {
const columns = 4;
const rowSpan = 2;
final index = HudMetric.values.indexOf(metric);
final row = (index ~/ columns) * rowSpan;
final col = index % columns;
var col = 0;
var row = 0;
for (final m in HudMetric.values) {
final span = minColSpanForLabel(m.label);
if (col + span > hudGridColumns) {
col = 0;
row += rowSpan;
}
if (m == metric) {
return HudWidgetLayout(
metric: metric,
col: col,
row: row,
colSpan: 1,
colSpan: span,
rowSpan: rowSpan,
// The Map HUD mockup's own fixed row is Speed/Avg Speed/Dist/Time -- the first
// four enum values are ordered to match, so only those start visible.
visible: index < columns,
// The Map HUD mockup's own fixed row is Speed/Avg Speed/Dist/Time -- the
// first four enum values are ordered to match, so only those start visible.
// Their exact column positions may no longer form four perfectly even
// columns once their individual widths differ -- that is the correct,
// intended result of sizing by title, not a bug (see FB-09's Outcome
// section).
visible: HudMetric.values.indexOf(metric) < 4,
);
}
col += span;
}
// Unreachable: metric is always one of HudMetric.values.
throw StateError('Unknown metric: $metric');
}
/// Scans row-major from (0,0) for the first [colSpan]x[rowSpan] slot that doesn't
/// overlap any `visible` entry in [occupied]. Falls back to (0,0) unconditionally if

View File

@@ -146,9 +146,18 @@ class _DraggableResizableHudWidgetState extends State<DraggableResizableHudWidge
});
},
onPanEnd: (_) {
// FB-09: a rider can still make a widget larger than its title
// needs (for a bigger value reading), but never smaller than the
// title's own minimum -- clamped here rather than left to the
// generic hudMinSpan floor in HudLayoutController.
final snappedColSpan =
((layout.colSpan * _cellWidth + _resizeDelta.width) / _cellWidth)
.round();
(((layout.colSpan * _cellWidth + _resizeDelta.width) /
_cellWidth)
.round())
.clamp(
minColSpanForLabel(layout.metric.label),
hudMaxColSpan,
);
final snappedRowSpan =
((layout.rowSpan * _cellHeight + _resizeDelta.height) /
_cellHeight)

View File

@@ -328,17 +328,14 @@ class _HudMetricValue extends StatelessWidget {
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
// FB-03: FittedBox owns sizing here -- structurally impossible to overflow, so
// maxLines/overflow are dropped from both Texts. fontSize: 10/18 below (still
// scaled by V3-05/FB-02's mounted-mode [scale]) are now just the "reference" size
// FittedBox scales down from at small widget sizes; the ratio between label and
// value size is preserved automatically as it scales. Note: BoxFit.scaleDown never
// enlarges past that reference size, so a widget resized to the grid's maximum
// span still shows the same reference text, just with more empty space around it
// -- not larger text filling the space.
return FittedBox(
fit: BoxFit.scaleDown,
child: Column(
// FB-09: the title is a plain, unwrapped Text at its fixed reference size -- it
// must always fit at a readable size, never shrinking along with the value. Only
// the value is wrapped in its own FittedBox, inside a Flexible so it claims
// whatever vertical space is left after the fixed-size title rather than both
// competing for space inside one shared FittedBox as before. maxLines/overflow are
// restored on the title as a safety net in case a future label is ever added that
// minColSpanForLabel's thresholds under-estimate.
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
@@ -348,9 +345,14 @@ class _HudMetricValue extends StatelessWidget {
letterSpacing: 1,
color: colors.onSurfaceVariant,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Text(
Flexible(
child: FittedBox(
fit: BoxFit.scaleDown,
child: Text(
_value,
style: monoDigits.copyWith(
fontSize: 18 * scale,
@@ -358,8 +360,9 @@ class _HudMetricValue extends StatelessWidget {
color: _valueColor(colors),
),
),
],
),
),
],
);
}
}

View File

@@ -10,19 +10,32 @@ import 'package:rippr/src/ui/components/draggable_resizable_hud_widget.dart';
import 'package:rippr/src/ui/components/hud_edit_overlay.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// A representative HUD child -- structurally the same FittedBox(label + value)
/// A representative HUD child -- structurally the same fixed-title/flexible-value
/// shape as `record_screen.dart`'s private `_HudMetricValue`, which can't be
/// referenced directly from outside its library.
Widget _representativeHudChild() => const FittedBox(
fit: BoxFit.scaleDown,
child: Column(
/// referenced directly from outside its library. FB-09: the title is a plain,
/// unwrapped `Text` at a fixed reference size; only the value is wrapped in its own
/// `Flexible(child: FittedBox(...))`.
Widget _representativeHudChild({String label = 'SPEED', String value = '12.3 km/h'}) =>
Column(
mainAxisSize: MainAxisSize.min,
children: [
Text('SPEED', style: TextStyle(fontSize: 10, letterSpacing: 1)),
SizedBox(height: 4),
Text('12.3 km/h', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
],
Text(
label,
style: const TextStyle(fontSize: 10, letterSpacing: 1),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Flexible(
child: FittedBox(
fit: BoxFit.scaleDown,
child: Text(
value,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
),
),
],
);
/// UI-04: drag/toggle behaviour of the customizable HUD, simulated via `TestGesture`
@@ -263,4 +276,143 @@ void main() {
expect(tester.takeException(), isNull);
});
testWidgets(
'resizing a widget below its title\'s minimum colSpan via the resize handle '
'clamps to that minimum, not the generic hudMinSpan', (tester) async {
// Points captured is the longest label -- minColSpanForLabel gives it 3, well
// above the generic hudMinSpan of 1.
const metric = HudMetric.pointsCaptured;
final expectedMin = minColSpanForLabel(metric.label);
expect(expectedMin, greaterThan(hudMinSpan));
int? resizedColSpan;
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: SizedBox(
width: 400,
height: 400,
child: Stack(
children: [
DraggableResizableHudWidget(
layout: HudWidgetLayout(
metric: metric,
col: 0,
row: 0,
colSpan: expectedMin,
rowSpan: 2,
visible: true,
),
areaSize: const Size(400, 400),
editing: true,
onMoved: (_, _) {},
onResized: (colSpan, _) => resizedColSpan = colSpan,
child: _representativeHudChild(label: metric.label.toUpperCase()),
),
],
),
),
),
),
);
await tester.pumpAndSettle();
final handleFinder = find.byKey(const Key('hud-resize-handle'));
final gesture = await tester.startGesture(tester.getCenter(handleFinder));
// Drag far enough left/up to try to shrink well below every floor.
await gesture.moveBy(const Offset(-390, -390));
await tester.pump();
await gesture.up();
await tester.pumpAndSettle();
expect(resizedColSpan, expectedMin,
reason: 'the resize handle must clamp up to the title\'s own minimum, not '
'the generic hudMinSpan');
});
testWidgets(
'the longest label renders in full, without overflow, at exactly its own '
'title-driven minimum colSpan', (tester) async {
const metric = HudMetric.pointsCaptured;
final minSpan = minColSpanForLabel(metric.label);
final cellWidth = 400 / hudGridColumns;
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: SizedBox(
width: 400,
height: 400,
child: Stack(
children: [
DraggableResizableHudWidget(
layout: HudWidgetLayout(
metric: metric,
col: 0,
row: 0,
colSpan: minSpan,
rowSpan: 2,
visible: true,
),
areaSize: const Size(400, 400),
editing: false,
onMoved: (_, _) {},
onResized: (_, _) {},
child: _representativeHudChild(label: metric.label.toUpperCase()),
),
],
),
),
),
),
);
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
expect(find.text('POINTS CAPTURED'), findsOneWidget,
reason: 'the full title must be present, not an ellipsized fragment, at its '
'own minimum width ($minSpan cols * $cellWidth px each)');
});
testWidgets(
'the title\'s rendered font size never grows past its fixed reference size, '
'while the value is free to size differently', (tester) async {
// Pumped directly inside a tightly-sized SizedBox (rather than through
// DraggableResizableHudWidget's GlassPanel/Center/AnimatedScale chain, which
// hands the content loose rather than tight constraints) so the Column's
// Flexible-wrapped value is actually forced to size against the box, exercising
// the fixed-title/flexible-value split this ticket adds.
Widget host(Size size) => MaterialApp(
home: Scaffold(
body: SizedBox(
width: size.width,
height: size.height,
child: _representativeHudChild(),
),
),
);
// FittedBox scales its child at paint time via a transform, not by resizing the
// child's own layout box -- so the inner value Text's RenderBox size is always
// its unscaled natural size, regardless of how much the FittedBox actually
// shrank it visually. The FittedBox's own rendered size is what reflects the
// available space, so that -- not the Text inside it -- is what's compared here.
await tester.pumpWidget(host(const Size(60, 40)));
await tester.pumpAndSettle();
final smallTitleFontSize = tester.widget<Text>(find.text('SPEED')).style?.fontSize;
final smallValueBoxSize = tester.getSize(find.byType(FittedBox));
await tester.pumpWidget(host(const Size(600, 400)));
await tester.pumpAndSettle();
final bigTitleFontSize = tester.widget<Text>(find.text('SPEED')).style?.fontSize;
final bigValueBoxSize = tester.getSize(find.byType(FittedBox));
expect(bigTitleFontSize, smallTitleFontSize,
reason: 'the title is a fixed-size Text, not wrapped in a FittedBox, so a '
'much larger widget must not change its style\'s font size');
expect(bigValueBoxSize, isNot(smallValueBoxSize),
reason: 'the value is the flexible part and its FittedBox is free to be '
'sized differently as the widget grows');
});
}

View File

@@ -114,6 +114,22 @@ void main() {
'the row with no gap', () async {
final controller = HudLayoutController(await freshConfig());
// FB-09's title-driven defaultFor no longer puts all four default-visible
// metrics in row 0 with uniform colSpan 1 (avgSpeed and elapsedTime need 2
// columns for their longer titles). Force that uniform row-0 arrangement
// explicitly here so this test exercises _reflowRow's own algorithm in
// isolation, independent of exactly where defaultFor happens to place things.
controller.updateSize(HudMetric.elapsedTime, 1, 2);
controller.updateSize(HudMetric.avgSpeed, 1, 2);
controller.updatePosition(HudMetric.elapsedTime, 2, 0);
expect(
[HudMetric.speed, HudMetric.avgSpeed, HudMetric.elapsedTime, HudMetric.distance]
.map((m) => controller.state[m]!.row)
.toSet(),
{0},
reason: 'test setup assumes all four land in row 0',
);
controller.setVisible(HudMetric.avgSpeed, false);
final remaining = [HudMetric.speed, HudMetric.distance, HudMetric.elapsedTime]
@@ -155,7 +171,14 @@ void main() {
'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.
// FB-09's title-driven defaultFor no longer necessarily lands maxSpeed and
// movingTime in the same row (their default rows depend on every metric's
// individual title width). Move both hidden metrics into the same row
// explicitly, before enabling either, so this test is independent of exactly
// where defaultFor happens to place them.
controller.updatePosition(HudMetric.maxSpeed, 0, 6);
controller.updatePosition(HudMetric.movingTime, 1, 6);
controller.setVisible(HudMetric.maxSpeed, true);
controller.setVisible(HudMetric.movingTime, true);

View File

@@ -89,6 +89,42 @@ void main() {
expect(layout.clampedToGrid().rowSpan, layout.rowSpan);
}
});
test('every metric\'s colSpan is at least its own title-driven minimum', () {
for (final metric in HudMetric.values) {
final layout = HudWidgetLayout.defaultFor(metric);
expect(
layout.colSpan,
greaterThanOrEqualTo(minColSpanForLabel(metric.label)),
);
}
});
test('no row\'s members ever sum past hudGridColumns (the packing '
'algorithm\'s own invariant)', () {
final layouts = HudMetric.values.map(HudWidgetLayout.defaultFor).toList();
final byRow = <int, int>{};
for (final layout in layouts) {
byRow[layout.row] = (byRow[layout.row] ?? 0) + layout.colSpan;
}
for (final sum in byRow.values) {
expect(sum, lessThanOrEqualTo(hudGridColumns));
}
});
});
group('minColSpanForLabel', () {
test('a label under 10 characters fits one column', () {
expect(minColSpanForLabel(HudMetric.speed.label), 1);
});
test('a label under 15 characters needs two', () {
expect(minColSpanForLabel(HudMetric.elapsedTime.label), 2);
});
test('a label of 15 or more characters needs three', () {
expect(minColSpanForLabel(HudMetric.pointsCaptured.label), 3);
});
});
group('clampedToGrid', () {