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

@@ -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'),
),
),
],
),
);
},
);
}
}