Adds minColSpanForLabel as a character-count proxy for how many grid columns a metric's title needs, reworks HudWidgetLayout.defaultFor into a left-to-right row-packing layout using it, enforces the same minimum as a floor on manual resize, and splits _HudMetricValue's FittedBox so the title renders at a fixed reference size while only the value shrinks/grows to fill the remaining space. Confirms a real, documented interaction with FB-08's _reflowRow: its even column split has no awareness of a member's title-driven minimum and can push one below it. Left unfixed per this ticket's own Out-of-scope section -- _reflowRow's algorithm is FB-08's, not this ticket's, to change. Test count 426 -> 434, all green. flutter analyze unchanged (same 4 pre-existing, unrelated info-level issues).
329 lines
16 KiB
Markdown
329 lines
16 KiB
Markdown
# 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** Done
|
|
|
|
## Goal
|
|
A HUD widget's title must always fit at a fixed, readable size. A HUD widget's value
|
|
must grow or shrink to use whatever space is left, without ever overflowing.
|
|
|
|
## Context
|
|
Direct user feedback (`docs/FEEDBACK.md`):
|
|
|
|
> Also the default sizing is fucked. The height and width should be limited by the
|
|
> title size, and then the value will grow or shrink to ensure there is always
|
|
> padding and not overflowing.
|
|
|
|
### Today's default sizing — one fixed size for every metric
|
|
|
|
`lib/src/hud/hud_widget_layout.dart`, `defaultFor` (full factory):
|
|
|
|
```dart
|
|
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;
|
|
return HudWidgetLayout(
|
|
metric: metric,
|
|
col: col,
|
|
row: row,
|
|
colSpan: 1,
|
|
rowSpan: rowSpan,
|
|
visible: index < columns,
|
|
);
|
|
}
|
|
```
|
|
|
|
Every metric gets `colSpan: 1`, no matter how long its label is. `HudMetric.label`
|
|
(`lib/src/hud/hud_metric.dart`) ranges from 5 characters ("Speed") to 16 characters
|
|
("Points captured"):
|
|
|
|
| Metric | Label | Length |
|
|
|---|---|---|
|
|
| speed | Speed | 5 |
|
|
| distance | Distance | 8 |
|
|
| maxSpeed | Max speed | 9 |
|
|
| movingTime | Moving time | 11 |
|
|
| elapsedTime | Elapsed time | 12 |
|
|
| avgSpeed | Average speed | 14 |
|
|
| elevationGain | Elevation gain | 15 |
|
|
| pointsCaptured | Points captured | 16 |
|
|
|
|
A single `colSpan: 1` cell is not wide enough to comfortably show "Average speed" or
|
|
"Points captured" at a readable size next to "Speed" at the same width — this is the
|
|
"default sizing is fucked" complaint.
|
|
|
|
### Today's text sizing — one `FittedBox` shrinks both lines together
|
|
|
|
`lib/src/ui/record/record_screen.dart`, `_HudMetricValue.build()` (~lines 328-360):
|
|
|
|
```dart
|
|
return FittedBox(
|
|
fit: BoxFit.scaleDown,
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Text(
|
|
metric.label.toUpperCase(),
|
|
style: TextStyle(fontSize: 10 * scale, letterSpacing: 1, color: colors.onSurfaceVariant),
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
_value,
|
|
style: monoDigits.copyWith(fontSize: 18 * scale, fontWeight: FontWeight.bold, color: _valueColor(colors)),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
```
|
|
|
|
One `FittedBox` wraps both the title and the value together. `BoxFit.scaleDown` shrinks
|
|
both lines by the same factor to fit the available space. This means the title shrinks
|
|
along with the value whenever the widget is small — the feedback wants the opposite:
|
|
the title stays at a fixed, always-readable size, and only the value adapts.
|
|
|
|
### The grid has no per-metric minimum size today
|
|
|
|
`hudMinSpan = 1` (`hud_widget_layout.dart`) is the same floor for every metric,
|
|
regardless of label length. Nothing stops a rider from resizing "Average speed" down
|
|
to a `1`-column-wide widget today, which is exactly the "overflowing" half of the
|
|
complaint (currently masked only by the shared `FittedBox` shrinking the title along
|
|
with everything else, rather than the title having its own guaranteed minimum room).
|
|
|
|
## Design
|
|
`defaultFor` is a static factory with no access to real screen pixel dimensions (no
|
|
`BuildContext`, no `areaSize`) — an exact per-pixel text measurement is not available
|
|
at the point this factory runs. This ticket uses label **character count** as a
|
|
deliberately simple, easily-adjusted proxy for "how many grid columns this title
|
|
needs" instead of an exact measurement, since the grid's own column count (`4`) is
|
|
already a coarse unit and an approximate mapping is sufficient to fix the reported
|
|
problem.
|
|
|
|
- Add a helper to `hud_widget_layout.dart`:
|
|
```dart
|
|
/// 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;
|
|
}
|
|
```
|
|
Applying this to the table above: Speed (1), Distance (1), Max speed (1), Moving
|
|
time (2), Elapsed time (2), Average speed (2), Elevation gain (3), Points captured
|
|
(3).
|
|
- **Rework `defaultFor` into a left-to-right row-packing layout.** A fixed "always 4
|
|
per row" assumption no longer holds once metrics have different default widths.
|
|
Replace the index-based `row`/`col` math with a running cursor that wraps to a new
|
|
row when the current one would overflow:
|
|
```dart
|
|
factory HudWidgetLayout.defaultFor(HudMetric metric) {
|
|
const rowSpan = 2;
|
|
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: span,
|
|
rowSpan: rowSpan,
|
|
visible: HudMetric.values.indexOf(metric) < 4,
|
|
);
|
|
}
|
|
col += span;
|
|
}
|
|
// Unreachable: metric is always one of HudMetric.values.
|
|
throw StateError('Unknown metric: $metric');
|
|
}
|
|
```
|
|
The `visible: index < 4` rule is unchanged — the first four enum values (Speed, Avg
|
|
Speed, Distance, Elapsed Time) still start visible, matching the Map HUD mockup's own
|
|
fixed row. Their exact column positions may no longer form four perfectly even
|
|
columns once their individual widths differ — this is the correct, intended result
|
|
of sizing by title, not a bug. Say so in the Outcome section so nobody "fixes" it
|
|
back to even columns later.
|
|
- **Enforce the same minimum as a resize floor.** In
|
|
`lib/src/ui/components/draggable_resizable_hud_widget.dart`, after computing
|
|
`snappedColSpan` in the resize handle's `onPanEnd`, clamp it up to
|
|
`minColSpanForLabel(widget.layout.metric.label)` before calling `widget.onResized`:
|
|
```dart
|
|
final snappedColSpan = (((layout.colSpan * _cellWidth + _resizeDelta.width) / _cellWidth)
|
|
.round())
|
|
.clamp(minColSpanForLabel(layout.metric.label), hudMaxColSpan);
|
|
```
|
|
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.
|
|
- **Split the `FittedBox`: title fixed, value flexible.** In `_HudMetricValue.build()`,
|
|
keep the title as a plain, unwrapped `Text` at its fixed reference size (with
|
|
`maxLines: 1`/`TextOverflow.ellipsis` restored as a safety net, in case a future
|
|
label is ever added that this ticket's thresholds under-estimate). Wrap only the
|
|
value in its own `FittedBox`:
|
|
```dart
|
|
return Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Text(
|
|
metric.label.toUpperCase(),
|
|
style: TextStyle(fontSize: 10 * scale, letterSpacing: 1, color: colors.onSurfaceVariant),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
const SizedBox(height: 4),
|
|
Flexible(
|
|
child: FittedBox(
|
|
fit: BoxFit.scaleDown,
|
|
child: Text(
|
|
_value,
|
|
style: monoDigits.copyWith(fontSize: 18 * scale, fontWeight: FontWeight.bold, color: _valueColor(colors)),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
```
|
|
`Flexible` around the value's `FittedBox` lets it claim whatever vertical space is
|
|
left after the fixed-size title, rather than both competing for space inside one
|
|
shared `Column`/`FittedBox` as today.
|
|
|
|
## Implementation
|
|
1. Add `minColSpanForLabel` to `hud_widget_layout.dart`.
|
|
2. Rework `HudWidgetLayout.defaultFor` into the row-packing algorithm above.
|
|
3. Add the `minColSpanForLabel` floor to the resize clamp in
|
|
`draggable_resizable_hud_widget.dart`.
|
|
4. Change `_HudMetricValue.build()` to a fixed-size title `Text` plus a
|
|
`Flexible(child: FittedBox(...))`-wrapped value `Text`.
|
|
5. Run `flutter analyze` and `flutter test`.
|
|
|
|
## Acceptance criteria
|
|
- [ ] Every metric's default widget is wide enough to show its full title without
|
|
truncation at the fixed title font size.
|
|
- [ ] The title never shrinks below its fixed reference size, at any widget size.
|
|
- [ ] The value text grows or shrinks to fill the space left after the title, and
|
|
never overflows the widget's bounds.
|
|
- [ ] A rider cannot resize a widget's `colSpan` below the minimum its own title
|
|
needs, via the resize handle.
|
|
- [ ] `flutter analyze` clean, `flutter test` green, test count only goes up.
|
|
|
|
## Tests
|
|
In `test/hud_widget_layout_test.dart`:
|
|
- `minColSpanForLabel` returns `1` for a label under 10 characters, `2` for one under
|
|
15, `3` for 15 or more — test with `HudMetric.speed.label`,
|
|
`HudMetric.elapsedTime.label`, and `HudMetric.pointsCaptured.label` directly, not
|
|
synthetic strings, so the test breaks if a real label's length crosses a threshold.
|
|
- `defaultFor` gives every metric a `colSpan` at least equal to
|
|
`minColSpanForLabel(metric.label)`.
|
|
- `defaultFor` never produces a row whose members' `colSpan` sum exceeds
|
|
`hudGridColumns` (the packing algorithm's own invariant).
|
|
- `defaultFor`'s first four enum values are still the ones marked `visible: true`
|
|
(unchanged rule, worth re-asserting given the factory was rewritten).
|
|
|
|
Widget tests (extend `test/hud_edit_overlay_test.dart` or add to `test/widget_test.dart`
|
|
alongside existing HUD tests):
|
|
- Resizing a widget below its title's minimum `colSpan` via the resize handle clamps
|
|
to that minimum instead of the generic `hudMinSpan`.
|
|
- Pump `_HudMetricValue` for `HudMetric.pointsCaptured` (the longest label) inside a
|
|
widget sized to exactly `minColSpanForLabel`'s column count — assert
|
|
`tester.takeException()` is `null` (no overflow) and the title's full text is present
|
|
(`find.text('POINTS CAPTURED')`, not an ellipsized fragment).
|
|
- Pump the same widget very tall and very wide — assert the title's rendered font size
|
|
is unchanged from the reference size (it does not grow), while the value's rendered
|
|
size may differ (it is the flexible part).
|
|
|
|
## Risks
|
|
- The default HUD row of four no longer looks like four perfectly even columns once
|
|
title-driven widths differ (e.g. "Average speed" is now wider than "Speed"). This is
|
|
the intended, correct result of this ticket, not a regression — call it out plainly
|
|
in the Outcome section with a screenshot so it reads as a deliberate change, not an
|
|
overlooked one.
|
|
- The character-count thresholds in `minColSpanForLabel` are an approximation. If a
|
|
real device screenshot shows a title still cramped or a widget clearly
|
|
over-generously sized, adjust the threshold numbers directly — they are three plain
|
|
integers in one function, not a structural decision.
|
|
|
|
## Out of scope
|
|
FB-08's row-reflow-on-hide behavior — implement this ticket assuming FB-08 has already
|
|
merged, and re-run FB-08's own tests to confirm the two features compose correctly
|
|
(a reflowed row must still respect each remaining member's own title-driven minimum
|
|
`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.
|