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:
Janez T
2025-10-15 11:09:34 +02:00
parent 823a3c50f7
commit a6e317bc37
8 changed files with 822 additions and 378 deletions

View File

@@ -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
View File

@@ -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

View File

@@ -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();

View File

@@ -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(

View File

@@ -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;

View File

@@ -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,
); );

View File

@@ -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'),
), ),

View File

@@ -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,