192 lines
8.9 KiB
Markdown
192 lines
8.9 KiB
Markdown
# 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<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:
|
|
```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.
|