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_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 {
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,26 +145,38 @@ 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;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) {
|
||||
try {
|
||||
_mapController.move(centerLl, widget.zoom);
|
||||
} catch (_) {}
|
||||
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 if (zoomChanged) {
|
||||
} else {
|
||||
_pendingFollowTimer?.cancel();
|
||||
_pendingFollowTimer = null;
|
||||
if (zoomChanged) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) {
|
||||
try {
|
||||
|
|
@ -152,6 +185,7 @@ class _AwsMapViewState extends State<_AwsMapView> {
|
|||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (bearingChanged) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
|
|
@ -166,6 +200,7 @@ class _AwsMapViewState extends State<_AwsMapView> {
|
|||
|
||||
@override
|
||||
void dispose() {
|
||||
_pendingFollowTimer?.cancel();
|
||||
_mapController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
if (isFollowMode && _controller != null) {
|
||||
_controller!.animateCamera(
|
||||
gmaps.CameraUpdate.newLatLngZoom(target, zoom),
|
||||
return _GoogleMapView(
|
||||
center: center,
|
||||
zoom: zoom,
|
||||
bearing: bearing,
|
||||
markers: markers,
|
||||
polylines: polylines,
|
||||
isFollowMode: isFollowMode,
|
||||
onPositionChanged: onPositionChanged,
|
||||
onUserInteraction: onUserInteraction,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final gMarkers = markers.map((m) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@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),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,26 +111,38 @@ 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;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) {
|
||||
try {
|
||||
_mapController.move(centerLl, widget.zoom);
|
||||
} catch (_) {}
|
||||
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 if (zoomChanged) {
|
||||
} else {
|
||||
_pendingFollowTimer?.cancel();
|
||||
_pendingFollowTimer = null;
|
||||
if (zoomChanged) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) {
|
||||
try {
|
||||
|
|
@ -118,6 +151,7 @@ class _OsmMapViewState extends State<_OsmMapView> {
|
|||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (bearingChanged) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
|
|
@ -132,6 +166,7 @@ class _OsmMapViewState extends State<_OsmMapView> {
|
|||
|
||||
@override
|
||||
void dispose() {
|
||||
_pendingFollowTimer?.cancel();
|
||||
_mapController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue