UI-05: rebuild Record screen as the Map HUD (design north star)

Replaces the scrolling stats-card layout with the full-bleed Map HUD:
UI-04's draggable/resizable HUD widgets float over UI-01's shared
background map (full opacity on this tab), and the old Pause/Stop/Resume/
Discard buttons become full-bleed, icon-only segments matching the
mockup's tertiary-container/error-container colors exactly. The idle
state keeps its prior compact layout deliberately -- HUD widgets only
appear once a ride exists, preserving the existing "a resting screen
must not look like a ride going nowhere" guarantee rather than
reinterpreting it.

State-machine logic (ticker, wakelock, speed subscription, error
handling) is untouched; every pre-existing record-screen test passed
unchanged against the rebuilt screen. Added tests that actually tap
Start/Pause/Stop and verify engine state changes, confirm HUD widgets
render over the map rather than replacing it, and verify the
mounted-mode speed digit's real contrast ratio against GlassPanel's
translucent surface specifically (the ticket's own named risk).

Corrects a UI-04 mistake found while implementing this ticket: the HUD's
default four metrics were ordered Speed/Distance/Elapsed/Max Speed, a
guess made before reading the actual Map HUD mockup HTML closely. The
real fixed row is Speed/Avg Speed/Dist/Time -- reordered HudMetric to
match and updated every test that asserted the old order.

Adds PulsingLocationMarker (UI-03) to RideMap's live usage via a new
showLocationMarker flag, and explicit tertiaryContainer/errorContainer
tokens to ripprColors so the control bar matches the design system's
literal values rather than an auto-derived tonal palette.

Verified end-to-end on a real emulator: Start, Pause, Resume, and Stop
all correctly drive the trip state machine with the full live map behind
everything. A lengthy false alarm during this verification (taps
appearing to do nothing) turned out to be a screenshot-scale
mis-measurement on the verification side, not an app defect -- resolved
by sampling pixel colors directly from the raw screenshot to find the
control bar's true on-screen position.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Xki7YAcc2TiN2PRZJ2tXr
This commit is contained in:
2026-08-24 08:22:51 -05:00
parent bcface3529
commit fa6b1b8ef2
13 changed files with 508 additions and 230 deletions

View File

@@ -23,7 +23,7 @@ v3 held to.
| [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 | Done | | [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 | Done |
| [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 |
| [UI-08](UI-08-theme-modern-professional-dark.md) | Theme migration to Modern Professional Dark | S | — | Done | | [UI-08](UI-08-theme-modern-professional-dark.md) | Theme migration to Modern Professional Dark | S | — | Done |

View File

@@ -119,11 +119,17 @@ Built the full generic infrastructure the ticket scopes, deliberately stopping s
wiring it into the real Record screen -- that assembly (real metric values from 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 `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; 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 critically, the first four are ordered to match the Stitch mockup's fixed row exactly,
match the Stitch mockup's fixed row exactly, since `HudWidgetLayout.defaultFor` derives since `HudWidgetLayout.defaultFor` derives both default grid position and which metrics
both default grid position and which metrics start visible directly from enum index -- 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). **Correction (UI-05):** this ticket originally ordered those first four as
`speed, distance, elapsedTime, maxSpeed` -- a guess made without having read the actual
Map HUD mockup HTML closely yet. Implementing UI-05 against that same HTML directly
showed the real fixed row is Speed/**Avg Speed**/Dist/Time. The enum was reordered to
`speed, avgSpeed, distance, elapsedTime, maxSpeed, movingTime, elevationGain,
pointsCaptured` and every test here that had asserted the old order was updated
alongside it -- see UI-05's own Outcome for the full account.
`HudWidgetLayout` (`hud_widget_layout.dart`) holds fractional `x/y/width/height` + `HudWidgetLayout` (`hud_widget_layout.dart`) holds fractional `x/y/width/height` +
`visible`, with `clamped()` enforcing a legibility floor and sane ceiling (size clamped `visible`, with `clamped()` enforcing a legibility floor and sane ceiling (size clamped

View File

@@ -1,6 +1,6 @@
# UI-05 — Map HUD: Record screen redesign # UI-05 — Map HUD: Record screen redesign
**Depends on** UI-01, UI-03, UI-04 · **Size** M · **Status** Not started **Depends on** UI-01, UI-03, UI-04 · **Size** M · **Status** Done
## Goal ## Goal
Rebuild the Record screen as the fullscreen Map HUD from the Stitch export — this screen Rebuild the Record screen as the fullscreen Map HUD from the Stitch export — this screen
@@ -80,3 +80,90 @@ and everything else floats on top of it via `GlassPanel`/HUD widgets.
## Out of scope ## Out of scope
Following a planned route turn-by-turn (V3-09). Anything about Plan/Route Planning/Rides Following a planned route turn-by-turn (V3-09). Anything about Plan/Route Planning/Rides
History screens — those are UI-06/UI-07, restyled toward what this ticket establishes. History screens — those are UI-06/UI-07, restyled toward what this ticket establishes.
## Outcome
Rebuilt `RecordScreen`'s body entirely against UI-01's shared background map, keeping
every line of the existing state-machine logic (the ticker, wakelock handling, speed
subscription, `_guard`/error handling) completely untouched -- only the returned widget
tree changed. The idle state keeps its own compact layout (a single `GlassPanel` with
`BigStat` speed + "Ready"/"See Rides", exactly the old content, just re-skinned) rather
than switching to HUD widgets while there's no ride to show numbers for -- deliberately
preserving the pre-existing "a resting screen must not look like a ride going nowhere"
guarantee rather than reinterpreting it. Once a ride exists (recording or paused),
`HudEditOverlay` (UI-04) takes over, wired to a new `_HudMetricValue` that reads
`RecordUiState`/`UnitSystem` and renders through `HudMetric`'s existing set -- no new
data plumbing, exactly as the ticket's Implementation section asked for. Added
`RecordUiState.avgSpeedKmh` (distance-over-moving-time, zero before any movement) since
that metric didn't previously exist anywhere in the app.
**Colour convention decision:** the Map HUD mockup colours its four default metrics
individually (Speed → primary, Distance → secondary, Avg Speed/Time → plain ink) rather
than following this app's usual reference-vs-live split. `_HudMetricValue` matches the
mockup literally rather than inventing a new convention, since the mockup is this
ticket's explicit design source for exactly this screen.
**Correction found and fixed while implementing this ticket:** UI-04's `HudMetric` enum
ordered its "first four, default-visible" metrics as Speed/Distance/Elapsed/Max Speed --
a guess made before the actual Map HUD mockup HTML had been read closely. Reading
`docs/design/stitch-export/screens/map-hud-dark.html`'s own `#telemetry-container`
directly for this ticket showed the real fixed row is Speed/**Avg Speed**/Dist/Time.
Reordered the enum to match and updated every UI-04 test that asserted the old order
(five call sites across `hud_widget_layout_test.dart`, `hud_edit_overlay_test.dart`, and
`settings_screen_test.dart`) -- a real, disclosed correction, not a silent one.
**Control bar:** `_ControlBar`/`_Segment` render the full-bleed, icon-only bar the
mockup specifies for Pause (`tertiaryContainer`/`onTertiaryContainer`) and Stop
(`errorContainer`/`onErrorContainer`) -- both added explicitly to `ripprColors` in this
ticket, since `ColorScheme.dark`'s auto-derived tonal palette wouldn't have matched the
design system's literal hex values otherwise. Idle keeps a labelled "START RECORDING"
segment (an icon alone risked ambiguity for a rider's very first, highest-stakes tap,
a deliberate deviation from strict icon-only fidelity). Paused extends the same visual
language into three segments (Resume 50%, Stop 25%, Discard 25%) since the mockup itself
doesn't depict a paused state — Discard stays behind this dedicated slot, reachable only
while paused, preserving the existing gloved-mis-tap safeguard from V3-05 rather than
dropping it.
**Location marker:** `RideMap` gained a `showLocationMarker` bool; when true and points
exist, a `MarkerLayer` places `PulsingLocationMarker` (UI-03) at the latest point.
`ShellScaffold` passes `showLocationMarker: isMapTab` so the animation only ticks on the
tab where it's actually visible.
**Out of scope, confirmed at implementation time, not just planned:** the ticket allows
skipping planned-route rendering when V3-09 (route-following) doesn't exist yet, and it
doesn't -- there is no "route currently being followed" concept anywhere in the app to
source a planned-route polyline from. Left unimplemented rather than fabricating a data
source; `RideMap`'s existing ridden-path speed-gradient polyline is unaffected and still
renders live.
**Mounted mode re-verified against `GlassPanel` specifically**, per the ticket's own
named risk (mounted mode was built and tested against an opaque `Card`, not a blurred
translucent surface). Added a test asserting the actual contrast ratio of the mounted
speed digit against `GlassPanel`'s mounted-theme surface color (not just "differs from
the wrong, dark-theme ground" the way the pre-existing test checked) -- passes at
roughly 4.5:1+ AA with no colour changes needed.
**Tests:** `flutter analyze` clean. `flutter test` green at 369 tests. Every pre-existing
`record screen` test passed unchanged against the rebuilt screen with zero edits needed
-- the idle-state structural parity was deliberate and it paid off. Added: two tests that
actually tap Start/Pause/Stop and verify the underlying trip state changes (not just key
presence, which the pre-existing tests already covered), one confirming HUD widgets
render over the shared background map rather than replacing it, and the mounted-mode/
GlassPanel contrast test above.
**Android emulator verification** (`Medium_Phone_API_35`) exercised the full state
machine live on-device: Start → Pause → Resume → Stop, confirmed visually correct at
each transition (colours, icon changes, the "Paused" pill appearing/disappearing, the
HUD widgets updating), with the full-bleed live map behind everything on the Map tab
exactly matching the design intent. This surfaced and resolved a real debugging episode
worth recording: initial manual taps on the control bar appeared to do nothing across
many attempts (varied coordinates, map on/off, a full emulator+host reboot, a Gradle-
daemon memory-pressure cleanup, and a `Container`→`Ink` widget refactor along the way).
The eventual root cause was mundane and entirely on the verification side, not the
app: the button's true on-screen position was being mis-estimated from a scaled-down
screenshot preview, roughly 500px off from its actual location. Sampling pixel colours
directly from the raw screenshot file (via PIL) to find the button's exact bounds
resolved it immediately, and every interaction has worked correctly since. The emulator
reboot and Gradle-daemon stop were not the fix, but were a reasonable, low-risk
housekeeping step taken while investigating and are left in place as a genuine
improvement to the session's remaining build performance.

View File

@@ -7,16 +7,20 @@
library; library;
enum HudMetric { enum HudMetric {
// The first four are, in order, the Stitch mockup's fixed row (Speed/Distance/ // The first four are, in order, the actual Map HUD mockup's fixed telemetry row --
// Elapsed/Max speed) -- `HudWidgetLayout.defaultFor` uses this ordering directly to // Speed / Avg Speed / Dist / Time, verified directly against
// docs/design/stitch-export/screens/map-hud-dark.html's `#telemetry-container`
// rather than assumed. `HudWidgetLayout.defaultFor` uses this ordering directly to
// decide both default grid position and which metrics start visible, so this order // decide both default grid position and which metrics start visible, so this order
// is load-bearing, not cosmetic. // is load-bearing, not cosmetic. (UI-05 correction: an earlier guess here, made
// before that HTML had been read closely, wrongly ordered Distance/Elapsed/Max
// Speed as the fixed four.)
speed, speed,
avgSpeed,
distance, distance,
elapsedTime, elapsedTime,
maxSpeed, maxSpeed,
movingTime, movingTime,
avgSpeed,
elevationGain, elevationGain,
pointsCaptured; pointsCaptured;

View File

@@ -113,7 +113,7 @@ class HudWidgetLayout {
y: 0.06 + row * (cellHeight + gap), y: 0.06 + row * (cellHeight + gap),
width: cellWidth, width: cellWidth,
height: cellHeight, height: cellHeight,
// The mockup's own fixed row is Speed/Distance/Elapsed/Max speed -- the first // The Map HUD mockup's own fixed row is Speed/Avg Speed/Dist/Time -- the first
// four enum values are ordered to match, so only those start visible. // four enum values are ordered to match, so only those start visible.
visible: index < columns, visible: index < columns,
); );

View File

@@ -95,6 +95,9 @@ class ShellScaffold extends ConsumerWidget {
showEmptyLabel: false, showEmptyLabel: false,
tileProvider: ref.watch(cachedTileProviderProvider), tileProvider: ref.watch(cachedTileProviderProvider),
skeletonMode: ref.watch(mapConnectivityProvider).skeletonMode, skeletonMode: ref.watch(mapConnectivityProvider).skeletonMode,
// UI-05: only on the Map tab -- a pulsing marker ticking away behind
// a scrim on every other tab would be wasted animation no one sees.
showLocationMarker: isMapTab,
), ),
), ),
), ),

View File

@@ -27,6 +27,7 @@ import '../../domain/models.dart';
import '../../geo/geo.dart' as geo; import '../../geo/geo.dart' as geo;
import '../../tiles/tile_config.dart'; import '../../tiles/tile_config.dart';
import '../theme.dart' show ripprRadiusLarge; import '../theme.dart' show ripprRadiusLarge;
import 'pulsing_location_marker.dart';
import 'skeleton_map_layer.dart'; import 'skeleton_map_layer.dart';
export '../../tiles/tile_config.dart'; export '../../tiles/tile_config.dart';
@@ -57,6 +58,7 @@ class RideMap extends StatefulWidget {
this.fill = false, this.fill = false,
this.showEmptyLabel = true, this.showEmptyLabel = true,
this.skeletonMode = false, this.skeletonMode = false,
this.showLocationMarker = false,
}); });
final List<TrackPoint> points; final List<TrackPoint> points;
@@ -92,6 +94,11 @@ class RideMap extends StatefulWidget {
/// following at all. /// following at all.
final bool follow; final bool follow;
/// UI-05: draws a `PulsingLocationMarker` at the latest point -- only meaningful for
/// the live shell background (paired with [follow]), never for a finished ride's
/// static playback, where there is no "current position" to mark.
final bool showLocationMarker;
@override @override
State<RideMap> createState() => _RideMapState(); State<RideMap> createState() => _RideMapState();
} }
@@ -258,6 +265,21 @@ class _RideMapState extends State<RideMap> with WidgetsBindingObserver {
tileProvider: widget.tileProvider, tileProvider: widget.tileProvider,
), ),
PolylineLayer(polylines: polylines), PolylineLayer(polylines: polylines),
if (widget.showLocationMarker && hasPoints)
MarkerLayer(
markers: [
Marker(
key: const Key('location-marker'),
point: ll.LatLng(
widget.points.last.latitude,
widget.points.last.longitude,
),
width: 40,
height: 40,
child: const PulsingLocationMarker(),
),
],
),
const TileAttribution(), const TileAttribution(),
], ],
), ),

