diff --git a/assets/changelog.json b/assets/changelog.json index 4c8c987..413578c 100644 --- a/assets/changelog.json +++ b/assets/changelog.json @@ -1,10 +1,15 @@ { + "2.2.0": { + "content": [ + "• Fixed follow-me sync issues where tracking would sometimes stop working after mode changes" + ] + }, "2.1.3": { "content": [ "• Fixed nodes losing their greyed-out appearance when map is moved while viewing a node's tag sheet", "• 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)" + "• Higher frequency GPS updates when follow-me modes are active for smoother tracking (1-meter updates vs 5-meter)" ] }, "2.1.2": { diff --git a/lib/widgets/map/gps_controller.dart b/lib/widgets/map/gps_controller.dart index 328c2c6..1ce134a 100644 --- a/lib/widgets/map/gps_controller.dart +++ b/lib/widgets/map/gps_controller.dart @@ -14,219 +14,34 @@ import '../../models/node_profile.dart'; /// Handles GPS permissions, position streams, and follow-me behavior. class GpsController { StreamSubscription? _positionSub; - LatLng? _currentLatLng; - bool _hasLocation = false; Timer? _retryTimer; + + // Location state + LatLng? _currentLocation; + bool _hasLocation = false; + + // Current tracking settings + FollowMeMode _currentFollowMeMode = FollowMeMode.off; + + // Callbacks - set once during initialization + AnimatedMapController? _mapController; + VoidCallback? _onLocationUpdated; + FollowMeMode Function()? _getCurrentFollowMeMode; + bool Function()? _getProximityAlertsEnabled; + int Function()? _getProximityAlertDistance; + List Function()? _getNearbyNodes; + List Function()? _getEnabledProfiles; + VoidCallback? _onMapMovedProgrammatically; /// Get the current GPS location (if available) - LatLng? get currentLocation => _currentLatLng; + LatLng? get currentLocation => _currentLocation; /// Whether we currently have a valid GPS location bool get hasLocation => _hasLocation; - /// Initialize GPS location tracking - Future initializeLocation() async { - // Check if location services are enabled first - bool serviceEnabled = await Geolocator.isLocationServiceEnabled(); - if (!serviceEnabled) { - debugPrint('[GpsController] Location services disabled'); - _hasLocation = false; - _scheduleRetry(); - return; - } - - 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] Manual retry of location initialization'); - _cancelRetry(); // Cancel automatic retries, this is a manual retry - await initializeLocation(); - } - - /// Handle follow-me mode changes and animate map accordingly - void handleFollowMeModeChange({ - required FollowMeMode newMode, - required FollowMeMode oldMode, - required AnimatedMapController controller, - VoidCallback? onMapMovedProgrammatically, - }) { - 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 && - _currentLatLng != null) { - - try { - if (newMode == FollowMeMode.follow) { - controller.animateTo( - dest: _currentLatLng!, - zoom: controller.mapController.camera.zoom, - duration: kFollowMeAnimationDuration, - curve: Curves.easeOut, - ); - onMapMovedProgrammatically?.call(); - } else if (newMode == FollowMeMode.rotating) { - // When switching to rotating mode, reset to north-up first - controller.animateTo( - dest: _currentLatLng!, - zoom: controller.mapController.camera.zoom, - rotation: 0.0, - duration: kFollowMeAnimationDuration, - curve: Curves.easeOut, - ); - onMapMovedProgrammatically?.call(); - } - } catch (e) { - debugPrint('[GpsController] MapController not ready for follow-me change: $e'); - } - } - } - - /// Process GPS position updates and handle follow-me animations - void processPositionUpdate({ - required Position position, - required FollowMeMode followMeMode, - required AnimatedMapController controller, - required VoidCallback onLocationUpdated, - // Optional parameters for proximity alerts - bool proximityAlertsEnabled = false, - int proximityAlertDistance = 200, - List nearbyNodes = const [], - List enabledProfiles = const [], - // Optional callback when map is moved programmatically - VoidCallback? onMapMovedProgrammatically, - - }) { - 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(); - - // Check proximity alerts if enabled - if (proximityAlertsEnabled && nearbyNodes.isNotEmpty) { - ProximityAlertService().checkProximity( - userLocation: latLng, - nodes: nearbyNodes, - enabledProfiles: enabledProfiles, - alertDistance: proximityAlertDistance, - ); - } - - // Handle follow-me animations if enabled - use current mode from app state - if (followMeMode != FollowMeMode.off) { - debugPrint('[GpsController] GPS position update: ${latLng.latitude}, ${latLng.longitude}, follow-me: $followMeMode'); - WidgetsBinding.instance.addPostFrameCallback((_) { - try { - if (followMeMode == FollowMeMode.follow) { - // Follow position only, keep current rotation - controller.animateTo( - dest: latLng, - zoom: controller.mapController.camera.zoom, - rotation: controller.mapController.camera.rotation, - duration: kFollowMeAnimationDuration, - curve: Curves.easeOut, - ); - - // Notify that we moved the map programmatically (for node refresh) - onMapMovedProgrammatically?.call(); - } else if (followMeMode == FollowMeMode.rotating) { - // Follow position and rotation based on heading - final heading = position.heading; - final speed = position.speed; // Speed in m/s - - // Only apply rotation if moving fast enough to avoid wild spinning when stationary - final shouldRotate = !speed.isNaN && speed >= kMinSpeedForRotationMps && !heading.isNaN; - final rotation = shouldRotate ? -heading : controller.mapController.camera.rotation; - - controller.animateTo( - dest: latLng, - zoom: controller.mapController.camera.zoom, - rotation: rotation, - duration: kFollowMeAnimationDuration, - curve: Curves.easeOut, - ); - - // Notify that we moved the map programmatically (for node refresh) - onMapMovedProgrammatically?.call(); - } - } catch (e) { - debugPrint('[GpsController] MapController not ready for position animation: $e'); - } - }); - } - } - - /// Initialize GPS with custom position processing callback - Future initializeWithCallback({ - required FollowMeMode followMeMode, - required AnimatedMapController controller, + /// Initialize GPS tracking with callbacks for UI integration + Future initialize({ + required AnimatedMapController mapController, required VoidCallback onLocationUpdated, required FollowMeMode Function() getCurrentFollowMeMode, required bool Function() getProximityAlertsEnabled, @@ -234,92 +49,262 @@ class GpsController { required List Function() getNearbyNodes, required List Function() getEnabledProfiles, VoidCallback? onMapMovedProgrammatically, - }) async { - // Check if location services are enabled first + debugPrint('[GpsController] Initializing GPS controller'); + + // Store callbacks + _mapController = mapController; + _onLocationUpdated = onLocationUpdated; + _getCurrentFollowMeMode = getCurrentFollowMeMode; + _getProximityAlertsEnabled = getProximityAlertsEnabled; + _getProximityAlertDistance = getProximityAlertDistance; + _getNearbyNodes = getNearbyNodes; + _getEnabledProfiles = getEnabledProfiles; + _onMapMovedProgrammatically = onMapMovedProgrammatically; + + // Start location tracking + await _startLocationTracking(); + } + + /// Update follow-me mode and restart tracking with appropriate frequency + void updateFollowMeMode({ + required FollowMeMode newMode, + required FollowMeMode oldMode, + }) { + debugPrint('[GpsController] Follow-me mode changed: $oldMode → $newMode'); + _currentFollowMeMode = newMode; + + // Restart tracking with new frequency + _startLocationTracking(); + + // Handle initial animation when follow-me is first enabled + if (newMode != FollowMeMode.off && + oldMode == FollowMeMode.off && + _currentLocation != null && + _mapController != null) { + + _animateToCurrentLocation(newMode); + } + } + + /// Manually retry location initialization (e.g., after permission granted) + Future retryLocationInit() async { + debugPrint('[GpsController] Manual retry of location initialization'); + _cancelRetry(); + await _startLocationTracking(); + } + + /// Start or restart GPS location tracking + Future _startLocationTracking() async { + _stopLocationTracking(); // Clean slate + + // Check location services availability + if (!await _checkLocationAvailability()) { + _scheduleRetry(); + return; + } + + // Determine frequency settings based on current follow-me mode + final settings = _getLocationSettings(); + + debugPrint('[GpsController] Starting GPS position stream (${_currentFollowMeMode == FollowMeMode.off ? 'standard' : 'high'} frequency)'); + + try { + _positionSub = Geolocator.getPositionStream(locationSettings: settings).listen( + _onPositionReceived, + onError: _onPositionError, + ); + } catch (e) { + debugPrint('[GpsController] Failed to start position stream: $e'); + _hasLocation = false; + _scheduleRetry(); + } + } + + /// Check if location services are available and permissions are granted + Future _checkLocationAvailability() async { + // Check if location services are enabled bool serviceEnabled = await Geolocator.isLocationServiceEnabled(); if (!serviceEnabled) { debugPrint('[GpsController] Location services disabled'); _hasLocation = false; - _scheduleRetry(); - return; + return false; } + // Check permissions 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'); - + if (perm == LocationPermission.whileInUse || perm == LocationPermission.always) { + debugPrint('[GpsController] Location permission granted: $perm'); + return true; + } + + if (perm == LocationPermission.denied || perm == LocationPermission.deniedForever) { // Try approximate location as fallback + debugPrint('[GpsController] Precise location permission denied, trying approximate location'); 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 + debugPrint('[GpsController] Approximate location available'); + return true; } 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; } + + debugPrint('[GpsController] Location unavailable, permission: $perm'); + _hasLocation = false; + return false; + } - _positionSub?.cancel(); // Cancel any existing subscription - debugPrint('[GpsController] Starting GPS position stream'); - _positionSub = Geolocator.getPositionStream( - locationSettings: const LocationSettings( + /// Get location settings based on current follow-me mode + LocationSettings _getLocationSettings() { + if (_currentFollowMeMode != FollowMeMode.off) { + // High frequency for follow-me modes + return 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'); + distanceFilter: 1, // Update when moved 1+ meter + ); + } else { + // Standard frequency when not following + return const LocationSettings( + accuracy: LocationAccuracy.high, + distanceFilter: 5, // Update when moved 5+ meters + ); + } + } + + /// Handle position updates from GPS stream + void _onPositionReceived(Position position) { + final newLocation = LatLng(position.latitude, position.longitude); + _currentLocation = newLocation; + + if (!_hasLocation) { + debugPrint('[GpsController] GPS location acquired'); + } + _hasLocation = true; + _cancelRetry(); + + debugPrint('[GpsController] GPS position updated: ${newLocation.latitude}, ${newLocation.longitude} (accuracy: ${position.accuracy}m)'); + + // Notify UI that location was updated + _onLocationUpdated?.call(); + + // Handle proximity alerts + _checkProximityAlerts(newLocation); + + // Handle follow-me animations + _handleFollowMeUpdate(position, newLocation); + } + + /// Handle position stream errors + void _onPositionError(error) { + debugPrint('[GpsController] Position stream error: $error'); + if (_hasLocation) { + debugPrint('[GpsController] GPS location lost, starting retry attempts'); + } + _hasLocation = false; + _currentLocation = null; + _onLocationUpdated?.call(); + _scheduleRetry(); + } + + /// Check proximity alerts if enabled + void _checkProximityAlerts(LatLng userLocation) { + final proximityEnabled = _getProximityAlertsEnabled?.call() ?? false; + final nearbyNodes = _getNearbyNodes?.call() ?? []; + + if (proximityEnabled && nearbyNodes.isNotEmpty) { + final alertDistance = _getProximityAlertDistance?.call() ?? 200; + final enabledProfiles = _getEnabledProfiles?.call() ?? []; + + ProximityAlertService().checkProximity( + userLocation: userLocation, + nodes: nearbyNodes, + enabledProfiles: enabledProfiles, + alertDistance: alertDistance, + ); + } + } + + /// Handle follow-me animations and map updates + void _handleFollowMeUpdate(Position position, LatLng location) { + // Get current follow-me mode from app state (in case it changed) + final followMeMode = _getCurrentFollowMeMode?.call() ?? FollowMeMode.off; + + if (followMeMode == FollowMeMode.off || _mapController == null) { + return; // Not following or no map controller + } + + debugPrint('[GpsController] GPS position update for follow-me: ${location.latitude}, ${location.longitude}, mode: $followMeMode'); + + WidgetsBinding.instance.addPostFrameCallback((_) { + try { + if (followMeMode == FollowMeMode.follow) { + // Follow position only, preserve current rotation + _mapController!.animateTo( + dest: location, + zoom: _mapController!.mapController.camera.zoom, + rotation: _mapController!.mapController.camera.rotation, + duration: kFollowMeAnimationDuration, + curve: Curves.easeOut, + ); + } else if (followMeMode == FollowMeMode.rotating) { + // Follow position and rotation based on heading + final heading = position.heading; + final speed = position.speed; + + // Only apply rotation if moving fast enough to avoid wild spinning + final shouldRotate = !speed.isNaN && speed >= kMinSpeedForRotationMps && !heading.isNaN; + final rotation = shouldRotate ? -heading : _mapController!.mapController.camera.rotation; + + _mapController!.animateTo( + dest: location, + zoom: _mapController!.mapController.camera.zoom, + rotation: rotation, + duration: kFollowMeAnimationDuration, + curve: Curves.easeOut, + ); } - _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, + // Notify that we moved the map programmatically (for node refresh) + _onMapMovedProgrammatically?.call(); + } catch (e) { + debugPrint('[GpsController] MapController not ready for position animation: $e'); + } + }); + } + + /// Animate to current location when follow-me is first enabled + void _animateToCurrentLocation(FollowMeMode mode) { + if (_currentLocation == null || _mapController == null) return; + + try { + if (mode == FollowMeMode.follow) { + _mapController!.animateTo( + dest: _currentLocation!, + zoom: _mapController!.mapController.camera.zoom, + duration: kFollowMeAnimationDuration, + curve: Curves.easeOut, ); - }, - 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 - }, - ); + } else if (mode == FollowMeMode.rotating) { + // When switching to rotating mode, reset to north-up first + _mapController!.animateTo( + dest: _currentLocation!, + zoom: _mapController!.mapController.camera.zoom, + rotation: 0.0, + duration: kFollowMeAnimationDuration, + curve: Curves.easeOut, + ); + } + + _onMapMovedProgrammatically?.call(); + } catch (e) { + debugPrint('[GpsController] MapController not ready for initial follow-me animation: $e'); + } } /// Schedule periodic retry attempts to get location @@ -327,7 +312,7 @@ class GpsController { _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 + _startLocationTracking(); }); } @@ -340,80 +325,26 @@ class GpsController { } } - /// 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() { + /// Stop location tracking and clean up + void _stopLocationTracking() { _positionSub?.cancel(); _positionSub = null; + } + + /// Dispose of all GPS resources + void dispose() { + debugPrint('[GpsController] Disposing GPS controller'); + _stopLocationTracking(); _cancelRetry(); - debugPrint('[GpsController] GPS controller disposed'); + + // Clear callbacks + _mapController = null; + _onLocationUpdated = null; + _getCurrentFollowMeMode = null; + _getProximityAlertsEnabled = null; + _getProximityAlertDistance = null; + _getNearbyNodes = null; + _getEnabledProfiles = null; + _onMapMovedProgrammatically = null; } } \ No newline at end of file diff --git a/lib/widgets/map_view.dart b/lib/widgets/map_view.dart index 6ceee1d..a14dbe3 100644 --- a/lib/widgets/map_view.dart +++ b/lib/widgets/map_view.dart @@ -120,9 +120,8 @@ class MapViewState extends State { }); // Initialize GPS with callback for position updates and follow-me - _gpsController.initializeWithCallback( - followMeMode: widget.followMeMode, - controller: _controller, + _gpsController.initialize( + mapController: _controller, onLocationUpdated: () { setState(() {}); widget.onLocationStatusChanged?.call(); // Notify parent about location status change @@ -195,7 +194,6 @@ class MapViewState extends State { // Refresh nodes when GPS controller moves the map _refreshNodesFromProvider(); }, - ); // Fetch initial cameras @@ -267,13 +265,9 @@ class MapViewState extends State { super.didUpdateWidget(oldWidget); // Handle follow-me mode changes - only if it actually changed if (widget.followMeMode != oldWidget.followMeMode) { - _gpsController.handleFollowMeModeChange( + _gpsController.updateFollowMeMode( newMode: widget.followMeMode, oldMode: oldWidget.followMeMode, - controller: _controller, - onMapMovedProgrammatically: () { - _refreshNodesFromProvider(); - }, ); } } diff --git a/pubspec.yaml b/pubspec.yaml index c49af21..82f0dd3 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.1.3+36 # The thing after the + is the version code, incremented with each release +version: 2.2.0+36 # The thing after the + is the version code, incremented with each release environment: sdk: ">=3.5.0 <4.0.0" # oauth2_client 4.x needs Dart 3.5+