Files
rippr/docs/feedback/FB-07-route-planner-null-island.md
uhryniuk d9aa412e15 Write FB-06..FB-09 feedback tickets (round two)
Turns the newly-added issues in docs/FEEDBACK.md into four more
self-contained tickets: always-interactive map with a recenter
button, Route Planner's Null Island centering bug, HUD reflow-on-hide,
and title-driven HUD sizing. Sequenced FB-08 before FB-09 since both
rework the same HUD layout files.
2026-08-24 23:26:49 -05:00

7.6 KiB

FB-07 — Route Planner opens on Null Island instead of the rider's real location

Depends on FB-01 (reuses its ambient-location provider) · Size S/M · Status Not started

Goal

A new route, or a route with fewer than two pins, must open the map on the rider's real location. Today it opens on the middle of the ocean.

Context

Direct user feedback (docs/FEEDBACK.md):

Pin drops still do not work at all, the map when placing the pins doesn't render at a all, it's just a blank canvas that you can zoom in and out of but no detail appears. The pins can also be placed but nothing is rendered on the map so you have no clue where they are.

lib/src/ui/routes/route_planner_screen.dart, build() (~line 165):

child: FlutterMap(
  mapController: _mapController,
  options: MapOptions(
    initialCameraFit: _initialFit(waypoints),
    initialCenter: waypoints.isEmpty
        ? const ll.LatLng(0, 0)
        : ll.LatLng(waypoints.first.latitude, waypoints.first.longitude),
    initialZoom: waypoints.length <= 1 ? ambientZoom : maxTileZoom - 3,
    maxZoom: maxTileZoom,
    onTap: (tapPosition, point) {
      repo.addWaypoint(widget.routeId, point.latitude, point.longitude);
      ...

ll.LatLng(0, 0) is Null Island — a single point in the Atlantic Ocean with no land and no map detail at any zoom level. A brand-new route (0 pins), or a route with exactly 1 pin dropped near that point, opens the map centered there. At street-level zoom, an area of open ocean legitimately renders as a flat, featureless expanse — this matches the report exactly: "a blank canvas... no detail appears," and a pin dropped anywhere near that same spot lands in the same empty area, with nothing else on screen to show where it is relative to.

RoutePlannerScreen manages its own FlutterMap directly — it does not use RideMap and does not currently read ambientPositionProvider at all (confirmed by grep: no reference to ambientPositionProvider anywhere in route_planner_screen.dart). FB-01 already built exactly the provider this ticket needs — lib/src/app/providers.dart (~line 66):

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);
});

LocationFix is defined in lib/src/recording/location_source.dart.

initialCenter/initialZoom are read exactly once, at FlutterMap construction — not on every rebuild. The existing comment in this file already documents this constraint for the waypoints stream (~line 158): the screen waits for waypointsAsync.hasValue before building FlutterMap at all, specifically so the first real camera position is correct from the start. The same constraint applies here: reading ambientPositionProvider must happen before FlutterMap is constructed, not patched in afterward with a controller move — mirror the existing wait-for-first-value pattern, do not invent a new one.

Design

  • Watch ambientPositionProvider in build(), the same way ShellScaffold already does (lib/src/ui/app_shell.dart ~line 74):
    final ambientFix = ref.watch(ambientPositionProvider).valueOrNull;
    final ambientPosition = ambientFix == null
        ? null
        : ll.LatLng(ambientFix.latitude, ambientFix.longitude);
    
  • Use ambientPosition for initialCenter when there are fewer than 2 waypoints, falling back to (0, 0) only when no ambient fix is available yet (permission denied, service disabled, or the fix has not arrived yet):
    initialCenter: waypoints.isEmpty
        ? (ambientPosition ?? const ll.LatLng(0, 0))
        : ll.LatLng(waypoints.first.latitude, waypoints.first.longitude),
    
  • Do not block the map on waiting for the ambient fix the way waypointsAsync is blocked on its own first value. A missing ambient fix must fall back to (0, 0) immediately, not show a spinner — the rider must always eventually reach a usable map, even with location permission denied. This matches the existing RideMap fallback behavior in FB-01 exactly (see ride_map.dart's bounds == null branch).
  • Leave the 1-waypoint and 2-plus-waypoint camera logic unchanged — a route that already has a real pin should still center on that pin, not on the ambient position, since the pin is a stronger signal of where the rider actually wants to look.
  • Confirm tiles genuinely render once centered on a real location. After this fix, drop a pin near a real city on the Android emulator (use adb emu geo fix to set a real location, as FB-01's own verification pass did). Take a screenshot. Confirm street-level map detail actually appears, not just a differently-colored blank area. If tiles still do not render even at a real location, that is a second, distinct bug — investigate TileLayer's setup in this file (urlTemplate, tileProvider, mapConnectivityProvider's skeletonMode) before assuming this ticket's fix is sufficient, and document the real cause in the Outcome section.

Implementation

  1. Add final ambientFix = ref.watch(ambientPositionProvider).valueOrNull; and the ambientPosition conversion to build().
  2. Change the initialCenter fallback for the empty-waypoints case from const ll.LatLng(0, 0) to ambientPosition ?? const ll.LatLng(0, 0).
  3. Run the app on the Android emulator. Set a real mock location. Open a new route. Confirm the map opens on that location with visible street detail, not open ocean.

Acceptance criteria

  • A brand-new route (0 pins) opens the map centered on the rider's real location, when a location fix is available.
  • A brand-new route still opens on (0, 0) when no location fix is available (permission denied, service disabled, or no fix yet) — no crash, no infinite spinner.
  • A route with 1 or more pins still centers on the pin, unchanged from today.
  • Dropping a pin near a real city on the emulator shows visible street-level map detail underneath it, confirmed by a real screenshot.
  • flutter analyze clean, flutter test green, test count only goes up.

Tests

  • Widget test: pump RoutePlannerScreen for a route with 0 waypoints, with ambientPositionProvider overridden to a fixed LocationFix — assert the FlutterMap's options.initialCenter matches that fix, not (0, 0).
  • Widget test: pump RoutePlannerScreen for a route with 0 waypoints, with ambientPositionProvider overridden to emit null (no fix available) — assert initialCenter falls back to (0, 0), matching today's existing behavior.
  • Widget test: pump RoutePlannerScreen for a route with 1 real waypoint — assert initialCenter still matches that waypoint's own coordinates, not the ambient position, even when ambientPositionProvider emits a different fix.

Risks

  • If the on-device check in this ticket's Implementation step 3 finds tiles still do not render at a real location, this ticket's fix alone is not sufficient — document the real root cause found and either fix it in this same ticket or state plainly in the Outcome section that a further ticket is needed. Do not claim this ticket is done without confirming real map detail actually appears on a screenshot.

Out of scope

Any change to the Map tab's own map (FB-06 covers its remaining issues). Turn-by-turn route following (V3-09, already deferred elsewhere).