import 'dart:async'; import 'dart:math' as math; import 'package:fforte/l10n/app_localizations.dart'; import 'package:fforte/services/notification_service.dart'; import 'package:flutter/material.dart'; import 'package:geolocator/geolocator.dart'; import 'package:latlong2/latlong.dart'; import 'package:shared_preferences/shared_preferences.dart'; /// Service that runs the geolocator service to track the device's position /// Uses the Singleton design pattern to ensure only one tracking instance exists /// This service is essential for excursion tracking functionality /// /// Start tracking via [startTracking] /// Control tracking via [pauseTracking], [stopTracking], and [resumeTracking] class TrackingService { // Singleton implementation static TrackingService? _instance; factory TrackingService() { _instance ??= TrackingService._internal(); return _instance!; } TrackingService._internal(); /// Reset all tracking values for a fresh start static void resetInstance() { if (_instance != null) { _instance!.dispose(); _instance = null; } } // Core tracking variables // Stores tracked GPS coordinates List pathList = []; // Stores GPS accuracy values List accuracyList = []; // Timer for periodic tracking Timer? _positionTimer; // Current tracking status bool isTracking = false; // Context for UI interactions BuildContext? _lastContext; // Stream controllers for position and stats updates final _positionController = StreamController.broadcast(); final _statsController = StreamController.broadcast(); // Stream getters Stream get positionStream$ => _positionController.stream; Stream get statsStream$ => _statsController.stream; // Last measured GPS accuracy for display double? currentAccuracy; /// Calculate median accuracy from a list of accuracy values /// This is preferred over mean because initial accuracy values can be very high (~9000m) double _calculateMedianAccuracy(List accuracies) { if (accuracies.isEmpty) return 0; if (accuracies.length == 1) return accuracies.first; // Copy list to preserve original data var sorted = List.from(accuracies)..sort(); // Calculate median if (sorted.length % 2 == 0) { int midIndex = sorted.length ~/ 2; return (sorted[midIndex - 1] + sorted[midIndex]) / 2; } else { return sorted[sorted.length ~/ 2]; } } /// Start position tracking /// - Initializes high-accuracy GPS tracking /// - Sets up periodic position updates /// - Shows tracking notification Future startTracking(BuildContext context) async { if (isTracking) return; // Configure high-accuracy GPS settings final LocationSettings locationSettings = LocationSettings(accuracy: LocationAccuracy.high); _lastContext = context; // Initialize and show tracking notification await NotificationService().initNotification(); if (context.mounted) { NotificationService().showNotification( title: AppLocalizations.of(context)!.trackingRunningInBackground, ); } // Load tracking interval from settings final prefs = await SharedPreferences.getInstance(); final intervalSeconds = prefs.getInt('trackingInterval') ?? 60; // Set up periodic position updates _positionTimer = Timer.periodic(Duration(seconds: intervalSeconds), (_) async { try { final Position position = await Geolocator.getCurrentPosition( locationSettings: locationSettings); // Store position and accuracy data pathList.add(LatLng(position.latitude, position.longitude)); accuracyList.add(position.accuracy); currentAccuracy = position.accuracy; _positionController.add(position); _updateStats(); } catch (e) { // Handle errors with notification NotificationService().deleteNotification(); NotificationService().showNotification(title: "ERROR: $e"); } }); // Get initial position immediately try { final Position position = await Geolocator.getCurrentPosition( locationSettings: locationSettings); pathList.add(LatLng(position.latitude, position.longitude)); accuracyList.add(position.accuracy); currentAccuracy = position.accuracy; _positionController.add(position); _updateStats(); } catch (e) { NotificationService().deleteNotification(); NotificationService().showNotification(title: "ERROR: $e"); } isTracking = true; } // Last calculated tracking statistics TrackingStats? _lastStats; TrackingStats? get currentStats => _lastStats; /// Update tracking statistics /// Calculates: /// - Current GPS accuracy /// - Average accuracy /// - Total distance traveled void _updateStats() { if (pathList.isEmpty) { _lastStats = TrackingStats( currentAccuracy: currentAccuracy ?? 0, averageAccuracy: 0, totalDistanceMeters: 0); _statsController.add(_lastStats!); return; } // Calculate total distance double totalDistance = 0; for (int i = 1; i < pathList.length; i++) { totalDistance += _calculateDistance( pathList[i - 1].latitude, pathList[i - 1].longitude, pathList[i].latitude, pathList[i].longitude); } double medianAccuracy = _calculateMedianAccuracy(accuracyList); _lastStats = TrackingStats( currentAccuracy: currentAccuracy ?? 0, averageAccuracy: medianAccuracy, totalDistanceMeters: totalDistance); _statsController.add(_lastStats!); } /// Request a manual stats update void requestStatsUpdate() { _updateStats(); } /// Calculate distance between two GPS coordinates using the Haversine formula /// @return Distance in meters double _calculateDistance( double lat1, double lon1, double lat2, double lon2) { const double earthRadius = 6371000; // Earth radius in meters // Convert coordinates to radians double lat1Rad = lat1 * math.pi / 180; double lat2Rad = lat2 * math.pi / 180; double deltaLat = (lat2 - lat1) * math.pi / 180; double deltaLon = (lon2 - lon1) * math.pi / 180; // Haversine formula calculation double a = math.sin(deltaLat / 2) * math.sin(deltaLat / 2) + math.cos(lat1Rad) * math.cos(lat2Rad) * math.sin(deltaLon / 2) * math.sin(deltaLon / 2); double c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a)); return earthRadius * c; } /// Temporarily pause tracking void pauseTracking() { _positionTimer?.cancel(); isTracking = false; } /// Resume paused tracking void resumeTracking() { if (!isTracking && _lastContext != null) { startTracking(_lastContext!); } isTracking = true; } /// Stop tracking completely and clear current state void stopTracking() { _positionTimer?.cancel(); NotificationService().deleteNotification(); isTracking = false; accuracyList.clear(); currentAccuracy = null; _lastContext = null; } /// Clear all recorded position data void clearPath() { pathList.clear(); accuracyList.clear(); currentAccuracy = null; _updateStats(); } /// Convert tracked path to string format /// Format: "latitude,longitude;latitude,longitude;..." String getPathAsString() { return pathList.map((pos) => "${pos.latitude},${pos.longitude}").join(";"); } /// Clean up resources void dispose() { stopTracking(); _positionController.close(); _statsController.close(); } } /// Data class for tracking statistics /// Contains: /// - Current GPS accuracy /// - Average accuracy /// - Total distance in meters class TrackingStats { final double currentAccuracy; final double averageAccuracy; final double totalDistanceMeters; TrackingStats({ required this.currentAccuracy, required this.averageAccuracy, required this.totalDistanceMeters, }); }