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.
This commit is contained in:
@@ -367,4 +367,192 @@ void main() {
|
||||
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