FB-09: size default HUD widgets by title, not a fixed uniform cell

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).
This commit is contained in:
2026-08-25 00:28:40 -05:00
parent aff59bfcf7
commit 7b452b7b81
7 changed files with 382 additions and 65 deletions

View File

@@ -10,20 +10,33 @@ 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(
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)),
],
),
);
/// 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(
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`
/// the same way other drag interactions in this codebase are tested (see
@@ -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');
});
}