diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 0055a45..5393a8b 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -22,7 +22,8 @@ "Bash(cat:*)", "Read(//Users/dz0ny/.pub-cache/hosted/pub.dev/**)", "Bash(find:*)", - "Read(//Users/dz0ny/meshcore-sar/**)" + "Read(//Users/dz0ny/meshcore-sar/**)", + "Bash(git grep:*)" ], "deny": [], "ask": [] diff --git a/CLAUDE.md b/CLAUDE.md index 0301896..0445f11 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -38,13 +38,17 @@ lib/ │ ├── buffer_writer.dart # Binary protocol writer │ ├── cayenne_lpp_parser.dart # Telemetry decoder │ ├── tile_cache_service.dart # Offline map tiles +│ ├── background_location_service.dart # Background GPS tracking (legacy) │ ├── protocol/ # Protocol layer (628 lines) │ │ ├── frame_parser.dart # Parse incoming BLE frames │ │ └── frame_builder.dart # Build outgoing BLE frames -│ └── ble/ # BLE layer (963 lines) -│ ├── ble_connection_manager.dart # Connection lifecycle -│ ├── ble_command_sender.dart # Command transmission -│ └── ble_response_handler.dart # Response processing +│ ├── ble/ # BLE layer (963 lines) +│ │ ├── ble_connection_manager.dart # Connection lifecycle +│ │ ├── ble_command_sender.dart # Command transmission +│ │ └── ble_response_handler.dart # Response processing +│ ├── location_tracking_service.dart # GPS tracking & mesh broadcasting (501 lines) +│ ├── map_marker_service.dart # Marker generation & geodesic calculations (518 lines) +│ └── validation_service.dart # Form validation & input parsing (511 lines) ├── providers/ # State management │ ├── connection_provider.dart # BLE connection state (957 lines) │ ├── contacts_provider.dart # Contact list management @@ -784,6 +788,434 @@ enum ContactType { - Only `ContactType.chat` contacts with valid GPS are shown on map - Repeaters and rooms are listed in Contacts tab but not mapped +## Service Layer Architecture + +The app uses a service layer pattern to centralize business logic outside of UI components. Three main services handle location tracking, map operations, and validation. + +### LocationTrackingService + +**Purpose**: Singleton service for GPS tracking and intelligent mesh network location broadcasting. + +**Pattern**: Callback-based architecture with configurable thresholds. + +**Initialization**: +```dart +final locationService = LocationTrackingService(); +await locationService.initialize(bleService); + +// Set up callbacks +locationService.onPositionUpdate = (position) { + // Handle GPS position updates + print('Position: ${position.latitude}, ${position.longitude}'); +}; + +locationService.onError = (error) { + // Handle errors + showSnackBar(error); +}; + +locationService.onBroadcastSent = (position) { + // Called when location is broadcast to mesh network + print('Broadcast sent: ${position.latitude}, ${position.longitude}'); +}; + +locationService.onTrackingStateChanged = (isTracking) { + // Called when tracking starts/stops + setState(() => _isTracking = isTracking); +}; +``` + +**Configuration Parameters**: +```dart +// Minimum distance before considering broadcast (default: 5.0m) +locationService.minDistanceMeters = 5.0; + +// Maximum distance that forces immediate broadcast (default: 100.0m) +locationService.maxDistanceMeters = 100.0; + +// Minimum time between broadcasts (default: 30s) +locationService.minTimeIntervalSeconds = 30; + +// GPS update distance threshold (default: 10.0m) +locationService.gpsUpdateDistance = 10.0; +``` + +**Smart Broadcasting Logic**: +The service implements intelligent broadcasting that balances network traffic with position accuracy: + +1. **First broadcast**: Always sends immediately (no previous position to compare) +2. **Maximum distance trigger**: If user moves ≥100m (configurable), broadcasts immediately regardless of time +3. **Combined trigger**: If user moves ≥5m (configurable) AND ≥30s have passed since last broadcast, broadcasts + +This prevents flooding the mesh network while ensuring position updates are sent when meaningful movement occurs. + +**Usage Example**: +```dart +// Request permissions +final granted = await locationService.requestPermissions(); +if (!granted) { + showError('Location permission denied'); + return; +} + +// Start tracking +await locationService.startTracking(distanceThreshold: 10); + +// Manual broadcast (bypasses smart logic) +final success = await locationService.broadcastLocationNow(); + +// Stop tracking +await locationService.stopTracking(); + +// Check state +if (locationService.isTracking) { + print('Current: ${locationService.currentPosition?.latitude}'); +} +``` + +**Haversine Distance Calculation**: +The service uses the Haversine formula to calculate accurate distances between GPS coordinates, accounting for Earth's curvature: +```dart +double _calculateDistance(Position pos1, Position pos2) { + const earthRadius = 6371000.0; // meters + final dLat = _degreesToRadians(pos2.latitude - pos1.latitude); + final dLon = _degreesToRadians(pos2.longitude - pos1.longitude); + + final a = sin(dLat / 2) * sin(dLat / 2) + + cos(_degreesToRadians(pos1.latitude)) * cos(_degreesToRadians(pos2.latitude)) * + sin(dLon / 2) * sin(dLon / 2); + + final c = 2 * atan2(sqrt(a), sqrt(1 - a)); + return earthRadius * c; +} +``` + +### MapMarkerService + +**Purpose**: Singleton service for generating map markers and performing geodesic calculations. + +**Pattern**: Pure functions for testability and performance. + +**Generate Contact Markers**: +```dart +final markerService = MapMarkerService(); + +final contactMarkers = markerService.generateContactMarkers( + contacts: contactsWithLocation, + onTap: (contact) => showContactDetails(contact), + userLat: currentUserLatitude, // Optional: for distance calculations + userLon: currentUserLongitude, +); +``` + +**Generate SAR Markers**: +```dart +final sarMarkers = markerService.generateSarMarkers( + sarMarkers: allSarMarkers, + onTap: (marker) => showSarMarkerDetails(marker), +); +``` + +**Calculate Distance Between Points**: +```dart +final distance = markerService.calculateDistance( + lat1: 46.0569, lon1: 14.5058, // Point A + lat2: 46.0570, lon2: 14.5060, // Point B +); +print('Distance: ${distance.toStringAsFixed(1)}m'); +``` + +**Calculate Bearing/Azimuth**: +```dart +final bearing = markerService.calculateBearing( + lat1: userLat, lon1: userLon, + lat2: targetLat, lon2: targetLon, +); +print('Bearing: ${bearing.toStringAsFixed(1)}°'); +``` + +**Format Distance for Display**: +```dart +final formatted = markerService.formatDistance(1234.56); +// Returns: "1.2 km" or "123 m" depending on distance +``` + +**Marker Features**: +- Contact markers show battery level badge and distance from user +- SAR markers are color-coded by type (green=person, red=fire, orange=staging) +- Automatic "time ago" labels (e.g., "5m ago", "2h ago") +- Tap handlers for showing detailed information +- Custom icons and colors per marker type + +**Implementation Notes**: +- All functions are pure (no side effects) +- Uses Haversine formula for accurate geodesic calculations +- Marker widgets are lightweight for performance +- Distance calculations account for Earth's curvature + +### ValidationService + +**Purpose**: Singleton service for form validation and input parsing with structured error handling. + +**Pattern**: Structured result types (`ValidationResult`, `ParseResult`) for type-safe error handling. + +**Coordinate Validation**: +```dart +final validator = ValidationService(); + +// Validate latitude +final latResult = validator.validateLatitude(46.0569); +if (!latResult.isValid) { + showError(latResult.errorMessage!); +} + +// Validate longitude +final lonResult = validator.validateLongitude(14.5058); +if (!lonResult.isValid) { + showError(lonResult.errorMessage!); +} + +// Validate both coordinates at once +final coordResult = validator.validateCoordinates( + 46.0569, // latitude + 14.5058, // longitude +); +if (!coordResult.isValid) { + showError(coordResult.errorMessage!); +} + +// Validate bounds (for map region downloads) +final boundsResult = validator.validateBounds( + north: 46.10, south: 46.00, + east: 14.60, west: 14.50, +); +``` + +**Parse + Validate Text Input**: +```dart +// Parse latitude from text field +final latResult = validator.parseLatitude(latController.text); +if (!latResult.isSuccess) { + showError(latResult.errorMessage!); + return; +} +final latitude = latResult.value!; // Safe to use + +// Parse longitude from text field +final lonResult = validator.parseLongitude(lonController.text); +if (!lonResult.isSuccess) { + showError(lonResult.errorMessage!); + return; +} +final longitude = lonResult.value!; + +// Parse radio frequency +final freqResult = validator.parseFrequency(freqController.text); +if (!freqResult.isSuccess) { + showError(freqResult.errorMessage!); + return; +} +final frequency = freqResult.value!; +``` + +**Radio Parameter Validation**: +```dart +// Frequency (137.0 - 1020.0 MHz) +final freqValidation = validator.validateFrequency(433.5); + +// Bandwidth (7.8 - 500.0 kHz) +final bwValidation = validator.validateBandwidth(125.0); + +// Spreading Factor (5 - 12) +final sfValidation = validator.validateSpreadingFactor(7); + +// Coding Rate (5 - 8) +final crValidation = validator.validateCodingRate(5); + +// TX Power (-9 to +22 dBm, device-dependent) +final txValidation = validator.validateTxPower(20, maxTxPower: 22); +``` + +**Text and Name Validation**: +```dart +// Validate name (max length) +final nameResult = validator.validateName( + nameController.text, + maxLength: 32, +); + +// Validate with minimum length +final passwordResult = validator.validateName( + passwordController.text, + minLength: 4, + maxLength: 15, +); +``` + +**Zoom Level Validation**: +```dart +final zoomResult = validator.validateZoomLevel(15); +if (!zoomResult.isValid) { + showError('Zoom: ${zoomResult.errorMessage}'); +} +``` + +**Validation Ranges**: +- **Latitude**: -90.0 to +90.0 (decimal degrees) +- **Longitude**: -180.0 to +180.0 (decimal degrees) +- **Frequency**: 137.0 to 1020.0 (MHz) +- **Bandwidth**: 7.8 to 500.0 (kHz) +- **Spreading Factor**: 5 to 12 +- **Coding Rate**: 5 to 8 +- **TX Power**: -9 to +22 dBm (max depends on device) +- **Zoom Level**: 0 to 19 + +**Result Types**: +```dart +// ValidationResult - for validation only +class ValidationResult { + final bool isValid; + final String? errorMessage; + + const ValidationResult.valid() : isValid = true, errorMessage = null; + const ValidationResult.invalid(this.errorMessage) : isValid = false; +} + +// ParseResult - for parsing + validation +class ParseResult { + final T? value; + final String? errorMessage; + + const ParseResult.success(this.value) : errorMessage = null; + const ParseResult.error(this.errorMessage) : value = null; + + bool get isSuccess => value != null; +} +``` + +**Usage Pattern**: +```dart +// Pattern 1: Validate existing value +final validation = validator.validateLatitude(existingValue); +if (validation.isValid) { + // Use existingValue +} + +// Pattern 2: Parse + validate text input +final parseResult = validator.parseLatitude(textController.text); +if (parseResult.isSuccess) { + final latitude = parseResult.value!; // Type-safe + // Use latitude +} else { + showError(parseResult.errorMessage!); +} +``` + +### Service Integration Examples + +**Settings Screen** (settings_screen.dart): +```dart +class _SettingsScreenState extends State { + final LocationTrackingService _locationService = LocationTrackingService(); + + @override + void initState() { + super.initState(); + _initLocationService(); + } + + Future _initLocationService() async { + await _locationService.initialize(bleService); + + _locationService.onError = (error) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(error), backgroundColor: Colors.orange), + ); + }; + + _locationService.onBroadcastSent = (position) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Location broadcast: ${position.latitude.toStringAsFixed(5)}, ${position.longitude.toStringAsFixed(5)}'), + backgroundColor: Colors.green, + ), + ); + }; + } + + @override + void dispose() { + _locationService.stopTracking(); + super.dispose(); + } +} +``` + +**Map Tab** (map_tab.dart): +```dart +class _MapTabState extends State { + final LocationTrackingService _locationService = LocationTrackingService(); + final MapMarkerService _markerService = MapMarkerService(); + + @override + Widget build(BuildContext context) { + // Generate markers using service + final contactMarkers = _markerService.generateContactMarkers( + contacts: contactsProvider.contactsWithLocation, + onTap: (contact) => _showContactDetails(contact), + userLat: _locationService.currentPosition?.latitude, + userLon: _locationService.currentPosition?.longitude, + ); + + final sarMarkers = _markerService.generateSarMarkers( + sarMarkers: messagesProvider.sarMarkers, + onTap: (marker) => _showSarMarkerDetails(marker), + ); + + return FlutterMap( + children: [ + TileLayer(...), + MarkerLayer(markers: [...contactMarkers, ...sarMarkers]), + ], + ); + } +} +``` + +**Device Config Screen** (device_config_screen.dart): +```dart +Future _saveRadioParams() async { + final validator = ValidationService(); + + // Parse and validate all inputs + final freqResult = validator.parseFrequency(_freqController.text); + if (!freqResult.isSuccess) { + _showError(freqResult.errorMessage!); + return; + } + + final bwResult = validator.parseBandwidth(_bwController.text); + if (!bwResult.isSuccess) { + _showError(bwResult.errorMessage!); + return; + } + + final sfResult = validator.parseSpreadingFactor(_sfController.text); + if (!sfResult.isSuccess) { + _showError(sfResult.errorMessage!); + return; + } + + // All validation passed, save to device + await connectionProvider.setRadioParams( + frequency: freqResult.value!, + bandwidth: bwResult.value!, + spreadingFactor: sfResult.value!, + codingRate: crResult.value!, + ); +} +``` + ## Map Implementation ### Tile Layers diff --git a/lib/providers/connection_provider.dart b/lib/providers/connection_provider.dart index 5779351..12f240e 100644 --- a/lib/providers/connection_provider.dart +++ b/lib/providers/connection_provider.dart @@ -454,20 +454,20 @@ class ConnectionProvider with ChangeNotifier { } try { + // IMPORTANT: Track pending message BEFORE sending to avoid race condition + // The SENT response can arrive so quickly that if we track after sending, + // the callback will fire before we add the message ID to the queue. + if (messageId != null) { + _messageDeliveryTracker.trackPendingMessage(messageId); + print(' Added message ID to pending queue BEFORE sending: $messageId'); + } + // Send the message await _bleService.sendTextMessage( contactPublicKey: contactPublicKey, text: text, ); - // If message ID provided, add it to the pending queue via helper - // When the SENT response arrives, it will be matched with this message ID - // Note: Messages must be sent sequentially for this to work correctly - if (messageId != null) { - _messageDeliveryTracker.trackPendingMessage(messageId); - print(' Added message ID to pending queue: $messageId'); - } - return true; } catch (e) { _error = 'Failed to send message: $e'; @@ -491,16 +491,16 @@ class ConnectionProvider with ChangeNotifier { } try { + // IMPORTANT: Track pending message BEFORE sending to avoid race condition + if (messageId != null) { + _messageDeliveryTracker.trackPendingMessage(messageId); + print(' Added message ID to pending queue BEFORE sending: $messageId'); + } + await _bleService.sendChannelMessage( channelIdx: channelIdx, text: text, ); - - // If message ID provided, add it to the pending queue via helper - if (messageId != null) { - _messageDeliveryTracker.trackPendingMessage(messageId); - print(' Added message ID to pending queue: $messageId'); - } } catch (e) { _error = 'Failed to send channel message: $e'; notifyListeners(); diff --git a/lib/screens/device_config_screen.dart b/lib/screens/device_config_screen.dart index 61fed23..8086946 100644 --- a/lib/screens/device_config_screen.dart +++ b/lib/screens/device_config_screen.dart @@ -3,6 +3,7 @@ import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; import 'package:geolocator/geolocator.dart'; import '../providers/connection_provider.dart'; +import '../services/validation_service.dart'; class DeviceConfigScreen extends StatefulWidget { const DeviceConfigScreen({super.key}); @@ -106,6 +107,7 @@ class _DeviceConfigScreenState extends State { Future _savePublicInfo() async { final connectionProvider = context.read(); final deviceInfo = connectionProvider.deviceInfo; + final validator = ValidationService(); try { // Save name @@ -115,11 +117,36 @@ class _DeviceConfigScreenState extends State { // Save position and telemetry settings if (_telemetryEnabled) { - final lat = double.tryParse(_latController.text) ?? 0.0; - final lon = double.tryParse(_lonController.text) ?? 0.0; + // Parse and validate coordinates + final latResult = validator.parseLatitude(_latController.text); + if (!latResult.isSuccess) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(latResult.errorMessage!), + backgroundColor: Colors.red, + ), + ); + } + return; + } + + final lonResult = validator.parseLongitude(_lonController.text); + if (!lonResult.isSuccess) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(lonResult.errorMessage!), + backgroundColor: Colors.red, + ), + ); + } + return; + } + await connectionProvider.setAdvertLatLon( - latitude: lat, - longitude: lon, + latitude: latResult.value!, + longitude: lonResult.value!, ); // Set telemetry modes to "Allow All" (mode 2 for both base and location) @@ -167,21 +194,53 @@ class _DeviceConfigScreenState extends State { Future _saveRadioSettings() async { final connectionProvider = context.read(); + final validator = ValidationService(); + final deviceInfo = connectionProvider.deviceInfo; try { - // Parse and save frequency (convert from MHz to kHz) - final freq = (double.tryParse(_freqController.text) ?? 869.618) * 1000; + // Parse and validate frequency + final freqResult = validator.parseFrequency(_freqController.text); + if (!freqResult.isSuccess) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(freqResult.errorMessage!), + backgroundColor: Colors.red, + ), + ); + } + return; + } + + // Parse and validate TX power + final txPowerResult = validator.parseTxPower( + _txPowerController.text, + maxPower: deviceInfo.maxTxPower, + ); + if (!txPowerResult.isSuccess) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(txPowerResult.errorMessage!), + backgroundColor: Colors.red, + ), + ); + } + return; + } + + // Convert from MHz to kHz for protocol + final freqKhz = (freqResult.value! * 1000).round(); await connectionProvider.setRadioParams( - frequency: freq.round(), + frequency: freqKhz, bandwidth: _bandwidthToValue(_selectedBandwidth), spreadingFactor: _selectedSpreadingFactor, codingRate: _selectedCodingRate, ); // Save TX power - final txPower = int.tryParse(_txPowerController.text) ?? 20; - await connectionProvider.setTxPower(txPower); + await connectionProvider.setTxPower(txPowerResult.value!); if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( diff --git a/lib/screens/map_management_screen.dart b/lib/screens/map_management_screen.dart index f224a22..ea4b5e4 100644 --- a/lib/screens/map_management_screen.dart +++ b/lib/screens/map_management_screen.dart @@ -4,6 +4,7 @@ import 'package:latlong2/latlong.dart'; import 'package:file_picker/file_picker.dart'; import 'package:share_plus/share_plus.dart'; import '../services/tile_cache_service.dart'; +import '../services/validation_service.dart'; import '../models/map_layer.dart'; class MapManagementScreen extends StatefulWidget { @@ -108,25 +109,49 @@ class _MapManagementScreenState extends State { } Future _downloadRegion() async { + final validator = ValidationService(); + try { + // Parse coordinates final north = double.tryParse(_northController.text); final south = double.tryParse(_southController.text); final east = double.tryParse(_eastController.text); final west = double.tryParse(_westController.text); - if (north == null || south == null || east == null || west == null) { - _showError('Invalid coordinates. Please enter valid numbers.'); + // Validate bounds + final boundsResult = validator.validateBounds( + north: north, + south: south, + east: east, + west: west, + ); + + if (!boundsResult.isValid) { + _showError(boundsResult.errorMessage!); return; } - if (north <= south || east <= west) { - _showError('Invalid bounds. North must be > South, East must be > West.'); + // Validate zoom levels + final minZoomResult = validator.validateZoomLevel(_minZoom); + if (!minZoomResult.isValid) { + _showError('Min zoom: ${minZoomResult.errorMessage}'); + return; + } + + final maxZoomResult = validator.validateZoomLevel(_maxZoom); + if (!maxZoomResult.isValid) { + _showError('Max zoom: ${maxZoomResult.errorMessage}'); + return; + } + + if (_minZoom > _maxZoom) { + _showError('Minimum zoom must be less than or equal to maximum zoom'); return; } final bounds = LatLngBounds( - LatLng(south, west), - LatLng(north, east), + LatLng(south!, west!), + LatLng(north!, east!), ); if (!mounted) return; diff --git a/lib/screens/map_tab.dart b/lib/screens/map_tab.dart index 413a4b4..b8762ca 100644 --- a/lib/screens/map_tab.dart +++ b/lib/screens/map_tab.dart @@ -1,5 +1,4 @@ import 'dart:async'; -import 'dart:math'; import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:flutter_map/flutter_map.dart'; @@ -19,7 +18,8 @@ import '../models/map_layer.dart'; import '../models/message.dart'; import '../services/tile_cache_service.dart'; import '../services/background_location_service.dart'; -import '../widgets/map_markers.dart'; +import '../services/location_tracking_service.dart'; +import '../services/map_marker_service.dart'; import '../widgets/map_debug_info.dart'; import '../widgets/map/map_legend.dart'; import '../widgets/map/compass_widget.dart'; @@ -37,17 +37,17 @@ class MapTab extends StatefulWidget { class _MapTabState extends State with AutomaticKeepAliveClientMixin { final MapController _mapController = MapController(); final TileCacheService _tileCache = TileCacheService(); + final LocationTrackingService _locationService = LocationTrackingService(); + final MapMarkerService _markerService = MapMarkerService(); bool _isInitialized = false; bool _isMapReady = false; // Track when map widget is actually rendered MapLayer _currentLayer = MapLayer.openStreetMap; - Position? _currentPosition; double? _compassHeading; // Compass sensor heading bool _rotateMarkerWithHeading = false; // Toggle for rotation bool _showLegend = false; bool _showMapDebugInfo = false; // Toggle for debug info double _gpsUpdateDistance = 3.0; // meters bool _backgroundTrackingEnabled = false; // Toggle for background tracking - StreamSubscription? _positionStreamSubscription; StreamSubscription? _compassStreamSubscription; final BackgroundLocationService _backgroundLocationService = BackgroundLocationService(); @@ -72,7 +72,7 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { super.initState(); _loadSettings(); _initializeTileCache(); - _requestLocationPermission(); + _initLocationTracking(); _startCompassTracking(); // Listen to map provider for navigation requests @@ -89,6 +89,45 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { }); } + Future _initLocationTracking() async { + // Initialize LocationTrackingService + final appProvider = context.read(); + await _locationService.initialize(appProvider.connectionProvider.bleService); + + // Set up callbacks + _locationService.onPositionUpdate = (position) { + if (mounted) { + setState(() { + // Position updates are now handled by the service + }); + + // Rotate map if rotation mode is enabled and heading is available + if (_isMapReady && _rotateMarkerWithHeading && position.heading >= 0) { + try { + final camera = _mapController.camera; + _mapController.moveAndRotate( + camera.center, + camera.zoom, + -position.heading, + ); + } catch (e) { + // Map not ready yet, ignore + } + } + } + }; + + _locationService.onError = (error) { + debugPrint('Location tracking error: $error'); + }; + + // Request permissions and start tracking + final hasPermission = await _locationService.requestPermissions(); + if (hasPermission) { + await _locationService.startTracking(distanceThreshold: _gpsUpdateDistance); + } + } + void _startCompassTracking() { final compassStream = FlutterCompass.events; if (compassStream == null) { @@ -178,70 +217,6 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { } } - Future _requestLocationPermission() async { - bool serviceEnabled = await Geolocator.isLocationServiceEnabled(); - if (!serviceEnabled) { - return; - } - - LocationPermission permission = await Geolocator.checkPermission(); - if (permission == LocationPermission.denied) { - permission = await Geolocator.requestPermission(); - if (permission == LocationPermission.denied) { - return; - } - } - - if (permission == LocationPermission.deniedForever) { - return; - } - - // Get initial position - try { - final position = await Geolocator.getCurrentPosition( - locationSettings: const LocationSettings( - accuracy: LocationAccuracy.best, - distanceFilter: 0, - ), - ); - if (mounted) { - setState(() { - _currentPosition = position; - }); - } - } catch (e) { - debugPrint('Error getting location: $e'); - } - - // Start listening to location updates - _positionStreamSubscription = Geolocator.getPositionStream( - locationSettings: LocationSettings( - accuracy: LocationAccuracy.best, - distanceFilter: _gpsUpdateDistance.toInt(), - ), - ).listen((Position position) { - if (mounted) { - setState(() { - _currentPosition = position; - }); - - // Rotate map if rotation mode is enabled and heading is available - // Heading of -1.0 means heading is unavailable - if (_isMapReady && _rotateMarkerWithHeading && position.heading != null && position.heading >= 0) { - try { - final camera = _mapController.camera; - _mapController.moveAndRotate( - camera.center, - camera.zoom, - -position.heading, - ); - } catch (e) { - // Map not ready yet, ignore - } - } - } - }); - } void _handleMapNavigation() { final mapProvider = context.read(); @@ -312,8 +287,8 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { final mapProvider = context.read(); mapProvider.removeListener(_handleMapNavigation); - _positionStreamSubscription?.cancel(); _compassStreamSubscription?.cancel(); + _locationService.stopTracking(); _mapController.dispose(); _tileCache.dispose(); super.dispose(); @@ -326,8 +301,9 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { return _compassHeading; } // Fall back to GPS heading when moving - if (_currentPosition?.heading != null && _currentPosition!.heading >= 0) { - return _currentPosition!.heading; + final currentPosition = _locationService.currentPosition; + if (currentPosition?.heading != null && currentPosition!.heading >= 0) { + return currentPosition.heading; } return null; } @@ -344,27 +320,11 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { } LatLng _calculateCenter(List contacts, List sarMarkers) { - final allPoints = []; - - for (final contact in contacts) { - if (contact.displayLocation != null) { - allPoints.add(contact.displayLocation!); - } - } - - for (final marker in sarMarkers) { - allPoints.add(marker.location); - } - - if (allPoints.isEmpty) return _defaultCenter; - - double lat = 0, lng = 0; - for (final point in allPoints) { - lat += point.latitude; - lng += point.longitude; - } - - return LatLng(lat / allPoints.length, lng / allPoints.length); + return _markerService.calculateCenter( + contacts: contacts, + sarMarkers: sarMarkers, + defaultCenter: _defaultCenter, + ); } void _showLayerSelector(BuildContext context) { @@ -556,7 +516,7 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), ), child: DetailedCompassDialog( - initialPosition: _currentPosition, + initialPosition: _locationService.currentPosition, initialHeading: _currentHeading, contacts: contacts, sarMarkers: sarMarkers, @@ -582,7 +542,7 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), ), child: DetailedCompassDialog( - initialPosition: _currentPosition, + initialPosition: _locationService.currentPosition, initialHeading: _currentHeading, contacts: contacts, sarMarkers: sarMarkers, @@ -609,7 +569,7 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), ), child: DetailedCompassDialog( - initialPosition: _currentPosition, + initialPosition: _locationService.currentPosition, initialHeading: _currentHeading, contacts: contacts, sarMarkers: sarMarkers, @@ -620,37 +580,8 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { } void _restartLocationStream() { - // Cancel existing subscription - _positionStreamSubscription?.cancel(); - - // Start new stream with updated distance - _positionStreamSubscription = Geolocator.getPositionStream( - locationSettings: LocationSettings( - accuracy: LocationAccuracy.best, - distanceFilter: _gpsUpdateDistance.toInt(), - ), - ).listen((Position position) { - if (mounted) { - setState(() { - _currentPosition = position; - }); - - // Rotate map if rotation mode is enabled and heading is available - // Heading of -1.0 means heading is unavailable - if (_isMapReady && _rotateMarkerWithHeading && position.heading != null && position.heading >= 0) { - try { - final camera = _mapController.camera; - _mapController.moveAndRotate( - camera.center, - camera.zoom, - -position.heading, - ); - } catch (e) { - // Map not ready yet, ignore - } - } - } - }); + // Update distance threshold in location service + _locationService.updateDistanceThreshold(_gpsUpdateDistance); // Update background tracking distance if active if (_backgroundTrackingEnabled) { @@ -695,18 +626,12 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { /// Calculate distance between two points in meters double _calculateDistanceInMeters(double lat1, double lon1, double lat2, double lon2) { - const R = 6371000; // Earth's radius in meters - final dLat = (lat2 - lat1) * pi / 180; - final dLon = (lon2 - lon1) * pi / 180; - - final a = sin(dLat / 2) * sin(dLat / 2) + - cos(lat1 * pi / 180) * - cos(lat2 * pi / 180) * - sin(dLon / 2) * - sin(dLon / 2); - - final c = 2 * atan2(sqrt(a), sqrt(1 - a)); - return R * c; + return _markerService.calculateDistance( + lat1: lat1, + lon1: lon1, + lat2: lat2, + lon2: lon2, + ); } /// Show SAR dialog with pre-populated location from map long press @@ -964,11 +889,13 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { ), MarkerLayer( markers: [ - ...MapMarkers.createTeamMemberMarkers( - contactsWithLocation, - context, + // Contact markers + ..._markerService.generateContactMarkers( + contacts: contactsWithLocation, + context: context, mapRotation: _getMapRotation(), - onContactTap: (contact) { + userPosition: _locationService.currentPosition, + onTap: (contact) { _showDetailedCompassWithContact( context, contactsProvider.contactsWithLocation, @@ -977,11 +904,12 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { ); }, ), - ...MapMarkers.createSarMarkers( - sarMarkers, - context, + // SAR markers + ..._markerService.generateSarMarkers( + sarMarkers: sarMarkers, + context: context, mapRotation: _getMapRotation(), - onSarMarkerTap: (marker) { + onTap: (marker) { _showDetailedCompassWithSarMarker( context, contactsProvider.contactsWithLocation, @@ -991,40 +919,14 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { }, ), // User location marker - if (_currentPosition != null) - Marker( - point: LatLng( - _currentPosition!.latitude, - _currentPosition!.longitude, - ), - width: 40, - height: 40, - rotate: false, // Don't rotate with map - child: Container( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.3), - shape: BoxShape.circle, - ), - child: Container( - margin: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.primary, - shape: BoxShape.circle, - boxShadow: [ - BoxShadow( - color: Colors.black26, - blurRadius: 4, - ), - ], - ), - child: const Icon( - Icons.my_location, - color: Colors.white, - size: 16, - ), - ), - ), - ), + if (_markerService.generateUserLocationMarker( + position: _locationService.currentPosition, + context: context, + ) != null) + _markerService.generateUserLocationMarker( + position: _locationService.currentPosition, + context: context, + )!, // Dropped pin marker with label if (_droppedPinLocation != null) Marker( @@ -1152,30 +1054,23 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { heroTag: 'center_map', onPressed: !_isMapReady ? null : () async { // Force update GPS location and jump to it - try { - final position = await Geolocator.getCurrentPosition( - locationSettings: const LocationSettings( - accuracy: LocationAccuracy.best, - distanceFilter: 0, - ), + final position = await _locationService.getCurrentPosition(); + if (position != null && mounted) { + setState(() { + // Position updated in service + }); + _mapController.move( + LatLng(position.latitude, position.longitude), + 16, ); - if (mounted) { - setState(() { - _currentPosition = position; - }); - _mapController.move( - LatLng(position.latitude, position.longitude), - 16, - ); - } - } catch (e) { - debugPrint('Error getting location: $e'); + } else { // Fallback to cached position or default center - if (_currentPosition != null) { + final currentPosition = _locationService.currentPosition; + if (currentPosition != null) { _mapController.move( LatLng( - _currentPosition!.latitude, - _currentPosition!.longitude, + currentPosition.latitude, + currentPosition.longitude, ), 16, ); diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 1349b01..9538847 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -7,7 +7,7 @@ import 'package:latlong2/latlong.dart'; import '../providers/contacts_provider.dart'; import '../providers/messages_provider.dart'; import '../providers/app_provider.dart'; -import '../services/background_location_service.dart'; +import '../services/location_tracking_service.dart'; import '../utils/sample_data_generator.dart'; import '../theme/app_theme.dart'; @@ -29,21 +29,15 @@ class _SettingsScreenState extends State { late AppThemeMode _selectedTheme; PackageInfo? _packageInfo; bool _isLoadingSampleData = false; - double _gpsUpdateDistance = 10.0; - double _gpsMinDistance = 5.0; - double _gpsMaxDistance = 100.0; - int _minTimeIntervalSeconds = 30; - bool _backgroundTrackingEnabled = false; bool _isSendingLocationUpdate = false; - final BackgroundLocationService _backgroundLocationService = - BackgroundLocationService(); + final LocationTrackingService _locationService = LocationTrackingService(); @override void initState() { super.initState(); _selectedTheme = widget.currentTheme; _loadPackageInfo(); - _loadLocationSettings(); + _initializeLocationService(); } Future _loadPackageInfo() async { @@ -55,45 +49,60 @@ class _SettingsScreenState extends State { } } - Future _loadLocationSettings() async { - final prefs = await SharedPreferences.getInstance(); - if (mounted) { - setState(() { - _gpsUpdateDistance = prefs.getDouble('map_gps_update_distance') ?? 10.0; - _gpsMinDistance = prefs.getDouble('map_gps_min_distance') ?? 5.0; - _gpsMaxDistance = prefs.getDouble('map_gps_max_distance') ?? 100.0; - _minTimeIntervalSeconds = prefs.getInt('map_gps_min_time_interval') ?? 30; - _backgroundTrackingEnabled = - prefs.getBool('background_tracking_enabled') ?? false; - }); + Future _initializeLocationService() async { + // Initialize location service with BLE service + WidgetsBinding.instance.addPostFrameCallback((_) async { + if (mounted) { + final appProvider = context.read(); + await _locationService.initialize( + appProvider.connectionProvider.bleService, + ); - // Initialize background location service - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) { - final appProvider = context.read(); - _backgroundLocationService.initialize( - appProvider.connectionProvider.bleService, - ); - - // Restore background tracking state - if (_backgroundTrackingEnabled) { - _startBackgroundTracking(); + // Set up callbacks for UI feedback + _locationService.onError = (error) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(error), + backgroundColor: Colors.orange, + ), + ); } - } - }); - } - } + }; - Future _saveLocationSettings() async { - final prefs = await SharedPreferences.getInstance(); - await prefs.setDouble('map_gps_update_distance', _gpsUpdateDistance); - await prefs.setDouble('map_gps_min_distance', _gpsMinDistance); - await prefs.setDouble('map_gps_max_distance', _gpsMaxDistance); - await prefs.setInt('map_gps_min_time_interval', _minTimeIntervalSeconds); - await prefs.setBool( - 'background_tracking_enabled', - _backgroundTrackingEnabled, - ); + _locationService.onBroadcastSent = (position) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'Location broadcast: ${position.latitude.toStringAsFixed(5)}, ${position.longitude.toStringAsFixed(5)}', + ), + backgroundColor: Colors.green, + duration: const Duration(seconds: 2), + ), + ); + } + }; + + _locationService.onTrackingStateChanged = (isTracking) { + if (mounted) { + setState(() {}); + } + }; + + // Load settings and restore tracking state + final prefs = await SharedPreferences.getInstance(); + final wasTracking = prefs.getBool('background_tracking_enabled') ?? false; + + if (wasTracking) { + await _startBackgroundTracking(); + } + + if (mounted) { + setState(() {}); + } + } + }); } Future _saveThemePreference(AppThemeMode theme) async { @@ -198,89 +207,47 @@ class _SettingsScreenState extends State { } Future _startBackgroundTracking() async { - final success = await _backgroundLocationService.startTracking( - distanceThreshold: _gpsUpdateDistance, + final success = await _locationService.startTracking( + distanceThreshold: _locationService.gpsUpdateDistance, ); - if (!success) { - if (mounted) { - setState(() { - _backgroundTrackingEnabled = false; - }); - _saveLocationSettings(); - - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text( - 'Failed to start background tracking. Check permissions and BLE connection.', - ), - duration: Duration(seconds: 3), + if (!success && mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'Failed to start background tracking. Check permissions and BLE connection.', ), - ); - } + duration: Duration(seconds: 3), + ), + ); + } + + if (mounted) { + setState(() {}); } } Future _stopBackgroundTracking() async { - await _backgroundLocationService.stopTracking(); + await _locationService.stopTracking(); + if (mounted) { + setState(() {}); + } } Future _sendLocationUpdateNow() async { setState(() => _isSendingLocationUpdate = true); try { - // Get current location - Position position = await Geolocator.getCurrentPosition( - locationSettings: const LocationSettings( - accuracy: LocationAccuracy.best, - ), - ); + final success = await _locationService.broadcastLocationNow(); - if (!mounted) return; - - // Get connection provider - final appProvider = context.read(); - final connectionProvider = appProvider.connectionProvider; - - if (!connectionProvider.deviceInfo.isConnected) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Not connected to device'), - backgroundColor: Colors.orange, - ), - ); - } - return; - } - - // Update device location - await connectionProvider.setAdvertLatLon( - latitude: position.latitude, - longitude: position.longitude, - ); - - // Send advertisement - await connectionProvider.sendSelfAdvert(floodMode: true); - - if (!mounted) return; - - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - 'Location broadcast: ${position.latitude.toStringAsFixed(5)}, ${position.longitude.toStringAsFixed(5)}', + if (!success && mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Failed to send location update'), + backgroundColor: Colors.red, ), - backgroundColor: Colors.green, - ), - ); - } catch (e) { - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Failed to send location: $e'), - backgroundColor: Colors.red, - ), - ); + ); + } } finally { if (mounted) { setState(() => _isSendingLocationUpdate = false); @@ -383,21 +350,17 @@ class _SettingsScreenState extends State { secondary: const Icon(Icons.location_on), title: const Text('Auto Location Tracking'), subtitle: const Text('Automatically broadcast position updates'), - value: _backgroundTrackingEnabled, + value: _locationService.isTracking, onChanged: (value) { - setState(() { - _backgroundTrackingEnabled = value; - if (value) { - _startBackgroundTracking(); - } else { - _stopBackgroundTracking(); - } - }); - _saveLocationSettings(); + if (value) { + _startBackgroundTracking(); + } else { + _stopBackgroundTracking(); + } }, ), - if (_backgroundTrackingEnabled) ...[ + if (_locationService.isTracking) ...[ ListTile( leading: const Icon(Icons.tune), title: const Text('Configure Tracking'), @@ -509,9 +472,9 @@ class _SettingsScreenState extends State { } void _showTrackingConfigDialog() { - double tempMinDistance = _gpsMinDistance; - double tempMaxDistance = _gpsMaxDistance; - int tempTimeInterval = _minTimeIntervalSeconds; + double tempMinDistance = _locationService.minDistanceMeters; + double tempMaxDistance = _locationService.maxDistanceMeters; + int tempTimeInterval = _locationService.minTimeIntervalSeconds; showDialog( context: context, @@ -670,23 +633,27 @@ class _SettingsScreenState extends State { child: const Text('Cancel'), ), TextButton( - onPressed: () { - setState(() { - _gpsMinDistance = tempMinDistance; - _gpsMaxDistance = tempMaxDistance; - _minTimeIntervalSeconds = tempTimeInterval; - _gpsUpdateDistance = tempMinDistance; // Use min as the primary threshold - }); - _saveLocationSettings(); + onPressed: () async { + // Update location service configuration + _locationService.minDistanceMeters = tempMinDistance; + _locationService.maxDistanceMeters = tempMaxDistance; + _locationService.minTimeIntervalSeconds = tempTimeInterval; + _locationService.gpsUpdateDistance = tempMinDistance; - // Update background tracking if active - if (_backgroundTrackingEnabled) { - _backgroundLocationService.updateDistanceThreshold( - tempMinDistance, - ); + // Save settings + await _locationService.saveSettings(); + + // Update tracking if active + if (_locationService.isTracking) { + await _locationService.updateDistanceThreshold(tempMinDistance); } + // Close dialog before setState Navigator.pop(context); + + if (mounted) { + setState(() {}); + } }, child: const Text('Save'), ), diff --git a/lib/widgets/messages/sar_update_sheet.dart b/lib/widgets/messages/sar_update_sheet.dart index 0345f41..8dec0b8 100644 --- a/lib/widgets/messages/sar_update_sheet.dart +++ b/lib/widgets/messages/sar_update_sheet.dart @@ -5,6 +5,7 @@ import 'package:geolocator/geolocator.dart'; import '../../providers/contacts_provider.dart'; import '../../models/contact.dart'; import '../../models/sar_marker.dart'; +import '../../services/validation_service.dart'; /// SAR Update Sheet - Modal bottom sheet for creating and sending SAR markers /// This widget is public so it can be used from both messages_tab.dart and map_tab.dart @@ -584,12 +585,76 @@ class _SarUpdateSheetState extends State { onPressed: _currentPosition == null || _selectedContact == null ? null : () async { + final validator = ValidationService(); + + // Validate coordinates + final coordResult = validator.validateCoordinates( + _currentPosition!.latitude, + _currentPosition!.longitude, + ); + if (!coordResult.isValid) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(coordResult.errorMessage!), + backgroundColor: Colors.red, + ), + ); + } + return; + } + + // Validate notes length if provided + final notes = _notesController.text.trim(); + if (notes.isNotEmpty) { + final notesResult = validator.validateName( + notes, + maxLength: 100, + ); + if (!notesResult.isValid) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(notesResult.errorMessage!), + backgroundColor: Colors.red, + ), + ); + } + return; + } + } + + // Validate location accuracy (warn if >50m) + if (_currentPosition!.accuracy != null && + _currentPosition!.accuracy! > 50.0) { + final shouldContinue = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Low Location Accuracy'), + content: Text( + 'Location accuracy is ±${_currentPosition!.accuracy!.round()}m. ' + 'This may not be accurate enough for SAR operations.\n\n' + 'Continue anyway?', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('Continue'), + ), + ], + ), + ); + if (shouldContinue != true) return; + } + await widget.onSend( _selectedType, _currentPosition!, - _notesController.text.trim().isEmpty - ? null - : _notesController.text.trim(), + notes.isEmpty ? null : notes, _selectedContact!.isChannel ? null : _selectedContact!.publicKey,