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:
2026-08-25 11:34:05 -05:00
parent 4051416add
commit dde6ec833a
3 changed files with 417 additions and 79 deletions

View File

@@ -133,6 +133,14 @@ class _RideMapState extends State<RideMap> with WidgetsBindingObserver {
/// primitive, and an app resume already triggers a full rebuild anyway.
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
void 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.
WidgetsBinding.instance.addPostFrameCallback((_) {
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);
});
} else if (widget.ambientPosition != null &&
@@ -163,6 +175,8 @@ class _RideMapState extends State<RideMap> with WidgetsBindingObserver {
final isFirstFix = old.ambientPosition == null;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || !_following) return;
// FB-10: see the identical check above -- same race, same reason.
if (_gestureInProgress) return;
_controller.move(
widget.ambientPosition!,
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.
borderRadius:
widget.fill ? BorderRadius.zero : BorderRadius.circular(ripprRadiusLarge),
child: FlutterMap(
mapController: _controller,
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),
// FB-10: raw pointer observation only -- this must not consume or claim the
// event, so flutter_map's own gesture recognizers underneath keep working exactly
// as they do today. A pointer is "in progress" from the instant a finger touches
// down, well before flutter_map's own recognizers decide the movement counts as a
// drag and report `hasGesture: true` -- that gap is the race this closes.
child: Listener(
onPointerDown: (_) => _gestureInProgress = true,
onPointerUp: (_) => _gestureInProgress = false,
onPointerCancel: (_) => _gestureInProgress = false,
child: FlutterMap(
mapController: _controller,
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),
// The clamp. Without it a short ride lands past OSM's max tile zoom
// and renders an empty grid.
maxZoom: maxTileZoom,
),
initialCenter: bounds == null
? (widget.ambientPosition ?? const ll.LatLng(0, 0))
: ll.LatLng(bounds.centerLat, bounds.centerLon),
initialZoom: bounds == null
? (widget.ambientPosition == null ? 2 : ambientZoom)
: (bounds.isDegenerate ? shortRideZoom : maxTileZoom),
maxZoom: maxTileZoom,
// FB-06: pan/zoom must always be available, idle or recording -- the old
// `hasPoints` gate locked the map to `InteractiveFlag.none` whenever no trip
// was recording, which is exactly the "can't zoom and move around" report.
interactionOptions: const InteractionOptions(
flags: InteractiveFlag.pinchZoom | InteractiveFlag.drag,
initialCenter: bounds == null
? (widget.ambientPosition ?? const ll.LatLng(0, 0))
: ll.LatLng(bounds.centerLat, bounds.centerLon),
initialZoom: bounds == null
? (widget.ambientPosition == null ? 2 : ambientZoom)
: (bounds.isDegenerate ? shortRideZoom : maxTileZoom),
maxZoom: maxTileZoom,
// FB-06: pan/zoom must always be available, idle or recording -- the old
// `hasPoints` gate locked the map to `InteractiveFlag.none` whenever no trip
// was recording, which is exactly the "can't zoom and move around" report.
interactionOptions: const InteractionOptions(
flags: InteractiveFlag.pinchZoom | InteractiveFlag.drag,
),
onPositionChanged: !widget.follow
? null
: (position, hasGesture) {
// 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
? null
: (position, hasGesture) {
// 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);
}
},
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(),
],
),
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(),
],
),
);