Files
samplez/rippr-flutter-src/docs/v3/V3-05-mounted-mode.md
uhryniuk 4c634354bd Refresh the Flutter snapshot: v3 tickets V3-04 through V3-16, plus a fresh installable APK
V3-04 through V3-07, V3-10, V3-16 shipped complete; V3-11/V3-12/V3-14 shipped code-complete pending device/account verification; V3-08/V3-09 deferred behind a new V3-17 (self-hosted OSRM investigation). 316 tests passing, up from 221.

The APK is a fresh release build (debug-signed, no release signing config exists yet) with two build fixes applied: core library desugaring enabled for flutter_local_notifications, and sentry_flutter bumped to 9.27.0 (8.14.2's bundled Kotlin plugin was incompatible with this project's Kotlin 2.4.0 toolchain).
2026-08-19 13:36:04 -05:00

5.6 KiB

V3-05 — Mounted (handlebar) mode

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 rather than an accident.

Context

v1 and v2 were built entirely around "start it, pocket it, stop it". A mounted phone is a different product. Treating it as a mode makes the differences deliberate instead of half-met.

Design

A toggle that changes several things at once:

  • Keep the screen awake for the whole ride (wakelock_plus). Currently the screen sleeps and recording continues; mounted, that is wrong.
  • Sunlight legibility. The dark theme was chosen for glanceability at night and in a pocket-glance. Behind a visor in daylight it is the wrong choice — a high-contrast variant with larger figures is needed. This is not the same as V3-16's visual identity.
  • Larger touch targets still. 72 dp works stopped; at speed with gloves it does not.
  • Interruptions. What happens on an incoming call or a notification — the recording must survive and the screen must come back.

Implementation

  1. mountedMode in Config, surfaced in settings and as a quick toggle on the record screen
  2. wakelock_plus, acquired on start when mounted, released on stop/pause — and released on dispose, or the screen stays lit after the app closes
  3. A high-contrast text scale applied when mounted
  4. Handle AppLifecycleState.inactive (a call arriving) distinctly from paused

Acceptance criteria

  • Mounted: the screen never sleeps during a ride
  • Un-mounted: behaviour is exactly as today
  • The wake lock is released on stop, on discard, and on app exit
  • An incoming call does not stop recording
  • Battery cost of mounted mode is measured and written down (V3-13)

Tests

  • Widget: mounted toggle changes text scale and requests the wake lock (fake the plugin)
  • Widget: the lock is released on stop
  • Manual, on a real bike — legibility in daylight cannot be tested any other way

Risks

  • A leaked wake lock flattens the battery, silently and after the app is closed. Test the release path harder than the acquire path.
  • Legibility is a judgement call that needs a real ride in real sun.

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.