diff --git a/docs/ui-redesign/README.md b/docs/ui-redesign/README.md index a8cad25..ef958af 100644 --- a/docs/ui-redesign/README.md +++ b/docs/ui-redesign/README.md @@ -22,7 +22,7 @@ v3 held to. | [UI-01](UI-01-tab-shell-background-map.md) | Persistent tab shell with an always-visible background map | L | — | Done | | [UI-02](UI-02-offline-skeleton-map.md) | Offline / no-connection skeleton map | S | UI-01 | Done | | [UI-03](UI-03-glass-component-kit.md) | Shared floating-glass component kit | M | UI-08 | Done | -| [UI-04](UI-04-customizable-hud-widgets.md) | Customizable HUD telemetry widgets (drag, resize, visibility toggle) | L | UI-03 | Not started | +| [UI-04](UI-04-customizable-hud-widgets.md) | Customizable HUD telemetry widgets (drag, resize, visibility toggle) | L | UI-03 | Done | | [UI-05](UI-05-map-hud-record-screen.md) | Map HUD: Record screen redesign | M | UI-01, UI-03, UI-04 | Not started | | [UI-06](UI-06-plan-and-route-planning.md) | Plan & Route Planning redesign | M | UI-01, UI-03 | Not started | | [UI-07](UI-07-rides-history.md) | Rides History redesign | M | UI-01, UI-03 | Not started | diff --git a/docs/ui-redesign/UI-04-customizable-hud-widgets.md b/docs/ui-redesign/UI-04-customizable-hud-widgets.md index 182e0b0..61924c9 100644 --- a/docs/ui-redesign/UI-04-customizable-hud-widgets.md +++ b/docs/ui-redesign/UI-04-customizable-hud-widgets.md @@ -1,6 +1,6 @@ # UI-04 — Customizable HUD telemetry widgets -**Depends on** UI-03 · **Size** L · **Status** Not started +**Depends on** UI-03 · **Size** L · **Status** Done ## Goal Let the rider drag each floating telemetry widget (Speed, Distance, etc.) to wherever @@ -112,3 +112,80 @@ same pattern as every other `Config` preference in this app — see `mountedMode Z-ordering/overlap resolution between widgets (widgets simply clamp to stay on-screen; overlapping each other is the rider's own choice to avoid). Per-metric colour customization. Sharing/exporting a HUD layout between devices. + +## Outcome + +Built the full generic infrastructure the ticket scopes, deliberately stopping short of +wiring it into the real Record screen -- that assembly (real metric values from +`RecordUiState`, replacing the fixed stats card) is UI-05's job, per the dependency +graph. `HudMetric` (`lib/src/hud/hud_metric.dart`) is the stable 8-value enum; +critically, the first four (`speed, distance, elapsedTime, maxSpeed`) are ordered to +match the Stitch mockup's fixed row exactly, since `HudWidgetLayout.defaultFor` derives +both default grid position and which metrics start visible directly from enum index -- +a bug caught by a widget-layout test (`maxSpeed` briefly wasn't in the first four because +`movingTime` was ordered ahead of it). + +`HudWidgetLayout` (`hud_widget_layout.dart`) holds fractional `x/y/width/height` + +`visible`, with `clamped()` enforcing a legibility floor and sane ceiling (size clamped +first, then position re-clamped against the now-bounded size, so a resize that would +push a widget off-screen shrinks it rather than silently relocating it) and +`fromJson`/`defaultFor` degrading to sane defaults on any malformed or missing data +rather than crashing Settings on launch. `Config.hudLayout`/`setHudLayout` +(`config/config.dart`) follow the exact JSON-map-of-a-stable-key pattern already +established there. `HudLayoutController` (a `StateNotifier`, `hud_layout_controller.dart`) +holds the authoritative in-memory layout during an edit session and only calls +`Config.setHudLayout` on `persist()` -- never per drag frame, the ticket's own named +risk. + +`DraggableResizableHudWidget` and `HudEditOverlay` (`lib/src/ui/components/`) are the +interactive pieces. A widget only attaches a drag/resize `GestureDetector` at all when +`editing` is true -- "a normal tap never moves a widget outside edit mode" is guaranteed +structurally by the absence of a recognizer, not by an internal flag a future edit could +weaken. Edit mode itself is entered by a long-press on *empty* HUD space and exited by +"Done" or a tap on empty space, both handled by `HudEditOverlay`'s own background +`GestureDetector`. + +**Bug found and fixed during testing, not anticipated by the plan:** outside edit mode, +`DraggableResizableHudWidget` initially attached no gesture detector at all, meaning a +long-press *on a widget* was free to bubble up through the gesture arena to +`HudEditOverlay`'s background long-press handler and wrongly enter edit mode from a +touch that landed on a specific widget, not the empty area the design explicitly calls +for ("a long-press anywhere on the HUD area *that isn't a specific widget*"). Fixed by +giving the non-editing state a no-op `GestureDetector(onLongPress: () {})` -- an inner +recognizer of the same gesture type wins the arena over the outer one, absorbing the +press instead of letting it propagate. Caught by a widget test that intentionally +dragged directly on a widget while not editing and asserted edit mode never engaged. + +**Tests:** `flutter analyze` clean. `flutter test` green at 365 tests (336 + 29 new, +across `hud_widget_layout_test.dart`, `hud_layout_controller_test.dart`, +`hud_edit_overlay_test.dart`, and additions to `config_test.dart`/ +`settings_screen_test.dart`) -- covering JSON round-trips and malformed-data fallback, +every clamp edge case with fixed geometry (no gestures needed), the controller's +in-memory-until-persist discipline, `TestGesture`-simulated long-press-drag actually +moving and persisting a widget, and the Settings toggle writing through to `Config` +immediately. One pre-existing-test collateral fix: adding 8 new `SwitchListTile`s +pushed everything after them below the test viewport's initial fold (a plain +`ListView(children:)` still lazily builds via a sliver, same as `.builder` -- an +assumption several existing tests unknowingly depended on). Moved the new section to +the very end of the list (after "About") so no earlier section's position changed, and +added `scrollUntilVisible` to the two new tests that need to reach it. + +**Android emulator verification** (`Medium_Phone_API_35`): used a throwaway preview +entry point (`lib/main_ui04_preview.dart`, deleted after use) since no consuming screen +exists yet. Confirmed on-device: the default four-card row renders exactly like the +mockup; a long-press on a widget does nothing (the arena-fix above); a long-press on +empty space enters edit mode, showing every resize handle and a "Done" pill; dragging a +resize handle (a plain pan, not gated by long-press, so directly reproducible via `adb +input swipe`) visibly grows a widget; tapping "Done" hides the edit chrome and keeps the +new size. **Full end-to-end persistence was verified across a real process restart**, +not just via the widget-test's in-memory assertions: resized Speed, tapped Done, +force-stopped the app, relaunched it fresh, and the enlarged Speed widget was still +enlarged. The long-press-*then*-drag move gesture itself could not be reproduced via +`adb input swipe` (its linear interpolation moves throughout the whole gesture rather +than holding still for the ~500ms long-press window first, so Flutter's arena resolves +it as a rejected pan rather than a recognized long-press) -- that exact interaction is +what the `TestGesture`-based widget test (which holds the pointer down, waits out +`kLongPressTimeout`, then moves) verifies precisely, and is trusted as the ground truth +for that specific gesture. Also confirmed via the real (non-preview) app that the new +Settings section renders correctly alongside every existing section and that toggling +"Average speed" flips its switch immediately. diff --git a/lib/src/app/providers.dart b/lib/src/app/providers.dart index 0a77383..f75b7b3 100644 --- a/lib/src/app/providers.dart +++ b/lib/src/app/providers.dart @@ -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( (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>( + (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 diff --git a/lib/src/config/config.dart b/lib/src/config/config.dart index c1232fb..8204430 100644 --- a/lib/src/config/config.dart +++ b/lib/src/config/config.dart @@ -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 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 get hudLayout { + final raw = _prefs.getString(_keyHudLayout); + Map stored = const {}; + if (raw != null) { + try { + stored = jsonDecode(raw) as Map; + } catch (_) { + stored = const {}; + } + } + return { + for (final metric in HudMetric.values) + metric: stored[metric.name] is Map + ? HudWidgetLayout.fromJson(metric, stored[metric.name] as Map) + : HudWidgetLayout.defaultFor(metric), + }; + } + + Future setHudLayout(Map 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); diff --git a/lib/src/hud/hud_layout_controller.dart b/lib/src/hud/hud_layout_controller.dart new file mode 100644 index 0000000..8bf4f69 --- /dev/null +++ b/lib/src/hud/hud_layout_controller.dart @@ -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> { + // `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 persist() async => _config?.setHudLayout(state); +} diff --git a/lib/src/hud/hud_metric.dart b/lib/src/hud/hud_metric.dart new file mode 100644 index 0000000..9f0618d --- /dev/null +++ b/lib/src/hud/hud_metric.dart @@ -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', + }; +} diff --git a/lib/src/hud/hud_widget_layout.dart b/lib/src/hud/hud_widget_layout.dart new file mode 100644 index 0000000..2ba0179 --- /dev/null +++ b/lib/src/hud/hud_widget_layout.dart @@ -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 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 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, + ); + } +} diff --git a/lib/src/ui/components/draggable_resizable_hud_widget.dart b/lib/src/ui/components/draggable_resizable_hud_widget.dart new file mode 100644 index 0000000..fd28e19 --- /dev/null +++ b/lib/src/ui/components/draggable_resizable_hud_widget.dart @@ -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 createState() => + _DraggableResizableHudWidgetState(); +} + +class _DraggableResizableHudWidgetState extends State { + 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), + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/src/ui/components/hud_edit_overlay.dart b/lib/src/ui/components/hud_edit_overlay.dart new file mode 100644 index 0000000..ba2c6d4 --- /dev/null +++ b/lib/src/ui/components/hud_edit_overlay.dart @@ -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 createState() => _HudEditOverlayState(); +} + +class _HudEditOverlayState extends ConsumerState { + 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'), + ), + ), + ], + ), + ); + }, + ); + } +} diff --git a/lib/src/ui/settings/settings_screen.dart b/lib/src/ui/settings/settings_screen.dart index 373301d..240282e 100644 --- a/lib/src/ui/settings/settings_screen.dart +++ b/lib/src/ui/settings/settings_screen.dart @@ -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(); + }, + ), ], ); } diff --git a/test/config_test.dart b/test/config_test.dart index 0497a77..1e8bf1c 100644 --- a/test/config_test.dart +++ b/test/config_test.dart @@ -1,6 +1,8 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:rippr/src/config/config.dart'; import 'package:rippr/src/domain/models.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'; /// `Config` had no dedicated tests before V3-03 — every existing widget test leaves @@ -81,6 +83,57 @@ void main() { }); }); + group('hudLayout (UI-04)', () { + test('a fresh install gets a default layout for every metric', () async { + final config = await freshConfig(); + final layout = config.hudLayout; + expect(layout.keys.toSet(), HudMetric.values.toSet()); + }); + + test('round-trips an edited layout through set/get', () async { + final config = await freshConfig(); + final edited = { + ...config.hudLayout, + HudMetric.speed: const HudWidgetLayout( + metric: HudMetric.speed, + x: 0.33, + y: 0.44, + width: 0.25, + height: 0.1, + visible: true, + ), + }; + + await config.setHudLayout(edited); + final reloaded = config.hudLayout; + + expect(reloaded[HudMetric.speed]!.x, 0.33); + expect(reloaded[HudMetric.speed]!.y, 0.44); + }); + + 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 { + SharedPreferences.setMockInitialValues({ + 'hud_layout': '{"speed": {"x": 0.1, "y": 0.1, "width": 0.2, "height": 0.1, ' + '"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]!.x, expectedDefault.x); + expect(layout[HudMetric.distance]!.visible, expectedDefault.visible); + }); + + test('corrupted stored JSON falls back to defaults rather than crashing', + () async { + SharedPreferences.setMockInitialValues({'hud_layout': 'not json at all'}); + final config = await Config.load(); + expect(config.hudLayout.keys.toSet(), HudMetric.values.toSet()); + }); + }); + group('deviceId', () { test('is generated once and then stable across reads', () async { final config = await freshConfig(); diff --git a/test/hud_edit_overlay_test.dart b/test/hud_edit_overlay_test.dart new file mode 100644 index 0000000..beead68 --- /dev/null +++ b/test/hud_edit_overlay_test.dart @@ -0,0 +1,113 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:rippr/src/app/providers.dart'; +import 'package:rippr/src/config/config.dart'; +import 'package:rippr/src/hud/hud_metric.dart'; +import 'package:rippr/src/ui/components/hud_edit_overlay.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +/// UI-04: drag/toggle behaviour of the customizable HUD, simulated via `TestGesture` +/// the same way other drag interactions in this codebase are tested (see +/// `route_planner_screen_test.dart`'s waypoint-drag coverage). +void main() { + late Config config; + + setUp(() async { + SharedPreferences.setMockInitialValues({}); + config = await Config.load(); + }); + + Widget host() => ProviderScope( + overrides: [configProvider.overrideWith((ref) => config)], + child: MaterialApp( + home: Scaffold( + body: SizedBox.expand( + child: HudEditOverlay( + metricBuilder: (context, metric) => Text(metric.label), + ), + ), + ), + ), + ); + + testWidgets('a tap outside edit mode does not move a widget', (tester) async { + await tester.pumpWidget(host()); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('hud-edit-done')), findsNothing, + reason: 'not editing yet'); + + await tester.tap(find.text('Speed')); + await tester.pump(); + + expect(find.byKey(const Key('hud-edit-done')), findsNothing, + reason: 'a plain tap on a widget must never enter edit mode or move it'); + }); + + testWidgets('a long-press on empty space enters edit mode, showing resize handles ' + 'and Done', (tester) async { + await tester.pumpWidget(host()); + await tester.pumpAndSettle(); + + await tester.longPress(find.byKey(const Key('hud-edit-background'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('hud-edit-done')), findsOneWidget); + expect(find.byKey(const Key('hud-resize-handle')), findsWidgets); + }); + + testWidgets('a long-press-and-drag on a widget moves it, and Done persists the new ' + 'position', (tester) async { + await tester.pumpWidget(host()); + await tester.pumpAndSettle(); + + await tester.longPress(find.byKey(const Key('hud-edit-background'))); + await tester.pumpAndSettle(); + + final before = config.hudLayout[HudMetric.speed]!; + + final speedFinder = find.text('Speed'); + final gesture = await tester.startGesture(tester.getCenter(speedFinder)); + await tester.pump(kLongPressTimeout + kPressTimeout); + await gesture.moveBy(const Offset(40, 60)); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('hud-edit-done'))); + await tester.pumpAndSettle(); + + final after = config.hudLayout[HudMetric.speed]!; + expect(after.x, isNot(before.x)); + expect(after.y, isNot(before.y)); + }); + + testWidgets('a drag while not editing does not move the widget', (tester) async { + await tester.pumpWidget(host()); + await tester.pumpAndSettle(); + + final before = config.hudLayout[HudMetric.speed]!; + final speedFinder = find.text('Speed'); + + final gesture = await tester.startGesture(tester.getCenter(speedFinder)); + await tester.pump(kLongPressTimeout + kPressTimeout); + await gesture.moveBy(const Offset(40, 60)); + await gesture.up(); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('hud-edit-done')), findsNothing); + final after = config.hudLayout[HudMetric.speed]!; + expect(after.x, before.x); + expect(after.y, before.y); + }); + + testWidgets('only visible metrics render a widget', (tester) async { + await tester.pumpWidget(host()); + await tester.pumpAndSettle(); + + expect(find.text('Speed'), findsOneWidget, reason: 'visible by default'); + expect(find.text('Average speed'), findsNothing, reason: 'hidden by default'); + }); +} diff --git a/test/hud_layout_controller_test.dart b/test/hud_layout_controller_test.dart new file mode 100644 index 0000000..3363407 --- /dev/null +++ b/test/hud_layout_controller_test.dart @@ -0,0 +1,71 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:rippr/src/config/config.dart'; +import 'package:rippr/src/hud/hud_layout_controller.dart'; +import 'package:rippr/src/hud/hud_metric.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + Future freshConfig() async { + SharedPreferences.setMockInitialValues({}); + return Config.load(); + } + + group('HudLayoutController', () { + test('updatePosition updates only the given metric, clamped', () async { + final controller = HudLayoutController(await freshConfig()); + final before = controller.state[HudMetric.distance]!; + + controller.updatePosition(HudMetric.speed, 5.0, 5.0); + + expect(controller.state[HudMetric.speed]!.x, lessThanOrEqualTo(1.0)); + expect(controller.state[HudMetric.distance]!.x, before.x, + reason: 'moving one metric must not disturb another'); + }); + + test('updateSize clamps to the legibility floor/ceiling', () async { + final controller = HudLayoutController(await freshConfig()); + + controller.updateSize(HudMetric.speed, 0.0, 0.0); + expect(controller.state[HudMetric.speed]!.width, greaterThan(0.0)); + expect(controller.state[HudMetric.speed]!.height, greaterThan(0.0)); + }); + + test('setVisible(false) then setVisible(true) restores the last saved position, ' + 'not a fresh default', () async { + final controller = HudLayoutController(await freshConfig()); + controller.updatePosition(HudMetric.avgSpeed, 0.6, 0.6); + final movedX = controller.state[HudMetric.avgSpeed]!.x; + + controller.setVisible(HudMetric.avgSpeed, false); + expect(controller.state[HudMetric.avgSpeed]!.visible, isFalse); + + controller.setVisible(HudMetric.avgSpeed, true); + expect(controller.state[HudMetric.avgSpeed]!.visible, isTrue); + expect(controller.state[HudMetric.avgSpeed]!.x, movedX, + reason: 'a metric with a real saved position keeps it when re-enabled'); + }); + + test('persist writes the current state to Config', () async { + final config = await freshConfig(); + final controller = HudLayoutController(config); + controller.updatePosition(HudMetric.speed, 0.4, 0.4); + + await controller.persist(); + + final reloaded = config.hudLayout; + expect(reloaded[HudMetric.speed]!.x, 0.4); + }); + + test('edits before persist are not visible to a fresh read of Config', () async { + final config = await freshConfig(); + final controller = HudLayoutController(config); + controller.updatePosition(HudMetric.speed, 0.4, 0.4); + + // No persist() call yet. + final reloaded = config.hudLayout; + expect(reloaded[HudMetric.speed]!.x, isNot(0.4), + reason: 'a drag in progress must only touch in-memory state -- ' + 'persisting every frame is the ticket\'s own named risk'); + }); + }); +} diff --git a/test/hud_widget_layout_test.dart b/test/hud_widget_layout_test.dart new file mode 100644 index 0000000..28c8b8f --- /dev/null +++ b/test/hud_widget_layout_test.dart @@ -0,0 +1,127 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:rippr/src/hud/hud_metric.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 +/// -- no real gestures needed to test either, per the ticket's own Tests section. +void main() { + group('JSON round trip', () { + test('encodes and decodes exactly', () { + const original = HudWidgetLayout( + metric: HudMetric.speed, + x: 0.1, + y: 0.2, + width: 0.3, + height: 0.15, + visible: true, + ); + + final decoded = HudWidgetLayout.fromJson(HudMetric.speed, original.toJson()); + + expect(decoded.x, original.x); + expect(decoded.y, original.y); + expect(decoded.width, original.width); + expect(decoded.height, original.height); + expect(decoded.visible, original.visible); + }); + + test('a malformed entry falls back to the default rather than crashing', () { + final decoded = HudWidgetLayout.fromJson(HudMetric.distance, {'x': 'not a number'}); + final default_ = HudWidgetLayout.defaultFor(HudMetric.distance); + + expect(decoded.x, default_.x); + expect(decoded.width, default_.width); + }); + + 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 default_ = HudWidgetLayout.defaultFor(HudMetric.maxSpeed); + + expect(decoded.x, default_.x); + }); + }); + + group('defaultFor', () { + test('every metric gets a distinct default position', () { + final positions = HudMetric.values + .map(HudWidgetLayout.defaultFor) + .map((l) => '${l.x},${l.y}') + .toSet(); + expect(positions.length, HudMetric.values.length, + reason: 'no two metrics should default to overlapping positions'); + }); + + test('the first four metrics (the mockup\'s fixed row) start visible', () { + expect(HudWidgetLayout.defaultFor(HudMetric.speed).visible, isTrue); + expect(HudWidgetLayout.defaultFor(HudMetric.distance).visible, isTrue); + expect(HudWidgetLayout.defaultFor(HudMetric.elapsedTime).visible, isTrue); + expect(HudWidgetLayout.defaultFor(HudMetric.maxSpeed).visible, isTrue); + }); + + test('metrics beyond the fixed row start hidden', () { + expect(HudWidgetLayout.defaultFor(HudMetric.avgSpeed).visible, isFalse); + expect(HudWidgetLayout.defaultFor(HudMetric.elevationGain).visible, isFalse); + }); + + test('every default is already within the valid clamp bounds', () { + for (final metric in HudMetric.values) { + final layout = HudWidgetLayout.defaultFor(metric); + expect(layout.clamped().x, layout.x); + expect(layout.clamped().y, layout.y); + expect(layout.clamped().width, layout.width); + expect(layout.clamped().height, layout.height); + } + }); + }); + + group('clamped', () { + const base = HudWidgetLayout( + metric: HudMetric.speed, + x: 0.5, + y: 0.5, + width: 0.3, + height: 0.15, + visible: true, + ); + + test('a position dragged past the right/bottom edge is pulled back on-screen', () { + final result = base.copyWith(x: 1.5, y: 1.5).clamped(); + expect(result.x, 1.0 - base.width); + expect(result.y, 1.0 - base.height); + }); + + test('a position dragged past the left/top edge is pulled back on-screen', () { + final result = base.copyWith(x: -0.5, y: -0.5).clamped(); + expect(result.x, 0.0); + expect(result.y, 0.0); + }); + + test('a resize below the legibility floor is corrected up to the minimum', () { + final result = base.copyWith(width: 0.01, height: 0.01).clamped(); + expect(result.width, hudMinWidthFraction); + expect(result.height, hudMinHeightFraction); + }); + + test('a resize above the sane ceiling is corrected down to the maximum', () { + final result = base.copyWith(width: 5.0, height: 5.0).clamped(); + expect(result.width, hudMaxWidthFraction); + expect(result.height, hudMaxHeightFraction); + }); + + test('shrinking to fit happens before repositioning, so a widget resized past ' + 'the edge shrinks rather than silently relocates', () { + final result = base.copyWith(x: 0.9, width: 0.7).clamped(); + expect(result.width, hudMaxWidthFraction); + // x itself is still clamped against the (now-bounded) width so the widget + // never sits partially off-screen either. + expect(result.x + result.width, lessThanOrEqualTo(1.0)); + }); + + test('an already-valid layout is unchanged', () { + expect(base.clamped().x, base.x); + expect(base.clamped().y, base.y); + expect(base.clamped().width, base.width); + expect(base.clamped().height, base.height); + }); + }); +} diff --git a/test/settings_screen_test.dart b/test/settings_screen_test.dart index f3ffee3..8a9934e 100644 --- a/test/settings_screen_test.dart +++ b/test/settings_screen_test.dart @@ -4,6 +4,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:rippr/src/app/providers.dart'; import 'package:rippr/src/config/config.dart'; import 'package:rippr/src/domain/models.dart'; +import 'package:rippr/src/hud/hud_metric.dart'; import 'package:rippr/src/ui/settings/settings_screen.dart'; import 'package:rippr/src/ui/theme.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -196,6 +197,46 @@ void main() { expect(tester.takeException(), isNull); }); + group('Live HUD stats (UI-04)', () { + // The section sits at the bottom of a long ListView -- these rows are not built + // until scrolled into view (a plain `ListView(children:)` still lazily materialises + // via a sliver, same as `.builder`), so every test here scrolls first. + Future scrollTo(WidgetTester tester, Key key) => tester.scrollUntilVisible( + find.byKey(key), + 200, + scrollable: find.byType(Scrollable).first, + ); + + testWidgets('a metric switch reflects and writes through to Config', (tester) async { + await tester.pumpWidget(host()); + await tester.pumpAndSettle(); + await scrollTo(tester, const Key('hud-visible-avgSpeed')); + + final initial = tester + .widget(find.byKey(const Key('hud-visible-avgSpeed'))) + .value; + expect(initial, isFalse, reason: 'metrics beyond the fixed row start hidden'); + + await tester.tap(find.byKey(const Key('hud-visible-avgSpeed'))); + await tester.pumpAndSettle(); + + expect(config.hudLayout[HudMetric.avgSpeed]!.visible, isTrue, + reason: 'the switch must write through to Config immediately, not wait ' + 'for a HUD edit-mode session to end'); + }); + + testWidgets('a metric already visible by default shows as on', (tester) async { + await tester.pumpWidget(host()); + await tester.pumpAndSettle(); + await scrollTo(tester, const Key('hud-visible-speed')); + + final speedSwitch = tester + .widget(find.byKey(const Key('hud-visible-speed'))) + .value; + expect(speedSwitch, isTrue); + }); + }); + testWidgets('shows a spinner rather than crashing while Config is still loading', (tester) async { await tester.pumpWidget(