mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 16:30:28 +00:00
- Implemented LocationTrackingService to handle GPS tracking, distance thresholds, and location broadcasting. - Added permission handling, SharedPreferences persistence, and real-time position updates via callbacks. feat: Introduce MapMarkerService for managing map markers - Created MapMarkerService to generate contact and SAR markers, calculate distances, and manage marker display. - Included methods for user location markers and distance/bearing calculations. feat: Implement ValidationService for input validation and sanitization - Developed ValidationService to validate coordinates, radio parameters, distances, time intervals, and names. - Added parse and sanitize methods for common input types, ensuring robust error handling and user feedback.
502 lines
17 KiB
Dart
502 lines
17 KiB
Dart
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;
|
|
|
|
/// Last position that was broadcast to mesh network
|
|
Position? _lastBroadcastPosition;
|
|
|
|
/// Timestamp of last broadcast
|
|
DateTime? _lastBroadcastTime;
|
|
|
|
/// Whether tracking is currently active
|
|
bool isTracking = false;
|
|
|
|
/// Whether service has been initialized with BLE service
|
|
bool _isInitialized = 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.
|
|
Future<Position?> getCurrentPosition() async {
|
|
try {
|
|
final position = await Geolocator.getCurrentPosition(
|
|
locationSettings: const LocationSettings(
|
|
accuracy: LocationAccuracy.best,
|
|
timeLimit: Duration(seconds: 5),
|
|
),
|
|
);
|
|
|
|
currentPosition = position;
|
|
return position;
|
|
} catch (e) {
|
|
debugPrint('❌ [LocationTracking] Error getting position: $e');
|
|
onError?.call('Failed to get current position: $e');
|
|
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.
|
|
Future<bool> startTracking({double? distanceThreshold}) async {
|
|
if (!_isInitialized || _bleService == null) {
|
|
debugPrint('⚠️ [LocationTracking] Service not initialized or BLE service null');
|
|
onError?.call('Location tracking service not initialized');
|
|
return false;
|
|
}
|
|
|
|
if (!_bleService!.isConnected) {
|
|
debugPrint('⚠️ [LocationTracking] BLE not connected');
|
|
onError?.call('Not connected to mesh device');
|
|
return false;
|
|
}
|
|
|
|
// 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();
|
|
|
|
// Get initial position
|
|
await getCurrentPosition();
|
|
|
|
// Start position stream
|
|
try {
|
|
_positionSubscription = getPositionStream(distanceFilter: threshold).listen(
|
|
_handlePositionUpdate,
|
|
onError: (error) {
|
|
debugPrint('❌ [LocationTracking] Position stream error: $error');
|
|
onError?.call('Position stream error: $error');
|
|
},
|
|
);
|
|
|
|
isTracking = true;
|
|
onTrackingStateChanged?.call(true);
|
|
|
|
debugPrint('✅ [LocationTracking] Tracking started with ${threshold}m threshold');
|
|
return true;
|
|
} catch (e) {
|
|
debugPrint('❌ [LocationTracking] Failed to start tracking: $e');
|
|
onError?.call('Failed to start 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);
|
|
|
|
// 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);
|
|
|
|
// Check if we should broadcast to mesh network
|
|
_checkAndBroadcast(position);
|
|
}
|
|
|
|
/// Check if position should be broadcast based on distance and time thresholds
|
|
void _checkAndBroadcast(Position position) {
|
|
// If never broadcast before, do it now
|
|
if (_lastBroadcastPosition == null || _lastBroadcastTime == null) {
|
|
_broadcastPosition(position);
|
|
return;
|
|
}
|
|
|
|
// Calculate time since last broadcast
|
|
final timeSinceLastBroadcast = DateTime.now().difference(_lastBroadcastTime!);
|
|
final secondsSinceLastBroadcast = timeSinceLastBroadcast.inSeconds;
|
|
|
|
// Calculate distance from last broadcast position
|
|
final distanceFromLastBroadcast = Geolocator.distanceBetween(
|
|
_lastBroadcastPosition!.latitude,
|
|
_lastBroadcastPosition!.longitude,
|
|
position.latitude,
|
|
position.longitude,
|
|
);
|
|
|
|
debugPrint(' Distance from last broadcast: ${distanceFromLastBroadcast.toStringAsFixed(1)}m');
|
|
debugPrint(' Time since last broadcast: ${secondsSinceLastBroadcast}s');
|
|
|
|
// Broadcast if moved beyond max distance threshold
|
|
if (distanceFromLastBroadcast >= maxDistanceMeters) {
|
|
debugPrint(' 📤 Triggering broadcast: exceeded max distance (${maxDistanceMeters}m)');
|
|
_broadcastPosition(position);
|
|
return;
|
|
}
|
|
|
|
// Broadcast if minimum time interval passed AND moved beyond min distance
|
|
if (secondsSinceLastBroadcast >= minTimeIntervalSeconds &&
|
|
distanceFromLastBroadcast >= minDistanceMeters) {
|
|
debugPrint(' 📤 Triggering broadcast: exceeded min time (${minTimeIntervalSeconds}s) and min distance (${minDistanceMeters}m)');
|
|
_broadcastPosition(position);
|
|
return;
|
|
}
|
|
|
|
debugPrint(' ⏸️ Not broadcasting: thresholds not met');
|
|
}
|
|
|
|
// ============================================================================
|
|
// Mesh Network Broadcasting
|
|
// ============================================================================
|
|
|
|
/// Broadcast position to mesh network via BLE
|
|
void _broadcastPosition(Position position) async {
|
|
if (_bleService == null || !_bleService!.isConnected) {
|
|
debugPrint('⚠️ [LocationTracking] Cannot broadcast: BLE not connected');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
debugPrint('📤 [LocationTracking] Broadcasting position to mesh network');
|
|
|
|
// Update device's advertised location
|
|
await _bleService!.setAdvertLatLon(
|
|
latitude: position.latitude,
|
|
longitude: position.longitude,
|
|
);
|
|
|
|
// Send advertisement to mesh network
|
|
await _bleService!.sendSelfAdvert(floodMode: true);
|
|
|
|
// Update broadcast tracking
|
|
_lastBroadcastPosition = position;
|
|
_lastBroadcastTime = DateTime.now();
|
|
|
|
// Save to preferences
|
|
final prefs = await SharedPreferences.getInstance();
|
|
await prefs.setDouble(_prefKeyLastLat, position.latitude);
|
|
await prefs.setDouble(_prefKeyLastLon, position.longitude);
|
|
|
|
debugPrint('✅ [LocationTracking] Position broadcast successful');
|
|
onBroadcastSent?.call(position);
|
|
} catch (e) {
|
|
debugPrint('❌ [LocationTracking] Failed to broadcast position: $e');
|
|
onError?.call('Failed to broadcast position: $e');
|
|
}
|
|
}
|
|
|
|
/// Manually broadcast current location immediately
|
|
///
|
|
/// Useful for "Send Location Now" button functionality.
|
|
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;
|
|
}
|
|
|
|
// Broadcast regardless of thresholds
|
|
await _bleService!.setAdvertLatLon(
|
|
latitude: position.latitude,
|
|
longitude: position.longitude,
|
|
);
|
|
|
|
await _bleService!.sendSelfAdvert(floodMode: true);
|
|
|
|
// Update broadcast tracking
|
|
_lastBroadcastPosition = position;
|
|
_lastBroadcastTime = DateTime.now();
|
|
|
|
debugPrint('✅ [LocationTracking] Manual broadcast successful');
|
|
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;
|
|
|
|
// Load last broadcast position if available
|
|
final lastLat = prefs.getDouble(_prefKeyLastLat);
|
|
final lastLon = prefs.getDouble(_prefKeyLastLon);
|
|
if (lastLat != null && lastLon != null) {
|
|
_lastBroadcastPosition = Position(
|
|
latitude: lastLat,
|
|
longitude: lastLon,
|
|
timestamp: DateTime.now(),
|
|
accuracy: 0.0,
|
|
altitude: 0.0,
|
|
altitudeAccuracy: 0.0,
|
|
heading: 0.0,
|
|
headingAccuracy: 0.0,
|
|
speed: 0.0,
|
|
speedAccuracy: 0.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;
|
|
}
|
|
}
|