View File

@@ -1,4 +1,9 @@
/// Ported from `com.rippr.ui.record.RecordScreen` and `RecordViewModel`. /// Ported from `com.rippr.ui.record.RecordScreen` and `RecordViewModel`.
///
/// UI-05: this is the Map HUD -- the design north star of the whole redesign. No
/// embedded map card, no stats `Card`, no header: the map is the shared full-opacity
/// background this tab already provides (UI-01), and every other visual floats on top
/// of it as a `GlassPanel`/HUD widget.
library; library;
import 'dart:async'; import 'dart:async';
@@ -8,9 +13,12 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../app/providers.dart'; import '../../app/providers.dart';
import '../../domain/models.dart'; import '../../domain/models.dart';
import '../../hud/hud_metric.dart';
import '../../recording/location_source.dart'; import '../../recording/location_source.dart';
import '../../recording/wakelock_controller.dart'; import '../../recording/wakelock_controller.dart';
import '../../telemetry/telemetry.dart'; import '../../telemetry/telemetry.dart';
import '../components/glass_panel.dart';
import '../components/hud_edit_overlay.dart';
import '../components/stats.dart'; import '../components/stats.dart';
import '../format.dart'; import '../format.dart';
import '../theme.dart'; import '../theme.dart';
@@ -34,6 +42,14 @@ class RecordUiState {
bool get isIdle => trip == null; bool get isIdle => trip == null;
bool get isRecording => trip?.state == TripState.recording; bool get isRecording => trip?.state == TripState.recording;
bool get isPaused => trip?.state == TripState.paused; bool get isPaused => trip?.state == TripState.paused;
/// Distance-over-moving-time -- there is no stored average, only the pieces to
/// derive one. Zero rather than a divide-by-zero NaN before the ride has moved at all.
double get avgSpeedKmh {
final t = trip;
if (t == null || t.movingMillis <= 0) return 0;
return (t.distanceM / 1000) / (t.movingMillis / 3600000);
}
} }
class RecordScreen extends ConsumerStatefulWidget { class RecordScreen extends ConsumerStatefulWidget {
@@ -140,6 +156,12 @@ class _RecordScreenState extends ConsumerState<RecordScreen> {
} }
} }
/// 72dp normally, 96dp mounted -- one constant height regardless of how many
/// segments the control bar has (one/two/three), so the HUD area above it is always
/// the same size and a widget's saved fractional position never jumps between ride
/// states.
double _controlBarHeight(bool mountedMode) => mountedMode ? 96 : 72;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final mountedMode = ref.watch(mountedModeProvider); final mountedMode = ref.watch(mountedModeProvider);
@@ -165,29 +187,24 @@ class _RecordScreenState extends ConsumerState<RecordScreen> {
// UI-01: no header, no embedded map card -- the map is the shared background behind // UI-01: no header, no embedded map card -- the map is the shared background behind
// this whole tab (`AppShell`), full opacity here. A quick mounted-mode toggle used to // this whole tab (`AppShell`), full opacity here. A quick mounted-mode toggle used to
// live in a header row; that row is gone, and the toggle's only home for now is // live in a header row; that row is gone, and the toggle's only home for now is
// Settings' existing switch (see the ticket's Outcome for why this is a deliberate, // Settings' existing switch.
// temporary trade rather than an oversight -- UI-05 restyles this screen properly). return Theme(
return Theme(data: theme, child: Scaffold( data: theme,
child: Scaffold(
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
// Scrollable, but still centred when there is room.
//
// With a ride active the card grows to six stat rows, which overflows a short
// screen. Compose clipped this silently; Flutter reports it, and a widget test
// caught it immediately. Glove-sized 72dp controls make the content tall enough
// that this is a real risk on small phones, not a theoretical one.
body: SafeArea( body: SafeArea(
child: LayoutBuilder( child: Stack(
builder: (context, constraints) => SingleChildScrollView( children: [
child: ConstrainedBox( if (ui.isIdle)
constraints: BoxConstraints(minHeight: constraints.maxHeight), Center(
child: Padding( child: GlassPanel(
padding: const EdgeInsets.all(24), padding: const EdgeInsets.all(24),
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisSize: MainAxisSize.min,
children: [ children: [
// Current speed, not max: a max figure only moves when you beat it, which // Current speed, not max: a max figure only moves when you beat
// reads as a frozen screen while riding steadily. This is the 2.0.1 fix and // it, which reads as a frozen screen while riding steadily.
// it must not regress. // This is the 2.0.1 fix and it must not regress.
BigStat( BigStat(
key: const Key('speed'), key: const Key('speed'),
label: 'SPEED', label: 'SPEED',
@@ -196,69 +213,52 @@ class _RecordScreenState extends ConsumerState<RecordScreen> {
scale: mountedMode ? mountedTextScale : 1.0, scale: mountedMode ? mountedTextScale : 1.0,
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
// A resting state, not a zeroed ride -- otherwise the screen
Card( // looks like a recording that is going nowhere.
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
if (ui.isIdle) ...[
// A resting state, not a zeroed ride — otherwise the screen looks
// like a recording that is going nowhere.
const StatRow(label: 'Status', value: 'Ready'), const StatRow(label: 'Status', value: 'Ready'),
const StatRow( const StatRow(label: 'Last ride', value: 'See Rides'),
label: 'Last ride', ],
value: 'See Rides',
),
] else ...[
StatRow(
label: 'Distance',
value: formatDistance(
ui.trip!.distanceM,
unit: units,
), ),
), ),
StatRow( )
label: 'Elapsed', else
value: formatDuration(ui.elapsedMillis), Positioned.fill(
), bottom: _controlBarHeight(mountedMode),
StatRow( child: HudEditOverlay(
label: 'Moving time', metricBuilder: (context, metric) =>
value: formatDuration(ui.trip!.movingMillis), _HudMetricValue(metric: metric, ui: ui, units: units),
),
StatRow(
label: 'Max speed',
value: formatSpeed(
ui.trip!.maxSpeedKmh,
unit: units,
),
reference: true,
),
StatRow(
label: 'Points captured',
value: '${ui.trip!.pointCount}',
),
StatRow(
label: 'Elevation gain',
value: formatElevation(
ui.trip!.elevationGainM,
unit: units,
), ),
), ),
if (ui.isPaused) if (ui.isPaused)
const StatRow(label: 'Status', value: 'Paused'), Positioned(
], top: 8,
left: 0,
right: 0,
child: Center(
child: GlassPanel(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
borderRadius: const BorderRadius.all(Radius.circular(999)),
child: const Text('Paused', style: TextStyle(fontWeight: FontWeight.bold)),
),
),
),
if (ui.uploadError != null) if (ui.uploadError != null)
StatRow(label: 'Problem', value: ui.uploadError!), Positioned(
], top: 8,
left: 16,
right: 16,
child: GlassPanel(
padding: const EdgeInsets.all(12),
child: Text(ui.uploadError!),
), ),
), ),
), Positioned(
left: 0,
const SizedBox(height: 28), right: 0,
_Controls( bottom: 0,
height: _controlBarHeight(mountedMode),
child: _ControlBar(
ui: ui, ui: ui,
mounted: mountedMode,
onStart: () => _guard(engine.start), onStart: () => _guard(engine.start),
onPause: () => _guard(engine.pause), onPause: () => _guard(engine.pause),
onStop: () => _guard(() async => engine.stop()), onStop: () => _guard(() async => engine.stop()),
@@ -275,26 +275,89 @@ class _RecordScreenState extends ConsumerState<RecordScreen> {
if (confirmed) await _guard(engine.discard); if (confirmed) await _guard(engine.discard);
}, },
), ),
),
], ],
), ),
), ),
), ),
), );
),
),
));
} }
} }
/// The content of one HUD telemetry widget -- purely presentational, sourced from the
/// same `RecordUiState`/units that powered the old fixed stats card. No new data
/// plumbing, only new presentation, per the ticket's own Implementation section.
class _HudMetricValue extends StatelessWidget {
const _HudMetricValue({required this.metric, required this.ui, required this.units});
class _Controls extends StatelessWidget { final HudMetric metric;
const _Controls({ final RecordUiState ui;
final UnitSystem units;
/// Matches the Map HUD mockup's own colour choices for its default four metrics
/// exactly (Speed: primary, Avg Speed/Time: plain ink, Dist: secondary) rather than
/// this app's usual reference-vs-live convention -- this HUD is a new visual context
/// the mockup already specifies directly, colour by colour.
Color _valueColor(ColorScheme colors) => switch (metric) {
HudMetric.speed => colors.primary,
HudMetric.distance => colors.secondary,
_ => colors.onSurface,
};
String get _value {
final trip = ui.trip;
return switch (metric) {
HudMetric.speed => formatSpeedParts(ui.currentSpeedKmh, unit: units).$1,
HudMetric.avgSpeed => formatSpeedParts(ui.avgSpeedKmh, unit: units).$1,
HudMetric.distance => formatDistance(trip?.distanceM ?? 0, unit: units),
HudMetric.elapsedTime => formatDuration(ui.elapsedMillis),
HudMetric.maxSpeed => formatSpeed(trip?.maxSpeedKmh ?? 0, unit: units),
HudMetric.movingTime => formatDuration(trip?.movingMillis ?? 0),
HudMetric.elevationGain => formatElevation(trip?.elevationGainM ?? 0, unit: units),
HudMetric.pointsCaptured => '${trip?.pointCount ?? 0}',
};
}
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
metric.label.toUpperCase(),
style: TextStyle(fontSize: 10, letterSpacing: 1, color: colors.onSurfaceVariant),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Text(
_value,
style: monoDigits.copyWith(
fontSize: 18,
fontWeight: FontWeight.bold,
color: _valueColor(colors),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
);
}
}
/// The full-bleed, icon-only control bar directly above the bottom nav bar -- matches
/// the Map HUD mockup's Pause/Stop segment exactly for the recording state; the idle
/// and paused states aren't shown in that mockup, so they extend the same visual
/// language (full-bleed, icon-only, colour communicates the action) rather than
/// reverting to the old text-labelled buttons.
class _ControlBar extends StatelessWidget {
const _ControlBar({
required this.ui, required this.ui,
required this.onStart, required this.onStart,
required this.onPause, required this.onPause,
required this.onStop, required this.onStop,
required this.onDiscard, required this.onDiscard,
this.mounted = false,
}); });
final RecordUiState ui; final RecordUiState ui;
@@ -303,133 +366,143 @@ class _Controls extends StatelessWidget {
final VoidCallback onStop; final VoidCallback onStop;
final VoidCallback onDiscard; final VoidCallback onDiscard;
/// V3-05: 72dp works stopped; at speed, with gloves, it does not.
final bool mounted;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (ui.isIdle) { if (ui.isIdle) {
return _PrimaryButton( return _Segment(
label: 'START RECORDING',
onPressed: onStart,
keyName: 'start', keyName: 'start',
mounted: mounted, icon: Icons.play_arrow,
label: 'START RECORDING',
background: Theme.of(context).colorScheme.tertiaryContainer,
foreground: Theme.of(context).colorScheme.onTertiaryContainer,
onTap: onStart,
); );
} }
if (ui.isRecording) { if (ui.isRecording) {
return Column( final colors = Theme.of(context).colorScheme;
return Row(
children: [ children: [
_PrimaryButton( Expanded(
label: 'PAUSE', onPressed: onPause, keyName: 'pause', mounted: mounted), child: _Segment(
const SizedBox(height: 12), keyName: 'pause',
_SecondaryButton( icon: Icons.pause,
label: 'STOP', onPressed: onStop, keyName: 'stop', mounted: mounted), background: colors.tertiaryContainer,
foreground: colors.onTertiaryContainer,
onTap: onPause,
trailingBorder: true,
),
),
Expanded(
child: _Segment(
keyName: 'stop',
icon: Icons.stop,
background: colors.errorContainer,
foreground: colors.onErrorContainer,
onTap: onStop,
),
),
], ],
); );
} }
// Discard is offered only while paused. Putting a destructive action next to Pause // Discard is offered only while paused. Putting a destructive action next to Pause
// during a live ride invites a gloved mis-tap at speed. // during a live ride invites a gloved mis-tap at speed.
return Column(
children: [
_PrimaryButton(
label: 'RESUME', onPressed: onStart, keyName: 'resume', mounted: mounted),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: _SecondaryButton(
label: 'STOP',
onPressed: onStop,
keyName: 'stop',
mounted: mounted,
),
),
const SizedBox(width: 12),
Expanded(
child: _SecondaryButton(
label: 'DISCARD',
onPressed: onDiscard,
keyName: 'discard',
destructive: true,
mounted: mounted,
),
),
],
),
],
);
}
}
/// 72dp tall throughout — these get pressed with gloves on. Mounted mode goes bigger
/// still (V3-05): stopped, 72dp is enough; at speed, with gloves, it is not.
class _PrimaryButton extends StatelessWidget {
const _PrimaryButton({
required this.label,
required this.onPressed,
required this.keyName,
this.mounted = false,
});
final String label;
final VoidCallback onPressed;
final String keyName;
final bool mounted;
@override
Widget build(BuildContext context) => SizedBox(
width: double.infinity,
height: mounted ? 96 : 72,
child: FilledButton(
key: Key(keyName),
onPressed: onPressed,
child: Text(
label,
style: TextStyle(
fontSize: mounted ? 26 : 20,
fontWeight: FontWeight.bold,
),
),
),
);
}
class _SecondaryButton extends StatelessWidget {
const _SecondaryButton({
required this.label,
required this.onPressed,
required this.keyName,
this.destructive = false,
this.mounted = false,
});
final String label;
final VoidCallback onPressed;
final String keyName;
final bool destructive;
final bool mounted;
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme; final colors = Theme.of(context).colorScheme;
return SizedBox( return Row(
height: mounted ? 76 : 56, children: [
child: OutlinedButton( Expanded(
key: Key(keyName), flex: 2,
onPressed: onPressed, child: _Segment(
style: OutlinedButton.styleFrom( keyName: 'resume',
foregroundColor: destructive ? colors.error : colors.onSurface, icon: Icons.play_arrow,
background: colors.tertiaryContainer,
foreground: colors.onTertiaryContainer,
onTap: onStart,
trailingBorder: true,
), ),
child: Text( ),
label, Expanded(
child: _Segment(
keyName: 'stop',
icon: Icons.stop,
background: colors.errorContainer,
foreground: colors.onErrorContainer,
onTap: onStop,
trailingBorder: true,
),
),
Expanded(
child: _Segment(
keyName: 'discard',
icon: Icons.delete_outline,
background: colors.surfaceContainerHighest,
foreground: colors.error,
onTap: onDiscard,
),
),
],
);
}
}
class _Segment extends StatelessWidget {
const _Segment({
required this.keyName,
required this.icon,
required this.background,
required this.foreground,
required this.onTap,
this.label,
this.trailingBorder = false,
});
final String keyName;
final IconData icon;
final Color background;
final Color foreground;
final VoidCallback onTap;
final String? label;
final bool trailingBorder;
@override
Widget build(BuildContext context) => Material(
color: background,
// The canonical `Material` + `Ink` pairing: `Ink` paints the border directly onto
// the `Material`'s own layer, which is what lets the `InkWell`'s splash paint
// above it -- a separate `Container` ancestor wrapping `Material` would put the
// border outside the ink layer entirely.
child: Ink(
decoration: BoxDecoration(
border: trailingBorder
? Border(right: BorderSide(color: Colors.white.withValues(alpha: 0.1)))
: null,
),
child: InkWell(
key: Key(keyName),
onTap: onTap,
child: SizedBox.expand(
child: Center(
child: label == null
? Icon(icon, color: foreground, size: 32)
: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, color: foreground),
const SizedBox(width: 8),
Text(
label!,
style: TextStyle( style: TextStyle(
fontSize: mounted ? 20 : 16, color: foreground,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
fontSize: 16,
),
),
],
),
),
), ),
), ),
), ),
); );
} }
}

