From fbdb55258c24c0d10565b8beb997a0de6bbd866d Mon Sep 17 00:00:00 2001 From: uhryniuk Date: Mon, 17 Aug 2026 14:55:47 -0500 Subject: [PATCH] V3-05: mounted (handlebar) mode Wake lock via a WakelockController seam (mirrors LocationSource), gated strictly to actual recording and released on stop/discard/navigate-away/dispose. High-contrast ripprMountedTheme() applied only to the record screen. BigStat gained a scale param for the headline figure rather than a blanket TextTheme.apply, which crashes on Material 3's default theme. --- docs/v3/README.md | 2 +- docs/v3/V3-05-mounted-mode.md | 50 ++++++++++- lib/src/app/providers.dart | 11 +++ lib/src/config/config.dart | 9 ++ lib/src/recording/wakelock_controller.dart | 38 +++++++++ lib/src/ui/components/stats.dart | 12 ++- lib/src/ui/record/record_screen.dart | 99 +++++++++++++++++++--- lib/src/ui/settings/settings_screen.dart | 15 ++++ lib/src/ui/theme.dart | 56 +++++++++++- pubspec.lock | 16 ++++ pubspec.yaml | 1 + test/config_test.dart | 13 +++ test/settings_screen_test.dart | 20 +++++ test/widget_test.dart | 78 +++++++++++++++++ 14 files changed, 401 insertions(+), 19 deletions(-) create mode 100644 lib/src/recording/wakelock_controller.dart diff --git a/docs/v3/README.md b/docs/v3/README.md index 64f20ad..655675c 100644 --- a/docs/v3/README.md +++ b/docs/v3/README.md @@ -21,7 +21,7 @@ backup are v4 — see [../BACKLOG.md](../BACKLOG.md). | [V3-02](V3-02-settings-screen.md) | Settings screen | S | — | Done | | [V3-03](V3-03-units.md) | Distance and speed units | S | V3-02 | Done | | [V3-04](V3-04-live-map.md) | Live map on the recording screen | M | — | Done | -| [V3-05](V3-05-mounted-mode.md) | Mounted (handlebar) mode | M | V3-04 | Not started | +| [V3-05](V3-05-mounted-mode.md) | Mounted (handlebar) mode | M | V3-04 | Done | | [V3-06](V3-06-notification-stats.md) | Live stats in the notification | S | — | Not started | | [V3-07](V3-07-route-drawing.md) | Route drawing (pins, straight lines) | M | — | Not started | | [V3-08](V3-08-road-routing.md) | Road-snapped routing and ETA | L | V3-07, V3-01 | Not started | diff --git a/docs/v3/V3-05-mounted-mode.md b/docs/v3/V3-05-mounted-mode.md index 80132d4..e4e9c11 100644 --- a/docs/v3/V3-05-mounted-mode.md +++ b/docs/v3/V3-05-mounted-mode.md @@ -1,6 +1,6 @@ # V3-05 — Mounted (handlebar) mode -**Phase** Live map · **Depends on** V3-04 · **Size** M · **Status** Not started +**Phase** Live map · **Depends on** V3-04 · **Size** M · **Status** Done ## Goal Make the app usable on handlebars in daylight, at speed, with gloves — as an explicit mode @@ -51,3 +51,51 @@ A toggle that changes several things at once: ## Out of scope A dedicated mounted layout with different information architecture — start by scaling what exists and see what the ride teaches. + +## Outcome +Shipped as designed, plus two deviations worth recording. + +`Config.mountedMode` follows the same seeded-`StateProvider` shape as `mapEnabled` and +`unitSystem` (`mountedModeProvider`); a quick-toggle icon button sits next to Settings on +the record screen, and a matching switch was added to `SettingsScreen`. The wake lock is +wrapped in a `WakelockController` seam (`FakeWakelockController` for tests), mirroring +`LocationSource` — the same reasoning: the risk named in this ticket ("a leaked lock +flattens the battery silently") is exactly the kind of thing that has to be provable, not +just plausible. + +**Deviation 1 — no `TextTheme.apply(fontSizeFactor: ...)`.** The design called for scaling +the whole mounted text theme at once; Flutter's `TextStyle.apply` asserts when +`fontSizeFactor != 1.0` meets any style with a null `fontSize`, which Material 3's default +`TextTheme` has for at least one role. Scaling was moved to where it already existed: +`BigStat` gained an explicit `scale` parameter (default `1.0`), applied to the record +screen's headline figure only. `StatRow` and button labels were **not** wired to +`mountedTextScale` — the acceptance criterion is legibility of the number that matters at +a glance, not uniform scaling of every row, and over-scaling the stat card risked +reintroducing the record screen's known overflow-on-short-phones failure mode. + +**Deviation 2 — the mounted theme wraps only the record screen**, via a local `Theme(...)` +widget inside `RecordScreen.build`, not the app's `MaterialApp`. `Theme.of(context)` inside +that build method would still report the ambient dark theme, so `colors` is read off the +locally-built `ThemeData` directly rather than through `Theme.of(context)` — a small trap +worth flagging for V3-16, which will touch this same file. + +Wake lock acquisition is gated on **recording**, not merely mounted-and-idle or +mounted-and-paused, and re-evaluated both on trip-state transitions and on the mounted +toggle itself changing mid-ride. Release happens on stop, on discard (both drive the same +trip-state listener), on navigating away (`dispose`), and defensively whenever mounted mode +is off. One implementation snag: reading `ref` inside `State.dispose()` throws +(`ConsumerStatefulElement` forbids it once unmounting has started) — fixed by capturing the +`WakelockController` once in `initState` via a `late final` field instead of reading it +fresh in `dispose`. + +7 new tests across `widget_test.dart` (wake lock acquired while recording+mounted, +never requested un-mounted, released on ride completion, released on navigating away +while still recording, mounted theme scales the headline and enlarges Start), +`settings_screen_test.dart` (switch writes through to `Config`), and `config_test.dart` +(default/round-trip). `flutter analyze` clean; full suite green (231 tests, up from 224). + +Not done, and explicitly out of scope per the ticket: real daylight/glove legibility +(needs an actual ride — V3-13), and `AppLifecycleState.inactive` handling for an incoming +call — recording is already fully DB-derived and does not observe app lifecycle at all, so +a call cannot stop it; this was verified by reasoning about the existing architecture +rather than a new test, since there is no lifecycle-reactive code path to test. diff --git a/lib/src/app/providers.dart b/lib/src/app/providers.dart index 4308a7b..270a641 100644 --- a/lib/src/app/providers.dart +++ b/lib/src/app/providers.dart @@ -17,6 +17,7 @@ import '../domain/models.dart'; import '../recording/geolocator_location_source.dart'; import '../recording/location_source.dart'; import '../recording/recording_engine.dart'; +import '../recording/wakelock_controller.dart'; import '../telemetry/live_telemetry.dart'; import '../telemetry/telemetry_uploader.dart'; @@ -111,6 +112,16 @@ final unitSystemProvider = StateProvider( (ref) => ref.watch(configProvider)?.unitSystem ?? UnitSystem.metric, ); +/// V3-05: same seeded-then-directly-written shape as [mapEnabledProvider]. +final mountedModeProvider = StateProvider( + (ref) => ref.watch(configProvider)?.mountedMode ?? false, +); + +/// Overridden in tests with [FakeWakelockController]. +final wakelockControllerProvider = Provider( + (ref) => PlusWakelockController(), +); + /// Live path for the recording screen's map (V3-04). `.family` + `autoDispose` so the /// stream tears down the moment the record screen stops watching it — no leftover /// subscription ticking away once a ride ends or the map toggle goes off. diff --git a/lib/src/config/config.dart b/lib/src/config/config.dart index 57bfb0b..93f58a1 100644 --- a/lib/src/config/config.dart +++ b/lib/src/config/config.dart @@ -15,6 +15,7 @@ const _keyEndpoint = 'upload_endpoint'; const _keyDeviceId = 'device_id'; const _keyMapEnabled = 'map_enabled'; const _keyUnitSystem = 'unit_system'; +const _keyMountedMode = 'mounted_mode'; /// Countries that did not adopt metric for everyday distances. Not exhaustive — a /// best-effort default, not a claim of authority. Anyone can override it in Settings. @@ -69,6 +70,14 @@ class Config { : UnitSystem.metric; } + /// V3-05: a mounted phone is a different product from a pocketed one -- kept awake, + /// higher contrast, bigger targets. Off by default; a pocketed ride is still the + /// common case. + bool get mountedMode => _prefs.getBool(_keyMountedMode) ?? false; + + Future setMountedMode(bool enabled) => + _prefs.setBool(_keyMountedMode, enabled); + /// Stable per-install id so a server can distinguish riders in a group. String get deviceId { final existing = _prefs.getString(_keyDeviceId); diff --git a/lib/src/recording/wakelock_controller.dart b/lib/src/recording/wakelock_controller.dart new file mode 100644 index 0000000..f4abb41 --- /dev/null +++ b/lib/src/recording/wakelock_controller.dart @@ -0,0 +1,38 @@ +/// V3-05: mounted mode keeps the screen awake for the whole ride. Wrapped behind a seam +/// -- the same reasoning as [LocationSource] -- so tests can assert the acquire/release +/// path without a real platform plugin, and so a leaked lock (the ticket's named risk) is +/// something a test can actually catch. +library; + +import 'package:wakelock_plus/wakelock_plus.dart'; + +abstract class WakelockController { + Future enable(); + Future disable(); +} + +class PlusWakelockController implements WakelockController { + @override + Future enable() => WakelockPlus.enable(); + + @override + Future disable() => WakelockPlus.disable(); +} + +class FakeWakelockController implements WakelockController { + bool enabled = false; + int enableCalls = 0; + int disableCalls = 0; + + @override + Future enable() async { + enabled = true; + enableCalls++; + } + + @override + Future disable() async { + enabled = false; + disableCalls++; + } +} diff --git a/lib/src/ui/components/stats.dart b/lib/src/ui/components/stats.dart index ce25bbb..257dd97 100644 --- a/lib/src/ui/components/stats.dart +++ b/lib/src/ui/components/stats.dart @@ -17,12 +17,18 @@ class BigStat extends StatelessWidget { required this.label, required this.value, required this.unit, + this.scale = 1.0, }); final String label; final String value; final String unit; + /// V3-05: mounted mode reads bigger, at a glance, at speed. Fixed pixel sizes below + /// (not the ambient text theme) are what a glove-and-visor readout actually is, so + /// scaling has to happen here rather than through `Theme.of(context).textTheme`. + final double scale; + @override Widget build(BuildContext context) { final colors = Theme.of(context).colorScheme; @@ -33,7 +39,7 @@ class BigStat extends StatelessWidget { Text( label, style: TextStyle( - fontSize: 13, + fontSize: 13 * scale, letterSpacing: 2, color: colors.outline, ), @@ -46,7 +52,7 @@ class BigStat extends StatelessWidget { Text( value, style: monoDigits.copyWith( - fontSize: 64, + fontSize: 64 * scale, fontWeight: FontWeight.bold, // Named explicitly. The Compose version rendered black-on-black when the // ambient content colour went missing. @@ -55,7 +61,7 @@ class BigStat extends StatelessWidget { ), Text( ' $unit', - style: TextStyle(fontSize: 18, color: colors.outline), + style: TextStyle(fontSize: 18 * scale, color: colors.outline), ), ], ), diff --git a/lib/src/ui/record/record_screen.dart b/lib/src/ui/record/record_screen.dart index 7fb1406..cd978c4 100644 --- a/lib/src/ui/record/record_screen.dart +++ b/lib/src/ui/record/record_screen.dart @@ -9,10 +9,12 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../app/providers.dart'; import '../../domain/models.dart'; import '../../recording/location_source.dart'; +import '../../recording/wakelock_controller.dart'; import '../../telemetry/telemetry.dart'; import '../components/ride_map.dart'; import '../components/stats.dart'; import '../format.dart'; +import '../theme.dart'; /// Everything the recording screen draws, in one snapshot. class RecordUiState { @@ -62,6 +64,10 @@ class _RecordScreenState extends ConsumerState { StreamSubscription? _speedSub; String? _error; + /// Captured once, rather than read via `ref` in [dispose] -- Riverpod forbids using + /// `ref` once the element is already mid-unmount, which a real leaked-lock test caught. + late final WakelockController _wakelock = ref.read(wakelockControllerProvider); + @override void initState() { super.initState(); @@ -70,14 +76,39 @@ class _RecordScreenState extends ConsumerState { }); // Start and stop the clock with the ride itself. ref.listenManual>(activeTripProvider, (previous, next) { - next.whenData((trip) => _setTicking(trip != null)); + next.whenData((trip) { + _setTicking(trip != null); + _setWakelock(trip?.state == TripState.recording); + }); }, fireImmediately: true); + // Toggling mounted mode mid-ride must take effect immediately, not just on the next + // start -- otherwise turning it on while already recording leaves the screen able to + // sleep for the rest of that ride. + ref.listenManual(mountedModeProvider, (previous, next) { + final recording = + ref.read(activeTripProvider).valueOrNull?.state == TripState.recording; + _setWakelock(recording); + }); + } + + /// V3-05: acquired only while actually recording (not merely mounted-and-idle, and not + /// while paused), and released the moment that stops being true. The named risk is a + /// leaked lock outliving the ride, so this path is exercised harder than the acquire + /// path -- see the widget tests. + void _setWakelock(bool shouldHold) { + if (!ref.read(mountedModeProvider) || !shouldHold) { + _wakelock.disable(); + return; + } + _wakelock.enable(); } @override void dispose() { _ticker?.cancel(); _speedSub?.cancel(); + // Defensive: whatever the ride state was, the lock must not survive this screen. + _wakelock.disable(); super.dispose(); } @@ -115,7 +146,13 @@ class _RecordScreenState extends ConsumerState { @override Widget build(BuildContext context) { - final colors = Theme.of(context).colorScheme; + final mountedMode = ref.watch(mountedModeProvider); + // V3-05: applied only to this screen. Everywhere else is still read at a pocket + // glance, which the dark theme is deliberately tuned for -- so this reads the + // colour scheme straight off a locally-built theme rather than `Theme.of(context)`, + // which would still report the app-wide (dark) theme here. + final theme = mountedMode ? ripprMountedTheme() : Theme.of(context); + final colors = theme.colorScheme; final engine = ref.watch(recordingEngineProvider); final trip = ref.watch(activeTripProvider).valueOrNull; final units = ref.watch(unitSystemProvider); @@ -130,7 +167,7 @@ class _RecordScreenState extends ConsumerState { : (_now - trip.startedAt).clamp(0, 1 << 62), ); - return Scaffold( + return Theme(data: theme, child: Scaffold( // Scrollable, but still centred when there is room. // // With a ride active the card grows to six stat rows, which overflows a short @@ -165,6 +202,21 @@ class _RecordScreenState extends ConsumerState { onPressed: widget.onOpenTrips, child: const Text('Rides'), ), + IconButton( + key: const Key('mounted-mode-toggle'), + icon: Icon(mountedMode + ? Icons.motorcycle + : Icons.motorcycle_outlined), + tooltip: mountedMode + ? 'Mounted mode on' + : 'Mounted mode off', + color: mountedMode ? colors.primary : null, + onPressed: () async { + final next = !mountedMode; + await ref.read(configProvider)?.setMountedMode(next); + ref.read(mountedModeProvider.notifier).state = next; + }, + ), IconButton( key: const Key('open-settings'), icon: const Icon(Icons.settings_outlined), @@ -185,6 +237,7 @@ class _RecordScreenState extends ConsumerState { label: 'SPEED', value: speedValue, unit: speedUnit, + scale: mountedMode ? mountedTextScale : 1.0, ), const SizedBox(height: 16), @@ -259,6 +312,7 @@ class _RecordScreenState extends ConsumerState { const SizedBox(height: 28), _Controls( ui: ui, + mounted: mountedMode, onStart: () => _guard(engine.start), onPause: () => _guard(engine.pause), onStop: () => _guard(() async => engine.stop()), @@ -282,7 +336,7 @@ class _RecordScreenState extends ConsumerState { ), ), ), - ); + )); } } @@ -315,6 +369,7 @@ class _Controls extends StatelessWidget { required this.onPause, required this.onStop, required this.onDiscard, + this.mounted = false, }); final RecordUiState ui; @@ -323,6 +378,9 @@ 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) { @@ -330,15 +388,18 @@ class _Controls extends StatelessWidget { label: 'START RECORDING', onPressed: onStart, keyName: 'start', + mounted: mounted, ); } if (ui.isRecording) { return Column( children: [ - _PrimaryButton(label: 'PAUSE', onPressed: onPause, keyName: 'pause'), + _PrimaryButton( + label: 'PAUSE', onPressed: onPause, keyName: 'pause', mounted: mounted), const SizedBox(height: 12), - _SecondaryButton(label: 'STOP', onPressed: onStop, keyName: 'stop'), + _SecondaryButton( + label: 'STOP', onPressed: onStop, keyName: 'stop', mounted: mounted), ], ); } @@ -347,7 +408,8 @@ class _Controls extends StatelessWidget { // during a live ride invites a gloved mis-tap at speed. return Column( children: [ - _PrimaryButton(label: 'RESUME', onPressed: onStart, keyName: 'resume'), + _PrimaryButton( + label: 'RESUME', onPressed: onStart, keyName: 'resume', mounted: mounted), const SizedBox(height: 12), Row( children: [ @@ -356,6 +418,7 @@ class _Controls extends StatelessWidget { label: 'STOP', onPressed: onStop, keyName: 'stop', + mounted: mounted, ), ), const SizedBox(width: 12), @@ -365,6 +428,7 @@ class _Controls extends StatelessWidget { onPressed: onDiscard, keyName: 'discard', destructive: true, + mounted: mounted, ), ), ], @@ -374,28 +438,34 @@ class _Controls extends StatelessWidget { } } -/// 72dp tall throughout — these get pressed with gloves on. +/// 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: 72, + height: mounted ? 96 : 72, child: FilledButton( key: Key(keyName), onPressed: onPressed, child: Text( label, - style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold), + style: TextStyle( + fontSize: mounted ? 26 : 20, + fontWeight: FontWeight.bold, + ), ), ), ); @@ -407,18 +477,20 @@ class _SecondaryButton extends StatelessWidget { 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: 56, + height: mounted ? 76 : 56, child: OutlinedButton( key: Key(keyName), onPressed: onPressed, @@ -427,7 +499,10 @@ class _SecondaryButton extends StatelessWidget { ), child: Text( label, - style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + style: TextStyle( + fontSize: mounted ? 20 : 16, + fontWeight: FontWeight.bold, + ), ), ), ); diff --git a/lib/src/ui/settings/settings_screen.dart b/lib/src/ui/settings/settings_screen.dart index 58b8f43..448626f 100644 --- a/lib/src/ui/settings/settings_screen.dart +++ b/lib/src/ui/settings/settings_screen.dart @@ -127,6 +127,21 @@ class _SettingsBodyState extends ConsumerState<_SettingsBody> { ), ), const Divider(), + const _SectionHeader('Mounted mode'), + SwitchListTile( + key: const Key('mounted-mode-switch'), + title: const Text('Handlebar mount'), + subtitle: const Text( + 'Keeps the screen awake, switches to a high-contrast daylight theme, and ' + 'enlarges the touch targets for the whole ride. See V3-05.', + ), + value: ref.watch(mountedModeProvider), + onChanged: (value) async { + await widget.config.setMountedMode(value); + ref.read(mountedModeProvider.notifier).state = value; + }, + ), + const Divider(), const _SectionHeader('Sync'), Padding( padding: const EdgeInsets.symmetric(horizontal: 16), diff --git a/lib/src/ui/theme.dart b/lib/src/ui/theme.dart index 3d6328d..85f0285 100644 --- a/lib/src/ui/theme.dart +++ b/lib/src/ui/theme.dart @@ -1,7 +1,9 @@ /// Ported from `com.rippr.ui.theme.RipprTheme`. /// -/// Dark-only by design. This is read at a glance in daylight with a visor down, and a -/// light theme would be actively worse for that. +/// Dark-only by design for a pocketed ride -- read at a glance, in a pocket-glance, at +/// night as much as in daylight. V3-05 adds [ripprMountedTheme], a deliberate exception +/// for the one screen that is actually looked at continuously, in direct daylight, behind +/// a visor -- where the dark ground loses to glare instead of helping. library; import 'package:flutter/material.dart'; @@ -55,6 +57,56 @@ ThemeData ripprTheme() { ); } +// V3-05: mounted colours. Near-white ground, near-black ink -- the inverse tradeoff from +// the pocketed theme above. Read against a visor in direct daylight, a dark ground loses +// to glare in a way a dark theme never has to answer for at a pocket glance. +const _mountedGround = Color(0xFFF5F3EF); +const _mountedInk = Color(0xFF12100D); +const _mountedAccent = Color(0xFFD84315); + +const ripprMountedColors = ColorScheme.light( + primary: _mountedAccent, + onPrimary: Color(0xFFFFFFFF), + secondary: Color(0xFF4A4034), + surface: Color(0xFFFFFFFF), + onSurface: _mountedInk, + surfaceContainerHighest: Color(0xFFE7E2D9), + onSurfaceVariant: _mountedInk, + outline: Color(0xFF706A5E), + error: Color(0xFFB3261E), + onError: Color(0xFFFFFFFF), +); + +const ripprMountedBackground = _mountedGround; + +/// Scales body/headline text up for legibility at a glance, at speed, behind a visor. +/// Kept separate from V3-16's visual identity work -- this is a stopgap born of a real +/// constraint, not a design pass. +const double mountedTextScale = 1.25; + +/// V3-05's mounted mode: high contrast, larger text. Only applied to the record screen +/// while mounted, not the whole app -- everywhere else is still read at a pocket glance. +ThemeData ripprMountedTheme() { + final base = ThemeData( + colorScheme: ripprMountedColors, + useMaterial3: true, + scaffoldBackgroundColor: _mountedGround, + ); + + return base.copyWith( + textTheme: base.textTheme.apply( + bodyColor: _mountedInk, + displayColor: _mountedInk, + ), + appBarTheme: const AppBarTheme( + backgroundColor: _mountedGround, + foregroundColor: _mountedInk, + elevation: 0, + ), + dividerTheme: const DividerThemeData(color: Color(0xFFDAD4C8), space: 1), + ); +} + /// Monospace for anything numeric. /// /// Digits that change every second must not make the layout jitter, which proportional diff --git a/pubspec.lock b/pubspec.lock index cdac83e..8d5d357 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1140,6 +1140,22 @@ packages: url: "https://pub.dev" source: hosted version: "15.2.0" + wakelock_plus: + dependency: "direct main" + description: + name: wakelock_plus + sha256: "7253bca0fcf40d8413ddfcf4d2a1fa0a82475e79be25a4f2c564b695c9351486" + url: "https://pub.dev" + source: hosted + version: "1.7.0" + wakelock_plus_platform_interface: + dependency: transitive + description: + name: wakelock_plus_platform_interface + sha256: "0618d1799f0b28bcf98255b4ee8313e6fc4d38589dc4ee5fe5840d57d1aff6da" + url: "https://pub.dev" + source: hosted + version: "1.6.0" watcher: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index effb8f7..04f2049 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -48,6 +48,7 @@ dependencies: http: ^1.6.0 synchronized: ^3.4.1+1 intl: ^0.20.3 + wakelock_plus: ^1.7.0 dev_dependencies: integration_test: diff --git a/test/config_test.dart b/test/config_test.dart index 83875db..fe2e58c 100644 --- a/test/config_test.dart +++ b/test/config_test.dart @@ -42,6 +42,19 @@ void main() { }); }); + group('mountedMode', () { + test('defaults to false', () async { + final config = await freshConfig(); + expect(config.mountedMode, isFalse); + }); + + test('round-trips through set/get', () async { + final config = await freshConfig(); + await config.setMountedMode(true); + expect(config.mountedMode, isTrue); + }); + }); + group('uploadEndpoint', () { test('defaults to empty, not null or a placeholder', () async { final config = await freshConfig(); diff --git a/test/settings_screen_test.dart b/test/settings_screen_test.dart index def3d68..728ffc6 100644 --- a/test/settings_screen_test.dart +++ b/test/settings_screen_test.dart @@ -74,6 +74,22 @@ void main() { expect(config.unitSystem, UnitSystem.metric); }); + testWidgets('the mounted mode switch writes through to Config (V3-05)', + (tester) async { + await tester.pumpWidget(host()); + await tester.pumpAndSettle(); + + final initial = tester + .widget(find.byKey(const Key('mounted-mode-switch'))) + .value; + expect(initial, isFalse, reason: 'a pocketed ride is still the common case'); + + await tester.tap(find.byKey(const Key('mounted-mode-switch'))); + await tester.pumpAndSettle(); + + expect(config.mountedMode, isTrue); + }); + group('upload endpoint', () { testWidgets('a valid https URL is saved', (tester) async { await tester.pumpWidget(host()); @@ -143,6 +159,10 @@ void main() { expect(find.text(config.deviceId), findsOneWidget); expect(find.byKey(const Key('copy-device-id')), findsOneWidget); + // The list has grown past one screen (V3-05 added a section above this), so the + // control is not necessarily within the default test viewport. + await tester.drag(find.byType(ListView).first, const Offset(0, -400)); + await tester.pumpAndSettle(); // Tapping must not throw even though no real clipboard exists in the test harness. await tester.tap(find.byKey(const Key('copy-device-id'))); await tester.pumpAndSettle(); diff --git a/test/widget_test.dart b/test/widget_test.dart index 9a68241..ed1ec6a 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -11,6 +11,7 @@ import 'package:rippr/src/data/trip_repository.dart'; import 'package:rippr/src/domain/models.dart'; 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/detail/trip_detail_screen.dart'; import 'package:rippr/src/ui/format.dart'; import 'package:rippr/src/ui/record/record_screen.dart'; @@ -74,6 +75,8 @@ void main() { Widget child, { bool map = true, UnitSystem units = UnitSystem.metric, + bool mountedMode = false, + FakeWakelockController? wakelock, }) => ProviderScope( overrides: [ databaseProvider.overrideWithValue(db), @@ -82,6 +85,10 @@ void main() { // tiles, which a widget test cannot serve, and it changes scroll geometry. mapEnabledProvider.overrideWith((ref) => map), unitSystemProvider.overrideWith((ref) => units), + mountedModeProvider.overrideWith((ref) => mountedMode), + wakelockControllerProvider.overrideWithValue( + wakelock ?? FakeWakelockController(), + ), ], child: MaterialApp(theme: ripprTheme(), home: child), ); @@ -270,6 +277,77 @@ void main() { expect(find.byType(TileLayer), findsOneWidget, reason: 'foregrounding again must resume tiles'); }); + + screenTest('mounted mode requests the wake lock while recording and releases ' + 'it on stop (V3-05)', (tester) async { + final wakelock = FakeWakelockController(); + await repo.startTrip(1000); + await pumpLive( + tester, + host(const RecordScreen(), mountedMode: true, wakelock: wakelock), + ); + + expect(wakelock.enabled, isTrue, + reason: 'recording, mounted: the screen must not sleep'); + + await repo.completeTrip(2000); + await tester.pump(); + + expect(wakelock.enabled, isFalse, + reason: 'the lock must not survive the ride ending'); + }); + + screenTest('un-mounted, the wake lock is never requested (V3-05)', + (tester) async { + final wakelock = FakeWakelockController(); + await repo.startTrip(1000); + await pumpLive( + tester, + host(const RecordScreen(), mountedMode: false, wakelock: wakelock), + ); + + expect(wakelock.enableCalls, 0, + reason: 'un-mounted behaviour must be exactly as before this ticket'); + }); + + screenTest('leaving the record screen releases the lock even if the ride is ' + 'still active (V3-05)', (tester) async { + final wakelock = FakeWakelockController(); + await repo.startTrip(1000); + await pumpLive( + tester, + host(const RecordScreen(), mountedMode: true, wakelock: wakelock), + ); + expect(wakelock.enabled, isTrue); + + // Navigating away must not leak the lock -- this is the ticket's named risk: a + // leak flattens the battery silently, after the screen that acquired it is gone. + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(); + + expect(wakelock.enabled, isFalse); + expect(wakelock.disableCalls, greaterThan(0)); + }); + + screenTest('the mounted theme is high-contrast and text scales up (V3-05)', + (tester) async { + 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'), + ), + ); + expect(speed.style?.fontSize, 64 * mountedTextScale); + expect(speed.style?.color, isNot(ripprBackground), + reason: 'still legible, just against a different (lighter) ground'); + + final start = tester.getSize(find.byKey(const Key('start'))); + expect(start.height, 96, + reason: 'V3-05: 72dp is not enough at speed, with gloves'); + }); }); group('trips list', () {