Merge FB-06: always-interactive map with recenter control

# Conflicts:
#	docs/feedback/FB-06-always-interactive-map-recenter.md
This commit is contained in:
2026-08-25 00:14:11 -05:00
3 changed files with 279 additions and 7 deletions

View File

@@ -1,6 +1,6 @@
# FB-06 — Map is always pannable and zoomable, with a recenter control # FB-06 — Map is always pannable and zoomable, with a recenter control
**Depends on** — · **Size** M · **Status** Not started **Depends on** — · **Size** M · **Status** Done
## Goal ## Goal
The rider must be able to pan and zoom the map at all times. Today the map locks all The rider must be able to pan and zoom the map at all times. Today the map locks all
@@ -151,3 +151,49 @@ Outcome section and make no code change for it.
Any change to the Route Planner's own map (FB-07 covers its remaining issue). Any 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 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. 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.

View File

@@ -27,6 +27,7 @@ import '../../domain/models.dart';
import '../../geo/geo.dart' as geo; import '../../geo/geo.dart' as geo;
import '../../tiles/tile_config.dart'; import '../../tiles/tile_config.dart';
import '../theme.dart' show ripprRadiusLarge; import '../theme.dart' show ripprRadiusLarge;
import 'glass_panel.dart';
import 'pulsing_location_marker.dart'; import 'pulsing_location_marker.dart';
import 'skeleton_map_layer.dart'; import 'skeleton_map_layer.dart';
@@ -267,11 +268,12 @@ class _RideMapState extends State<RideMap> with WidgetsBindingObserver {
? (widget.ambientPosition == null ? 2 : ambientZoom) ? (widget.ambientPosition == null ? 2 : ambientZoom)
: (bounds.isDegenerate ? shortRideZoom : maxTileZoom), : (bounds.isDegenerate ? shortRideZoom : maxTileZoom),
maxZoom: maxTileZoom, maxZoom: maxTileZoom,
interactionOptions: hasPoints // FB-06: pan/zoom must always be available, idle or recording -- the old
? const InteractionOptions( // `hasPoints` gate locked the map to `InteractiveFlag.none` whenever no trip
flags: InteractiveFlag.pinchZoom | InteractiveFlag.drag, // was recording, which is exactly the "can't zoom and move around" report.
) interactionOptions: const InteractionOptions(
: const InteractionOptions(flags: InteractiveFlag.none), flags: InteractiveFlag.pinchZoom | InteractiveFlag.drag,
),
onPositionChanged: !widget.follow onPositionChanged: !widget.follow
? null ? null
: (position, hasGesture) { : (position, hasGesture) {
@@ -326,7 +328,43 @@ class _RideMapState extends State<RideMap> with WidgetsBindingObserver {
), ),
); );
return _sized(child: map); // FB-06: the recenter control only ever makes sense once there is a `follow`
// mode to return to and the rider has actually panned away from it -- a static
// (non-following) map, like a finished ride in Trip Detail, never shows this.
final showRecenter = widget.follow && !_following;
return _sized(
child: Stack(
children: [
map,
if (showRecenter)
Positioned(
right: 16,
bottom: 16,
child: GlassPanel(
borderRadius: const BorderRadius.all(Radius.circular(999)),
child: IconButton(
key: const Key('recenter-button'),
icon: const Icon(Icons.my_location),
onPressed: _recenter,
),
),
),
],
),
);
}
/// FB-06: return the camera to the rider's latest known position and resume
/// following new updates. Zoom is left untouched -- the rider may have
/// deliberately zoomed in or out, and recenter should not undo that.
void _recenter() {
final ll.LatLng? target = widget.points.isNotEmpty
? ll.LatLng(widget.points.last.latitude, widget.points.last.longitude)
: widget.ambientPosition;
if (target == null) return;
setState(() => _following = true);
_controller.move(target, _controller.camera.zoom);
} }
/// One polyline per speed run within each segment. /// One polyline per speed run within each segment.

View File

