fix(dashboard): ensure Touring map follows rider and supports recenter (#48)
This commit is contained in:
parent
2ca069fdb9
commit
1d702102fc
|
|
@ -5,15 +5,18 @@ import '../../../core/theme/app_typography.dart';
|
||||||
import '../../../services/alerts/road_alert_models.dart';
|
import '../../../services/alerts/road_alert_models.dart';
|
||||||
import '../../../services/alerts/road_alert_service.dart';
|
import '../../../services/alerts/road_alert_service.dart';
|
||||||
import '../../../services/map/map_provider_interface.dart';
|
import '../../../services/map/map_provider_interface.dart';
|
||||||
|
import '../../../services/map/map_providers.dart';
|
||||||
import '../../../services/map/unified_map_view.dart';
|
import '../../../services/map/unified_map_view.dart';
|
||||||
import '../../../services/navigation/navigation_session_service.dart';
|
import '../../../services/navigation/navigation_session_service.dart';
|
||||||
import '../../../services/telemetry_service.dart';
|
import '../../../services/telemetry_service.dart';
|
||||||
|
import '../../rides/ride_state_machine.dart';
|
||||||
|
|
||||||
/// Embedded tactical map panel for Touring mode and responsive dashboard views.
|
/// Embedded tactical map panel for Touring mode and responsive dashboard views.
|
||||||
///
|
///
|
||||||
/// Subscribes to vehicle coordinates and heading via [telemetryStreamProvider]
|
/// Subscribes to vehicle coordinates and heading via [telemetryStreamProvider]
|
||||||
/// and alert pins via [activeAlertsStreamProvider].
|
/// and alert pins via [activeAlertsStreamProvider], maintains camera follow
|
||||||
class DashboardMapPanel extends ConsumerWidget {
|
/// state, and provides a recenter control after manual pan.
|
||||||
|
class DashboardMapPanel extends ConsumerStatefulWidget {
|
||||||
final double zoom;
|
final double zoom;
|
||||||
|
|
||||||
const DashboardMapPanel({
|
const DashboardMapPanel({
|
||||||
|
|
@ -22,7 +25,43 @@ class DashboardMapPanel extends ConsumerWidget {
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@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(
|
final vehicleCoords = ref.watch(
|
||||||
telemetryStreamProvider.select(
|
telemetryStreamProvider.select(
|
||||||
(s) => (
|
(s) => (
|
||||||
|
|
@ -34,19 +73,18 @@ class DashboardMapPanel extends ConsumerWidget {
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
final centerLat = (vehicleCoords.lat != null && vehicleCoords.lat != 0.0)
|
final hasCoordinates = _isValidCoord(vehicleCoords.lat, vehicleCoords.lng);
|
||||||
? vehicleCoords.lat!
|
if (hasCoordinates) {
|
||||||
: 37.7749;
|
_lastKnownRiderPos = AppLatLng(vehicleCoords.lat!, vehicleCoords.lng!);
|
||||||
final centerLng = (vehicleCoords.lng != null && vehicleCoords.lng != 0.0)
|
}
|
||||||
? vehicleCoords.lng!
|
|
||||||
: -122.4194;
|
|
||||||
|
|
||||||
|
final activeCenter = _lastKnownRiderPos ?? _defaultFallbackCenter;
|
||||||
final isFresh = vehicleCoords.isGpsFresh;
|
final isFresh = vehicleCoords.isGpsFresh;
|
||||||
final hasCoordinates = vehicleCoords.lat != null && vehicleCoords.lat != 0.0;
|
final isFollowMode = ref.watch(mapFollowStateProvider);
|
||||||
|
|
||||||
final vehicleMarker = AppMapMarker(
|
final vehicleMarker = AppMapMarker(
|
||||||
id: 'dash_vehicle',
|
id: 'dash_vehicle',
|
||||||
position: AppLatLng(centerLat, centerLng),
|
position: activeCenter,
|
||||||
heading: vehicleCoords.heading,
|
heading: vehicleCoords.heading,
|
||||||
iconWidget: Icon(
|
iconWidget: Icon(
|
||||||
Icons.navigation,
|
Icons.navigation,
|
||||||
|
|
@ -64,7 +102,7 @@ class DashboardMapPanel extends ConsumerWidget {
|
||||||
for (final alert in activeAlerts) {
|
for (final alert in activeAlerts) {
|
||||||
mapMarkers.add(
|
mapMarkers.add(
|
||||||
AppMapMarker(
|
AppMapMarker(
|
||||||
id: 'dash_alert__',
|
id: 'dash_alert_${alert.id}',
|
||||||
position: AppLatLng(alert.latitude, alert.longitude),
|
position: AppLatLng(alert.latitude, alert.longitude),
|
||||||
heading: alert.heading ?? 0.0,
|
heading: alert.heading ?? 0.0,
|
||||||
label: alert.category.displayName,
|
label: alert.category.displayName,
|
||||||
|
|
@ -116,14 +154,14 @@ class DashboardMapPanel extends ConsumerWidget {
|
||||||
Positioned.fill(
|
Positioned.fill(
|
||||||
child: UnifiedMapView(
|
child: UnifiedMapView(
|
||||||
key: const ValueKey('dash_map_x'),
|
key: const ValueKey('dash_map_x'),
|
||||||
center: AppLatLng(centerLat, centerLng),
|
center: activeCenter,
|
||||||
zoom: zoom,
|
zoom: widget.zoom,
|
||||||
bearing: 0.0,
|
bearing: 0.0,
|
||||||
markers: mapMarkers,
|
markers: mapMarkers,
|
||||||
polylines: polylines,
|
polylines: polylines,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (!isFresh && hasCoordinates)
|
if (!isFresh && (hasCoordinates || _lastKnownRiderPos != null))
|
||||||
Positioned(
|
Positioned(
|
||||||
top: 10,
|
top: 10,
|
||||||
left: 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 {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import 'dart:async';
|
||||||
import 'dart:math' as math;
|
import 'dart:math' as math;
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_map/flutter_map.dart';
|
import 'package:flutter_map/flutter_map.dart';
|
||||||
|
|
@ -102,11 +103,31 @@ class _AwsMapViewState extends State<_AwsMapView> {
|
||||||
late final MapController _mapController;
|
late final MapController _mapController;
|
||||||
ll.LatLng? _lastFollowPos;
|
ll.LatLng? _lastFollowPos;
|
||||||
DateTime? _lastFollowTime;
|
DateTime? _lastFollowTime;
|
||||||
|
Timer? _pendingFollowTimer;
|
||||||
|
|
||||||
|
static const int _followThrottleMs = 250;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_mapController = MapController();
|
_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
|
@override
|
||||||
|
|
@ -124,33 +145,46 @@ class _AwsMapViewState extends State<_AwsMapView> {
|
||||||
? 1.0
|
? 1.0
|
||||||
: (centerLl.latitude - _lastFollowPos!.latitude).abs() +
|
: (centerLl.latitude - _lastFollowPos!.latitude).abs() +
|
||||||
(centerLl.longitude - _lastFollowPos!.longitude).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 ||
|
_lastFollowPos == null ||
|
||||||
zoomChanged ||
|
zoomChanged ||
|
||||||
(posDelta > 0.00005 &&
|
(hasMoved && (elapsedMs >= _followThrottleMs || posDelta > 0.001));
|
||||||
(_lastFollowTime == null ||
|
|
||||||
now.difference(_lastFollowTime!).inMilliseconds > 500));
|
|
||||||
|
|
||||||
if (shouldMove) {
|
if (shouldMoveNow) {
|
||||||
_lastFollowPos = centerLl;
|
_pendingFollowTimer?.cancel();
|
||||||
_lastFollowTime = now;
|
_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((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
try {
|
try {
|
||||||
_mapController.move(centerLl, widget.zoom);
|
_mapController.move(_mapController.camera.center, widget.zoom);
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else if (zoomChanged) {
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
||||||
if (mounted) {
|
|
||||||
try {
|
|
||||||
_mapController.move(_mapController.camera.center, widget.zoom);
|
|
||||||
} catch (_) {}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (bearingChanged) {
|
if (bearingChanged) {
|
||||||
|
|
@ -166,6 +200,7 @@ class _AwsMapViewState extends State<_AwsMapView> {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_pendingFollowTimer?.cancel();
|
||||||
_mapController.dispose();
|
_mapController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,6 @@ import '../../core/theme/map_style.dart';
|
||||||
import 'map_provider_interface.dart';
|
import 'map_provider_interface.dart';
|
||||||
|
|
||||||
class GoogleMapProvider implements AppMapProvider {
|
class GoogleMapProvider implements AppMapProvider {
|
||||||
gmaps.GoogleMapController? _controller;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
AppMapType get type => AppMapType.googleMaps;
|
AppMapType get type => AppMapType.googleMaps;
|
||||||
|
|
||||||
|
|
@ -25,15 +23,122 @@ class GoogleMapProvider implements AppMapProvider {
|
||||||
required ValueChanged<AppLatLng>? onPositionChanged,
|
required ValueChanged<AppLatLng>? onPositionChanged,
|
||||||
required VoidCallback? onUserInteraction,
|
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) {
|
class _GoogleMapView extends StatefulWidget {
|
||||||
_controller!.animateCamera(
|
final AppLatLng center;
|
||||||
gmaps.CameraUpdate.newLatLngZoom(target, zoom),
|
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(
|
return gmaps.Marker(
|
||||||
markerId: gmaps.MarkerId(m.id),
|
markerId: gmaps.MarkerId(m.id),
|
||||||
position: gmaps.LatLng(m.position.latitude, m.position.longitude),
|
position: gmaps.LatLng(m.position.latitude, m.position.longitude),
|
||||||
|
|
@ -42,7 +147,7 @@ class GoogleMapProvider implements AppMapProvider {
|
||||||
);
|
);
|
||||||
}).toSet();
|
}).toSet();
|
||||||
|
|
||||||
final gPolylines = polylines.map((p) {
|
final gPolylines = widget.polylines.map((p) {
|
||||||
return gmaps.Polyline(
|
return gmaps.Polyline(
|
||||||
polylineId: gmaps.PolylineId(p.id),
|
polylineId: gmaps.PolylineId(p.id),
|
||||||
points: p.points.map((pt) => gmaps.LatLng(pt.latitude, pt.longitude)).toList(),
|
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'),
|
key: ValueKey('google_map_$sizeKey'),
|
||||||
initialCameraPosition: gmaps.CameraPosition(
|
initialCameraPosition: gmaps.CameraPosition(
|
||||||
target: target,
|
target: target,
|
||||||
zoom: zoom,
|
zoom: widget.zoom,
|
||||||
|
bearing: widget.bearing,
|
||||||
),
|
),
|
||||||
markers: gMarkers,
|
markers: gMarkers,
|
||||||
polylines: gPolylines,
|
polylines: gPolylines,
|
||||||
|
|
@ -68,13 +174,21 @@ class GoogleMapProvider implements AppMapProvider {
|
||||||
compassEnabled: false,
|
compassEnabled: false,
|
||||||
onMapCreated: (controller) {
|
onMapCreated: (controller) {
|
||||||
_controller = controller;
|
_controller = controller;
|
||||||
|
if (widget.isFollowMode) {
|
||||||
|
_moveCamera(target, widget.zoom, widget.bearing);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onCameraMoveStarted: () {
|
onCameraMoveStarted: () {
|
||||||
onUserInteraction?.call();
|
if (!_isProgrammaticMove) {
|
||||||
|
widget.onUserInteraction?.call();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onCameraMove: (position) {
|
onCameraMove: (position) {
|
||||||
onPositionChanged?.call(AppLatLng(position.target.latitude, position.target.longitude));
|
widget.onPositionChanged?.call(
|
||||||
|
AppLatLng(position.target.latitude, position.target.longitude),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import 'dart:async';
|
||||||
import 'dart:math' as math;
|
import 'dart:math' as math;
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_map/flutter_map.dart';
|
import 'package:flutter_map/flutter_map.dart';
|
||||||
|
|
@ -69,11 +70,31 @@ class _OsmMapViewState extends State<_OsmMapView> {
|
||||||
late final MapController _mapController;
|
late final MapController _mapController;
|
||||||
ll.LatLng? _lastFollowPos;
|
ll.LatLng? _lastFollowPos;
|
||||||
DateTime? _lastFollowTime;
|
DateTime? _lastFollowTime;
|
||||||
|
Timer? _pendingFollowTimer;
|
||||||
|
|
||||||
|
static const int _followThrottleMs = 250;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_mapController = MapController();
|
_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
|
@override
|
||||||
|
|
@ -90,33 +111,46 @@ class _OsmMapViewState extends State<_OsmMapView> {
|
||||||
? 1.0
|
? 1.0
|
||||||
: (centerLl.latitude - _lastFollowPos!.latitude).abs() +
|
: (centerLl.latitude - _lastFollowPos!.latitude).abs() +
|
||||||
(centerLl.longitude - _lastFollowPos!.longitude).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 ||
|
_lastFollowPos == null ||
|
||||||
zoomChanged ||
|
zoomChanged ||
|
||||||
(posDelta > 0.00005 &&
|
(hasMoved && (elapsedMs >= _followThrottleMs || posDelta > 0.001));
|
||||||
(_lastFollowTime == null ||
|
|
||||||
now.difference(_lastFollowTime!).inMilliseconds > 500));
|
|
||||||
|
|
||||||
if (shouldMove) {
|
if (shouldMoveNow) {
|
||||||
_lastFollowPos = centerLl;
|
_pendingFollowTimer?.cancel();
|
||||||
_lastFollowTime = now;
|
_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((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
try {
|
try {
|
||||||
_mapController.move(centerLl, widget.zoom);
|
_mapController.move(_mapController.camera.center, widget.zoom);
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else if (zoomChanged) {
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
||||||
if (mounted) {
|
|
||||||
try {
|
|
||||||
_mapController.move(_mapController.camera.center, widget.zoom);
|
|
||||||
} catch (_) {}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (bearingChanged) {
|
if (bearingChanged) {
|
||||||
|
|
@ -132,6 +166,7 @@ class _OsmMapViewState extends State<_OsmMapView> {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_pendingFollowTimer?.cancel();
|
||||||
_mapController.dispose();
|
_mapController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_map/flutter_map.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.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_models.dart';
|
||||||
import 'package:motorcycle_dash/services/alerts/road_alert_service.dart';
|
import 'package:motorcycle_dash/services/alerts/road_alert_service.dart';
|
||||||
import 'package:motorcycle_dash/services/location_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';
|
import 'package:motorcycle_dash/services/telemetry_service.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
|
|
@ -344,6 +347,166 @@ void main() {
|
||||||
expect(find.text('Enable'), findsOneWidget);
|
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>
|
class _TestRideStateMachine extends StateNotifier<RideSession>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue