From 1cfca663a6e3b30bdfd7aa08a71bcd6750476880 Mon Sep 17 00:00:00 2001 From: stopflock Date: Sat, 25 Jul 2026 16:32:08 -0500 Subject: [PATCH 1/3] catch and correct possible bad values for lat/lon, add tests --- lib/services/coordinate_validation.dart | 23 ++++++ lib/widgets/map/gps_controller.dart | 18 +++++ lib/widgets/map/map_position_manager.dart | 25 +++---- lib/widgets/map_view.dart | 74 ++++++++++++++++++- test/services/coordinate_validation_test.dart | 56 ++++++++++++++ 5 files changed, 182 insertions(+), 14 deletions(-) create mode 100644 lib/services/coordinate_validation.dart create mode 100644 test/services/coordinate_validation_test.dart diff --git a/lib/services/coordinate_validation.dart b/lib/services/coordinate_validation.dart new file mode 100644 index 0000000..d98a0f9 --- /dev/null +++ b/lib/services/coordinate_validation.dart @@ -0,0 +1,23 @@ +/// Shared validation helpers for geographic coordinates and zoom levels. +/// +/// Used anywhere a [LatLng]-like value or zoom level comes from an external +/// or platform source (GPS fixes, persisted preferences, live map camera +/// state) that isn't guaranteed to be finite/in-range. Centralizing this +/// avoids subtly inconsistent validation logic being duplicated across the +/// GPS controller, map position persistence, and live camera guards. +library; + +/// Validate that a latitude or longitude value is finite and within the +/// valid range for geographic coordinates (-180 to 180). +/// +/// Note: this intentionally uses the wider +/-180 range (rather than +/-90 +/// for latitude) so a single helper can validate both latitude and +/// longitude values without the caller needing to track which is which. +bool isValidCoordinate(double value) { + return !value.isNaN && !value.isInfinite && value >= -180.0 && value <= 180.0; +} + +/// Validate that a zoom level is finite and within a sane range. +bool isValidZoom(double zoom, {double min = 0.0, double max = 25.0}) { + return !zoom.isNaN && !zoom.isInfinite && zoom >= min && zoom <= max; +} diff --git a/lib/widgets/map/gps_controller.dart b/lib/widgets/map/gps_controller.dart index cdc67f7..0599605 100644 --- a/lib/widgets/map/gps_controller.dart +++ b/lib/widgets/map/gps_controller.dart @@ -8,9 +8,11 @@ import 'package:flutter/foundation.dart' show defaultTargetPlatform, TargetPlatf import '../../dev_config.dart'; import '../../app_state.dart' show FollowMeMode; import '../../services/proximity_alert_service.dart'; +import '../../services/coordinate_validation.dart'; import '../../models/osm_node.dart'; import '../../models/node_profile.dart'; + /// Simple GPS controller that handles precise location permissions only. /// Key principles: /// - Respect "denied forever" - stop trying @@ -187,6 +189,21 @@ class GpsController { /// Handle incoming GPS position void _onPositionReceived(Position position) { + // Guard against malformed fixes from the platform location stack (some + // OEM/fused location providers occasionally emit NaN/Infinite lat/lng, + // especially while a fix is still being acquired). An unvalidated bad + // value here can crash the app at launch (via initialCenter) or corrupt + // the live map camera (via follow-me animateTo), so treat it like any + // other transient location error rather than trusting it. + if (!isValidCoordinate(position.latitude) || !isValidCoordinate(position.longitude)) { + debugPrint( + '[GpsController] Ignoring invalid GPS position: ' + 'lat=${position.latitude}, lng=${position.longitude}', + ); + return; + } + + final newLocation = LatLng(position.latitude, position.longitude); _currentLocation = newLocation; @@ -209,6 +226,7 @@ class GpsController { } /// Handle GPS stream errors + void _onPositionError(dynamic error) { debugPrint('[GpsController] Position stream error: $error'); if (_hasLocation) { diff --git a/lib/widgets/map/map_position_manager.dart b/lib/widgets/map/map_position_manager.dart index 2fb7ba6..3c42d0c 100644 --- a/lib/widgets/map/map_position_manager.dart +++ b/lib/widgets/map/map_position_manager.dart @@ -3,6 +3,9 @@ import 'package:flutter_map_animations/flutter_map_animations.dart'; import 'package:latlong2/latlong.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import '../../services/coordinate_validation.dart' as coord_validation; + + /// Manages map position persistence and initial positioning. /// Handles saving/loading last map position and moving to initial locations. @@ -30,7 +33,8 @@ class MapPositionManager { final zoom = prefs.getDouble('last_map_zoom'); if (lat != null && lng != null && - _isValidCoordinate(lat) && _isValidCoordinate(lng)) { + coord_validation.isValidCoordinate(lat) && coord_validation.isValidCoordinate(lng)) { + final validZoom = zoom != null && _isValidZoom(zoom) ? zoom : 15.0; _initialLocation = LatLng(lat, lng); _initialZoom = validZoom; @@ -50,9 +54,10 @@ class MapPositionManager { try { final zoom = _initialZoom ?? 15.0; // Double-check coordinates are valid before moving - if (_isValidCoordinate(_initialLocation!.latitude) && - _isValidCoordinate(_initialLocation!.longitude) && + if (coord_validation.isValidCoordinate(_initialLocation!.latitude) && + coord_validation.isValidCoordinate(_initialLocation!.longitude) && _isValidZoom(zoom)) { + controller.mapController.move(_initialLocation!, zoom); _hasMovedToInitialLocation = true; debugPrint('[MapPositionManager] Moved to initial location: ${_initialLocation!.latitude}, ${_initialLocation!.longitude}'); @@ -70,9 +75,10 @@ class MapPositionManager { Future saveMapPosition(LatLng location, double zoom) async { try { // Validate coordinates and zoom before saving - if (!_isValidCoordinate(location.latitude) || - !_isValidCoordinate(location.longitude) || + if (!coord_validation.isValidCoordinate(location.latitude) || + !coord_validation.isValidCoordinate(location.longitude) || !_isValidZoom(zoom)) { + debugPrint('[MapPositionManager] Invalid map position, not saving: lat=${location.latitude}, lng=${location.longitude}, zoom=$zoom'); return; } @@ -102,15 +108,8 @@ class MapPositionManager { } } - /// Validate that a coordinate value is valid (not NaN, not infinite, within bounds) - bool _isValidCoordinate(double value) { - return !value.isNaN && - !value.isInfinite && - value >= -180.0 && - value <= 180.0; - } - /// Validate that a zoom level is valid + bool _isValidZoom(double zoom) { return !zoom.isNaN && !zoom.isInfinite && diff --git a/lib/widgets/map_view.dart b/lib/widgets/map_view.dart index c7abcad..34b0e95 100644 --- a/lib/widgets/map_view.dart +++ b/lib/widgets/map_view.dart @@ -26,9 +26,12 @@ import 'node_limit_indicator.dart'; import 'proximity_alert_banner.dart'; import '../dev_config.dart'; import '../services/proximity_alert_service.dart'; +import '../services/coordinate_validation.dart'; import 'sheet_aware_map.dart'; + import 'custom_scale_bar.dart'; + class MapView extends StatefulWidget { final AnimatedMapController controller; const MapView({ @@ -80,6 +83,15 @@ class MapViewState extends State { // Track map center to clear queue on significant panning LatLng? _lastCenter; + + // Track the last known-good (finite, in-range) camera center/zoom so we + // can self-heal if the camera ever gets pushed into a degenerate state + // (e.g. by an edge case in flutter_map's internal gesture-to-camera + // pipeline). This mirrors the validation used for persisted map position + // in MapPositionManager, applied to the *live* camera as well. + LatLng? _lastValidCenter; + double? _lastValidZoom; + // State for proximity alert banner bool _showProximityBanner = false; @@ -275,6 +287,29 @@ class MapViewState extends State { return (!appState.offlineMode && appState.isInSearchMode) ? 60.0 : 0.0; } + /// Get a known-good fallback location for [initialCenter], preferring a + /// validated GPS location, then a validated persisted position, then a + /// hardcoded default. GpsController already validates incoming fixes, but + /// this extra check keeps launch resilient even if that guard is ever + /// bypassed (e.g. future refactor). + LatLng get _safeInitialCenter { + final gpsLocation = _gpsController.currentLocation; + if (gpsLocation != null && + isValidCoordinate(gpsLocation.latitude) && + isValidCoordinate(gpsLocation.longitude)) { + return gpsLocation; + } + final persisted = _positionManager.initialLocation; + if (persisted != null && + isValidCoordinate(persisted.latitude) && + isValidCoordinate(persisted.longitude)) { + return persisted; + } + return LatLng(37.7749, -122.4194); + } + + + @override void didUpdateWidget(covariant MapView oldWidget) { @@ -414,18 +449,55 @@ class MapViewState extends State { key: ValueKey('map_${appState.selectedTileProvider?.id ?? 'none'}_${appState.selectedTileType?.id ?? 'none'}_${appState.offlineMode}_${_tileManager.mapRebuildKey}'), mapController: _controller.mapController, options: MapOptions( - initialCenter: _gpsController.currentLocation ?? _positionManager.initialLocation ?? LatLng(37.7749, -122.4194), + initialCenter: _safeInitialCenter, initialZoom: _positionManager.initialZoom ?? 15, + minZoom: 1.0, maxZoom: (appState.selectedTileType?.maxZoom ?? 18).toDouble(), interactionOptions: _interactionManager.getInteractionOptions(editSession), onPositionChanged: (pos, gesture) { + // Self-heal if the camera has been pushed into a degenerate + // state (non-finite or wildly out-of-range center/zoom). This + // has been observed to occur silently during ordinary panning + // (no crash, no console output) — likely an edge case in + // flutter_map's internal gesture-to-camera math — leaving the + // map showing a blank grey view with no tiles or markers, + // since every subsequent gesture computes its new position as + // a delta from the already-broken center. Snap back to the + // last known-good position the moment this is detected rather + // than requiring the user to discover the GPS button "fixes" + // it (which works only because it supplies a fresh valid + // destination). + if (!isValidCoordinate(pos.center.latitude) || + !isValidCoordinate(pos.center.longitude) || + !isValidZoom(pos.zoom)) { + + debugPrint( + '[MapView] Detected invalid camera position ' + '(lat=${pos.center.latitude}, lng=${pos.center.longitude}, zoom=${pos.zoom}) ' + '- snapping back to last known-good position', + ); + final recoveryCenter = _lastValidCenter ?? _safeInitialCenter; + final recoveryZoom = _lastValidZoom ?? (_positionManager.initialZoom ?? 15.0); + WidgetsBinding.instance.addPostFrameCallback((_) { + try { + _controller.mapController.move(recoveryCenter, recoveryZoom); + } catch (e) { + debugPrint('[MapView] Failed to recover camera position: $e'); + } + }); + return; // Don't process this invalid position further + } + _lastValidCenter = pos.center; + _lastValidZoom = pos.zoom; + setState(() {}); // Instant UI update for zoom, etc. if (gesture) { widget.onUserGesture(); } // Enforce minimum zoom level for add/edit node sheets (but not tag sheet) + if ((session != null || editSession != null) && pos.zoom < kMinZoomForNodeEditingSheets) { // User tried to zoom out below minimum - snap back to minimum zoom _controller.animateTo( diff --git a/test/services/coordinate_validation_test.dart b/test/services/coordinate_validation_test.dart new file mode 100644 index 0000000..d0795fe --- /dev/null +++ b/test/services/coordinate_validation_test.dart @@ -0,0 +1,56 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:deflockapp/services/coordinate_validation.dart'; + +void main() { + group('isValidCoordinate', () { + test('accepts normal in-range values', () { + expect(isValidCoordinate(0), isTrue); + expect(isValidCoordinate(37.7749), isTrue); + expect(isValidCoordinate(-122.4194), isTrue); + expect(isValidCoordinate(180), isTrue); + expect(isValidCoordinate(-180), isTrue); + }); + + test('rejects NaN', () { + expect(isValidCoordinate(double.nan), isFalse); + }); + + test('rejects positive and negative infinity', () { + expect(isValidCoordinate(double.infinity), isFalse); + expect(isValidCoordinate(double.negativeInfinity), isFalse); + }); + + test('rejects out-of-range values', () { + expect(isValidCoordinate(180.1), isFalse); + expect(isValidCoordinate(-180.1), isFalse); + expect(isValidCoordinate(1000), isFalse); + }); + }); + + group('isValidZoom', () { + test('accepts normal in-range values', () { + expect(isValidZoom(0), isTrue); + expect(isValidZoom(15), isTrue); + expect(isValidZoom(25), isTrue); + }); + + test('rejects NaN and Infinite', () { + expect(isValidZoom(double.nan), isFalse); + expect(isValidZoom(double.infinity), isFalse); + expect(isValidZoom(double.negativeInfinity), isFalse); + }); + + test('rejects out-of-range values with default bounds', () { + expect(isValidZoom(-1), isFalse); + expect(isValidZoom(26), isFalse); + }); + + test('respects custom min/max bounds', () { + expect(isValidZoom(0.5, min: 1.0, max: 20.0), isFalse); + expect(isValidZoom(1.0, min: 1.0, max: 20.0), isTrue); + expect(isValidZoom(20.0, min: 1.0, max: 20.0), isTrue); + expect(isValidZoom(20.1, min: 1.0, max: 20.0), isFalse); + }); + }); +} From dd8c9b88ccdc691451beef3ad1e83d377f1fc5f3 Mon Sep 17 00:00:00 2001 From: stopflock Date: Sat, 25 Jul 2026 16:32:25 -0500 Subject: [PATCH 2/3] bump version and changelog --- assets/changelog.json | 5 +++++ pubspec.yaml | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/assets/changelog.json b/assets/changelog.json index 0de981d..9f62ce7 100644 --- a/assets/changelog.json +++ b/assets/changelog.json @@ -1,4 +1,9 @@ { + "2.10.4": { + "content": [ + "• Catch and correct a couple rare map bounds issues" + ] + }, "2.10.3": { "content": [ "• Data loading performance improvements", diff --git a/pubspec.yaml b/pubspec.yaml index b683854..b787d20 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: deflockapp description: Map public surveillance infrastructure with OpenStreetMap publish_to: "none" -version: 2.10.3+58 # The thing after the + is the version code, incremented with each release +version: 2.10.4+59 # The thing after the + is the version code, incremented with each release environment: sdk: ">=3.10.3 <4.0.0" # Resolved dependency floor (Dart 3.10.3 = Flutter 3.38+) From af2a08bf3d81a93e73d508804274f57255ce048b Mon Sep 17 00:00:00 2001 From: stopflock Date: Sat, 25 Jul 2026 16:42:38 -0500 Subject: [PATCH 3/3] better limits for min/max zoom validations, separate lat+lon validation, less verbose comments --- lib/services/coordinate_validation.dart | 30 ++++++---- lib/widgets/map/gps_controller.dart | 11 ++-- lib/widgets/map/map_position_manager.dart | 38 ++++-------- lib/widgets/map_view.dart | 46 ++++++-------- test/services/coordinate_validation_test.dart | 60 ++++++++++++------- 5 files changed, 87 insertions(+), 98 deletions(-) diff --git a/lib/services/coordinate_validation.dart b/lib/services/coordinate_validation.dart index d98a0f9..471d5ba 100644 --- a/lib/services/coordinate_validation.dart +++ b/lib/services/coordinate_validation.dart @@ -1,23 +1,27 @@ /// Shared validation helpers for geographic coordinates and zoom levels. /// -/// Used anywhere a [LatLng]-like value or zoom level comes from an external -/// or platform source (GPS fixes, persisted preferences, live map camera -/// state) that isn't guaranteed to be finite/in-range. Centralizing this -/// avoids subtly inconsistent validation logic being duplicated across the -/// GPS controller, map position persistence, and live camera guards. +/// Used wherever a coordinate or zoom level comes from an external source +/// (GPS fixes, persisted preferences, live map camera state) that isn't +/// guaranteed to be finite/in-range. library; -/// Validate that a latitude or longitude value is finite and within the -/// valid range for geographic coordinates (-180 to 180). -/// -/// Note: this intentionally uses the wider +/-180 range (rather than +/-90 -/// for latitude) so a single helper can validate both latitude and -/// longitude values without the caller needing to track which is which. -bool isValidCoordinate(double value) { +import '../dev_config.dart' show kAbsoluteMaxZoom; + +/// Validate that a latitude value is finite and within -90 to 90. +bool isValidLatitude(double value) { + return !value.isNaN && !value.isInfinite && value >= -90.0 && value <= 90.0; +} + +/// Validate that a longitude value is finite and within -180 to 180. +bool isValidLongitude(double value) { return !value.isNaN && !value.isInfinite && value >= -180.0 && value <= 180.0; } /// Validate that a zoom level is finite and within a sane range. -bool isValidZoom(double zoom, {double min = 0.0, double max = 25.0}) { +/// +/// Defaults to 1.0 to [kAbsoluteMaxZoom]. `num` is used (rather than +/// `double`) so the `int` constant [kAbsoluteMaxZoom] can be used directly +/// as a default parameter value. +bool isValidZoom(double zoom, {num min = 1.0, num max = kAbsoluteMaxZoom}) { return !zoom.isNaN && !zoom.isInfinite && zoom >= min && zoom <= max; } diff --git a/lib/widgets/map/gps_controller.dart b/lib/widgets/map/gps_controller.dart index 0599605..8ad041e 100644 --- a/lib/widgets/map/gps_controller.dart +++ b/lib/widgets/map/gps_controller.dart @@ -189,13 +189,10 @@ class GpsController { /// Handle incoming GPS position void _onPositionReceived(Position position) { - // Guard against malformed fixes from the platform location stack (some - // OEM/fused location providers occasionally emit NaN/Infinite lat/lng, - // especially while a fix is still being acquired). An unvalidated bad - // value here can crash the app at launch (via initialCenter) or corrupt - // the live map camera (via follow-me animateTo), so treat it like any - // other transient location error rather than trusting it. - if (!isValidCoordinate(position.latitude) || !isValidCoordinate(position.longitude)) { + // Reject malformed fixes (occasionally NaN/Infinite from the platform + // location stack) before they reach map state. + if (!isValidLatitude(position.latitude) || !isValidLongitude(position.longitude)) { + debugPrint( '[GpsController] Ignoring invalid GPS position: ' 'lat=${position.latitude}, lng=${position.longitude}', diff --git a/lib/widgets/map/map_position_manager.dart b/lib/widgets/map/map_position_manager.dart index 3c42d0c..465f41e 100644 --- a/lib/widgets/map/map_position_manager.dart +++ b/lib/widgets/map/map_position_manager.dart @@ -3,9 +3,7 @@ import 'package:flutter_map_animations/flutter_map_animations.dart'; import 'package:latlong2/latlong.dart'; import 'package:shared_preferences/shared_preferences.dart'; -import '../../services/coordinate_validation.dart' as coord_validation; - - +import '../../services/coordinate_validation.dart'; /// Manages map position persistence and initial positioning. /// Handles saving/loading last map position and moving to initial locations. @@ -32,10 +30,9 @@ class MapPositionManager { final lng = prefs.getDouble('last_map_longitude'); final zoom = prefs.getDouble('last_map_zoom'); - if (lat != null && lng != null && - coord_validation.isValidCoordinate(lat) && coord_validation.isValidCoordinate(lng)) { - - final validZoom = zoom != null && _isValidZoom(zoom) ? zoom : 15.0; + if (lat != null && lng != null && + isValidLatitude(lat) && isValidLongitude(lng)) { + final validZoom = zoom != null && isValidZoom(zoom) ? zoom : 15.0; _initialLocation = LatLng(lat, lng); _initialZoom = validZoom; debugPrint('[MapPositionManager] Loaded last map position: ${_initialLocation!.latitude}, ${_initialLocation!.longitude}, zoom: $_initialZoom'); @@ -54,10 +51,9 @@ class MapPositionManager { try { final zoom = _initialZoom ?? 15.0; // Double-check coordinates are valid before moving - if (coord_validation.isValidCoordinate(_initialLocation!.latitude) && - coord_validation.isValidCoordinate(_initialLocation!.longitude) && - _isValidZoom(zoom)) { - + if (isValidLatitude(_initialLocation!.latitude) && + isValidLongitude(_initialLocation!.longitude) && + isValidZoom(zoom)) { controller.mapController.move(_initialLocation!, zoom); _hasMovedToInitialLocation = true; debugPrint('[MapPositionManager] Moved to initial location: ${_initialLocation!.latitude}, ${_initialLocation!.longitude}'); @@ -75,10 +71,9 @@ class MapPositionManager { Future saveMapPosition(LatLng location, double zoom) async { try { // Validate coordinates and zoom before saving - if (!coord_validation.isValidCoordinate(location.latitude) || - !coord_validation.isValidCoordinate(location.longitude) || - !_isValidZoom(zoom)) { - + if (!isValidLatitude(location.latitude) || + !isValidLongitude(location.longitude) || + !isValidZoom(zoom)) { debugPrint('[MapPositionManager] Invalid map position, not saving: lat=${location.latitude}, lng=${location.longitude}, zoom=$zoom'); return; } @@ -93,8 +88,6 @@ class MapPositionManager { } } - - /// Clear any stored map position (useful for recovery from invalid data) static Future clearStoredMapPosition() async { try { @@ -107,13 +100,4 @@ class MapPositionManager { debugPrint('[MapPositionManager] Failed to clear stored map position: $e'); } } - - /// Validate that a zoom level is valid - - bool _isValidZoom(double zoom) { - return !zoom.isNaN && - !zoom.isInfinite && - zoom >= 1.0 && - zoom <= 25.0; - } -} \ No newline at end of file +} diff --git a/lib/widgets/map_view.dart b/lib/widgets/map_view.dart index 34b0e95..5d59e96 100644 --- a/lib/widgets/map_view.dart +++ b/lib/widgets/map_view.dart @@ -84,14 +84,12 @@ class MapViewState extends State { // Track map center to clear queue on significant panning LatLng? _lastCenter; - // Track the last known-good (finite, in-range) camera center/zoom so we - // can self-heal if the camera ever gets pushed into a degenerate state - // (e.g. by an edge case in flutter_map's internal gesture-to-camera - // pipeline). This mirrors the validation used for persisted map position - // in MapPositionManager, applied to the *live* camera as well. + // Last known-good camera center/zoom, used to self-heal if the camera + // ever gets pushed into an invalid state. LatLng? _lastValidCenter; double? _lastValidZoom; + // State for proximity alert banner bool _showProximityBanner = false; @@ -287,22 +285,19 @@ class MapViewState extends State { return (!appState.offlineMode && appState.isInSearchMode) ? 60.0 : 0.0; } - /// Get a known-good fallback location for [initialCenter], preferring a - /// validated GPS location, then a validated persisted position, then a - /// hardcoded default. GpsController already validates incoming fixes, but - /// this extra check keeps launch resilient even if that guard is ever - /// bypassed (e.g. future refactor). + /// Known-good fallback location for [initialCenter]: validated GPS + /// location, then validated persisted position, then a hardcoded default. LatLng get _safeInitialCenter { final gpsLocation = _gpsController.currentLocation; if (gpsLocation != null && - isValidCoordinate(gpsLocation.latitude) && - isValidCoordinate(gpsLocation.longitude)) { + isValidLatitude(gpsLocation.latitude) && + isValidLongitude(gpsLocation.longitude)) { return gpsLocation; } final persisted = _positionManager.initialLocation; if (persisted != null && - isValidCoordinate(persisted.latitude) && - isValidCoordinate(persisted.longitude)) { + isValidLatitude(persisted.latitude) && + isValidLongitude(persisted.longitude)) { return persisted; } return LatLng(37.7749, -122.4194); @@ -311,6 +306,7 @@ class MapViewState extends State { + @override void didUpdateWidget(covariant MapView oldWidget) { super.didUpdateWidget(oldWidget); @@ -456,23 +452,15 @@ class MapViewState extends State { maxZoom: (appState.selectedTileType?.maxZoom ?? 18).toDouble(), interactionOptions: _interactionManager.getInteractionOptions(editSession), onPositionChanged: (pos, gesture) { - // Self-heal if the camera has been pushed into a degenerate - // state (non-finite or wildly out-of-range center/zoom). This - // has been observed to occur silently during ordinary panning - // (no crash, no console output) — likely an edge case in - // flutter_map's internal gesture-to-camera math — leaving the - // map showing a blank grey view with no tiles or markers, - // since every subsequent gesture computes its new position as - // a delta from the already-broken center. Snap back to the - // last known-good position the moment this is detected rather - // than requiring the user to discover the GPS button "fixes" - // it (which works only because it supplies a fresh valid - // destination). - if (!isValidCoordinate(pos.center.latitude) || - !isValidCoordinate(pos.center.longitude) || + // Self-heal if the camera center/zoom ever becomes invalid + // (observed to happen silently during ordinary panning, + // leaving a blank grey map with no tiles/markers). Snap back + // to the last known-good position immediately. + if (!isValidLatitude(pos.center.latitude) || + !isValidLongitude(pos.center.longitude) || !isValidZoom(pos.zoom)) { - debugPrint( + '[MapView] Detected invalid camera position ' '(lat=${pos.center.latitude}, lng=${pos.center.longitude}, zoom=${pos.zoom}) ' '- snapping back to last known-good position', diff --git a/test/services/coordinate_validation_test.dart b/test/services/coordinate_validation_test.dart index d0795fe..efe5abd 100644 --- a/test/services/coordinate_validation_test.dart +++ b/test/services/coordinate_validation_test.dart @@ -3,36 +3,51 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:deflockapp/services/coordinate_validation.dart'; void main() { - group('isValidCoordinate', () { - test('accepts normal in-range values', () { - expect(isValidCoordinate(0), isTrue); - expect(isValidCoordinate(37.7749), isTrue); - expect(isValidCoordinate(-122.4194), isTrue); - expect(isValidCoordinate(180), isTrue); - expect(isValidCoordinate(-180), isTrue); + group('isValidLatitude', () { + test('accepts in-range values', () { + expect(isValidLatitude(0), isTrue); + expect(isValidLatitude(37.7749), isTrue); + expect(isValidLatitude(90), isTrue); + expect(isValidLatitude(-90), isTrue); }); - test('rejects NaN', () { - expect(isValidCoordinate(double.nan), isFalse); - }); - - test('rejects positive and negative infinity', () { - expect(isValidCoordinate(double.infinity), isFalse); - expect(isValidCoordinate(double.negativeInfinity), isFalse); + test('rejects NaN and Infinite', () { + expect(isValidLatitude(double.nan), isFalse); + expect(isValidLatitude(double.infinity), isFalse); + expect(isValidLatitude(double.negativeInfinity), isFalse); }); test('rejects out-of-range values', () { - expect(isValidCoordinate(180.1), isFalse); - expect(isValidCoordinate(-180.1), isFalse); - expect(isValidCoordinate(1000), isFalse); + expect(isValidLatitude(90.1), isFalse); + expect(isValidLatitude(-90.1), isFalse); + }); + }); + + group('isValidLongitude', () { + test('accepts in-range values', () { + expect(isValidLongitude(0), isTrue); + expect(isValidLongitude(-122.4194), isTrue); + expect(isValidLongitude(180), isTrue); + expect(isValidLongitude(-180), isTrue); + }); + + test('rejects NaN and Infinite', () { + expect(isValidLongitude(double.nan), isFalse); + expect(isValidLongitude(double.infinity), isFalse); + expect(isValidLongitude(double.negativeInfinity), isFalse); + }); + + test('rejects out-of-range values', () { + expect(isValidLongitude(180.1), isFalse); + expect(isValidLongitude(-180.1), isFalse); }); }); group('isValidZoom', () { - test('accepts normal in-range values', () { - expect(isValidZoom(0), isTrue); + test('accepts in-range values (default bounds: 1.0 to kAbsoluteMaxZoom)', () { + expect(isValidZoom(1), isTrue); expect(isValidZoom(15), isTrue); - expect(isValidZoom(25), isTrue); + expect(isValidZoom(23), isTrue); }); test('rejects NaN and Infinite', () { @@ -42,8 +57,9 @@ void main() { }); test('rejects out-of-range values with default bounds', () { - expect(isValidZoom(-1), isFalse); - expect(isValidZoom(26), isFalse); + expect(isValidZoom(0), isFalse); + expect(isValidZoom(0.5), isFalse); + expect(isValidZoom(24), isFalse); }); test('respects custom min/max bounds', () {