fix(dashboard): ensure Touring map follows rider and supports recenter (#48)

This commit is contained in:
Developer 2026-10-16 22:51:18 -04:00
parent 2ca069fdb9
commit 1d702102fc
5 changed files with 494 additions and 58 deletions

View File

@ -5,15 +5,18 @@ import '../../../core/theme/app_typography.dart';
import '../../../services/alerts/road_alert_models.dart';
import '../../../services/alerts/road_alert_service.dart';
import '../../../services/map/map_provider_interface.dart';
import '../../../services/map/map_providers.dart';
import '../../../services/map/unified_map_view.dart';
import '../../../services/navigation/navigation_session_service.dart';
import '../../../services/telemetry_service.dart';
import '../../rides/ride_state_machine.dart';
/// Embedded tactical map panel for Touring mode and responsive dashboard views.
///
/// Subscribes to vehicle coordinates and heading via [telemetryStreamProvider]
/// and alert pins via [activeAlertsStreamProvider].
class DashboardMapPanel extends ConsumerWidget {
/// and alert pins via [activeAlertsStreamProvider], maintains camera follow
/// state, and provides a recenter control after manual pan.
class DashboardMapPanel extends ConsumerStatefulWidget {
final double zoom;
const DashboardMapPanel({
@ -22,7 +25,43 @@ class DashboardMapPanel extends ConsumerWidget {
});
@override
Widget build(BuildContext context, WidgetRef ref) {
ConsumerState<DashboardMapPanel> createState() => _DashboardMapPanelState();
}
class _DashboardMapPanelState extends ConsumerState<DashboardMapPanel> {
static const AppLatLng _defaultFallbackCenter = AppLatLng(37.7749, -122.4194);
AppLatLng? _lastKnownRiderPos;
@override
void initState() {
super.initState();
// Ensure follow mode is active whenever the Touring map panel is mounted.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
ref.read(mapFollowStateProvider.notifier).recenter();
}
});
}
bool _isValidCoord(double? lat, double? lng) {
return lat != null &&
lng != null &&
lat.isFinite &&
lng.isFinite &&
(lat != 0.0 || lng != 0.0);
}
@override
Widget build(BuildContext context) {
// Re-enable camera follow automatically when a ride session starts.
ref.listen<RideSession>(rideStateMachineProvider, (previous, next) {
if (previous?.state != RideState.riding &&
next.state == RideState.riding &&
mounted) {
ref.read(mapFollowStateProvider.notifier).recenter();
}
});
final vehicleCoords = ref.watch(
telemetryStreamProvider.select(
(s) => (
@ -34,19 +73,18 @@ class DashboardMapPanel extends ConsumerWidget {
),
);
final centerLat = (vehicleCoords.lat != null && vehicleCoords.lat != 0.0)
? vehicleCoords.lat!
: 37.7749;
final centerLng = (vehicleCoords.lng != null && vehicleCoords.lng != 0.0)
? vehicleCoords.lng!
: -122.4194;
final hasCoordinates = _isValidCoord(vehicleCoords.lat, vehicleCoords.lng);
if (hasCoordinates) {
_lastKnownRiderPos = AppLatLng(vehicleCoords.lat!, vehicleCoords.lng!);
}
final activeCenter = _lastKnownRiderPos ?? _defaultFallbackCenter;
final isFresh = vehicleCoords.isGpsFresh;
final hasCoordinates = vehicleCoords.lat != null && vehicleCoords.lat != 0.0;
final isFollowMode = ref.watch(mapFollowStateProvider);
final vehicleMarker = AppMapMarker(
id: 'dash_vehicle',
position: AppLatLng(centerLat, centerLng),
position: activeCenter,
heading: vehicleCoords.heading,
iconWidget: Icon(
Icons.navigation,
@ -64,7 +102,7 @@ class DashboardMapPanel extends ConsumerWidget {
for (final alert in activeAlerts) {
mapMarkers.add(
AppMapMarker(
id: 'dash_alert__',
id: 'dash_alert_${alert.id}',
position: AppLatLng(alert.latitude, alert.longitude),
heading: alert.heading ?? 0.0,
label: alert.category.displayName,
@ -116,14 +154,14 @@ class DashboardMapPanel extends ConsumerWidget {
Positioned.fill(
child: UnifiedMapView(
key: const ValueKey('dash_map_x'),
center: AppLatLng(centerLat, centerLng),
zoom: zoom,
center: activeCenter,
zoom: widget.zoom,
bearing: 0.0,
markers: mapMarkers,
polylines: polylines,
),
),
if (!isFresh && hasCoordinates)
if (!isFresh && (hasCoordinates || _lastKnownRiderPos != null))
Positioned(
top: 10,
left: 10,
@ -154,6 +192,56 @@ class DashboardMapPanel extends ConsumerWidget {
),
),
),
if (!isFollowMode)
Positioned(
bottom: 10,
right: 10,
child: Material(
color: Colors.black.withValues(alpha: 0.8),
borderRadius: BorderRadius.circular(20),
child: InkWell(
key: const ValueKey('dashboard_map_recenter_button'),
borderRadius: BorderRadius.circular(20),
onTap: () {
ref.read(mapFollowStateProvider.notifier).recenter();
},
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 6,
),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: AppColors.cyberCyan,
width: 1.2,
),
),
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.my_location,
size: 14,
color: AppColors.cyberCyan,
),
SizedBox(width: 5),
Text(
'RECENTER',
style: TextStyle(
fontFamily: AppTypography.fontTelemetry,
fontSize: 10,
fontWeight: FontWeight.w800,
letterSpacing: 0.6,
color: AppColors.cyberCyan,
),
),
],
),
),
),
),
),
],
),
);
@ -162,3 +250,4 @@ class DashboardMapPanel extends ConsumerWidget {
);
}
}

