diff --git a/docs/ui-redesign/README.md b/docs/ui-redesign/README.md index ef958af..753480a 100644 --- a/docs/ui-redesign/README.md +++ b/docs/ui-redesign/README.md @@ -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 | diff --git a/docs/ui-redesign/UI-04-customizable-hud-widgets.md b/docs/ui-redesign/UI-04-customizable-hud-widgets.md index 61924c9..e528e5b 100644 --- a/docs/ui-redesign/UI-04-customizable-hud-widgets.md +++ b/docs/ui-redesign/UI-04-customizable-hud-widgets.md @@ -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 diff --git a/docs/ui-redesign/UI-05-map-hud-record-screen.md b/docs/ui-redesign/UI-05-map-hud-record-screen.md index 7c62011..ddd8eb2 100644 --- a/docs/ui-redesign/UI-05-map-hud-record-screen.md +++ b/docs/ui-redesign/UI-05-map-hud-record-screen.md @@ -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. diff --git a/lib/src/hud/hud_metric.dart b/lib/src/hud/hud_metric.dart index 9f0618d..b3d9d78 100644 --- a/lib/src/hud/hud_metric.dart +++ b/lib/src/hud/hud_metric.dart @@ -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; diff --git a/lib/src/hud/hud_widget_layout.dart b/lib/src/hud/hud_widget_layout.dart index 2ba0179..6c6adaf 100644 --- a/lib/src/hud/hud_widget_layout.dart +++ b/lib/src/hud/hud_widget_layout.dart @@ -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, ); diff --git a/lib/src/ui/app_shell.dart b/lib/src/ui/app_shell.dart index fcb30ec..062a77a 100644 --- a/lib/src/ui/app_shell.dart +++ b/lib/src/ui/app_shell.dart @@ -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, ), ), ), diff --git a/lib/src/ui/components/ride_map.dart b/lib/src/ui/components/ride_map.dart index 122d880..ad15ade 100644 --- a/lib/src/ui/components/ride_map.dart +++ b/lib/src/ui/components/ride_map.dart @@ -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 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 createState() => _RideMapState(); } @@ -258,6 +265,21 @@ class _RideMapState extends State 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(), ], ), diff --git a/lib/src/ui/record/record_screen.dart b/lib/src/ui/record/record_screen.dart index fe0be4d..3130f3f 100644 --- a/lib/src/ui/record/record_screen.dart +++ b/lib/src/ui/record/record_screen.dart @@ -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 { } } + /// 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,136 +187,177 @@ class _RecordScreenState extends ConsumerState { // 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( - 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( - padding: const EdgeInsets.all(24), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - 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. - BigStat( - key: const Key('speed'), - label: 'SPEED', - value: speedValue, - unit: speedUnit, - 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. - 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, - ), - ), - 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, - ), - ), - if (ui.isPaused) - const StatRow(label: 'Status', value: 'Paused'), - ], - if (ui.uploadError != null) - StatRow(label: 'Problem', value: ui.uploadError!), - ], + // Settings' existing switch. + return Theme( + data: theme, + child: Scaffold( + backgroundColor: Colors.transparent, + body: SafeArea( + child: Stack( + children: [ + if (ui.isIdle) + Center( + child: GlassPanel( + padding: const EdgeInsets.all(24), + child: Column( + 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. + BigStat( + key: const Key('speed'), + label: 'SPEED', + value: speedValue, + unit: speedUnit, + scale: mountedMode ? mountedTextScale : 1.0, ), - ), + const SizedBox(height: 16), + // 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'), + ], ), - - const SizedBox(height: 28), - _Controls( - ui: ui, - mounted: mountedMode, - onStart: () => _guard(engine.start), - onPause: () => _guard(engine.pause), - onStop: () => _guard(() async => engine.stop()), - onDiscard: () async { - final confirmed = await confirmDialog( - context, - title: 'Discard this ride?', - message: - '${ui.trip?.pointCount ?? 0} points recorded over ' - '${formatDistance(ui.trip?.distanceM ?? 0)} will be ' - 'permanently deleted.', - confirmLabel: 'Discard', - ); - if (confirmed) await _guard(engine.discard); - }, + ), + ) + else + Positioned.fill( + bottom: _controlBarHeight(mountedMode), + child: HudEditOverlay( + metricBuilder: (context, metric) => + _HudMetricValue(metric: metric, ui: ui, units: units), + ), + ), + if (ui.isPaused) + 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) + Positioned( + top: 8, + left: 16, + right: 16, + child: GlassPanel( + padding: const EdgeInsets.all(12), + child: Text(ui.uploadError!), + ), + ), + Positioned( + left: 0, + right: 0, + bottom: 0, + height: _controlBarHeight(mountedMode), + child: _ControlBar( + ui: ui, + onStart: () => _guard(engine.start), + onPause: () => _guard(engine.pause), + onStop: () => _guard(() async => engine.stop()), + onDiscard: () async { + final confirmed = await confirmDialog( + context, + title: 'Discard this ride?', + message: + '${ui.trip?.pointCount ?? 0} points recorded over ' + '${formatDistance(ui.trip?.distanceM ?? 0)} will be ' + 'permanently deleted.', + confirmLabel: '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 { - 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( + final colors = Theme.of(context).colorScheme; + return Row( 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, - ), - ), - ], + Expanded( + flex: 2, + child: _Segment( + keyName: 'resume', + icon: Icons.play_arrow, + background: colors.tertiaryContainer, + foreground: colors.onTertiaryContainer, + onTap: onStart, + trailingBorder: true, + ), + ), + 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, + ), ), ], ); } } -/// 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, +class _Segment extends StatelessWidget { + const _Segment({ required this.keyName, - this.mounted = false, + required this.icon, + required this.background, + required this.foreground, + required this.onTap, + this.label, + this.trailingBorder = false, }); - final String label; - final VoidCallback onPressed; final String keyName; - final bool mounted; + final IconData icon; + final Color background; + final Color foreground; + final VoidCallback onTap; + final String? label; + final bool trailingBorder; @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, + 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( + color: foreground, + fontWeight: FontWeight.bold, + fontSize: 16, + ), + ), + ], + ), + ), ), ), ), ); } - -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, - ), - child: Text( - label, - style: TextStyle( - fontSize: mounted ? 20 : 16, - fontWeight: FontWeight.bold, - ), - ), - ), - ); - } -} diff --git a/lib/src/ui/theme.dart b/lib/src/ui/theme.dart index d7c340b..145ee93 100644 --- a/lib/src/ui/theme.dart +++ b/lib/src/ui/theme.dart @@ -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. diff --git a/test/hud_edit_overlay_test.dart b/test/hud_edit_overlay_test.dart index beead68..328a9e3 100644 --- a/test/hud_edit_overlay_test.dart +++ b/test/hud_edit_overlay_test.dart @@ -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'); }); } diff --git a/test/hud_widget_layout_test.dart b/test/hud_widget_layout_test.dart index 28c8b8f..d0b9ed5 100644 --- a/test/hud_widget_layout_test.dart +++ b/test/hud_widget_layout_test.dart @@ -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); }); diff --git a/test/settings_screen_test.dart b/test/settings_screen_test.dart index 8a9934e..509203c 100644 --- a/test/settings_screen_test.dart +++ b/test/settings_screen_test.dart @@ -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(find.byKey(const Key('hud-visible-avgSpeed'))) + .widget(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'); }); diff --git a/test/widget_test.dart b/test/widget_test.dart index 8479524..053fdee 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -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( + find.descendant(of: find.byKey(const Key('speed')), matching: find.text('0')), + ); + final panel = tester.widget(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', () {