FB-01: ambient GPS position drives the shared background map while idle
Adds ambientPositionProvider (never calls LocationSource.stop(), only the idempotent start()) and wires it into ShellScaffold whenever no trip is active, so the persistent background map centers and follows the device's live GPS position at street-level zoom instead of sitting at (0,0)/zoom 2 until a recording starts. RideMap gains an ambientPosition param that the existing chase-camera/pan-cancel/location-marker logic falls back to whenever there are no recorded points, with recorded points always taking priority. Also brings in the docs/feedback ticket set (FB-01..FB-05, README, FEEDBACK.md) that this worktree's branch point predated. Adds 10 tests (374 -> 384): RideMap-level ambient centering/chase/pan-cancel/ marker coverage in test/ride_map_test.dart, plus shell-wiring and provider-level (never-calls-stop, mapEnabledProvider-off) coverage in test/widget_test.dart. flutter analyze remains clean.
This commit is contained in:
@@ -47,6 +47,12 @@ const double maxTileZoom = 19.0;
|
||||
/// What a very short ride falls back to, so streets stay visible.
|
||||
const double shortRideZoom = 17.0;
|
||||
|
||||
/// Street-level zoom for the idle, no-recorded-points ambient position (FB-01). A
|
||||
/// distinct name from [shortRideZoom] even though the value happens to match -- one
|
||||
/// means "a very short recorded ride," the other means "no ride at all, just ambient
|
||||
/// GPS."
|
||||
const double ambientZoom = 17.0;
|
||||
|
||||
class RideMap extends StatefulWidget {
|
||||
const RideMap({
|
||||
super.key,
|
||||
@@ -60,12 +66,19 @@ class RideMap extends StatefulWidget {
|
||||
this.skeletonMode = false,
|
||||
this.showLocationMarker = false,
|
||||
this.showAttribution = true,
|
||||
this.ambientPosition,
|
||||
});
|
||||
|
||||
final List<TrackPoint> points;
|
||||
final List<Segment> segments;
|
||||
final double height;
|
||||
|
||||
/// FB-01: the device's current GPS position when there are no recorded [points] to
|
||||
/// chase -- drives the shared background map's "look like Google Maps while idle"
|
||||
/// behaviour. Only meaningful when [points] is empty; a recorded path always takes
|
||||
/// priority over ambient position.
|
||||
final ll.LatLng? ambientPosition;
|
||||
|
||||
/// UI-02: true when `MapConnectivityState` has decided neither the offline cache nor
|
||||
/// the network can currently produce tiles. Swaps `TileLayer` for `SkeletonMapLayer`
|
||||
/// -- markers/polylines are unaffected, since those come from local data, not tiles.
|
||||
@@ -128,14 +141,24 @@ class _RideMapState extends State<RideMap> with WidgetsBindingObserver {
|
||||
@override
|
||||
void didUpdateWidget(RideMap old) {
|
||||
super.didUpdateWidget(old);
|
||||
if (!_following || widget.points.isEmpty || _backgrounded) return;
|
||||
final last = widget.points.last;
|
||||
// A manual camera move already exists once the controller has been used; jumping
|
||||
// straight to the latest fix keeps the map from ever being one flush behind.
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted || !_following) return;
|
||||
_controller.move(ll.LatLng(last.latitude, last.longitude), _controller.camera.zoom);
|
||||
});
|
||||
if (!_following || _backgrounded) return;
|
||||
if (widget.points.isNotEmpty) {
|
||||
final last = widget.points.last;
|
||||
// A manual camera move already exists once the controller has been used; jumping
|
||||
// straight to the latest fix keeps the map from ever being one flush behind.
|
||||
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) {
|
||||
// FB-01: no recorded path yet -- chase the ambient GPS fix instead, the same way
|
||||
// a recording is chased above.
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted || !_following) return;
|
||||
_controller.move(widget.ambientPosition!, _controller.camera.zoom);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -229,10 +252,10 @@ class _RideMapState extends State<RideMap> with WidgetsBindingObserver {
|
||||
maxZoom: maxTileZoom,
|
||||
),
|
||||
initialCenter: bounds == null
|
||||
? const ll.LatLng(0, 0)
|
||||
? (widget.ambientPosition ?? const ll.LatLng(0, 0))
|
||||
: ll.LatLng(bounds.centerLat, bounds.centerLon),
|
||||
initialZoom: bounds == null
|
||||
? 2
|
||||
? (widget.ambientPosition == null ? 2 : ambientZoom)
|
||||
: (bounds.isDegenerate ? shortRideZoom : maxTileZoom),
|
||||
maxZoom: maxTileZoom,
|
||||
interactionOptions: hasPoints
|
||||
@@ -272,15 +295,17 @@ class _RideMapState extends State<RideMap> with WidgetsBindingObserver {
|
||||
tileProvider: widget.tileProvider,
|
||||
),
|
||||
PolylineLayer(polylines: polylines),
|
||||
if (widget.showLocationMarker && hasPoints)
|
||||
if (widget.showLocationMarker && (hasPoints || widget.ambientPosition != null))
|
||||
MarkerLayer(
|
||||
markers: [
|
||||
Marker(
|
||||
key: const Key('location-marker'),
|
||||
point: ll.LatLng(
|
||||
widget.points.last.latitude,
|
||||
widget.points.last.longitude,
|
||||
),
|
||||
point: hasPoints
|
||||
? ll.LatLng(
|
||||
widget.points.last.latitude,
|
||||
widget.points.last.longitude,
|
||||
)
|
||||
: widget.ambientPosition!,
|
||||
width: 40,
|
||||
height: 40,
|
||||
child: const PulsingLocationMarker(),
|
||||
|
||||
Reference in New Issue
Block a user