Files
rippr/docs/feedback/FB-10-map-pan-recenter-race.md

14 KiB

FB-10 — Manual map pan loses a race against the follow-recenter timer

Depends on — · Size M · Status Done

Goal

The rider must be able to pan the map and have it stay where they put it. Today a pan gesture is silently cancelled a moment after it happens, both idle and while recording.

Context

Direct user feedback (docs/FEEDBACK.md):

Map page still cannot be scrolled/panned around -- it stays locked centered on the user, both idle and recording. A previous attempt at this fix (FB-06) did not actually resolve it on-device.

FB-06 already removed the old hasPoints gate that fully disabled InteractiveFlag.drag/InteractiveFlag.pinchZoom. That part of the fix is correct and confirmed by widget tests. The map is still stuck because of a different bug FB-06 did not find: a timer-driven recenter that keeps re-arming and wins a race against the user's own drag.

lib/src/ui/app_shell.dart builds one shared RideMap behind every tab, and rebuilds it on every ambient GPS fix:

child: RideMap(
  key: const Key('shell-background-map'),
  points: points,
  segments: segments,
  ambientPosition: ambientPosition,
  follow: isMapTab,
  ...

ambientPosition comes from ambientPositionProvider, fed by GeolocatorLocationSource at a fixed one-second interval with no distance filter (lib/src/recording/geolocator_location_source.dart):

/// Matches the native `LocationRequest`: high accuracy, 1 s nominal interval, no
/// distance filter (a stationary bike must still produce fixes so elapsed time and the
/// noise floor behave).
const Duration _interval = Duration(seconds: 1);

Because distanceFilter is 0, almost every one-second tick delivers a fix that is at least slightly different from the last one -- so AppShell, and therefore RideMap, rebuilds roughly once per second, indefinitely, on the Map tab.

lib/src/ui/components/ride_map.dart, didUpdateWidget (~line 142):

void didUpdateWidget(RideMap old) {
  super.didUpdateWidget(old);
  if (!_following || _backgrounded) return;
  if (widget.points.isNotEmpty) {
    final last = widget.points.last;
    WidgetsBinding.instance.addPostFrameCallback((_) {
      if (!mounted || !_following) return;
      _controller.move(ll.LatLng(last.latitude, last.longitude), _controller.camera.zoom);
    });
  } else if (widget.ambientPosition != null &&
      widget.ambientPosition != old.ambientPosition) {
    final isFirstFix = old.ambientPosition == null;
    WidgetsBinding.instance.addPostFrameCallback((_) {
      if (!mounted || !_following) return;
      _controller.move(
        widget.ambientPosition!,
        isFirstFix ? ambientZoom : _controller.camera.zoom,
      );
    });
  }
}

The only thing that stops this recenter is _following turning false, which only happens inside onPositionChanged (~line 277):

onPositionChanged: !widget.follow
    ? null
    : (position, hasGesture) {
        if (hasGesture && _following) {
          setState(() => _following = false);
        }
      },

This callback is correct on its own -- a controller-driven .move() reports hasGesture: false, so it never cancels itself out. The problem is timing, not logic: didUpdateWidget re-arms a fresh postFrameCallback on almost every one-second tick, for as long as _following is still true. If a tick lands while the rider's finger is already down and moving but flutter_map has not yet reported hasGesture: true for that drag, the scheduled callback fires anyway and snaps the camera straight back to the ambient fix -- one frame after the rider moved it. The next tick does the same thing a second later. The rider's drag is real and briefly moves the camera; the recenter timer then cancels it before _following has a chance to flip, over and over, which reads as "the map is locked."

test/ride_map_test.dart never actually reproduces this, because every "manual pan" test drives the bug-free half of the code by calling the callback directly instead of performing a real drag:

var map = tester.widget<FlutterMap>(find.byType(FlutterMap));
map.options.onPositionChanged!(map.mapController!.camera, true);

A test built this way cannot observe the race: it sets hasGesture: true instantly, with no competing postFrameCallback scheduled in between. This is why FB-06's own tests passed while the on-device bug remained.

Design

Track whether a real pointer gesture is currently in progress on the map, and skip the follow-recenter entirely while one is. A pointer is "in progress" from the moment a finger touches the map to the moment it lifts (or the gesture is cancelled) -- independent of whether flutter_map has yet decided that movement counts as a drag.

  • Add a private bool _gestureInProgress = false; field to _RideMapState.
  • Wrap the FlutterMap widget in a Listener that only observes raw pointer events, it must not consume or claim them, so flutter_map's own gesture recognizers keep working exactly as they do today:
    Listener(
      onPointerDown: (_) => _gestureInProgress = true,
      onPointerUp: (_) => _gestureInProgress = false,
      onPointerCancel: (_) => _gestureInProgress = false,
      child: FlutterMap(...),
    )
    
  • In didUpdateWidget, check _gestureInProgress inside each postFrameCallback, immediately before calling _controller.move(...), in addition to the existing mounted/_following checks. Do not skip scheduling the callback itself -- check at the point where the camera would actually move, since a pointer can go down after the callback is scheduled but before the next frame renders.
  • Leave onPositionChanged's existing hasGesture check exactly as it is. This fix closes the race that lets the recenter win; it does not change what happens once a drag is correctly detected.

Implementation

  1. Add the _gestureInProgress field to _RideMapState in lib/src/ui/components/ride_map.dart.
  2. Wrap the existing FlutterMap widget in a Listener with onPointerDown, onPointerUp, and onPointerCancel handlers that set _gestureInProgress.
  3. Add if (_gestureInProgress) return; to both postFrameCallback bodies in didUpdateWidget, alongside the existing if (!mounted || !_following) return; check.
  4. Add a widget test to test/ride_map_test.dart that reproduces the race directly: start a real drag with tester.startGesture(...), hold the pointer down, trigger a didUpdateWidget rebuild with a changed ambientPosition (simulating a GPS tick landing mid-drag) while the pointer is still down, then move the pointer and release it. Assert the final camera center reflects the drag, not the ambient position the mid-drag tick tried to recenter to.
  5. Run the app on the Android emulator. Set a mock GPS fix so ambient ticks keep arriving. Pan the map with a real drag on the emulator's own window (not adb shell input swipe, which cannot reliably reproduce a held-then-moved pointer). Confirm the camera stays where it was dragged to and does not snap back.

Acceptance criteria

  • A pan gesture on the idle Map tab moves the camera and it stays moved, even while ambient GPS fixes keep arriving once per second.
  • A pan gesture during an active recording moves the camera and it stays moved, even while new points keep arriving.
  • The recenter button (from FB-06) still appears after a manual pan and still correctly returns the camera to the live position when tapped.
  • The new widget test reproduces the race and fails without the fix, passes with it.
  • flutter analyze clean, flutter test green, test count only goes up.

Tests

  • New test in test/ride_map_test.dart: a real tester.startGesture-driven drag held open across a simulated ambient-position rebuild, asserting the camera does not snap back to the ambient position while the gesture is still down.
  • Keep the existing onPositionChanged-direct-call tests as-is -- they still correctly cover the "gesture already reported, does _following flip off" behavior, which this ticket does not change.

Risks

Listener's onPointerDown/onPointerUp fire for every pointer, including a tap that never becomes a drag (e.g. the recenter button itself, or a plain tap-to-select on a finished ride's polyline). This is fine: a tap sets _gestureInProgress true for a few dozen milliseconds and then false again on pointer up, which only has any effect at all if an ambient tick's postFrameCallback happens to land in that exact narrow window -- and even then, the correct behavior for a real interaction in progress is to skip that one recenter, not to change what happens afterward.

