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:
2026-08-23 20:45:02 -05:00
parent 24f4e17e81
commit bcface3529
15 changed files with 973 additions and 2 deletions

View File

@@ -19,6 +19,9 @@ import '../crash/crash_reporter.dart';
import '../data/database.dart';
import '../data/route_plan_repository.dart';
import '../data/trip_repository.dart';
import '../hud/hud_layout_controller.dart';
import '../hud/hud_metric.dart';
import '../hud/hud_widget_layout.dart';
import '../domain/models.dart';
import '../notification/ride_notification_controller.dart';
import '../notification/ride_notification_coordinator.dart';
@@ -131,6 +134,14 @@ final mountedModeProvider = StateProvider<bool>(
(ref) => ref.watch(configProvider)?.mountedMode ?? false,
);
/// UI-04: the customizable HUD's live layout. A `StateNotifierProvider`, not a plain
/// `StateProvider` like the flags above -- editing needs methods (`updatePosition`/
/// `updateSize`/`setVisible`/`persist`), not just a settable value.
final hudLayoutControllerProvider =
StateNotifierProvider<HudLayoutController, Map<HudMetric, HudWidgetLayout>>(
(ref) => HudLayoutController(ref.watch(configProvider)),
);
/// V3-12: same shape again. Note that flipping this at runtime does not retroactively
/// start or stop a Sentry client already initialised at app launch -- see
/// `maybeInitCrashReporting`'s doc comment on why that gate is checked once, in

View File

@@ -4,12 +4,15 @@
/// recording must work with no server at all, and uploading is opt-in.
library;
import 'dart:convert';
import 'dart:io';
import 'dart:math';
import 'package:shared_preferences/shared_preferences.dart';
import '../domain/models.dart' show UnitSystem;
import '../hud/hud_metric.dart';
import '../hud/hud_widget_layout.dart';
const _keyEndpoint = 'upload_endpoint';
const _keyDeviceId = 'device_id';
@@ -17,6 +20,7 @@ const _keyMapEnabled = 'map_enabled';
const _keyUnitSystem = 'unit_system';
const _keyMountedMode = 'mounted_mode';
const _keyCrashReportingEnabled = 'crash_reporting_enabled';
const _keyHudLayout = 'hud_layout';
/// Countries that did not adopt metric for everyday distances. Not exhaustive — a
/// best-effort default, not a claim of authority. Anyone can override it in Settings.
@@ -88,6 +92,33 @@ class Config {
Future<void> setCrashReportingEnabled(bool enabled) =>
_prefs.setBool(_keyCrashReportingEnabled, enabled);
/// UI-04: every metric's position, size, and visibility on the customizable HUD.
/// Missing entirely (fresh install) or missing a specific metric (an app update
/// that added one) both fall back to [HudWidgetLayout.defaultFor] -- never a crash,
/// same discipline [HudWidgetLayout.fromJson] itself follows for a malformed entry.
Map<HudMetric, HudWidgetLayout> get hudLayout {
final raw = _prefs.getString(_keyHudLayout);
Map<String, dynamic> stored = const {};
if (raw != null) {
try {
stored = jsonDecode(raw) as Map<String, dynamic>;
} catch (_) {
stored = const {};
}
}
return {
for (final metric in HudMetric.values)
metric: stored[metric.name] is Map<String, dynamic>
? HudWidgetLayout.fromJson(metric, stored[metric.name] as Map<String, dynamic>)
: HudWidgetLayout.defaultFor(metric),
};
}
Future<void> setHudLayout(Map<HudMetric, HudWidgetLayout> layout) => _prefs.setString(
_keyHudLayout,
jsonEncode({for (final entry in layout.entries) entry.key.name: entry.value.toJson()}),
);
/// Stable per-install id so a server can distinguish riders in a group.
String get deviceId {
final existing = _prefs.getString(_keyDeviceId);

View 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);
}

View 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',
};
}

View 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,
);
}
}

View File