View File

@@ -45,6 +45,12 @@ const ripprColors = ColorScheme.dark(
secondary: Color(0xFFAEC7F6), secondary: Color(0xFFAEC7F6),
tertiary: _referenceOrange, tertiary: _referenceOrange,
onTertiary: Color(0xFF532200), onTertiary: Color(0xFF532200),
// UI-05: explicit rather than left to `ColorScheme.dark`'s own M3 tonal-palette
// derivation -- the Map HUD's Pause/Stop control bar needs to match the design
// system's literal `tertiary-container`/`error-container` tokens exactly, not
// whatever a generated tonal palette happens to compute from `tertiary`/`error` alone.
tertiaryContainer: Color(0xFFE3711F),
onTertiaryContainer: Color(0xFF481D00),
surface: _surfaceMid, surface: _surfaceMid,
onSurface: _ink, onSurface: _ink,
surfaceContainerHighest: Color(0xFF353534), surfaceContainerHighest: Color(0xFF353534),
@@ -57,6 +63,8 @@ const ripprColors = ColorScheme.dark(
outlineVariant: Color(0xFF414753), outlineVariant: Color(0xFF414753),
error: _danger, error: _danger,
onError: Color(0xFF690005), onError: Color(0xFF690005),
errorContainer: Color(0xFF93000A),
onErrorContainer: Color(0xFFFFDAD6),
); );
/// The near-black ground the whole app sits on. /// The near-black ground the whole app sits on.

