Files
rippr/docs/feedback/FB-01-live-street-level-map.md
uhryniuk cf6b9d1d73 Write FB-01..FB-05 feedback tickets
Turns docs/FEEDBACK.md into five self-contained tickets, each grounded in
exact current code and a concrete implementation approach, so they can be
handed to independent subagents with no prior context.
2026-08-24 15:38:44 -05:00

16 KiB

FB-01 — Live, street-level map everywhere

Depends on — · Size L · Status Not started

Goal

The map is too zoomed out on every screen, and the shared background map has zero location awareness while idle — it should behave like Google/Apple Maps: zoomed in enough to see the street you're on and the streets around it, and continuously tracking the device's real position in real time, whether or not a ride is being recorded.

Context

Direct user feedback (docs/FEEDBACK.md, "All pages" section):

The map is too zoomed out. Start by having the map zoomed in enough where you could easily see what street the user is on and the streets around it. This goes for all screens that use the map, and even if the map is a background it should be updating real time to the person moving so if they aren't recording but they are riding in a car, it will update just like google maps. Essentially we want something that looks identical to google maps or apple maps.

Today's behavior, exactly:

lib/src/ui/components/ride_map.dart, _RideMapState.build() (~lines 200-237):

geo.Bounds? bounds;
if (hasPoints) {
  bounds = geo.bounds([
    for (final p in widget.points) geo.LatLon(p.latitude, p.longitude),
  ]);
}
...
options: MapOptions(
  initialCameraFit: (bounds == null || bounds.isDegenerate)
      ? null
      : CameraFit.bounds(
          bounds: LatLngBounds(...),
          padding: const EdgeInsets.all(24),
          maxZoom: maxTileZoom,
        ),
  initialCenter: bounds == null
      ? const ll.LatLng(0, 0)
      : ll.LatLng(bounds.centerLat, bounds.centerLon),
  initialZoom: bounds == null
      ? 2
      : (bounds.isDegenerate ? shortRideZoom : maxTileZoom),
  maxZoom: maxTileZoom,
  ...

bounds == null happens whenever widget.points is empty — which is exactly the state of the shared background map (ShellScaffold in lib/src/ui/app_shell.dart) any time no trip is actively recording. In that state the map centers on (0, 0) (Null Island) at zoom 2 — the literal opposite of "zoomed in enough to see your street."

The shared background map's data source, ShellScaffold.build() (app_shell.dart ~lines 59-72):

final trip = ref.watch(activeTripProvider).valueOrNull;
final points = trip == null
    ? const <TrackPoint>[]
    : ref.watch(livePointsProvider(trip.id)).valueOrNull ?? const <TrackPoint>[];
final segments = trip == null
    ? const <Segment>[]
    : ref.watch(liveSegmentsProvider(trip.id)).valueOrNull ?? const <Segment>[];

livePointsProvider/liveSegmentsProvider are DB-backed streams of a trip's stored points — they only ever produce data while RecordingEngine is actively writing to a trip. There is no code path anywhere that feeds the background map a raw GPS position independent of an active recording — confirmed by grep: locationSourceProvider (lib/src/app/providers.dart ~line 54) is only ever watched by recordingEngineProvider (~lines 79-89). The background map is blind while idle, which is the root cause of both complaints at once (wrong zoom AND no live tracking) — there's simply no location signal reaching it until a trip starts.

RideMap already has exactly the chase-camera logic this needs, just scoped to recorded points — _RideMapState (ride_map.dart ~lines 113-147):

class _RideMapState extends State<RideMap> with WidgetsBindingObserver {
  final _controller = MapController();
  late bool _following = widget.follow;
  bool _backgrounded = false;

  @override
  void didUpdateWidget(RideMap old) {
    super.didUpdateWidget(old);
    if (!_following || widget.points.isEmpty || _backgrounded) return;
    final last = widget.points.last;
    WidgetsBinding.instance.addPostFrameCallback((_) {
      if (!mounted || !_following) return;
      _controller.move(ll.LatLng(last.latitude, last.longitude), _controller.camera.zoom);
    });
  }
  ...

And the onPositionChanged cancel-on-manual-pan guard (ride_map.dart ~lines 243-252):

onPositionChanged: !widget.follow
    ? null
    : (position, hasGesture) {
        if (hasGesture && _following) {
          setState(() => _following = false);
        }
      },

This is the exact "chase the rider, but a real pan/pinch cancels it" behavior the feedback is asking for — it just needs a second data source (ambient GPS) wired in for when there are no recorded points to chase.

The shared LocationSource instance is process-wide — do not call stop() on it from ambient code. lib/src/app/providers.dart:

final locationSourceProvider = Provider<LocationSource>((ref) {
  final source = GeolocatorLocationSource();
  ref.onDispose(source.dispose);
  return source;
});

One GeolocatorLocationSource for the whole app. Its start() is idempotent (safe to call from two places — lib/src/recording/geolocator_location_source.dart: if (_subscription != null) return; // idempotent), but its stop() is not reference-counted — it unconditionally cancels the one underlying platform subscription:

@override
Future<void> stop() async {
  await _subscription?.cancel();
  _subscription = null;
}

If ambient-mode code ever calls locationSource.stop() (e.g. from a provider's ref.onDispose, or when the Map tab becomes invisible), and a real recording happens to be in progress at that moment, it would silently kill GPS delivery to the active recording — the engine has no way to know its location source was just stopped out from under it by an unrelated consumer. This must not be possible. See Design below.

Design

  • New constant in lib/src/ui/components/ride_map.dart, near maxTileZoom/ shortRideZoom: const double ambientZoom = 17.0; — a distinct name from shortRideZoom even though the value happens to match, since they mean different things (one is "a very short recorded ride," the other is "no ride at all, just ambient GPS").
  • New provider, in lib/src/app/providers.dart:
    /// The device's current position when nothing is being recorded — drives the shared
    /// background map's "look like Google Maps while idle" behavior. Deliberately NOT
    /// gated through `recordingEngineProvider`/`RecordingEngine.start()` — this must work
    /// whether or not a ride is ever recorded. Never calls `LocationSource.stop()`: the
    /// underlying `locationSourceProvider` instance is shared with the recording engine,
    /// and `stop()` is not reference-counted (see FB-01's ticket for why).
    final ambientPositionProvider = StreamProvider.autoDispose<LocationFix?>((ref) async* {
      if (!ref.watch(mapEnabledProvider)) {
        yield null;
        return;
      }
      final source = ref.watch(locationSourceProvider);
      try {
        await source.start(); // idempotent; safe even if a recording already started it
      } on LocationException {
        yield null; // permission denied / service disabled — ambient mode is best-effort
        return;
      }
      yield* source.fixes.map<LocationFix?>((fix) => fix);
      // No `stop()` call, ever, on dispose — see the doc comment above. Only this
      // provider's own subscription to the broadcast `fixes` stream ends; the shared
      // platform subscription is left exactly as it was.
    });
    
    LocationFix and LocationException are both already imported/available via lib/src/recording/location_source.dart (already imported in providers.dart). autoDispose is correct here — this should stop listening the moment nothing watches it (e.g. the app backgrounded, or a trip starts and ShellScaffold stops watching this provider — see next bullet), same lifecycle discipline as every other StreamProvider.autoDispose in this file.
  • ShellScaffold.build() (lib/src/ui/app_shell.dart): only watch ambientPositionProvider while idle, so it's never even subscribed during an active recording:
    final trip = ref.watch(activeTripProvider).valueOrNull;
    final ambientFix = trip == null ? ref.watch(ambientPositionProvider).valueOrNull : null;
    final ambientPosition = ambientFix == null
        ? null
        : ll.LatLng(ambientFix.latitude, ambientFix.longitude);
    
    (needs import 'package:latlong2/latlong.dart' as ll;) then pass ambientPosition: ambientPosition into the RideMap(...) constructor call alongside the existing points/segments/follow/etc. Leave follow: isMapTab and showLocationMarker: isMapTab exactly as they are — ambient following should only run on the visible Map tab, matching the existing recording-follow rationale already in that file's comments.
  • RideMap (lib/src/ui/components/ride_map.dart):
    • Add final ll.LatLng? ambientPosition; to the widget's fields (with a doc comment explaining it's only meaningful when points is empty — a recorded path always takes priority) and thread it through the constructor.
    • In build(), change the bounds == null branch to use it:
      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),
      
      (initialCenter/initialZoom are read once at FlutterMap construction by flutter_map — this only fixes the first placement; live tracking needs the didUpdateWidget change below.)
    • In didUpdateWidget, extend the chase logic to fall back to ambient position when there are no recorded points:
      @override
      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) {
          WidgetsBinding.instance.addPostFrameCallback((_) {
            if (!mounted || !_following) return;
            _controller.move(widget.ambientPosition!, _controller.camera.zoom);
          });
        }
      }
      
    • showLocationMarker: today it only ever reads widget.points.last (MarkerLayer/PulsingLocationMarker at ~lines 275-289). Add a fallback so the pulsing marker also shows on ambient position:
      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(),
            ),
          ],
        ),
      
  • Permission prompt timing: LocationSource.start() requests permission if not already granted (_ensurePermission in geolocator_location_source.dart). This means the very first time a user opens the app (or opens the Map tab) they may see a real OS location-permission dialog before ever pressing Start — this is intentional per the feedback ("even if they aren't recording... it will update just like Google Maps") and matches how a real maps app behaves. A denial must degrade gracefully: catch LocationException and yield null (already shown above) so the map simply falls back to today's (0,0)/zoom-2 behavior rather than crashing or showing an error.

