mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 16:30:28 +00:00
feat: Add LocationTrackingService for GPS tracking and mesh network integration
- Implemented LocationTrackingService to handle GPS tracking, distance thresholds, and location broadcasting. - Added permission handling, SharedPreferences persistence, and real-time position updates via callbacks. feat: Introduce MapMarkerService for managing map markers - Created MapMarkerService to generate contact and SAR markers, calculate distances, and manage marker display. - Included methods for user location markers and distance/bearing calculations. feat: Implement ValidationService for input validation and sanitization - Developed ValidationService to validate coordinates, radio parameters, distances, time intervals, and names. - Added parse and sanitize methods for common input types, ensuring robust error handling and user feedback.
This commit is contained in:
501
lib/services/location_tracking_service.dart
Normal file
501
lib/services/location_tracking_service.dart
Normal file
@@ -0,0 +1,501 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'meshcore_ble_service.dart';
|
||||
|
||||
/// Centralized location tracking service for MeshCore SAR
|
||||
///
|
||||
/// Handles GPS tracking, distance thresholds, background updates,
|
||||
/// and location broadcasting to the mesh network.
|
||||
///
|
||||
/// Features:
|
||||
/// - Singleton pattern for app-wide access
|
||||
/// - Configurable distance thresholds (min/max)
|
||||
/// - Configurable time intervals
|
||||
/// - Permission handling
|
||||
/// - SharedPreferences persistence
|
||||
/// - MeshCore mesh network integration
|
||||
/// - Real-time position updates via callbacks
|
||||
class LocationTrackingService {
|
||||
// ============================================================================
|
||||
// Singleton Pattern
|
||||
// ============================================================================
|
||||
|
||||
static final LocationTrackingService _instance = LocationTrackingService._internal();
|
||||
|
||||
/// Get the singleton instance
|
||||
factory LocationTrackingService() => _instance;
|
||||
|
||||
LocationTrackingService._internal();
|
||||
|
||||
// ============================================================================
|
||||
// SharedPreferences Keys
|
||||
// ============================================================================
|
||||
|
||||
static const String _prefKeyEnabled = 'background_tracking_enabled';
|
||||
static const String _prefKeyMinDistance = 'map_gps_min_distance';
|
||||
static const String _prefKeyMaxDistance = 'map_gps_max_distance';
|
||||
static const String _prefKeyMinTimeInterval = 'map_gps_min_time_interval';
|
||||
static const String _prefKeyGpsUpdateDistance = 'map_gps_update_distance';
|
||||
static const String _prefKeyLastLat = 'background_last_lat';
|
||||
static const String _prefKeyLastLon = 'background_last_lon';
|
||||
|
||||
// ============================================================================
|
||||
// Configuration Properties
|
||||
// ============================================================================
|
||||
|
||||
/// Minimum distance in meters before broadcasting update
|
||||
double minDistanceMeters = 5.0;
|
||||
|
||||
/// Maximum distance in meters that forces a broadcast regardless of time
|
||||
double maxDistanceMeters = 100.0;
|
||||
|
||||
/// Minimum time interval in seconds between broadcasts
|
||||
int minTimeIntervalSeconds = 30;
|
||||
|
||||
/// GPS update distance filter for position stream
|
||||
double gpsUpdateDistance = 10.0;
|
||||
|
||||
// ============================================================================
|
||||
// State Properties
|
||||
// ============================================================================
|
||||
|
||||
/// Current GPS position
|
||||
Position? currentPosition;
|
||||
|
||||
/// Last position that was broadcast to mesh network
|
||||
Position? _lastBroadcastPosition;
|
||||
|
||||
/// Timestamp of last broadcast
|
||||
DateTime? _lastBroadcastTime;
|
||||
|
||||
/// Whether tracking is currently active
|
||||
bool isTracking = false;
|
||||
|
||||
/// Whether service has been initialized with BLE service
|
||||
bool _isInitialized = false;
|
||||
|
||||
// ============================================================================
|
||||
// Private Properties
|
||||
// ============================================================================
|
||||
|
||||
/// Reference to MeshCore BLE service for broadcasting
|
||||
MeshCoreBleService? _bleService;
|
||||
|
||||
/// Position stream subscription
|
||||
StreamSubscription<Position>? _positionSubscription;
|
||||
|
||||
// ============================================================================
|
||||
// Callback Properties
|
||||
// ============================================================================
|
||||
|
||||
/// Called when position is updated
|
||||
void Function(Position)? onPositionUpdate;
|
||||
|
||||
/// Called when an error occurs
|
||||
void Function(String error)? onError;
|
||||
|
||||
/// Called when a location broadcast is sent to mesh network
|
||||
void Function(Position)? onBroadcastSent;
|
||||
|
||||
/// Called when tracking state changes
|
||||
void Function(bool isTracking)? onTrackingStateChanged;
|
||||
|
||||
// ============================================================================
|
||||
// Initialization
|
||||
// ============================================================================
|
||||
|
||||
/// Initialize the service with MeshCore BLE service reference
|
||||
///
|
||||
/// Must be called before starting tracking.
|
||||
Future<bool> initialize(MeshCoreBleService bleService) async {
|
||||
_bleService = bleService;
|
||||
_isInitialized = true;
|
||||
|
||||
// Load saved settings
|
||||
await loadSettings();
|
||||
|
||||
debugPrint('✅ [LocationTracking] Service initialized');
|
||||
return true;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Permission Handling
|
||||
// ============================================================================
|
||||
|
||||
/// Check if location permissions are granted
|
||||
Future<bool> checkPermissions() async {
|
||||
final permission = await Geolocator.checkPermission();
|
||||
return permission == LocationPermission.always ||
|
||||
permission == LocationPermission.whileInUse;
|
||||
}
|
||||
|
||||
/// Request location permissions from user
|
||||
///
|
||||
/// Returns true if granted, false otherwise.
|
||||
Future<bool> requestPermissions() async {
|
||||
// Check if location service is enabled
|
||||
bool serviceEnabled = await Geolocator.isLocationServiceEnabled();
|
||||
if (!serviceEnabled) {
|
||||
onError?.call('Location services are disabled');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check current permission
|
||||
LocationPermission permission = await Geolocator.checkPermission();
|
||||
|
||||
if (permission == LocationPermission.denied) {
|
||||
permission = await Geolocator.requestPermission();
|
||||
if (permission == LocationPermission.denied) {
|
||||
onError?.call('Location permission denied');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (permission == LocationPermission.deniedForever) {
|
||||
onError?.call('Location permission permanently denied. Please enable in settings.');
|
||||
return false;
|
||||
}
|
||||
|
||||
debugPrint('✅ [LocationTracking] Location permissions granted');
|
||||
return true;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// GPS Position Methods
|
||||
// ============================================================================
|
||||
|
||||
/// Get current GPS position
|
||||
///
|
||||
/// Returns null if position unavailable or permissions denied.
|
||||
Future<Position?> getCurrentPosition() async {
|
||||
try {
|
||||
final position = await Geolocator.getCurrentPosition(
|
||||
locationSettings: const LocationSettings(
|
||||
accuracy: LocationAccuracy.best,
|
||||
timeLimit: Duration(seconds: 5),
|
||||
),
|
||||
);
|
||||
|
||||
currentPosition = position;
|
||||
return position;
|
||||
} catch (e) {
|
||||
debugPrint('❌ [LocationTracking] Error getting position: $e');
|
||||
onError?.call('Failed to get current position: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get position stream with configurable distance filter
|
||||
///
|
||||
/// [distanceFilter] - Minimum distance in meters between position updates
|
||||
Stream<Position> getPositionStream({double distanceFilter = 10.0}) {
|
||||
return Geolocator.getPositionStream(
|
||||
locationSettings: LocationSettings(
|
||||
accuracy: LocationAccuracy.best,
|
||||
distanceFilter: distanceFilter.toInt(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tracking Control
|
||||
// ============================================================================
|
||||
|
||||
/// Start location tracking
|
||||
///
|
||||
/// [distanceThreshold] - GPS update distance filter
|
||||
///
|
||||
/// Returns true if successful, false otherwise.
|
||||
Future<bool> startTracking({double? distanceThreshold}) async {
|
||||
if (!_isInitialized || _bleService == null) {
|
||||
debugPrint('⚠️ [LocationTracking] Service not initialized or BLE service null');
|
||||
onError?.call('Location tracking service not initialized');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!_bleService!.isConnected) {
|
||||
debugPrint('⚠️ [LocationTracking] BLE not connected');
|
||||
onError?.call('Not connected to mesh device');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check permissions
|
||||
final hasPermission = await requestPermissions();
|
||||
if (!hasPermission) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Use provided threshold or current setting
|
||||
final threshold = distanceThreshold ?? gpsUpdateDistance;
|
||||
gpsUpdateDistance = threshold;
|
||||
|
||||
// Save settings
|
||||
await saveSettings();
|
||||
|
||||
// Get initial position
|
||||
await getCurrentPosition();
|
||||
|
||||
// Start position stream
|
||||
try {
|
||||
_positionSubscription = getPositionStream(distanceFilter: threshold).listen(
|
||||
_handlePositionUpdate,
|
||||
onError: (error) {
|
||||
debugPrint('❌ [LocationTracking] Position stream error: $error');
|
||||
onError?.call('Position stream error: $error');
|
||||
},
|
||||
);
|
||||
|
||||
isTracking = true;
|
||||
onTrackingStateChanged?.call(true);
|
||||
|
||||
debugPrint('✅ [LocationTracking] Tracking started with ${threshold}m threshold');
|
||||
return true;
|
||||
} catch (e) {
|
||||
debugPrint('❌ [LocationTracking] Failed to start tracking: $e');
|
||||
onError?.call('Failed to start tracking: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop location tracking
|
||||
Future<void> stopTracking() async {
|
||||
debugPrint('🛑 [LocationTracking] Stopping tracking');
|
||||
|
||||
await _positionSubscription?.cancel();
|
||||
_positionSubscription = null;
|
||||
|
||||
isTracking = false;
|
||||
onTrackingStateChanged?.call(false);
|
||||
|
||||
// Save disabled state
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_prefKeyEnabled, false);
|
||||
|
||||
debugPrint('✅ [LocationTracking] Tracking stopped');
|
||||
}
|
||||
|
||||
/// Update the distance threshold and restart tracking if active
|
||||
Future<void> updateDistanceThreshold(double meters) async {
|
||||
gpsUpdateDistance = meters;
|
||||
await saveSettings();
|
||||
|
||||
debugPrint('📏 [LocationTracking] Distance threshold updated to ${meters}m');
|
||||
|
||||
// Restart tracking if currently active
|
||||
if (isTracking) {
|
||||
await stopTracking();
|
||||
await startTracking(distanceThreshold: meters);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Position Update Handler
|
||||
// ============================================================================
|
||||
|
||||
/// Handle incoming position updates from GPS stream
|
||||
void _handlePositionUpdate(Position position) {
|
||||
debugPrint('📍 [LocationTracking] New position: ${position.latitude}, ${position.longitude}');
|
||||
|
||||
// Update current position
|
||||
currentPosition = position;
|
||||
|
||||
// Notify listeners
|
||||
onPositionUpdate?.call(position);
|
||||
|
||||
// Check if we should broadcast to mesh network
|
||||
_checkAndBroadcast(position);
|
||||
}
|
||||
|
||||
/// Check if position should be broadcast based on distance and time thresholds
|
||||
void _checkAndBroadcast(Position position) {
|
||||
// If never broadcast before, do it now
|
||||
if (_lastBroadcastPosition == null || _lastBroadcastTime == null) {
|
||||
_broadcastPosition(position);
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate time since last broadcast
|
||||
final timeSinceLastBroadcast = DateTime.now().difference(_lastBroadcastTime!);
|
||||
final secondsSinceLastBroadcast = timeSinceLastBroadcast.inSeconds;
|
||||
|
||||
// Calculate distance from last broadcast position
|
||||
final distanceFromLastBroadcast = Geolocator.distanceBetween(
|
||||
_lastBroadcastPosition!.latitude,
|
||||
_lastBroadcastPosition!.longitude,
|
||||
position.latitude,
|
||||
position.longitude,
|
||||
);
|
||||
|
||||
debugPrint(' Distance from last broadcast: ${distanceFromLastBroadcast.toStringAsFixed(1)}m');
|
||||
debugPrint(' Time since last broadcast: ${secondsSinceLastBroadcast}s');
|
||||
|
||||
// Broadcast if moved beyond max distance threshold
|
||||
if (distanceFromLastBroadcast >= maxDistanceMeters) {
|
||||
debugPrint(' 📤 Triggering broadcast: exceeded max distance (${maxDistanceMeters}m)');
|
||||
_broadcastPosition(position);
|
||||
return;
|
||||
}
|
||||
|
||||
// Broadcast if minimum time interval passed AND moved beyond min distance
|
||||
if (secondsSinceLastBroadcast >= minTimeIntervalSeconds &&
|
||||
distanceFromLastBroadcast >= minDistanceMeters) {
|
||||
debugPrint(' 📤 Triggering broadcast: exceeded min time (${minTimeIntervalSeconds}s) and min distance (${minDistanceMeters}m)');
|
||||
_broadcastPosition(position);
|
||||
return;
|
||||
}
|
||||
|
||||
debugPrint(' ⏸️ Not broadcasting: thresholds not met');
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Mesh Network Broadcasting
|
||||
// ============================================================================
|
||||
|
||||
/// Broadcast position to mesh network via BLE
|
||||
void _broadcastPosition(Position position) async {
|
||||
if (_bleService == null || !_bleService!.isConnected) {
|
||||
debugPrint('⚠️ [LocationTracking] Cannot broadcast: BLE not connected');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
debugPrint('📤 [LocationTracking] Broadcasting position to mesh network');
|
||||
|
||||
// Update device's advertised location
|
||||
await _bleService!.setAdvertLatLon(
|
||||
latitude: position.latitude,
|
||||
longitude: position.longitude,
|
||||
);
|
||||
|
||||
// Send advertisement to mesh network
|
||||
await _bleService!.sendSelfAdvert(floodMode: true);
|
||||
|
||||
// Update broadcast tracking
|
||||
_lastBroadcastPosition = position;
|
||||
_lastBroadcastTime = DateTime.now();
|
||||
|
||||
// Save to preferences
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setDouble(_prefKeyLastLat, position.latitude);
|
||||
await prefs.setDouble(_prefKeyLastLon, position.longitude);
|
||||
|
||||
debugPrint('✅ [LocationTracking] Position broadcast successful');
|
||||
onBroadcastSent?.call(position);
|
||||
} catch (e) {
|
||||
debugPrint('❌ [LocationTracking] Failed to broadcast position: $e');
|
||||
onError?.call('Failed to broadcast position: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Manually broadcast current location immediately
|
||||
///
|
||||
/// Useful for "Send Location Now" button functionality.
|
||||
Future<bool> broadcastLocationNow() async {
|
||||
if (!_isInitialized || _bleService == null) {
|
||||
onError?.call('Location tracking service not initialized');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!_bleService!.isConnected) {
|
||||
onError?.call('Not connected to mesh device');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// Get current position
|
||||
final position = await getCurrentPosition();
|
||||
if (position == null) {
|
||||
onError?.call('Failed to get current position');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Broadcast regardless of thresholds
|
||||
await _bleService!.setAdvertLatLon(
|
||||
latitude: position.latitude,
|
||||
longitude: position.longitude,
|
||||
);
|
||||
|
||||
await _bleService!.sendSelfAdvert(floodMode: true);
|
||||
|
||||
// Update broadcast tracking
|
||||
_lastBroadcastPosition = position;
|
||||
_lastBroadcastTime = DateTime.now();
|
||||
|
||||
debugPrint('✅ [LocationTracking] Manual broadcast successful');
|
||||
onBroadcastSent?.call(position);
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
debugPrint('❌ [LocationTracking] Manual broadcast failed: $e');
|
||||
onError?.call('Failed to broadcast location: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Settings Persistence
|
||||
// ============================================================================
|
||||
|
||||
/// Load settings from SharedPreferences
|
||||
Future<void> loadSettings() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
|
||||
minDistanceMeters = prefs.getDouble(_prefKeyMinDistance) ?? 5.0;
|
||||
maxDistanceMeters = prefs.getDouble(_prefKeyMaxDistance) ?? 100.0;
|
||||
minTimeIntervalSeconds = prefs.getInt(_prefKeyMinTimeInterval) ?? 30;
|
||||
gpsUpdateDistance = prefs.getDouble(_prefKeyGpsUpdateDistance) ?? 10.0;
|
||||
|
||||
// Load last broadcast position if available
|
||||
final lastLat = prefs.getDouble(_prefKeyLastLat);
|
||||
final lastLon = prefs.getDouble(_prefKeyLastLon);
|
||||
if (lastLat != null && lastLon != null) {
|
||||
_lastBroadcastPosition = Position(
|
||||
latitude: lastLat,
|
||||
longitude: lastLon,
|
||||
timestamp: DateTime.now(),
|
||||
accuracy: 0.0,
|
||||
altitude: 0.0,
|
||||
altitudeAccuracy: 0.0,
|
||||
heading: 0.0,
|
||||
headingAccuracy: 0.0,
|
||||
speed: 0.0,
|
||||
speedAccuracy: 0.0,
|
||||
);
|
||||
}
|
||||
|
||||
debugPrint('✅ [LocationTracking] Settings loaded');
|
||||
debugPrint(' Min distance: ${minDistanceMeters}m');
|
||||
debugPrint(' Max distance: ${maxDistanceMeters}m');
|
||||
debugPrint(' Min time interval: ${minTimeIntervalSeconds}s');
|
||||
debugPrint(' GPS update distance: ${gpsUpdateDistance}m');
|
||||
}
|
||||
|
||||
/// Save settings to SharedPreferences
|
||||
Future<void> saveSettings() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
|
||||
await prefs.setDouble(_prefKeyMinDistance, minDistanceMeters);
|
||||
await prefs.setDouble(_prefKeyMaxDistance, maxDistanceMeters);
|
||||
await prefs.setInt(_prefKeyMinTimeInterval, minTimeIntervalSeconds);
|
||||
await prefs.setDouble(_prefKeyGpsUpdateDistance, gpsUpdateDistance);
|
||||
await prefs.setBool(_prefKeyEnabled, isTracking);
|
||||
|
||||
debugPrint('✅ [LocationTracking] Settings saved');
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Cleanup
|
||||
// ============================================================================
|
||||
|
||||
/// Dispose resources and cleanup
|
||||
void dispose() {
|
||||
debugPrint('🗑️ [LocationTracking] Disposing service');
|
||||
_positionSubscription?.cancel();
|
||||
_positionSubscription = null;
|
||||
_bleService = null;
|
||||
_isInitialized = false;
|
||||
isTracking = false;
|
||||
}
|
||||
}
|
||||
518
lib/services/map_marker_service.dart
Normal file
518
lib/services/map_marker_service.dart
Normal file
@@ -0,0 +1,518 @@
|
||||
import 'dart:math';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
import '../models/contact.dart';
|
||||
import '../models/sar_marker.dart';
|
||||
|
||||
/// Centralized service for map marker management.
|
||||
///
|
||||
/// This service handles:
|
||||
/// - Contact marker generation
|
||||
/// - SAR marker generation
|
||||
/// - User location marker
|
||||
/// - Distance calculations (Haversine formula)
|
||||
/// - Bearing/azimuth calculations
|
||||
/// - Marker color assignment
|
||||
/// - Marker icon selection
|
||||
///
|
||||
/// Uses singleton pattern for consistent behavior across the app.
|
||||
class MapMarkerService {
|
||||
// Singleton pattern
|
||||
static final MapMarkerService _instance = MapMarkerService._internal();
|
||||
factory MapMarkerService() => _instance;
|
||||
MapMarkerService._internal();
|
||||
|
||||
/// Generate markers for team member contacts.
|
||||
///
|
||||
/// Parameters:
|
||||
/// - [contacts]: List of contacts with location data
|
||||
/// - [context]: Build context for theme access
|
||||
/// - [onTap]: Callback when a marker is tapped
|
||||
/// - [mapRotation]: Current map rotation in degrees (for counter-rotation)
|
||||
///
|
||||
/// Returns a list of markers positioned at contact locations.
|
||||
List<Marker> generateContactMarkers({
|
||||
required List<Contact> contacts,
|
||||
required BuildContext context,
|
||||
Function(Contact)? onTap,
|
||||
double mapRotation = 0,
|
||||
Position? userPosition,
|
||||
}) {
|
||||
return contacts.map((contact) {
|
||||
final location = contact.displayLocation;
|
||||
if (location == null) return null;
|
||||
|
||||
return Marker(
|
||||
point: location,
|
||||
width: 80,
|
||||
height: 100,
|
||||
rotate: false, // Don't rotate the entire marker with map
|
||||
child: Transform.rotate(
|
||||
angle: -mapRotation * pi / 180,
|
||||
child: GestureDetector(
|
||||
onTap: onTap != null ? () => onTap(contact) : null,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Location update time indicator
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
|
||||
decoration: BoxDecoration(
|
||||
color: getLocationAgeColor(contact),
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
child: Text(
|
||||
contact.timeSinceLocationUpdate,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 9,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
// Marker icon
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: getContactMarkerColor(contact, context),
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: Colors.white, width: 2),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.3),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
padding: const EdgeInsets.all(6),
|
||||
child: Icon(
|
||||
getContactMarkerIcon(contact),
|
||||
color: Colors.white,
|
||||
size: 18,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
// Name label (without emoji)
|
||||
Container(
|
||||
constraints: const BoxConstraints(maxWidth: 80),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: 0.7),
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
child: Text(
|
||||
contact.displayName,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 9,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
maxLines: 1,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}).whereType<Marker>().toList();
|
||||
}
|
||||
|
||||
/// Generate markers for SAR events.
|
||||
///
|
||||
/// Parameters:
|
||||
/// - [sarMarkers]: List of SAR markers to display
|
||||
/// - [context]: Build context for theme access
|
||||
/// - [onTap]: Callback when a marker is tapped
|
||||
/// - [mapRotation]: Current map rotation in degrees (for counter-rotation)
|
||||
///
|
||||
/// Returns a list of markers positioned at SAR event locations.
|
||||
List<Marker> generateSarMarkers({
|
||||
required List<SarMarker> sarMarkers,
|
||||
required BuildContext context,
|
||||
Function(SarMarker)? onTap,
|
||||
double mapRotation = 0,
|
||||
}) {
|
||||
return sarMarkers.map((marker) {
|
||||
return Marker(
|
||||
point: marker.location,
|
||||
width: 90,
|
||||
height: 100,
|
||||
rotate: false, // Don't rotate the entire marker with map
|
||||
child: Transform.rotate(
|
||||
angle: -mapRotation * pi / 180,
|
||||
child: GestureDetector(
|
||||
onTap: onTap != null ? () => onTap(marker) : null,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Time ago label
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
|
||||
decoration: BoxDecoration(
|
||||
color: getSarMarkerColor(marker.type),
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
child: Text(
|
||||
marker.timeAgo,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 8,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
// Marker emoji/icon
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: getSarMarkerColor(marker.type),
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: Colors.white, width: 2),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.3),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
padding: const EdgeInsets.all(6),
|
||||
child: Text(
|
||||
marker.type.emoji,
|
||||
style: const TextStyle(fontSize: 18),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
// Type label
|
||||
Container(
|
||||
constraints: const BoxConstraints(maxWidth: 90),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: 0.7),
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
child: Text(
|
||||
marker.type.displayName,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 9,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
maxLines: 1,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
/// Generate user location marker.
|
||||
///
|
||||
/// Parameters:
|
||||
/// - [position]: Current GPS position
|
||||
/// - [context]: Build context for theme access
|
||||
///
|
||||
/// Returns null if position is unavailable.
|
||||
Marker? generateUserLocationMarker({
|
||||
required Position? position,
|
||||
required BuildContext context,
|
||||
}) {
|
||||
if (position == null) return null;
|
||||
|
||||
return Marker(
|
||||
point: LatLng(position.latitude, position.longitude),
|
||||
width: 40,
|
||||
height: 40,
|
||||
rotate: false, // Don't rotate with map
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.3),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Container(
|
||||
margin: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: Colors.black26,
|
||||
blurRadius: 4,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.my_location,
|
||||
color: Colors.white,
|
||||
size: 16,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Calculate distance between two lat/lon points using Haversine formula.
|
||||
///
|
||||
/// Parameters:
|
||||
/// - [lat1]: Starting latitude in decimal degrees
|
||||
/// - [lon1]: Starting longitude in decimal degrees
|
||||
/// - [lat2]: Ending latitude in decimal degrees
|
||||
/// - [lon2]: Ending longitude in decimal degrees
|
||||
///
|
||||
/// Returns distance in meters.
|
||||
double calculateDistance({
|
||||
required double lat1,
|
||||
required double lon1,
|
||||
required double lat2,
|
||||
required double lon2,
|
||||
}) {
|
||||
const R = 6371000; // Earth's radius in meters
|
||||
final dLat = (lat2 - lat1) * pi / 180;
|
||||
final dLon = (lon2 - lon1) * pi / 180;
|
||||
|
||||
final a = sin(dLat / 2) * sin(dLat / 2) +
|
||||
cos(lat1 * pi / 180) *
|
||||
cos(lat2 * pi / 180) *
|
||||
sin(dLon / 2) *
|
||||
sin(dLon / 2);
|
||||
|
||||
final c = 2 * atan2(sqrt(a), sqrt(1 - a));
|
||||
return R * c;
|
||||
}
|
||||
|
||||
/// Calculate bearing/azimuth from point 1 to point 2.
|
||||
///
|
||||
/// Parameters:
|
||||
/// - [lat1]: Starting latitude in decimal degrees
|
||||
/// - [lon1]: Starting longitude in decimal degrees
|
||||
/// - [lat2]: Ending latitude in decimal degrees
|
||||
/// - [lon2]: Ending longitude in decimal degrees
|
||||
///
|
||||
/// Returns bearing in degrees (0-360), where 0 is North, 90 is East.
|
||||
double calculateBearing({
|
||||
required double lat1,
|
||||
required double lon1,
|
||||
required double lat2,
|
||||
required double lon2,
|
||||
}) {
|
||||
final dLon = (lon2 - lon1) * pi / 180;
|
||||
final lat1Rad = lat1 * pi / 180;
|
||||
final lat2Rad = lat2 * pi / 180;
|
||||
|
||||
final y = sin(dLon) * cos(lat2Rad);
|
||||
final x = cos(lat1Rad) * sin(lat2Rad) -
|
||||
sin(lat1Rad) * cos(lat2Rad) * cos(dLon);
|
||||
|
||||
final bearing = atan2(y, x) * 180 / pi;
|
||||
return (bearing + 360) % 360;
|
||||
}
|
||||
|
||||
/// Convert bearing to cardinal direction.
|
||||
///
|
||||
/// Parameters:
|
||||
/// - [bearing]: Bearing in degrees (0-360)
|
||||
///
|
||||
/// Returns cardinal direction (N, NE, E, SE, S, SW, W, NW).
|
||||
String bearingToCardinal(double bearing) {
|
||||
const directions = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW'];
|
||||
final index = ((bearing + 22.5) / 45).floor() % 8;
|
||||
return directions[index];
|
||||
}
|
||||
|
||||
/// Format distance for display.
|
||||
///
|
||||
/// Parameters:
|
||||
/// - [meters]: Distance in meters
|
||||
///
|
||||
/// Returns formatted string (e.g., "123m" or "1.2km").
|
||||
String formatDistance(double meters) {
|
||||
if (meters < 1000) {
|
||||
return '${meters.round()}m';
|
||||
} else {
|
||||
return '${(meters / 1000).toStringAsFixed(1)}km';
|
||||
}
|
||||
}
|
||||
|
||||
/// Get color for SAR marker type.
|
||||
///
|
||||
/// Parameters:
|
||||
/// - [type]: SAR marker type
|
||||
///
|
||||
/// Returns color for marker background.
|
||||
Color getSarMarkerColor(SarMarkerType type) {
|
||||
switch (type) {
|
||||
case SarMarkerType.foundPerson:
|
||||
return Colors.green;
|
||||
case SarMarkerType.fire:
|
||||
return Colors.red;
|
||||
case SarMarkerType.stagingArea:
|
||||
return Colors.orange;
|
||||
case SarMarkerType.object:
|
||||
return Colors.purple;
|
||||
case SarMarkerType.unknown:
|
||||
return Colors.grey;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get color for contact marker based on contact type.
|
||||
///
|
||||
/// Parameters:
|
||||
/// - [contact]: Contact to get color for
|
||||
/// - [context]: Build context for theme access
|
||||
///
|
||||
/// Returns color for marker background.
|
||||
Color getContactMarkerColor(Contact contact, BuildContext context) {
|
||||
switch (contact.type) {
|
||||
case ContactType.chat:
|
||||
return Theme.of(context).colorScheme.primary; // Blue for team members
|
||||
case ContactType.repeater:
|
||||
return Colors.deepPurple; // Purple for repeaters
|
||||
case ContactType.room:
|
||||
return Colors.teal; // Teal for rooms
|
||||
case ContactType.channel:
|
||||
return Colors.orange; // Orange for channels
|
||||
case ContactType.none:
|
||||
return Colors.grey;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get icon for contact marker based on contact type.
|
||||
///
|
||||
/// Parameters:
|
||||
/// - [contact]: Contact to get icon for
|
||||
///
|
||||
/// Returns icon data for marker.
|
||||
IconData getContactMarkerIcon(Contact contact) {
|
||||
switch (contact.type) {
|
||||
case ContactType.chat:
|
||||
return Icons.person; // Person for team members
|
||||
case ContactType.repeater:
|
||||
return Icons.router; // Router icon for repeaters
|
||||
case ContactType.room:
|
||||
return Icons.forum; // Forum/chat icon for rooms
|
||||
case ContactType.channel:
|
||||
return Icons.public; // Public icon for channels
|
||||
case ContactType.none:
|
||||
return Icons.help_outline;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get color for location age indicator.
|
||||
///
|
||||
/// Color indicates how recent the location update is:
|
||||
/// - Green: < 5 minutes (very recent)
|
||||
/// - Light blue: 5-30 minutes (recent)
|
||||
/// - Orange: 30 minutes - 2 hours (getting old)
|
||||
/// - Red: > 2 hours (stale)
|
||||
/// - Grey: Unknown
|
||||
///
|
||||
/// Parameters:
|
||||
/// - [contact]: Contact to check location age for
|
||||
///
|
||||
/// Returns color for location age indicator.
|
||||
Color getLocationAgeColor(Contact contact) {
|
||||
final updateTime = contact.locationUpdateTime;
|
||||
if (updateTime == null) return Colors.grey;
|
||||
|
||||
final diff = DateTime.now().difference(updateTime);
|
||||
if (diff.inMinutes < 5) return Colors.green; // Very recent
|
||||
if (diff.inMinutes < 30) return Colors.lightBlue; // Recent
|
||||
if (diff.inHours < 2) return Colors.orange; // Getting old
|
||||
return Colors.red; // Stale
|
||||
}
|
||||
|
||||
/// Cluster markers if too many are visible.
|
||||
///
|
||||
/// This is a placeholder for future clustering implementation.
|
||||
/// When implemented, it should group nearby markers into clusters
|
||||
/// to improve performance and reduce visual clutter.
|
||||
///
|
||||
/// Parameters:
|
||||
/// - [markers]: All markers to potentially cluster
|
||||
/// - [maxVisibleMarkers]: Maximum number of individual markers to show
|
||||
///
|
||||
/// Returns list of markers (clustered or original).
|
||||
List<Marker> clusterMarkers({
|
||||
required List<Marker> markers,
|
||||
required int maxVisibleMarkers,
|
||||
}) {
|
||||
// TODO: Implement marker clustering algorithm
|
||||
// For now, just return all markers
|
||||
return markers;
|
||||
}
|
||||
|
||||
/// Calculate optimal map center from list of points.
|
||||
///
|
||||
/// Parameters:
|
||||
/// - [contacts]: Contacts with locations
|
||||
/// - [sarMarkers]: SAR markers with locations
|
||||
/// - [defaultCenter]: Fallback center if no points available
|
||||
///
|
||||
/// Returns center point (average of all locations).
|
||||
LatLng calculateCenter({
|
||||
required List<Contact> contacts,
|
||||
required List<SarMarker> sarMarkers,
|
||||
LatLng? defaultCenter,
|
||||
}) {
|
||||
final allPoints = <LatLng>[];
|
||||
|
||||
for (final contact in contacts) {
|
||||
if (contact.displayLocation != null) {
|
||||
allPoints.add(contact.displayLocation!);
|
||||
}
|
||||
}
|
||||
|
||||
for (final marker in sarMarkers) {
|
||||
allPoints.add(marker.location);
|
||||
}
|
||||
|
||||
if (allPoints.isEmpty) {
|
||||
return defaultCenter ?? const LatLng(46.0569, 14.5058); // Ljubljana, Slovenia
|
||||
}
|
||||
|
||||
double lat = 0, lng = 0;
|
||||
for (final point in allPoints) {
|
||||
lat += point.latitude;
|
||||
lng += point.longitude;
|
||||
}
|
||||
|
||||
return LatLng(lat / allPoints.length, lng / allPoints.length);
|
||||
}
|
||||
|
||||
/// Check if two positions are close enough to be considered the same location.
|
||||
///
|
||||
/// Parameters:
|
||||
/// - [lat1]: First latitude
|
||||
/// - [lon1]: First longitude
|
||||
/// - [lat2]: Second latitude
|
||||
/// - [lon2]: Second longitude
|
||||
/// - [thresholdMeters]: Distance threshold in meters (default: 50)
|
||||
///
|
||||
/// Returns true if points are within threshold distance.
|
||||
bool isNearby({
|
||||
required double lat1,
|
||||
required double lon1,
|
||||
required double lat2,
|
||||
required double lon2,
|
||||
double thresholdMeters = 50,
|
||||
}) {
|
||||
final distance = calculateDistance(
|
||||
lat1: lat1,
|
||||
lon1: lon1,
|
||||
lat2: lat2,
|
||||
lon2: lon2,
|
||||
);
|
||||
return distance <= thresholdMeters;
|
||||
}
|
||||
}
|
||||
511
lib/services/validation_service.dart
Normal file
511
lib/services/validation_service.dart
Normal file
@@ -0,0 +1,511 @@
|
||||
/// Centralized validation service for form validation, coordinate validation,
|
||||
/// input sanitization, and common validation patterns used across the app.
|
||||
///
|
||||
/// This service provides structured validation results with helpful error messages
|
||||
/// and includes parse + validate methods for complex inputs.
|
||||
class ValidationService {
|
||||
// Singleton pattern
|
||||
static final ValidationService _instance = ValidationService._internal();
|
||||
factory ValidationService() => _instance;
|
||||
ValidationService._internal();
|
||||
|
||||
// ============================================================================
|
||||
// COORDINATE VALIDATION
|
||||
// ============================================================================
|
||||
|
||||
/// Validates latitude value (-90.0 to +90.0)
|
||||
ValidationResult validateLatitude(double? lat) {
|
||||
if (lat == null) {
|
||||
return const ValidationResult.invalid('Latitude is required');
|
||||
}
|
||||
if (lat < -90.0 || lat > 90.0) {
|
||||
return const ValidationResult.invalid(
|
||||
'Latitude must be between -90.0 and +90.0',
|
||||
);
|
||||
}
|
||||
return const ValidationResult.valid();
|
||||
}
|
||||
|
||||
/// Validates longitude value (-180.0 to +180.0)
|
||||
ValidationResult validateLongitude(double? lon) {
|
||||
if (lon == null) {
|
||||
return const ValidationResult.invalid('Longitude is required');
|
||||
}
|
||||
if (lon < -180.0 || lon > 180.0) {
|
||||
return const ValidationResult.invalid(
|
||||
'Longitude must be between -180.0 and +180.0',
|
||||
);
|
||||
}
|
||||
return const ValidationResult.valid();
|
||||
}
|
||||
|
||||
/// Validates both latitude and longitude coordinates
|
||||
ValidationResult validateCoordinates(double? lat, double? lon) {
|
||||
final latResult = validateLatitude(lat);
|
||||
if (!latResult.isValid) return latResult;
|
||||
|
||||
final lonResult = validateLongitude(lon);
|
||||
if (!lonResult.isValid) return lonResult;
|
||||
|
||||
return const ValidationResult.valid();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// COORDINATE BOUNDS VALIDATION (for region downloads)
|
||||
// ============================================================================
|
||||
|
||||
/// Validates coordinate bounds for map region downloads
|
||||
///
|
||||
/// Checks:
|
||||
/// - All coordinates are valid numbers
|
||||
/// - North > South
|
||||
/// - East > West
|
||||
/// - Coordinates are within valid ranges
|
||||
ValidationResult validateBounds({
|
||||
required double? north,
|
||||
required double? south,
|
||||
required double? east,
|
||||
required double? west,
|
||||
}) {
|
||||
// Validate all coordinates exist
|
||||
if (north == null || south == null || east == null || west == null) {
|
||||
return const ValidationResult.invalid(
|
||||
'All coordinates are required (North, South, East, West)',
|
||||
);
|
||||
}
|
||||
|
||||
// Validate individual coordinate ranges
|
||||
final northResult = validateLatitude(north);
|
||||
if (!northResult.isValid) {
|
||||
return ValidationResult.invalid('North: ${northResult.errorMessage}');
|
||||
}
|
||||
|
||||
final southResult = validateLatitude(south);
|
||||
if (!southResult.isValid) {
|
||||
return ValidationResult.invalid('South: ${southResult.errorMessage}');
|
||||
}
|
||||
|
||||
final eastResult = validateLongitude(east);
|
||||
if (!eastResult.isValid) {
|
||||
return ValidationResult.invalid('East: ${eastResult.errorMessage}');
|
||||
}
|
||||
|
||||
final westResult = validateLongitude(west);
|
||||
if (!westResult.isValid) {
|
||||
return ValidationResult.invalid('West: ${westResult.errorMessage}');
|
||||
}
|
||||
|
||||
// Validate bounds relationships
|
||||
if (north <= south) {
|
||||
return const ValidationResult.invalid(
|
||||
'North must be greater than South',
|
||||
);
|
||||
}
|
||||
|
||||
if (east <= west) {
|
||||
return const ValidationResult.invalid(
|
||||
'East must be greater than West',
|
||||
);
|
||||
}
|
||||
|
||||
return const ValidationResult.valid();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// RADIO PARAMETER VALIDATION
|
||||
// ============================================================================
|
||||
|
||||
/// Validates LoRa radio frequency in MHz (137.0 to 1020.0 MHz)
|
||||
ValidationResult validateFrequency(double? freqMhz) {
|
||||
if (freqMhz == null) {
|
||||
return const ValidationResult.invalid('Frequency is required');
|
||||
}
|
||||
if (freqMhz < 137.0 || freqMhz > 1020.0) {
|
||||
return const ValidationResult.invalid(
|
||||
'Frequency must be between 137.0 and 1020.0 MHz',
|
||||
);
|
||||
}
|
||||
return const ValidationResult.valid();
|
||||
}
|
||||
|
||||
/// Validates TX power in dBm (-9 to +22 dBm typical, or up to maxPower)
|
||||
///
|
||||
/// If maxPower is provided, uses that as upper limit.
|
||||
/// Otherwise defaults to +22 dBm.
|
||||
ValidationResult validateTxPower(int? powerDbm, int? maxPower) {
|
||||
if (powerDbm == null) {
|
||||
return const ValidationResult.invalid('TX power is required');
|
||||
}
|
||||
|
||||
final max = maxPower ?? 22;
|
||||
|
||||
if (powerDbm < -9) {
|
||||
return const ValidationResult.invalid(
|
||||
'TX power must be at least -9 dBm',
|
||||
);
|
||||
}
|
||||
|
||||
if (powerDbm > max) {
|
||||
return ValidationResult.invalid(
|
||||
'TX power must not exceed $max dBm',
|
||||
);
|
||||
}
|
||||
|
||||
return const ValidationResult.valid();
|
||||
}
|
||||
|
||||
/// Validates LoRa bandwidth index (0-9)
|
||||
///
|
||||
/// Valid bandwidth indices:
|
||||
/// 0=7.8kHz, 1=10.4kHz, 2=15.6kHz, 3=20.8kHz, 4=31.25kHz,
|
||||
/// 5=41.7kHz, 6=62.5kHz, 7=125kHz, 8=250kHz, 9=500kHz
|
||||
ValidationResult validateBandwidth(int? bwIndex) {
|
||||
if (bwIndex == null) {
|
||||
return const ValidationResult.invalid('Bandwidth is required');
|
||||
}
|
||||
if (bwIndex < 0 || bwIndex > 9) {
|
||||
return const ValidationResult.invalid(
|
||||
'Bandwidth index must be between 0 and 9',
|
||||
);
|
||||
}
|
||||
return const ValidationResult.valid();
|
||||
}
|
||||
|
||||
/// Validates LoRa spreading factor (7-12)
|
||||
ValidationResult validateSpreadingFactor(int? sf) {
|
||||
if (sf == null) {
|
||||
return const ValidationResult.invalid('Spreading factor is required');
|
||||
}
|
||||
if (sf < 7 || sf > 12) {
|
||||
return const ValidationResult.invalid(
|
||||
'Spreading factor must be between 7 and 12',
|
||||
);
|
||||
}
|
||||
return const ValidationResult.valid();
|
||||
}
|
||||
|
||||
/// Validates LoRa coding rate (5-8)
|
||||
ValidationResult validateCodingRate(int? cr) {
|
||||
if (cr == null) {
|
||||
return const ValidationResult.invalid('Coding rate is required');
|
||||
}
|
||||
if (cr < 5 || cr > 8) {
|
||||
return const ValidationResult.invalid(
|
||||
'Coding rate must be between 5 and 8',
|
||||
);
|
||||
}
|
||||
return const ValidationResult.valid();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// DISTANCE AND TIME VALIDATION
|
||||
// ============================================================================
|
||||
|
||||
/// Validates distance in meters
|
||||
///
|
||||
/// Optional min and max bounds can be provided.
|
||||
/// Defaults to 1m minimum if not specified.
|
||||
ValidationResult validateDistance(
|
||||
double? meters, {
|
||||
double? min,
|
||||
double? max,
|
||||
}) {
|
||||
if (meters == null) {
|
||||
return const ValidationResult.invalid('Distance is required');
|
||||
}
|
||||
|
||||
final minValue = min ?? 1.0;
|
||||
|
||||
if (meters < minValue) {
|
||||
return ValidationResult.invalid(
|
||||
'Distance must be at least ${minValue.toStringAsFixed(0)}m',
|
||||
);
|
||||
}
|
||||
|
||||
if (max != null && meters > max) {
|
||||
return ValidationResult.invalid(
|
||||
'Distance must not exceed ${max.toStringAsFixed(0)}m',
|
||||
);
|
||||
}
|
||||
|
||||
return const ValidationResult.valid();
|
||||
}
|
||||
|
||||
/// Validates time interval in seconds
|
||||
///
|
||||
/// Optional min and max bounds can be provided.
|
||||
/// Defaults to 10 seconds minimum if not specified.
|
||||
ValidationResult validateTimeInterval(
|
||||
int? seconds, {
|
||||
int? min,
|
||||
int? max,
|
||||
}) {
|
||||
if (seconds == null) {
|
||||
return const ValidationResult.invalid('Time interval is required');
|
||||
}
|
||||
|
||||
final minValue = min ?? 10;
|
||||
|
||||
if (seconds < minValue) {
|
||||
return ValidationResult.invalid(
|
||||
'Time interval must be at least ${minValue}s',
|
||||
);
|
||||
}
|
||||
|
||||
if (max != null && seconds > max) {
|
||||
return ValidationResult.invalid(
|
||||
'Time interval must not exceed ${max}s',
|
||||
);
|
||||
}
|
||||
|
||||
return const ValidationResult.valid();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// ZOOM LEVEL VALIDATION
|
||||
// ============================================================================
|
||||
|
||||
/// Validates map zoom level (1-19 for most tile sources)
|
||||
ValidationResult validateZoomLevel(int? zoom) {
|
||||
if (zoom == null) {
|
||||
return const ValidationResult.invalid('Zoom level is required');
|
||||
}
|
||||
if (zoom < 1 || zoom > 19) {
|
||||
return const ValidationResult.invalid(
|
||||
'Zoom level must be between 1 and 19',
|
||||
);
|
||||
}
|
||||
return const ValidationResult.valid();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// NAME AND TEXT VALIDATION
|
||||
// ============================================================================
|
||||
|
||||
/// Validates name/text field
|
||||
///
|
||||
/// Checks for:
|
||||
/// - Non-empty after trimming
|
||||
/// - Maximum length (defaults to 32 characters)
|
||||
ValidationResult validateName(String? name, {int? maxLength}) {
|
||||
if (name == null || name.trim().isEmpty) {
|
||||
return const ValidationResult.invalid('Name cannot be empty');
|
||||
}
|
||||
|
||||
final max = maxLength ?? 32;
|
||||
|
||||
if (name.length > max) {
|
||||
return ValidationResult.invalid(
|
||||
'Name must not exceed $max characters',
|
||||
);
|
||||
}
|
||||
|
||||
return const ValidationResult.valid();
|
||||
}
|
||||
|
||||
/// Validates password field
|
||||
///
|
||||
/// Checks for:
|
||||
/// - Non-empty
|
||||
/// - Maximum length of 15 characters (MeshCore protocol limit)
|
||||
ValidationResult validatePassword(String? password) {
|
||||
if (password == null || password.isEmpty) {
|
||||
return const ValidationResult.invalid('Password cannot be empty');
|
||||
}
|
||||
|
||||
if (password.length > 15) {
|
||||
return const ValidationResult.invalid(
|
||||
'Password must not exceed 15 characters',
|
||||
);
|
||||
}
|
||||
|
||||
return const ValidationResult.valid();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// PARSE AND VALIDATE METHODS
|
||||
// ============================================================================
|
||||
|
||||
/// Parses and validates latitude string
|
||||
///
|
||||
/// Returns ParseResult with parsed value or error message.
|
||||
ParseResult<double> parseLatitude(String text) {
|
||||
if (text.trim().isEmpty) {
|
||||
return const ParseResult.error('Latitude is required');
|
||||
}
|
||||
|
||||
final value = double.tryParse(text.trim());
|
||||
if (value == null) {
|
||||
return const ParseResult.error('Invalid number format');
|
||||
}
|
||||
|
||||
final validation = validateLatitude(value);
|
||||
if (!validation.isValid) {
|
||||
return ParseResult.error(validation.errorMessage!);
|
||||
}
|
||||
|
||||
return ParseResult.success(value);
|
||||
}
|
||||
|
||||
/// Parses and validates longitude string
|
||||
///
|
||||
/// Returns ParseResult with parsed value or error message.
|
||||
ParseResult<double> parseLongitude(String text) {
|
||||
if (text.trim().isEmpty) {
|
||||
return const ParseResult.error('Longitude is required');
|
||||
}
|
||||
|
||||
final value = double.tryParse(text.trim());
|
||||
if (value == null) {
|
||||
return const ParseResult.error('Invalid number format');
|
||||
}
|
||||
|
||||
final validation = validateLongitude(value);
|
||||
if (!validation.isValid) {
|
||||
return ParseResult.error(validation.errorMessage!);
|
||||
}
|
||||
|
||||
return ParseResult.success(value);
|
||||
}
|
||||
|
||||
/// Parses and validates frequency string (in MHz)
|
||||
///
|
||||
/// Returns ParseResult with parsed value or error message.
|
||||
ParseResult<double> parseFrequency(String text) {
|
||||
if (text.trim().isEmpty) {
|
||||
return const ParseResult.error('Frequency is required');
|
||||
}
|
||||
|
||||
final value = double.tryParse(text.trim());
|
||||
if (value == null) {
|
||||
return const ParseResult.error('Invalid number format');
|
||||
}
|
||||
|
||||
final validation = validateFrequency(value);
|
||||
if (!validation.isValid) {
|
||||
return ParseResult.error(validation.errorMessage!);
|
||||
}
|
||||
|
||||
return ParseResult.success(value);
|
||||
}
|
||||
|
||||
/// Parses and validates TX power string (in dBm)
|
||||
///
|
||||
/// Returns ParseResult with parsed value or error message.
|
||||
ParseResult<int> parseTxPower(String text, {int? maxPower}) {
|
||||
if (text.trim().isEmpty) {
|
||||
return const ParseResult.error('TX power is required');
|
||||
}
|
||||
|
||||
final value = int.tryParse(text.trim());
|
||||
if (value == null) {
|
||||
return const ParseResult.error('Invalid number format');
|
||||
}
|
||||
|
||||
final validation = validateTxPower(value, maxPower);
|
||||
if (!validation.isValid) {
|
||||
return ParseResult.error(validation.errorMessage!);
|
||||
}
|
||||
|
||||
return ParseResult.success(value);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SANITIZATION METHODS
|
||||
// ============================================================================
|
||||
|
||||
/// Sanitizes name string
|
||||
///
|
||||
/// - Trims whitespace
|
||||
/// - Removes control characters
|
||||
/// - Truncates to maxLength if specified (defaults to 32)
|
||||
String sanitizeName(String name, {int? maxLength}) {
|
||||
final max = maxLength ?? 32;
|
||||
|
||||
// Trim whitespace
|
||||
String sanitized = name.trim();
|
||||
|
||||
// Remove control characters (0x00-0x1F, 0x7F)
|
||||
sanitized = sanitized.replaceAll(RegExp(r'[\x00-\x1F\x7F]'), '');
|
||||
|
||||
// Truncate if too long
|
||||
if (sanitized.length > max) {
|
||||
sanitized = sanitized.substring(0, max);
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
/// Sanitizes password string
|
||||
///
|
||||
/// - Removes whitespace
|
||||
/// - Removes control characters
|
||||
/// - Truncates to 15 characters (MeshCore protocol limit)
|
||||
String sanitizePassword(String password) {
|
||||
// Remove all whitespace
|
||||
String sanitized = password.replaceAll(RegExp(r'\s'), '');
|
||||
|
||||
// Remove control characters
|
||||
sanitized = sanitized.replaceAll(RegExp(r'[\x00-\x1F\x7F]'), '');
|
||||
|
||||
// Truncate to protocol limit
|
||||
if (sanitized.length > 15) {
|
||||
sanitized = sanitized.substring(0, 15);
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
}
|
||||
|
||||
// ==============================================================================
|
||||
// RESULT CLASSES
|
||||
// ==============================================================================
|
||||
|
||||
/// Result of a validation operation
|
||||
///
|
||||
/// Contains either success (isValid=true) or failure with error message.
|
||||
class ValidationResult {
|
||||
/// Whether the validation passed
|
||||
final bool isValid;
|
||||
|
||||
/// Error message if validation failed (null if valid)
|
||||
final String? errorMessage;
|
||||
|
||||
/// Creates a valid result
|
||||
const ValidationResult.valid()
|
||||
: isValid = true,
|
||||
errorMessage = null;
|
||||
|
||||
/// Creates an invalid result with error message
|
||||
const ValidationResult.invalid(this.errorMessage) : isValid = false;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return isValid ? 'Valid' : 'Invalid: $errorMessage';
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of a parse operation
|
||||
///
|
||||
/// Contains either parsed value (success) or error message (failure).
|
||||
class ParseResult<T> {
|
||||
/// Parsed value if successful (null if error)
|
||||
final T? value;
|
||||
|
||||
/// Error message if parsing failed (null if successful)
|
||||
final String? errorMessage;
|
||||
|
||||
/// Creates a successful parse result
|
||||
const ParseResult.success(this.value) : errorMessage = null;
|
||||
|
||||
/// Creates a failed parse result with error message
|
||||
const ParseResult.error(this.errorMessage) : value = null;
|
||||
|
||||
/// Whether the parse operation succeeded
|
||||
bool get isSuccess => value != null;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return isSuccess ? 'Success: $value' : 'Error: $errorMessage';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user