View File

@@ -108,6 +108,6 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('Speed'), findsOneWidget, reason: 'visible by default'); expect(find.text('Speed'), findsOneWidget, reason: 'visible by default');
expect(find.text('Average speed'), findsNothing, reason: 'hidden by default'); expect(find.text('Max speed'), findsNothing, reason: 'hidden by default');
}); });
} }

View File

@@ -51,15 +51,16 @@ void main() {
reason: 'no two metrics should default to overlapping positions'); reason: 'no two metrics should default to overlapping positions');
}); });
test('the first four metrics (the mockup\'s fixed row) start visible', () { test('the first four metrics (the Map HUD mockup\'s fixed row: Speed/Avg Speed/'
'Dist/Time) start visible', () {
expect(HudWidgetLayout.defaultFor(HudMetric.speed).visible, isTrue); expect(HudWidgetLayout.defaultFor(HudMetric.speed).visible, isTrue);
expect(HudWidgetLayout.defaultFor(HudMetric.avgSpeed).visible, isTrue);
expect(HudWidgetLayout.defaultFor(HudMetric.distance).visible, isTrue); expect(HudWidgetLayout.defaultFor(HudMetric.distance).visible, isTrue);
expect(HudWidgetLayout.defaultFor(HudMetric.elapsedTime).visible, isTrue); expect(HudWidgetLayout.defaultFor(HudMetric.elapsedTime).visible, isTrue);
expect(HudWidgetLayout.defaultFor(HudMetric.maxSpeed).visible, isTrue);
}); });
test('metrics beyond the fixed row start hidden', () { test('metrics beyond the fixed row start hidden', () {
expect(HudWidgetLayout.defaultFor(HudMetric.avgSpeed).visible, isFalse); expect(HudWidgetLayout.defaultFor(HudMetric.maxSpeed).visible, isFalse);
expect(HudWidgetLayout.defaultFor(HudMetric.elevationGain).visible, isFalse); expect(HudWidgetLayout.defaultFor(HudMetric.elevationGain).visible, isFalse);
}); });

