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:
@@ -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 |
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
38
lib/src/recording/wakelock_controller.dart
Normal file
38
lib/src/recording/wakelock_controller.dart
Normal 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++;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
16
pubspec.lock
16
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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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', () {
|
||||
|
||||
Reference in New Issue
Block a user