mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 16:30:28 +00:00
Enhance validation and error handling across various screens
- Updated ConnectionProvider to track pending messages before sending to avoid race conditions. - Integrated ValidationService in DeviceConfigScreen for validating latitude, longitude, frequency, and TX power inputs. - Added bounds and zoom level validation in MapManagementScreen using ValidationService. - Refactored MapTab to utilize LocationTrackingService for location updates and removed deprecated location permission handling. - Replaced BackgroundLocationService with LocationTrackingService in SettingsScreen, improving location update management and error handling. - Enhanced SAR Update Sheet to validate coordinates, notes length, and location accuracy before sending SAR markers.
This commit is contained in:
@@ -22,7 +22,8 @@
|
|||||||
"Bash(cat:*)",
|
"Bash(cat:*)",
|
||||||
"Read(//Users/dz0ny/.pub-cache/hosted/pub.dev/**)",
|
"Read(//Users/dz0ny/.pub-cache/hosted/pub.dev/**)",
|
||||||
"Bash(find:*)",
|
"Bash(find:*)",
|
||||||
"Read(//Users/dz0ny/meshcore-sar/**)"
|
"Read(//Users/dz0ny/meshcore-sar/**)",
|
||||||
|
"Bash(git grep:*)"
|
||||||
],
|
],
|
||||||
"deny": [],
|
"deny": [],
|
||||||
"ask": []
|
"ask": []
|
||||||
|
|||||||
440
CLAUDE.md
440
CLAUDE.md
@@ -38,13 +38,17 @@ lib/
|
|||||||
│ ├── buffer_writer.dart # Binary protocol writer
|
│ ├── buffer_writer.dart # Binary protocol writer
|
||||||
│ ├── cayenne_lpp_parser.dart # Telemetry decoder
|
│ ├── cayenne_lpp_parser.dart # Telemetry decoder
|
||||||
│ ├── tile_cache_service.dart # Offline map tiles
|
│ ├── tile_cache_service.dart # Offline map tiles
|
||||||
|
│ ├── background_location_service.dart # Background GPS tracking (legacy)
|
||||||
│ ├── protocol/ # Protocol layer (628 lines)
|
│ ├── protocol/ # Protocol layer (628 lines)
|
||||||
│ │ ├── frame_parser.dart # Parse incoming BLE frames
|
│ │ ├── frame_parser.dart # Parse incoming BLE frames
|
||||||
│ │ └── frame_builder.dart # Build outgoing BLE frames
|
│ │ └── frame_builder.dart # Build outgoing BLE frames
|
||||||
│ └── ble/ # BLE layer (963 lines)
|
│ ├── ble/ # BLE layer (963 lines)
|
||||||
│ ├── ble_connection_manager.dart # Connection lifecycle
|
│ │ ├── ble_connection_manager.dart # Connection lifecycle
|
||||||
│ ├── ble_command_sender.dart # Command transmission
|
│ │ ├── ble_command_sender.dart # Command transmission
|
||||||
│ └── ble_response_handler.dart # Response processing
|
│ │ └── 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
|
├── providers/ # State management
|
||||||
│ ├── connection_provider.dart # BLE connection state (957 lines)
|
│ ├── connection_provider.dart # BLE connection state (957 lines)
|
||||||
│ ├── contacts_provider.dart # Contact list management
|
│ ├── contacts_provider.dart # Contact list management
|
||||||
@@ -784,6 +788,434 @@ enum ContactType {
|
|||||||
- Only `ContactType.chat` contacts with valid GPS are shown on map
|
- Only `ContactType.chat` contacts with valid GPS are shown on map
|
||||||
- Repeaters and rooms are listed in Contacts tab but not mapped
|
- 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<T>`) 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<T> - for parsing + validation
|
||||||
|
class ParseResult<T> {
|
||||||
|
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<SettingsScreen> {
|
||||||
|
final LocationTrackingService _locationService = LocationTrackingService();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_initLocationService();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _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<MapTab> {
|
||||||
|
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<void> _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
|
## Map Implementation
|
||||||
|
|
||||||
### Tile Layers
|
### Tile Layers
|
||||||
|
|||||||
@@ -454,20 +454,20 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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
|
// Send the message
|
||||||
await _bleService.sendTextMessage(
|
await _bleService.sendTextMessage(
|
||||||
contactPublicKey: contactPublicKey,
|
contactPublicKey: contactPublicKey,
|
||||||
text: text,
|
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;
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_error = 'Failed to send message: $e';
|
_error = 'Failed to send message: $e';
|
||||||
@@ -491,16 +491,16 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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(
|
await _bleService.sendChannelMessage(
|
||||||
channelIdx: channelIdx,
|
channelIdx: channelIdx,
|
||||||
text: text,
|
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) {
|
} catch (e) {
|
||||||
_error = 'Failed to send channel message: $e';
|
_error = 'Failed to send channel message: $e';
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'package:flutter/services.dart';
|
|||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:geolocator/geolocator.dart';
|
import 'package:geolocator/geolocator.dart';
|
||||||
import '../providers/connection_provider.dart';
|
import '../providers/connection_provider.dart';
|
||||||
|
import '../services/validation_service.dart';
|
||||||
|
|
||||||
class DeviceConfigScreen extends StatefulWidget {
|
class DeviceConfigScreen extends StatefulWidget {
|
||||||
const DeviceConfigScreen({super.key});
|
const DeviceConfigScreen({super.key});
|
||||||
@@ -106,6 +107,7 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
|
|||||||
Future<void> _savePublicInfo() async {
|
Future<void> _savePublicInfo() async {
|
||||||
final connectionProvider = context.read<ConnectionProvider>();
|
final connectionProvider = context.read<ConnectionProvider>();
|
||||||
final deviceInfo = connectionProvider.deviceInfo;
|
final deviceInfo = connectionProvider.deviceInfo;
|
||||||
|
final validator = ValidationService();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Save name
|
// Save name
|
||||||
@@ -115,11 +117,36 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
|
|||||||
|
|
||||||
// Save position and telemetry settings
|
// Save position and telemetry settings
|
||||||
if (_telemetryEnabled) {
|
if (_telemetryEnabled) {
|
||||||
final lat = double.tryParse(_latController.text) ?? 0.0;
|
// Parse and validate coordinates
|
||||||
final lon = double.tryParse(_lonController.text) ?? 0.0;
|
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(
|
await connectionProvider.setAdvertLatLon(
|
||||||
latitude: lat,
|
latitude: latResult.value!,
|
||||||
longitude: lon,
|
longitude: lonResult.value!,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Set telemetry modes to "Allow All" (mode 2 for both base and location)
|
// Set telemetry modes to "Allow All" (mode 2 for both base and location)
|
||||||
@@ -167,21 +194,53 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
|
|||||||
|
|
||||||
Future<void> _saveRadioSettings() async {
|
Future<void> _saveRadioSettings() async {
|
||||||
final connectionProvider = context.read<ConnectionProvider>();
|
final connectionProvider = context.read<ConnectionProvider>();
|
||||||
|
final validator = ValidationService();
|
||||||
|
final deviceInfo = connectionProvider.deviceInfo;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Parse and save frequency (convert from MHz to kHz)
|
// Parse and validate frequency
|
||||||
final freq = (double.tryParse(_freqController.text) ?? 869.618) * 1000;
|
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(
|
await connectionProvider.setRadioParams(
|
||||||
frequency: freq.round(),
|
frequency: freqKhz,
|
||||||
bandwidth: _bandwidthToValue(_selectedBandwidth),
|
bandwidth: _bandwidthToValue(_selectedBandwidth),
|
||||||
spreadingFactor: _selectedSpreadingFactor,
|
spreadingFactor: _selectedSpreadingFactor,
|
||||||
codingRate: _selectedCodingRate,
|
codingRate: _selectedCodingRate,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Save TX power
|
// Save TX power
|
||||||
final txPower = int.tryParse(_txPowerController.text) ?? 20;
|
await connectionProvider.setTxPower(txPowerResult.value!);
|
||||||
await connectionProvider.setTxPower(txPower);
|
|
||||||
|
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import 'package:latlong2/latlong.dart';
|
|||||||
import 'package:file_picker/file_picker.dart';
|
import 'package:file_picker/file_picker.dart';
|
||||||
import 'package:share_plus/share_plus.dart';
|
import 'package:share_plus/share_plus.dart';
|
||||||
import '../services/tile_cache_service.dart';
|
import '../services/tile_cache_service.dart';
|
||||||
|
import '../services/validation_service.dart';
|
||||||
import '../models/map_layer.dart';
|
import '../models/map_layer.dart';
|
||||||
|
|
||||||
class MapManagementScreen extends StatefulWidget {
|
class MapManagementScreen extends StatefulWidget {
|
||||||
@@ -108,25 +109,49 @@ class _MapManagementScreenState extends State<MapManagementScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _downloadRegion() async {
|
Future<void> _downloadRegion() async {
|
||||||
|
final validator = ValidationService();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Parse coordinates
|
||||||
final north = double.tryParse(_northController.text);
|
final north = double.tryParse(_northController.text);
|
||||||
final south = double.tryParse(_southController.text);
|
final south = double.tryParse(_southController.text);
|
||||||
final east = double.tryParse(_eastController.text);
|
final east = double.tryParse(_eastController.text);
|
||||||
final west = double.tryParse(_westController.text);
|
final west = double.tryParse(_westController.text);
|
||||||
|
|
||||||
if (north == null || south == null || east == null || west == null) {
|
// Validate bounds
|
||||||
_showError('Invalid coordinates. Please enter valid numbers.');
|
final boundsResult = validator.validateBounds(
|
||||||
|
north: north,
|
||||||
|
south: south,
|
||||||
|
east: east,
|
||||||
|
west: west,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!boundsResult.isValid) {
|
||||||
|
_showError(boundsResult.errorMessage!);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (north <= south || east <= west) {
|
// Validate zoom levels
|
||||||
_showError('Invalid bounds. North must be > South, East must be > West.');
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final bounds = LatLngBounds(
|
final bounds = LatLngBounds(
|
||||||
LatLng(south, west),
|
LatLng(south!, west!),
|
||||||
LatLng(north, east),
|
LatLng(north!, east!),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:math';
|
|
||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_map/flutter_map.dart';
|
import 'package:flutter_map/flutter_map.dart';
|
||||||
@@ -19,7 +18,8 @@ import '../models/map_layer.dart';
|
|||||||
import '../models/message.dart';
|
import '../models/message.dart';
|
||||||
import '../services/tile_cache_service.dart';
|
import '../services/tile_cache_service.dart';
|
||||||
import '../services/background_location_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_debug_info.dart';
|
||||||
import '../widgets/map/map_legend.dart';
|
import '../widgets/map/map_legend.dart';
|
||||||
import '../widgets/map/compass_widget.dart';
|
import '../widgets/map/compass_widget.dart';
|
||||||
@@ -37,17 +37,17 @@ class MapTab extends StatefulWidget {
|
|||||||
class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
|
class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
|
||||||
final MapController _mapController = MapController();
|
final MapController _mapController = MapController();
|
||||||
final TileCacheService _tileCache = TileCacheService();
|
final TileCacheService _tileCache = TileCacheService();
|
||||||
|
final LocationTrackingService _locationService = LocationTrackingService();
|
||||||
|
final MapMarkerService _markerService = MapMarkerService();
|
||||||
bool _isInitialized = false;
|
bool _isInitialized = false;
|
||||||
bool _isMapReady = false; // Track when map widget is actually rendered
|
bool _isMapReady = false; // Track when map widget is actually rendered
|
||||||
MapLayer _currentLayer = MapLayer.openStreetMap;
|
MapLayer _currentLayer = MapLayer.openStreetMap;
|
||||||
Position? _currentPosition;
|
|
||||||
double? _compassHeading; // Compass sensor heading
|
double? _compassHeading; // Compass sensor heading
|
||||||
bool _rotateMarkerWithHeading = false; // Toggle for rotation
|
bool _rotateMarkerWithHeading = false; // Toggle for rotation
|
||||||
bool _showLegend = false;
|
bool _showLegend = false;
|
||||||
bool _showMapDebugInfo = false; // Toggle for debug info
|
bool _showMapDebugInfo = false; // Toggle for debug info
|
||||||
double _gpsUpdateDistance = 3.0; // meters
|
double _gpsUpdateDistance = 3.0; // meters
|
||||||
bool _backgroundTrackingEnabled = false; // Toggle for background tracking
|
bool _backgroundTrackingEnabled = false; // Toggle for background tracking
|
||||||
StreamSubscription<Position>? _positionStreamSubscription;
|
|
||||||
StreamSubscription<CompassEvent>? _compassStreamSubscription;
|
StreamSubscription<CompassEvent>? _compassStreamSubscription;
|
||||||
final BackgroundLocationService _backgroundLocationService = BackgroundLocationService();
|
final BackgroundLocationService _backgroundLocationService = BackgroundLocationService();
|
||||||
|
|
||||||
@@ -72,7 +72,7 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
|
|||||||
super.initState();
|
super.initState();
|
||||||
_loadSettings();
|
_loadSettings();
|
||||||
_initializeTileCache();
|
_initializeTileCache();
|
||||||
_requestLocationPermission();
|
_initLocationTracking();
|
||||||
_startCompassTracking();
|
_startCompassTracking();
|
||||||
|
|
||||||
// Listen to map provider for navigation requests
|
// Listen to map provider for navigation requests
|
||||||
@@ -89,6 +89,45 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _initLocationTracking() async {
|
||||||
|
// Initialize LocationTrackingService
|
||||||
|
final appProvider = context.read<AppProvider>();
|
||||||
|
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() {
|
void _startCompassTracking() {
|
||||||
final compassStream = FlutterCompass.events;
|
final compassStream = FlutterCompass.events;
|
||||||
if (compassStream == null) {
|
if (compassStream == null) {
|
||||||
@@ -178,70 +217,6 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _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() {
|
void _handleMapNavigation() {
|
||||||
final mapProvider = context.read<MapProvider>();
|
final mapProvider = context.read<MapProvider>();
|
||||||
@@ -312,8 +287,8 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
|
|||||||
|
|
||||||
final mapProvider = context.read<MapProvider>();
|
final mapProvider = context.read<MapProvider>();
|
||||||
mapProvider.removeListener(_handleMapNavigation);
|
mapProvider.removeListener(_handleMapNavigation);
|
||||||
_positionStreamSubscription?.cancel();
|
|
||||||
_compassStreamSubscription?.cancel();
|
_compassStreamSubscription?.cancel();
|
||||||
|
_locationService.stopTracking();
|
||||||
_mapController.dispose();
|
_mapController.dispose();
|
||||||
_tileCache.dispose();
|
_tileCache.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
@@ -326,8 +301,9 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
|
|||||||
return _compassHeading;
|
return _compassHeading;
|
||||||
}
|
}
|
||||||
// Fall back to GPS heading when moving
|
// Fall back to GPS heading when moving
|
||||||
if (_currentPosition?.heading != null && _currentPosition!.heading >= 0) {
|
final currentPosition = _locationService.currentPosition;
|
||||||
return _currentPosition!.heading;
|
if (currentPosition?.heading != null && currentPosition!.heading >= 0) {
|
||||||
|
return currentPosition.heading;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -344,27 +320,11 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
LatLng _calculateCenter(List<Contact> contacts, List<SarMarker> sarMarkers) {
|
LatLng _calculateCenter(List<Contact> contacts, List<SarMarker> sarMarkers) {
|
||||||
final allPoints = <LatLng>[];
|
return _markerService.calculateCenter(
|
||||||
|
contacts: contacts,
|
||||||
for (final contact in contacts) {
|
sarMarkers: sarMarkers,
|
||||||
if (contact.displayLocation != null) {
|
defaultCenter: _defaultCenter,
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _showLayerSelector(BuildContext context) {
|
void _showLayerSelector(BuildContext context) {
|
||||||
@@ -556,7 +516,7 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
|
|||||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
|
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
|
||||||
),
|
),
|
||||||
child: DetailedCompassDialog(
|
child: DetailedCompassDialog(
|
||||||
initialPosition: _currentPosition,
|
initialPosition: _locationService.currentPosition,
|
||||||
initialHeading: _currentHeading,
|
initialHeading: _currentHeading,
|
||||||
contacts: contacts,
|
contacts: contacts,
|
||||||
sarMarkers: sarMarkers,
|
sarMarkers: sarMarkers,
|
||||||
@@ -582,7 +542,7 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
|
|||||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
|
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
|
||||||
),
|
),
|
||||||
child: DetailedCompassDialog(
|
child: DetailedCompassDialog(
|
||||||
initialPosition: _currentPosition,
|
initialPosition: _locationService.currentPosition,
|
||||||
initialHeading: _currentHeading,
|
initialHeading: _currentHeading,
|
||||||
contacts: contacts,
|
contacts: contacts,
|
||||||
sarMarkers: sarMarkers,
|
sarMarkers: sarMarkers,
|
||||||
@@ -609,7 +569,7 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
|
|||||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
|
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
|
||||||
),
|
),
|
||||||
child: DetailedCompassDialog(
|
child: DetailedCompassDialog(
|
||||||
initialPosition: _currentPosition,
|
initialPosition: _locationService.currentPosition,
|
||||||
initialHeading: _currentHeading,
|
initialHeading: _currentHeading,
|
||||||
contacts: contacts,
|
contacts: contacts,
|
||||||
sarMarkers: sarMarkers,
|
sarMarkers: sarMarkers,
|
||||||
@@ -620,37 +580,8 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _restartLocationStream() {
|
void _restartLocationStream() {
|
||||||
// Cancel existing subscription
|
// Update distance threshold in location service
|
||||||
_positionStreamSubscription?.cancel();
|
_locationService.updateDistanceThreshold(_gpsUpdateDistance);
|
||||||
|
|
||||||
// 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 background tracking distance if active
|
// Update background tracking distance if active
|
||||||
if (_backgroundTrackingEnabled) {
|
if (_backgroundTrackingEnabled) {
|
||||||
@@ -695,18 +626,12 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
|
|||||||
|
|
||||||
/// Calculate distance between two points in meters
|
/// Calculate distance between two points in meters
|
||||||
double _calculateDistanceInMeters(double lat1, double lon1, double lat2, double lon2) {
|
double _calculateDistanceInMeters(double lat1, double lon1, double lat2, double lon2) {
|
||||||
const R = 6371000; // Earth's radius in meters
|
return _markerService.calculateDistance(
|
||||||
final dLat = (lat2 - lat1) * pi / 180;
|
lat1: lat1,
|
||||||
final dLon = (lon2 - lon1) * pi / 180;
|
lon1: lon1,
|
||||||
|
lat2: lat2,
|
||||||
final a = sin(dLat / 2) * sin(dLat / 2) +
|
lon2: lon2,
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Show SAR dialog with pre-populated location from map long press
|
/// Show SAR dialog with pre-populated location from map long press
|
||||||
@@ -964,11 +889,13 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
|
|||||||
),
|
),
|
||||||
MarkerLayer(
|
MarkerLayer(
|
||||||
markers: [
|
markers: [
|
||||||
...MapMarkers.createTeamMemberMarkers(
|
// Contact markers
|
||||||
contactsWithLocation,
|
..._markerService.generateContactMarkers(
|
||||||
context,
|
contacts: contactsWithLocation,
|
||||||
|
context: context,
|
||||||
mapRotation: _getMapRotation(),
|
mapRotation: _getMapRotation(),
|
||||||
onContactTap: (contact) {
|
userPosition: _locationService.currentPosition,
|
||||||
|
onTap: (contact) {
|
||||||
_showDetailedCompassWithContact(
|
_showDetailedCompassWithContact(
|
||||||
context,
|
context,
|
||||||
contactsProvider.contactsWithLocation,
|
contactsProvider.contactsWithLocation,
|
||||||
@@ -977,11 +904,12 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
...MapMarkers.createSarMarkers(
|
// SAR markers
|
||||||
sarMarkers,
|
..._markerService.generateSarMarkers(
|
||||||
context,
|
sarMarkers: sarMarkers,
|
||||||
|
context: context,
|
||||||
mapRotation: _getMapRotation(),
|
mapRotation: _getMapRotation(),
|
||||||
onSarMarkerTap: (marker) {
|
onTap: (marker) {
|
||||||
_showDetailedCompassWithSarMarker(
|
_showDetailedCompassWithSarMarker(
|
||||||
context,
|
context,
|
||||||
contactsProvider.contactsWithLocation,
|
contactsProvider.contactsWithLocation,
|
||||||
@@ -991,40 +919,14 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
// User location marker
|
// User location marker
|
||||||
if (_currentPosition != null)
|
if (_markerService.generateUserLocationMarker(
|
||||||
Marker(
|
position: _locationService.currentPosition,
|
||||||
point: LatLng(
|
context: context,
|
||||||
_currentPosition!.latitude,
|
) != null)
|
||||||
_currentPosition!.longitude,
|
_markerService.generateUserLocationMarker(
|
||||||
),
|
position: _locationService.currentPosition,
|
||||||
width: 40,
|
context: context,
|
||||||
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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
// Dropped pin marker with label
|
// Dropped pin marker with label
|
||||||
if (_droppedPinLocation != null)
|
if (_droppedPinLocation != null)
|
||||||
Marker(
|
Marker(
|
||||||
@@ -1152,30 +1054,23 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
|
|||||||
heroTag: 'center_map',
|
heroTag: 'center_map',
|
||||||
onPressed: !_isMapReady ? null : () async {
|
onPressed: !_isMapReady ? null : () async {
|
||||||
// Force update GPS location and jump to it
|
// Force update GPS location and jump to it
|
||||||
try {
|
final position = await _locationService.getCurrentPosition();
|
||||||
final position = await Geolocator.getCurrentPosition(
|
if (position != null && mounted) {
|
||||||
locationSettings: const LocationSettings(
|
setState(() {
|
||||||
accuracy: LocationAccuracy.best,
|
// Position updated in service
|
||||||
distanceFilter: 0,
|
});
|
||||||
),
|
_mapController.move(
|
||||||
|
LatLng(position.latitude, position.longitude),
|
||||||
|
16,
|
||||||
);
|
);
|
||||||
if (mounted) {
|
} else {
|
||||||
setState(() {
|
|
||||||
_currentPosition = position;
|
|
||||||
});
|
|
||||||
_mapController.move(
|
|
||||||
LatLng(position.latitude, position.longitude),
|
|
||||||
16,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('Error getting location: $e');
|
|
||||||
// Fallback to cached position or default center
|
// Fallback to cached position or default center
|
||||||
if (_currentPosition != null) {
|
final currentPosition = _locationService.currentPosition;
|
||||||
|
if (currentPosition != null) {
|
||||||
_mapController.move(
|
_mapController.move(
|
||||||
LatLng(
|
LatLng(
|
||||||
_currentPosition!.latitude,
|
currentPosition.latitude,
|
||||||
_currentPosition!.longitude,
|
currentPosition.longitude,
|
||||||
),
|
),
|
||||||
16,
|
16,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import 'package:latlong2/latlong.dart';
|
|||||||
import '../providers/contacts_provider.dart';
|
import '../providers/contacts_provider.dart';
|
||||||
import '../providers/messages_provider.dart';
|
import '../providers/messages_provider.dart';
|
||||||
import '../providers/app_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 '../utils/sample_data_generator.dart';
|
||||||
import '../theme/app_theme.dart';
|
import '../theme/app_theme.dart';
|
||||||
|
|
||||||
@@ -29,21 +29,15 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
late AppThemeMode _selectedTheme;
|
late AppThemeMode _selectedTheme;
|
||||||
PackageInfo? _packageInfo;
|
PackageInfo? _packageInfo;
|
||||||
bool _isLoadingSampleData = false;
|
bool _isLoadingSampleData = false;
|
||||||
double _gpsUpdateDistance = 10.0;
|
|
||||||
double _gpsMinDistance = 5.0;
|
|
||||||
double _gpsMaxDistance = 100.0;
|
|
||||||
int _minTimeIntervalSeconds = 30;
|
|
||||||
bool _backgroundTrackingEnabled = false;
|
|
||||||
bool _isSendingLocationUpdate = false;
|
bool _isSendingLocationUpdate = false;
|
||||||
final BackgroundLocationService _backgroundLocationService =
|
final LocationTrackingService _locationService = LocationTrackingService();
|
||||||
BackgroundLocationService();
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_selectedTheme = widget.currentTheme;
|
_selectedTheme = widget.currentTheme;
|
||||||
_loadPackageInfo();
|
_loadPackageInfo();
|
||||||
_loadLocationSettings();
|
_initializeLocationService();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _loadPackageInfo() async {
|
Future<void> _loadPackageInfo() async {
|
||||||
@@ -55,45 +49,60 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _loadLocationSettings() async {
|
Future<void> _initializeLocationService() async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
// Initialize location service with BLE service
|
||||||
if (mounted) {
|
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
||||||
setState(() {
|
if (mounted) {
|
||||||
_gpsUpdateDistance = prefs.getDouble('map_gps_update_distance') ?? 10.0;
|
final appProvider = context.read<AppProvider>();
|
||||||
_gpsMinDistance = prefs.getDouble('map_gps_min_distance') ?? 5.0;
|
await _locationService.initialize(
|
||||||
_gpsMaxDistance = prefs.getDouble('map_gps_max_distance') ?? 100.0;
|
appProvider.connectionProvider.bleService,
|
||||||
_minTimeIntervalSeconds = prefs.getInt('map_gps_min_time_interval') ?? 30;
|
);
|
||||||
_backgroundTrackingEnabled =
|
|
||||||
prefs.getBool('background_tracking_enabled') ?? false;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Initialize background location service
|
// Set up callbacks for UI feedback
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
_locationService.onError = (error) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
final appProvider = context.read<AppProvider>();
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
_backgroundLocationService.initialize(
|
SnackBar(
|
||||||
appProvider.connectionProvider.bleService,
|
content: Text(error),
|
||||||
);
|
backgroundColor: Colors.orange,
|
||||||
|
),
|
||||||
// Restore background tracking state
|
);
|
||||||
if (_backgroundTrackingEnabled) {
|
|
||||||
_startBackgroundTracking();
|
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _saveLocationSettings() async {
|
_locationService.onBroadcastSent = (position) {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
if (mounted) {
|
||||||
await prefs.setDouble('map_gps_update_distance', _gpsUpdateDistance);
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
await prefs.setDouble('map_gps_min_distance', _gpsMinDistance);
|
SnackBar(
|
||||||
await prefs.setDouble('map_gps_max_distance', _gpsMaxDistance);
|
content: Text(
|
||||||
await prefs.setInt('map_gps_min_time_interval', _minTimeIntervalSeconds);
|
'Location broadcast: ${position.latitude.toStringAsFixed(5)}, ${position.longitude.toStringAsFixed(5)}',
|
||||||
await prefs.setBool(
|
),
|
||||||
'background_tracking_enabled',
|
backgroundColor: Colors.green,
|
||||||
_backgroundTrackingEnabled,
|
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<void> _saveThemePreference(AppThemeMode theme) async {
|
Future<void> _saveThemePreference(AppThemeMode theme) async {
|
||||||
@@ -198,89 +207,47 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _startBackgroundTracking() async {
|
Future<void> _startBackgroundTracking() async {
|
||||||
final success = await _backgroundLocationService.startTracking(
|
final success = await _locationService.startTracking(
|
||||||
distanceThreshold: _gpsUpdateDistance,
|
distanceThreshold: _locationService.gpsUpdateDistance,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!success) {
|
if (!success && mounted) {
|
||||||
if (mounted) {
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
setState(() {
|
const SnackBar(
|
||||||
_backgroundTrackingEnabled = false;
|
content: Text(
|
||||||
});
|
'Failed to start background tracking. Check permissions and BLE connection.',
|
||||||
_saveLocationSettings();
|
|
||||||
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
const SnackBar(
|
|
||||||
content: Text(
|
|
||||||
'Failed to start background tracking. Check permissions and BLE connection.',
|
|
||||||
),
|
|
||||||
duration: Duration(seconds: 3),
|
|
||||||
),
|
),
|
||||||
);
|
duration: Duration(seconds: 3),
|
||||||
}
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _stopBackgroundTracking() async {
|
Future<void> _stopBackgroundTracking() async {
|
||||||
await _backgroundLocationService.stopTracking();
|
await _locationService.stopTracking();
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _sendLocationUpdateNow() async {
|
Future<void> _sendLocationUpdateNow() async {
|
||||||
setState(() => _isSendingLocationUpdate = true);
|
setState(() => _isSendingLocationUpdate = true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Get current location
|
final success = await _locationService.broadcastLocationNow();
|
||||||
Position position = await Geolocator.getCurrentPosition(
|
|
||||||
locationSettings: const LocationSettings(
|
|
||||||
accuracy: LocationAccuracy.best,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!mounted) return;
|
if (!success && mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
// Get connection provider
|
const SnackBar(
|
||||||
final appProvider = context.read<AppProvider>();
|
content: Text('Failed to send location update'),
|
||||||
final connectionProvider = appProvider.connectionProvider;
|
backgroundColor: Colors.red,
|
||||||
|
|
||||||
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)}',
|
|
||||||
),
|
),
|
||||||
backgroundColor: Colors.green,
|
);
|
||||||
),
|
}
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
if (!mounted) return;
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
content: Text('Failed to send location: $e'),
|
|
||||||
backgroundColor: Colors.red,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() => _isSendingLocationUpdate = false);
|
setState(() => _isSendingLocationUpdate = false);
|
||||||
@@ -383,21 +350,17 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
secondary: const Icon(Icons.location_on),
|
secondary: const Icon(Icons.location_on),
|
||||||
title: const Text('Auto Location Tracking'),
|
title: const Text('Auto Location Tracking'),
|
||||||
subtitle: const Text('Automatically broadcast position updates'),
|
subtitle: const Text('Automatically broadcast position updates'),
|
||||||
value: _backgroundTrackingEnabled,
|
value: _locationService.isTracking,
|
||||||
onChanged: (value) {
|
onChanged: (value) {
|
||||||
setState(() {
|
if (value) {
|
||||||
_backgroundTrackingEnabled = value;
|
_startBackgroundTracking();
|
||||||
if (value) {
|
} else {
|
||||||
_startBackgroundTracking();
|
_stopBackgroundTracking();
|
||||||
} else {
|
}
|
||||||
_stopBackgroundTracking();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
_saveLocationSettings();
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
||||||
if (_backgroundTrackingEnabled) ...[
|
if (_locationService.isTracking) ...[
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.tune),
|
leading: const Icon(Icons.tune),
|
||||||
title: const Text('Configure Tracking'),
|
title: const Text('Configure Tracking'),
|
||||||
@@ -509,9 +472,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _showTrackingConfigDialog() {
|
void _showTrackingConfigDialog() {
|
||||||
double tempMinDistance = _gpsMinDistance;
|
double tempMinDistance = _locationService.minDistanceMeters;
|
||||||
double tempMaxDistance = _gpsMaxDistance;
|
double tempMaxDistance = _locationService.maxDistanceMeters;
|
||||||
int tempTimeInterval = _minTimeIntervalSeconds;
|
int tempTimeInterval = _locationService.minTimeIntervalSeconds;
|
||||||
|
|
||||||
showDialog(
|
showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
@@ -670,23 +633,27 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
child: const Text('Cancel'),
|
child: const Text('Cancel'),
|
||||||
),
|
),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () {
|
onPressed: () async {
|
||||||
setState(() {
|
// Update location service configuration
|
||||||
_gpsMinDistance = tempMinDistance;
|
_locationService.minDistanceMeters = tempMinDistance;
|
||||||
_gpsMaxDistance = tempMaxDistance;
|
_locationService.maxDistanceMeters = tempMaxDistance;
|
||||||
_minTimeIntervalSeconds = tempTimeInterval;
|
_locationService.minTimeIntervalSeconds = tempTimeInterval;
|
||||||
_gpsUpdateDistance = tempMinDistance; // Use min as the primary threshold
|
_locationService.gpsUpdateDistance = tempMinDistance;
|
||||||
});
|
|
||||||
_saveLocationSettings();
|
|
||||||
|
|
||||||
// Update background tracking if active
|
// Save settings
|
||||||
if (_backgroundTrackingEnabled) {
|
await _locationService.saveSettings();
|
||||||
_backgroundLocationService.updateDistanceThreshold(
|
|
||||||
tempMinDistance,
|
// Update tracking if active
|
||||||
);
|
if (_locationService.isTracking) {
|
||||||
|
await _locationService.updateDistanceThreshold(tempMinDistance);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Close dialog before setState
|
||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {});
|
||||||
|
}
|
||||||
},
|
},
|
||||||
child: const Text('Save'),
|
child: const Text('Save'),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import 'package:geolocator/geolocator.dart';
|
|||||||
import '../../providers/contacts_provider.dart';
|
import '../../providers/contacts_provider.dart';
|
||||||
import '../../models/contact.dart';
|
import '../../models/contact.dart';
|
||||||
import '../../models/sar_marker.dart';
|
import '../../models/sar_marker.dart';
|
||||||
|
import '../../services/validation_service.dart';
|
||||||
|
|
||||||
/// SAR Update Sheet - Modal bottom sheet for creating and sending SAR markers
|
/// 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
|
/// 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<SarUpdateSheet> {
|
|||||||
onPressed: _currentPosition == null || _selectedContact == null
|
onPressed: _currentPosition == null || _selectedContact == null
|
||||||
? null
|
? null
|
||||||
: () async {
|
: () 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<bool>(
|
||||||
|
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(
|
await widget.onSend(
|
||||||
_selectedType,
|
_selectedType,
|
||||||
_currentPosition!,
|
_currentPosition!,
|
||||||
_notesController.text.trim().isEmpty
|
notes.isEmpty ? null : notes,
|
||||||
? null
|
|
||||||
: _notesController.text.trim(),
|
|
||||||
_selectedContact!.isChannel
|
_selectedContact!.isChannel
|
||||||
? null
|
? null
|
||||||
: _selectedContact!.publicKey,
|
: _selectedContact!.publicKey,
|
||||||
|
|||||||
Reference in New Issue
Block a user