UI-04: customizable, persisted HUD telemetry widget layout
Adds the full drag/resize/visibility infrastructure for a rider-owned HUD layout: HudMetric (the stable 8-metric set), HudWidgetLayout (fractional x/y/width/height + visible, with clamping to a legibility floor/ceiling and JSON round-trip that degrades to sane defaults rather than crashing), Config.hudLayout persistence, and a HudLayoutController that stays in-memory-authoritative during an edit session and only writes through on persist() -- never per drag frame. DraggableResizableHudWidget and HudEditOverlay assemble the interaction: edit mode is entered by a long-press on empty HUD space (not a specific widget) and exited via Done or a tap on empty space; only while editing does a widget attach any drag/resize gesture at all, so a normal tap can never move one mid-ride by construction, not by an internal flag. Fixes a real gesture-arena bug found during testing: outside edit mode, a long-press landing on a widget was free to bubble to the overlay's background long-press handler and wrongly enter edit mode. An inner no-op GestureDetector of the same gesture type now absorbs it. Adds a "Live HUD stats" Settings section, one switch per metric, that persists immediately (unlike drag frames). Placed at the end of the Settings list rather than in the middle -- inserting mid-list pushed every later section below several existing tests' viewport assumptions. Verified end-to-end on a real emulator including a full process restart: resized a widget, force-stopped the app, relaunched, and the resize held. No consuming screen exists yet (UI-05's job) -- verified via a throwaway preview entry point, deleted after use. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Xki7YAcc2TiN2PRZJ2tXr
This commit is contained in:
51
lib/src/hud/hud_layout_controller.dart
Normal file
51
lib/src/hud/hud_layout_controller.dart
Normal file
@@ -0,0 +1,51 @@
|
||||
/// UI-04: 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
|
||||
/// drag/resize frame, which would hammer `SharedPreferences` mid-drag (the ticket's own
|
||||
/// named risk). A drag in progress only ever touches this in-memory state; [persist] is
|
||||
/// what a `HudEditOverlay` calls when the rider exits edit mode.
|
||||
library;
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../config/config.dart';
|
||||
import 'hud_metric.dart';
|
||||
import 'hud_widget_layout.dart';
|
||||
|
||||
class HudLayoutController extends StateNotifier<Map<HudMetric, HudWidgetLayout>> {
|
||||
// `Config?`, not `Config` -- at app boot `configProvider` is briefly null (loaded
|
||||
// asynchronously post-first-frame, same as every other Config-seeded provider in
|
||||
// this app). Defaults stand in until it resolves; [persist] silently no-ops rather
|
||||
// than blocking, since editing the HUD before Config has loaded isn't a real path a
|
||||
// rider can reach in practice.
|
||||
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()};
|
||||
}
|
||||
|
||||
/// A metric turned on for the first time (no meaningfully-placed prior layout) gets
|
||||
/// [HudWidgetLayout.defaultFor] rather than whatever stale position it held from
|
||||
/// before it was last turned off -- the ticket's own acceptance criterion. A metric
|
||||
/// that already has a real saved position keeps it.
|
||||
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);
|
||||
}
|
||||
35
lib/src/hud/hud_metric.dart
Normal file
35
lib/src/hud/hud_metric.dart
Normal file
@@ -0,0 +1,35 @@
|
||||
/// UI-04: the stable set of metrics a rider can place on the customizable HUD.
|
||||
///
|
||||
/// The same set `RecordUiState`/`Trip` already expose (see `record_screen.dart`'s
|
||||
/// stats card) -- not a new data source, just a stable id for each one so a layout can
|
||||
/// be persisted and re-applied across app versions without breaking if display order
|
||||
/// changes.
|
||||
library;
|
||||
|
||||
enum HudMetric {
|
||||
// The first four are, in order, the Stitch mockup's fixed row (Speed/Distance/
|
||||
// Elapsed/Max speed) -- `HudWidgetLayout.defaultFor` uses this ordering directly to
|
||||
// decide both default grid position and which metrics start visible, so this order
|
||||
// is load-bearing, not cosmetic.
|
||||
speed,
|
||||
distance,
|
||||
elapsedTime,
|
||||
maxSpeed,
|
||||
movingTime,
|
||||
avgSpeed,
|
||||
elevationGain,
|
||||
pointsCaptured;
|
||||
|
||||
/// Settings' "Live HUD stats" list label. Enum name, not this, is what's persisted --
|
||||
/// this can be reworded freely without touching a saved layout.
|
||||
String get label => switch (this) {
|
||||
HudMetric.speed => 'Speed',
|
||||
HudMetric.distance => 'Distance',
|
||||
HudMetric.elapsedTime => 'Elapsed time',
|
||||
HudMetric.movingTime => 'Moving time',
|
||||
HudMetric.maxSpeed => 'Max speed',
|
||||
HudMetric.avgSpeed => 'Average speed',
|
||||
HudMetric.elevationGain => 'Elevation gain',
|
||||
HudMetric.pointsCaptured => 'Points captured',
|
||||
};
|
||||
}
|
||||
121
lib/src/hud/hud_widget_layout.dart
Normal file
121
lib/src/hud/hud_widget_layout.dart
Normal file
@@ -0,0 +1,121 @@
|
||||
/// UI-04: 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
|
||||
/// available HUD area rather than absolute pixels: a layout saved on one device or
|
||||
/// orientation still makes sense on another.
|
||||
library;
|
||||
|
||||
import 'hud_metric.dart';
|
||||
|
||||
/// Legibility floor and a sane ceiling -- a widget must never shrink to the point its
|
||||
/// own number is unreadable, or grow to the point it swallows the whole HUD.
|
||||
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;
|
||||
|
||||
/// Top-left corner, as a fraction of the HUD area's width/height.
|
||||
final double x;
|
||||
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(
|
||||
metric: metric,
|
||||
x: x ?? this.x,
|
||||
y: y ?? this.y,
|
||||
width: width ?? this.width,
|
||||
height: height ?? this.height,
|
||||
visible: visible ?? this.visible,
|
||||
);
|
||||
|
||||
/// Corrects a drag/resize result that ended outside the allowed area back to the
|
||||
/// nearest valid position/size -- clamped to the [hudMinWidthFraction]/
|
||||
/// [hudMaxWidthFraction] etc. bounds first (size), then positioned so it can never
|
||||
/// sit even partially outside the 0.0-1.0 HUD area (position), in that order: a
|
||||
/// resize that would push a widget off-screen should shrink it back on-screen, not
|
||||
/// silently reposition it out from under the rider's finger.
|
||||
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,
|
||||
};
|
||||
|
||||
/// 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
|
||||
/// recognise should degrade to a sane default, not crash Settings on launch.
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// A deterministic starting grid -- a fresh install has a working, if plain, HUD
|
||||
/// before the rider customises anything, and a metric toggled on for the first time
|
||||
/// (with no saved position) lands somewhere sane rather than stacked on another
|
||||
/// widget. Two rows of four, matching the Stitch mockup's row of cards for however
|
||||
/// many metrics fit in the first row, with the rest continuing below it.
|
||||
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,
|
||||
// The mockup's own fixed row is Speed/Distance/Elapsed/Max speed -- the first
|
||||
// four enum values are ordered to match, so only those start visible.
|
||||
visible: index < columns,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user