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.
154 lines
7.6 KiB
Markdown
154 lines
7.6 KiB
Markdown
# FB-06 — Map is always pannable and zoomable, with a recenter control
|
|
|
|
**Depends on** — · **Size** M · **Status** Not started
|
|
|
|
## 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):
|
|
|
|
```dart
|
|
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):
|
|
|
|
```dart
|
|
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:
|
|
|
|
```dart
|
|
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.
|