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:
134
lib/src/ui/components/draggable_resizable_hud_widget.dart
Normal file
134
lib/src/ui/components/draggable_resizable_hud_widget.dart
Normal 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),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
87
lib/src/ui/components/hud_edit_overlay.dart
Normal file
87
lib/src/ui/components/hud_edit_overlay.dart
Normal 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'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user