View File

@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
@ -102,11 +103,31 @@ class _AwsMapViewState extends State<_AwsMapView> {
late final MapController _mapController;
ll.LatLng? _lastFollowPos;
DateTime? _lastFollowTime;
Timer? _pendingFollowTimer;
static const int _followThrottleMs = 250;
@override
void initState() {
super.initState();
_mapController = MapController();
_lastFollowPos = ll.LatLng(widget.center.latitude, widget.center.longitude);
}
void _applyFollowMove(ll.LatLng target, double zoom) {
_lastFollowPos = target;
_lastFollowTime = DateTime.now();
try {
_mapController.move(target, zoom);
} catch (_) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
try {
_mapController.move(target, zoom);
} catch (_) {}
}
});
}
}
@override
@ -124,33 +145,46 @@ class _AwsMapViewState extends State<_AwsMapView> {
? 1.0
: (centerLl.latitude - _lastFollowPos!.latitude).abs() +
(centerLl.longitude - _lastFollowPos!.longitude).abs();
final hasMoved = posDelta > 0.00001;
final elapsedMs = _lastFollowTime == null
? _followThrottleMs + 1
: now.difference(_lastFollowTime!).inMilliseconds;
final shouldMove = justBecameFollowMode ||
final shouldMoveNow = justBecameFollowMode ||
_lastFollowPos == null ||
zoomChanged ||
(posDelta > 0.00005 &&
(_lastFollowTime == null ||
now.difference(_lastFollowTime!).inMilliseconds > 500));
(hasMoved && (elapsedMs >= _followThrottleMs || posDelta > 0.001));
if (shouldMove) {
_lastFollowPos = centerLl;
_lastFollowTime = now;
if (shouldMoveNow) {
_pendingFollowTimer?.cancel();
_pendingFollowTimer = null;
_applyFollowMove(centerLl, widget.zoom);
} else if (hasMoved) {
_pendingFollowTimer?.cancel();
final remainingMs = (_followThrottleMs - elapsedMs).clamp(16, _followThrottleMs);
_pendingFollowTimer = Timer(Duration(milliseconds: remainingMs), () {
_pendingFollowTimer = null;
if (mounted && widget.isFollowMode) {
final latestCenter = ll.LatLng(
widget.center.latitude,
widget.center.longitude,
);
_applyFollowMove(latestCenter, widget.zoom);
}
});
}
} else {
_pendingFollowTimer?.cancel();
_pendingFollowTimer = null;
if (zoomChanged) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
try {
_mapController.move(centerLl, widget.zoom);
_mapController.move(_mapController.camera.center, widget.zoom);
} catch (_) {}
}
});
}
} else if (zoomChanged) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
try {
_mapController.move(_mapController.camera.center, widget.zoom);
} catch (_) {}
}
});
}
if (bearingChanged) {
@ -166,6 +200,7 @@ class _AwsMapViewState extends State<_AwsMapView> {
@override
void dispose() {
_pendingFollowTimer?.cancel();
_mapController.dispose();
super.dispose();
}

View File

@ -4,8 +4,6 @@ import '../../core/theme/map_style.dart';
import 'map_provider_interface.dart';
class GoogleMapProvider implements AppMapProvider {
gmaps.GoogleMapController? _controller;
@override
AppMapType get type => AppMapType.googleMaps;
@ -25,15 +23,122 @@ class GoogleMapProvider implements AppMapProvider {
required ValueChanged<AppLatLng>? onPositionChanged,
required VoidCallback? onUserInteraction,
}) {
final target = gmaps.LatLng(center.latitude, center.longitude);
return _GoogleMapView(
center: center,
zoom: zoom,
bearing: bearing,
markers: markers,
polylines: polylines,
isFollowMode: isFollowMode,
onPositionChanged: onPositionChanged,
onUserInteraction: onUserInteraction,
);
}
}
if (isFollowMode && _controller != null) {
_controller!.animateCamera(
gmaps.CameraUpdate.newLatLngZoom(target, zoom),
class _GoogleMapView extends StatefulWidget {
final AppLatLng center;
final double zoom;
final double bearing;
final List<AppMapMarker> markers;
final List<AppPolyline> polylines;
final bool isFollowMode;
final ValueChanged<AppLatLng>? onPositionChanged;
final VoidCallback? onUserInteraction;
const _GoogleMapView({
required this.center,
required this.zoom,
this.bearing = 0.0,
required this.markers,
required this.polylines,
required this.isFollowMode,
required this.onPositionChanged,
required this.onUserInteraction,
});
@override
State<_GoogleMapView> createState() => _GoogleMapViewState();
}
class _GoogleMapViewState extends State<_GoogleMapView> {
gmaps.GoogleMapController? _controller;
gmaps.LatLng? _lastFollowPos;
DateTime? _lastFollowTime;
bool _isProgrammaticMove = false;
static const int _followThrottleMs = 250;
@override
void initState() {
super.initState();
_lastFollowPos = gmaps.LatLng(widget.center.latitude, widget.center.longitude);
}
Future<void> _moveCamera(gmaps.LatLng target, double zoom, double bearing) async {
final controller = _controller;
if (controller == null) return;
_lastFollowPos = target;
_lastFollowTime = DateTime.now();
_isProgrammaticMove = true;
try {
await controller.animateCamera(
gmaps.CameraUpdate.newCameraPosition(
gmaps.CameraPosition(
target: target,
zoom: zoom,
bearing: bearing,
),
),
);
} catch (_) {
} finally {
_isProgrammaticMove = false;
}
}
final gMarkers = markers.map((m) {
@override
void didUpdateWidget(covariant _GoogleMapView oldWidget) {
super.didUpdateWidget(oldWidget);
if (_controller == null) return;
final target = gmaps.LatLng(widget.center.latitude, widget.center.longitude);
final zoomChanged = (oldWidget.zoom - widget.zoom).abs() > 0.01;
final bearingChanged = (oldWidget.bearing - widget.bearing).abs() > 0.1;
final justBecameFollowMode = !oldWidget.isFollowMode && widget.isFollowMode;
if (widget.isFollowMode) {
final now = DateTime.now();
final posDelta = _lastFollowPos == null
? 1.0
: (target.latitude - _lastFollowPos!.latitude).abs() +
(target.longitude - _lastFollowPos!.longitude).abs();
final hasMoved = posDelta > 0.00001;
final elapsedMs = _lastFollowTime == null
? _followThrottleMs + 1
: now.difference(_lastFollowTime!).inMilliseconds;
if (justBecameFollowMode ||
_lastFollowPos == null ||
zoomChanged ||
bearingChanged ||
(hasMoved && (elapsedMs >= _followThrottleMs || posDelta > 0.001))) {
_moveCamera(target, widget.zoom, widget.bearing);
}
}
}
@override
void dispose() {
_controller?.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final target = gmaps.LatLng(widget.center.latitude, widget.center.longitude);
final gMarkers = widget.markers.map((m) {
return gmaps.Marker(
markerId: gmaps.MarkerId(m.id),
position: gmaps.LatLng(m.position.latitude, m.position.longitude),
@ -42,7 +147,7 @@ class GoogleMapProvider implements AppMapProvider {
);
}).toSet();
final gPolylines = polylines.map((p) {
final gPolylines = widget.polylines.map((p) {
return gmaps.Polyline(
polylineId: gmaps.PolylineId(p.id),
points: p.points.map((pt) => gmaps.LatLng(pt.latitude, pt.longitude)).toList(),
@ -58,7 +163,8 @@ class GoogleMapProvider implements AppMapProvider {
key: ValueKey('google_map_$sizeKey'),
initialCameraPosition: gmaps.CameraPosition(
target: target,
zoom: zoom,
zoom: widget.zoom,
bearing: widget.bearing,
),
markers: gMarkers,
polylines: gPolylines,
@ -68,13 +174,21 @@ class GoogleMapProvider implements AppMapProvider {
compassEnabled: false,
onMapCreated: (controller) {
_controller = controller;
if (widget.isFollowMode) {
_moveCamera(target, widget.zoom, widget.bearing);
}
},
onCameraMoveStarted: () {
onUserInteraction?.call();
if (!_isProgrammaticMove) {
widget.onUserInteraction?.call();
}
},
onCameraMove: (position) {
onPositionChanged?.call(AppLatLng(position.target.latitude, position.target.longitude));
widget.onPositionChanged?.call(
AppLatLng(position.target.latitude, position.target.longitude),
);
},
);
}
}

View File

@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
@ -69,11 +70,31 @@ class _OsmMapViewState extends State<_OsmMapView> {
late final MapController _mapController;
ll.LatLng? _lastFollowPos;
DateTime? _lastFollowTime;
Timer? _pendingFollowTimer;
static const int _followThrottleMs = 250;
@override
void initState() {
super.initState();
_mapController = MapController();
_lastFollowPos = ll.LatLng(widget.center.latitude, widget.center.longitude);
}
void _applyFollowMove(ll.LatLng target, double zoom) {
_lastFollowPos = target;
_lastFollowTime = DateTime.now();
try {
_mapController.move(target, zoom);
} catch (_) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
try {
_mapController.move(target, zoom);
} catch (_) {}
}
});
}
}
@override
@ -90,33 +111,46 @@ class _OsmMapViewState extends State<_OsmMapView> {
? 1.0
: (centerLl.latitude - _lastFollowPos!.latitude).abs() +
(centerLl.longitude - _lastFollowPos!.longitude).abs();
final hasMoved = posDelta > 0.00001;
final elapsedMs = _lastFollowTime == null
? _followThrottleMs + 1
: now.difference(_lastFollowTime!).inMilliseconds;
final shouldMove = justBecameFollowMode ||
final shouldMoveNow = justBecameFollowMode ||
_lastFollowPos == null ||
zoomChanged ||
(posDelta > 0.00005 &&
(_lastFollowTime == null ||
now.difference(_lastFollowTime!).inMilliseconds > 500));
(hasMoved && (elapsedMs >= _followThrottleMs || posDelta > 0.001));
if (shouldMove) {
_lastFollowPos = centerLl;
_lastFollowTime = now;
if (shouldMoveNow) {
_pendingFollowTimer?.cancel();
_pendingFollowTimer = null;
_applyFollowMove(centerLl, widget.zoom);
} else if (hasMoved) {
_pendingFollowTimer?.cancel();
final remainingMs = (_followThrottleMs - elapsedMs).clamp(16, _followThrottleMs);
_pendingFollowTimer = Timer(Duration(milliseconds: remainingMs), () {
_pendingFollowTimer = null;
if (mounted && widget.isFollowMode) {
final latestCenter = ll.LatLng(
widget.center.latitude,
widget.center.longitude,
);
_applyFollowMove(latestCenter, widget.zoom);
}
});
}
} else {
_pendingFollowTimer?.cancel();
_pendingFollowTimer = null;
if (zoomChanged) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
try {
_mapController.move(centerLl, widget.zoom);
_mapController.move(_mapController.camera.center, widget.zoom);
} catch (_) {}
}
});
}
} else if (zoomChanged) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
try {
_mapController.move(_mapController.camera.center, widget.zoom);
} catch (_) {}
}
});
}
if (bearingChanged) {
@ -132,6 +166,7 @@ class _OsmMapViewState extends State<_OsmMapView> {
@override
void dispose() {
_pendingFollowTimer?.cancel();
_mapController.dispose();
super.dispose();
}

View File

@ -1,5 +1,6 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
@ -20,6 +21,8 @@ import 'package:motorcycle_dash/models/telemetry_sample.dart';
import 'package:motorcycle_dash/services/alerts/road_alert_models.dart';
import 'package:motorcycle_dash/services/alerts/road_alert_service.dart';
import 'package:motorcycle_dash/services/location_service.dart';
import 'package:motorcycle_dash/services/map/map_providers.dart';
import 'package:motorcycle_dash/services/map/unified_map_view.dart';
import 'package:motorcycle_dash/services/telemetry_service.dart';
void main() {
@ -344,6 +347,166 @@ void main() {
expect(find.text('Enable'), findsOneWidget);
});
});
group('BUG-001: Touring Map Rider Follow & Recenter Regression Tests', () {
testWidgets(
'Touring map follows incoming GPS coordinates in portrait and landscape, pauses on manual pan, and resumes on recenter',
(tester) async {
await prefs.setString('dashboardLayout', 'touring');
configureViewport(tester, 400, 800);
addTearDown(() => tester.view.resetPhysicalSize());
await tester.pumpWidget(buildTestDashboard());
await tester.pump();
expect(find.byType(DashboardMapPanel), findsOneWidget);
expect(find.byType(FlutterMap), findsOneWidget);
expect(
find.byKey(const ValueKey('dashboard_map_recenter_button')),
findsNothing,
);
// Emit initial GPS fix within 50ms of mount (previously dropped by 500ms cooldown)
telemetryController.add(
TelemetrySample(
timestamp: DateTime.now(),
speed: 35.0,
heading: 90.0,
latitude: 34.0522,
longitude: -118.2437,
gpsAccuracy: 5.0,
gpsAgeMs: 100,
),
);
await tester.pump(const Duration(milliseconds: 50));
var unifiedMap = tester.widget<UnifiedMapView>(
find.byType(UnifiedMapView),
);
var flutterMap = tester.widget<FlutterMap>(find.byType(FlutterMap));
expect(unifiedMap.center.latitude, closeTo(34.0522, 0.0001));
expect(unifiedMap.center.longitude, closeTo(-118.2437, 0.0001));
expect(
flutterMap.mapController!.camera.center.latitude,
closeTo(34.0522, 0.0001),
);
expect(
flutterMap.mapController!.camera.center.longitude,
closeTo(-118.2437, 0.0001),
);
// Emit rapid subsequent GPS fix within 100ms (tests trailing timer follow)
telemetryController.add(
TelemetrySample(
timestamp: DateTime.now(),
speed: 45.0,
heading: 95.0,
latitude: 34.0526,
longitude: -118.2431,
gpsAccuracy: 5.0,
gpsAgeMs: 80,
),
);
await tester.pump(const Duration(milliseconds: 50));
// Advance past the 250ms trailing follow timer window
await tester.pump(const Duration(milliseconds: 300));
flutterMap = tester.widget<FlutterMap>(find.byType(FlutterMap));
expect(
flutterMap.mapController!.camera.center.latitude,
closeTo(34.0526, 0.0001),
);
expect(
flutterMap.mapController!.camera.center.longitude,
closeTo(-118.2431, 0.0001),
);
// Simulate user manual pan / gesture interaction on the Touring map
final element = tester.element(find.byType(DashboardMapPanel));
final container = ProviderScope.containerOf(element);
container.read(mapFollowStateProvider.notifier).userInteracted();
flutterMap.mapController!.move(
flutterMap.mapController!.camera.center,
16.5,
);
await tester.pump();
// Recenter button must appear when follow mode is disabled
final recenterFinder = find.byKey(
const ValueKey('dashboard_map_recenter_button'),
);
expect(recenterFinder, findsOneWidget);
// While panned (follow mode false), incoming GPS updates move the rider marker
// but do NOT hijack the user's panned camera center
final pannedCameraCenter = flutterMap.mapController!.camera.center;
telemetryController.add(
TelemetrySample(
timestamp: DateTime.now(),
speed: 50.0,
heading: 100.0,
latitude: 34.0600,
longitude: -118.2500,
gpsAccuracy: 5.0,
gpsAgeMs: 80,
),
);
await tester.pump(const Duration(milliseconds: 300));
flutterMap = tester.widget<FlutterMap>(find.byType(FlutterMap));
expect(
flutterMap.mapController!.camera.center.latitude,
closeTo(pannedCameraCenter.latitude, 0.0001),
);
expect(
flutterMap.mapController!.camera.center.longitude,
closeTo(pannedCameraCenter.longitude, 0.0001),
);
// Tap RECENTER button -> camera snaps back to latest rider position (34.0600, -118.2500)
await tester.tap(recenterFinder);
await tester.pump();
expect(recenterFinder, findsNothing);
flutterMap = tester.widget<FlutterMap>(find.byType(FlutterMap));
expect(
flutterMap.mapController!.camera.center.latitude,
closeTo(34.0600, 0.0001),
);
expect(
flutterMap.mapController!.camera.center.longitude,
closeTo(-118.2500, 0.0001),
);
// Rotate to landscape Touring mode and verify GPS follow continues cleanly
configureViewport(tester, 800, 360);
await tester.pump();
telemetryController.add(
TelemetrySample(
timestamp: DateTime.now(),
speed: 55.0,
heading: 110.0,
latitude: 34.0700,
longitude: -118.2600,
gpsAccuracy: 5.0,
gpsAgeMs: 80,
),
);
await tester.pump(const Duration(milliseconds: 300));
flutterMap = tester.widget<FlutterMap>(find.byType(FlutterMap));
expect(
flutterMap.mapController!.camera.center.latitude,
closeTo(34.0700, 0.0001),
);
expect(
flutterMap.mapController!.camera.center.longitude,
closeTo(-118.2600, 0.0001),
);
},
);
});
}
class _TestRideStateMachine extends StateNotifier<RideSession>