# 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: ```dart 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`): ```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): ```dart 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): ```dart 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: ```dart var map = tester.widget(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: ```dart 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.