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
FlutterMapwidget in aListenerthat 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_gestureInProgressinside eachpostFrameCallback, immediately before calling_controller.move(...), in addition to the existingmounted/_followingchecks. 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 existinghasGesturecheck 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
- Add the
_gestureInProgressfield to_RideMapStateinlib/src/ui/components/ride_map.dart. - Wrap the existing
FlutterMapwidget in aListenerwithonPointerDown,onPointerUp, andonPointerCancelhandlers that set_gestureInProgress. - Add
if (_gestureInProgress) return;to bothpostFrameCallbackbodies indidUpdateWidget, alongside the existingif (!mounted || !_following) return;check. - Add a widget test to
test/ride_map_test.dartthat reproduces the race directly: start a real drag withtester.startGesture(...), hold the pointer down, trigger adidUpdateWidgetrebuild with a changedambientPosition(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. - 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 analyzeclean,flutter testgreen, test count only goes up.
Tests
- New test in
test/ride_map_test.dart: a realtester.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_followingflip 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.