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:
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user