FB-10: close the ambient-tick-vs-drag race that snapped the map back
A pointer down/up Listener around the FlutterMap now tracks whether a real gesture is in progress, and didUpdateWidget's follow-recenter callbacks skip the camera move while one is -- closing the timing window where a once-per- second ambient GPS tick's postFrameCallback could land after a finger touched the map but before flutter_map reported hasGesture: true, snapping the camera back out from under an in-progress pan.
This commit is contained in:
241
docs/feedback/FB-10-map-pan-recenter-race.md
Normal file
241
docs/feedback/FB-10-map-pan-recenter-race.md
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
# 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:
|
||||||
|
|
||||||
|
```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.
|
||||||
|
|
||||||
|
## 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.
|
||||||
@@ -133,6 +133,14 @@ class _RideMapState extends State<RideMap> with WidgetsBindingObserver {
|
|||||||
/// primitive, and an app resume already triggers a full rebuild anyway.
|
/// primitive, and an app resume already triggers a full rebuild anyway.
|
||||||
bool _backgrounded = false;
|
bool _backgrounded = false;
|
||||||
|
|
||||||
|
/// FB-10: true 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 the
|
||||||
|
/// movement counts as a drag. Closes a race where a once-per-second ambient GPS tick's
|
||||||
|
/// `postFrameCallback` lands after the finger is down but before flutter_map has
|
||||||
|
/// reported `hasGesture: true`, snapping the camera back out from under the rider's
|
||||||
|
/// own in-progress pan.
|
||||||
|
bool _gestureInProgress = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
@@ -149,6 +157,10 @@ class _RideMapState extends State<RideMap> with WidgetsBindingObserver {
|
|||||||
// straight to the latest fix keeps the map from ever being one flush behind.
|
// straight to the latest fix keeps the map from ever being one flush behind.
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
if (!mounted || !_following) return;
|
if (!mounted || !_following) return;
|
||||||
|
// FB-10: a pointer can go down after this callback is scheduled but before the
|
||||||
|
// next frame renders, so the check has to happen here, at the point where the
|
||||||
|
// camera would actually move, not when the callback is scheduled.
|
||||||
|
if (_gestureInProgress) return;
|
||||||
_controller.move(ll.LatLng(last.latitude, last.longitude), _controller.camera.zoom);
|
_controller.move(ll.LatLng(last.latitude, last.longitude), _controller.camera.zoom);
|
||||||
});
|
});
|
||||||
} else if (widget.ambientPosition != null &&
|
} else if (widget.ambientPosition != null &&
|
||||||
@@ -163,6 +175,8 @@ class _RideMapState extends State<RideMap> with WidgetsBindingObserver {
|
|||||||
final isFirstFix = old.ambientPosition == null;
|
final isFirstFix = old.ambientPosition == null;
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
if (!mounted || !_following) return;
|
if (!mounted || !_following) return;
|
||||||
|
// FB-10: see the identical check above -- same race, same reason.
|
||||||
|
if (_gestureInProgress) return;
|
||||||
_controller.move(
|
_controller.move(
|
||||||
widget.ambientPosition!,
|
widget.ambientPosition!,
|
||||||
isFirstFix ? ambientZoom : _controller.camera.zoom,
|
isFirstFix ? ambientZoom : _controller.camera.zoom,
|
||||||
@@ -243,88 +257,98 @@ class _RideMapState extends State<RideMap> with WidgetsBindingObserver {
|
|||||||
// adjacent controls, which osmdroid did until it was explicitly bounded.
|
// adjacent controls, which osmdroid did until it was explicitly bounded.
|
||||||
borderRadius:
|
borderRadius:
|
||||||
widget.fill ? BorderRadius.zero : BorderRadius.circular(ripprRadiusLarge),
|
widget.fill ? BorderRadius.zero : BorderRadius.circular(ripprRadiusLarge),
|
||||||
child: FlutterMap(
|
// FB-10: raw pointer observation only -- this must not consume or claim the
|
||||||
mapController: _controller,
|
// event, so flutter_map's own gesture recognizers underneath keep working exactly
|
||||||
options: MapOptions(
|
// as they do today. A pointer is "in progress" from the instant a finger touches
|
||||||
initialCameraFit: (bounds == null || bounds.isDegenerate)
|
// down, well before flutter_map's own recognizers decide the movement counts as a
|
||||||
// No points yet, or every point at one spot (a parked "ride") --
|
// drag and report `hasGesture: true` -- that gap is the race this closes.
|
||||||
// fitting a degenerate box would zoom to infinity, so centre on a
|
child: Listener(
|
||||||
// neutral or last-known point at a sane street-level zoom instead.
|
onPointerDown: (_) => _gestureInProgress = true,
|
||||||
? null
|
onPointerUp: (_) => _gestureInProgress = false,
|
||||||
: CameraFit.bounds(
|
onPointerCancel: (_) => _gestureInProgress = false,
|
||||||
bounds: LatLngBounds(
|
child: FlutterMap(
|
||||||
ll.LatLng(bounds.minLat, bounds.minLon),
|
mapController: _controller,
|
||||||
ll.LatLng(bounds.maxLat, bounds.maxLon),
|
options: MapOptions(
|
||||||
|
initialCameraFit: (bounds == null || bounds.isDegenerate)
|
||||||
|
// No points yet, or every point at one spot (a parked "ride") --
|
||||||
|
// fitting a degenerate box would zoom to infinity, so centre on a
|
||||||
|
// neutral or last-known point at a sane street-level zoom instead.
|
||||||
|
? null
|
||||||
|
: CameraFit.bounds(
|
||||||
|
bounds: LatLngBounds(
|
||||||
|
ll.LatLng(bounds.minLat, bounds.minLon),
|
||||||
|
ll.LatLng(bounds.maxLat, bounds.maxLon),
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
// The clamp. Without it a short ride lands past OSM's max tile zoom
|
||||||
|
// and renders an empty grid.
|
||||||
|
maxZoom: maxTileZoom,
|
||||||
),
|
),
|
||||||
padding: const EdgeInsets.all(24),
|
initialCenter: bounds == null
|
||||||
// The clamp. Without it a short ride lands past OSM's max tile zoom
|
? (widget.ambientPosition ?? const ll.LatLng(0, 0))
|
||||||
// and renders an empty grid.
|
: ll.LatLng(bounds.centerLat, bounds.centerLon),
|
||||||
maxZoom: maxTileZoom,
|
initialZoom: bounds == null
|
||||||
),
|
? (widget.ambientPosition == null ? 2 : ambientZoom)
|
||||||
initialCenter: bounds == null
|
: (bounds.isDegenerate ? shortRideZoom : maxTileZoom),
|
||||||
? (widget.ambientPosition ?? const ll.LatLng(0, 0))
|
maxZoom: maxTileZoom,
|
||||||
: ll.LatLng(bounds.centerLat, bounds.centerLon),
|
// FB-06: pan/zoom must always be available, idle or recording -- the old
|
||||||
initialZoom: bounds == null
|
// `hasPoints` gate locked the map to `InteractiveFlag.none` whenever no trip
|
||||||
? (widget.ambientPosition == null ? 2 : ambientZoom)
|
// was recording, which is exactly the "can't zoom and move around" report.
|
||||||
: (bounds.isDegenerate ? shortRideZoom : maxTileZoom),
|
interactionOptions: const InteractionOptions(
|
||||||
maxZoom: maxTileZoom,
|
flags: InteractiveFlag.pinchZoom | InteractiveFlag.drag,
|
||||||
// FB-06: pan/zoom must always be available, idle or recording -- the old
|
),
|
||||||
// `hasPoints` gate locked the map to `InteractiveFlag.none` whenever no trip
|
onPositionChanged: !widget.follow
|
||||||
// was recording, which is exactly the "can't zoom and move around" report.
|
? null
|
||||||
interactionOptions: const InteractionOptions(
|
: (position, hasGesture) {
|
||||||
flags: InteractiveFlag.pinchZoom | InteractiveFlag.drag,
|
// Only a real pan/pinch turns following off -- the programmatic
|
||||||
|
// moves this widget makes to chase the rider must not cancel
|
||||||
|
// themselves out.
|
||||||
|
if (hasGesture && _following) {
|
||||||
|
setState(() => _following = false);
|
||||||
|
}
|
||||||
|
},
|
||||||
),
|
),
|
||||||
onPositionChanged: !widget.follow
|
children: [
|
||||||
? null
|
// UI-02: the skeleton replaces the TileLayer entirely rather than sitting on
|
||||||
: (position, hasGesture) {
|
// top of it -- a widget that keeps trying and failing to fetch underneath its
|
||||||
// Only a real pan/pinch turns following off -- the programmatic
|
// own placeholder would be exactly the retry loop the ticket warns against.
|
||||||
// moves this widget makes to chase the rider must not cancel
|
if (widget.skeletonMode)
|
||||||
// themselves out.
|
const SkeletonMapLayer()
|
||||||
if (hasGesture && _following) {
|
// Omitted entirely while backgrounded -- not just visually hidden -- so no
|
||||||
setState(() => _following = false);
|
// tile request can fire off-screen. See the lifecycle observer above.
|
||||||
}
|
else if (!_backgrounded)
|
||||||
},
|
TileLayer(
|
||||||
|
urlTemplate: tileUrlTemplate,
|
||||||
|
subdomains: tileSubdomains,
|
||||||
|
retinaMode: true,
|
||||||
|
userAgentPackageName: tileUserAgent,
|
||||||
|
maxNativeZoom: tileMaxNativeZoom,
|
||||||
|
// Respect the tile host's usage policy: render what is looked at, never
|
||||||
|
// bulk prefetch.
|
||||||
|
panBuffer: 0,
|
||||||
|
tileProvider: widget.tileProvider,
|
||||||
|
),
|
||||||
|
PolylineLayer(polylines: polylines),
|
||||||
|
if (widget.showLocationMarker && (hasPoints || widget.ambientPosition != null))
|
||||||
|
MarkerLayer(
|
||||||
|
markers: [
|
||||||
|
Marker(
|
||||||
|
key: const Key('location-marker'),
|
||||||
|
point: hasPoints
|
||||||
|
? ll.LatLng(
|
||||||
|
widget.points.last.latitude,
|
||||||
|
widget.points.last.longitude,
|
||||||
|
)
|
||||||
|
: widget.ambientPosition!,
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
child: const PulsingLocationMarker(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (widget.showAttribution) const TileAttribution(),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
children: [
|
|
||||||
// UI-02: the skeleton replaces the TileLayer entirely rather than sitting on
|
|
||||||
// top of it -- a widget that keeps trying and failing to fetch underneath its
|
|
||||||
// own placeholder would be exactly the retry loop the ticket warns against.
|
|
||||||
if (widget.skeletonMode)
|
|
||||||
const SkeletonMapLayer()
|
|
||||||
// Omitted entirely while backgrounded -- not just visually hidden -- so no
|
|
||||||
// tile request can fire off-screen. See the lifecycle observer above.
|
|
||||||
else if (!_backgrounded)
|
|
||||||
TileLayer(
|
|
||||||
urlTemplate: tileUrlTemplate,
|
|
||||||
subdomains: tileSubdomains,
|
|
||||||
retinaMode: true,
|
|
||||||
userAgentPackageName: tileUserAgent,
|
|
||||||
maxNativeZoom: tileMaxNativeZoom,
|
|
||||||
// Respect the tile host's usage policy: render what is looked at, never
|
|
||||||
// bulk prefetch.
|
|
||||||
panBuffer: 0,
|
|
||||||
tileProvider: widget.tileProvider,
|
|
||||||
),
|
|
||||||
PolylineLayer(polylines: polylines),
|
|
||||||
if (widget.showLocationMarker && (hasPoints || widget.ambientPosition != null))
|
|
||||||
MarkerLayer(
|
|
||||||
markers: [
|
|
||||||
Marker(
|
|
||||||
key: const Key('location-marker'),
|
|
||||||
point: hasPoints
|
|
||||||
? ll.LatLng(
|
|
||||||
widget.points.last.latitude,
|
|
||||||
widget.points.last.longitude,
|
|
||||||
)
|
|
||||||
: widget.ambientPosition!,
|
|
||||||
width: 40,
|
|
||||||
height: 40,
|
|
||||||
child: const PulsingLocationMarker(),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
if (widget.showAttribution) const TileAttribution(),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -520,6 +520,79 @@ void main() {
|
|||||||
expect(center.longitude, closeTo(fix1.longitude, 1e-9));
|
expect(center.longitude, closeTo(fix1.longitude, 1e-9));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'a real drag beats an ambient tick that lands mid-drag (FB-10)',
|
||||||
|
(tester) async {
|
||||||
|
const fix1 = ll.LatLng(51.0, -114.0);
|
||||||
|
// The ambient tick that lands while the finger is down but before
|
||||||
|
// flutter_map's own gesture recognizer has reported `hasGesture: true` --
|
||||||
|
// this is the exact race the ticket describes.
|
||||||
|
const fix2 = ll.LatLng(51.5, -114.5);
|
||||||
|
|
||||||
|
Widget build(ll.LatLng ambient) => MaterialApp(
|
||||||
|
theme: ripprTheme(),
|
||||||
|
home: Scaffold(
|
||||||
|
body: RideMap(
|
||||||
|
points: const [],
|
||||||
|
segments: const [],
|
||||||
|
showEmptyLabel: false,
|
||||||
|
follow: true,
|
||||||
|
ambientPosition: ambient,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpWidget(build(fix1));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
// Put a real pointer down on the map -- this is what a rider's finger
|
||||||
|
// touching the screen looks like, well before flutter_map decides the
|
||||||
|
// movement counts as a drag.
|
||||||
|
final gesture =
|
||||||
|
await tester.startGesture(tester.getCenter(find.byType(FlutterMap)));
|
||||||
|
addTearDown(() => gesture.removePointer());
|
||||||
|
|
||||||
|
// Simulate a once-per-second ambient GPS tick landing while the pointer
|
||||||
|
// is already down but before it has moved -- exactly the race window
|
||||||
|
// the ticket describes: flutter_map has not yet reported `hasGesture:
|
||||||
|
// true`, so nothing has flipped `_following` off yet.
|
||||||
|
await tester.pumpWidget(build(fix2));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
// The critical assertion: with the pointer still down and untouched by
|
||||||
|
// any real drag, the camera must not have snapped to the ambient tick's
|
||||||
|
// position. Without the fix, the race lets the scheduled
|
||||||
|
// `postFrameCallback` win here and the camera jumps to `fix2` before
|
||||||
|
// the rider's finger has moved at all.
|
||||||
|
var map = tester.widget<FlutterMap>(find.byType(FlutterMap));
|
||||||
|
var center = map.mapController!.camera.center;
|
||||||
|
expect(
|
||||||
|
center.latitude,
|
||||||
|
isNot(closeTo(fix2.latitude, 1e-6)),
|
||||||
|
reason: 'the mid-drag ambient tick must not win the race and snap the '
|
||||||
|
'camera back to the ambient position while the gesture is still '
|
||||||
|
'down',
|
||||||
|
);
|
||||||
|
expect(center.longitude, isNot(closeTo(fix2.longitude, 1e-6)));
|
||||||
|
|
||||||
|
// Now the finger actually moves and lifts -- the real drag completes
|
||||||
|
// normally, and the final position reflects it, not the ambient tick.
|
||||||
|
await gesture.moveBy(const Offset(-100, -100));
|
||||||
|
await tester.pump();
|
||||||
|
await gesture.up();
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
map = tester.widget<FlutterMap>(find.byType(FlutterMap));
|
||||||
|
center = map.mapController!.camera.center;
|
||||||
|
expect(
|
||||||
|
center.latitude,
|
||||||
|
isNot(closeTo(fix2.latitude, 1e-6)),
|
||||||
|
reason: 'the completed drag must still reflect the rider\'s own pan, '
|
||||||
|
'not the ambient position the mid-drag tick tried to recenter to',
|
||||||
|
);
|
||||||
|
expect(center.longitude, isNot(closeTo(fix2.longitude, 1e-6)));
|
||||||
|
});
|
||||||
|
|
||||||
testWidgets('tapping recenter moves the camera to the latest recorded '
|
testWidgets('tapping recenter moves the camera to the latest recorded '
|
||||||
'point when recording', (tester) async {
|
'point when recording', (tester) async {
|
||||||
final points = [for (var i = 0; i < 4; i++) p(1, i)];
|
final points = [for (var i = 0; i < 4; i++) p(1, i)];
|
||||||
|
|||||||
Reference in New Issue
Block a user