mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 16:30:28 +00:00
feat: MeshCore SAR - Flutter BLE mesh radio companion app
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
517
lib/services/location_tracking_service.dart
Normal file
517
lib/services/location_tracking_service.dart
Normal file
@@ -0,0 +1,517 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'meshcore_ble_service.dart';
|
||||
|
||||
/// Centralized location tracking service for MeshCore SAR
|
||||
///
|
||||
/// Handles GPS tracking, distance thresholds, background updates,
|
||||
/// and location broadcasting to the mesh network.
|
||||
///
|
||||
/// Features:
|
||||
/// - Singleton pattern for app-wide access
|
||||
/// - Configurable distance thresholds (min/max)
|
||||
/// - Configurable time intervals
|
||||
/// - Permission handling
|
||||
/// - SharedPreferences persistence
|
||||
/// - MeshCore mesh network integration
|
||||
/// - Real-time position updates via callbacks
|
||||
class LocationTrackingService {
|
||||
// ============================================================================
|
||||
// Singleton Pattern
|
||||
// ============================================================================
|
||||
|
||||
static final LocationTrackingService _instance =
|
||||
LocationTrackingService._internal();
|
||||
|
||||
/// Get the singleton instance
|
||||
factory LocationTrackingService() => _instance;
|
||||
|
||||
LocationTrackingService._internal();
|
||||
|
||||
// ============================================================================
|
||||
// SharedPreferences Keys
|
||||
// ============================================================================
|
||||
|
||||
static const String _prefKeyEnabled = 'background_tracking_enabled';
|
||||
static const String _prefKeyMinDistance = 'map_gps_min_distance';
|
||||
static const String _prefKeyMaxDistance = 'map_gps_max_distance';
|
||||
static const String _prefKeyMinTimeInterval = 'map_gps_min_time_interval';
|
||||
static const String _prefKeyGpsUpdateDistance = 'map_gps_update_distance';
|
||||
static const String _prefKeyLastLat = 'background_last_lat';
|
||||
static const String _prefKeyLastLon = 'background_last_lon';
|
||||
|
||||
// ============================================================================
|
||||
// Configuration Properties
|
||||
// ============================================================================
|
||||
|
||||
/// Minimum distance in meters before broadcasting update
|
||||
double minDistanceMeters = 5.0;
|
||||
|
||||
/// Maximum distance in meters that forces a broadcast regardless of time
|
||||
double maxDistanceMeters = 100.0;
|
||||
|
||||
/// Minimum time interval in seconds between broadcasts
|
||||
int minTimeIntervalSeconds = 30;
|
||||
|
||||
/// GPS update distance filter for position stream
|
||||
double gpsUpdateDistance = 10.0;
|
||||
|
||||
// ============================================================================
|
||||
// State Properties
|
||||
// ============================================================================
|
||||
|
||||
/// Current GPS position
|
||||
Position? currentPosition;
|
||||
|
||||
/// Whether tracking is currently active
|
||||
bool isTracking = false;
|
||||
|
||||
/// Whether service has been initialized with BLE service
|
||||
bool _isInitialized = false;
|
||||
|
||||
/// Whether the first stable position has been set (without broadcast)
|
||||
bool _firstPositionSet = false;
|
||||
|
||||
// ============================================================================
|
||||
// Private Properties
|
||||
// ============================================================================
|
||||
|
||||
/// Reference to MeshCore BLE service for broadcasting
|
||||
MeshCoreBleService? _bleService;
|
||||
|
||||
/// Position stream subscription
|
||||
StreamSubscription<Position>? _positionSubscription;
|
||||
|
||||
// ============================================================================
|
||||
// Callback Properties
|
||||
// ============================================================================
|
||||
|
||||
/// Called when position is updated
|
||||
void Function(Position)? onPositionUpdate;
|
||||
|
||||
/// Called when an error occurs
|
||||
void Function(String error)? onError;
|
||||
|
||||
/// Called when a location broadcast is sent to mesh network
|
||||
void Function(Position)? onBroadcastSent;
|
||||
|
||||
/// Called when tracking state changes
|
||||
void Function(bool isTracking)? onTrackingStateChanged;
|
||||
|
||||
// ============================================================================
|
||||
// Initialization
|
||||
// ============================================================================
|
||||
|
||||
/// Initialize the service with MeshCore BLE service reference
|
||||
///
|
||||
/// Must be called before starting tracking.
|
||||
Future<bool> initialize(MeshCoreBleService bleService) async {
|
||||
_bleService = bleService;
|
||||
_isInitialized = true;
|
||||
|
||||
// Load saved settings
|
||||
await loadSettings();
|
||||
|
||||
debugPrint('✅ [LocationTracking] Service initialized');
|
||||
return true;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Permission Handling
|
||||
// ============================================================================
|
||||
|
||||
/// Check if location permissions are granted
|
||||
Future<bool> checkPermissions() async {
|
||||
final permission = await Geolocator.checkPermission();
|
||||
return permission == LocationPermission.always ||
|
||||
permission == LocationPermission.whileInUse;
|
||||
}
|
||||
|
||||
/// Request location permissions from user
|
||||
///
|
||||
/// Returns true if granted, false otherwise.
|
||||
Future<bool> requestPermissions() async {
|
||||
// Check if location service is enabled
|
||||
bool serviceEnabled = await Geolocator.isLocationServiceEnabled();
|
||||
if (!serviceEnabled) {
|
||||
onError?.call('Location services are disabled');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check current permission
|
||||
LocationPermission permission = await Geolocator.checkPermission();
|
||||
|
||||
if (permission == LocationPermission.denied) {
|
||||
permission = await Geolocator.requestPermission();
|
||||
if (permission == LocationPermission.denied) {
|
||||
onError?.call('Location permission denied');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (permission == LocationPermission.deniedForever) {
|
||||
onError?.call(
|
||||
'Location permission permanently denied. Please enable in settings.',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
debugPrint('✅ [LocationTracking] Location permissions granted');
|
||||
return true;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// GPS Position Methods
|
||||
// ============================================================================
|
||||
|
||||
/// Get current GPS position
|
||||
///
|
||||
/// Returns null if position unavailable or permissions denied.
|
||||
/// [timeLimit] - Maximum time to wait for position (default: 15 seconds)
|
||||
/// [retryCount] - Number of retry attempts (default: 2)
|
||||
Future<Position?> getCurrentPosition({
|
||||
Duration timeLimit = const Duration(seconds: 15),
|
||||
int retryCount = 2,
|
||||
}) async {
|
||||
for (int attempt = 0; attempt <= retryCount; attempt++) {
|
||||
try {
|
||||
if (attempt > 0) {
|
||||
debugPrint('🔄 [LocationTracking] Retry attempt $attempt/$retryCount');
|
||||
// Exponential backoff: wait 2^attempt seconds before retry
|
||||
await Future.delayed(Duration(seconds: 1 << attempt));
|
||||
}
|
||||
|
||||
final position = await Geolocator.getCurrentPosition(
|
||||
locationSettings: LocationSettings(
|
||||
accuracy: LocationAccuracy.best,
|
||||
timeLimit: timeLimit,
|
||||
),
|
||||
);
|
||||
|
||||
currentPosition = position;
|
||||
if (attempt > 0) {
|
||||
debugPrint('✅ [LocationTracking] Position acquired after $attempt retries');
|
||||
}
|
||||
return position;
|
||||
} catch (e) {
|
||||
final isLastAttempt = attempt == retryCount;
|
||||
if (isLastAttempt) {
|
||||
debugPrint('❌ [LocationTracking] Failed to get position after $retryCount retries: $e');
|
||||
// Only call error callback on final failure, and make it user-friendly
|
||||
if (e.toString().contains('TimeoutException')) {
|
||||
onError?.call('GPS signal weak. Position stream will continue trying...');
|
||||
} else {
|
||||
onError?.call('Failed to get GPS position. Check device settings.');
|
||||
}
|
||||
} else {
|
||||
debugPrint('⚠️ [LocationTracking] Position attempt $attempt failed: $e');
|
||||
}
|
||||
|
||||
if (isLastAttempt) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Get position stream with configurable distance filter
|
||||
///
|
||||
/// [distanceFilter] - Minimum distance in meters between position updates
|
||||
Stream<Position> getPositionStream({double distanceFilter = 10.0}) {
|
||||
return Geolocator.getPositionStream(
|
||||
locationSettings: LocationSettings(
|
||||
accuracy: LocationAccuracy.best,
|
||||
distanceFilter: distanceFilter.toInt(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tracking Control
|
||||
// ============================================================================
|
||||
|
||||
/// Start location tracking
|
||||
///
|
||||
/// [distanceThreshold] - GPS update distance filter
|
||||
///
|
||||
/// Returns true if successful, false otherwise.
|
||||
/// Note: This method returns immediately after starting the position stream.
|
||||
/// Initial position acquisition happens asynchronously in the background.
|
||||
///
|
||||
/// GPS tracking works WITHOUT BLE connection - device broadcasts are simply skipped.
|
||||
Future<bool> startTracking({double? distanceThreshold}) async {
|
||||
if (!_isInitialized) {
|
||||
debugPrint(
|
||||
'⚠️ [LocationTracking] Service not initialized',
|
||||
);
|
||||
onError?.call('Location tracking service not initialized');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Allow tracking without BLE connection - broadcasts will be skipped
|
||||
if (_bleService == null || !_bleService!.isConnected) {
|
||||
debugPrint('ℹ️ [LocationTracking] Starting GPS tracking without BLE connection (broadcasts disabled)');
|
||||
}
|
||||
|
||||
// Check permissions
|
||||
final hasPermission = await requestPermissions();
|
||||
if (!hasPermission) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Use provided threshold or current setting
|
||||
final threshold = distanceThreshold ?? gpsUpdateDistance;
|
||||
gpsUpdateDistance = threshold;
|
||||
|
||||
// Save settings
|
||||
await saveSettings();
|
||||
|
||||
// Try to get initial position in background (non-blocking)
|
||||
// This will populate currentPosition but won't block tracking startup
|
||||
getCurrentPosition(
|
||||
timeLimit: const Duration(seconds: 10),
|
||||
retryCount: 1,
|
||||
).then((position) {
|
||||
if (position != null) {
|
||||
debugPrint('✅ [LocationTracking] Initial position acquired in background');
|
||||
}
|
||||
}).catchError((error) {
|
||||
debugPrint('⚠️ [LocationTracking] Background initial position failed: $error');
|
||||
// Not critical - position stream will eventually provide position
|
||||
});
|
||||
|
||||
// Start position stream immediately (don't wait for initial position)
|
||||
try {
|
||||
_positionSubscription = getPositionStream(distanceFilter: threshold)
|
||||
.listen(
|
||||
_handlePositionUpdate,
|
||||
onError: (error) {
|
||||
debugPrint('❌ [LocationTracking] Position stream error: $error');
|
||||
onError?.call('GPS stream error. Retrying...');
|
||||
},
|
||||
);
|
||||
|
||||
isTracking = true;
|
||||
onTrackingStateChanged?.call(true);
|
||||
|
||||
debugPrint(
|
||||
'✅ [LocationTracking] Tracking started with ${threshold}m threshold',
|
||||
);
|
||||
debugPrint('📡 [LocationTracking] Waiting for GPS signal...');
|
||||
return true;
|
||||
} catch (e) {
|
||||
debugPrint('❌ [LocationTracking] Failed to start tracking: $e');
|
||||
onError?.call('Failed to start GPS tracking: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop location tracking
|
||||
Future<void> stopTracking() async {
|
||||
debugPrint('🛑 [LocationTracking] Stopping tracking');
|
||||
|
||||
await _positionSubscription?.cancel();
|
||||
_positionSubscription = null;
|
||||
|
||||
isTracking = false;
|
||||
onTrackingStateChanged?.call(false);
|
||||
|
||||
// Reset first position flag so next connection starts fresh
|
||||
_firstPositionSet = false;
|
||||
|
||||
// Save disabled state
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_prefKeyEnabled, false);
|
||||
|
||||
debugPrint('✅ [LocationTracking] Tracking stopped');
|
||||
}
|
||||
|
||||
/// Update the distance threshold and restart tracking if active
|
||||
Future<void> updateDistanceThreshold(double meters) async {
|
||||
gpsUpdateDistance = meters;
|
||||
await saveSettings();
|
||||
|
||||
debugPrint(
|
||||
'📏 [LocationTracking] Distance threshold updated to ${meters}m',
|
||||
);
|
||||
|
||||
// Restart tracking if currently active
|
||||
if (isTracking) {
|
||||
await stopTracking();
|
||||
await startTracking(distanceThreshold: meters);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Position Update Handler
|
||||
// ============================================================================
|
||||
|
||||
/// Handle incoming position updates from GPS stream
|
||||
void _handlePositionUpdate(Position position) {
|
||||
debugPrint(
|
||||
'📍 [LocationTracking] New position: ${position.latitude}, ${position.longitude}',
|
||||
);
|
||||
|
||||
// Update current position
|
||||
currentPosition = position;
|
||||
|
||||
// Notify listeners
|
||||
onPositionUpdate?.call(position);
|
||||
|
||||
// SPECIAL CASE: First stable position after connection
|
||||
// Set lat/lon on device WITHOUT broadcasting to mesh network
|
||||
if (!_firstPositionSet) {
|
||||
_setInitialPosition(position);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if we should broadcast to mesh network
|
||||
_checkAndBroadcast(position);
|
||||
}
|
||||
|
||||
/// Set initial position on device without broadcasting
|
||||
///
|
||||
/// Called only for the first stable GPS position after connection starts.
|
||||
/// Updates the device's advertised lat/lon but does NOT send an advertisement.
|
||||
void _setInitialPosition(Position position) async {
|
||||
if (_bleService == null || !_bleService!.isConnected) {
|
||||
debugPrint('⚠️ [LocationTracking] Cannot set initial position: BLE not connected');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
debugPrint('📍 [LocationTracking] Setting initial position (no broadcast)');
|
||||
|
||||
// Update device's advertised location WITHOUT sending advertisement
|
||||
await _bleService!.setAdvertLatLon(
|
||||
latitude: position.latitude,
|
||||
longitude: position.longitude,
|
||||
);
|
||||
|
||||
// Mark first position as set
|
||||
_firstPositionSet = true;
|
||||
|
||||
// Save to preferences
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setDouble(_prefKeyLastLat, position.latitude);
|
||||
await prefs.setDouble(_prefKeyLastLon, position.longitude);
|
||||
|
||||
debugPrint('✅ [LocationTracking] Initial position set without broadcast');
|
||||
debugPrint(' Next broadcast allowed in ${minTimeIntervalSeconds}s');
|
||||
} catch (e) {
|
||||
debugPrint('⚠️ [LocationTracking] Failed to set initial position: $e');
|
||||
debugPrint(' Will retry on next GPS update');
|
||||
// Don't mark as set on failure, so it will retry on next update
|
||||
// Don't call onError - this is not critical since it will retry automatically
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if position should be broadcast based on distance and time thresholds
|
||||
/// DISABLED: Automatic broadcasting removed - use advert button for manual broadcasts
|
||||
void _checkAndBroadcast(Position position) {
|
||||
// Automatic broadcasting disabled
|
||||
// Use the manual advert button instead
|
||||
debugPrint(' ⏸️ [LocationTracking] Automatic broadcasting disabled (use advert button)');
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Mesh Network Broadcasting
|
||||
// ============================================================================
|
||||
|
||||
/// Manually broadcast current location immediately
|
||||
///
|
||||
/// Useful for "Send Location Now" button functionality.
|
||||
/// Note: Manual broadcasts bypass automatic throttling and can be sent anytime.
|
||||
/// However, they still update the last broadcast time to maintain proper spacing
|
||||
/// for subsequent automatic broadcasts.
|
||||
Future<bool> broadcastLocationNow() async {
|
||||
if (!_isInitialized || _bleService == null) {
|
||||
onError?.call('Location tracking service not initialized');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!_bleService!.isConnected) {
|
||||
onError?.call('Not connected to mesh device');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// Get current position
|
||||
final position = await getCurrentPosition();
|
||||
if (position == null) {
|
||||
onError?.call('Failed to get current position');
|
||||
return false;
|
||||
}
|
||||
|
||||
debugPrint('📤 [LocationTracking] Manual broadcast requested');
|
||||
|
||||
// Broadcast regardless of automatic throttling thresholds
|
||||
await _bleService!.setAdvertLatLon(
|
||||
latitude: position.latitude,
|
||||
longitude: position.longitude,
|
||||
);
|
||||
|
||||
await _bleService!.sendSelfAdvert(floodMode: true);
|
||||
|
||||
debugPrint('✅ [LocationTracking] Manual broadcast successful');
|
||||
debugPrint(' Automatic broadcasts will resume after ${minTimeIntervalSeconds}s');
|
||||
onBroadcastSent?.call(position);
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
debugPrint('❌ [LocationTracking] Manual broadcast failed: $e');
|
||||
onError?.call('Failed to broadcast location: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Settings Persistence
|
||||
// ============================================================================
|
||||
|
||||
/// Load settings from SharedPreferences
|
||||
Future<void> loadSettings() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
|
||||
minDistanceMeters = prefs.getDouble(_prefKeyMinDistance) ?? 5.0;
|
||||
maxDistanceMeters = prefs.getDouble(_prefKeyMaxDistance) ?? 100.0;
|
||||
minTimeIntervalSeconds = prefs.getInt(_prefKeyMinTimeInterval) ?? 30;
|
||||
gpsUpdateDistance = prefs.getDouble(_prefKeyGpsUpdateDistance) ?? 10.0;
|
||||
|
||||
debugPrint('✅ [LocationTracking] Settings loaded');
|
||||
debugPrint(' Min distance: ${minDistanceMeters}m');
|
||||
debugPrint(' Max distance: ${maxDistanceMeters}m');
|
||||
debugPrint(' Min time interval: ${minTimeIntervalSeconds}s');
|
||||
debugPrint(' GPS update distance: ${gpsUpdateDistance}m');
|
||||
}
|
||||
|
||||
/// Save settings to SharedPreferences
|
||||
Future<void> saveSettings() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
|
||||
await prefs.setDouble(_prefKeyMinDistance, minDistanceMeters);
|
||||
await prefs.setDouble(_prefKeyMaxDistance, maxDistanceMeters);
|
||||
await prefs.setInt(_prefKeyMinTimeInterval, minTimeIntervalSeconds);
|
||||
await prefs.setDouble(_prefKeyGpsUpdateDistance, gpsUpdateDistance);
|
||||
await prefs.setBool(_prefKeyEnabled, isTracking);
|
||||
|
||||
debugPrint('✅ [LocationTracking] Settings saved');
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Cleanup
|
||||
// ============================================================================
|
||||
|
||||
/// Dispose resources and cleanup
|
||||
void dispose() {
|
||||
debugPrint('🗑️ [LocationTracking] Disposing service');
|
||||
_positionSubscription?.cancel();
|
||||
_positionSubscription = null;
|
||||
_bleService = null;
|
||||
_isInitialized = false;
|
||||
isTracking = false;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user