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

8.9 KiB

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

Depends on — · Size M · Status Not started

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.