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

@@ -461,6 +461,85 @@ void main() {
});
});
group('ambient position (FB-01)', () {
screenTest('with no active trip, an ambient GPS fix drives the shared '
'background map', (tester) async {
await tester.pumpWidget(host(
ShellScaffold(
currentIndex: 0,
onDestinationSelected: (_) {},
child: const SizedBox.shrink(),
),
));
// A frame to build the tree, then time for `ambientPositionProvider`'s
// `await source.start()` to resolve and subscribe to `source.fixes`.
await tester.pump();
await tester.pump(const Duration(milliseconds: 50));
// No fix yet: still today's neutral fallback, no marker.
expect(find.byKey(const Key('location-marker')), findsNothing);
source.emitAt(timestamp: 1000, latitude: 51.2, longitude: -114.2);
await tester.pump();
await tester.pump(const Duration(milliseconds: 50));
expect(find.byKey(const Key('location-marker')), findsOneWidget,
reason: 'an ambient fix drives the pulsing marker even with no trip '
'ever recorded');
final map = tester.widget<FlutterMap>(find.byType(FlutterMap));
final center = map.mapController!.camera.center;
expect(center.latitude, closeTo(51.2, 1e-9));
expect(center.longitude, closeTo(-114.2, 1e-9));
// A second fix keeps the map following, exactly like the recorded-path chase.
source.emitAt(timestamp: 2000, latitude: 51.21, longitude: -114.21);
await tester.pump();
await tester.pump(const Duration(milliseconds: 50));
final moved = tester.widget<FlutterMap>(find.byType(FlutterMap));
final movedCenter = moved.mapController!.camera.center;
expect(movedCenter.latitude, closeTo(51.21, 1e-9));
expect(movedCenter.longitude, closeTo(-114.21, 1e-9));
});
screenTest('once a trip starts recording, the shell stops watching ambient '
'GPS -- no duplicate location consumer during a ride', (tester) async {
// Idle first: the ambient provider engages and calls start() at least once
// (activeTripProvider's own first, async emission of `null` briefly reads
// as "idle" too, which is expected -- same shape as `points`/`segments`
// falling back to empty lists until their streams first emit).
await tester.pumpWidget(host(
ShellScaffold(
currentIndex: 0,
onDestinationSelected: (_) {},
child: const SizedBox.shrink(),
),
));
await tester.pump();
await tester.pump(const Duration(milliseconds: 50));
source.emitAt(timestamp: 1000, latitude: 51.0, longitude: -114.0);
await tester.pump();
await tester.pump(const Duration(milliseconds: 50));
expect(find.byKey(const Key('location-marker')), findsOneWidget,
reason: 'sanity check: ambient mode is actually engaged before the '
'trip starts');
final callsWhileIdle = source.startCalls;
expect(callsWhileIdle, greaterThanOrEqualTo(1));
// Now a trip starts recording. Nothing in this test ever taps Start, so the
// only thing that could call `LocationSource.start()` again is
// `ambientPositionProvider` -- and it must not, because `ShellScaffold`
// stops watching it entirely the moment `activeTripProvider` is non-null.
await repo.startTrip(2000);
await tester.pump();
await tester.pump(const Duration(milliseconds: 50));
expect(source.startCalls, callsWhileIdle,
reason: 'once a trip is active, ambientPositionProvider must no '
'longer be watched at all');
});
});
group('trips list', () {
screenTest('empty state explains what to do', (tester) async {
await tester.pumpWidget(host(const TripsScreen()));
@@ -829,4 +908,59 @@ void main() {
expect(await repo.segmentsForTrip(h.tripId), hasLength(1));
});
});
group('ambientPositionProvider (FB-01)', () {
// Provider-level, not widget-level: exercises the provider directly against a
// `ProviderContainer` so its lifecycle (including disposal) can be driven
// precisely, per the ticket's own suggested test shape.
test('never calls LocationSource.stop() -- the underlying source is shared '
'with an active recording and stop() is not reference-counted', () async {
final fakeSource = FakeLocationSource();
final container = ProviderContainer(
overrides: [locationSourceProvider.overrideWithValue(fakeSource)],
);
addTearDown(container.dispose);
final sub = container.listen(ambientPositionProvider, (_, _) {});
// Let the provider's `await source.start()` resolve and subscribe.
await Future<void>.delayed(Duration.zero);
expect(fakeSource.startCalls, 1);
sub.close();
container.dispose();
expect(fakeSource.stopCalls, 0,
reason: 'ambientPositionProvider must only ever call start(), never '
'stop(), on the shared LocationSource');
await fakeSource.dispose();
});
test('turning mapEnabledProvider off never starts location and yields null',
() async {
final fakeSource = FakeLocationSource();
final container = ProviderContainer(
overrides: [
locationSourceProvider.overrideWithValue(fakeSource),
mapEnabledProvider.overrideWith((ref) => false),
],
);
addTearDown(container.dispose);
final values = <AsyncValue<LocationFix?>>[];
final sub = container.listen(
ambientPositionProvider,
(_, next) => values.add(next),
fireImmediately: true,
);
await Future<void>.delayed(Duration.zero);
expect(fakeSource.startCalls, 0,
reason: 'no tile or location request should fire while the map is '
'disabled, matching the existing map-toggle guarantee');
expect(values.last.valueOrNull, isNull);
sub.close();
await fakeSource.dispose();
});
});
}