@@ -367,4 +367,192 @@ void main() {
expect(find.byKey(const Key('location-marker')), findsNothing); expect(find.byKey(const Key('location-marker')), findsNothing);
}); });
}); });
group('always-interactive map + recenter (FB-06)', () {
testWidgets('pan/zoom is allowed even with no recorded points (idle map)',
(tester) async {
await tester.pumpWidget(MaterialApp(
theme: ripprTheme(),
home: const Scaffold(
body: RideMap(points: [], segments: [], showEmptyLabel: false),
),
));
await tester.pump();
final map = tester.widget<FlutterMap>(find.byType(FlutterMap));
expect(
map.options.interactionOptions.flags & InteractiveFlag.drag,
InteractiveFlag.drag,
);
expect(
map.options.interactionOptions.flags & InteractiveFlag.pinchZoom,
InteractiveFlag.pinchZoom,
);
});
testWidgets('pan/zoom is allowed while recording (has points)', (tester) async {
final points = [for (var i = 0; i < 4; i++) p(1, i)];
const segments = [Segment(id: 1, tripId: 1, startedAt: 0, endedAt: 1)];
await tester.pumpWidget(MaterialApp(
theme: ripprTheme(),
home: Scaffold(body: RideMap(points: points, segments: segments)),
));
await tester.pump();
final map = tester.widget<FlutterMap>(find.byType(FlutterMap));
expect(
map.options.interactionOptions.flags & InteractiveFlag.drag,
InteractiveFlag.drag,
);
expect(
map.options.interactionOptions.flags & InteractiveFlag.pinchZoom,
InteractiveFlag.pinchZoom,
);
});
testWidgets('no recenter button before any manual pan', (tester) async {
const fix = ll.LatLng(51.0, -114.0);
await tester.pumpWidget(MaterialApp(
theme: ripprTheme(),
home: const Scaffold(
body: RideMap(
points: [],
segments: [],
showEmptyLabel: false,
follow: true,
ambientPosition: fix,
),
),
));
await tester.pump();
expect(find.byKey(const Key('recenter-button')), findsNothing);
});
testWidgets('no recenter button when follow is false, even after a pan',
(tester) async {
const fix = ll.LatLng(51.0, -114.0);
await tester.pumpWidget(MaterialApp(
theme: ripprTheme(),
home: const Scaffold(
body: RideMap(
points: [],
segments: [],
showEmptyLabel: false,
ambientPosition: fix,
),
),
));
await tester.pump();
final map = tester.widget<FlutterMap>(find.byType(FlutterMap));
map.options.onPositionChanged?.call(map.mapController!.camera, true);
await tester.pump();
expect(find.byKey(const Key('recenter-button')), findsNothing);
});
testWidgets('a manual pan while following shows the recenter button',
(tester) async {
const fix = ll.LatLng(51.0, -114.0);
await tester.pumpWidget(MaterialApp(
theme: ripprTheme(),
home: const Scaffold(
body: RideMap(
points: [],
segments: [],
showEmptyLabel: false,
follow: true,
ambientPosition: fix,
),
),
));
await tester.pump();
expect(find.byKey(const Key('recenter-button')), findsNothing);
final map = tester.widget<FlutterMap>(find.byType(FlutterMap));
map.options.onPositionChanged!(map.mapController!.camera, true);
await tester.pump();
expect(find.byKey(const Key('recenter-button')), findsOneWidget);
});
testWidgets(
'tapping recenter moves the camera back to the ambient position and '
'hides the button again (following resumed)', (tester) async {
const fix1 = ll.LatLng(51.0, -114.0);
const fix2 = ll.LatLng(52.0, -115.0);
await tester.pumpWidget(MaterialApp(
theme: ripprTheme(),
home: const Scaffold(
body: RideMap(
points: [],
segments: [],
showEmptyLabel: false,
follow: true,
ambientPosition: fix1,
),
),
));
await tester.pump();
var map = tester.widget<FlutterMap>(find.byType(FlutterMap));
// Simulate the rider manually panning away, then a fresh ambient fix
// arriving while following is off (so the camera does not auto-chase it).
map.options.onPositionChanged!(map.mapController!.camera, true);
map.mapController!.move(fix2, map.mapController!.camera.zoom);
await tester.pump();
expect(find.byKey(const Key('recenter-button')), findsOneWidget);
await tester.tap(find.byKey(const Key('recenter-button')));
await tester.pump();
expect(find.byKey(const Key('recenter-button')), findsNothing);
map = tester.widget<FlutterMap>(find.byType(FlutterMap));
final center = map.mapController!.camera.center;
expect(center.latitude, closeTo(fix1.latitude, 1e-9),
reason: 'recenter should move back to the latest known ambient '
'position, not stay at the panned-to location');
expect(center.longitude, closeTo(fix1.longitude, 1e-9));
});
testWidgets('tapping recenter moves the camera to the latest recorded '
'point when recording', (tester) async {
final points = [for (var i = 0; i < 4; i++) p(1, i)];
const segments = [Segment(id: 1, tripId: 1, startedAt: 0, endedAt: 1)];
await tester.pumpWidget(MaterialApp(
theme: ripprTheme(),
home: Scaffold(
body: RideMap(
points: points,
segments: segments,
follow: true,
),
),
));
await tester.pump();
var map = tester.widget<FlutterMap>(find.byType(FlutterMap));
map.options.onPositionChanged!(map.mapController!.camera, true);
map.mapController!.move(const ll.LatLng(0, 0), map.mapController!.camera.zoom);
await tester.pump();
expect(find.byKey(const Key('recenter-button')), findsOneWidget);
await tester.tap(find.byKey(const Key('recenter-button')));
await tester.pump();
expect(find.byKey(const Key('recenter-button')), findsNothing);
map = tester.widget<FlutterMap>(find.byType(FlutterMap));
final last = points.last;
final center = map.mapController!.camera.center;
expect(center.latitude, closeTo(last.latitude, 1e-9));
expect(center.longitude, closeTo(last.longitude, 1e-9));
});
});
} }