From 276d0f447093de1153055710ea83fe5cfb102097 Mon Sep 17 00:00:00 2001 From: Janez T Date: Wed, 15 Oct 2025 10:55:11 +0200 Subject: [PATCH] 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. --- lib/models/message.dart | 4 + lib/providers/messages_provider.dart | 48 +- lib/services/location_tracking_service.dart | 501 +++++++++++++++++++ lib/services/map_marker_service.dart | 518 ++++++++++++++++++++ lib/services/validation_service.dart | 511 +++++++++++++++++++ 5 files changed, 1576 insertions(+), 6 deletions(-) create mode 100644 lib/services/location_tracking_service.dart create mode 100644 lib/services/map_marker_service.dart create mode 100644 lib/services/validation_service.dart diff --git a/lib/models/message.dart b/lib/models/message.dart index cd48085..fc4151b 100644 --- a/lib/models/message.dart +++ b/lib/models/message.dart @@ -60,6 +60,7 @@ class Message { final int? suggestedTimeoutMs; // Suggested timeout from SENT response final int? roundTripTimeMs; // RTT from SEND_CONFIRMED final DateTime? deliveredAt; // When delivery was confirmed + final Uint8List? recipientPublicKey; // Full 32-byte public key of recipient (for retry) Message({ required this.id, @@ -80,6 +81,7 @@ class Message { this.suggestedTimeoutMs, this.roundTripTimeMs, this.deliveredAt, + this.recipientPublicKey, }); /// Get sender public key as hex string @@ -182,6 +184,7 @@ class Message { int? suggestedTimeoutMs, int? roundTripTimeMs, DateTime? deliveredAt, + Uint8List? recipientPublicKey, }) { return Message( id: id ?? this.id, @@ -202,6 +205,7 @@ class Message { suggestedTimeoutMs: suggestedTimeoutMs ?? this.suggestedTimeoutMs, roundTripTimeMs: roundTripTimeMs ?? this.roundTripTimeMs, deliveredAt: deliveredAt ?? this.deliveredAt, + recipientPublicKey: recipientPublicKey ?? this.recipientPublicKey, ); } diff --git a/lib/providers/messages_provider.dart b/lib/providers/messages_provider.dart index ec73bfb..3914687 100644 --- a/lib/providers/messages_provider.dart +++ b/lib/providers/messages_provider.dart @@ -338,8 +338,9 @@ class MessagesProvider with ChangeNotifier { void markMessageSent(String messageId, int expectedAckTag, int suggestedTimeoutMs) { print('πŸ“€ [MessagesProvider] markMessageSent called'); print(' Message ID: $messageId'); - print(' Expected ACK tag: $expectedAckTag'); + print(' Expected ACK tag: $expectedAckTag (0x${expectedAckTag.toRadixString(16).padLeft(8, '0')})'); print(' Timeout: ${suggestedTimeoutMs}ms'); + print(' Current pending ACKs before adding: ${_pendingSentMessages.keys.toList()}'); final index = _messages.indexWhere((m) => m.id == messageId); print(' Message index in list: $index'); @@ -347,6 +348,7 @@ class MessagesProvider with ChangeNotifier { if (index != -1) { final message = _messages[index]; print(' Current status: ${message.deliveryStatus}'); + print(' Message text preview: ${message.text.substring(0, message.text.length > 30 ? 30 : message.text.length)}...'); final updatedMessage = message.copyWith( deliveryStatus: MessageDeliveryStatus.sent, @@ -357,8 +359,9 @@ class MessagesProvider with ChangeNotifier { // Track by ACK tag for matching with delivery confirmation _pendingSentMessages[expectedAckTag] = updatedMessage; - print(' Added to pending messages map with ACK: $expectedAckTag'); + print(' βœ… Added to pending messages map with ACK: $expectedAckTag'); print(' Total pending messages: ${_pendingSentMessages.length}'); + print(' Pending ACKs after adding: ${_pendingSentMessages.keys.toList()}'); // Start timeout timer _timeoutTimers[expectedAckTag] = Timer( @@ -372,11 +375,19 @@ class MessagesProvider with ChangeNotifier { ); print('⏱️ [MessagesProvider] Started ${suggestedTimeoutMs}ms timeout timer for message $messageId (ACK $expectedAckTag)'); + print(' Calling notifyListeners() to update UI with "sent" status'); _persistMessages(); notifyListeners(); + + print(' βœ… markMessageSent completed successfully'); } else { print('⚠️ [MessagesProvider] Message not found in list: $messageId'); + print(' Total messages in list: ${_messages.length}'); + print(' Recent messages:'); + for (final m in _messages.take(5)) { + print(' - ID: ${m.id}, Status: ${m.deliveryStatus}'); + } } } @@ -384,12 +395,13 @@ class MessagesProvider with ChangeNotifier { void markMessageDelivered(int ackCode, int roundTripTimeMs) { print('πŸ” [MessagesProvider] markMessageDelivered called with ACK: $ackCode, RTT: ${roundTripTimeMs}ms'); print(' Current pending messages: ${_pendingSentMessages.keys.toList()}'); + print(' Total messages in list: ${_messages.length}'); print(' Looking for ACK: $ackCode'); // Find message by ACK code final message = _pendingSentMessages[ackCode]; if (message != null) { - print(' βœ… Found message: ${message.id}'); + print(' βœ… Found message in pending map: ${message.id}'); final index = _messages.indexWhere((m) => m.id == message.id); print(' Message index in list: $index'); @@ -409,19 +421,43 @@ class MessagesProvider with ChangeNotifier { _pendingSentMessages.remove(ackCode); print('βœ… [MessagesProvider] Message ${message.id} delivered in ${roundTripTimeMs}ms (ACK $ackCode)'); + print(' Updated status to: ${updatedMessage.deliveryStatus}'); print(' Calling notifyListeners() to update UI'); _persistMessages(); notifyListeners(); + + print(' βœ… notifyListeners() called successfully'); } else { - print('⚠️ [MessagesProvider] Message not found in list (index=-1)'); + print('⚠️ [MessagesProvider] Message not found in messages list (index=-1)'); + print(' This should never happen - message was in pending map but not in messages list'); } } else { print('⚠️ [MessagesProvider] No pending message found for ACK code: $ackCode'); + print(' Pending ACK codes: ${_pendingSentMessages.keys.toList()}'); print(' This means either:'); - print(' 1. markMessageSent() was never called for this message'); - print(' 2. The ACK code doesn\'t match the expected ACK tag from RESP_CODE_SENT'); + print(' 1. markMessageSent() was never called for this message (ACK tag not stored)'); + print(' 2. The ACK code from PUSH_CODE_SEND_CONFIRMED doesn\'t match the expected ACK tag from RESP_CODE_SENT'); print(' 3. The message was already delivered or timed out'); + print(' Searching all messages for debugging...'); + + // Debug: Search for any message with this ACK tag + final matchingMessages = _messages.where((m) => m.expectedAckTag == ackCode).toList(); + if (matchingMessages.isNotEmpty) { + print(' ⚠️ Found ${matchingMessages.length} message(s) with matching ACK tag but NOT in pending map:'); + for (final m in matchingMessages) { + print(' - Message ID: ${m.id}, Status: ${m.deliveryStatus}, ACK: ${m.expectedAckTag}'); + } + print(' This indicates the message was sent but never added to _pendingSentMessages map'); + print(' Likely cause: markMessageSent() was not called with correct message ID'); + } else { + print(' No messages found with ACK tag $ackCode'); + print(' Recent sent messages:'); + final sentMessages = _messages.where((m) => m.isSentMessage).take(5).toList(); + for (final m in sentMessages) { + print(' - ID: ${m.id}, Status: ${m.deliveryStatus}, ACK: ${m.expectedAckTag}'); + } + } } } diff --git a/lib/services/location_tracking_service.dart b/lib/services/location_tracking_service.dart new file mode 100644 index 0000000..3a9e317 --- /dev/null +++ b/lib/services/location_tracking_service.dart @@ -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? _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 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 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 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 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 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 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 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 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 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 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 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; + } +} diff --git a/lib/services/map_marker_service.dart b/lib/services/map_marker_service.dart new file mode 100644 index 0000000..2fd300f --- /dev/null +++ b/lib/services/map_marker_service.dart @@ -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 generateContactMarkers({ + required List 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().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 generateSarMarkers({ + required List 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 clusterMarkers({ + required List 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 contacts, + required List sarMarkers, + LatLng? defaultCenter, + }) { + final allPoints = []; + + for (final contact in contacts) { + if (contact.displayLocation != null) { + allPoints.add(contact.displayLocation!); + } + } + + for (final marker in sarMarkers) { + allPoints.add(marker.location); + } + + if (allPoints.isEmpty) { + return defaultCenter ?? 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; + } +} diff --git a/lib/services/validation_service.dart b/lib/services/validation_service.dart new file mode 100644 index 0000000..95615a0 --- /dev/null +++ b/lib/services/validation_service.dart @@ -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 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 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 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 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 { + /// 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'; + } +}