Files
rippr/docs/feedback/FB-06-always-interactive-map-recenter.md
uhryniuk 38ad2c75c3 FB-06: always-interactive map with a recenter control
Remove the hasPoints gate on RideMap's InteractionOptions so pan/zoom
work in both the idle and recording states. Add a recenter button
(GlassPanel + IconButton, matching the existing FloatingPill/
_OverflowMenu circular-chrome pattern) shown only when follow is true
and the rider has panned away; tapping it resumes following and jumps
the camera back to the latest point or ambient position without
changing zoom.

Adds 7 widget tests covering the always-on interaction flags and the
recenter button's visibility/behavior. flutter analyze stays clean;
flutter test goes from 412 to 419, all passing.

On-device marker-pulse verification was skipped: no Android tooling
(adb/emulator) is available in this environment.
2026-08-25 00:11:58 -05:00

11 KiB

FB-06 — Map is always pannable and zoomable, with a recenter control

Depends on — · Size M · Status Done

Goal

The rider must be able to pan and zoom the map at all times. Today the map locks all interaction while idle. Add a recenter button. The button must bring the camera back to the rider's live position.

Context

Direct user feedback (docs/FEEDBACK.md):

Map page, both when recording is enabled and disabled, you cannot zoom and move around the map, it's be nice to still be able to move around and see the surrounding area, and then tap a "center" icon to recenter on the user.

User dot on the map should always glow and occilate in size like it does when recording.

lib/src/ui/components/ride_map.dart, _RideMapState.build() (~line 270):

interactionOptions: hasPoints
    ? const InteractionOptions(
        flags: InteractiveFlag.pinchZoom | InteractiveFlag.drag,
      )
    : const InteractionOptions(flags: InteractiveFlag.none),

hasPoints is widget.points.isNotEmpty. The shared background map is idle (no recorded points) any time no trip is recording. In that state InteractiveFlag.none blocks every pan and zoom gesture. This is the exact cause of "when recording is disabled you cannot zoom and move around the map."

During an active recording, hasPoints is true and pan/zoom are already allowed. The existing chase-camera logic already cancels auto-follow on a real gesture — see didUpdateWidget (~line 142) and onPositionChanged (~line 270):

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

Once _following is set to false, nothing ever sets it back to true again — there is no way today to resume following the rider's live position after one manual pan. That is the missing "recenter" affordance the feedback names directly.

_following is a late bool field set once, at State creation (late bool _following = widget.follow;, ~line 128) — it never re-reads widget.follow on a later rebuild. A recenter action must set this field back to true directly (inside _RideMapState, since it owns the field) — there is no way to do this from outside the widget today, and there should not be; this stays internal.

