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

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