View File

@@ -210,17 +210,17 @@ void main() {
testWidgets('a metric switch reflects and writes through to Config', (tester) async { testWidgets('a metric switch reflects and writes through to Config', (tester) async {
await tester.pumpWidget(host()); await tester.pumpWidget(host());
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await scrollTo(tester, const Key('hud-visible-avgSpeed')); await scrollTo(tester, const Key('hud-visible-maxSpeed'));
final initial = tester final initial = tester
.widget<SwitchListTile>(find.byKey(const Key('hud-visible-avgSpeed'))) .widget<SwitchListTile>(find.byKey(const Key('hud-visible-maxSpeed')))
.value; .value;
expect(initial, isFalse, reason: 'metrics beyond the fixed row start hidden'); expect(initial, isFalse, reason: 'metrics beyond the fixed row start hidden');
await tester.tap(find.byKey(const Key('hud-visible-avgSpeed'))); await tester.tap(find.byKey(const Key('hud-visible-maxSpeed')));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(config.hudLayout[HudMetric.avgSpeed]!.visible, isTrue, expect(config.hudLayout[HudMetric.maxSpeed]!.visible, isTrue,
reason: 'the switch must write through to Config immediately, not wait ' reason: 'the switch must write through to Config immediately, not wait '
'for a HUD edit-mode session to end'); 'for a HUD edit-mode session to end');
}); });

View File

@@ -13,6 +13,7 @@ import 'package:rippr/src/ui/activity_display.dart';
import 'package:rippr/src/recording/location_source.dart'; import 'package:rippr/src/recording/location_source.dart';
import 'package:rippr/src/recording/wakelock_controller.dart'; import 'package:rippr/src/recording/wakelock_controller.dart';
import 'package:rippr/src/ui/app_shell.dart'; import 'package:rippr/src/ui/app_shell.dart';
import 'package:rippr/src/ui/components/glass_panel.dart';
import 'package:rippr/src/ui/detail/trip_detail_screen.dart'; import 'package:rippr/src/ui/detail/trip_detail_screen.dart';
import 'package:rippr/src/ui/format.dart'; import 'package:rippr/src/ui/format.dart';
import 'package:rippr/src/ui/record/record_screen.dart'; import 'package:rippr/src/ui/record/record_screen.dart';
@@ -185,6 +186,51 @@ void main() {
reason: 'cancelling must not delete the ride'); reason: 'cancelling must not delete the ride');
}); });
screenTest('tapping START actually starts a trip (UI-05)', (tester) async {
await tester.pumpWidget(host(const RecordScreen(), map: false));
await tester.pumpAndSettle();
expect(await repo.activeTrip(), isNull);
await tester.tap(find.byKey(const Key('start')));
await tester.pump();
expect(await repo.activeTrip(), isNotNull,
reason: 'the new icon-only control bar must still drive the same engine '
'call the old text button did');
});
screenTest('tapping PAUSE and then STOP actually pauses and completes the trip '
'(UI-05)', (tester) async {
await repo.startTrip(1000);
await pumpLive(tester, host(const RecordScreen(), map: false));
await tester.tap(find.byKey(const Key('pause')));
await tester.pump();
final paused = await repo.activeTrip();
expect(paused?.state, TripState.paused);
await tester.tap(find.byKey(const Key('stop')));
await tester.pump();
expect(await repo.activeTrip(), isNull,
reason: 'stopping completes the trip -- it is no longer the active one');
});
screenTest('HUD widgets render over the map, not replacing it (UI-05)',
(tester) async {
await repo.startTrip(1000);
await pumpLive(
tester,
host(
ShellScaffold(currentIndex: 0, onDestinationSelected: (_) {}, child: const RecordScreen()),
),
);
expect(find.byKey(const Key('shell-background-map')), findsOneWidget,
reason: 'the map is still there, underneath the HUD');
expect(find.text('SPEED'), findsOneWidget);
expect(find.text('DISTANCE'), findsOneWidget);
});
screenTest('text is legible against the dark ground', (tester) async { screenTest('text is legible against the dark ground', (tester) async {
// The regression this exists for: removing Compose's Surface left LocalContentColor // The regression this exists for: removing Compose's Surface left LocalContentColor
// black, and a 64sp figure rendered invisibly on a near-black background. No logic // black, and a 64sp figure rendered invisibly on a near-black background. No logic
@@ -349,6 +395,34 @@ void main() {
expect(start.height, 96, expect(start.height, 96,
reason: 'V3-05: 72dp is not enough at speed, with gloves'); reason: 'V3-05: 72dp is not enough at speed, with gloves');
}); });
screenTest('the mounted-mode speed digit is legible against GlassPanel\'s own '
'translucent surface, not just different from the dark ground (UI-05)',
(tester) async {
// UI-05's own named risk: mounted mode was built and tested against an opaque
// Card, not a blurred, translucent GlassPanel -- a light theme through blurred
// content behaves differently than the dark-on-dark case V3-05 originally
// guarded against. This asserts the real contrast ratio, not just "differs from
// the wrong ground" the way the test above does.
await tester.pumpWidget(host(const RecordScreen(), mountedMode: true));
await tester.pumpAndSettle();
final speed = tester.widget<Text>(
find.descendant(of: find.byKey(const Key('speed')), matching: find.text('0')),
);
final panel = tester.widget<GlassPanel>(find.byType(GlassPanel).first);
final mountedColors = ripprMountedTheme().colorScheme;
// GlassPanel fills with `colors.surface` at `GlassPanel.fillOpacity` -- since it
// is a solid, near-opaque fill (not a transparency composited over unknown
// content), the panel's own surface colour is what the text is actually read
// against in practice.
expect(panel.child, isNotNull);
expect(
contrastRatio(speed.style!.color!, mountedColors.surface),
greaterThanOrEqualTo(4.5),
reason: 'AA normal text against GlassPanel\'s mounted-theme surface',
);
});
}); });
group('shell nav bar', () { group('shell nav bar', () {