mirror of
https://github.com/FoggedLens/deflock-app.git
synced 2026-08-10 21:20:18 +02:00
@@ -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",
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
/// Shared validation helpers for geographic coordinates and zoom levels.
|
||||
///
|
||||
/// 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;
|
||||
|
||||
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.
|
||||
///
|
||||
/// 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;
|
||||
}
|
||||
@@ -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,18 @@ class GpsController {
|
||||
|
||||
/// Handle incoming GPS position
|
||||
void _onPositionReceived(Position position) {
|
||||
// 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}',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
final newLocation = LatLng(position.latitude, position.longitude);
|
||||
_currentLocation = newLocation;
|
||||
|
||||
@@ -209,6 +223,7 @@ class GpsController {
|
||||
}
|
||||
|
||||
/// Handle GPS stream errors
|
||||
|
||||
void _onPositionError(dynamic error) {
|
||||
debugPrint('[GpsController] Position stream error: $error');
|
||||
if (_hasLocation) {
|
||||
|
||||
@@ -3,6 +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';
|
||||
|
||||
/// Manages map position persistence and initial positioning.
|
||||
/// Handles saving/loading last map position and moving to initial locations.
|
||||
@@ -29,9 +30,9 @@ class MapPositionManager {
|
||||
final lng = prefs.getDouble('last_map_longitude');
|
||||
final zoom = prefs.getDouble('last_map_zoom');
|
||||
|
||||
if (lat != null && lng != null &&
|
||||
_isValidCoordinate(lat) && _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');
|
||||
@@ -50,9 +51,9 @@ class MapPositionManager {
|
||||
try {
|
||||
final zoom = _initialZoom ?? 15.0;
|
||||
// Double-check coordinates are valid before moving
|
||||
if (_isValidCoordinate(_initialLocation!.latitude) &&
|
||||
_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}');
|
||||
@@ -70,9 +71,9 @@ class MapPositionManager {
|
||||
Future<void> saveMapPosition(LatLng location, double zoom) async {
|
||||
try {
|
||||
// Validate coordinates and zoom before saving
|
||||
if (!_isValidCoordinate(location.latitude) ||
|
||||
!_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;
|
||||
}
|
||||
@@ -87,8 +88,6 @@ class MapPositionManager {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// Clear any stored map position (useful for recovery from invalid data)
|
||||
static Future<void> clearStoredMapPosition() async {
|
||||
try {
|
||||
@@ -101,20 +100,4 @@ class MapPositionManager {
|
||||
debugPrint('[MapPositionManager] Failed to clear stored map position: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 &&
|
||||
zoom >= 1.0 &&
|
||||
zoom <= 25.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,13 @@ class MapViewState extends State<MapView> {
|
||||
|
||||
// Track map center to clear queue on significant panning
|
||||
LatLng? _lastCenter;
|
||||
|
||||
// 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;
|
||||
@@ -275,6 +285,27 @@ class MapViewState extends State<MapView> {
|
||||
return (!appState.offlineMode && appState.isInSearchMode) ? 60.0 : 0.0;
|
||||
}
|
||||
|
||||
/// 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 &&
|
||||
isValidLatitude(gpsLocation.latitude) &&
|
||||
isValidLongitude(gpsLocation.longitude)) {
|
||||
return gpsLocation;
|
||||
}
|
||||
final persisted = _positionManager.initialLocation;
|
||||
if (persisted != null &&
|
||||
isValidLatitude(persisted.latitude) &&
|
||||
isValidLongitude(persisted.longitude)) {
|
||||
return persisted;
|
||||
}
|
||||
return LatLng(37.7749, -122.4194);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant MapView oldWidget) {
|
||||
@@ -414,18 +445,47 @@ class MapViewState extends State<MapView> {
|
||||
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 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',
|
||||
);
|
||||
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(
|
||||
|
||||
+1
-1
@@ -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+)
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:deflockapp/services/coordinate_validation.dart';
|
||||
|
||||
void main() {
|
||||
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 and Infinite', () {
|
||||
expect(isValidLatitude(double.nan), isFalse);
|
||||
expect(isValidLatitude(double.infinity), isFalse);
|
||||
expect(isValidLatitude(double.negativeInfinity), isFalse);
|
||||
});
|
||||
|
||||
test('rejects out-of-range values', () {
|
||||
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 in-range values (default bounds: 1.0 to kAbsoluteMaxZoom)', () {
|
||||
expect(isValidZoom(1), isTrue);
|
||||
expect(isValidZoom(15), isTrue);
|
||||
expect(isValidZoom(23), 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(0), isFalse);
|
||||
expect(isValidZoom(0.5), isFalse);
|
||||
expect(isValidZoom(24), 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user