Implementation

  1. Add ambientZoom constant to ride_map.dart.
  2. Add ambientPosition field + constructor param to RideMap.
  3. Update the bounds == null branch of initialCenter/initialZoom.
  4. Update didUpdateWidget to chase ambientPosition when there are no recorded points.
  5. Update the showLocationMarker MarkerLayer to fall back to ambientPosition.
  6. Add ambientPositionProvider to lib/src/app/providers.dart.
  7. Wire it into ShellScaffold.build() in app_shell.dart, gated on trip == null.

Acceptance criteria

  • With no active trip and no ambient fix yet available, the background map still falls back to today's (0,0)/zoom-2 (no regression / no crash on first frame before permission resolves).
  • Once an ambient fix arrives (simulated via FakeLocationSource.emit in tests), the background map centers on it at ambientZoom (street level) and continues to re-center as new fixes arrive, exactly like the existing recorded-path chase behavior.
  • A manual pan/pinch on the Map tab cancels ambient following the same way it cancels recording-follow today (reuses _following/onPositionChanged unchanged).
  • The moment a trip starts recording, the background map switches to following the trip's own recorded points (unchanged priority — hasPoints already wins in didUpdateWidget), and ShellScaffold stops watching ambientPositionProvider entirely (trip == null gate) so there's no duplicate GPS consumer during a ride.
  • Turning mapEnabledProvider off stops the ambient GPS subscription (via the provider's own ref.watch(mapEnabledProvider) short-circuit) — no tile or location request fires while the map is disabled, matching the existing map-toggle guarantee.
  • LocationSource.stop() is never called by any code this ticket adds — grep the diff to confirm.
  • flutter analyze clean, flutter test green, test count only goes up.

Tests

  • Widget test: ShellScaffold with activeTripProvider returning null and ambientPositionProvider overridden/fed a fake LocationFix — assert the background RideMap's effective camera center/zoom reflects the ambient fix (read via MapController.camera or by asserting the constructor args passed to RideMap, whichever is more direct given the existing shell test patterns in test/widget_test.dart's shell nav bar group).
  • Widget test: manually panning the map while an ambient fix is active stops further auto-recentering on subsequent fixes (mirrors any existing recording-follow-cancel test, if one exists — check test/widget_test.dart/test/ride_map_test.dart).
  • Widget/provider test: ambientPositionProvider never calls LocationSource.stop() — drive a FakeLocationSource, dispose the provider (e.g. via container.dispose() in a ProviderContainer-based test), and assert fakeSource.stopCalls == 0.
  • Widget test: activeTripProvider returning a non-null trip means the shell never reads from ambientPositionProvider (e.g. assert no permission/start() call happens when a trip is active and the fake source's startCalls was already incremented by the recording path only).
  • Existing RideMap/shell background tests continue to pass unmodified except where they need a new ambientPosition: null default (should be a no-op given it's an optional/nullable constructor param).

Risks

  • Battery: ambientPositionProvider calling start() means GPS may run continuously any time the Map tab (or the app in general, given the shell map is always mounted) is open and the map is enabled, even with no ride ever recorded. This ticket deliberately does not add a "stop after N minutes idle" or reference-counted shutdown — that's a real product decision better made with actual battery data, not guessed at here. Flag it in the Outcome section rather than solving it silently.
  • Permission timing: as noted above, this may surface a permission dialog earlier in the app's lifecycle than before (first Map-tab view rather than first Start press). Confirmed intentional per the feedback; call out if it feels wrong in practice.

Out of scope

Route Planner's own map (it manages its own FlutterMap directly, not through RideMap) — covered by FB-04, which should reuse ambientZoom from this ticket for zoom-consistency but does not need ambient location following (Route Planner already centers on the route's own waypoints, which is correct). HUD widget changes (FB-03). The idle Speed panel (FB-02) — unrelated, but note FB-02's idle screen will now show a genuinely live, moving map underneath once this ticket lands, which is the whole point.