Merge FB-06: always-interactive map with recenter control
# Conflicts: # docs/feedback/FB-06-always-interactive-map-recenter.md
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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));
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user