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:
@@ -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-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-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-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-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-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 |
|
| [UI-07](UI-07-rides-history.md) | Rides History redesign | M | UI-01, UI-03 | Not started |
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# UI-04 — Customizable HUD telemetry widgets
|
# UI-04 — Customizable HUD telemetry widgets
|
||||||
|
|
||||||
**Depends on** UI-03 · **Size** L · **Status** Not started
|
**Depends on** UI-03 · **Size** L · **Status** Done
|
||||||
|
|
||||||
## Goal
|
## Goal
|
||||||
Let the rider drag each floating telemetry widget (Speed, Distance, etc.) to wherever
|
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;
|
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
|
overlapping each other is the rider's own choice to avoid). Per-metric colour
|
||||||
customization. Sharing/exporting a HUD layout between devices.
|
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.
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ import '../crash/crash_reporter.dart';
|
|||||||
import '../data/database.dart';
|
import '../data/database.dart';
|
||||||
import '../data/route_plan_repository.dart';
|
import '../data/route_plan_repository.dart';
|
||||||
import '../data/trip_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 '../domain/models.dart';
|
||||||
import '../notification/ride_notification_controller.dart';
|
import '../notification/ride_notification_controller.dart';
|
||||||
import '../notification/ride_notification_coordinator.dart';
|
import '../notification/ride_notification_coordinator.dart';
|
||||||
@@ -131,6 +134,14 @@ final mountedModeProvider = StateProvider<bool>(
|
|||||||
(ref) => ref.watch(configProvider)?.mountedMode ?? false,
|
(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
|
/// 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
|
/// start or stop a Sentry client already initialised at app launch -- see
|
||||||
/// `maybeInitCrashReporting`'s doc comment on why that gate is checked once, in
|
/// `maybeInitCrashReporting`'s doc comment on why that gate is checked once, in
|
||||||
|
|||||||
@@ -4,12 +4,15 @@
|
|||||||
/// recording must work with no server at all, and uploading is opt-in.
|
/// recording must work with no server at all, and uploading is opt-in.
|
||||||
library;
|
library;
|
||||||
|
|
||||||
|
import 'dart:convert';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'dart:math';
|
import 'dart:math';
|
||||||
|
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
import '../domain/models.dart' show UnitSystem;
|
import '../domain/models.dart' show UnitSystem;
|
||||||
|
import '../hud/hud_metric.dart';
|
||||||
|
import '../hud/hud_widget_layout.dart';
|
||||||
|
|
||||||
const _keyEndpoint = 'upload_endpoint';
|
const _keyEndpoint = 'upload_endpoint';
|
||||||
const _keyDeviceId = 'device_id';
|
const _keyDeviceId = 'device_id';
|
||||||
@@ -17,6 +20,7 @@ const _keyMapEnabled = 'map_enabled';
|
|||||||
const _keyUnitSystem = 'unit_system';
|
const _keyUnitSystem = 'unit_system';
|
||||||
const _keyMountedMode = 'mounted_mode';
|
const _keyMountedMode = 'mounted_mode';
|
||||||
const _keyCrashReportingEnabled = 'crash_reporting_enabled';
|
const _keyCrashReportingEnabled = 'crash_reporting_enabled';
|
||||||
|
const _keyHudLayout = 'hud_layout';
|
||||||
|
|
||||||
/// Countries that did not adopt metric for everyday distances. Not exhaustive — a
|
/// 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.
|
/// 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) =>
|
Future<void> setCrashReportingEnabled(bool enabled) =>
|
||||||
_prefs.setBool(_keyCrashReportingEnabled, 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.
|
/// Stable per-install id so a server can distinguish riders in a group.
|
||||||
String get deviceId {
|
String get deviceId {
|
||||||
final existing = _prefs.getString(_keyDeviceId);
|
final existing = _prefs.getString(_keyDeviceId);
|
||||||
|
|||||||
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
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 '../../app/providers.dart';
|
||||||
import '../../config/config.dart';
|
import '../../config/config.dart';
|
||||||
import '../../domain/models.dart';
|
import '../../domain/models.dart';
|
||||||
|
import '../../hud/hud_metric.dart';
|
||||||
|
|
||||||
class SettingsScreen extends ConsumerWidget {
|
class SettingsScreen extends ConsumerWidget {
|
||||||
const SettingsScreen({super.key});
|
const SettingsScreen({super.key});
|
||||||
@@ -241,6 +242,24 @@ class _SettingsBodyState extends ConsumerState<_SettingsBody> {
|
|||||||
applicationVersion: '1.0.0',
|
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();
|
||||||
|
},
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:rippr/src/config/config.dart';
|
import 'package:rippr/src/config/config.dart';
|
||||||
import 'package:rippr/src/domain/models.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';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
/// `Config` had no dedicated tests before V3-03 — every existing widget test leaves
|
/// `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', () {
|
group('deviceId', () {
|
||||||
test('is generated once and then stable across reads', () async {
|
test('is generated once and then stable across reads', () async {
|
||||||
final config = await freshConfig();
|
final config = await freshConfig();
|
||||||
|
|||||||
113
test/hud_edit_overlay_test.dart
Normal file
113
test/hud_edit_overlay_test.dart
Normal file
@@ -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');
|
||||||
|
});
|
||||||
|
}
|
||||||
71
test/hud_layout_controller_test.dart
Normal file
71
test/hud_layout_controller_test.dart
Normal file
@@ -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<Config> 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
127
test/hud_widget_layout_test.dart
Normal file
127
test/hud_widget_layout_test.dart
Normal file
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import 'package:flutter_test/flutter_test.dart';
|
|||||||
import 'package:rippr/src/app/providers.dart';
|
import 'package:rippr/src/app/providers.dart';
|
||||||
import 'package:rippr/src/config/config.dart';
|
import 'package:rippr/src/config/config.dart';
|
||||||
import 'package:rippr/src/domain/models.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/settings/settings_screen.dart';
|
||||||
import 'package:rippr/src/ui/theme.dart';
|
import 'package:rippr/src/ui/theme.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
@@ -196,6 +197,46 @@ void main() {
|
|||||||
expect(tester.takeException(), isNull);
|
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<void> 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<SwitchListTile>(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<SwitchListTile>(find.byKey(const Key('hud-visible-speed')))
|
||||||
|
.value;
|
||||||
|
expect(speedSwitch, isTrue);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
testWidgets('shows a spinner rather than crashing while Config is still loading',
|
testWidgets('shows a spinner rather than crashing while Config is still loading',
|
||||||
(tester) async {
|
(tester) async {
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
|
|||||||
Reference in New Issue
Block a user