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:
2026-08-24 15:58:45 -05:00
parent 04127d7237
commit 756e3a9f53
12 changed files with 2234 additions and 15 deletions

View File

@@ -14,6 +14,7 @@ library;
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:latlong2/latlong.dart' as ll;
import '../app/providers.dart';
import '../domain/models.dart';
@@ -70,6 +71,13 @@ class ShellScaffold extends ConsumerWidget {
final segments = trip == null
? const <Segment>[]
: ref.watch(liveSegmentsProvider(trip.id)).valueOrNull ?? const <Segment>[];
// FB-01: only watched while idle, so there's never a duplicate GPS consumer during
// an active recording -- the moment a trip starts, this stops being watched at all.
final ambientFix =
trip == null ? ref.watch(ambientPositionProvider).valueOrNull : null;
final ambientPosition = ambientFix == null
? null
: ll.LatLng(ambientFix.latitude, ambientFix.longitude);
return Scaffold(
// Falls back to the ordinary theme background when the map is disabled -- the
@@ -90,6 +98,7 @@ class ShellScaffold extends ConsumerWidget {
key: const Key('shell-background-map'),
points: points,
segments: segments,
ambientPosition: ambientPosition,
follow: isMapTab,
fill: true,
showEmptyLabel: false,

View File

@@ -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(),