lib/src/ui/components/pulsing_location_marker.dart already animates continuously (AnimationController.repeat() in didChangeDependencies, unless the platform's reduced-motion setting is on) and is the same widget instance used for both the idle (ambient) marker and the recording marker — see ride_map.dart ~line 307:

if (widget.showLocationMarker && (hasPoints || widget.ambientPosition != null))
  MarkerLayer(
    markers: [
      Marker(
        key: const Key('location-marker'),
        point: hasPoints ? ... : widget.ambientPosition!,
        width: 40,
        height: 40,
        child: const PulsingLocationMarker(),
      ),
    ],
  ),

The code already pulses the marker in both states. This ticket's job for the marker is to confirm, on a real device, that the pulse is actually visible in both states — not to assume the report is wrong. If the pulse is confirmed working, say so plainly in the Outcome section and make no code change for it.

Design

  • Always allow pan and zoom. Remove the hasPoints gate on interactionOptions. Use const InteractionOptions(flags: InteractiveFlag.pinchZoom | InteractiveFlag.drag) unconditionally.
  • Add a recenter button inside RideMap. Show it only when widget.follow is true and _following is false — the exact state where the rider panned away from an actively-followed camera. Place it as a small circular icon button, bottom-right of the map, above the tile layer, using Icons.my_location and the app's existing GlassPanel/theme conventions (check lib/src/ui/components/glass_panel.dart for the existing floating-control pattern this app already uses, e.g. FloatingPill, and match it rather than inventing new chrome). Give it key: const Key('recenter-button'). On tap:
    1. Set _following = true.
    2. Move the camera to the latest known position: widget.points.last if widget.points.isNotEmpty, else widget.ambientPosition if it is not null. If neither is available, do nothing (no position to recenter on yet).
    3. Keep the current zoom level — do not force a specific zoom on recenter, since the rider may have deliberately zoomed in or out and recenter should not undo that.
  • Do not show the recenter button when there is no follow mode at all (e.g. a finished-ride static map in Trip Detail, where follow is always false) — the gate above (widget.follow && !_following) already excludes this case correctly.
  • Marker verification. Run the app on the Android emulator. Watch the location marker in the idle state and in the recording state. Confirm the pulse ring expands and fades in both states. Write the result in the Outcome section. Fix the code only if the pulse is genuinely missing in one of the two states — do not change the animation if it is already working.

Implementation

  1. Remove the hasPoints conditional on interactionOptions in ride_map.dart. Use the pinch/drag flags unconditionally.
  2. Add a recenter button widget inside _RideMapState.build(), gated on widget.follow && !_following.
  3. Wire the button's onPressed to set _following = true and move the camera to the latest point or ambient position, keeping the current zoom.
  4. Run the app on the Android emulator. Confirm the marker pulses in both the idle and the recording state. Record the result in the ticket's Outcome section.

Acceptance criteria

  • The map can be panned and zoomed while idle (no active recording).
  • The map can be panned and zoomed while recording.
  • Panning the map while follow is active shows a recenter button.
  • Tapping the recenter button returns the camera to the rider's latest known position and resumes following new position updates.
  • The recenter button does not appear on a static, non-following map (e.g. Trip Detail's finished-ride view).
  • The location marker's pulse animation is confirmed visible on-device in both the idle and the recording state, or fixed if it is not.
  • flutter analyze clean, flutter test green, test count only goes up.

Tests

  • Widget test: RideMap with hasPoints: false (no recorded points) allows a pan gesture to change the camera position — assert the map's InteractionOptions.flags include InteractiveFlag.drag/pinchZoom regardless of points/ambientPosition.
  • Widget test: after a manual pan cancels following (_following set to false via the existing onPositionChanged gesture path — see the pattern already used in test/ride_map_test.dart's "a manual pan cancels ambient following" test), the recenter button (find.byKey(const Key('recenter-button'))) appears.
  • Widget test: before any manual pan (or when follow is false), the recenter button does not appear.
  • Widget test: tapping the recenter button moves the camera back to the latest point (or ambient position) and the button disappears again (following resumed).

Risks

  • None significant. This is additive (a new optional control) and a removed restriction (interaction gating), not a data-model or persistence change.

Out of scope

Any change to the Route Planner's own map (FB-07 covers its remaining issue). Any change to PulsingLocationMarker's own animation code, unless the on-device check in this ticket finds it is genuinely not visible in one of the two states.

Outcome

Implemented the Design section exactly, in lib/src/ui/components/ride_map.dart:

  • Removed the hasPoints gate on interactionOptions. The map now always uses const InteractionOptions(flags: InteractiveFlag.pinchZoom | InteractiveFlag.drag), idle or recording.
  • Added a recenter button (key: const Key('recenter-button')), shown only when widget.follow && !_following — i.e. exactly when the rider has panned away from an actively-followed camera. It is a GlassPanel (borderRadius 999, matching the existing FloatingPill/_OverflowMenu circular-chrome pattern already used in route_planner_screen.dart) wrapping an IconButton with Icons.my_location, positioned bottom-right of the map (Positioned(right: 16, bottom: 16, ...) inside a Stack that now wraps the map).
  • Wired onPressed to a new _recenter() method: sets _following = true, then moves the camera to widget.points.last if points are non-empty, else widget.ambientPosition if non-null, else does nothing. Zoom is left untouched (_controller.camera.zoom is passed straight through to _controller.move), per the design's explicit "keep the current zoom level" requirement.
  • Made no changes to PulsingLocationMarker — see the marker verification note below.

No deviation from the design.

Tests. Added 7 new widget tests to test/ride_map_test.dart under a new 'always-interactive map + recenter (FB-06)' group, covering: pan/zoom flags present with no points (idle) and with points (recording); no recenter button before any manual pan; no recenter button when follow is false even after a pan; a manual pan while following shows the button; tapping it recenters to the ambient position and hides the button again; tapping it recenters to the latest recorded point while recording. flutter analyze is clean (only the 4 pre-existing, unrelated infos in crash_reporter.dart/map_connectivity.dart — nothing new). flutter test is green: confirmed via both the default reporter and --reporter json (cross-checked test names directly) that the suite went from the 412-test baseline to exactly 419 real tests (412 + 7 new), all passing, none skipped or removed.

On-device marker verification: skipped. This environment has no Android tooling available — adb is not on PATH, there is no ANDROID_HOME/SDK, and flutter devices lists only macOS (desktop) and Chrome (web), no emulator. Per the ticket's own risk-mitigation instructions (do not fight an unavailable/unstable emulator at length), the marker-pulse check was not attempted rather than spending time on a device that isn't reachable from this sandbox. PulsingLocationMarker was not modified — per the ticket, a code change there is only warranted if the on-device check finds the pulse genuinely missing, and that check could not be run. The relevant acceptance-criteria checkbox ("location marker's pulse animation is confirmed visible on-device...") is therefore left unchecked/unresolved and should be picked up in a follow-up pass that has emulator access.