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-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 |
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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(),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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', () {
|
||||||
|
|||||||
Reference in New Issue
Block a user