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:
@@ -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-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-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-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 |
|
||||
|
||||
@@ -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
|
||||
`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).
|
||||
critically, the first four 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.
|
||||
|
||||
**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` +
|
||||
`visible`, with `clamped()` enforcing a legibility floor and sane ceiling (size clamped
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 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
|
||||
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
|
||||
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.
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -7,16 +7,20 @@
|
||||
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
|
||||
// The first four are, in order, the actual Map HUD mockup's fixed telemetry row --
|
||||
// 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
|
||||
// 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,
|
||||
avgSpeed,
|
||||
distance,
|
||||
elapsedTime,
|
||||
maxSpeed,
|
||||
movingTime,
|
||||
avgSpeed,
|
||||
elevationGain,
|
||||
pointsCaptured;
|
||||
|
||||
|
||||
@@ -113,7 +113,7 @@ class HudWidgetLayout {
|
||||
y: 0.06 + row * (cellHeight + gap),
|
||||
width: cellWidth,
|
||||
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.
|
||||
visible: index < columns,
|
||||
);
|
||||
|
||||
@@ -95,6 +95,9 @@ class ShellScaffold extends ConsumerWidget {
|
||||
showEmptyLabel: false,
|
||||
tileProvider: ref.watch(cachedTileProviderProvider),
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -27,6 +27,7 @@ import '../../domain/models.dart';
|
||||
import '../../geo/geo.dart' as geo;
|
||||
import '../../tiles/tile_config.dart';
|
||||
import '../theme.dart' show ripprRadiusLarge;
|
||||
import 'pulsing_location_marker.dart';
|
||||
import 'skeleton_map_layer.dart';
|
||||
|
||||
export '../../tiles/tile_config.dart';
|
||||
@@ -57,6 +58,7 @@ class RideMap extends StatefulWidget {
|
||||
this.fill = false,
|
||||
this.showEmptyLabel = true,
|
||||
this.skeletonMode = false,
|
||||
this.showLocationMarker = false,
|
||||
});
|
||||
|
||||
final List<TrackPoint> points;
|
||||
@@ -92,6 +94,11 @@ class RideMap extends StatefulWidget {
|
||||
/// following at all.
|
||||
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
|
||||
State<RideMap> createState() => _RideMapState();
|
||||
}
|
||||
@@ -258,6 +265,21 @@ class _RideMapState extends State<RideMap> with WidgetsBindingObserver {
|
||||
tileProvider: widget.tileProvider,
|
||||
),
|
||||
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(),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
/// 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;
|
||||
|
||||
import 'dart:async';
|
||||
@@ -8,9 +13,12 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../app/providers.dart';
|
||||
import '../../domain/models.dart';
|
||||
import '../../hud/hud_metric.dart';
|
||||
import '../../recording/location_source.dart';
|
||||
import '../../recording/wakelock_controller.dart';
|
||||
import '../../telemetry/telemetry.dart';
|
||||
import '../components/glass_panel.dart';
|
||||
import '../components/hud_edit_overlay.dart';
|
||||
import '../components/stats.dart';
|
||||
import '../format.dart';
|
||||
import '../theme.dart';
|
||||
@@ -34,6 +42,14 @@ class RecordUiState {
|
||||
bool get isIdle => trip == null;
|
||||
bool get isRecording => trip?.state == TripState.recording;
|
||||
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 {
|
||||
@@ -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
|
||||
Widget build(BuildContext context) {
|
||||
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
|
||||
// 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
|
||||
// Settings' existing switch (see the ticket's Outcome for why this is a deliberate,
|
||||
// temporary trade rather than an oversight -- UI-05 restyles this screen properly).
|
||||
return Theme(data: theme, child: Scaffold(
|
||||
// Settings' existing switch.
|
||||
return Theme(
|
||||
data: theme,
|
||||
child: Scaffold(
|
||||
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(
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) => SingleChildScrollView(
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(minHeight: constraints.maxHeight),
|
||||
child: Padding(
|
||||
child: Stack(
|
||||
children: [
|
||||
if (ui.isIdle)
|
||||
Center(
|
||||
child: GlassPanel(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Current speed, not max: a max figure only moves when you beat it, which
|
||||
// reads as a frozen screen while riding steadily. This is the 2.0.1 fix and
|
||||
// it must not regress.
|
||||
// Current speed, not max: a max figure only moves when you beat
|
||||
// it, which reads as a frozen screen while riding steadily.
|
||||
// This is the 2.0.1 fix and it must not regress.
|
||||
BigStat(
|
||||
key: const Key('speed'),
|
||||
label: 'SPEED',
|
||||
@@ -196,69 +213,52 @@ class _RecordScreenState extends ConsumerState<RecordScreen> {
|
||||
scale: mountedMode ? mountedTextScale : 1.0,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
Card(
|
||||
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.
|
||||
// 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: 'Last ride',
|
||||
value: 'See Rides',
|
||||
),
|
||||
] else ...[
|
||||
StatRow(
|
||||
label: 'Distance',
|
||||
value: formatDistance(
|
||||
ui.trip!.distanceM,
|
||||
unit: units,
|
||||
const StatRow(label: 'Last ride', value: 'See Rides'),
|
||||
],
|
||||
),
|
||||
),
|
||||
StatRow(
|
||||
label: 'Elapsed',
|
||||
value: formatDuration(ui.elapsedMillis),
|
||||
),
|
||||
StatRow(
|
||||
label: 'Moving time',
|
||||
value: formatDuration(ui.trip!.movingMillis),
|
||||
),
|
||||
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,
|
||||
)
|
||||
else
|
||||
Positioned.fill(
|
||||
bottom: _controlBarHeight(mountedMode),
|
||||
child: HudEditOverlay(
|
||||
metricBuilder: (context, metric) =>
|
||||
_HudMetricValue(metric: metric, ui: ui, units: units),
|
||||
),
|
||||
),
|
||||
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)
|
||||
StatRow(label: 'Problem', value: ui.uploadError!),
|
||||
],
|
||||
Positioned(
|
||||
top: 8,
|
||||
left: 16,
|
||||
right: 16,
|
||||
child: GlassPanel(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Text(ui.uploadError!),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 28),
|
||||
_Controls(
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
height: _controlBarHeight(mountedMode),
|
||||
child: _ControlBar(
|
||||
ui: ui,
|
||||
mounted: mountedMode,
|
||||
onStart: () => _guard(engine.start),
|
||||
onPause: () => _guard(engine.pause),
|
||||
onStop: () => _guard(() async => engine.stop()),
|
||||
@@ -275,26 +275,89 @@ class _RecordScreenState extends ConsumerState<RecordScreen> {
|
||||
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 {
|
||||
const _Controls({
|
||||
final HudMetric metric;
|
||||
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.onStart,
|
||||
required this.onPause,
|
||||
required this.onStop,
|
||||
required this.onDiscard,
|
||||
this.mounted = false,
|
||||
});
|
||||
|
||||
final RecordUiState ui;
|
||||
@@ -303,133 +366,143 @@ class _Controls extends StatelessWidget {
|
||||
final VoidCallback onStop;
|
||||
final VoidCallback onDiscard;
|
||||
|
||||
/// V3-05: 72dp works stopped; at speed, with gloves, it does not.
|
||||
final bool mounted;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (ui.isIdle) {
|
||||
return _PrimaryButton(
|
||||
label: 'START RECORDING',
|
||||
onPressed: onStart,
|
||||
return _Segment(
|
||||
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) {
|
||||
return Column(
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
return Row(
|
||||
children: [
|
||||
_PrimaryButton(
|
||||
label: 'PAUSE', onPressed: onPause, keyName: 'pause', mounted: mounted),
|
||||
const SizedBox(height: 12),
|
||||
_SecondaryButton(
|
||||
label: 'STOP', onPressed: onStop, keyName: 'stop', mounted: mounted),
|
||||
Expanded(
|
||||
child: _Segment(
|
||||
keyName: 'pause',
|
||||
icon: Icons.pause,
|
||||
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
|
||||
// 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;
|
||||
return SizedBox(
|
||||
height: mounted ? 76 : 56,
|
||||
child: OutlinedButton(
|
||||
key: Key(keyName),
|
||||
onPressed: onPressed,
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: destructive ? colors.error : colors.onSurface,
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: _Segment(
|
||||
keyName: 'resume',
|
||||
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(
|
||||
fontSize: mounted ? 20 : 16,
|
||||
color: foreground,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,6 +45,12 @@ const ripprColors = ColorScheme.dark(
|
||||
secondary: Color(0xFFAEC7F6),
|
||||
tertiary: _referenceOrange,
|
||||
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,
|
||||
onSurface: _ink,
|
||||
surfaceContainerHighest: Color(0xFF353534),
|
||||
@@ -57,6 +63,8 @@ const ripprColors = ColorScheme.dark(
|
||||
outlineVariant: Color(0xFF414753),
|
||||
error: _danger,
|
||||
onError: Color(0xFF690005),
|
||||
errorContainer: Color(0xFF93000A),
|
||||
onErrorContainer: Color(0xFFFFDAD6),
|
||||
);
|
||||
|
||||
/// The near-black ground the whole app sits on.
|
||||
|
||||
@@ -108,6 +108,6 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
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');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -51,15 +51,16 @@ void main() {
|
||||
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.avgSpeed).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.maxSpeed).visible, isFalse);
|
||||
expect(HudWidgetLayout.defaultFor(HudMetric.elevationGain).visible, isFalse);
|
||||
});
|
||||
|
||||
|
||||
@@ -210,17 +210,17 @@ void main() {
|
||||
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'));
|
||||
await scrollTo(tester, const Key('hud-visible-maxSpeed'));
|
||||
|
||||
final initial = tester
|
||||
.widget<SwitchListTile>(find.byKey(const Key('hud-visible-avgSpeed')))
|
||||
.widget<SwitchListTile>(find.byKey(const Key('hud-visible-maxSpeed')))
|
||||
.value;
|
||||
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();
|
||||
|
||||
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 '
|
||||
'for a HUD edit-mode session to end');
|
||||
});
|
||||
|
||||
@@ -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/wakelock_controller.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/format.dart';
|
||||
import 'package:rippr/src/ui/record/record_screen.dart';
|
||||
@@ -185,6 +186,51 @@ void main() {
|
||||
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 {
|
||||
// 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
|
||||
@@ -349,6 +395,34 @@ void main() {
|
||||
expect(start.height, 96,
|
||||
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', () {
|
||||
|
||||
Reference in New Issue
Block a user