FB-03: grid-snapped HUD widgets with auto-fit, centered text
Replace free-form fractional HUD widget positioning with a 4x8 int grid (col/row/colSpan/rowSpan), reject drag/resize moves that would overlap another visible widget, and re-place a re-enabled metric into the next free grid slot instead of stacking it on an occupier. Drag/resize now track a local pixel offset during the gesture and only snap/commit once at gesture end. HUD widget text is wrapped in FittedBox(scaleDown) so it never overflows and stays centered at any grid size.
This commit is contained in:
661
docs/feedback/FB-03-grid-snapped-hud-widgets.md
Normal file
661
docs/feedback/FB-03-grid-snapped-hud-widgets.md
Normal file
@@ -0,0 +1,661 @@
|
|||||||
|
# FB-03 — Grid-snapped HUD widgets with auto-fit, centered text
|
||||||
|
|
||||||
|
**Depends on** — · **Size** L · **Status** Done
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
Rework the customizable HUD telemetry widgets (built in UI-04) from free-form fractional
|
||||||
|
positioning into an Android-home-screen-style snapped grid — drag and resize stick to
|
||||||
|
grid cells, a placement that would overlap another widget is rejected rather than
|
||||||
|
silently stacking — and make each widget's label/value text scale to fill its own
|
||||||
|
current size instead of a fixed font that can overflow when small or look lost when
|
||||||
|
large.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
Direct user feedback (`docs/FEEDBACK.md`, "Map Page" section):
|
||||||
|
|
||||||
|
> Speed and other widgets should only appear when recording starts. When toggling off
|
||||||
|
> different statistics, the flow and arrangement of the widgets goes crazy. This should
|
||||||
|
> be "drag and drop" but they stick to a grid much like the home screen on android, and
|
||||||
|
> they can be resizable like android widgets too. Make sure the font and values are
|
||||||
|
> centered in the widgets as well, and scale to the size of the widget (no over or
|
||||||
|
> underflow).
|
||||||
|
|
||||||
|
(The "only appear when recording starts" half is FB-02, already scoped separately —
|
||||||
|
this ticket is the grid/drag/resize/text-fit half.)
|
||||||
|
|
||||||
|
### Today's model — free-form fractions, no grid at all
|
||||||
|
|
||||||
|
`lib/src/hud/hud_widget_layout.dart` (full file, 122 lines):
|
||||||
|
|
||||||
|
```dart
|
||||||
|
const double hudMinWidthFraction = 0.20;
|
||||||
|
const double hudMaxWidthFraction = 0.70;
|
||||||
|
const double hudMinHeightFraction = 0.08;
|
||||||
|
const double hudMaxHeightFraction = 0.40;
|
||||||
|
|
||||||
|
class HudWidgetLayout {
|
||||||
|
const HudWidgetLayout({
|
||||||
|
required this.metric,
|
||||||
|
required this.x,
|
||||||
|
required this.y,
|
||||||
|
required this.width,
|
||||||
|
required this.height,
|
||||||
|
required this.visible,
|
||||||
|
});
|
||||||
|
|
||||||
|
final HudMetric metric;
|
||||||
|
final double x; // top-left, fraction of the HUD area
|
||||||
|
final double y;
|
||||||
|
final double width;
|
||||||
|
final double height;
|
||||||
|
final bool visible;
|
||||||
|
|
||||||
|
HudWidgetLayout copyWith({double? x, double? y, double? width, double? height, bool? visible}) => ...;
|
||||||
|
|
||||||
|
HudWidgetLayout clamped() {
|
||||||
|
final clampedWidth = width.clamp(hudMinWidthFraction, hudMaxWidthFraction);
|
||||||
|
final clampedHeight = height.clamp(hudMinHeightFraction, hudMaxHeightFraction);
|
||||||
|
final clampedX = x.clamp(0.0, 1.0 - clampedWidth);
|
||||||
|
final clampedY = y.clamp(0.0, 1.0 - clampedHeight);
|
||||||
|
return HudWidgetLayout(metric: metric, x: clampedX, y: clampedY, width: clampedWidth, height: clampedHeight, visible: visible);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {'x': x, 'y': y, 'width': width, 'height': height, 'visible': visible};
|
||||||
|
|
||||||
|
static HudWidgetLayout fromJson(HudMetric metric, Map<String, dynamic> json) {
|
||||||
|
try {
|
||||||
|
return HudWidgetLayout(metric: metric, x: (json['x'] as num).toDouble(), y: (json['y'] as num).toDouble(),
|
||||||
|
width: (json['width'] as num).toDouble(), height: (json['height'] as num).toDouble(), visible: json['visible'] as bool).clamped();
|
||||||
|
} catch (_) {
|
||||||
|
return HudWidgetLayout.defaultFor(metric);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
factory HudWidgetLayout.defaultFor(HudMetric metric) {
|
||||||
|
const columns = 4;
|
||||||
|
const cellWidth = 0.22;
|
||||||
|
const cellHeight = 0.12;
|
||||||
|
const gap = 0.02;
|
||||||
|
final index = HudMetric.values.indexOf(metric);
|
||||||
|
final row = index ~/ columns;
|
||||||
|
final col = index % columns;
|
||||||
|
return HudWidgetLayout(
|
||||||
|
metric: metric,
|
||||||
|
x: 0.02 + col * (cellWidth + gap),
|
||||||
|
y: 0.06 + row * (cellHeight + gap),
|
||||||
|
width: cellWidth,
|
||||||
|
height: cellHeight,
|
||||||
|
visible: index < columns, // only the first 4 (Speed/Avg Speed/Dist/Time) start visible
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`HudMetric.values` order (`lib/src/hud/hud_metric.dart`, load-bearing — `defaultFor` uses
|
||||||
|
this indexing directly): `speed, avgSpeed, distance, elapsedTime, maxSpeed, movingTime,
|
||||||
|
elevationGain, pointsCaptured` (8 total; first 4 start visible).
|
||||||
|
|
||||||
|
`lib/src/hud/hud_layout_controller.dart` (full file, 52 lines) — the in-memory,
|
||||||
|
authoritative layout during an edit session, persisted to `Config` only on exit:
|
||||||
|
|
||||||
|
```dart
|
||||||
|
class HudLayoutController extends StateNotifier<Map<HudMetric, HudWidgetLayout>> {
|
||||||
|
HudLayoutController(this._config)
|
||||||
|
: super(_config?.hudLayout ?? {for (final m in HudMetric.values) m: HudWidgetLayout.defaultFor(m)});
|
||||||
|
|
||||||
|
final Config? _config;
|
||||||
|
|
||||||
|
void updatePosition(HudMetric metric, double x, double y) {
|
||||||
|
final current = state[metric];
|
||||||
|
if (current == null) return;
|
||||||
|
state = {...state, metric: current.copyWith(x: x, y: y).clamped()};
|
||||||
|
}
|
||||||
|
|
||||||
|
void updateSize(HudMetric metric, double width, double height) {
|
||||||
|
final current = state[metric];
|
||||||
|
if (current == null) return;
|
||||||
|
state = {...state, metric: current.copyWith(width: width, height: height).clamped()};
|
||||||
|
}
|
||||||
|
|
||||||
|
void setVisible(HudMetric metric, bool visible) {
|
||||||
|
final current = state[metric] ?? HudWidgetLayout.defaultFor(metric);
|
||||||
|
state = {...state, metric: current.copyWith(visible: visible)};
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> persist() async => _config?.setHudLayout(state);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why "toggling widgets goes crazy" happens today**: `Config.hudLayout`'s getter
|
||||||
|
(`lib/src/config/config.dart` ~lines 95-113) always returns an entry for *every*
|
||||||
|
`HudMetric`, falling back to `HudWidgetLayout.defaultFor` for any metric never
|
||||||
|
explicitly saved — so `setVisible`'s `state[metric] ?? defaultFor(metric)` fallback is
|
||||||
|
essentially dead code; every metric already has a stored layout the moment the
|
||||||
|
controller exists. That stored layout is whatever it was the last time that metric was
|
||||||
|
visible (or its untouched `defaultFor` position if it's never been shown/moved). Nothing
|
||||||
|
about `setVisible`, `defaultFor`, or `clamped()` is aware of what other widgets
|
||||||
|
currently occupy — `clamped()` only keeps a single widget's own rectangle inside the
|
||||||
|
0.0–1.0 area, it has no concept of a sibling. So: drag widget A to overlap where widget
|
||||||
|
B's (currently hidden) default position sits, then re-enable B in Settings — B pops up
|
||||||
|
directly on top of A. Resize A larger (up to `hudMaxWidthFraction = 0.70`) and it can
|
||||||
|
swallow B's or C's default slot the same way. This reads as "the whole layout goes
|
||||||
|
crazy" even though, strictly, no *other* widget's own stored position ever actually
|
||||||
|
changes — the illusion is caused by newly-visible widgets landing on top of
|
||||||
|
already-visible ones with zero collision awareness.
|
||||||
|
|
||||||
|
### Drag/resize gesture wiring (continuous, per-frame, into shared state)
|
||||||
|
|
||||||
|
`lib/src/ui/components/draggable_resizable_hud_widget.dart` (`_DraggableResizableHudWidgetState.build`,
|
||||||
|
~lines 54-132) computes pixel position/size from `widget.layout`'s fractions × `widget.areaSize`
|
||||||
|
every build, and the drag/resize gesture handlers call the parent's callbacks **on every
|
||||||
|
frame of movement**, not just at gesture end:
|
||||||
|
|
||||||
|
```dart
|
||||||
|
onLongPressMoveUpdate: (details) {
|
||||||
|
widget.onMoved(
|
||||||
|
layout.x + details.offsetFromOrigin.dx / widget.areaSize.width,
|
||||||
|
layout.y + details.offsetFromOrigin.dy / widget.areaSize.height,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
...
|
||||||
|
onPanUpdate: (details) { // the resize handle
|
||||||
|
widget.onResized(
|
||||||
|
layout.width + details.delta.dx / widget.areaSize.width,
|
||||||
|
layout.height + details.delta.dy / widget.areaSize.height,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
```
|
||||||
|
|
||||||
|
`onMoved`/`onResized` are wired straight to `HudLayoutController.updatePosition`/
|
||||||
|
`updateSize` in `lib/src/ui/components/hud_edit_overlay.dart` (~lines 62-69):
|
||||||
|
|
||||||
|
```dart
|
||||||
|
DraggableResizableHudWidget(
|
||||||
|
key: ValueKey(entry.key),
|
||||||
|
layout: entry.value,
|
||||||
|
areaSize: areaSize,
|
||||||
|
editing: _editing,
|
||||||
|
onMoved: (x, y) => notifier.updatePosition(entry.key, x, y),
|
||||||
|
onResized: (w, h) => notifier.updateSize(entry.key, w, h),
|
||||||
|
child: widget.metricBuilder(context, entry.key),
|
||||||
|
),
|
||||||
|
```
|
||||||
|
|
||||||
|
So every pixel of drag movement recomputes and commits a new fraction to the shared
|
||||||
|
`StateNotifier` (though `persist()` to `SharedPreferences` still only happens on exit —
|
||||||
|
that part is fine and unchanged). This continuous-commit model does not translate
|
||||||
|
directly to a grid: an integer `col`/`row` can't represent "40% of the way toward the
|
||||||
|
next cell," so this ticket changes drag/resize to track a **local, uncommitted pixel
|
||||||
|
offset** during the gesture and only calls the parent callback once, with final snapped
|
||||||
|
grid coordinates, at gesture end. See Design below.
|
||||||
|
|
||||||
|
### Fixed-size text, no auto-fit
|
||||||
|
|
||||||
|
`lib/src/ui/record/record_screen.dart`, `_HudMetricValue.build()` (~lines 321-346):
|
||||||
|
|
||||||
|
```dart
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colors = Theme.of(context).colorScheme;
|
||||||
|
return Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
metric.label.toUpperCase(),
|
||||||
|
style: TextStyle(fontSize: 10, letterSpacing: 1, color: colors.onSurfaceVariant),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
_value,
|
||||||
|
style: monoDigits.copyWith(fontSize: 18, fontWeight: FontWeight.bold, color: _valueColor(colors)),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Font sizes are hardcoded regardless of the widget's actual current size — at the
|
||||||
|
smallest allowed size this can ellipsize; at the largest allowed size the same small
|
||||||
|
text looks lost in a big glass panel. `DraggableResizableHudWidget` just centers
|
||||||
|
whatever child it's given (`GlassPanel(child: Center(child: widget.child))`,
|
||||||
|
~line 64) — no text-scaling logic exists anywhere in this chain.
|
||||||
|
|
||||||
|
### Persistence — no migration needed
|
||||||
|
|
||||||
|
`HudWidgetLayout.fromJson`'s existing `try { ... } catch (_) { return
|
||||||
|
HudWidgetLayout.defaultFor(metric); }` already handles a field-shape change for free: an
|
||||||
|
old saved JSON blob has `x`/`y`/`width`/`height` keys (doubles); this ticket's new
|
||||||
|
`fromJson` reads `col`/`row`/`colSpan`/`rowSpan` (ints) and will throw on old data
|
||||||
|
(missing key, or a cast failure), falling back to `defaultFor` automatically — no schema
|
||||||
|
version, no explicit migration code. Confirmed reasonable: this is local
|
||||||
|
`SharedPreferences`, not a shared/synced format, and there's no production data to
|
||||||
|
preserve.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
### Grid model
|
||||||
|
Fixed **4 columns × 8 rows** spanning the HUD area (matches the current 4-column
|
||||||
|
default-row assumption and gives ample vertical room for the 8-metric list). Replace
|
||||||
|
`HudWidgetLayout`'s `x`/`y`/`width`/`height` doubles with:
|
||||||
|
|
||||||
|
```dart
|
||||||
|
const int hudGridColumns = 4;
|
||||||
|
const int hudGridRows = 8;
|
||||||
|
const int hudMinSpan = 1;
|
||||||
|
const int hudMaxColSpan = 4;
|
||||||
|
const int hudMaxRowSpan = 3;
|
||||||
|
|
||||||
|
class HudWidgetLayout {
|
||||||
|
const HudWidgetLayout({
|
||||||
|
required this.metric,
|
||||||
|
required this.col,
|
||||||
|
required this.row,
|
||||||
|
required this.colSpan,
|
||||||
|
required this.rowSpan,
|
||||||
|
required this.visible,
|
||||||
|
});
|
||||||
|
|
||||||
|
final HudMetric metric;
|
||||||
|
final int col;
|
||||||
|
final int row;
|
||||||
|
final int colSpan;
|
||||||
|
final int rowSpan;
|
||||||
|
final bool visible;
|
||||||
|
...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **`clampedToGrid()`** replaces `clamped()` — a *self-contained* bounds check with no
|
||||||
|
awareness of siblings (kept as its own method because `fromJson` and
|
||||||
|
`defaultFor`/`nextFreeSlot` all still need "is this rectangle even inside the grid"
|
||||||
|
independent of collision):
|
||||||
|
```dart
|
||||||
|
HudWidgetLayout clampedToGrid() {
|
||||||
|
final clampedColSpan = colSpan.clamp(hudMinSpan, hudMaxColSpan);
|
||||||
|
final clampedRowSpan = rowSpan.clamp(hudMinSpan, hudMaxRowSpan);
|
||||||
|
final clampedCol = col.clamp(0, hudGridColumns - clampedColSpan);
|
||||||
|
final clampedRow = row.clamp(0, hudGridRows - clampedRowSpan);
|
||||||
|
return HudWidgetLayout(metric: metric, col: clampedCol, row: clampedRow,
|
||||||
|
colSpan: clampedColSpan, rowSpan: clampedRowSpan, visible: visible);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- **Collision helper**, a static/top-level function usable by both the layout file and
|
||||||
|
the controller:
|
||||||
|
```dart
|
||||||
|
bool hudRectsOverlap(HudWidgetLayout a, HudWidgetLayout b) {
|
||||||
|
final aColEnd = a.col + a.colSpan;
|
||||||
|
final aRowEnd = a.row + a.rowSpan;
|
||||||
|
final bColEnd = b.col + b.colSpan;
|
||||||
|
final bRowEnd = b.row + b.rowSpan;
|
||||||
|
return a.col < bColEnd && aColEnd > b.col && a.row < bRowEnd && aRowEnd > b.row;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- **`defaultFor`/`nextFreeSlot`**: keep `defaultFor(metric)` for the *static* initial
|
||||||
|
layout (unchanged conceptually — still purely a function of the metric's own index,
|
||||||
|
no runtime state needed, since the 8 fixed default slots below never overlap each
|
||||||
|
other by construction):
|
||||||
|
```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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
(Speed/AvgSpeed/Distance/ElapsedTime → row 0, cols 0-3, visible; MaxSpeed/MovingTime/
|
||||||
|
ElevationGain/PointsCaptured → row 2, cols 0-3, hidden — 8 distinct, non-overlapping
|
||||||
|
slots, same invariant the existing `defaultFor` test already checks.)
|
||||||
|
|
||||||
|
Add a **new** function for the actual re-enable-without-collision fix:
|
||||||
|
```dart
|
||||||
|
/// Scans row-major from (0,0) for the first colSpan×rowSpan slot that doesn't
|
||||||
|
/// overlap any `visible` entry in [occupied]. Falls back to (0,0) unconditionally
|
||||||
|
/// if the grid is fully packed -- a stacked default is better than a crash or an
|
||||||
|
/// exception the rider can't do anything about.
|
||||||
|
static HudWidgetLayout nextFreeSlot(
|
||||||
|
HudMetric metric, {
|
||||||
|
required Map<HudMetric, HudWidgetLayout> occupied,
|
||||||
|
int colSpan = 1,
|
||||||
|
int rowSpan = 2,
|
||||||
|
}) {
|
||||||
|
final candidate = (int col, int row) => HudWidgetLayout(
|
||||||
|
metric: metric, col: col, row: row, colSpan: colSpan, rowSpan: rowSpan, visible: true,
|
||||||
|
);
|
||||||
|
for (var row = 0; row <= hudGridRows - rowSpan; row++) {
|
||||||
|
for (var col = 0; col <= hudGridColumns - colSpan; col++) {
|
||||||
|
final c = candidate(col, row);
|
||||||
|
final overlapsAny = occupied.values
|
||||||
|
.where((l) => l.visible && l.metric != metric)
|
||||||
|
.any((other) => hudRectsOverlap(c, other));
|
||||||
|
if (!overlapsAny) return c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return candidate(0, 0);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Controller — collision-aware position/size updates, free-slot re-enable
|
||||||
|
`lib/src/hud/hud_layout_controller.dart`:
|
||||||
|
|
||||||
|
```dart
|
||||||
|
void updatePosition(HudMetric metric, int col, int row) {
|
||||||
|
final current = state[metric];
|
||||||
|
if (current == null) return;
|
||||||
|
final candidate = current.copyWith(col: col, row: row).clampedToGrid();
|
||||||
|
if (_overlapsAnyOther(metric, candidate)) return; // reject: no-op, widget stays put
|
||||||
|
state = {...state, metric: candidate};
|
||||||
|
}
|
||||||
|
|
||||||
|
void updateSize(HudMetric metric, int colSpan, int rowSpan) {
|
||||||
|
final current = state[metric];
|
||||||
|
if (current == null) return;
|
||||||
|
final candidate = current.copyWith(colSpan: colSpan, rowSpan: rowSpan).clampedToGrid();
|
||||||
|
if (_overlapsAnyOther(metric, candidate)) return;
|
||||||
|
state = {...state, metric: candidate};
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _overlapsAnyOther(HudMetric metric, HudWidgetLayout candidate) => state.values
|
||||||
|
.where((l) => l.visible && l.metric != metric)
|
||||||
|
.any((other) => hudRectsOverlap(candidate, other));
|
||||||
|
|
||||||
|
void setVisible(HudMetric metric, bool visible) {
|
||||||
|
var current = state[metric] ?? HudWidgetLayout.defaultFor(metric);
|
||||||
|
if (visible && _overlapsAnyOther(metric, current.copyWith(visible: true))) {
|
||||||
|
// The metric's own saved/default slot is now occupied by something else (the
|
||||||
|
// "goes crazy" bug this ticket exists to fix) -- find a genuinely free one
|
||||||
|
// instead of popping up on top of whatever's there.
|
||||||
|
current = HudWidgetLayout.nextFreeSlot(
|
||||||
|
metric, occupied: state, colSpan: current.colSpan, rowSpan: current.rowSpan,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
state = {...state, metric: current.copyWith(visible: visible)};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**"Reject and no-op" is the collision rule for drag/resize** — simplest correct
|
||||||
|
behavior, and it's the same operation as "revert to last valid" here since `state` is
|
||||||
|
never mutated until the candidate passes the check (there's nothing to revert *from*,
|
||||||
|
the old value was never overwritten). A drag/resize that would overlap another visible
|
||||||
|
widget simply has no effect for that frame/gesture; the widget stays at its last valid
|
||||||
|
position. `setVisible` gets the one exception — it actively finds a free slot rather
|
||||||
|
than rejecting, since "the metric just doesn't turn on" would be a much worse user
|
||||||
|
experience than briefly landing somewhere else on the grid.
|
||||||
|
|
||||||
|
### Drag/resize gesture — snap on gesture end, not live
|
||||||
|
`lib/src/ui/components/draggable_resizable_hud_widget.dart`: track a **local, transient
|
||||||
|
pixel offset** in `_DraggableResizableHudWidgetState` during the gesture (not committed
|
||||||
|
to the controller), paint the widget at `layout position (from grid) + local offset`
|
||||||
|
so the drag still feels smooth and continuous under the finger — then on
|
||||||
|
`onLongPressEnd`/`onPanEnd`, convert the final raw pixel position/size to the nearest
|
||||||
|
grid cell and call `widget.onMoved`/`onResized` exactly once with the snapped integer
|
||||||
|
coordinates, then reset the local offset to zero.
|
||||||
|
|
||||||
|
```dart
|
||||||
|
class _DraggableResizableHudWidgetState extends State<DraggableResizableHudWidget> {
|
||||||
|
bool _grabbed = false;
|
||||||
|
Offset _dragOffset = Offset.zero; // pixels, uncommitted, live during a move gesture
|
||||||
|
Size _resizeDelta = Size.zero; // pixels, uncommitted, live during a resize gesture
|
||||||
|
|
||||||
|
double get _cellWidth => widget.areaSize.width / hudGridColumns;
|
||||||
|
double get _cellHeight => widget.areaSize.height / hudGridRows;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final layout = widget.layout;
|
||||||
|
final left = layout.col * _cellWidth + _dragOffset.dx;
|
||||||
|
final top = layout.row * _cellHeight + _dragOffset.dy;
|
||||||
|
final width = layout.colSpan * _cellWidth + _resizeDelta.width;
|
||||||
|
final height = layout.rowSpan * _cellHeight + _resizeDelta.height;
|
||||||
|
...
|
||||||
|
onLongPressMoveUpdate: (details) => setState(() => _dragOffset = details.offsetFromOrigin),
|
||||||
|
onLongPressEnd: (_) {
|
||||||
|
final snappedCol = ((layout.col * _cellWidth + _dragOffset.dx) / _cellWidth).round();
|
||||||
|
final snappedRow = ((layout.row * _cellHeight + _dragOffset.dy) / _cellHeight).round();
|
||||||
|
widget.onMoved(snappedCol, snappedRow);
|
||||||
|
setState(() { _grabbed = false; _dragOffset = Offset.zero; });
|
||||||
|
},
|
||||||
|
onLongPressCancel: () => setState(() { _grabbed = false; _dragOffset = Offset.zero; }),
|
||||||
|
...
|
||||||
|
// resize handle:
|
||||||
|
onPanUpdate: (details) => setState(() =>
|
||||||
|
_resizeDelta = Size(_resizeDelta.width + details.delta.dx, _resizeDelta.height + details.delta.dy)),
|
||||||
|
onPanEnd: (_) {
|
||||||
|
final snappedColSpan = ((layout.colSpan * _cellWidth + _resizeDelta.width) / _cellWidth).round();
|
||||||
|
final snappedRowSpan = ((layout.rowSpan * _cellHeight + _resizeDelta.height) / _cellHeight).round();
|
||||||
|
widget.onResized(snappedColSpan, snappedRowSpan);
|
||||||
|
setState(() => _resizeDelta = Size.zero);
|
||||||
|
},
|
||||||
|
```
|
||||||
|
|
||||||
|
If the parent rejects the move/resize (collision), the widget simply rebuilds at its
|
||||||
|
unchanged `layout` — since `_dragOffset`/`_resizeDelta` are reset to zero right after
|
||||||
|
calling `onMoved`/`onResized` regardless of whether the controller accepted it, the
|
||||||
|
widget visually snaps back to wherever it actually ended up (its last accepted grid
|
||||||
|
cell) the instant the gesture ends. No separate "was it accepted?" callback needed.
|
||||||
|
|
||||||
|
`widget.onMoved`/`onResized` function signatures change to `void Function(int col, int
|
||||||
|
row)` / `void Function(int colSpan, int rowSpan)`.
|
||||||
|
|
||||||
|
`lib/src/ui/components/hud_edit_overlay.dart`: update the two callback wire-ups
|
||||||
|
(`onMoved: (col, row) => notifier.updatePosition(entry.key, col, row)`, `onResized:
|
||||||
|
(colSpan, rowSpan) => notifier.updateSize(entry.key, colSpan, rowSpan)`) — no other
|
||||||
|
change needed in this file; `areaSize` is still the raw pixel `Size` from its own
|
||||||
|
`LayoutBuilder`, cell-size division now happens inside
|
||||||
|
`DraggableResizableHudWidget` as shown above.
|
||||||
|
|
||||||
|
### Text auto-fit — `FittedBox`, not a computed font size
|
||||||
|
`lib/src/ui/record/record_screen.dart`, `_HudMetricValue.build()`: wrap the existing
|
||||||
|
label+value `Column` in `FittedBox(fit: BoxFit.scaleDown)`, drop `maxLines`/
|
||||||
|
`TextOverflow.ellipsis` on both `Text`s (structurally impossible to overflow once
|
||||||
|
`FittedBox` owns sizing), and keep the existing `fontSize: 10`/`18` values as the
|
||||||
|
"reference" size `FittedBox` scales down from — the ratio between label and value size
|
||||||
|
is preserved automatically as it scales:
|
||||||
|
|
||||||
|
```dart
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colors = Theme.of(context).colorScheme;
|
||||||
|
return FittedBox(
|
||||||
|
fit: BoxFit.scaleDown,
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
metric.label.toUpperCase(),
|
||||||
|
style: TextStyle(fontSize: 10, letterSpacing: 1, color: colors.onSurfaceVariant),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
_value,
|
||||||
|
style: monoDigits.copyWith(fontSize: 18, fontWeight: FontWeight.bold, color: _valueColor(colors)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`FittedBox` only scales *down* (`BoxFit.scaleDown` never enlarges past the reference
|
||||||
|
size) — so a widget resized to the grid's maximum span (4 cols × 3 rows) shows text at
|
||||||
|
its natural 10/18px reference size, not blown up to fill the space. That's an
|
||||||
|
intentional, reasonable simplification for this ticket (matching "no overflow" exactly
|
||||||
|
as asked; "looks proportionally larger in a bigger widget" is a nice-to-have, not named
|
||||||
|
in the feedback) — call this trade-off out plainly in the Outcome section rather than
|
||||||
|
silently under-delivering on it.
|
||||||
|
|
||||||
|
`DraggableResizableHudWidget`'s own centering (`GlassPanel(child: Center(child:
|
||||||
|
widget.child))`) already satisfies "centered" — no change needed there.
|
||||||
|
|
||||||
|
## Implementation
|
||||||
|
1. Rewrite `lib/src/hud/hud_widget_layout.dart`: grid constants, `col`/`row`/`colSpan`/
|
||||||
|
`rowSpan` fields, `clampedToGrid()`, `hudRectsOverlap()`, updated `toJson`/
|
||||||
|
`fromJson`, updated `defaultFor`, new `nextFreeSlot`.
|
||||||
|
2. Rewrite `lib/src/hud/hud_layout_controller.dart`: `updatePosition`/`updateSize` take
|
||||||
|
ints and reject-on-collision; `setVisible` uses `nextFreeSlot` when the metric's
|
||||||
|
current slot collides.
|
||||||
|
3. Rewrite `DraggableResizableHudWidget`: local pixel-offset drag state, snap-on-end,
|
||||||
|
updated callback signatures, pixel math from `areaSize / hudGridColumns` /
|
||||||
|
`hudGridRows`.
|
||||||
|
4. Update `HudEditOverlay`'s two callback closures to the new int signatures.
|
||||||
|
5. Wrap `_HudMetricValue`'s `Column` in `FittedBox(fit: BoxFit.scaleDown)`, drop
|
||||||
|
`maxLines`/`overflow` on its two `Text`s.
|
||||||
|
6. `flutter analyze`, fix any resulting type errors elsewhere that referenced the old
|
||||||
|
`x`/`y`/`width`/`height` fields (grep for `.x`/`.y`/`.width`/`.height` on
|
||||||
|
`HudWidgetLayout` instances across `lib/` and `test/` to be thorough).
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
- [ ] Dragging a HUD widget in edit mode snaps to a grid cell on release; the widget
|
||||||
|
tracks the finger smoothly during the drag itself (no per-frame jump/snap while
|
||||||
|
still moving).
|
||||||
|
- [ ] Resizing via the handle snaps to whole grid cells on release, same smooth-during-
|
||||||
|
drag behavior.
|
||||||
|
- [ ] Dragging or resizing a widget onto a cell already occupied by another *visible*
|
||||||
|
widget rejects the move — the widget returns to its last valid position/size,
|
||||||
|
no crash, no silent overlap.
|
||||||
|
- [ ] Re-enabling a hidden metric whose saved/default slot is now occupied by another
|
||||||
|
visible widget places it in the next free grid cell instead of stacking on top of
|
||||||
|
the occupier — this is the concrete fix for the "goes crazy" feedback.
|
||||||
|
- [ ] Re-enabling a hidden metric whose slot is still free keeps its exact prior
|
||||||
|
position (unchanged from today's guarantee, still worth re-verifying under the
|
||||||
|
new model).
|
||||||
|
- [ ] HUD widget text (label + value) never overflows or gets ellipsized at any allowed
|
||||||
|
grid size, and stays visually centered within its widget, at both the smallest
|
||||||
|
(`1×1`) and largest (`hudMaxColSpan × hudMaxRowSpan`) allowed sizes.
|
||||||
|
- [ ] An old-shaped saved layout (pre-this-ticket JSON with `x`/`y`/`width`/`height`
|
||||||
|
keys) loads without crashing — falls back to `defaultFor` per metric, exactly as
|
||||||
|
`fromJson`'s existing try/catch already guarantees.
|
||||||
|
- [ ] `flutter analyze` clean, `flutter test` green, test count only goes up.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
`test/hud_widget_layout_test.dart` needs a full rewrite for the new field names/types —
|
||||||
|
keep the same test *intents*, translated to grid coordinates:
|
||||||
|
- JSON round trip (encode/decode a `col`/`row`/`colSpan`/`rowSpan`/`visible` layout
|
||||||
|
exactly).
|
||||||
|
- A malformed/missing-field JSON entry falls back to `defaultFor` (unchanged intent,
|
||||||
|
new field names in the malformed input).
|
||||||
|
- `defaultFor`: every metric gets a distinct (non-overlapping, via `hudRectsOverlap`)
|
||||||
|
default rect; the first four metrics start visible, the rest hidden (unchanged
|
||||||
|
intent).
|
||||||
|
- `clampedToGrid`: a position/span pushed outside `[0, hudGridColumns)`/
|
||||||
|
`[0, hudGridRows)` or `[hudMinSpan, hudMaxColSpan/RowSpan]` is corrected back inside
|
||||||
|
(parallel structure to the old `clamped` tests: past the right/bottom edge, past the
|
||||||
|
left/top edge, below the legibility floor, above the ceiling, shrink-before-reposition,
|
||||||
|
already-valid-is-unchanged).
|
||||||
|
- New: `hudRectsOverlap` — two identical rects overlap; two rects sharing only an edge
|
||||||
|
(touching, not overlapping) do not; two disjoint rects don't; a rect fully containing
|
||||||
|
another does.
|
||||||
|
- New: `nextFreeSlot` — given a set of `occupied` visible layouts that collide with a
|
||||||
|
metric's own `defaultFor` position, returns some other rect that doesn't collide with
|
||||||
|
any of them; given an empty/all-hidden `occupied` map, returns the same rect
|
||||||
|
`defaultFor` would have (or at least a valid non-colliding one at `(0,0)`).
|
||||||
|
|
||||||
|
`test/hud_layout_controller_test.dart` needs a parallel rewrite for the new int
|
||||||
|
signatures and the new collision behavior:
|
||||||
|
- `updatePosition`/`updateSize` update only the given metric, still true.
|
||||||
|
- New: `updatePosition` to a cell already occupied by another visible metric is a
|
||||||
|
no-op (state for the target metric is unchanged).
|
||||||
|
- New: `updateSize` that would make a widget overlap a sibling is a no-op.
|
||||||
|
- `setVisible(false)` then `setVisible(true)` restores the last position **when that
|
||||||
|
position is still free** (keep this test, using coordinates guaranteed not to
|
||||||
|
collide with anything else default-visible).
|
||||||
|
- New: `setVisible(true)` when the metric's last position now collides with another
|
||||||
|
visible widget places it somewhere else instead (assert the resulting `state[metric]`
|
||||||
|
doesn't overlap anything, not a specific coordinate).
|
||||||
|
- `persist`/pre-persist-not-visible-to-Config tests carry over unchanged in spirit
|
||||||
|
(just using int fields).
|
||||||
|
|
||||||
|
Widget-level: extend or add to `test/hud_edit_overlay_test.dart` — a drag gesture ending
|
||||||
|
over an occupied cell leaves the dragged widget's rendered position unchanged from
|
||||||
|
before the gesture; a resize past the point of colliding with a sibling leaves the
|
||||||
|
widget at its pre-gesture size. Also add a widget test putting `_HudMetricValue` (or a
|
||||||
|
representative HUD child) inside a very small `DraggableResizableHudWidget` (1×1 grid
|
||||||
|
cell) and a very large one (`hudMaxColSpan`×`hudMaxRowSpan`) and asserting no
|
||||||
|
`RenderFlex overflowed` exception/error is recorded by `FlutterError.onError` during
|
||||||
|
the pump (the standard way to assert "no overflow" in a widget test — check
|
||||||
|
`test/widget_test.dart`/other existing tests in this repo for the established pattern,
|
||||||
|
if any, otherwise use `tester.takeException()` after pumping and assert it's `null`).
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
- `FittedBox(fit: BoxFit.scaleDown)` never enlarges text past its reference size, so a
|
||||||
|
widget resized to the grid's maximum span shows the same 10/18px reference text as a
|
||||||
|
1×1 widget, just with more empty space around it — not larger text filling the space.
|
||||||
|
This satisfies "no overflow" and "centered" exactly as asked; "text should grow to
|
||||||
|
fill a bigger widget" is not explicitly requested and is treated as future work, not a
|
||||||
|
silent gap — document this trade-off in the Outcome section.
|
||||||
|
- Existing saved HUD layouts (from anyone who ran a build before this ticket) reset to
|
||||||
|
defaults the first time they're loaded post-update, per the fromJson fallback. No
|
||||||
|
user-facing warning is added for this — acceptable given there's no real user base
|
||||||
|
yet; flag it anyway in Outcome for completeness.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
FB-02 (hiding the idle Speed panel — separate ticket, this one only touches the
|
||||||
|
recording-state HUD widgets themselves). Making widget text grow to actually fill a
|
||||||
|
larger grid span (see Risks). Any change to which metrics exist or their default
|
||||||
|
visibility set — `HudMetric`'s own enum and `label`s are untouched.
|
||||||
|
|
||||||
|
## Outcome
|
||||||
|
Implemented exactly per the Design section above, no deviations from the approach:
|
||||||
|
|
||||||
|
- `lib/src/hud/hud_widget_layout.dart` rewritten with the 4x8 int grid
|
||||||
|
(`hudGridColumns`/`hudGridRows`), `col`/`row`/`colSpan`/`rowSpan` fields,
|
||||||
|
`clampedToGrid()`, top-level `hudRectsOverlap()`, updated `toJson`/`fromJson` (now
|
||||||
|
reading `col`/`row`/`colSpan`/`rowSpan` — an old `x`/`y`/`width`/`height` blob throws
|
||||||
|
in the try/catch and falls back to `defaultFor`, no migration code needed, as
|
||||||
|
predicted), an unchanged-in-spirit `defaultFor`, and the new `nextFreeSlot` scan.
|
||||||
|
- `lib/src/hud/hud_layout_controller.dart` rewritten: `updatePosition`/`updateSize`
|
||||||
|
take ints, clamp, and reject-on-collision (no-op, no revert needed since state is
|
||||||
|
never mutated until the candidate passes); `setVisible` calls `nextFreeSlot` only
|
||||||
|
when the metric's own slot now collides with another visible widget.
|
||||||
|
- `lib/src/ui/components/draggable_resizable_hud_widget.dart` rewritten: local
|
||||||
|
`_dragOffset`/`_resizeDelta` pixel state tracks the live gesture smoothly; snapping
|
||||||
|
to the nearest grid cell and calling `onMoved`/`onResized` exactly once happens on
|
||||||
|
`onLongPressEnd`/`onPanEnd`. Both offsets reset to zero right after that call
|
||||||
|
regardless of whether the controller accepted the move, which is what makes a
|
||||||
|
rejected move/resize visually snap back with no separate accept/reject callback.
|
||||||
|
Callback signatures changed to `void Function(int, int)`.
|
||||||
|
- `lib/src/ui/components/hud_edit_overlay.dart`: the two callback closures updated to
|
||||||
|
the new int signatures; no other change needed.
|
||||||
|
- `lib/src/ui/record/record_screen.dart`'s `_HudMetricValue.build()`: wrapped in
|
||||||
|
`FittedBox(fit: BoxFit.scaleDown)`, `maxLines`/`overflow` dropped from both `Text`s.
|
||||||
|
|
||||||
|
**Confirmed trade-off** (the one the ticket flagged in advance): `BoxFit.scaleDown`
|
||||||
|
never enlarges past the reference size, so a widget resized to `hudMaxColSpan` x
|
||||||
|
`hudMaxRowSpan` renders the same 10px/18px text as a 1x1 widget — just with more empty
|
||||||
|
space around it, still centered via `DraggableResizableHudWidget`'s existing
|
||||||
|
`Center`. Verified directly with the new "largest allowed size" widget test (a 4x3
|
||||||
|
`DraggableResizableHudWidget` at 400x400 pixels records no overflow exception, same as
|
||||||
|
the 1x1 case) — text-scaling-with-widget-size remains explicit future work, not a
|
||||||
|
silent gap.
|
||||||
|
|
||||||
|
**Confirmed migration behavior**: an old-shaped saved layout (pre-this-ticket JSON with
|
||||||
|
`x`/`y`/`width`/`height` keys) loads without crashing and falls back to `defaultFor`
|
||||||
|
per metric — covered directly by a new `hud_widget_layout_test.dart` case and a new
|
||||||
|
`config_test.dart` case using literal old-shaped JSON. No user-facing warning was
|
||||||
|
added, per the ticket's own Risks section (no real user base yet to protect).
|
||||||
|
|
||||||
|
**Tests**: `test/hud_widget_layout_test.dart` and `test/hud_layout_controller_test.dart`
|
||||||
|
were fully rewritten for the int grid fields, per the ticket's Tests section (JSON
|
||||||
|
round trip, malformed/old-shaped-JSON fallback, `defaultFor` non-overlap and
|
||||||
|
visibility, `clampedToGrid` edge/floor/ceiling/shrink-before-reposition cases,
|
||||||
|
`hudRectsOverlap` touching/disjoint/containing/partial cases, `nextFreeSlot` colliding
|
||||||
|
and empty/hidden-occupied cases, controller collision-rejection for both
|
||||||
|
`updatePosition` and `updateSize`, and `setVisible`'s free-slot fallback). Also updated:
|
||||||
|
`test/config_test.dart` (its `HudWidgetLayout` literal and JSON fixtures used the old
|
||||||
|
field names — updated to the grid fields, plus one new case for old-shaped JSON) and
|
||||||
|
`test/hud_edit_overlay_test.dart` (its `.x`/`.y` assertions became `.col`/`.row`; added
|
||||||
|
the two ticket-requested widget tests for a collision-rejected drag and a
|
||||||
|
collision-rejected resize, plus two more building `DraggableResizableHudWidget`
|
||||||
|
directly at `1x1` and `hudMaxColSpan x hudMaxRowSpan` to assert `tester.takeException()`
|
||||||
|
is `null` — the established no-overflow pattern already used in
|
||||||
|
`settings_screen_test.dart`).
|
||||||
|
|
||||||
|
Final count: **392 tests passing** (up from the 374 baseline), `flutter analyze` clean.
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
/// UI-04: the in-memory, authoritative HUD layout during an editing session.
|
/// UI-04/FB-03: the in-memory, authoritative HUD layout during an editing session.
|
||||||
///
|
///
|
||||||
/// Seeded from `Config` once, and written back to it only on [persist] -- not on every
|
/// Seeded from `Config` once, and written back to it only on [persist] -- not on every
|
||||||
/// drag/resize frame, which would hammer `SharedPreferences` mid-drag (the ticket's own
|
/// drag/resize frame, which would hammer `SharedPreferences` mid-drag (the ticket's own
|
||||||
@@ -26,24 +26,47 @@ class HudLayoutController extends StateNotifier<Map<HudMetric, HudWidgetLayout>>
|
|||||||
|
|
||||||
final Config? _config;
|
final Config? _config;
|
||||||
|
|
||||||
void updatePosition(HudMetric metric, double x, double y) {
|
/// "Reject and no-op" is the collision rule for drag/resize -- simplest correct
|
||||||
|
/// behaviour, and it's equivalent to "revert to last valid" here since `state` is
|
||||||
|
/// never mutated until the candidate passes the check. A drag/resize that would
|
||||||
|
/// overlap another visible widget simply has no effect for that gesture; the widget
|
||||||
|
/// stays at its last valid position.
|
||||||
|
void updatePosition(HudMetric metric, int col, int row) {
|
||||||
final current = state[metric];
|
final current = state[metric];
|
||||||
if (current == null) return;
|
if (current == null) return;
|
||||||
state = {...state, metric: current.copyWith(x: x, y: y).clamped()};
|
final candidate = current.copyWith(col: col, row: row).clampedToGrid();
|
||||||
|
if (_overlapsAnyOther(metric, candidate)) return;
|
||||||
|
state = {...state, metric: candidate};
|
||||||
}
|
}
|
||||||
|
|
||||||
void updateSize(HudMetric metric, double width, double height) {
|
void updateSize(HudMetric metric, int colSpan, int rowSpan) {
|
||||||
final current = state[metric];
|
final current = state[metric];
|
||||||
if (current == null) return;
|
if (current == null) return;
|
||||||
state = {...state, metric: current.copyWith(width: width, height: height).clamped()};
|
final candidate = current.copyWith(colSpan: colSpan, rowSpan: rowSpan).clampedToGrid();
|
||||||
|
if (_overlapsAnyOther(metric, candidate)) return;
|
||||||
|
state = {...state, metric: candidate};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool _overlapsAnyOther(HudMetric metric, HudWidgetLayout candidate) => state.values
|
||||||
|
.where((l) => l.visible && l.metric != metric)
|
||||||
|
.any((other) => hudRectsOverlap(candidate, other));
|
||||||
|
|
||||||
/// A metric turned on for the first time (no meaningfully-placed prior layout) gets
|
/// A metric turned on for the first time (no meaningfully-placed prior layout) gets
|
||||||
/// [HudWidgetLayout.defaultFor] rather than whatever stale position it held from
|
/// [HudWidgetLayout.defaultFor] rather than whatever stale position it held from
|
||||||
/// before it was last turned off -- the ticket's own acceptance criterion. A metric
|
/// before it was last turned off. A metric that already has a real saved position
|
||||||
/// that already has a real saved position keeps it.
|
/// keeps it -- unless that slot is now occupied by another visible widget (the
|
||||||
|
/// "goes crazy" bug this ticket exists to fix), in which case it's placed in the
|
||||||
|
/// next free grid cell instead of popping up on top of the occupier.
|
||||||
void setVisible(HudMetric metric, bool visible) {
|
void setVisible(HudMetric metric, bool visible) {
|
||||||
final current = state[metric] ?? HudWidgetLayout.defaultFor(metric);
|
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)};
|
state = {...state, metric: current.copyWith(visible: visible)};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,94 +1,110 @@
|
|||||||
/// UI-04: one telemetry widget's position, size, and visibility on the customizable
|
/// FB-03: one telemetry widget's position, size, and visibility on the customizable
|
||||||
/// HUD -- see the ticket's Design section for why these are fractions (0.0-1.0) of the
|
/// HUD -- an Android-home-screen-style snapped grid (see the ticket's Design section
|
||||||
/// available HUD area rather than absolute pixels: a layout saved on one device or
|
/// for why: free-form fractional positioning had no concept of a sibling widget, so
|
||||||
/// orientation still makes sense on another.
|
/// toggling widgets on/off could stack one directly on top of another).
|
||||||
library;
|
library;
|
||||||
|
|
||||||
import 'hud_metric.dart';
|
import 'hud_metric.dart';
|
||||||
|
|
||||||
/// Legibility floor and a sane ceiling -- a widget must never shrink to the point its
|
/// Fixed grid spanning the HUD area -- matches the previous 4-column default-row
|
||||||
/// own number is unreadable, or grow to the point it swallows the whole HUD.
|
/// assumption and gives ample vertical room for the 8-metric list.
|
||||||
const double hudMinWidthFraction = 0.20;
|
const int hudGridColumns = 4;
|
||||||
const double hudMaxWidthFraction = 0.70;
|
const int hudGridRows = 8;
|
||||||
const double hudMinHeightFraction = 0.08;
|
|
||||||
const double hudMaxHeightFraction = 0.40;
|
/// Legibility floor and a sane ceiling, in grid cells -- a widget must never shrink to
|
||||||
|
/// the point its own number is unreadable, or grow to the point it swallows the whole
|
||||||
|
/// HUD.
|
||||||
|
const int hudMinSpan = 1;
|
||||||
|
const int hudMaxColSpan = 4;
|
||||||
|
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.
|
||||||
|
bool hudRectsOverlap(HudWidgetLayout a, HudWidgetLayout b) {
|
||||||
|
final aColEnd = a.col + a.colSpan;
|
||||||
|
final aRowEnd = a.row + a.rowSpan;
|
||||||
|
final bColEnd = b.col + b.colSpan;
|
||||||
|
final bRowEnd = b.row + b.rowSpan;
|
||||||
|
return a.col < bColEnd && aColEnd > b.col && a.row < bRowEnd && aRowEnd > b.row;
|
||||||
|
}
|
||||||
|
|
||||||
class HudWidgetLayout {
|
class HudWidgetLayout {
|
||||||
const HudWidgetLayout({
|
const HudWidgetLayout({
|
||||||
required this.metric,
|
required this.metric,
|
||||||
required this.x,
|
required this.col,
|
||||||
required this.y,
|
required this.row,
|
||||||
required this.width,
|
required this.colSpan,
|
||||||
required this.height,
|
required this.rowSpan,
|
||||||
required this.visible,
|
required this.visible,
|
||||||
});
|
});
|
||||||
|
|
||||||
final HudMetric metric;
|
final HudMetric metric;
|
||||||
|
|
||||||
/// Top-left corner, as a fraction of the HUD area's width/height.
|
/// Top-left cell, in grid coordinates (0-based).
|
||||||
final double x;
|
final int col;
|
||||||
final double y;
|
final int row;
|
||||||
final double width;
|
|
||||||
final double height;
|
/// How many cells wide/tall this widget spans.
|
||||||
|
final int colSpan;
|
||||||
|
final int rowSpan;
|
||||||
final bool visible;
|
final bool visible;
|
||||||
|
|
||||||
HudWidgetLayout copyWith({
|
HudWidgetLayout copyWith({
|
||||||
double? x,
|
int? col,
|
||||||
double? y,
|
int? row,
|
||||||
double? width,
|
int? colSpan,
|
||||||
double? height,
|
int? rowSpan,
|
||||||
bool? visible,
|
bool? visible,
|
||||||
}) => HudWidgetLayout(
|
}) => HudWidgetLayout(
|
||||||
metric: metric,
|
metric: metric,
|
||||||
x: x ?? this.x,
|
col: col ?? this.col,
|
||||||
y: y ?? this.y,
|
row: row ?? this.row,
|
||||||
width: width ?? this.width,
|
colSpan: colSpan ?? this.colSpan,
|
||||||
height: height ?? this.height,
|
rowSpan: rowSpan ?? this.rowSpan,
|
||||||
visible: visible ?? this.visible,
|
visible: visible ?? this.visible,
|
||||||
);
|
);
|
||||||
|
|
||||||
/// Corrects a drag/resize result that ended outside the allowed area back to the
|
/// A *self-contained* bounds check with no awareness of siblings -- corrects a
|
||||||
/// nearest valid position/size -- clamped to the [hudMinWidthFraction]/
|
/// drag/resize result that ended outside the grid back to the nearest valid
|
||||||
/// [hudMaxWidthFraction] etc. bounds first (size), then positioned so it can never
|
/// position/size, span first (so a resize that would push a widget off-grid shrinks
|
||||||
/// sit even partially outside the 0.0-1.0 HUD area (position), in that order: a
|
/// it back on-grid rather than silently repositioning it), then position.
|
||||||
/// resize that would push a widget off-screen should shrink it back on-screen, not
|
HudWidgetLayout clampedToGrid() {
|
||||||
/// silently reposition it out from under the rider's finger.
|
final clampedColSpan = colSpan.clamp(hudMinSpan, hudMaxColSpan);
|
||||||
HudWidgetLayout clamped() {
|
final clampedRowSpan = rowSpan.clamp(hudMinSpan, hudMaxRowSpan);
|
||||||
final clampedWidth = width.clamp(hudMinWidthFraction, hudMaxWidthFraction);
|
final clampedCol = col.clamp(0, hudGridColumns - clampedColSpan);
|
||||||
final clampedHeight = height.clamp(hudMinHeightFraction, hudMaxHeightFraction);
|
final clampedRow = row.clamp(0, hudGridRows - clampedRowSpan);
|
||||||
final clampedX = x.clamp(0.0, 1.0 - clampedWidth);
|
|
||||||
final clampedY = y.clamp(0.0, 1.0 - clampedHeight);
|
|
||||||
return HudWidgetLayout(
|
return HudWidgetLayout(
|
||||||
metric: metric,
|
metric: metric,
|
||||||
x: clampedX,
|
col: clampedCol,
|
||||||
y: clampedY,
|
row: clampedRow,
|
||||||
width: clampedWidth,
|
colSpan: clampedColSpan,
|
||||||
height: clampedHeight,
|
rowSpan: clampedRowSpan,
|
||||||
visible: visible,
|
visible: visible,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Map<String, dynamic> toJson() => {
|
Map<String, dynamic> toJson() => {
|
||||||
'x': x,
|
'col': col,
|
||||||
'y': y,
|
'row': row,
|
||||||
'width': width,
|
'colSpan': colSpan,
|
||||||
'height': height,
|
'rowSpan': rowSpan,
|
||||||
'visible': visible,
|
'visible': visible,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Falls back to [defaultFor] rather than throwing on a malformed/partial entry --
|
/// Falls back to [defaultFor] rather than throwing on a malformed/partial entry --
|
||||||
/// an old saved layout from a future app version with fields this version doesn't
|
/// an old saved layout (pre-grid, with `x`/`y`/`width`/`height` keys) or one with
|
||||||
/// recognise should degrade to a sane default, not crash Settings on launch.
|
/// fields this version doesn't recognise should degrade to a sane default, not crash
|
||||||
|
/// Settings on launch.
|
||||||
static HudWidgetLayout fromJson(HudMetric metric, Map<String, dynamic> json) {
|
static HudWidgetLayout fromJson(HudMetric metric, Map<String, dynamic> json) {
|
||||||
try {
|
try {
|
||||||
return HudWidgetLayout(
|
return HudWidgetLayout(
|
||||||
metric: metric,
|
metric: metric,
|
||||||
x: (json['x'] as num).toDouble(),
|
col: (json['col'] as num).toInt(),
|
||||||
y: (json['y'] as num).toDouble(),
|
row: (json['row'] as num).toInt(),
|
||||||
width: (json['width'] as num).toDouble(),
|
colSpan: (json['colSpan'] as num).toInt(),
|
||||||
height: (json['height'] as num).toDouble(),
|
rowSpan: (json['rowSpan'] as num).toInt(),
|
||||||
visible: json['visible'] as bool,
|
visible: json['visible'] as bool,
|
||||||
).clamped();
|
).clampedToGrid();
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
return HudWidgetLayout.defaultFor(metric);
|
return HudWidgetLayout.defaultFor(metric);
|
||||||
}
|
}
|
||||||
@@ -98,24 +114,54 @@ class HudWidgetLayout {
|
|||||||
/// before the rider customises anything, and a metric toggled on for the first time
|
/// 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
|
/// (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
|
/// 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.
|
/// 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.
|
||||||
factory HudWidgetLayout.defaultFor(HudMetric metric) {
|
factory HudWidgetLayout.defaultFor(HudMetric metric) {
|
||||||
const columns = 4;
|
const columns = 4;
|
||||||
const cellWidth = 0.22;
|
const rowSpan = 2;
|
||||||
const cellHeight = 0.12;
|
|
||||||
const gap = 0.02;
|
|
||||||
final index = HudMetric.values.indexOf(metric);
|
final index = HudMetric.values.indexOf(metric);
|
||||||
final row = index ~/ columns;
|
final row = (index ~/ columns) * rowSpan;
|
||||||
final col = index % columns;
|
final col = index % columns;
|
||||||
return HudWidgetLayout(
|
return HudWidgetLayout(
|
||||||
metric: metric,
|
metric: metric,
|
||||||
x: 0.02 + col * (cellWidth + gap),
|
col: col,
|
||||||
y: 0.06 + row * (cellHeight + gap),
|
row: row,
|
||||||
width: cellWidth,
|
colSpan: 1,
|
||||||
height: cellHeight,
|
rowSpan: rowSpan,
|
||||||
// The Map HUD mockup's own fixed row is Speed/Avg Speed/Dist/Time -- the first
|
// 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.
|
// four enum values are ordered to match, so only those start visible.
|
||||||
visible: index < columns,
|
visible: index < columns,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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
|
||||||
|
/// the grid is fully packed -- a stacked default is better than a crash or an
|
||||||
|
/// exception the rider can't do anything about.
|
||||||
|
static HudWidgetLayout nextFreeSlot(
|
||||||
|
HudMetric metric, {
|
||||||
|
required Map<HudMetric, HudWidgetLayout> occupied,
|
||||||
|
int colSpan = 1,
|
||||||
|
int rowSpan = 2,
|
||||||
|
}) {
|
||||||
|
HudWidgetLayout candidate(int col, int row) => HudWidgetLayout(
|
||||||
|
metric: metric,
|
||||||
|
col: col,
|
||||||
|
row: row,
|
||||||
|
colSpan: colSpan,
|
||||||
|
rowSpan: rowSpan,
|
||||||
|
visible: true,
|
||||||
|
);
|
||||||
|
for (var row = 0; row <= hudGridRows - rowSpan; row++) {
|
||||||
|
for (var col = 0; col <= hudGridColumns - colSpan; col++) {
|
||||||
|
final c = candidate(col, row);
|
||||||
|
final overlapsAny = occupied.values
|
||||||
|
.where((l) => l.visible && l.metric != metric)
|
||||||
|
.any((other) => hudRectsOverlap(c, other));
|
||||||
|
if (!overlapsAny) return c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return candidate(0, 0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
/// UI-04: one telemetry widget on the customizable HUD -- positioned/sized from a
|
/// UI-04/FB-03: one telemetry widget on the customizable HUD -- positioned/sized from a
|
||||||
/// [HudWidgetLayout]'s fractions against whatever pixel area it's given, draggable and
|
/// [HudWidgetLayout]'s grid cell against whatever pixel area it's given, draggable and
|
||||||
/// resizable only while [editing] is true.
|
/// resizable only while [editing] is true. Drag/resize snap to whole grid cells on
|
||||||
|
/// gesture end (see [_DraggableResizableHudWidgetState]'s local pixel-offset fields);
|
||||||
|
/// during the gesture itself the widget tracks the finger/handle continuously in
|
||||||
|
/// pixels for a smooth feel.
|
||||||
///
|
///
|
||||||
/// Deliberately does not attach any drag gesture at all when [editing] is false --
|
/// Deliberately does not attach any drag gesture at all when [editing] is false --
|
||||||
/// "outside edit mode, a normal tap never moves a widget" is guaranteed structurally
|
/// "outside edit mode, a normal tap never moves a widget" is guaranteed structurally
|
||||||
@@ -36,11 +39,13 @@ class DraggableResizableHudWidget extends StatefulWidget {
|
|||||||
final bool editing;
|
final bool editing;
|
||||||
final Widget child;
|
final Widget child;
|
||||||
|
|
||||||
/// Fractional x/y, already relative to [areaSize] -- not yet clamped; the caller
|
/// Final snapped grid coordinates -- called exactly once, at gesture end, not on
|
||||||
/// (`HudLayoutController.updatePosition`) owns clamping so there is exactly one
|
/// every frame of movement (a local, uncommitted pixel offset tracks the live drag;
|
||||||
/// place that logic lives.
|
/// see [_DraggableResizableHudWidgetState]). The caller
|
||||||
final void Function(double x, double y) onMoved;
|
/// (`HudLayoutController.updatePosition`) owns collision/bounds checking so there is
|
||||||
final void Function(double width, double height) onResized;
|
/// exactly one place that logic lives.
|
||||||
|
final void Function(int col, int row) onMoved;
|
||||||
|
final void Function(int colSpan, int rowSpan) onResized;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<DraggableResizableHudWidget> createState() =>
|
State<DraggableResizableHudWidget> createState() =>
|
||||||
@@ -50,13 +55,27 @@ class DraggableResizableHudWidget extends StatefulWidget {
|
|||||||
class _DraggableResizableHudWidgetState extends State<DraggableResizableHudWidget> {
|
class _DraggableResizableHudWidgetState extends State<DraggableResizableHudWidget> {
|
||||||
bool _grabbed = false;
|
bool _grabbed = false;
|
||||||
|
|
||||||
|
/// Pixels, uncommitted, live only during a move gesture -- painted on top of the
|
||||||
|
/// last-committed grid position so the drag tracks the finger smoothly, then reset
|
||||||
|
/// to zero the instant the gesture ends (whether or not the parent accepted the
|
||||||
|
/// resulting snapped position), which is what makes the widget visually "snap back"
|
||||||
|
/// on a rejected move with no separate accept/reject callback needed.
|
||||||
|
Offset _dragOffset = Offset.zero;
|
||||||
|
|
||||||
|
/// Pixels, uncommitted, live only during a resize gesture -- same idea as
|
||||||
|
/// [_dragOffset].
|
||||||
|
Size _resizeDelta = Size.zero;
|
||||||
|
|
||||||
|
double get _cellWidth => widget.areaSize.width / hudGridColumns;
|
||||||
|
double get _cellHeight => widget.areaSize.height / hudGridRows;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final layout = widget.layout;
|
final layout = widget.layout;
|
||||||
final left = layout.x * widget.areaSize.width;
|
final left = layout.col * _cellWidth + _dragOffset.dx;
|
||||||
final top = layout.y * widget.areaSize.height;
|
final top = layout.row * _cellHeight + _dragOffset.dy;
|
||||||
final width = layout.width * widget.areaSize.width;
|
final width = layout.colSpan * _cellWidth + _resizeDelta.width;
|
||||||
final height = layout.height * widget.areaSize.height;
|
final height = layout.rowSpan * _cellHeight + _resizeDelta.height;
|
||||||
|
|
||||||
Widget card = AnimatedScale(
|
Widget card = AnimatedScale(
|
||||||
scale: _grabbed ? 1.05 : 1.0,
|
scale: _grabbed ? 1.05 : 1.0,
|
||||||
@@ -71,13 +90,23 @@ class _DraggableResizableHudWidgetState extends State<DraggableResizableHudWidge
|
|||||||
setState(() => _grabbed = true);
|
setState(() => _grabbed = true);
|
||||||
},
|
},
|
||||||
onLongPressMoveUpdate: (details) {
|
onLongPressMoveUpdate: (details) {
|
||||||
widget.onMoved(
|
setState(() => _dragOffset = details.offsetFromOrigin);
|
||||||
layout.x + details.offsetFromOrigin.dx / widget.areaSize.width,
|
|
||||||
layout.y + details.offsetFromOrigin.dy / widget.areaSize.height,
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
onLongPressEnd: (_) => setState(() => _grabbed = false),
|
onLongPressEnd: (_) {
|
||||||
onLongPressCancel: () => setState(() => _grabbed = false),
|
final snappedCol =
|
||||||
|
((layout.col * _cellWidth + _dragOffset.dx) / _cellWidth).round();
|
||||||
|
final snappedRow =
|
||||||
|
((layout.row * _cellHeight + _dragOffset.dy) / _cellHeight).round();
|
||||||
|
widget.onMoved(snappedCol, snappedRow);
|
||||||
|
setState(() {
|
||||||
|
_grabbed = false;
|
||||||
|
_dragOffset = Offset.zero;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onLongPressCancel: () => setState(() {
|
||||||
|
_grabbed = false;
|
||||||
|
_dragOffset = Offset.zero;
|
||||||
|
}),
|
||||||
child: card,
|
child: card,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
@@ -109,10 +138,23 @@ class _DraggableResizableHudWidgetState extends State<DraggableResizableHudWidge
|
|||||||
bottom: -8,
|
bottom: -8,
|
||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
onPanUpdate: (details) {
|
onPanUpdate: (details) {
|
||||||
widget.onResized(
|
setState(() {
|
||||||
layout.width + details.delta.dx / widget.areaSize.width,
|
_resizeDelta = Size(
|
||||||
layout.height + details.delta.dy / widget.areaSize.height,
|
_resizeDelta.width + details.delta.dx,
|
||||||
);
|
_resizeDelta.height + details.delta.dy,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onPanEnd: (_) {
|
||||||
|
final snappedColSpan =
|
||||||
|
((layout.colSpan * _cellWidth + _resizeDelta.width) / _cellWidth)
|
||||||
|
.round();
|
||||||
|
final snappedRowSpan =
|
||||||
|
((layout.rowSpan * _cellHeight + _resizeDelta.height) /
|
||||||
|
_cellHeight)
|
||||||
|
.round();
|
||||||
|
widget.onResized(snappedColSpan, snappedRowSpan);
|
||||||
|
setState(() => _resizeDelta = Size.zero);
|
||||||
},
|
},
|
||||||
child: Container(
|
child: Container(
|
||||||
key: const Key('hud-resize-handle'),
|
key: const Key('hud-resize-handle'),
|
||||||
|
|||||||
@@ -64,8 +64,9 @@ class _HudEditOverlayState extends ConsumerState<HudEditOverlay> {
|
|||||||
layout: entry.value,
|
layout: entry.value,
|
||||||
areaSize: areaSize,
|
areaSize: areaSize,
|
||||||
editing: _editing,
|
editing: _editing,
|
||||||
onMoved: (x, y) => notifier.updatePosition(entry.key, x, y),
|
onMoved: (col, row) => notifier.updatePosition(entry.key, col, row),
|
||||||
onResized: (w, h) => notifier.updateSize(entry.key, w, h),
|
onResized: (colSpan, rowSpan) =>
|
||||||
|
notifier.updateSize(entry.key, colSpan, rowSpan),
|
||||||
child: widget.metricBuilder(context, entry.key),
|
child: widget.metricBuilder(context, entry.key),
|
||||||
),
|
),
|
||||||
if (_editing)
|
if (_editing)
|
||||||
|
|||||||
@@ -321,27 +321,33 @@ class _HudMetricValue extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final colors = Theme.of(context).colorScheme;
|
final colors = Theme.of(context).colorScheme;
|
||||||
return Column(
|
// FB-03: FittedBox owns sizing here -- structurally impossible to overflow, so
|
||||||
mainAxisSize: MainAxisSize.min,
|
// maxLines/overflow are dropped from both Texts. fontSize: 10/18 below are now
|
||||||
children: [
|
// just the "reference" size FittedBox scales down from at small widget sizes; the
|
||||||
Text(
|
// ratio between label and value size is preserved automatically as it scales.
|
||||||
metric.label.toUpperCase(),
|
// Note: BoxFit.scaleDown never enlarges past that reference size, so a widget
|
||||||
style: TextStyle(fontSize: 10, letterSpacing: 1, color: colors.onSurfaceVariant),
|
// resized to the grid's maximum span still shows 10/18px text, just with more
|
||||||
maxLines: 1,
|
// empty space around it -- not larger text filling the space.
|
||||||
overflow: TextOverflow.ellipsis,
|
return FittedBox(
|
||||||
),
|
fit: BoxFit.scaleDown,
|
||||||
const SizedBox(height: 4),
|
child: Column(
|
||||||
Text(
|
mainAxisSize: MainAxisSize.min,
|
||||||
_value,
|
children: [
|
||||||
style: monoDigits.copyWith(
|
Text(
|
||||||
fontSize: 18,
|
metric.label.toUpperCase(),
|
||||||
fontWeight: FontWeight.bold,
|
style: TextStyle(fontSize: 10, letterSpacing: 1, color: colors.onSurfaceVariant),
|
||||||
color: _valueColor(colors),
|
|
||||||
),
|
),
|
||||||
maxLines: 1,
|
const SizedBox(height: 4),
|
||||||
overflow: TextOverflow.ellipsis,
|
Text(
|
||||||
),
|
_value,
|
||||||
],
|
style: monoDigits.copyWith(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: _valueColor(colors),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -96,10 +96,10 @@ void main() {
|
|||||||
...config.hudLayout,
|
...config.hudLayout,
|
||||||
HudMetric.speed: const HudWidgetLayout(
|
HudMetric.speed: const HudWidgetLayout(
|
||||||
metric: HudMetric.speed,
|
metric: HudMetric.speed,
|
||||||
x: 0.33,
|
col: 2,
|
||||||
y: 0.44,
|
row: 3,
|
||||||
width: 0.25,
|
colSpan: 2,
|
||||||
height: 0.1,
|
rowSpan: 1,
|
||||||
visible: true,
|
visible: true,
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
@@ -107,12 +107,27 @@ void main() {
|
|||||||
await config.setHudLayout(edited);
|
await config.setHudLayout(edited);
|
||||||
final reloaded = config.hudLayout;
|
final reloaded = config.hudLayout;
|
||||||
|
|
||||||
expect(reloaded[HudMetric.speed]!.x, 0.33);
|
expect(reloaded[HudMetric.speed]!.col, 2);
|
||||||
expect(reloaded[HudMetric.speed]!.y, 0.44);
|
expect(reloaded[HudMetric.speed]!.row, 3);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('a metric missing from a saved layout (e.g. one added in a later app '
|
test('a metric missing from a saved layout (e.g. one added in a later app '
|
||||||
'version) falls back to its default rather than being omitted', () async {
|
'version) falls back to its default rather than being omitted', () async {
|
||||||
|
SharedPreferences.setMockInitialValues({
|
||||||
|
'hud_layout': '{"speed": {"col": 1, "row": 1, "colSpan": 1, "rowSpan": 2, '
|
||||||
|
'"visible": true}}',
|
||||||
|
});
|
||||||
|
final config = await Config.load();
|
||||||
|
final layout = config.hudLayout;
|
||||||
|
|
||||||
|
expect(layout.keys.toSet(), HudMetric.values.toSet());
|
||||||
|
final expectedDefault = HudWidgetLayout.defaultFor(HudMetric.distance);
|
||||||
|
expect(layout[HudMetric.distance]!.col, expectedDefault.col);
|
||||||
|
expect(layout[HudMetric.distance]!.visible, expectedDefault.visible);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an old-shaped (pre-grid) saved layout falls back to defaults rather than '
|
||||||
|
'crashing', () async {
|
||||||
SharedPreferences.setMockInitialValues({
|
SharedPreferences.setMockInitialValues({
|
||||||
'hud_layout': '{"speed": {"x": 0.1, "y": 0.1, "width": 0.2, "height": 0.1, '
|
'hud_layout': '{"speed": {"x": 0.1, "y": 0.1, "width": 0.2, "height": 0.1, '
|
||||||
'"visible": true}}',
|
'"visible": true}}',
|
||||||
@@ -121,9 +136,8 @@ void main() {
|
|||||||
final layout = config.hudLayout;
|
final layout = config.hudLayout;
|
||||||
|
|
||||||
expect(layout.keys.toSet(), HudMetric.values.toSet());
|
expect(layout.keys.toSet(), HudMetric.values.toSet());
|
||||||
final expectedDefault = HudWidgetLayout.defaultFor(HudMetric.distance);
|
final expectedDefault = HudWidgetLayout.defaultFor(HudMetric.speed);
|
||||||
expect(layout[HudMetric.distance]!.x, expectedDefault.x);
|
expect(layout[HudMetric.speed]!.col, expectedDefault.col);
|
||||||
expect(layout[HudMetric.distance]!.visible, expectedDefault.visible);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('corrupted stored JSON falls back to defaults rather than crashing',
|
test('corrupted stored JSON falls back to defaults rather than crashing',
|
||||||
|
|||||||
@@ -5,9 +5,26 @@ import 'package:flutter_test/flutter_test.dart';
|
|||||||
import 'package:rippr/src/app/providers.dart';
|
import 'package:rippr/src/app/providers.dart';
|
||||||
import 'package:rippr/src/config/config.dart';
|
import 'package:rippr/src/config/config.dart';
|
||||||
import 'package:rippr/src/hud/hud_metric.dart';
|
import 'package:rippr/src/hud/hud_metric.dart';
|
||||||
|
import 'package:rippr/src/hud/hud_widget_layout.dart';
|
||||||
|
import 'package:rippr/src/ui/components/draggable_resizable_hud_widget.dart';
|
||||||
import 'package:rippr/src/ui/components/hud_edit_overlay.dart';
|
import 'package:rippr/src/ui/components/hud_edit_overlay.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
/// A representative HUD child -- structurally the same FittedBox(label + 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)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
/// UI-04: drag/toggle behaviour of the customizable HUD, simulated via `TestGesture`
|
/// UI-04: drag/toggle behaviour of the customizable HUD, simulated via `TestGesture`
|
||||||
/// the same way other drag interactions in this codebase are tested (see
|
/// the same way other drag interactions in this codebase are tested (see
|
||||||
/// `route_planner_screen_test.dart`'s waypoint-drag coverage).
|
/// `route_planner_screen_test.dart`'s waypoint-drag coverage).
|
||||||
@@ -71,7 +88,9 @@ void main() {
|
|||||||
final speedFinder = find.text('Speed');
|
final speedFinder = find.text('Speed');
|
||||||
final gesture = await tester.startGesture(tester.getCenter(speedFinder));
|
final gesture = await tester.startGesture(tester.getCenter(speedFinder));
|
||||||
await tester.pump(kLongPressTimeout + kPressTimeout);
|
await tester.pump(kLongPressTimeout + kPressTimeout);
|
||||||
await gesture.moveBy(const Offset(40, 60));
|
// Large enough to guarantee at least one grid cell of movement in both axes
|
||||||
|
// regardless of the test surface's exact pixel size.
|
||||||
|
await gesture.moveBy(const Offset(300, 300));
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
await gesture.up();
|
await gesture.up();
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
@@ -80,8 +99,8 @@ void main() {
|
|||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
final after = config.hudLayout[HudMetric.speed]!;
|
final after = config.hudLayout[HudMetric.speed]!;
|
||||||
expect(after.x, isNot(before.x));
|
expect(after.col, isNot(before.col));
|
||||||
expect(after.y, isNot(before.y));
|
expect(after.row, isNot(before.row));
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('a drag while not editing does not move the widget', (tester) async {
|
testWidgets('a drag while not editing does not move the widget', (tester) async {
|
||||||
@@ -99,8 +118,8 @@ void main() {
|
|||||||
|
|
||||||
expect(find.byKey(const Key('hud-edit-done')), findsNothing);
|
expect(find.byKey(const Key('hud-edit-done')), findsNothing);
|
||||||
final after = config.hudLayout[HudMetric.speed]!;
|
final after = config.hudLayout[HudMetric.speed]!;
|
||||||
expect(after.x, before.x);
|
expect(after.col, before.col);
|
||||||
expect(after.y, before.y);
|
expect(after.row, before.row);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('only visible metrics render a widget', (tester) async {
|
testWidgets('only visible metrics render a widget', (tester) async {
|
||||||
@@ -110,4 +129,138 @@ void main() {
|
|||||||
expect(find.text('Speed'), findsOneWidget, reason: 'visible by default');
|
expect(find.text('Speed'), findsOneWidget, reason: 'visible by default');
|
||||||
expect(find.text('Max speed'), findsNothing, reason: 'hidden by default');
|
expect(find.text('Max speed'), findsNothing, reason: 'hidden by default');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('a drag ending over a cell occupied by another visible widget leaves '
|
||||||
|
'the dragged widget\'s rendered position unchanged', (tester) async {
|
||||||
|
await tester.pumpWidget(host());
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.longPress(find.byKey(const Key('hud-edit-background')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
final speedFinder = find.byKey(const ValueKey(HudMetric.speed));
|
||||||
|
final before = tester.getTopLeft(speedFinder);
|
||||||
|
|
||||||
|
// Speed (col 0, row 0) dragged exactly one cell right lands squarely on
|
||||||
|
// avgSpeed's (col 1, row 0) cell -- both share the same row-0 default rowSpan, so
|
||||||
|
// this is a genuine, not merely edge-touching, overlap.
|
||||||
|
final gesture = await tester.startGesture(tester.getCenter(find.text('Speed')));
|
||||||
|
await tester.pump(kLongPressTimeout + kPressTimeout);
|
||||||
|
await gesture.moveBy(Offset(tester.view.physicalSize.width / tester.view.devicePixelRatio / hudGridColumns, 0));
|
||||||
|
await tester.pump();
|
||||||
|
await gesture.up();
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(const Key('hud-edit-done')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(config.hudLayout[HudMetric.speed]!.col, 0,
|
||||||
|
reason: 'the rejected move must not change the persisted grid cell either');
|
||||||
|
expect(tester.getTopLeft(speedFinder), before);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('a resize past the point of colliding with a sibling leaves the widget '
|
||||||
|
'at its pre-gesture size', (tester) async {
|
||||||
|
await tester.pumpWidget(host());
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.longPress(find.byKey(const Key('hud-edit-background')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
final speedFinder = find.byKey(const ValueKey(HudMetric.speed));
|
||||||
|
final beforeSize = tester.getSize(speedFinder);
|
||||||
|
final handleFinder = find.descendant(
|
||||||
|
of: speedFinder,
|
||||||
|
matching: find.byKey(const Key('hud-resize-handle')),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Speed defaults to colSpan 1 at col 0; growing it across the full grid width
|
||||||
|
// would swallow avgSpeed/distance/elapsedTime's row-0 slots.
|
||||||
|
final cellWidth = tester.view.physicalSize.width / tester.view.devicePixelRatio / hudGridColumns;
|
||||||
|
final gesture = await tester.startGesture(tester.getCenter(handleFinder));
|
||||||
|
await gesture.moveBy(Offset(cellWidth * hudGridColumns, 0));
|
||||||
|
await tester.pump();
|
||||||
|
await gesture.up();
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(const Key('hud-edit-done')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(config.hudLayout[HudMetric.speed]!.colSpan, 1,
|
||||||
|
reason: 'the rejected resize must not change the persisted span either');
|
||||||
|
expect(tester.getSize(speedFinder), beforeSize);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('HUD widget text never overflows at the smallest (1x1) allowed size',
|
||||||
|
(tester) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: Scaffold(
|
||||||
|
body: SizedBox(
|
||||||
|
width: 400,
|
||||||
|
height: 400,
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
DraggableResizableHudWidget(
|
||||||
|
layout: const HudWidgetLayout(
|
||||||
|
metric: HudMetric.speed,
|
||||||
|
col: 0,
|
||||||
|
row: 0,
|
||||||
|
colSpan: hudMinSpan,
|
||||||
|
rowSpan: hudMinSpan,
|
||||||
|
visible: true,
|
||||||
|
),
|
||||||
|
areaSize: const Size(400, 400),
|
||||||
|
editing: false,
|
||||||
|
onMoved: (_, _) {},
|
||||||
|
onResized: (_, _) {},
|
||||||
|
child: _representativeHudChild(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'HUD widget text never overflows at the largest (max col/row span) allowed size',
|
||||||
|
(tester) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: Scaffold(
|
||||||
|
body: SizedBox(
|
||||||
|
width: 400,
|
||||||
|
height: 400,
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
DraggableResizableHudWidget(
|
||||||
|
layout: const HudWidgetLayout(
|
||||||
|
metric: HudMetric.speed,
|
||||||
|
col: 0,
|
||||||
|
row: 0,
|
||||||
|
colSpan: hudMaxColSpan,
|
||||||
|
rowSpan: hudMaxRowSpan,
|
||||||
|
visible: true,
|
||||||
|
),
|
||||||
|
areaSize: const Size(400, 400),
|
||||||
|
editing: false,
|
||||||
|
onMoved: (_, _) {},
|
||||||
|
onResized: (_, _) {},
|
||||||
|
child: _representativeHudChild(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'package:flutter_test/flutter_test.dart';
|
|||||||
import 'package:rippr/src/config/config.dart';
|
import 'package:rippr/src/config/config.dart';
|
||||||
import 'package:rippr/src/hud/hud_layout_controller.dart';
|
import 'package:rippr/src/hud/hud_layout_controller.dart';
|
||||||
import 'package:rippr/src/hud/hud_metric.dart';
|
import 'package:rippr/src/hud/hud_metric.dart';
|
||||||
|
import 'package:rippr/src/hud/hud_widget_layout.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
@@ -11,59 +12,123 @@ void main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
group('HudLayoutController', () {
|
group('HudLayoutController', () {
|
||||||
test('updatePosition updates only the given metric, clamped', () async {
|
test('updatePosition updates only the given metric, clamped to the grid', () async {
|
||||||
final controller = HudLayoutController(await freshConfig());
|
final controller = HudLayoutController(await freshConfig());
|
||||||
final before = controller.state[HudMetric.distance]!;
|
final before = controller.state[HudMetric.distance]!;
|
||||||
|
|
||||||
controller.updatePosition(HudMetric.speed, 5.0, 5.0);
|
// Row 4 is unused by any default layout, so this can't collide.
|
||||||
|
controller.updatePosition(HudMetric.speed, 0, 4);
|
||||||
|
|
||||||
expect(controller.state[HudMetric.speed]!.x, lessThanOrEqualTo(1.0));
|
expect(controller.state[HudMetric.speed]!.row, 4);
|
||||||
expect(controller.state[HudMetric.distance]!.x, before.x,
|
expect(controller.state[HudMetric.distance]!.col, before.col,
|
||||||
reason: 'moving one metric must not disturb another');
|
reason: 'moving one metric must not disturb another');
|
||||||
|
expect(controller.state[HudMetric.distance]!.row, before.row);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('updatePosition clamps a candidate that lands outside the grid', () async {
|
||||||
|
final controller = HudLayoutController(await freshConfig());
|
||||||
|
|
||||||
|
controller.updatePosition(HudMetric.speed, 99, 99);
|
||||||
|
|
||||||
|
final speed = controller.state[HudMetric.speed]!;
|
||||||
|
expect(speed.col, lessThanOrEqualTo(hudGridColumns - speed.colSpan));
|
||||||
|
expect(speed.row, lessThanOrEqualTo(hudGridRows - speed.rowSpan));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('updateSize clamps to the legibility floor/ceiling', () async {
|
test('updateSize clamps to the legibility floor/ceiling', () async {
|
||||||
final controller = HudLayoutController(await freshConfig());
|
final controller = HudLayoutController(await freshConfig());
|
||||||
|
|
||||||
controller.updateSize(HudMetric.speed, 0.0, 0.0);
|
controller.updateSize(HudMetric.speed, 0, 0);
|
||||||
expect(controller.state[HudMetric.speed]!.width, greaterThan(0.0));
|
expect(controller.state[HudMetric.speed]!.colSpan, hudMinSpan);
|
||||||
expect(controller.state[HudMetric.speed]!.height, greaterThan(0.0));
|
expect(controller.state[HudMetric.speed]!.rowSpan, hudMinSpan);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('updatePosition onto a cell occupied by another visible metric is a no-op', () async {
|
||||||
|
final controller = HudLayoutController(await freshConfig());
|
||||||
|
final before = controller.state[HudMetric.speed]!;
|
||||||
|
final avgSpeed = controller.state[HudMetric.avgSpeed]!;
|
||||||
|
|
||||||
|
// avgSpeed is visible by default -- try to drag speed directly on top of it.
|
||||||
|
controller.updatePosition(HudMetric.speed, avgSpeed.col, avgSpeed.row);
|
||||||
|
|
||||||
|
expect(controller.state[HudMetric.speed]!.col, before.col);
|
||||||
|
expect(controller.state[HudMetric.speed]!.row, before.row);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('updateSize that would overlap a sibling is a no-op', () async {
|
||||||
|
final controller = HudLayoutController(await freshConfig());
|
||||||
|
final before = controller.state[HudMetric.speed]!;
|
||||||
|
|
||||||
|
// Growing speed (default col 0, row 0) to span all 4 columns would swallow
|
||||||
|
// avgSpeed/distance/elapsedTime's row-0 slots.
|
||||||
|
controller.updateSize(HudMetric.speed, hudGridColumns, before.rowSpan);
|
||||||
|
|
||||||
|
expect(controller.state[HudMetric.speed]!.colSpan, before.colSpan);
|
||||||
|
expect(controller.state[HudMetric.speed]!.rowSpan, before.rowSpan);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('setVisible(false) then setVisible(true) restores the last saved position, '
|
test('setVisible(false) then setVisible(true) restores the last saved position, '
|
||||||
'not a fresh default', () async {
|
'when that position is still free', () async {
|
||||||
final controller = HudLayoutController(await freshConfig());
|
final controller = HudLayoutController(await freshConfig());
|
||||||
controller.updatePosition(HudMetric.avgSpeed, 0.6, 0.6);
|
// Row 4 is unused by any default layout, so this can't collide with anything.
|
||||||
final movedX = controller.state[HudMetric.avgSpeed]!.x;
|
controller.updatePosition(HudMetric.avgSpeed, 0, 4);
|
||||||
|
final moved = controller.state[HudMetric.avgSpeed]!;
|
||||||
|
|
||||||
controller.setVisible(HudMetric.avgSpeed, false);
|
controller.setVisible(HudMetric.avgSpeed, false);
|
||||||
expect(controller.state[HudMetric.avgSpeed]!.visible, isFalse);
|
expect(controller.state[HudMetric.avgSpeed]!.visible, isFalse);
|
||||||
|
|
||||||
controller.setVisible(HudMetric.avgSpeed, true);
|
controller.setVisible(HudMetric.avgSpeed, true);
|
||||||
expect(controller.state[HudMetric.avgSpeed]!.visible, isTrue);
|
expect(controller.state[HudMetric.avgSpeed]!.visible, isTrue);
|
||||||
expect(controller.state[HudMetric.avgSpeed]!.x, movedX,
|
expect(controller.state[HudMetric.avgSpeed]!.col, moved.col,
|
||||||
reason: 'a metric with a real saved position keeps it when re-enabled');
|
reason: 'a metric with a real saved position keeps it when re-enabled');
|
||||||
|
expect(controller.state[HudMetric.avgSpeed]!.row, moved.row);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('setVisible(true) when the metric\'s last position now collides with '
|
||||||
|
'another visible widget places it somewhere else instead', () async {
|
||||||
|
final controller = HudLayoutController(await freshConfig());
|
||||||
|
final maxSpeedDefault = controller.state[HudMetric.maxSpeed]!;
|
||||||
|
|
||||||
|
// maxSpeed starts hidden at its default slot; move a currently-visible metric
|
||||||
|
// onto that exact slot so it's occupied by the time maxSpeed is re-enabled.
|
||||||
|
controller.updatePosition(
|
||||||
|
HudMetric.speed,
|
||||||
|
maxSpeedDefault.col,
|
||||||
|
maxSpeedDefault.row,
|
||||||
|
);
|
||||||
|
|
||||||
|
controller.setVisible(HudMetric.maxSpeed, true);
|
||||||
|
|
||||||
|
final maxSpeed = controller.state[HudMetric.maxSpeed]!;
|
||||||
|
expect(maxSpeed.visible, isTrue);
|
||||||
|
final others = controller.state.values.where(
|
||||||
|
(l) => l.visible && l.metric != HudMetric.maxSpeed,
|
||||||
|
);
|
||||||
|
for (final other in others) {
|
||||||
|
expect(hudRectsOverlap(maxSpeed, other), isFalse,
|
||||||
|
reason: 'the re-enabled metric must not stack on any other visible widget');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
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);
|
||||||
controller.updatePosition(HudMetric.speed, 0.4, 0.4);
|
controller.updatePosition(HudMetric.speed, 0, 4);
|
||||||
|
|
||||||
await controller.persist();
|
await controller.persist();
|
||||||
|
|
||||||
final reloaded = config.hudLayout;
|
final reloaded = config.hudLayout;
|
||||||
expect(reloaded[HudMetric.speed]!.x, 0.4);
|
expect(reloaded[HudMetric.speed]!.row, 4);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('edits before persist are not visible to a fresh read of Config', () async {
|
test('edits before persist are not visible to a fresh read of Config', () async {
|
||||||
final config = await freshConfig();
|
final config = await freshConfig();
|
||||||
final controller = HudLayoutController(config);
|
final controller = HudLayoutController(config);
|
||||||
controller.updatePosition(HudMetric.speed, 0.4, 0.4);
|
controller.updatePosition(HudMetric.speed, 0, 4);
|
||||||
|
|
||||||
// No persist() call yet.
|
// No persist() call yet.
|
||||||
final reloaded = config.hudLayout;
|
final reloaded = config.hudLayout;
|
||||||
expect(reloaded[HudMetric.speed]!.x, isNot(0.4),
|
expect(reloaded[HudMetric.speed]!.row, isNot(4),
|
||||||
reason: 'a drag in progress must only touch in-memory state -- '
|
reason: 'a drag in progress must only touch in-memory state -- '
|
||||||
'persisting every frame is the ticket\'s own named risk');
|
'persisting every frame is the ticket\'s own named risk');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,53 +2,69 @@ import 'package:flutter_test/flutter_test.dart';
|
|||||||
import 'package:rippr/src/hud/hud_metric.dart';
|
import 'package:rippr/src/hud/hud_metric.dart';
|
||||||
import 'package:rippr/src/hud/hud_widget_layout.dart';
|
import 'package:rippr/src/hud/hud_widget_layout.dart';
|
||||||
|
|
||||||
/// UI-04: the JSON round-trip and clamp math are exercised directly with fixed inputs
|
/// FB-03: the JSON round-trip, grid-clamp math, overlap check, and free-slot search
|
||||||
/// -- no real gestures needed to test either, per the ticket's own Tests section.
|
/// are all exercised directly with fixed inputs -- no real gestures needed to test
|
||||||
|
/// any of these, per the ticket's own Tests section.
|
||||||
void main() {
|
void main() {
|
||||||
group('JSON round trip', () {
|
group('JSON round trip', () {
|
||||||
test('encodes and decodes exactly', () {
|
test('encodes and decodes exactly', () {
|
||||||
const original = HudWidgetLayout(
|
const original = HudWidgetLayout(
|
||||||
metric: HudMetric.speed,
|
metric: HudMetric.speed,
|
||||||
x: 0.1,
|
col: 1,
|
||||||
y: 0.2,
|
row: 2,
|
||||||
width: 0.3,
|
colSpan: 3,
|
||||||
height: 0.15,
|
rowSpan: 2,
|
||||||
visible: true,
|
visible: true,
|
||||||
);
|
);
|
||||||
|
|
||||||
final decoded = HudWidgetLayout.fromJson(HudMetric.speed, original.toJson());
|
final decoded = HudWidgetLayout.fromJson(HudMetric.speed, original.toJson());
|
||||||
|
|
||||||
expect(decoded.x, original.x);
|
expect(decoded.col, original.col);
|
||||||
expect(decoded.y, original.y);
|
expect(decoded.row, original.row);
|
||||||
expect(decoded.width, original.width);
|
expect(decoded.colSpan, original.colSpan);
|
||||||
expect(decoded.height, original.height);
|
expect(decoded.rowSpan, original.rowSpan);
|
||||||
expect(decoded.visible, original.visible);
|
expect(decoded.visible, original.visible);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('a malformed entry falls back to the default rather than crashing', () {
|
test('a malformed entry falls back to the default rather than crashing', () {
|
||||||
final decoded = HudWidgetLayout.fromJson(HudMetric.distance, {'x': 'not a number'});
|
final decoded = HudWidgetLayout.fromJson(HudMetric.distance, {'col': 'not a number'});
|
||||||
final default_ = HudWidgetLayout.defaultFor(HudMetric.distance);
|
final default_ = HudWidgetLayout.defaultFor(HudMetric.distance);
|
||||||
|
|
||||||
expect(decoded.x, default_.x);
|
expect(decoded.col, default_.col);
|
||||||
expect(decoded.width, default_.width);
|
expect(decoded.colSpan, default_.colSpan);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('a missing field falls back to the default rather than crashing', () {
|
test('a missing field falls back to the default rather than crashing', () {
|
||||||
final decoded = HudWidgetLayout.fromJson(HudMetric.maxSpeed, {'x': 0.1, 'y': 0.1});
|
final decoded = HudWidgetLayout.fromJson(HudMetric.maxSpeed, {'col': 1, 'row': 1});
|
||||||
final default_ = HudWidgetLayout.defaultFor(HudMetric.maxSpeed);
|
final default_ = HudWidgetLayout.defaultFor(HudMetric.maxSpeed);
|
||||||
|
|
||||||
expect(decoded.x, default_.x);
|
expect(decoded.col, default_.col);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an old-shaped (pre-grid) saved layout falls back to the default rather '
|
||||||
|
'than crashing', () {
|
||||||
|
final decoded = HudWidgetLayout.fromJson(
|
||||||
|
HudMetric.speed,
|
||||||
|
{'x': 0.1, 'y': 0.2, 'width': 0.3, 'height': 0.15, 'visible': true},
|
||||||
|
);
|
||||||
|
final default_ = HudWidgetLayout.defaultFor(HudMetric.speed);
|
||||||
|
|
||||||
|
expect(decoded.col, default_.col);
|
||||||
|
expect(decoded.row, default_.row);
|
||||||
|
expect(decoded.colSpan, default_.colSpan);
|
||||||
|
expect(decoded.rowSpan, default_.rowSpan);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
group('defaultFor', () {
|
group('defaultFor', () {
|
||||||
test('every metric gets a distinct default position', () {
|
test('every metric gets a distinct, non-overlapping default rect', () {
|
||||||
final positions = HudMetric.values
|
final layouts = HudMetric.values.map(HudWidgetLayout.defaultFor).toList();
|
||||||
.map(HudWidgetLayout.defaultFor)
|
for (var i = 0; i < layouts.length; i++) {
|
||||||
.map((l) => '${l.x},${l.y}')
|
for (var j = i + 1; j < layouts.length; j++) {
|
||||||
.toSet();
|
expect(hudRectsOverlap(layouts[i], layouts[j]), isFalse,
|
||||||
expect(positions.length, HudMetric.values.length,
|
reason: 'no two metrics should default to overlapping rects');
|
||||||
reason: 'no two metrics should default to overlapping positions');
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('the first four metrics (the Map HUD mockup\'s fixed row: Speed/Avg Speed/'
|
test('the first four metrics (the Map HUD mockup\'s fixed row: Speed/Avg Speed/'
|
||||||
@@ -64,65 +80,146 @@ void main() {
|
|||||||
expect(HudWidgetLayout.defaultFor(HudMetric.elevationGain).visible, isFalse);
|
expect(HudWidgetLayout.defaultFor(HudMetric.elevationGain).visible, isFalse);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('every default is already within the valid clamp bounds', () {
|
test('every default is already within the valid grid bounds', () {
|
||||||
for (final metric in HudMetric.values) {
|
for (final metric in HudMetric.values) {
|
||||||
final layout = HudWidgetLayout.defaultFor(metric);
|
final layout = HudWidgetLayout.defaultFor(metric);
|
||||||
expect(layout.clamped().x, layout.x);
|
expect(layout.clampedToGrid().col, layout.col);
|
||||||
expect(layout.clamped().y, layout.y);
|
expect(layout.clampedToGrid().row, layout.row);
|
||||||
expect(layout.clamped().width, layout.width);
|
expect(layout.clampedToGrid().colSpan, layout.colSpan);
|
||||||
expect(layout.clamped().height, layout.height);
|
expect(layout.clampedToGrid().rowSpan, layout.rowSpan);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
group('clamped', () {
|
group('clampedToGrid', () {
|
||||||
const base = HudWidgetLayout(
|
const base = HudWidgetLayout(
|
||||||
metric: HudMetric.speed,
|
metric: HudMetric.speed,
|
||||||
x: 0.5,
|
col: 1,
|
||||||
y: 0.5,
|
row: 1,
|
||||||
width: 0.3,
|
colSpan: 2,
|
||||||
height: 0.15,
|
rowSpan: 2,
|
||||||
visible: true,
|
visible: true,
|
||||||
);
|
);
|
||||||
|
|
||||||
test('a position dragged past the right/bottom edge is pulled back on-screen', () {
|
test('a position dragged past the right/bottom edge is pulled back onto the '
|
||||||
final result = base.copyWith(x: 1.5, y: 1.5).clamped();
|
'grid', () {
|
||||||
expect(result.x, 1.0 - base.width);
|
final result = base.copyWith(col: 20, row: 20).clampedToGrid();
|
||||||
expect(result.y, 1.0 - base.height);
|
expect(result.col, hudGridColumns - base.colSpan);
|
||||||
|
expect(result.row, hudGridRows - base.rowSpan);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('a position dragged past the left/top edge is pulled back on-screen', () {
|
test('a position dragged past the left/top edge is pulled back onto the grid', () {
|
||||||
final result = base.copyWith(x: -0.5, y: -0.5).clamped();
|
final result = base.copyWith(col: -5, row: -5).clampedToGrid();
|
||||||
expect(result.x, 0.0);
|
expect(result.col, 0);
|
||||||
expect(result.y, 0.0);
|
expect(result.row, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('a resize below the legibility floor is corrected up to the minimum', () {
|
test('a resize below the legibility floor is corrected up to the minimum', () {
|
||||||
final result = base.copyWith(width: 0.01, height: 0.01).clamped();
|
final result = base.copyWith(colSpan: 0, rowSpan: 0).clampedToGrid();
|
||||||
expect(result.width, hudMinWidthFraction);
|
expect(result.colSpan, hudMinSpan);
|
||||||
expect(result.height, hudMinHeightFraction);
|
expect(result.rowSpan, hudMinSpan);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('a resize above the sane ceiling is corrected down to the maximum', () {
|
test('a resize above the sane ceiling is corrected down to the maximum', () {
|
||||||
final result = base.copyWith(width: 5.0, height: 5.0).clamped();
|
final result = base.copyWith(colSpan: 99, rowSpan: 99).clampedToGrid();
|
||||||
expect(result.width, hudMaxWidthFraction);
|
expect(result.colSpan, hudMaxColSpan);
|
||||||
expect(result.height, hudMaxHeightFraction);
|
expect(result.rowSpan, hudMaxRowSpan);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('shrinking to fit happens before repositioning, so a widget resized past '
|
test('shrinking to fit happens before repositioning, so a widget resized past '
|
||||||
'the edge shrinks rather than silently relocates', () {
|
'the edge shrinks rather than silently relocates', () {
|
||||||
final result = base.copyWith(x: 0.9, width: 0.7).clamped();
|
final result = base.copyWith(col: 3, colSpan: 4).clampedToGrid();
|
||||||
expect(result.width, hudMaxWidthFraction);
|
expect(result.colSpan, hudMaxColSpan);
|
||||||
// x itself is still clamped against the (now-bounded) width so the widget
|
// col itself is still clamped against the (now-bounded) span so the widget
|
||||||
// never sits partially off-screen either.
|
// never sits even partially off the grid either.
|
||||||
expect(result.x + result.width, lessThanOrEqualTo(1.0));
|
expect(result.col + result.colSpan, lessThanOrEqualTo(hudGridColumns));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('an already-valid layout is unchanged', () {
|
test('an already-valid layout is unchanged', () {
|
||||||
expect(base.clamped().x, base.x);
|
expect(base.clampedToGrid().col, base.col);
|
||||||
expect(base.clamped().y, base.y);
|
expect(base.clampedToGrid().row, base.row);
|
||||||
expect(base.clamped().width, base.width);
|
expect(base.clampedToGrid().colSpan, base.colSpan);
|
||||||
expect(base.clamped().height, base.height);
|
expect(base.clampedToGrid().rowSpan, base.rowSpan);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('hudRectsOverlap', () {
|
||||||
|
const a = HudWidgetLayout(
|
||||||
|
metric: HudMetric.speed,
|
||||||
|
col: 1,
|
||||||
|
row: 1,
|
||||||
|
colSpan: 2,
|
||||||
|
rowSpan: 2,
|
||||||
|
visible: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
test('two identical rects overlap', () {
|
||||||
|
expect(hudRectsOverlap(a, a), isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('two rects sharing only an edge (touching, not overlapping) do not overlap', () {
|
||||||
|
final rightNeighbor = a.copyWith(col: a.col + a.colSpan);
|
||||||
|
expect(hudRectsOverlap(a, rightNeighbor), isFalse);
|
||||||
|
|
||||||
|
final belowNeighbor = a.copyWith(row: a.row + a.rowSpan);
|
||||||
|
expect(hudRectsOverlap(a, belowNeighbor), isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('two disjoint rects do not overlap', () {
|
||||||
|
final far = a.copyWith(col: a.col + a.colSpan + 3, row: a.row + a.rowSpan + 3);
|
||||||
|
expect(hudRectsOverlap(a, far), isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a rect fully containing another overlaps', () {
|
||||||
|
const big = HudWidgetLayout(
|
||||||
|
metric: HudMetric.distance,
|
||||||
|
col: 0,
|
||||||
|
row: 0,
|
||||||
|
colSpan: 4,
|
||||||
|
rowSpan: 4,
|
||||||
|
visible: true,
|
||||||
|
);
|
||||||
|
expect(hudRectsOverlap(big, a), isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('two partially-overlapping rects overlap', () {
|
||||||
|
final overlapping = a.copyWith(col: a.col + 1, row: a.row + 1);
|
||||||
|
expect(hudRectsOverlap(a, overlapping), isTrue);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('nextFreeSlot', () {
|
||||||
|
test('returns a rect that does not collide with any occupied visible layout, '
|
||||||
|
'when the metric\'s own default position is taken', () {
|
||||||
|
final occupier = HudWidgetLayout.defaultFor(HudMetric.speed);
|
||||||
|
final occupied = {HudMetric.speed: occupier};
|
||||||
|
|
||||||
|
final result = HudWidgetLayout.nextFreeSlot(
|
||||||
|
HudMetric.avgSpeed,
|
||||||
|
occupied: occupied,
|
||||||
|
colSpan: occupier.colSpan,
|
||||||
|
rowSpan: occupier.rowSpan,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(hudRectsOverlap(result, occupier), isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns the same rect defaultFor would have (or another valid one) when '
|
||||||
|
'occupied is empty', () {
|
||||||
|
final result = HudWidgetLayout.nextFreeSlot(HudMetric.speed, occupied: {});
|
||||||
|
expect(result.col, 0);
|
||||||
|
expect(result.row, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns the same rect defaultFor would have (or another valid one) when '
|
||||||
|
'occupied is all-hidden', () {
|
||||||
|
final hidden = HudWidgetLayout.defaultFor(HudMetric.speed).copyWith(visible: false);
|
||||||
|
final result = HudWidgetLayout.nextFreeSlot(
|
||||||
|
HudMetric.avgSpeed,
|
||||||
|
occupied: {HudMetric.speed: hidden},
|
||||||
|
);
|
||||||
|
expect(result.col, 0);
|
||||||
|
expect(result.row, 0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user