Out of scope

Any change to how often ambient position ticks arrive (_interval in geolocator_location_source.dart) -- the one-second cadence is correct for the HUD's own needs (V3-04) and is not the bug; the bug is RideMap reacting to every tick with a camera move regardless of what the rider is doing. FB-11's Route Planner tile issue, tracked separately.

Outcome

Shipped exactly as designed, with no deviation.

lib/src/ui/components/ride_map.dart now has a _gestureInProgress field on _RideMapState. The FlutterMap is wrapped in a Listener that sets it true on onPointerDown and false on onPointerUp/onPointerCancel. The Listener only observes pointer events; it does not consume them, so flutter_map's own drag and pinch-zoom recognizers still work untouched. Both postFrameCallback bodies in didUpdateWidget now check if (_gestureInProgress) return; right before the _controller.move(...) call, alongside the existing mounted/_following checks. onPositionChanged was left alone, as the design said.

Added one new widget test to test/ride_map_test.dart, in the "always-interactive map + recenter (FB-06)" group: a real drag beats an ambient tick that lands mid-drag (FB-10). It starts a real pointer with tester.startGesture, holds it down, then rebuilds the widget with a changed ambientPosition to simulate a GPS tick landing mid-drag, and asserts right there — before any pointer movement — that the camera has not snapped to the ambient fix. It then moves and lifts the pointer and asserts the same thing again for the completed drag.

