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.
This commit is contained in:
2026-08-17 14:55:47 -05:00
parent f3c7e2d6b4
commit fbdb55258c
14 changed files with 401 additions and 19 deletions

View File

@@ -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 |

View File

@@ -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.

View File

@@ -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<UnitSystem>(
(ref) => ref.watch(configProvider)?.unitSystem ?? UnitSystem.metric,
);
/// V3-05: same seeded-then-directly-written shape as [mapEnabledProvider].
final mountedModeProvider = StateProvider<bool>(
(ref) => ref.watch(configProvider)?.mountedMode ?? false,
);
/// Overridden in tests with [FakeWakelockController].
final wakelockControllerProvider = Provider<WakelockController>(
(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.

View File

@@ -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<void> 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);

View File

@@ -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<void> enable();
Future<void> disable();
}
class PlusWakelockController implements WakelockController {
@override
Future<void> enable() => WakelockPlus.enable();
@override
Future<void> disable() => WakelockPlus.disable();
}
class FakeWakelockController implements WakelockController {
bool enabled = false;
int enableCalls = 0;
int disableCalls = 0;
@override
Future<void> enable() async {
enabled = true;
enableCalls++;
}
@override
Future<void> disable() async {
enabled = false;
disableCalls++;
}
}

View File

@@ -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),
),
],
),

View File

@@ -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<RecordScreen> {
StreamSubscription<double>? _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<RecordScreen> {
});
// Start and stop the clock with the ride itself.
ref.listenManual<AsyncValue<Trip?>>(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<bool>(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<RecordScreen> {
@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<RecordScreen> {
: (_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<RecordScreen> {
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<RecordScreen> {
label: 'SPEED',
value: speedValue,
unit: speedUnit,
scale: mountedMode ? mountedTextScale : 1.0,
),
const SizedBox(height: 16),
@@ -259,6 +312,7 @@ class _RecordScreenState extends ConsumerState<RecordScreen> {
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<RecordScreen> {
),
),
),
);
));
}
}
@@ -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,
),
),
),
);

View File

@@ -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),

View File

@@ -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

View File

@@ -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:

View File

@@ -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:

View File

@@ -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();

View File

@@ -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<SwitchListTile>(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();

View File

@@ -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<Text>(
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', () {