diff --git a/assets/changelog.json b/assets/changelog.json index ac176f2..4c8c987 100644 --- a/assets/changelog.json +++ b/assets/changelog.json @@ -2,7 +2,9 @@ "2.1.3": { "content": [ "• Fixed nodes losing their greyed-out appearance when map is moved while viewing a node's tag sheet", - "• Nodes now stay properly dimmed when viewing tags until the sheet is closed, regardless of map movement" + "• Improved GPS location handling - follow-me button is now greyed out when location is unavailable", + "• Added approximate location fallback - if precise location is denied, app will use approximate location", + "• Higher frequency GPS updates when follow-me modes are active for smoother tracking (1-second updates vs 5-second)" ] }, "2.1.2": { diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 913f422..49305c3 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -433,16 +433,18 @@ class _HomeScreenState extends State with TickerProviderStateMixin { IconButton( tooltip: _getFollowMeTooltip(appState.followMeMode), icon: Icon(_getFollowMeIcon(appState.followMeMode)), - onPressed: () { - final oldMode = appState.followMeMode; - final newMode = _getNextFollowMeMode(oldMode); - debugPrint('[HomeScreen] Follow mode changed: $oldMode → $newMode'); - appState.setFollowMeMode(newMode); - // If enabling follow-me, retry location init in case permission was granted - if (newMode != FollowMeMode.off) { - _mapViewKey.currentState?.retryLocationInit(); - } - }, + onPressed: _mapViewKey.currentState?.hasLocation == true + ? () { + final oldMode = appState.followMeMode; + final newMode = _getNextFollowMeMode(oldMode); + debugPrint('[HomeScreen] Follow mode changed: $oldMode → $newMode'); + appState.setFollowMeMode(newMode); + // If enabling follow-me, retry location init in case permission was granted + if (newMode != FollowMeMode.off) { + _mapViewKey.currentState?.retryLocationInit(); + } + } + : null, // Grey out when no location ), AnimatedBuilder( animation: LocalizationService.instance, @@ -490,6 +492,10 @@ class _HomeScreenState extends State with TickerProviderStateMixin { _isNodeLimitActive = isLimited; }); }, + onLocationStatusChanged: () { + // Re-render when location status changes (for follow-me button state) + setState(() {}); + }, onUserGesture: () { // Only clear selected node if tag sheet is not open // This prevents nodes from losing their grey-out when map is moved while viewing tags diff --git a/lib/widgets/map/gps_controller.dart b/lib/widgets/map/gps_controller.dart index caf8edb..328c2c6 100644 --- a/lib/widgets/map/gps_controller.dart +++ b/lib/widgets/map/gps_controller.dart @@ -15,29 +15,91 @@ import '../../models/node_profile.dart'; class GpsController { StreamSubscription? _positionSub; LatLng? _currentLatLng; + bool _hasLocation = false; + Timer? _retryTimer; /// Get the current GPS location (if available) LatLng? get currentLocation => _currentLatLng; + + /// Whether we currently have a valid GPS location + bool get hasLocation => _hasLocation; /// Initialize GPS location tracking Future initializeLocation() async { - final perm = await Geolocator.requestPermission(); - if (perm == LocationPermission.denied || - perm == LocationPermission.deniedForever) { - debugPrint('[GpsController] Location permission denied'); + // Check if location services are enabled first + bool serviceEnabled = await Geolocator.isLocationServiceEnabled(); + if (!serviceEnabled) { + debugPrint('[GpsController] Location services disabled'); + _hasLocation = false; + _scheduleRetry(); return; } - _positionSub = Geolocator.getPositionStream().listen((Position position) { - final latLng = LatLng(position.latitude, position.longitude); - _currentLatLng = latLng; - debugPrint('[GpsController] GPS position updated: ${latLng.latitude}, ${latLng.longitude}'); - }); + final perm = await Geolocator.requestPermission(); + debugPrint('[GpsController] Location permission result: $perm'); + + if (perm == LocationPermission.denied || + perm == LocationPermission.deniedForever) { + debugPrint('[GpsController] Precise location permission denied, trying approximate location'); + + // Try approximate location as fallback + try { + await Geolocator.getCurrentPosition( + desiredAccuracy: LocationAccuracy.low, + timeLimit: const Duration(seconds: 10), + ); + debugPrint('[GpsController] Approximate location available, proceeding with location stream'); + // If we got here, approximate location works, continue with stream setup below + } catch (e) { + debugPrint('[GpsController] Approximate location also unavailable: $e'); + _hasLocation = false; + _scheduleRetry(); + return; + } + } else if (perm == LocationPermission.whileInUse || perm == LocationPermission.always) { + debugPrint('[GpsController] Location permission granted: $perm'); + // Permission is granted, continue with normal setup + } else { + debugPrint('[GpsController] Unexpected permission state: $perm'); + _hasLocation = false; + _scheduleRetry(); + return; + } + + _positionSub?.cancel(); // Cancel any existing subscription + debugPrint('[GpsController] Starting GPS position stream'); + _positionSub = Geolocator.getPositionStream( + locationSettings: const LocationSettings( + accuracy: LocationAccuracy.high, + distanceFilter: 5, // Update when moved at least 5 meters (standard frequency) + ), + ).listen( + (Position position) { + final latLng = LatLng(position.latitude, position.longitude); + _currentLatLng = latLng; + if (!_hasLocation) { + debugPrint('[GpsController] GPS location acquired'); + } + _hasLocation = true; + _cancelRetry(); // Got location, stop retrying + debugPrint('[GpsController] GPS position updated: ${latLng.latitude}, ${latLng.longitude} (accuracy: ${position.accuracy}m)'); + }, + onError: (error) { + debugPrint('[GpsController] Position stream error: $error'); + if (_hasLocation) { + debugPrint('[GpsController] GPS location lost, starting retry attempts'); + } + _hasLocation = false; + _currentLatLng = null; + _scheduleRetry(); // Lost location, start retrying + }, + ); } /// Retry location initialization (e.g., after permission granted) Future retryLocationInit() async { - debugPrint('[GpsController] Retrying location initialization'); + debugPrint('[GpsController] Manual retry of location initialization'); + _cancelRetry(); // Cancel automatic retries, this is a manual retry await initializeLocation(); } @@ -50,6 +112,9 @@ class GpsController { }) { debugPrint('[GpsController] Follow-me mode changed: $oldMode → $newMode'); + // Restart position stream with appropriate frequency for new mode + _restartPositionStream(newMode); + // Only act when follow-me is first enabled and we have a current location if (newMode != FollowMeMode.off && oldMode == FollowMeMode.off && @@ -98,6 +163,8 @@ class GpsController { }) { final latLng = LatLng(position.latitude, position.longitude); _currentLatLng = latLng; + _hasLocation = true; + _cancelRetry(); // Got location, stop any retries // Notify that location was updated (for setState, etc.) onLocationUpdated(); @@ -169,38 +236,184 @@ class GpsController { VoidCallback? onMapMovedProgrammatically, }) async { - final perm = await Geolocator.requestPermission(); - if (perm == LocationPermission.denied || - perm == LocationPermission.deniedForever) { - debugPrint('[GpsController] Location permission denied'); + // Check if location services are enabled first + bool serviceEnabled = await Geolocator.isLocationServiceEnabled(); + if (!serviceEnabled) { + debugPrint('[GpsController] Location services disabled'); + _hasLocation = false; + _scheduleRetry(); return; } - _positionSub = Geolocator.getPositionStream().listen((Position position) { - // Get the current follow-me mode from the app state each time - final currentFollowMeMode = getCurrentFollowMeMode(); - final proximityAlertsEnabled = getProximityAlertsEnabled(); - final proximityAlertDistance = getProximityAlertDistance(); - final nearbyNodes = getNearbyNodes(); - final enabledProfiles = getEnabledProfiles(); - processPositionUpdate( - position: position, - followMeMode: currentFollowMeMode, - controller: controller, - onLocationUpdated: onLocationUpdated, - proximityAlertsEnabled: proximityAlertsEnabled, - proximityAlertDistance: proximityAlertDistance, - nearbyNodes: nearbyNodes, - enabledProfiles: enabledProfiles, - onMapMovedProgrammatically: onMapMovedProgrammatically, - ); + final perm = await Geolocator.requestPermission(); + debugPrint('[GpsController] Location permission result: $perm'); + + if (perm == LocationPermission.denied || + perm == LocationPermission.deniedForever) { + debugPrint('[GpsController] Precise location permission denied, trying approximate location'); + + // Try approximate location as fallback + try { + await Geolocator.getCurrentPosition( + desiredAccuracy: LocationAccuracy.low, + timeLimit: const Duration(seconds: 10), + ); + debugPrint('[GpsController] Approximate location available, proceeding with location stream'); + // If we got here, approximate location works, continue with stream setup below + } catch (e) { + debugPrint('[GpsController] Approximate location also unavailable: $e'); + _hasLocation = false; + _scheduleRetry(); + return; + } + } else if (perm == LocationPermission.whileInUse || perm == LocationPermission.always) { + debugPrint('[GpsController] Location permission granted: $perm'); + // Permission is granted, continue with normal setup + } else { + debugPrint('[GpsController] Unexpected permission state: $perm'); + _hasLocation = false; + _scheduleRetry(); + return; + } + + _positionSub?.cancel(); // Cancel any existing subscription + debugPrint('[GpsController] Starting GPS position stream'); + _positionSub = Geolocator.getPositionStream( + locationSettings: const LocationSettings( + accuracy: LocationAccuracy.high, + distanceFilter: 5, // Update when moved at least 5 meters (standard frequency) + ), + ).listen( + (Position position) { + if (!_hasLocation) { + debugPrint('[GpsController] GPS location acquired'); + } + _hasLocation = true; + _cancelRetry(); // Got location, stop retrying + + // Get the current follow-me mode from the app state each time + final currentFollowMeMode = getCurrentFollowMeMode(); + final proximityAlertsEnabled = getProximityAlertsEnabled(); + final proximityAlertDistance = getProximityAlertDistance(); + final nearbyNodes = getNearbyNodes(); + final enabledProfiles = getEnabledProfiles(); + processPositionUpdate( + position: position, + followMeMode: currentFollowMeMode, + controller: controller, + onLocationUpdated: onLocationUpdated, + proximityAlertsEnabled: proximityAlertsEnabled, + proximityAlertDistance: proximityAlertDistance, + nearbyNodes: nearbyNodes, + enabledProfiles: enabledProfiles, + onMapMovedProgrammatically: onMapMovedProgrammatically, + ); + }, + onError: (error) { + debugPrint('[GpsController] Position stream error: $error'); + if (_hasLocation) { + debugPrint('[GpsController] GPS location lost, starting retry attempts'); + } + _hasLocation = false; + _currentLatLng = null; + onLocationUpdated(); // Notify UI that location was lost + _scheduleRetry(); // Lost location, start retrying + }, + ); + } + + /// Schedule periodic retry attempts to get location + void _scheduleRetry() { + _retryTimer?.cancel(); + _retryTimer = Timer.periodic(const Duration(seconds: 15), (timer) { + debugPrint('[GpsController] Automatic retry of location initialization (attempt ${timer.tick})'); + initializeLocation(); // This will cancel the timer if successful }); } + + /// Cancel any scheduled retry attempts + void _cancelRetry() { + if (_retryTimer != null) { + debugPrint('[GpsController] Canceling location retry timer'); + _retryTimer?.cancel(); + _retryTimer = null; + } + } + + /// Restart position stream with frequency optimized for follow-me mode + void _restartPositionStream(FollowMeMode followMeMode) { + if (_positionSub == null || !_hasLocation) { + // No active stream or no location - let normal initialization handle it + return; + } + + _positionSub?.cancel(); + + // Use higher frequency when follow-me is enabled + if (followMeMode != FollowMeMode.off) { + debugPrint('[GpsController] Starting high-frequency GPS updates for follow-me mode'); + _positionSub = Geolocator.getPositionStream( + locationSettings: const LocationSettings( + accuracy: LocationAccuracy.high, + distanceFilter: 1, // Update when moved at least 1 meter + ), + ).listen( + (Position position) { + final latLng = LatLng(position.latitude, position.longitude); + _currentLatLng = latLng; + if (!_hasLocation) { + debugPrint('[GpsController] GPS location acquired'); + } + _hasLocation = true; + _cancelRetry(); // Got location, stop retrying + debugPrint('[GpsController] GPS position updated: ${latLng.latitude}, ${latLng.longitude} (accuracy: ${position.accuracy}m)'); + }, + onError: (error) { + debugPrint('[GpsController] Position stream error: $error'); + if (_hasLocation) { + debugPrint('[GpsController] GPS location lost, starting retry attempts'); + } + _hasLocation = false; + _currentLatLng = null; + _scheduleRetry(); // Lost location, start retrying + }, + ); + } else { + debugPrint('[GpsController] Starting standard-frequency GPS updates'); + _positionSub = Geolocator.getPositionStream( + locationSettings: const LocationSettings( + accuracy: LocationAccuracy.high, + distanceFilter: 5, // Update when moved at least 5 meters + ), + ).listen( + (Position position) { + final latLng = LatLng(position.latitude, position.longitude); + _currentLatLng = latLng; + if (!_hasLocation) { + debugPrint('[GpsController] GPS location acquired'); + } + _hasLocation = true; + _cancelRetry(); // Got location, stop retrying + debugPrint('[GpsController] GPS position updated: ${latLng.latitude}, ${latLng.longitude} (accuracy: ${position.accuracy}m)'); + }, + onError: (error) { + debugPrint('[GpsController] Position stream error: $error'); + if (_hasLocation) { + debugPrint('[GpsController] GPS location lost, starting retry attempts'); + } + _hasLocation = false; + _currentLatLng = null; + _scheduleRetry(); // Lost location, start retrying + }, + ); + } + } /// Dispose of GPS resources void dispose() { _positionSub?.cancel(); _positionSub = null; + _cancelRetry(); debugPrint('[GpsController] GPS controller disposed'); } } \ No newline at end of file diff --git a/lib/widgets/map_view.dart b/lib/widgets/map_view.dart index 7e495fe..6ceee1d 100644 --- a/lib/widgets/map_view.dart +++ b/lib/widgets/map_view.dart @@ -44,6 +44,7 @@ class MapView extends StatefulWidget { this.onSuspectedLocationTap, this.onSearchPressed, this.onNodeLimitChanged, + this.onLocationStatusChanged, }); final FollowMeMode followMeMode; @@ -54,6 +55,7 @@ class MapView extends StatefulWidget { final void Function(SuspectedLocation)? onSuspectedLocationTap; final VoidCallback? onSearchPressed; final void Function(bool isLimited)? onNodeLimitChanged; + final VoidCallback? onLocationStatusChanged; @override State createState() => MapViewState(); @@ -121,7 +123,10 @@ class MapViewState extends State { _gpsController.initializeWithCallback( followMeMode: widget.followMeMode, controller: _controller, - onLocationUpdated: () => setState(() {}), + onLocationUpdated: () { + setState(() {}); + widget.onLocationStatusChanged?.call(); // Notify parent about location status change + }, getCurrentFollowMeMode: () { // Use mounted check to avoid calling context when widget is disposed if (mounted) { @@ -230,6 +235,9 @@ class MapViewState extends State { LatLng? getUserLocation() { return _gpsController.currentLocation; } + + /// Whether we currently have a valid GPS location + bool get hasLocation => _gpsController.hasLocation; /// Expose static methods from MapPositionManager for external access static Future clearStoredMapPosition() =>