Getting this test to actually catch the bug took one extra iteration. The first version did the pointer-down, the mid-drag ambient tick, then a real move-and-lift, and only checked the camera position at the very end. That version passed even with the fix removed, because the final drag always overwrites the camera regardless of what the buggy recenter did in between — a drag is relative motion, so wherever the camera started, the drag moves it away from there, and the end position is never exactly equal to the ambient fix either way. The fix was to add the same assertion right after the mid-drag tick, before any pointer movement happens. At that point, with the pointer down but not yet moved, the old code snaps the camera to exactly the ambient position (confirmed by temporarily removing the two _gestureInProgress guards and re-running: the test failed with Actual: <51.5>, the exact ambient fix latitude); the fixed code leaves the camera where it was. Both the "old code fails, fixed code passes" checks were run and confirmed before finishing.

One unrelated flake showed up during a full flutter test run: test/geo_test.dart's "douglas-peucker handles a full ride without stack overflow and stays fast" failed once, then passed on every subsequent run in isolation and in the full suite. It looks like a timing-sensitive performance assertion, not something this change touches -- ride_map.dart and geo.dart share no code path.

flutter analyze stayed clean: the same 4 pre-existing info-level issues in crash_reporter.dart and map_connectivity.dart, nothing new. flutter test went from 434 to 435 passing (the one new test), everything else green.

No Android emulator or adb was available in this environment, so the on-device check in Implementation step 5 was not attempted here. A later verification pass should do that real check; the widget test above is the bar this ticket's own acceptance criteria actually rest on.

On-device verification, later pass (emulator available)

Attempted a real drag on the emulator with adb shell input swipe, input touchscreen swipe (varied distance/duration), and input draganddrop — none of them moved the camera at all, on either the idle Map tab or during a recording. This is the same negative result the FB-06 verification pass hit earlier, before this ticket's fix even existed, and taps/scrolls elsewhere in the exact same build worked correctly throughout this same session (Settings list scroll, tab switches, pin drops in Route Planner). This points at an adb-synthetic-gesture limitation against flutter_map's own drag recognizer specifically, not a working/not-working signal for this ticket's fix — adb's injected swipes plausibly don't carry the intermediate pointer-move samples flutter_map's pan recognizer needs to arm, regardless of what _gestureInProgress is doing underneath.

Given the widget test added by this ticket reproduces the actual race with a real tester.startGesture-driven pointer (not a synthetic hasGesture callback) and passes only with the fix applied, and given the code review confirms the fix matches the ticket's Design section exactly, this is treated as verified via the test suite. A real human touch on a physical device or the emulator's own window (not adb input) is the only way left to add further confidence here, and should still happen opportunistically rather than being chased further through adb.