@@ -0,0 +1,134 @@
/// UI-04: one telemetry widget on the customizable HUD -- positioned/sized from a
/// [HudWidgetLayout]'s fractions against whatever pixel area it's given, draggable and
/// resizable only while [editing] is true.
///
/// 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
/// by the absence of a `GestureDetector`, not by an internal flag a bug could ignore.
library;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../hud/hud_widget_layout.dart';
import 'glass_panel.dart';
class DraggableResizableHudWidget extends StatefulWidget {
const DraggableResizableHudWidget({
super.key,
required this.layout,
required this.areaSize,
required this.editing,
required this.child,
required this.onMoved,
required this.onResized,
});
final HudWidgetLayout layout;
/// The pixel size of the HUD area the fractions in [layout] are relative to --
/// supplied by the caller (a `HudEditOverlay` reading its own `LayoutBuilder`
/// constraints), which is what keeps this widget itself free of any assumption
/// about what "the HUD area" means on a given screen (e.g. excluding the bottom nav
/// bar is the caller's job, not this widget's).
final Size areaSize;
final bool editing;
final Widget child;
/// Fractional x/y, already relative to [areaSize] -- not yet clamped; the caller
/// (`HudLayoutController.updatePosition`) owns clamping so there is exactly one
/// place that logic lives.
final void Function(double x, double y) onMoved;
final void Function(double width, double height) onResized;
@override
State<DraggableResizableHudWidget> createState() =>
_DraggableResizableHudWidgetState();
}
class _DraggableResizableHudWidgetState extends State<DraggableResizableHudWidget> {
bool _grabbed = false;
@override
Widget build(BuildContext context) {
final layout = widget.layout;
final left = layout.x * widget.areaSize.width;
final top = layout.y * widget.areaSize.height;
final width = layout.width * widget.areaSize.width;
final height = layout.height * widget.areaSize.height;
Widget card = AnimatedScale(
scale: _grabbed ? 1.05 : 1.0,
duration: const Duration(milliseconds: 150),
child: GlassPanel(child: Center(child: widget.child)),
);
if (widget.editing) {
card = GestureDetector(
onLongPressStart: (_) {
HapticFeedback.mediumImpact();
setState(() => _grabbed = true);
},
onLongPressMoveUpdate: (details) {
widget.onMoved(
layout.x + details.offsetFromOrigin.dx / widget.areaSize.width,
layout.y + details.offsetFromOrigin.dy / widget.areaSize.height,
);
},
onLongPressEnd: (_) => setState(() => _grabbed = false),
onLongPressCancel: () => setState(() => _grabbed = false),
child: card,
);
} else {
// A no-op long-press recognizer, not merely the absence of one: without it, a
// long-press on this widget wins nothing here and is free to bubble up to a
// `HudEditOverlay`'s background gesture detector, wrongly entering edit mode
// from a press that landed *on* a widget rather than the empty HUD area the
// ticket's design specifically calls for ("a long-press anywhere on the HUD
// area that isn't a specific widget"). An inner `GestureDetector` registering
// the same gesture type wins the arena over the outer one, absorbing it here.
card = GestureDetector(onLongPress: () {}, child: card);
}
return Positioned(
left: left,
top: top,
width: width,
height: height,
child: Stack(
clipBehavior: Clip.none,
children: [
card,
// The resize handle: visible, and interactive, only in edit mode -- a
// permanent on-screen handle during a live ride would be visual noise the
// ticket explicitly calls out as unwanted.
if (widget.editing)
Positioned(
right: -8,
bottom: -8,
child: GestureDetector(
onPanUpdate: (details) {
widget.onResized(
layout.width + details.delta.dx / widget.areaSize.width,
layout.height + details.delta.dy / widget.areaSize.height,
);
},
child: Container(
key: const Key('hud-resize-handle'),
width: 24,
height: 24,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary,
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 2),
),
child: const Icon(Icons.open_in_full, size: 14, color: Colors.black),
),
),
),
],
),
);
}
}

View File

@@ -0,0 +1,87 @@
/// UI-04: hosts every visible HUD telemetry widget over a given area, and owns
/// entering/exiting edit mode -- the chrome (resize handles, the "Done" affordance)
/// only exists while editing, per the ticket's own reasoning against permanent-on-
/// screen edit chrome during a live ride.
///
/// Deliberately has no idea what a metric's live *value* is -- that's `RecordUiState`
/// (or equivalent), owned by whichever screen actually assembles a real HUD (UI-05).
/// [metricBuilder] is how that content gets in without this widget depending on it.
library;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../app/providers.dart';
import '../../hud/hud_metric.dart';
import 'draggable_resizable_hud_widget.dart';
class HudEditOverlay extends ConsumerStatefulWidget {
const HudEditOverlay({super.key, required this.metricBuilder});
final Widget Function(BuildContext context, HudMetric metric) metricBuilder;
@override
ConsumerState<HudEditOverlay> createState() => _HudEditOverlayState();
}
class _HudEditOverlayState extends ConsumerState<HudEditOverlay> {
bool _editing = false;
void _exitAndPersist() {
setState(() => _editing = false);
ref.read(hudLayoutControllerProvider.notifier).persist();
}
@override
Widget build(BuildContext context) {
final layout = ref.watch(hudLayoutControllerProvider);
final notifier = ref.read(hudLayoutControllerProvider.notifier);
return LayoutBuilder(
builder: (context, constraints) {
final areaSize = Size(constraints.maxWidth, constraints.maxHeight);
return GestureDetector(
key: const Key('hud-edit-background'),
behavior: HitTestBehavior.translucent,
// Long-press empty HUD space to enter edit mode; while already editing, a
// plain tap on empty space exits it (and persists) -- both per the ticket's
// Design section.
onLongPress: _editing
? null
: () {
HapticFeedback.selectionClick();
setState(() => _editing = true);
},
onTap: _editing ? _exitAndPersist : null,
child: Stack(
clipBehavior: Clip.none,
children: [
for (final entry in layout.entries)
if (entry.value.visible)
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),
),
if (_editing)
Positioned(
top: 8,
right: 8,
child: FilledButton(
key: const Key('hud-edit-done'),
onPressed: _exitAndPersist,
child: const Text('Done'),
),
),
],
),
);
},
);
}
}

View File

@@ -11,6 +11,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../app/providers.dart';
import '../../config/config.dart';
import '../../domain/models.dart';
import '../../hud/hud_metric.dart';
class SettingsScreen extends ConsumerWidget {
const SettingsScreen({super.key});
@@ -241,6 +242,24 @@ class _SettingsBodyState extends ConsumerState<_SettingsBody> {
applicationVersion: '1.0.0',
),
),
const Divider(),
const _SectionHeader('Live HUD stats'),
for (final metric in HudMetric.values)
SwitchListTile(
key: Key('hud-visible-${metric.name}'),
title: Text(metric.label),
// Order is the stable enum order, not current HUD position -- easy to
// scan, per the ticket's own Settings integration note.
value: ref.watch(hudLayoutControllerProvider)[metric]?.visible ?? false,
onChanged: (value) {
final notifier = ref.read(hudLayoutControllerProvider.notifier);
notifier.setVisible(metric, value);
// A Settings toggle is a discrete, deliberate action, unlike a drag
// frame -- it persists immediately, the same as every other switch on
// this screen, rather than waiting for a HUD edit-mode session to end.
notifier.persist();
},
),
],
);
}