From e831612a1a7c10db76d1187010fc222484d8773b Mon Sep 17 00:00:00 2001 From: Janez T Date: Wed, 15 Oct 2025 10:30:21 +0200 Subject: [PATCH] Add CompassSarList widget and refactor DetailedCompassDialog - Introduced CompassSarList widget to display filtered SAR markers with distance and bearing information. - Refactored DetailedCompassDialog to integrate CompassSarList and CompassContactList for better organization. - Removed the previous filter dialog implementation and replaced it with a more modular CompassFilters widget. - Simplified the handling of zoom and scale updates in the compass view. - Cleaned up unused code related to previous implementations of contact and SAR marker lists. --- lib/providers/connection_provider.dart | 52 +- .../helpers/message_delivery_tracker.dart | 71 + lib/providers/helpers/room_login_manager.dart | 85 + lib/services/ble/ble_command_sender.dart | 131 + lib/services/ble/ble_connection_manager.dart | 176 ++ lib/services/ble/ble_response_handler.dart | 656 +++++ lib/services/meshcore_ble_service.dart | 2244 ++--------------- lib/services/protocol/frame_builder.dart | 238 ++ lib/services/protocol/frame_parser.dart | 398 +++ lib/widgets/contacts/contact_tile.dart | 22 + .../map/compass/compass_contact_list.dart | 176 ++ lib/widgets/map/compass/compass_filters.dart | 181 ++ lib/widgets/map/compass/compass_header.dart | 598 +++++ lib/widgets/map/compass/compass_sar_list.dart | 195 ++ lib/widgets/map/detailed_compass_dialog.dart | 1093 +------- 15 files changed, 3261 insertions(+), 3055 deletions(-) create mode 100644 lib/providers/helpers/message_delivery_tracker.dart create mode 100644 lib/providers/helpers/room_login_manager.dart create mode 100644 lib/services/ble/ble_command_sender.dart create mode 100644 lib/services/ble/ble_connection_manager.dart create mode 100644 lib/services/ble/ble_response_handler.dart create mode 100644 lib/services/protocol/frame_builder.dart create mode 100644 lib/services/protocol/frame_parser.dart create mode 100644 lib/widgets/map/compass/compass_contact_list.dart create mode 100644 lib/widgets/map/compass/compass_filters.dart create mode 100644 lib/widgets/map/compass/compass_header.dart create mode 100644 lib/widgets/map/compass/compass_sar_list.dart diff --git a/lib/providers/connection_provider.dart b/lib/providers/connection_provider.dart index 612be43..e3ab7cd 100644 --- a/lib/providers/connection_provider.dart +++ b/lib/providers/connection_provider.dart @@ -2,7 +2,6 @@ import 'dart:async'; import 'dart:typed_data'; import 'package:flutter/foundation.dart'; import 'package:flutter_blue_plus/flutter_blue_plus.dart'; -import 'package:shared_preferences/shared_preferences.dart'; import '../models/device_info.dart'; import '../models/contact.dart'; import '../models/message.dart'; @@ -10,6 +9,8 @@ import '../models/room_login_state.dart'; import '../services/meshcore_ble_service.dart'; import '../services/cayenne_lpp_parser.dart'; import '../utils/sar_message_parser.dart'; +import 'helpers/room_login_manager.dart'; +import 'helpers/message_delivery_tracker.dart'; /// Connection Provider - manages MeshCore BLE connection class ConnectionProvider with ChangeNotifier { @@ -46,13 +47,12 @@ class ConnectionProvider with ChangeNotifier { // Message sync state bool _noMoreMessages = false; - // Room login state tracking - final Map _roomLoginStates = {}; - Map get roomLoginStates => Map.unmodifiable(_roomLoginStates); + // Helper instances + final RoomLoginManager _roomLoginManager = RoomLoginManager(); + final MessageDeliveryTracker _messageDeliveryTracker = MessageDeliveryTracker(); - // Track sent message IDs by ACK tag for delivery confirmation - final Map _ackTagToMessageId = {}; - final List _pendingSentMessageIds = []; // Queue of pending message IDs + // Expose room login states + Map get roomLoginStates => _roomLoginManager.roomLoginStates; // Callbacks for other providers Function(Contact)? onContactReceived; @@ -149,15 +149,12 @@ class ConnectionProvider with ChangeNotifier { print(' Public key prefix: ${publicKeyPrefix.map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}'); print(' Permissions: $permissions, Admin: $isAdmin, Tag: $tag'); - // Update room login state - final prefixHex = publicKeyPrefix.map((b) => b.toRadixString(16).padLeft(2, '0')).join(':'); - final hasPassword = await _hasPasswordForRoom(publicKeyPrefix); - _roomLoginStates[prefixHex] = RoomLoginState.loggedIn( + // Update room login state via helper + await _roomLoginManager.handleLoginSuccess( publicKeyPrefix: publicKeyPrefix, permissions: permissions, isAdmin: isAdmin, tag: tag, - hasPassword: hasPassword, ); notifyListeners(); @@ -168,11 +165,9 @@ class ConnectionProvider with ChangeNotifier { print('πŸ“₯ [Provider] Login failed to room'); print(' Public key prefix: ${publicKeyPrefix.map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}'); - // Update room login state to logged out - final prefixHex = publicKeyPrefix.map((b) => b.toRadixString(16).padLeft(2, '0')).join(':'); - _roomLoginStates[prefixHex] = RoomLoginState.loggedOut( + // Update room login state to logged out via helper + _roomLoginManager.handleLoginFail( publicKeyPrefix: publicKeyPrefix, - hasPassword: false, // Password was incorrect ); notifyListeners(); @@ -913,6 +908,31 @@ class ConnectionProvider with ChangeNotifier { } } + /// Reset routing path for a contact + /// + /// Clears the learned path to a contact, forcing the next message to use + /// flood routing to discover a new route. Useful when: + /// - A mobile repeater has moved and the path is broken + /// - You want to find a better/shorter route + /// - Direct messages are timing out due to path issues + /// + /// After calling this, the device will automatically fall back to flood mode + /// for the next message to this contact, and learn a new path from the response. + Future resetPath(Uint8List contactPublicKey) async { + if (!_bleService.isConnected) { + _error = 'Not connected to device'; + notifyListeners(); + return; + } + + try { + await _bleService.resetPath(contactPublicKey); + } catch (e) { + _error = 'Failed to reset path: $e'; + notifyListeners(); + } + } + /// Clear error message void clearError() { _error = null; diff --git a/lib/providers/helpers/message_delivery_tracker.dart b/lib/providers/helpers/message_delivery_tracker.dart new file mode 100644 index 0000000..f3853d3 --- /dev/null +++ b/lib/providers/helpers/message_delivery_tracker.dart @@ -0,0 +1,71 @@ +/// Message delivery tracking helper +/// +/// Manages message delivery tracking for sent messages, including: +/// - ACK tag to message ID mapping +/// - Pending sent message IDs queue +/// - Message sent/delivered coordination +class MessageDeliveryTracker { + /// Map of ACK tag to message ID for delivery confirmation + final Map _ackTagToMessageId = {}; + + /// Queue of pending message IDs (FIFO) + /// Messages must be sent sequentially for proper matching + final List _pendingSentMessageIds = []; + + /// Track a pending message ID + /// + /// Add message ID to pending queue. When SENT response arrives, + /// it will be matched with this message ID (FIFO order). + void trackPendingMessage(String messageId) { + _pendingSentMessageIds.add(messageId); + } + + /// Get message ID for ACK tag and remove it from tracking + /// + /// Called when SENT response arrives. Returns the message ID + /// that corresponds to this ACK tag (FIFO order). + /// + /// Returns null if no pending messages. + String? popPendingMessageId() { + if (_pendingSentMessageIds.isEmpty) { + return null; + } + return _pendingSentMessageIds.removeAt(0); + } + + /// Store ACK tag to message ID mapping + /// + /// Call this after receiving SENT response with expectedAckTag. + /// Later, when SEND_CONFIRMED arrives with matching ackCode, + /// you can look up the original message ID. + void mapAckTagToMessageId(int ackTag, String messageId) { + _ackTagToMessageId[ackTag] = messageId; + } + + /// Get message ID for ACK code + /// + /// Called when SEND_CONFIRMED arrives. Returns the message ID + /// that corresponds to this ACK code. + /// + /// Returns null if ACK tag not found. + String? getMessageIdForAck(int ackCode) { + return _ackTagToMessageId[ackCode]; + } + + /// Remove ACK tag mapping after delivery confirmed + void removeAckTag(int ackCode) { + _ackTagToMessageId.remove(ackCode); + } + + /// Clear all tracking state + void clearTracking() { + _ackTagToMessageId.clear(); + _pendingSentMessageIds.clear(); + } + + /// Get count of pending messages + int get pendingCount => _pendingSentMessageIds.length; + + /// Get count of tracked ACK tags + int get ackTagCount => _ackTagToMessageId.length; +} diff --git a/lib/providers/helpers/room_login_manager.dart b/lib/providers/helpers/room_login_manager.dart new file mode 100644 index 0000000..c9d2097 --- /dev/null +++ b/lib/providers/helpers/room_login_manager.dart @@ -0,0 +1,85 @@ +import 'dart:typed_data'; +import 'package:flutter/foundation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import '../../models/room_login_state.dart'; + +/// Room login state management helper +/// +/// Manages login state tracking for room contacts, including: +/// - Room login state per contact (Map) +/// - Password checking logic +/// - Login success/fail state updates +class RoomLoginManager { + /// Map of room public key prefix (hex string) to login state + final Map _roomLoginStates = {}; + + /// Get all room login states (unmodifiable view) + Map get roomLoginStates => Map.unmodifiable(_roomLoginStates); + + /// Get login state for a room by public key prefix + RoomLoginState? getRoomLoginState(Uint8List publicKeyPrefix) { + final prefixHex = _publicKeyPrefixToHex(publicKeyPrefix); + return _roomLoginStates[prefixHex]; + } + + /// Check if logged into a specific room + bool isLoggedIntoRoom(Uint8List publicKeyPrefix) { + final state = getRoomLoginState(publicKeyPrefix); + return state?.isLoggedIn ?? false; + } + + /// Update room login state after successful login + Future handleLoginSuccess({ + required Uint8List publicKeyPrefix, + required int permissions, + required bool isAdmin, + required int tag, + }) async { + final prefixHex = _publicKeyPrefixToHex(publicKeyPrefix); + final hasPassword = await _hasPasswordForRoom(publicKeyPrefix); + + _roomLoginStates[prefixHex] = RoomLoginState.loggedIn( + publicKeyPrefix: publicKeyPrefix, + permissions: permissions, + isAdmin: isAdmin, + tag: tag, + hasPassword: hasPassword, + ); + } + + /// Update room login state after failed login + void handleLoginFail({ + required Uint8List publicKeyPrefix, + }) { + final prefixHex = _publicKeyPrefixToHex(publicKeyPrefix); + + _roomLoginStates[prefixHex] = RoomLoginState.loggedOut( + publicKeyPrefix: publicKeyPrefix, + hasPassword: false, // Password was incorrect + ); + } + + /// Clear all room login states (call on disconnect) + void clearRoomLoginStates() { + _roomLoginStates.clear(); + } + + /// Check if a password exists for a room (by public key prefix) + Future _hasPasswordForRoom(Uint8List publicKeyPrefix) async { + try { + final prefs = await SharedPreferences.getInstance(); + // Convert prefix to hex string for storage key + final prefixHex = _publicKeyPrefixToHex(publicKeyPrefix); + final roomKey = 'room_password_$prefixHex'; + return prefs.getString(roomKey) != null; + } catch (e) { + debugPrint('Error checking password for room: $e'); + return false; + } + } + + /// Convert public key prefix to hex string (colon-separated) + String _publicKeyPrefixToHex(Uint8List publicKeyPrefix) { + return publicKeyPrefix.map((b) => b.toRadixString(16).padLeft(2, '0')).join(':'); + } +} diff --git a/lib/services/ble/ble_command_sender.dart b/lib/services/ble/ble_command_sender.dart new file mode 100644 index 0000000..c04953b --- /dev/null +++ b/lib/services/ble/ble_command_sender.dart @@ -0,0 +1,131 @@ +import 'dart:typed_data'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_blue_plus/flutter_blue_plus.dart'; +import '../meshcore_opcode_names.dart'; +import '../../models/ble_packet_log.dart'; + +/// Callback types for sender events +typedef OnErrorCallback = void Function(String error); + +/// Sends commands to the BLE device +class BleCommandSender { + BluetoothCharacteristic? _rxCharacteristic; + int _txPacketCount = 0; + final List _packetLogs = []; + static const int _maxLogSize = 1000; + + // Callbacks + OnErrorCallback? onError; + VoidCallback? onTxActivity; + + // Getters + int get txPacketCount => _txPacketCount; + List get packetLogs => List.unmodifiable(_packetLogs); + + /// Set the RX characteristic to write to + void setRxCharacteristic(BluetoothCharacteristic? characteristic) { + _rxCharacteristic = characteristic; + } + + /// Write data to RX characteristic + Future writeData(Uint8List data) async { + if (_rxCharacteristic == null) { + throw Exception('Not connected'); + } + try { + // Extract command code from first byte + final commandCode = data.isNotEmpty ? data[0] : null; + final opcodeName = commandCode != null + ? MeshCoreOpcodeNames.getCommandName(commandCode) + : 'UNKNOWN'; + final opcodeHex = commandCode != null + ? '0x${commandCode.toRadixString(16).padLeft(2, '0').toUpperCase()}' + : 'N/A'; + + print('πŸ“€ [TX] Sending command: $opcodeName ($opcodeHex)'); + print(' Data size: ${data.length} bytes'); + print(' Hex: ${data.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ')}'); + + // Check if the characteristic supports write without response + final supportsWriteWithoutResponse = _rxCharacteristic!.properties.writeWithoutResponse; + final supportsWrite = _rxCharacteristic!.properties.write; + + if (supportsWriteWithoutResponse) { + await _rxCharacteristic!.write(data, withoutResponse: true); + } else if (supportsWrite) { + await _rxCharacteristic!.write(data, withoutResponse: false); + } else { + throw Exception('Characteristic does not support write operations'); + } + + // Log TX packet + _logPacket(data, PacketDirection.tx, responseCode: commandCode); + + // Increment TX packet counter and trigger activity indicator + _txPacketCount++; + onTxActivity?.call(); + + print('βœ… [TX] Command sent successfully'); + } catch (e) { + print('❌ [TX] Write error: $e'); + onError?.call('Write error: $e'); + rethrow; + } + } + + /// Log a packet + void _logPacket(Uint8List data, PacketDirection direction, {int? responseCode}) { + // Add new packet + _packetLogs.add(BlePacketLog( + timestamp: DateTime.now(), + rawData: data, + direction: direction, + responseCode: responseCode, + description: _getPacketDescription(responseCode), + )); + + // Limit log size to prevent memory issues + if (_packetLogs.length > _maxLogSize) { + _packetLogs.removeAt(0); + } + } + + /// Get human-readable description of packet + String? _getPacketDescription(int? code) { + // TX packets - command codes + switch (code) { + case 4: // cmdGetContacts + return 'Get Contacts'; + case 2: // cmdSendTxtMsg + return 'Send Text Message'; + case 3: // cmdSendChannelTxtMsg + return 'Send Channel Message'; + case 39: // cmdSendTelemetryReq + return 'Request Telemetry'; + case 22: // cmdDeviceQuery + return 'Device Query'; + case 1: // cmdAppStart + return 'App Start'; + case 27: // cmdSendStatusReq + return 'Status Request'; + default: + return null; + } + } + + /// Reset packet counter + void resetCounter() { + _txPacketCount = 0; + } + + /// Clear packet logs + void clearPacketLogs() { + _packetLogs.clear(); + } + + /// Dispose resources + void dispose() { + _rxCharacteristic = null; + _packetLogs.clear(); + } +} diff --git a/lib/services/ble/ble_connection_manager.dart b/lib/services/ble/ble_connection_manager.dart new file mode 100644 index 0000000..0d51a66 --- /dev/null +++ b/lib/services/ble/ble_connection_manager.dart @@ -0,0 +1,176 @@ +import 'dart:async'; +import 'package:flutter_blue_plus/flutter_blue_plus.dart'; +import '../meshcore_constants.dart'; + +/// Callback types for connection events +typedef OnConnectionStateCallback = void Function(bool isConnected); +typedef OnErrorCallback = void Function(String error); + +/// Manages BLE connection lifecycle +class BleConnectionManager { + BluetoothDevice? _device; + BluetoothCharacteristic? _rxCharacteristic; + BluetoothCharacteristic? _txCharacteristic; + bool _isConnected = false; + + // Callbacks + OnConnectionStateCallback? onConnectionStateChanged; + OnErrorCallback? onError; + + // Getters + bool get isConnected => _isConnected; + BluetoothDevice? get device => _device; + BluetoothCharacteristic? get rxCharacteristic => _rxCharacteristic; + BluetoothCharacteristic? get txCharacteristic => _txCharacteristic; + + /// Scan for MeshCore devices + Stream scanForDevices({Duration timeout = const Duration(seconds: 10)}) async* { + try { + print('πŸ” [BLE] Starting scan for MeshCore devices...'); + print(' Service UUID: ${MeshCoreConstants.bleServiceUuid}'); + print(' Timeout: ${timeout.inSeconds}s'); + + await FlutterBluePlus.startScan( + timeout: timeout, + withServices: [Guid(MeshCoreConstants.bleServiceUuid)], + ); + print('βœ… [BLE] Scan started successfully'); + + int deviceCount = 0; + await for (final scanResult in FlutterBluePlus.scanResults) { + print('πŸ“‘ [BLE] Scan results batch received: ${scanResult.length} results'); + for (final result in scanResult) { + print(' Device: ${result.device.platformName} (${result.device.remoteId})'); + print(' RSSI: ${result.rssi}'); + print(' Service UUIDs: ${result.advertisementData.serviceUuids}'); + + if (result.advertisementData.serviceUuids + .contains(Guid(MeshCoreConstants.bleServiceUuid))) { + deviceCount++; + print(' βœ… MeshCore device found! Total: $deviceCount'); + yield result.device; + } else { + print(' ❌ Not a MeshCore device (service UUID mismatch)'); + } + } + } + print('🏁 [BLE] Scan completed. Found $deviceCount MeshCore devices'); + } catch (e) { + print('❌ [BLE] Scan error: $e'); + onError?.call('Scan error: $e'); + } + } + + /// Connect to a MeshCore device + Future connect(BluetoothDevice device) async { + try { + print('πŸ”΅ [BLE] Starting connection to device: ${device.platformName} (${device.remoteId})'); + _device = device; + + // Connect to device + print('πŸ”΅ [BLE] Calling device.connect() with 15s timeout...'); + await device.connect( + license: License.free, + timeout: const Duration(seconds: 15), + mtu: 512, + ); + print('βœ… [BLE] Device connected successfully'); + + // Discover services + print('πŸ”΅ [BLE] Discovering services...'); + final services = await device.discoverServices(); + print('βœ… [BLE] Found ${services.length} services'); + + // Log all discovered services for debugging + for (final service in services) { + print(' πŸ“‹ Service: ${service.uuid}'); + for (final char in service.characteristics) { + print(' - Characteristic: ${char.uuid}'); + } + } + + // Find MeshCore service + print('πŸ”΅ [BLE] Looking for MeshCore service: ${MeshCoreConstants.bleServiceUuid}'); + BluetoothService? meshCoreService; + for (final service in services) { + if (service.uuid.toString().toLowerCase() == + MeshCoreConstants.bleServiceUuid.toLowerCase()) { + meshCoreService = service; + print('βœ… [BLE] Found MeshCore service'); + break; + } + } + + if (meshCoreService == null) { + print('❌ [BLE] MeshCore service not found!'); + throw Exception('MeshCore service not found'); + } + + // Find RX and TX characteristics + print('πŸ”΅ [BLE] Looking for RX and TX characteristics...'); + print(' RX UUID: ${MeshCoreConstants.bleCharacteristicRxUuid}'); + print(' TX UUID: ${MeshCoreConstants.bleCharacteristicTxUuid}'); + + for (final characteristic in meshCoreService.characteristics) { + final uuid = characteristic.uuid.toString().toLowerCase(); + print(' πŸ“‹ Checking characteristic: $uuid'); + + if (uuid == MeshCoreConstants.bleCharacteristicRxUuid.toLowerCase()) { + _rxCharacteristic = characteristic; + print(' βœ… Found RX characteristic'); + } else if (uuid == + MeshCoreConstants.bleCharacteristicTxUuid.toLowerCase()) { + _txCharacteristic = characteristic; + print(' βœ… Found TX characteristic'); + } + } + + if (_rxCharacteristic == null || _txCharacteristic == null) { + print('❌ [BLE] Required characteristics not found!'); + print(' RX found: ${_rxCharacteristic != null}'); + print(' TX found: ${_txCharacteristic != null}'); + throw Exception('Required characteristics not found'); + } + + // Enable notifications on TX characteristic + print('πŸ”΅ [BLE] Enabling notifications on TX characteristic...'); + await _txCharacteristic!.setNotifyValue(true); + print('βœ… [BLE] Notifications enabled'); + + _isConnected = true; + print('πŸ”΅ [BLE] Notifying connection state change: connected'); + onConnectionStateChanged?.call(true); + + print('βœ…βœ…βœ… [BLE] Connection completed successfully!'); + return true; + } catch (e) { + print('❌❌❌ [BLE] Connection failed: $e'); + print('Stack trace: ${StackTrace.current}'); + onError?.call('Connection error: $e'); + _isConnected = false; + onConnectionStateChanged?.call(false); + return false; + } + } + + /// Disconnect from device + Future disconnect() async { + try { + await _device?.disconnect(); + _isConnected = false; + _device = null; + _rxCharacteristic = null; + _txCharacteristic = null; + onConnectionStateChanged?.call(false); + } catch (e) { + onError?.call('Disconnect error: $e'); + } + } + + /// Dispose resources + void dispose() { + _device = null; + _rxCharacteristic = null; + _txCharacteristic = null; + } +} diff --git a/lib/services/ble/ble_response_handler.dart b/lib/services/ble/ble_response_handler.dart new file mode 100644 index 0000000..d1d28e3 --- /dev/null +++ b/lib/services/ble/ble_response_handler.dart @@ -0,0 +1,656 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:typed_data'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_blue_plus/flutter_blue_plus.dart'; +import '../../models/contact.dart'; +import '../../models/message.dart'; +import '../../models/ble_packet_log.dart'; +import '../buffer_reader.dart'; +import '../meshcore_constants.dart'; +import '../meshcore_opcode_names.dart'; +import '../protocol/frame_parser.dart'; + +/// Callback types for response events +typedef OnContactCallback = void Function(Contact contact); +typedef OnContactsCompleteCallback = void Function(List contacts); +typedef OnMessageCallback = void Function(Message message); +typedef OnTelemetryCallback = void Function(Uint8List publicKey, Uint8List lppData); +typedef OnSelfInfoCallback = void Function(Map selfInfo); +typedef OnDeviceInfoCallback = void Function(Map deviceInfo); +typedef OnNoMoreMessagesCallback = void Function(); +typedef OnMessageWaitingCallback = void Function(); +typedef OnLoginSuccessCallback = void Function(Uint8List publicKeyPrefix, int permissions, bool isAdmin, int tag); +typedef OnLoginFailCallback = void Function(Uint8List publicKeyPrefix); +typedef OnAdvertReceivedCallback = void Function(Uint8List publicKey); +typedef OnPathUpdatedCallback = void Function(Uint8List publicKey); +typedef OnMessageSentCallback = void Function(int expectedAckTag, int suggestedTimeoutMs, bool isFloodMode); +typedef OnMessageDeliveredCallback = void Function(int ackCode, int roundTripTimeMs); +typedef OnStatusResponseCallback = void Function(Uint8List publicKeyPrefix, Uint8List statusData); +typedef OnBinaryResponseCallback = void Function(Uint8List publicKeyPrefix, int tag, Uint8List responseData); +typedef OnBatteryAndStorageCallback = void Function(int millivolts, int? usedKb, int? totalKb); +typedef OnErrorCallback = void Function(String error); + +/// Processes incoming responses from the BLE device +class BleResponseHandler { + StreamSubscription? _txSubscription; + final List _pendingContacts = []; + int _rxPacketCount = 0; + final List _packetLogs = []; + static const int _maxLogSize = 1000; + + // Callbacks + OnContactCallback? onContactReceived; + OnContactsCompleteCallback? onContactsComplete; + OnMessageCallback? onMessageReceived; + OnTelemetryCallback? onTelemetryReceived; + OnSelfInfoCallback? onSelfInfoReceived; + OnDeviceInfoCallback? onDeviceInfoReceived; + OnNoMoreMessagesCallback? onNoMoreMessages; + OnMessageWaitingCallback? onMessageWaiting; + OnLoginSuccessCallback? onLoginSuccess; + OnLoginFailCallback? onLoginFail; + OnAdvertReceivedCallback? onAdvertReceived; + OnPathUpdatedCallback? onPathUpdated; + OnMessageSentCallback? onMessageSent; + OnMessageDeliveredCallback? onMessageDelivered; + OnStatusResponseCallback? onStatusResponse; + OnBinaryResponseCallback? onBinaryResponse; + OnBatteryAndStorageCallback? onBatteryAndStorage; + OnErrorCallback? onError; + VoidCallback? onRxActivity; + + // Getters + int get rxPacketCount => _rxPacketCount; + List get packetLogs => List.unmodifiable(_packetLogs); + + /// Subscribe to TX characteristic notifications + void subscribeToNotifications(BluetoothCharacteristic txCharacteristic) { + _txSubscription = txCharacteristic.lastValueStream.listen( + _onDataReceived, + onError: (error) { + print('❌ [BLE] TX notification error: $error'); + onError?.call('TX notification error: $error'); + }, + ); + } + + /// Handle incoming data from TX characteristic + void _onDataReceived(List data) { + try { + // Handle empty data + if (data.isEmpty) { + print('⚠️ [RX] Empty data received, ignoring'); + return; + } + + final dataBytes = Uint8List.fromList(data); + + // Increment RX packet counter and trigger activity indicator + _rxPacketCount++; + onRxActivity?.call(); + + final reader = BufferReader(dataBytes); + final responseCode = reader.readByte(); + + // Get opcode name for logging + final opcodeName = MeshCoreOpcodeNames.getOpcodeName(responseCode, isTx: false); + final opcodeHex = '0x${responseCode.toRadixString(16).padLeft(2, '0').toUpperCase()}'; + + print('πŸ“₯ [RX] Received: $opcodeName ($opcodeHex)'); + print(' Data size: ${data.length} bytes'); + print(' Hex: ${data.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ')}'); + print(' Payload: ${reader.remainingBytesCount} bytes'); + + // Log RX packet (before processing so we capture everything) + _logPacket(dataBytes, PacketDirection.rx, responseCode: responseCode); + + switch (responseCode) { + case MeshCoreConstants.respContactsStart: + print(' β†’ Handling ContactsStart'); + _handleContactsStart(reader); + break; + case MeshCoreConstants.respContact: + print(' β†’ Handling Contact'); + _handleContact(reader); + break; + case MeshCoreConstants.respEndOfContacts: + print(' β†’ Handling EndOfContacts'); + _handleEndOfContacts(reader); + break; + case MeshCoreConstants.respSent: + print(' β†’ Handling Sent confirmation'); + _handleSentConfirmation(reader); + break; + case MeshCoreConstants.respContactMsgRecv: + print(' β†’ Handling ContactMessage'); + _handleContactMessage(reader); + break; + case MeshCoreConstants.respChannelMsgRecv: + print(' β†’ Handling ChannelMessage'); + _handleChannelMessage(reader); + break; + case MeshCoreConstants.pushTelemetryResponse: + print(' β†’ Handling TelemetryResponse'); + _handleTelemetryResponse(reader); + break; + case MeshCoreConstants.pushBinaryResponse: + print(' β†’ Handling BinaryResponse'); + _handleBinaryResponse(reader); + break; + case MeshCoreConstants.respDeviceInfo: + print(' β†’ Handling DeviceInfo'); + _handleDeviceInfo(reader); + break; + case MeshCoreConstants.respSelfInfo: + print(' β†’ Handling SelfInfo'); + _handleSelfInfo(reader); + break; + case MeshCoreConstants.pushAdvert: + print(' β†’ Handling Advert push'); + _handleAdvert(reader); + break; + case MeshCoreConstants.pushPathUpdated: + print(' β†’ Handling PathUpdated push'); + _handlePathUpdated(reader); + break; + case MeshCoreConstants.pushLogRxData: + print(' β†’ Handling LogRxData push'); + _handleLogRxData(reader); + break; + case MeshCoreConstants.pushNewAdvert: + print(' β†’ Handling NewAdvert push'); + _handleNewAdvert(reader); + break; + case MeshCoreConstants.pushSendConfirmed: + print(' β†’ Handling SendConfirmed push'); + _handleSendConfirmed(reader); + break; + case MeshCoreConstants.pushMsgWaiting: + print(' β†’ Handling MsgWaiting push'); + _handleMsgWaiting(reader); + break; + case MeshCoreConstants.pushLoginSuccess: + print(' β†’ Handling LoginSuccess push'); + _handleLoginSuccess(reader); + break; + case MeshCoreConstants.pushLoginFail: + print(' β†’ Handling LoginFail push'); + _handleLoginFail(reader); + break; + case MeshCoreConstants.pushStatusResponse: + print(' β†’ Handling StatusResponse push'); + _handleStatusResponse(reader); + break; + case MeshCoreConstants.respCurrTime: + print(' β†’ Handling CurrentTime'); + _handleCurrentTime(reader); + break; + case MeshCoreConstants.respBatteryVoltage: + print(' β†’ Handling BatteryAndStorage'); + _handleBatteryAndStorage(reader); + break; + case MeshCoreConstants.respNoMoreMessages: + print(' β†’ Response: No More Messages'); + onNoMoreMessages?.call(); + break; + case MeshCoreConstants.respOk: + print(' β†’ Response: OK'); + break; + case MeshCoreConstants.respErr: + print(' β†’ Response: ERROR'); + _handleError(reader); + break; + default: + print(' ⚠️ Unknown response code: $responseCode'); + break; + } + print('βœ… [BLE] Data parsed successfully'); + } catch (e, stackTrace) { + print('❌ [BLE] Data parsing error: $e'); + print(' Stack trace: $stackTrace'); + onError?.call('Data parsing error: $e'); + } + } + + /// Handle ContactsStart response + void _handleContactsStart(BufferReader reader) { + _pendingContacts.clear(); + FrameParser.parseContactsStart(reader); + } + + /// Handle Contact response + void _handleContact(BufferReader reader) { + try { + final contact = FrameParser.parseContact(reader); + print(' βœ… [Contact] Parsed successfully'); + _pendingContacts.add(contact); + onContactReceived?.call(contact); + } catch (e) { + print(' ❌ [Contact] Parsing error: $e'); + onError?.call('Contact parsing error: $e'); + } + } + + /// Handle EndOfContacts response + void _handleEndOfContacts(BufferReader reader) { + onContactsComplete?.call(List.from(_pendingContacts)); + _pendingContacts.clear(); + } + + /// Handle Sent confirmation response + void _handleSentConfirmation(BufferReader reader) { + try { + final result = FrameParser.parseSentConfirmation(reader); + if (result.isNotEmpty) { + print(' βœ… [Sent] Message sent successfully'); + onMessageSent?.call( + result['expectedAckTag'] as int, + result['suggestedTimeout'] as int, + result['isFloodMode'] as bool, + ); + } + } catch (e) { + print(' ❌ [Sent] Parsing error: $e'); + } + } + + /// Handle ContactMessage response + void _handleContactMessage(BufferReader reader) { + try { + final message = FrameParser.parseContactMessage(reader); + print(' βœ… [ContactMessage] Parsed successfully'); + onMessageReceived?.call(message); + } catch (e) { + print(' ❌ [ContactMessage] Parsing error: $e'); + onError?.call('Contact message parsing error: $e'); + } + } + + /// Handle ChannelMessage response + void _handleChannelMessage(BufferReader reader) { + try { + final message = FrameParser.parseChannelMessage(reader); + print(' βœ… [ChannelMessage] Parsed successfully'); + onMessageReceived?.call(message); + } catch (e) { + print(' ❌ [ChannelMessage] Parsing error: $e'); + onError?.call('Channel message parsing error: $e'); + } + } + + /// Handle TelemetryResponse push + void _handleTelemetryResponse(BufferReader reader) { + try { + final result = FrameParser.parseTelemetryResponse(reader); + print(' βœ… [Telemetry] Parsed successfully'); + onTelemetryReceived?.call( + result['publicKeyPrefix'] as Uint8List, + result['lppSensorData'] as Uint8List, + ); + } catch (e) { + print(' ❌ [Telemetry] Parsing error: $e'); + onError?.call('Telemetry parsing error: $e'); + } + } + + /// Handle BinaryResponse push + void _handleBinaryResponse(BufferReader reader) { + try { + final result = FrameParser.parseBinaryResponse(reader); + print(' βœ… [BinaryResponse] Parsed successfully'); + onBinaryResponse?.call( + result['publicKeyPrefix'] as Uint8List, + result['tag'] as int, + result['responseData'] as Uint8List, + ); + } catch (e) { + print(' ❌ [BinaryResponse] Parsing error: $e'); + onError?.call('Binary response parsing error: $e'); + } + } + + /// Handle DeviceInfo response + void _handleDeviceInfo(BufferReader reader) { + try { + final info = FrameParser.parseDeviceInfo(reader); + onDeviceInfoReceived?.call(info); + print(' βœ… [DeviceInfo] Parsed successfully'); + } catch (e) { + print(' ❌ [DeviceInfo] Parsing error: $e'); + onError?.call('DeviceInfo parsing error: $e'); + } + } + + /// Handle SelfInfo response + void _handleSelfInfo(BufferReader reader) { + try { + final info = FrameParser.parseSelfInfo(reader); + if (info.isNotEmpty) { + onSelfInfoReceived?.call(info); + } + print(' βœ… [SelfInfo] Parsed successfully'); + } catch (e) { + print(' ❌ [SelfInfo] Parsing error: $e'); + } + } + + /// Handle Advert push + void _handleAdvert(BufferReader reader) { + try { + final publicKey = FrameParser.parseAdvert(reader); + if (publicKey != null) { + onAdvertReceived?.call(publicKey); + } + print(' βœ… [Advert] Parsed successfully'); + } catch (e) { + print(' ❌ [Advert] Parsing error: $e'); + } + } + + /// Handle PathUpdated push + void _handlePathUpdated(BufferReader reader) { + try { + final publicKey = FrameParser.parsePathUpdated(reader); + if (publicKey != null) { + onPathUpdated?.call(publicKey); + } + print(' βœ… [PathUpdated] Parsed successfully'); + } catch (e) { + print(' ❌ [PathUpdated] Parsing error: $e'); + } + } + + /// Handle LogRxData push - includes extensive decoding logic + void _handleLogRxData(BufferReader reader) { + try { + print(' [LogRxData] Parsing log rx data from over-the-air packet...'); + final data = reader.readRemainingBytes(); + + if (data.length < 2) { + print(' ⚠️ [LogRxData] Insufficient data'); + return; + } + + final snrRaw = data[0]; + final snrDb = (snrRaw.toSigned(8)) / 4.0; + print(' SNR: ${snrDb.toStringAsFixed(2)} dB'); + + final rssiDbm = data[1].toSigned(8); + print(' RSSI: $rssiDbm dBm'); + + if (data.length <= 2) { + print(' ⚠️ [LogRxData] No raw packet data'); + return; + } + + final rawPacketData = data.sublist(2); + print(' Raw packet data: ${rawPacketData.length} bytes'); + + // Calculate entropy + final uniqueBytes = rawPacketData.toSet().length; + final entropy = uniqueBytes / rawPacketData.length; + final isLikelyEncrypted = entropy > 0.7; + + // Create decoded info for packet log + final logRxDataInfo = LogRxDataInfo( + entropy: entropy, + isLikelyEncrypted: isLikelyEncrypted, + ); + + // Update the most recent packet log entry + if (_packetLogs.isNotEmpty) { + final lastLog = _packetLogs.last; + if (lastLog.responseCode == MeshCoreConstants.pushLogRxData) { + _packetLogs[_packetLogs.length - 1] = BlePacketLog( + timestamp: lastLog.timestamp, + rawData: lastLog.rawData, + direction: lastLog.direction, + responseCode: lastLog.responseCode, + description: lastLog.description, + logRxDataInfo: logRxDataInfo, + ); + } + } + + print(' βœ… [LogRxData] Parsed successfully'); + } catch (e) { + print(' ❌ [LogRxData] Parsing error: $e'); + } + } + + /// Handle NewAdvert push + void _handleNewAdvert(BufferReader reader) { + try { + final contact = FrameParser.parseContact(reader); + print(' βœ… [NewAdvert] Parsed successfully'); + onContactReceived?.call(contact); + } catch (e) { + print(' ❌ [NewAdvert] Parsing error: $e'); + onError?.call('NewAdvert parsing error: $e'); + } + } + + /// Handle SendConfirmed push + void _handleSendConfirmed(BufferReader reader) { + try { + final result = FrameParser.parseSendConfirmed(reader); + if (result.isNotEmpty) { + print(' βœ… [SendConfirmed] Message delivery confirmed'); + onMessageDelivered?.call( + result['ackCode'] as int, + result['roundTripTime'] as int, + ); + } + } catch (e) { + print(' ❌ [SendConfirmed] Parsing error: $e'); + } + } + + /// Handle MsgWaiting push + void _handleMsgWaiting(BufferReader reader) { + try { + print(' [MsgWaiting] New message(s) waiting in queue'); + onMessageWaiting?.call(); + } catch (e) { + print(' ❌ [MsgWaiting] Parsing error: $e'); + } + } + + /// Handle LoginSuccess push + void _handleLoginSuccess(BufferReader reader) { + try { + final result = FrameParser.parseLoginSuccess(reader); + if (result.isNotEmpty) { + print(' βœ… [LoginSuccess] Successfully logged into room'); + onLoginSuccess?.call( + result['publicKeyPrefix'] as Uint8List, + result['permissions'] as int, + result['isAdmin'] as bool, + result['tag'] as int, + ); + } + } catch (e) { + print(' ❌ [LoginSuccess] Parsing error: $e'); + onError?.call('Login success parsing error: $e'); + } + } + + /// Handle LoginFail push + void _handleLoginFail(BufferReader reader) { + try { + final publicKeyPrefix = FrameParser.parseLoginFail(reader); + if (publicKeyPrefix != null) { + print(' ❌ [LoginFail] Failed to login to room'); + onLoginFail?.call(publicKeyPrefix); + } + } catch (e) { + print(' ❌ [LoginFail] Parsing error: $e'); + onError?.call('Login fail parsing error: $e'); + } + } + + /// Handle StatusResponse push + void _handleStatusResponse(BufferReader reader) { + try { + final result = FrameParser.parseStatusResponse(reader); + if (result.isNotEmpty) { + // Try to decode as ASCII text if printable + try { + final statusData = result['statusData'] as Uint8List; + final statusText = utf8.decode(statusData, allowMalformed: true); + if (statusText.isNotEmpty && _isPrintableAscii(statusText)) { + print(' Status data (text): $statusText'); + } + } catch (e) { + // Not text data + } + + print(' βœ… [StatusResponse] Received status response'); + onStatusResponse?.call( + result['publicKeyPrefix'] as Uint8List, + result['statusData'] as Uint8List, + ); + } + } catch (e) { + print(' ❌ [StatusResponse] Parsing error: $e'); + onError?.call('Status response parsing error: $e'); + } + } + + /// Check if a string contains only printable ASCII characters + bool _isPrintableAscii(String text) { + for (int i = 0; i < text.length; i++) { + final code = text.codeUnitAt(i); + if (code < 32 || code > 126) { + if (code != 10 && code != 13 && code != 9) { + return false; + } + } + } + return true; + } + + /// Handle CurrentTime response + void _handleCurrentTime(BufferReader reader) { + try { + final deviceTime = FrameParser.parseCurrentTime(reader); + if (deviceTime != null) { + final appTime = DateTime.now().millisecondsSinceEpoch ~/ 1000; + final drift = appTime - deviceTime; + print(' Clock drift: $drift seconds'); + } + print(' βœ… [CurrentTime] Parsed successfully'); + } catch (e) { + print(' ❌ [CurrentTime] Parsing error: $e'); + onError?.call('CurrentTime parsing error: $e'); + } + } + + /// Handle BatteryAndStorage response + void _handleBatteryAndStorage(BufferReader reader) { + try { + final result = FrameParser.parseBatteryAndStorage(reader); + if (result.isNotEmpty) { + onBatteryAndStorage?.call( + result['millivolts'] as int, + result['usedKb'] as int?, + result['totalKb'] as int?, + ); + } + print(' βœ… [BatteryAndStorage] Parsed successfully'); + } catch (e) { + print(' ❌ [BatteryAndStorage] Parsing error: $e'); + onError?.call('BatteryAndStorage parsing error: $e'); + } + } + + /// Handle Error response + void _handleError(BufferReader reader) { + try { + final errorCode = FrameParser.parseError(reader); + if (errorCode != null) { + final errorMsg = FrameParser.getErrorMessage(errorCode); + print(' ❌ [Error] $errorMsg'); + onError?.call(errorMsg); + } + } catch (e) { + print(' ❌ [Error] Parsing error: $e'); + } + } + + /// Log a packet + void _logPacket(Uint8List data, PacketDirection direction, {int? responseCode}) { + _packetLogs.add(BlePacketLog( + timestamp: DateTime.now(), + rawData: data, + direction: direction, + responseCode: responseCode, + description: _getPacketDescription(responseCode), + )); + + if (_packetLogs.length > _maxLogSize) { + _packetLogs.removeAt(0); + } + } + + /// Get human-readable description of packet + String? _getPacketDescription(int? code) { + // RX packets - response codes + switch (code) { + case 2: // respContactsStart + return 'Contacts Start'; + case 3: // respContact + return 'Contact Info'; + case 4: // respEndOfContacts + return 'End of Contacts'; + case 6: // respSent + return 'Message Sent'; + case 7: // respContactMsgRecv + return 'Contact Message'; + case 8: // respChannelMsgRecv + return 'Channel Message'; + case 0x8B: // pushTelemetryResponse + return 'Telemetry Data'; + case 13: // respDeviceInfo + return 'Device Info'; + case 5: // respSelfInfo + return 'Self Info'; + case 0x80: // pushAdvert + return 'Advertisement'; + case 0x81: // pushPathUpdated + return 'Path Updated'; + case 0x88: // pushLogRxData + return 'Log RX Data'; + case 0x8A: // pushNewAdvert + return 'New Advertisement'; + case 0x87: // pushStatusResponse + return 'Status Response'; + case 10: // respNoMoreMessages + return 'No More Messages'; + case 0: // respOk + return 'OK'; + case 1: // respErr + return 'ERROR'; + default: + return null; + } + } + + /// Reset packet counter + void resetCounter() { + _rxPacketCount = 0; + } + + /// Clear packet logs + void clearPacketLogs() { + _packetLogs.clear(); + } + + /// Dispose resources + Future dispose() async { + await _txSubscription?.cancel(); + _pendingContacts.clear(); + _packetLogs.clear(); + } +} diff --git a/lib/services/meshcore_ble_service.dart b/lib/services/meshcore_ble_service.dart index 8092759..ff9664b 100644 --- a/lib/services/meshcore_ble_service.dart +++ b/lib/services/meshcore_ble_service.dart @@ -1,16 +1,14 @@ import 'dart:async'; -import 'dart:convert'; import 'dart:typed_data'; import 'package:flutter/foundation.dart'; import 'package:flutter_blue_plus/flutter_blue_plus.dart'; import '../models/contact.dart'; -import '../models/contact_telemetry.dart'; import '../models/message.dart'; import '../models/ble_packet_log.dart'; -import 'buffer_reader.dart'; -import 'buffer_writer.dart'; -import 'meshcore_constants.dart'; -import 'meshcore_opcode_names.dart'; +import 'ble/ble_connection_manager.dart'; +import 'ble/ble_command_sender.dart'; +import 'ble/ble_response_handler.dart'; +import 'protocol/frame_builder.dart'; /// Callback types for MeshCore events typedef OnContactCallback = void Function(Contact contact); @@ -33,12 +31,12 @@ typedef OnBatteryAndStorageCallback = void Function(int millivolts, int? usedKb, typedef OnErrorCallback = void Function(String error); typedef OnConnectionStateCallback = void Function(bool isConnected); -/// MeshCore BLE Service - handles all BLE communication +/// MeshCore BLE Service - coordinates BLE communication components class MeshCoreBleService { - BluetoothDevice? _device; - BluetoothCharacteristic? _rxCharacteristic; - BluetoothCharacteristic? _txCharacteristic; - StreamSubscription? _txSubscription; + // Component instances + final BleConnectionManager _connectionManager = BleConnectionManager(); + final BleCommandSender _commandSender = BleCommandSender(); + final BleResponseHandler _responseHandler = BleResponseHandler(); // Event callbacks OnConnectionStateCallback? onConnectionStateChanged; @@ -61,1728 +59,136 @@ class MeshCoreBleService { OnBatteryAndStorageCallback? onBatteryAndStorage; OnErrorCallback? onError; - // Internal state - final List _pendingContacts = []; - bool _isConnected = false; - bool get isConnected => _isConnected; - - // Packet counters - int _rxPacketCount = 0; - int _txPacketCount = 0; - int get rxPacketCount => _rxPacketCount; - int get txPacketCount => _txPacketCount; - // Activity callbacks (for blinking indicators) VoidCallback? onRxActivity; VoidCallback? onTxActivity; - // Packet logging - final List _packetLogs = []; - List get packetLogs => List.unmodifiable(_packetLogs); - static const int _maxLogSize = 1000; // Keep last 1000 packets + // Constructor + MeshCoreBleService() { + _setupCallbacks(); + } + + // Setup callbacks between components + void _setupCallbacks() { + // Connection manager callbacks + _connectionManager.onConnectionStateChanged = (isConnected) { + onConnectionStateChanged?.call(isConnected); + }; + _connectionManager.onError = (error) { + onError?.call(error); + }; + + // Command sender callbacks + _commandSender.onError = (error) { + onError?.call(error); + }; + _commandSender.onTxActivity = () { + onTxActivity?.call(); + }; + + // Response handler callbacks + _responseHandler.onContactReceived = (contact) { + onContactReceived?.call(contact); + }; + _responseHandler.onContactsComplete = (contacts) { + onContactsComplete?.call(contacts); + }; + _responseHandler.onMessageReceived = (message) { + onMessageReceived?.call(message); + }; + _responseHandler.onTelemetryReceived = (publicKey, lppData) { + onTelemetryReceived?.call(publicKey, lppData); + }; + _responseHandler.onSelfInfoReceived = (selfInfo) { + onSelfInfoReceived?.call(selfInfo); + }; + _responseHandler.onDeviceInfoReceived = (deviceInfo) { + onDeviceInfoReceived?.call(deviceInfo); + }; + _responseHandler.onNoMoreMessages = () { + onNoMoreMessages?.call(); + }; + _responseHandler.onMessageWaiting = () { + onMessageWaiting?.call(); + }; + _responseHandler.onLoginSuccess = (publicKeyPrefix, permissions, isAdmin, tag) { + onLoginSuccess?.call(publicKeyPrefix, permissions, isAdmin, tag); + }; + _responseHandler.onLoginFail = (publicKeyPrefix) { + onLoginFail?.call(publicKeyPrefix); + }; + _responseHandler.onAdvertReceived = (publicKey) { + onAdvertReceived?.call(publicKey); + }; + _responseHandler.onPathUpdated = (publicKey) { + onPathUpdated?.call(publicKey); + }; + _responseHandler.onMessageSent = (expectedAckTag, suggestedTimeoutMs, isFloodMode) { + onMessageSent?.call(expectedAckTag, suggestedTimeoutMs, isFloodMode); + }; + _responseHandler.onMessageDelivered = (ackCode, roundTripTimeMs) { + onMessageDelivered?.call(ackCode, roundTripTimeMs); + }; + _responseHandler.onStatusResponse = (publicKeyPrefix, statusData) { + onStatusResponse?.call(publicKeyPrefix, statusData); + }; + _responseHandler.onBinaryResponse = (publicKeyPrefix, tag, responseData) { + onBinaryResponse?.call(publicKeyPrefix, tag, responseData); + }; + _responseHandler.onBatteryAndStorage = (millivolts, usedKb, totalKb) { + onBatteryAndStorage?.call(millivolts, usedKb, totalKb); + }; + _responseHandler.onError = (error) { + onError?.call(error); + }; + _responseHandler.onRxActivity = () { + onRxActivity?.call(); + }; + } + + // Getters + bool get isConnected => _connectionManager.isConnected; + int get rxPacketCount => _responseHandler.rxPacketCount; + int get txPacketCount => _commandSender.txPacketCount; + List get packetLogs { + // Merge logs from both sender and handler + final allLogs = [..._commandSender.packetLogs, ..._responseHandler.packetLogs]; + allLogs.sort((a, b) => a.timestamp.compareTo(b.timestamp)); + return allLogs; + } /// Scan for MeshCore devices - Stream scanForDevices({Duration timeout = const Duration(seconds: 10)}) async* { - try { - print('πŸ” [BLE] Starting scan for MeshCore devices...'); - print(' Service UUID: ${MeshCoreConstants.bleServiceUuid}'); - print(' Timeout: ${timeout.inSeconds}s'); - - // Start scanning - await FlutterBluePlus.startScan( - timeout: timeout, - withServices: [Guid(MeshCoreConstants.bleServiceUuid)], - ); - print('βœ… [BLE] Scan started successfully'); - - // Listen to scan results - int deviceCount = 0; - await for (final scanResult in FlutterBluePlus.scanResults) { - print('πŸ“‘ [BLE] Scan results batch received: ${scanResult.length} results'); - for (final result in scanResult) { - print(' Device: ${result.device.platformName} (${result.device.remoteId})'); - print(' RSSI: ${result.rssi}'); - print(' Service UUIDs: ${result.advertisementData.serviceUuids}'); - - if (result.advertisementData.serviceUuids - .contains(Guid(MeshCoreConstants.bleServiceUuid))) { - deviceCount++; - print(' βœ… MeshCore device found! Total: $deviceCount'); - yield result.device; - } else { - print(' ❌ Not a MeshCore device (service UUID mismatch)'); - } - } - } - print('🏁 [BLE] Scan completed. Found $deviceCount MeshCore devices'); - } catch (e) { - print('❌ [BLE] Scan error: $e'); - onError?.call('Scan error: $e'); - } + Stream scanForDevices({Duration timeout = const Duration(seconds: 10)}) { + return _connectionManager.scanForDevices(timeout: timeout); } /// Connect to a MeshCore device Future connect(BluetoothDevice device) async { - try { - print('πŸ”΅ [BLE] Starting connection to device: ${device.platformName} (${device.remoteId})'); - _device = device; + final success = await _connectionManager.connect(device); + if (success) { + // Setup command sender with RX characteristic + _commandSender.setRxCharacteristic(_connectionManager.rxCharacteristic); - // Connect to device - print('πŸ”΅ [BLE] Calling device.connect() with 15s timeout...'); - await device.connect( - license: License.free, - timeout: const Duration(seconds: 15), - mtu: 512, - ); - print('βœ… [BLE] Device connected successfully'); - - // Discover services - print('πŸ”΅ [BLE] Discovering services...'); - final services = await device.discoverServices(); - print('βœ… [BLE] Found ${services.length} services'); - - // Log all discovered services for debugging - for (final service in services) { - print(' πŸ“‹ Service: ${service.uuid}'); - for (final char in service.characteristics) { - print(' - Characteristic: ${char.uuid}'); - } + // Setup response handler with TX characteristic + if (_connectionManager.txCharacteristic != null) { + _responseHandler.subscribeToNotifications(_connectionManager.txCharacteristic!); } - // Find MeshCore service - print('πŸ”΅ [BLE] Looking for MeshCore service: ${MeshCoreConstants.bleServiceUuid}'); - BluetoothService? meshCoreService; - for (final service in services) { - if (service.uuid.toString().toLowerCase() == - MeshCoreConstants.bleServiceUuid.toLowerCase()) { - meshCoreService = service; - print('βœ… [BLE] Found MeshCore service'); - break; - } - } - - if (meshCoreService == null) { - print('❌ [BLE] MeshCore service not found!'); - throw Exception('MeshCore service not found'); - } - - // Find RX and TX characteristics - print('πŸ”΅ [BLE] Looking for RX and TX characteristics...'); - print(' RX UUID: ${MeshCoreConstants.bleCharacteristicRxUuid}'); - print(' TX UUID: ${MeshCoreConstants.bleCharacteristicTxUuid}'); - - for (final characteristic in meshCoreService.characteristics) { - final uuid = characteristic.uuid.toString().toLowerCase(); - print(' πŸ“‹ Checking characteristic: $uuid'); - - if (uuid == MeshCoreConstants.bleCharacteristicRxUuid.toLowerCase()) { - _rxCharacteristic = characteristic; - print(' βœ… Found RX characteristic'); - } else if (uuid == - MeshCoreConstants.bleCharacteristicTxUuid.toLowerCase()) { - _txCharacteristic = characteristic; - print(' βœ… Found TX characteristic'); - } - } - - if (_rxCharacteristic == null || _txCharacteristic == null) { - print('❌ [BLE] Required characteristics not found!'); - print(' RX found: ${_rxCharacteristic != null}'); - print(' TX found: ${_txCharacteristic != null}'); - throw Exception('Required characteristics not found'); - } - - // Enable notifications on TX characteristic - print('πŸ”΅ [BLE] Enabling notifications on TX characteristic...'); - await _txCharacteristic!.setNotifyValue(true); - print('βœ… [BLE] Notifications enabled'); - - // Listen to TX characteristic - print('πŸ”΅ [BLE] Setting up TX characteristic listener...'); - _txSubscription = _txCharacteristic!.lastValueStream.listen( - _onDataReceived, - onError: (error) { - print('❌ [BLE] TX notification error: $error'); - onError?.call('TX notification error: $error'); - }, - ); - print('βœ… [BLE] TX listener configured'); - - _isConnected = true; - print('πŸ”΅ [BLE] Notifying connection state change: connected'); - onConnectionStateChanged?.call(true); - // Send initial device query - print('πŸ”΅ [BLE] Sending initial device query...'); await _sendDeviceQuery(); - print('βœ… [BLE] Device query sent'); - - print('βœ…βœ…βœ… [BLE] Connection completed successfully!'); - return true; - } catch (e) { - print('❌❌❌ [BLE] Connection failed: $e'); - print('Stack trace: ${StackTrace.current}'); - onError?.call('Connection error: $e'); - _isConnected = false; - onConnectionStateChanged?.call(false); - return false; } + return success; } /// Disconnect from device Future disconnect() async { - try { - await _txSubscription?.cancel(); - await _device?.disconnect(); - _isConnected = false; - _device = null; - _rxCharacteristic = null; - _txCharacteristic = null; - onConnectionStateChanged?.call(false); - } catch (e) { - onError?.call('Disconnect error: $e'); - } + await _connectionManager.disconnect(); } - /// Write data to RX characteristic - Future _writeData(Uint8List data) async { - if (_rxCharacteristic == null) { - throw Exception('Not connected'); - } - try { - // Extract command code from first byte - final commandCode = data.isNotEmpty ? data[0] : null; - final opcodeName = commandCode != null - ? MeshCoreOpcodeNames.getCommandName(commandCode) - : 'UNKNOWN'; - final opcodeHex = commandCode != null - ? '0x${commandCode.toRadixString(16).padLeft(2, '0').toUpperCase()}' - : 'N/A'; - - print('πŸ“€ [TX] Sending command: $opcodeName ($opcodeHex)'); - print(' Data size: ${data.length} bytes'); - print(' Hex: ${data.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ')}'); - - // Check if the characteristic supports write without response - final supportsWriteWithoutResponse = _rxCharacteristic!.properties.writeWithoutResponse; - final supportsWrite = _rxCharacteristic!.properties.write; - - if (supportsWriteWithoutResponse) { - await _rxCharacteristic!.write(data, withoutResponse: true); - } else if (supportsWrite) { - await _rxCharacteristic!.write(data, withoutResponse: false); - } else { - throw Exception('Characteristic does not support write operations'); - } - - // Log TX packet - _logPacket(data, PacketDirection.tx, responseCode: commandCode); - - // Increment TX packet counter and trigger activity indicator - _txPacketCount++; - onTxActivity?.call(); - - print('βœ… [TX] Command sent successfully'); - } catch (e) { - print('❌ [TX] Write error: $e'); - onError?.call('Write error: $e'); - rethrow; - } - } - - /// Handle incoming data from TX characteristic - void _onDataReceived(List data) { - try { - // Handle empty data - if (data.isEmpty) { - print('⚠️ [RX] Empty data received, ignoring'); - return; - } - - final dataBytes = Uint8List.fromList(data); - - // Increment RX packet counter and trigger activity indicator - _rxPacketCount++; - onRxActivity?.call(); - - final reader = BufferReader(dataBytes); - final responseCode = reader.readByte(); - - // Get opcode name for logging - final opcodeName = MeshCoreOpcodeNames.getOpcodeName(responseCode, isTx: false); - final opcodeHex = '0x${responseCode.toRadixString(16).padLeft(2, '0').toUpperCase()}'; - - print('πŸ“₯ [RX] Received: $opcodeName ($opcodeHex)'); - print(' Data size: ${data.length} bytes'); - print(' Hex: ${data.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ')}'); - print(' Payload: ${reader.remainingBytesCount} bytes'); - - // Log RX packet (before processing so we capture everything) - _logPacket(dataBytes, PacketDirection.rx, responseCode: responseCode); - - switch (responseCode) { - case MeshCoreConstants.respContactsStart: - print(' β†’ Handling ContactsStart'); - _handleContactsStart(reader); - break; - case MeshCoreConstants.respContact: - print(' β†’ Handling Contact'); - _handleContact(reader); - break; - case MeshCoreConstants.respEndOfContacts: - print(' β†’ Handling EndOfContacts'); - _handleEndOfContacts(reader); - break; - case MeshCoreConstants.respSent: - print(' β†’ Handling Sent confirmation'); - _handleSentConfirmation(reader); - break; - case MeshCoreConstants.respContactMsgRecv: - print(' β†’ Handling ContactMessage'); - _handleContactMessage(reader); - break; - case MeshCoreConstants.respChannelMsgRecv: - print(' β†’ Handling ChannelMessage'); - _handleChannelMessage(reader); - break; - case MeshCoreConstants.pushTelemetryResponse: - print(' β†’ Handling TelemetryResponse'); - _handleTelemetryResponse(reader); - break; - case MeshCoreConstants.pushBinaryResponse: - print(' β†’ Handling BinaryResponse'); - _handleBinaryResponse(reader); - break; - case MeshCoreConstants.respDeviceInfo: - print(' β†’ Handling DeviceInfo'); - _handleDeviceInfo(reader); - break; - case MeshCoreConstants.respSelfInfo: - print(' β†’ Handling SelfInfo'); - _handleSelfInfo(reader); - break; - case MeshCoreConstants.pushAdvert: - print(' β†’ Handling Advert push'); - _handleAdvert(reader); - break; - case MeshCoreConstants.pushPathUpdated: - print(' β†’ Handling PathUpdated push'); - _handlePathUpdated(reader); - break; - case MeshCoreConstants.pushLogRxData: - print(' β†’ Handling LogRxData push'); - _handleLogRxData(reader); - break; - case MeshCoreConstants.pushNewAdvert: - print(' β†’ Handling NewAdvert push'); - _handleNewAdvert(reader); - break; - case MeshCoreConstants.pushSendConfirmed: - print(' β†’ Handling SendConfirmed push'); - _handleSendConfirmed(reader); - break; - case MeshCoreConstants.pushMsgWaiting: - print(' β†’ Handling MsgWaiting push'); - _handleMsgWaiting(reader); - break; - case MeshCoreConstants.pushLoginSuccess: - print(' β†’ Handling LoginSuccess push'); - _handleLoginSuccess(reader); - break; - case MeshCoreConstants.pushLoginFail: - print(' β†’ Handling LoginFail push'); - _handleLoginFail(reader); - break; - case MeshCoreConstants.pushStatusResponse: - print(' β†’ Handling StatusResponse push'); - _handleStatusResponse(reader); - break; - case MeshCoreConstants.respCurrTime: - print(' β†’ Handling CurrentTime'); - _handleCurrentTime(reader); - break; - case MeshCoreConstants.respBatteryVoltage: - print(' β†’ Handling BatteryAndStorage'); - _handleBatteryAndStorage(reader); - break; - case MeshCoreConstants.respNoMoreMessages: - print(' β†’ Response: No More Messages'); - onNoMoreMessages?.call(); - break; - case MeshCoreConstants.respOk: - print(' β†’ Response: OK'); - break; - case MeshCoreConstants.respErr: - print(' β†’ Response: ERROR'); - _handleError(reader); - break; - default: - print(' ⚠️ Unknown response code: $responseCode'); - break; - } - print('βœ… [BLE] Data parsed successfully'); - } catch (e, stackTrace) { - print('❌ [BLE] Data parsing error: $e'); - print(' Stack trace: $stackTrace'); - onError?.call('Data parsing error: $e'); - } - } - - /// Handle ContactsStart response - void _handleContactsStart(BufferReader reader) { - _pendingContacts.clear(); - final count = reader.readUInt32LE(); - // Optional: notify about expected count - } - - /// Handle Contact response - void _handleContact(BufferReader reader) { - try { - print(' [Contact] Parsing contact...'); - print(' Remaining bytes: ${reader.remainingBytesCount}'); - - final publicKey = reader.readBytes(32); - print(' Public key prefix: ${publicKey.sublist(0, 6).map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}'); - - final typeByte = reader.readByte(); - final type = ContactType.fromValue(typeByte); - print(' Type byte: $typeByte β†’ Type: $type'); - - final flags = reader.readByte(); - print(' Flags: $flags (0x${flags.toRadixString(16).padLeft(2, '0')})'); - - final outPathLen = reader.readInt8(); - print(' Out path length: $outPathLen'); - - final outPath = reader.readBytes(64); - print(' Out path: ${outPath.sublist(0, 6).map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}...'); - - final advName = reader.readCString(32); - print(' Advertised name: "$advName"'); - - final lastAdvert = reader.readUInt32LE(); - print(' Last advert timestamp: $lastAdvert'); - - final advLat = reader.readInt32LE(); - print(' Latitude (raw int32): $advLat'); - print(' Latitude (decimal): ${advLat / 1000000.0}Β°'); - - final advLon = reader.readInt32LE(); - print(' Longitude (raw int32): $advLon'); - print(' Longitude (decimal): ${advLon / 1000000.0}Β°'); - - final lastMod = reader.readUInt32LE(); - print(' Last modified timestamp: $lastMod'); - - final contact = Contact( - publicKey: publicKey, - type: type, - flags: flags, - outPathLen: outPathLen, - outPath: outPath, - advName: advName, - lastAdvert: lastAdvert, - advLat: advLat, - advLon: advLon, - lastMod: lastMod, - ); - - print(' βœ… [Contact] Parsed successfully'); - _pendingContacts.add(contact); - onContactReceived?.call(contact); - } catch (e) { - print(' ❌ [Contact] Parsing error: $e'); - onError?.call('Contact parsing error: $e'); - } - } - - /// Handle EndOfContacts response - void _handleEndOfContacts(BufferReader reader) { - onContactsComplete?.call(List.from(_pendingContacts)); - _pendingContacts.clear(); - } - - /// Handle Sent confirmation response (RESP_CODE_SENT) - /// - /// Protocol format: - /// - 1 byte: send type (1=flood, 0=direct) - /// - 4 bytes: expected ACK code or TAG - /// - 4 bytes: suggested timeout (uint32, milliseconds) - void _handleSentConfirmation(BufferReader reader) { - try { - print(' [Sent] Parsing sent confirmation...'); - print(' Remaining bytes: ${reader.remainingBytesCount}'); - - if (reader.remainingBytesCount >= 9) { - final sendType = reader.readByte(); - final sendTypeStr = sendType == 1 ? 'flood' : 'direct'; - final isFloodMode = sendType == 1; - print(' Send type: $sendType ($sendTypeStr)'); - - final expectedAckOrTagBytes = reader.readBytes(4); - final expectedAckTag = ByteData.sublistView(Uint8List.fromList(expectedAckOrTagBytes)).getUint32(0, Endian.little); - print(' Expected ACK/TAG: ${expectedAckOrTagBytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ')} (uint32: $expectedAckTag)'); - - final suggestedTimeout = reader.readUInt32LE(); - print(' Suggested timeout: ${suggestedTimeout}ms'); - - print(' βœ… [Sent] Message sent successfully ($sendTypeStr mode, timeout: ${suggestedTimeout}ms)'); - - // Notify provider that message was sent - onMessageSent?.call(expectedAckTag, suggestedTimeout, isFloodMode); - } else { - print(' ⚠️ [Sent] Insufficient data for full parsing'); - } - } catch (e) { - print(' ❌ [Sent] Parsing error: $e'); - // Don't call onError - sent confirmations are informational - } - } - - /// Handle ContactMsgRecv response - void _handleContactMessage(BufferReader reader) { - try { - print(' [ContactMessage] Parsing contact message...'); - print(' Remaining bytes: ${reader.remainingBytesCount}'); - - final pubKeyPrefix = reader.readBytes(6); - print(' Sender public key prefix: ${pubKeyPrefix.map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}'); - - final pathLen = reader.readByte(); - print(' Path length: $pathLen'); - - final txtTypeByte = reader.readByte(); - final txtType = MessageTextType.fromValue(txtTypeByte); - print(' Text type byte: $txtTypeByte β†’ Type: $txtType'); - - final senderTimestamp = reader.readUInt32LE(); - print(' Sender timestamp: $senderTimestamp (${DateTime.fromMillisecondsSinceEpoch(senderTimestamp * 1000)})'); - - // Handle different message types - String text; - Uint8List? senderPrefixExtra; - - if (txtType == MessageTextType.signedPlain) { - // Signed message format: [4-byte sender prefix][UTF-8 text] - // Note: Despite the name "signed", this doesn't contain a cryptographic signature - // It contains 4 extra bytes of the sender's public key prefix for verification - print(' Signed message detected - extracting extra sender prefix'); - - if (reader.remainingBytesCount >= 4) { - senderPrefixExtra = reader.readBytes(4); - print(' Extra sender prefix (4 bytes): ${senderPrefixExtra.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ')}'); - - // Remaining bytes are the actual text - if (reader.hasRemaining) { - text = reader.readString(); - } else { - text = ''; - print(' ⚠️ No text content after sender prefix'); - } - } else { - print(' ⚠️ Insufficient bytes for sender prefix (${reader.remainingBytesCount} < 4)'); - // Read remaining bytes as text anyway - text = reader.readString(); - } - } else { - // Plain text message - text = reader.readString(); - } - - print(' Text: "$text"'); - - final message = Message( - id: '${DateTime.now().millisecondsSinceEpoch}_${pubKeyPrefix.map((b) => b.toRadixString(16)).join()}', - messageType: MessageType.contact, - senderPublicKeyPrefix: pubKeyPrefix, - pathLen: pathLen, - textType: txtType, - senderTimestamp: senderTimestamp, - text: text, - receivedAt: DateTime.now(), - ); - - print(' βœ… [ContactMessage] Parsed successfully'); - onMessageReceived?.call(message); - } catch (e) { - print(' ❌ [ContactMessage] Parsing error: $e'); - onError?.call('Contact message parsing error: $e'); - } - } - - /// Handle ChannelMsgRecv response - void _handleChannelMessage(BufferReader reader) { - try { - print(' [ChannelMessage] Parsing channel message...'); - print(' Remaining bytes: ${reader.remainingBytesCount}'); - - final channelIdx = reader.readInt8(); - print(' Channel index: $channelIdx'); - - final pathLen = reader.readByte(); - print(' Path length: $pathLen'); - - final txtTypeByte = reader.readByte(); - final txtType = MessageTextType.fromValue(txtTypeByte); - print(' Text type byte: $txtTypeByte β†’ Type: $txtType'); - - final senderTimestamp = reader.readUInt32LE(); - print(' Sender timestamp: $senderTimestamp (${DateTime.fromMillisecondsSinceEpoch(senderTimestamp * 1000)})'); - - // Handle different message types - String text; - Uint8List? senderPrefixExtra; - - if (txtType == MessageTextType.signedPlain) { - // Signed message format: [4-byte sender prefix][UTF-8 text] - // Note: Despite the name "signed", this doesn't contain a cryptographic signature - // It contains 4 extra bytes of the sender's public key prefix for verification - print(' Signed message detected - extracting extra sender prefix'); - - if (reader.remainingBytesCount >= 4) { - senderPrefixExtra = reader.readBytes(4); - print(' Extra sender prefix (4 bytes): ${senderPrefixExtra.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ')}'); - - // Remaining bytes are the actual text - if (reader.hasRemaining) { - text = reader.readString(); - } else { - text = ''; - print(' ⚠️ No text content after sender prefix'); - } - } else { - print(' ⚠️ Insufficient bytes for sender prefix (${reader.remainingBytesCount} < 4)'); - // Read remaining bytes as text anyway - text = reader.readString(); - } - } else { - // Plain text message - text = reader.readString(); - } - - print(' Text: "$text"'); - - final message = Message( - id: '${DateTime.now().millisecondsSinceEpoch}_ch$channelIdx', - messageType: MessageType.channel, - channelIdx: channelIdx, - pathLen: pathLen, - textType: txtType, - senderTimestamp: senderTimestamp, - text: text, - receivedAt: DateTime.now(), - ); - - print(' βœ… [ChannelMessage] Parsed successfully'); - onMessageReceived?.call(message); - } catch (e) { - print(' ❌ [ChannelMessage] Parsing error: $e'); - onError?.call('Channel message parsing error: $e'); - } - } - - /// Handle TelemetryResponse push - void _handleTelemetryResponse(BufferReader reader) { - try { - print(' [Telemetry] Parsing telemetry response...'); - print(' Remaining bytes: ${reader.remainingBytesCount}'); - - final reserved = reader.readByte(); - print(' Reserved byte: $reserved'); - - final pubKeyPrefix = reader.readBytes(6); - print(' Public key prefix: ${pubKeyPrefix.map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}'); - - final lppSensorData = reader.readRemainingBytes(); - print(' LPP sensor data length: ${lppSensorData.length} bytes'); - print(' LPP sensor data (hex): ${lppSensorData.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ')}'); - - print(' βœ… [Telemetry] Parsed successfully'); - onTelemetryReceived?.call(pubKeyPrefix, lppSensorData); - } catch (e) { - print(' ❌ [Telemetry] Parsing error: $e'); - onError?.call('Telemetry parsing error: $e'); - } - } - - /// Handle BinaryResponse push (PUSH_CODE_BINARY_RESPONSE 0x8C) - /// - /// Protocol format: - /// - 1 byte: reserved (zero) - /// - 4 bytes: tag (uint32, matches RESP_CODE_SENT expected_ack_or_tag) - /// - N bytes: response data (remainder of frame) - void _handleBinaryResponse(BufferReader reader) { - try { - print(' [BinaryResponse] Parsing binary response...'); - print(' Remaining bytes: ${reader.remainingBytesCount}'); - - final reserved = reader.readByte(); - print(' Reserved byte: $reserved'); - - final tag = reader.readUInt32LE(); - print(' Tag: $tag (matches RESP_CODE_SENT expected_ack_or_tag)'); - - final responseData = reader.readRemainingBytes(); - print(' Response data length: ${responseData.length} bytes'); - print(' Response data (hex): ${responseData.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ')}'); - - // Extract public key prefix from response data if present - // Note: The firmware doesn't include the sender's public key prefix in binary responses - // The app must track which request corresponds to which tag - // For now, we'll use an empty prefix and rely on the tag for matching - final emptyPrefix = Uint8List(6); - - print(' βœ… [BinaryResponse] Parsed successfully'); - onBinaryResponse?.call(emptyPrefix, tag, responseData); - } catch (e) { - print(' ❌ [BinaryResponse] Parsing error: $e'); - onError?.call('Binary response parsing error: $e'); - } - } - - /// Handle DeviceInfo response - /// Handle DeviceInfo response (RESP_CODE_DEVICE_INFO) - /// - /// Protocol format: - /// - 1 byte: firmware version - /// - 1 byte: max contacts Γ· 2 (ver 3+) - /// - 1 byte: max channels (ver 3+) - /// - 4 bytes: BLE PIN (uint32, ver 3+) - /// - 12 bytes: firmware build date (ASCII null-terminated) - /// - 40 bytes: manufacturer model (ASCII null-terminated) - /// - 20 bytes: semantic version (ASCII null-terminated) - void _handleDeviceInfo(BufferReader reader) { - try { - print(' [DeviceInfo] Parsing device info...'); - print(' Remaining bytes: ${reader.remainingBytesCount}'); - - if (reader.remainingBytesCount < 1) { - print(' [DeviceInfo] No data to parse'); - return; - } - - final firmwareVersion = reader.readByte(); - print(' Firmware version: $firmwareVersion'); - - int? maxContacts; - int? maxChannels; - int? blePin; - if (reader.remainingBytesCount >= 6) { - final maxContactsDiv2 = reader.readByte(); - maxContacts = maxContactsDiv2 * 2; - print(' Max contacts: $maxContacts'); - - maxChannels = reader.readByte(); - print(' Max channels: $maxChannels'); - - blePin = reader.readUInt32LE(); - print(' BLE PIN: $blePin'); - } - - String? firmwareBuildDate; - if (reader.remainingBytesCount >= 12) { - final buildDateBytes = reader.readBytes(12); - firmwareBuildDate = String.fromCharCodes(buildDateBytes.takeWhile((b) => b != 0)); - print(' Firmware build date: "$firmwareBuildDate"'); - } - - String? manufacturerModel; - if (reader.remainingBytesCount >= 40) { - final modelBytes = reader.readBytes(40); - manufacturerModel = String.fromCharCodes(modelBytes.takeWhile((b) => b != 0)); - print(' Manufacturer model: "$manufacturerModel"'); - } - - String? semanticVersion; - if (reader.remainingBytesCount >= 20) { - final versionBytes = reader.readBytes(20); - semanticVersion = String.fromCharCodes(versionBytes.takeWhile((b) => b != 0)); - print(' Semantic version: "$semanticVersion"'); - } - - // Call callback with parsed data - onDeviceInfoReceived?.call({ - 'firmwareVersion': firmwareVersion, - 'maxContacts': maxContacts, - 'maxChannels': maxChannels, - 'blePin': blePin, - 'firmwareBuildDate': firmwareBuildDate, - 'manufacturerModel': manufacturerModel, - 'semanticVersion': semanticVersion, - }); - - print(' βœ… [DeviceInfo] Parsed successfully'); - } catch (e) { - print(' ❌ [DeviceInfo] Parsing error: $e'); - onError?.call('DeviceInfo parsing error: $e'); - } - } - - /// Handle SelfInfo response - void _handleSelfInfo(BufferReader reader) { - try { - print(' [SelfInfo] Parsing self info...'); - print(' Remaining bytes: ${reader.remainingBytesCount}'); - - // SelfInfo format (RESP_CODE_SELF_INFO): - // - 1 byte: type (ADV_TYPE_*) - // - 1 byte: tx power (dBm, current) - // - 1 byte: max tx power (dBm, max radio supports) - // - 32 bytes: public key - // - 4 bytes: adv lat * 1E6 (int32) - // - 4 bytes: adv lon * 1E6 (int32) - // - 1 byte: multi ACKs (0=no extra, 1=send extra ACK) - // - 1 byte: advert location policy (0=don't share, 1=share) - // - 1 byte: telemetry modes (bits 0-1: Base, bits 2-3: Location) - // - 1 byte: manual add contacts (0 or 1) - // - 4 bytes: radio freq * 1000 (uint32) - // - 4 bytes: radio bw (kHz) * 1000 (uint32) - // - 1 byte: spreading factor - // - 1 byte: coding rate - // - remaining: self name (null-terminated varchar) - - if (reader.remainingBytesCount < 54) { - print(' [SelfInfo] Insufficient data: ${reader.remainingBytesCount} bytes'); - // Just consume remaining bytes to avoid errors - reader.readRemainingBytes(); - return; - } - - print(' πŸ“ BYTE-BY-BYTE PARSING DEBUG:'); - print(' Position before reads: offset=0, remaining=${reader.remainingBytesCount}'); - - // NO protocol version byte - it starts with device type! - final deviceType = reader.readByte(); - print(' [Byte 0] Device type: $deviceType (0x${deviceType.toRadixString(16).padLeft(2, '0')})'); - - final txPower = reader.readByte(); - print(' [Byte 1] TX power: $txPower dBm (0x${txPower.toRadixString(16).padLeft(2, '0')})'); - - final maxTxPower = reader.readByte(); - print(' [Byte 2] Max TX power: $maxTxPower dBm (0x${maxTxPower.toRadixString(16).padLeft(2, '0')})'); - - final publicKey = reader.readBytes(32); - print(' [Bytes 3-34] Public key (32 bytes): ${publicKey.sublist(0, 8).map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ')}...'); - - final advLatBytes = reader.readBytes(4); - final advLat = ByteData.sublistView(Uint8List.fromList(advLatBytes)).getInt32(0, Endian.little); - print(' [Bytes 35-38] Adv Lat (raw bytes): ${advLatBytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ')}'); - print(' [Bytes 35-38] Adv Lat (int32 LE): $advLat'); - print(' [Bytes 35-38] Adv Lat (decimal): ${advLat / 1000000.0}Β°'); - - final advLonBytes = reader.readBytes(4); - final advLon = ByteData.sublistView(Uint8List.fromList(advLonBytes)).getInt32(0, Endian.little); - print(' [Bytes 39-42] Adv Lon (raw bytes): ${advLonBytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ')}'); - print(' [Bytes 39-42] Adv Lon (int32 LE): $advLon'); - print(' [Bytes 39-42] Adv Lon (decimal): ${advLon / 1000000.0}Β°'); - - final multiAcks = reader.readByte(); - print(' [Byte 43] Multi ACKs: $multiAcks (0x${multiAcks.toRadixString(16).padLeft(2, '0')})'); - - final advertLocPolicy = reader.readByte(); - print(' [Byte 44] Advert Loc Policy: $advertLocPolicy (0x${advertLocPolicy.toRadixString(16).padLeft(2, '0')})'); - - final telemetryModes = reader.readByte(); - print(' [Byte 45] Telemetry Modes: $telemetryModes (0x${telemetryModes.toRadixString(16).padLeft(2, '0')})'); - - final manualAddContacts = reader.readByte(); - print(' [Byte 46] Manual Add Contacts: $manualAddContacts (0x${manualAddContacts.toRadixString(16).padLeft(2, '0')})'); - - final radioFreqBytes = reader.readBytes(4); - final radioFreq = ByteData.sublistView(Uint8List.fromList(radioFreqBytes)).getUint32(0, Endian.little); - print(' [Bytes 47-50] Radio Freq (raw bytes): ${radioFreqBytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ')}'); - print(' [Bytes 47-50] Radio Freq (uint32 LE): $radioFreq'); - print(' [Bytes 47-50] Radio Freq (MHz): ${radioFreq / 1000.0}'); - - final radioBwBytes = reader.readBytes(4); - final radioBw = ByteData.sublistView(Uint8List.fromList(radioBwBytes)).getUint32(0, Endian.little); - print(' [Bytes 51-54] Radio BW (raw bytes): ${radioBwBytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ')}'); - print(' [Bytes 51-54] Radio BW (uint32 LE): $radioBw'); - print(' [Bytes 51-54] Radio BW (kHz): ${radioBw / 1000.0}'); - - final radioSf = reader.readByte(); - print(' [Byte 55] Radio SF: $radioSf (0x${radioSf.toRadixString(16).padLeft(2, '0')})'); - - final radioCr = reader.readByte(); - print(' [Byte 56] Radio CR: $radioCr (0x${radioCr.toRadixString(16).padLeft(2, '0')})'); - - print(' Remaining bytes after radio params: ${reader.remainingBytesCount}'); - - String? selfName; - if (reader.hasRemaining) { - final nameBytes = reader.readRemainingBytes(); - print(' Self name bytes (hex): ${nameBytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ')}'); - print(' Self name bytes (ASCII): ${nameBytes.map((b) => b >= 32 && b <= 126 ? String.fromCharCode(b) : '.')}'); - selfName = String.fromCharCodes(nameBytes.takeWhile((b) => b != 0)); - print(' Self name (parsed): "$selfName"'); - } - - print(' βœ… PARSED SUMMARY:'); - print(' Type: $deviceType'); - print(' TX Power: $txPower / $maxTxPower dBm'); - print(' Position: ${advLat / 1000000.0}Β°, ${advLon / 1000000.0}Β°'); - print(' Flags: multiAcks=$multiAcks, locPolicy=$advertLocPolicy, telemetry=$telemetryModes, manual=$manualAddContacts'); - print(' Radio: freq=${radioFreq / 1000.0} MHz, bw=${radioBw / 1000.0} kHz, sf=$radioSf, cr=$radioCr'); - print(' Name: "$selfName"'); - - // Call callback with parsed data - onSelfInfoReceived?.call({ - 'deviceType': deviceType, - 'txPower': txPower, - 'maxTxPower': maxTxPower, - 'publicKey': publicKey, - 'advLat': advLat, - 'advLon': advLon, - 'manualAddContacts': manualAddContacts == 1, - 'radioFreq': radioFreq, - 'radioBw': radioBw, - 'radioSf': radioSf, - 'radioCr': radioCr, - 'selfName': selfName, - }); - - print(' βœ… [SelfInfo] Parsed successfully'); - } catch (e) { - print(' ❌ [SelfInfo] Parsing error: $e'); - // Don't call onError for self info - it's not critical - } - } - - /// Handle Advert push (PUSH_CODE_ADVERT) - /// - /// This push notification indicates that a node in the mesh network - /// has broadcast an advertisement packet. The companion radio received - /// this over-the-air and is notifying the app. - /// - /// Protocol format: - /// - 32 bytes: public key of the advertising node - /// - /// Note: This is a passive notification - the companion radio handles - /// updating the contact automatically. The app can use this to show - /// real-time network activity or trigger UI updates. - /// - /// Behavior: - /// - If manual_add_contacts=0: Companion radio auto-updates contact, then sends PUSH_CODE_NEW_ADVERT with full details - /// - If manual_add_contacts=1: App must call CMD_GET_CONTACTS to sync updated contact - void _handleAdvert(BufferReader reader) { - try { - print(' [Advert] Parsing advert push notification...'); - print(' Remaining bytes: ${reader.remainingBytesCount}'); - - // Advert format: 32 bytes public key - if (reader.remainingBytesCount >= 32) { - final publicKey = reader.readBytes(32); - final publicKeyPrefix = publicKey.sublist(0, 6); - final publicKeyFull = publicKey.map((b) => b.toRadixString(16).padLeft(2, '0')).join(':'); - - print(' πŸ“‘ ADVERT RECEIVED FROM NODE:'); - print(' Public key prefix (6 bytes): ${publicKeyPrefix.map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}'); - print(' Public key (full 32 bytes): $publicKeyFull'); - print(' ℹ️ This indicates the node is broadcasting its presence on the mesh network'); - print(' ℹ️ The companion radio will automatically update contact info for this node'); - print(' ℹ️ Expected follow-up:'); - print(' - If manual_add_contacts=0: You will receive PUSH_CODE_NEW_ADVERT (0x8A) with full contact details'); - print(' - If manual_add_contacts=1: Call CMD_GET_CONTACTS to sync updated contact'); - - // Notify callback so app can trigger contact sync if desired - onAdvertReceived?.call(publicKey); - } else { - print(' ⚠️ [Advert] Insufficient data: expected 32 bytes, got ${reader.remainingBytesCount}'); - } - - // Consume any remaining bytes - if (reader.hasRemaining) { - final extraBytes = reader.readRemainingBytes(); - print(' ⚠️ [Advert] Extra bytes found: ${extraBytes.length} bytes'); - print(' Extra data (hex): ${extraBytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ')}'); - } - - print(' βœ… [Advert] Parsed successfully'); - } catch (e) { - print(' ❌ [Advert] Parsing error: $e'); - // Don't call onError - adverts are informational - } - } - - /// Handle PathUpdated push (PUSH_CODE_PATH_UPDATED) - /// - /// This push notification indicates that the mesh network has discovered - /// a new or better routing path to a contact. The companion radio sends - /// this notification when a contact's out_path is updated. - /// - /// Protocol format: - /// - 32 bytes: public key of the contact whose path was updated - /// - /// The app can use this to: - /// - Trigger a contact sync to get the updated path - /// - Show network topology changes in the UI - /// - Update signal quality indicators - void _handlePathUpdated(BufferReader reader) { - try { - print(' [PathUpdated] Parsing path updated push notification...'); - print(' Remaining bytes: ${reader.remainingBytesCount}'); - - // PathUpdated format: 32 bytes public key - if (reader.remainingBytesCount >= 32) { - final publicKey = reader.readBytes(32); - final publicKeyPrefix = publicKey.sublist(0, 6); - final publicKeyFull = publicKey.map((b) => b.toRadixString(16).padLeft(2, '0')).join(':'); - - print(' πŸ“‘ PATH UPDATED FOR CONTACT:'); - print(' Public key prefix (6 bytes): ${publicKeyPrefix.map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}'); - print(' Public key (full 32 bytes): $publicKeyFull'); - print(' ℹ️ The mesh network has discovered a new/better routing path to this contact'); - print(' ℹ️ The companion radio has updated the contact\'s out_path'); - print(' ℹ️ Recommended action: Call CMD_GET_CONTACTS to sync the updated contact info'); - - // Notify callback so app can trigger contact sync or update UI - onPathUpdated?.call(publicKey); - } else { - print(' ⚠️ [PathUpdated] Insufficient data: expected 32 bytes, got ${reader.remainingBytesCount}'); - } - - // Consume any remaining bytes - if (reader.hasRemaining) { - final extraBytes = reader.readRemainingBytes(); - print(' ⚠️ [PathUpdated] Extra bytes found: ${extraBytes.length} bytes'); - print(' Extra data (hex): ${extraBytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ')}'); - } - - print(' βœ… [PathUpdated] Parsed successfully'); - } catch (e) { - print(' ❌ [PathUpdated] Parsing error: $e'); - // Don't call onError - path updates are informational - } - } - - /// Handle LogRxData push (PUSH_CODE_LOG_RX_DATA) - /// - /// This push notification contains diagnostic data about packets received over-the-air. - /// Based on MyMesh.cpp logRxRaw() implementation: - /// - /// Frame format (after 0x88 opcode): - /// - Byte 0: SNR Γ— 4 (signed int8, divide by 4 to get SNR in dB) - /// - Byte 1: RSSI (signed int8, in dBm) - /// - Bytes 2+: Raw over-the-air packet data (encrypted mesh packet) - /// - /// The "raw" data is the actual LoRa packet received from another mesh node, - /// which is typically encrypted and has high entropy. - void _handleLogRxData(BufferReader reader) { - try { - print(' [LogRxData] Parsing log rx data from over-the-air packet...'); - print(' Remaining bytes: ${reader.remainingBytesCount}'); - - final data = reader.readRemainingBytes(); - print(' Data length: ${data.length} bytes'); - - // Parse signal quality metrics (first 2 bytes) - if (data.length < 2) { - print(' ⚠️ [LogRxData] Insufficient data (need at least 2 bytes for SNR+RSSI)'); - return; - } - - final snrRaw = data[0]; - final snrDb = (snrRaw.toSigned(8)) / 4.0; // Convert from int8 and divide by 4 - print(' SNR: ${snrDb.toStringAsFixed(2)} dB (raw byte: 0x${snrRaw.toRadixString(16).padLeft(2, '0')})'); - - final rssiDbm = data[1].toSigned(8); // Signed int8 - print(' RSSI: $rssiDbm dBm (raw byte: 0x${data[1].toRadixString(16).padLeft(2, '0')})'); - - // Remaining bytes are the raw over-the-air packet - if (data.length <= 2) { - print(' ⚠️ [LogRxData] No raw packet data after signal metrics'); - return; - } - - final rawPacketData = data.sublist(2); - print(' Raw packet data: ${rawPacketData.length} bytes'); - print(' ℹ️ This is the encrypted LoRa packet received from another mesh node'); - - // Variables to store decoded information - int? airtimeMs; - Uint8List? senderPublicKey; - int? ackCode; - final List embeddedStrings = []; - - // Enhanced hex dump with 16 bytes per line for readability - print(' πŸ“Š RAW PACKET HEX DUMP:'); - for (int i = 0; i < rawPacketData.length; i += 16) { - final end = (i + 16 < rawPacketData.length) ? i + 16 : rawPacketData.length; - final chunk = rawPacketData.sublist(i, end); - - // Offset column (4 hex digits) - final offset = i.toRadixString(16).padLeft(4, '0'); - - // Hex bytes (2 hex digits per byte, space separated) - final hexBytes = chunk.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' '); - - // ASCII representation (printable chars or '.') - final ascii = chunk.map((b) { - if (b >= 32 && b <= 126) { - return String.fromCharCode(b); - } else { - return '.'; - } - }).join(''); - - // Print formatted line: OFFSET: HEX_BYTES | ASCII - print(' $offset: ${hexBytes.padRight(47)} | $ascii'); - } - - // πŸ”₯ FORCED DECODING - Try ALL possible interpretations - print(' πŸ”₯ FORCED DECODING - EXHAUSTIVE ANALYSIS:'); - print(''); - - // ========== INTERPRETATION 1: All Possible uint32 Values ========== - print(' πŸ” [INTERPRETATION 1] All uint32 LE values at each offset:'); - for (int offset = 0; offset <= rawPacketData.length - 4; offset++) { - final value = ByteData.sublistView(Uint8List.fromList(rawPacketData.sublist(offset, offset + 4))).getUint32(0, Endian.little); - final valueHex = '0x${value.toRadixString(16).padLeft(8, '0')}'; - - String interpretation = ''; - - // Check if it's a valid timestamp - const minTimestamp = 1577836800; // 2020-01-01 - const maxTimestamp = 1893456000; // 2030-01-01 - if (value >= minTimestamp && value <= maxTimestamp) { - final date = DateTime.fromMillisecondsSinceEpoch(value * 1000); - interpretation = ' β†’ TIMESTAMP: $date'; - } else if (value < 100000) { - interpretation = ' β†’ Airtime/Duration: ${value}ms'; - } else if (value > 900000000 && value < 1000000000) { - interpretation = ' β†’ Radio freq: ${value / 1000} MHz'; - } - - print(' [Offset $offset] uint32: $value ($valueHex)$interpretation'); - } - print(''); - - // ========== INTERPRETATION 2: All Possible int32 Values ========== - print(' πŸ” [INTERPRETATION 2] All int32 LE values (for GPS coordinates):'); - for (int offset = 0; offset <= rawPacketData.length - 4; offset++) { - final value = ByteData.sublistView(Uint8List.fromList(rawPacketData.sublist(offset, offset + 4))).getInt32(0, Endian.little); - final latLon = value / 1000000.0; - - String interpretation = ''; - if (latLon >= -90 && latLon <= 90) { - interpretation = ' β†’ Possible GPS: ${latLon.toStringAsFixed(6)}Β°'; - } - - print(' [Offset $offset] int32: $value β†’ ${latLon.toStringAsFixed(6)}$interpretation'); - } - print(''); - - // ========== INTERPRETATION 3: All uint16 Values ========== - print(' πŸ” [INTERPRETATION 3] All uint16 LE values:'); - for (int offset = 0; offset <= rawPacketData.length - 2; offset++) { - final value = ByteData.sublistView(Uint8List.fromList(rawPacketData.sublist(offset, offset + 2))).getUint16(0, Endian.little); - print(' [Offset $offset] uint16: $value (0x${value.toRadixString(16).padLeft(4, '0')})'); - } - print(''); - - // ========== INTERPRETATION 4: Byte Pair Analysis ========== - print(' πŸ” [INTERPRETATION 4] Byte pair correlation (detect patterns):'); - final Map> bytePairs = {}; - for (int i = 0; i < rawPacketData.length - 1; i++) { - final key = rawPacketData[i]; - bytePairs.putIfAbsent(key, () => []); - bytePairs[key]!.add(rawPacketData[i + 1]); - } - - // Find repeating patterns - final repeatingPatterns = bytePairs.entries.where((e) => e.value.length > 1); - if (repeatingPatterns.isNotEmpty) { - print(' Repeating byte transitions found:'); - for (final entry in repeatingPatterns) { - print(' Byte 0x${entry.key.toRadixString(16).padLeft(2, '0')} β†’ ${entry.value.map((b) => '0x${b.toRadixString(16).padLeft(2, '0')}').join(', ')}'); - } - } else { - print(' No repeating byte transitions (high randomness)'); - } - print(''); - - // ========== INTERPRETATION 5: Nibble Distribution ========== - print(' πŸ” [INTERPRETATION 5] Nibble (half-byte) distribution:'); - final Map nibbleHist = {}; - for (final byte in rawPacketData) { - final high = (byte >> 4) & 0x0F; - final low = byte & 0x0F; - nibbleHist[high] = (nibbleHist[high] ?? 0) + 1; - nibbleHist[low] = (nibbleHist[low] ?? 0) + 1; - } - - final sortedNibbles = nibbleHist.entries.toList()..sort((a, b) => b.value.compareTo(a.value)); - print(' Top nibble frequencies:'); - for (int i = 0; i < (sortedNibbles.length < 5 ? sortedNibbles.length : 5); i++) { - final entry = sortedNibbles[i]; - final bar = 'β–ˆ' * ((entry.value / sortedNibbles[0].value * 20).round()); - print(' 0x${entry.key.toRadixString(16)}: ${entry.value.toString().padLeft(3)} $bar'); - } - print(''); - - // ========== INTERPRETATION 6: XOR Pattern Detection ========== - print(' πŸ” [INTERPRETATION 6] XOR pattern detection (simple encryption):'); - final List xorKeys = [0x00, 0xFF, 0xAA, 0x55, 0x42, 0x69]; - for (final xorKey in xorKeys) { - final xored = rawPacketData.map((b) => b ^ xorKey).toList(); - final printableCount = xored.where((b) => b >= 32 && b <= 126).length; - final printableRatio = printableCount / xored.length; - - if (printableRatio > 0.3) { - final preview = String.fromCharCodes(xored.take(20).map((b) => b >= 32 && b <= 126 ? b : 46)); - print(' XOR key 0x${xorKey.toRadixString(16).padLeft(2, '0')}: ${(printableRatio * 100).toStringAsFixed(1)}% printable β†’ "$preview..."'); - } - } - print(''); - - // ========== INTERPRETATION 7: Sliding Window CRC/Checksum ========== - print(' πŸ” [INTERPRETATION 7] Checksum/CRC candidates (last 1-4 bytes):'); - if (rawPacketData.length >= 2) { - // Try last byte as checksum - final lastByte = rawPacketData[rawPacketData.length - 1]; - final payload = rawPacketData.sublist(0, rawPacketData.length - 1); - final simpleSum = payload.reduce((a, b) => (a + b) & 0xFF); - final xorSum = payload.reduce((a, b) => a ^ b); - - print(' Last byte: 0x${lastByte.toRadixString(16).padLeft(2, '0')}'); - print(' Simple sum (mod 256): 0x${simpleSum.toRadixString(16).padLeft(2, '0')} ${simpleSum == lastByte ? 'βœ… MATCH!' : ''}'); - print(' XOR checksum: 0x${xorSum.toRadixString(16).padLeft(2, '0')} ${xorSum == lastByte ? 'βœ… MATCH!' : ''}'); - } - - if (rawPacketData.length >= 3) { - final last2 = ByteData.sublistView(Uint8List.fromList(rawPacketData.sublist(rawPacketData.length - 2))).getUint16(0, Endian.little); - print(' Last 2 bytes (uint16 LE): 0x${last2.toRadixString(16).padLeft(4, '0')} ($last2)'); - } - print(''); - - // ========== INTERPRETATION 8: Bit Pattern Analysis ========== - print(' πŸ” [INTERPRETATION 8] Bit-level analysis:'); - int bitCount1 = 0; - int bitCount0 = 0; - for (final byte in rawPacketData) { - for (int bit = 0; bit < 8; bit++) { - if ((byte & (1 << bit)) != 0) { - bitCount1++; - } else { - bitCount0++; - } - } - } - final bitRatio = bitCount1 / (bitCount0 + bitCount1); - print(' Bit 1 count: $bitCount1 (${(bitRatio * 100).toStringAsFixed(1)}%)'); - print(' Bit 0 count: $bitCount0 (${((1 - bitRatio) * 100).toStringAsFixed(1)}%)'); - print(' Balance: ${(bitRatio - 0.5).abs() < 0.05 ? 'βœ… Well-balanced (likely encrypted/random)' : '⚠️ Imbalanced (may have structure)'}'); - print(''); - - // ========== INTERPRETATION 9: LoRa Modulation Params ========== - print(' πŸ” [INTERPRETATION 9] LoRa modulation parameter candidates:'); - for (int i = 0; i < rawPacketData.length; i++) { - final byte = rawPacketData[i]; - - // Check if it could be spreading factor (7-12) - if (byte >= 7 && byte <= 12) { - print(' [Offset $i] Possible SF (Spreading Factor): $byte'); - } - - // Check if it could be coding rate (5-8) - if (byte >= 5 && byte <= 8) { - print(' [Offset $i] Possible CR (Coding Rate): $byte'); - } - - // Check if it could be bandwidth index (0-9) - if (byte >= 0 && byte <= 9) { - final bwValues = [7.8, 10.4, 15.6, 20.8, 31.25, 41.7, 62.5, 125, 250, 500]; - print(' [Offset $i] Possible BW index: $byte β†’ ${bwValues[byte]} kHz'); - } - } - print(''); - - // ========== Final Structure Analysis ========== - print(' πŸ” STRUCTURE ANALYSIS:'); - - // Calculate entropy to detect encryption - final uniqueBytes = rawPacketData.toSet().length; - final entropy = uniqueBytes / rawPacketData.length; - final isLikelyEncrypted = entropy > 0.7; - print(' Entropy: ${(entropy * 100).toStringAsFixed(1)}% (${uniqueBytes}/${rawPacketData.length} unique bytes)'); - if (isLikelyEncrypted) { - print(' ℹ️ High entropy suggests encrypted or compressed data'); - } - - // Look for printable strings (runs of 4+ printable characters) - final strings = []; - StringBuffer currentString = StringBuffer(); - - for (int i = 0; i < rawPacketData.length; i++) { - final byte = rawPacketData[i]; - if (byte >= 32 && byte <= 126) { - // Printable ASCII - currentString.write(String.fromCharCode(byte)); - } else { - // Non-printable - end current string if long enough - if (currentString.length >= 4) { - strings.add(currentString.toString()); - } - currentString.clear(); - } - } - // Catch final string - if (currentString.length >= 4) { - strings.add(currentString.toString()); - } - - if (strings.isNotEmpty) { - print(' Embedded strings found:'); - for (final str in strings) { - print(' β†’ "$str"'); - embeddedStrings.add(str); - } - } else { - print(' No printable strings found (likely encrypted/binary data)'); - } - - print(' βœ… [LogRxData] Forced decode complete'); - - // Create decoded info for packet log - final logRxDataInfo = LogRxDataInfo( - airtimeMs: airtimeMs, - senderPublicKey: senderPublicKey, - ackCode: ackCode, - embeddedStrings: embeddedStrings, - entropy: entropy, - isLikelyEncrypted: isLikelyEncrypted, - ); - - // Update the most recent packet log entry with decoded information - if (_packetLogs.isNotEmpty) { - final lastLog = _packetLogs.last; - if (lastLog.responseCode == MeshCoreConstants.pushLogRxData) { - _packetLogs[_packetLogs.length - 1] = BlePacketLog( - timestamp: lastLog.timestamp, - rawData: lastLog.rawData, - direction: lastLog.direction, - responseCode: lastLog.responseCode, - description: lastLog.description, - logRxDataInfo: logRxDataInfo, - ); - } - } - } catch (e) { - print(' ❌ [LogRxData] Parsing error: $e'); - // Don't call onError - logs are informational - } - } - - /// Handle NewAdvert push - void _handleNewAdvert(BufferReader reader) { - try { - print(' [NewAdvert] Parsing new advertisement...'); - print(' Remaining bytes: ${reader.remainingBytesCount}'); - - // NewAdvert format is identical to Contact response: - // - 32 bytes: public key - // - 1 byte: type - // - 1 byte: flags - // - 1 byte: outPathLen - // - 64 bytes: outPath - // - 32 bytes: advName (null-terminated string) - // - 4 bytes: lastAdvert (uint32) - // - 4 bytes: advLat (int32) - // - 4 bytes: advLon (int32) - // - 4 bytes: lastMod (uint32) - - final publicKey = reader.readBytes(32); - print(' Public key prefix: ${publicKey.sublist(0, 6).map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}'); - - final typeByte = reader.readByte(); - final type = ContactType.fromValue(typeByte); - print(' Type byte: $typeByte β†’ Type: $type'); - - final flags = reader.readByte(); - print(' Flags: $flags (0x${flags.toRadixString(16).padLeft(2, '0')})'); - - final outPathLen = reader.readInt8(); - print(' Out path length: $outPathLen'); - - final outPath = reader.readBytes(64); - print(' Out path: ${outPath.sublist(0, 6).map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}...'); - - final advName = reader.readCString(32); - print(' Advertised name: "$advName"'); - - final lastAdvert = reader.readUInt32LE(); - print(' Last advert timestamp: $lastAdvert'); - - final advLat = reader.readInt32LE(); - print(' Latitude (raw int32): $advLat'); - print(' Latitude (decimal): ${advLat / 1000000.0}Β°'); - - final advLon = reader.readInt32LE(); - print(' Longitude (raw int32): $advLon'); - print(' Longitude (decimal): ${advLon / 1000000.0}Β°'); - - final lastMod = reader.readUInt32LE(); - print(' Last modified timestamp: $lastMod'); - - final contact = Contact( - publicKey: publicKey, - type: type, - flags: flags, - outPathLen: outPathLen, - outPath: outPath, - advName: advName, - lastAdvert: lastAdvert, - advLat: advLat, - advLon: advLon, - lastMod: lastMod, - ); - - print(' βœ… [NewAdvert] Parsed successfully - new contact advertised on network'); - // Call the contact received callback to add/update the contact - onContactReceived?.call(contact); - } catch (e) { - print(' ❌ [NewAdvert] Parsing error: $e'); - onError?.call('NewAdvert parsing error: $e'); - } - } - - /// Handle SendConfirmed push (PUSH_CODE_SEND_CONFIRMED) - /// - /// Protocol format: - /// - 4 bytes: ACK code - /// - 4 bytes: round trip time (uint32, milliseconds) - void _handleSendConfirmed(BufferReader reader) { - try { - print(' [SendConfirmed] Parsing send confirmed...'); - print(' Remaining bytes: ${reader.remainingBytesCount}'); - - if (reader.remainingBytesCount >= 8) { - final ackCodeBytes = reader.readBytes(4); - final ackCode = ByteData.sublistView(Uint8List.fromList(ackCodeBytes)).getUint32(0, Endian.little); - print(' ACK code: ${ackCodeBytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ')} (uint32: $ackCode)'); - - final roundTripTime = reader.readUInt32LE(); - print(' Round trip time: ${roundTripTime}ms'); - - print(' βœ… [SendConfirmed] Message delivery confirmed (RTT: ${roundTripTime}ms)'); - - // Notify provider that message was delivered - onMessageDelivered?.call(ackCode, roundTripTime); - } else { - print(' ⚠️ [SendConfirmed] Insufficient data for full parsing'); - } - } catch (e) { - print(' ❌ [SendConfirmed] Parsing error: $e'); - // Don't call onError - confirmations are informational - } - } - - /// Handle MsgWaiting push (PUSH_CODE_MSG_WAITING) - /// - /// This push notification indicates that new messages are waiting - /// in the device queue and should be fetched using syncNextMessage() - void _handleMsgWaiting(BufferReader reader) { - try { - print(' [MsgWaiting] New message(s) waiting in queue'); - print(' βœ… [MsgWaiting] Notifying callback to fetch messages'); - onMessageWaiting?.call(); - } catch (e) { - print(' ❌ [MsgWaiting] Parsing error: $e'); - // Don't call onError - this is informational - } - } - - /// Handle LoginSuccess push (PUSH_CODE_LOGIN_SUCCESS) - /// - /// Protocol format: - /// - 1 byte: permissions (lowest bit = is_admin) - /// - 6 bytes: public key prefix (first 6 bytes) - /// - 4 bytes: tag (int32) - /// - 1 byte: (V7+) new permissions - void _handleLoginSuccess(BufferReader reader) { - try { - print(' [LoginSuccess] Parsing login success...'); - print(' Remaining bytes: ${reader.remainingBytesCount}'); - - if (reader.remainingBytesCount >= 11) { - final permissions = reader.readByte(); - final isAdmin = (permissions & 0x01) != 0; - print(' Permissions: $permissions (admin: $isAdmin)'); - - final publicKeyPrefix = reader.readBytes(6); - print(' Room public key prefix: ${publicKeyPrefix.map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}'); - - final tag = reader.readInt32LE(); - print(' Tag: $tag'); - - // V7+ new permissions byte - int? newPermissions; - if (reader.hasRemaining) { - newPermissions = reader.readByte(); - print(' New permissions (V7+): $newPermissions'); - } - - print(' βœ… [LoginSuccess] Successfully logged into room'); - onLoginSuccess?.call(publicKeyPrefix, permissions, isAdmin, tag); - } else { - print(' ⚠️ [LoginSuccess] Insufficient data for full parsing'); - } - } catch (e) { - print(' ❌ [LoginSuccess] Parsing error: $e'); - onError?.call('Login success parsing error: $e'); - } - } - - /// Handle LoginFail push (PUSH_CODE_LOGIN_FAIL) - /// - /// Protocol format: - /// - 1 byte: reserved (zero) - /// - 6 bytes: public key prefix (first 6 bytes) - void _handleLoginFail(BufferReader reader) { - try { - print(' [LoginFail] Parsing login fail...'); - print(' Remaining bytes: ${reader.remainingBytesCount}'); - - if (reader.remainingBytesCount >= 7) { - final reserved = reader.readByte(); - print(' Reserved: $reserved'); - - final publicKeyPrefix = reader.readBytes(6); - print(' Room public key prefix: ${publicKeyPrefix.map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}'); - - print(' ❌ [LoginFail] Failed to login to room (incorrect password or access denied)'); - onLoginFail?.call(publicKeyPrefix); - } else { - print(' ⚠️ [LoginFail] Insufficient data for full parsing'); - } - } catch (e) { - print(' ❌ [LoginFail] Parsing error: $e'); - onError?.call('Login fail parsing error: $e'); - } - } - - /// Handle StatusResponse push (PUSH_CODE_STATUS_RESPONSE) - /// - /// This push notification is received in response to CMD_SEND_STATUS_REQ. - /// It contains status information from a repeater or sensor node. - /// - /// Protocol format (PUSH_CODE_STATUS_RESPONSE, 0x87): - /// - 1 byte: reserved (zero) - /// - 6 bytes: public key prefix (first 6 bytes of responding node) - /// - N bytes: status data (remainder of frame, format depends on node type) - /// - /// The status data format is node-specific and may include: - /// - Repeater nodes: uptime, message counts, relay statistics - /// - Sensor nodes: sensor readings, battery level, operational state - /// - Room nodes: user counts, message storage stats - void _handleStatusResponse(BufferReader reader) { - try { - print(' [StatusResponse] Parsing status response...'); - print(' Remaining bytes: ${reader.remainingBytesCount}'); - - if (reader.remainingBytesCount >= 7) { - final reserved = reader.readByte(); - print(' Reserved: $reserved'); - - final publicKeyPrefix = reader.readBytes(6); - print(' Node public key prefix: ${publicKeyPrefix.map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}'); - - // Read remaining status data - final statusData = reader.readRemainingBytes(); - print(' Status data: ${statusData.length} bytes'); - print(' Status data (hex): ${statusData.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ')}'); - - // Try to decode as ASCII text if printable - try { - final statusText = utf8.decode(statusData, allowMalformed: true); - if (statusText.isNotEmpty && _isPrintableAscii(statusText)) { - print(' Status data (text): $statusText'); - } - } catch (e) { - // Not text data, that's fine - } - - print(' βœ… [StatusResponse] Received status response from node'); - onStatusResponse?.call(publicKeyPrefix, statusData); - } else { - print(' ⚠️ [StatusResponse] Insufficient data for full parsing'); - } - } catch (e) { - print(' ❌ [StatusResponse] Parsing error: $e'); - onError?.call('Status response parsing error: $e'); - } - } - - /// Check if a string contains only printable ASCII characters - bool _isPrintableAscii(String text) { - for (int i = 0; i < text.length; i++) { - final code = text.codeUnitAt(i); - if (code < 32 || code > 126) { - // Not printable ASCII (except newlines and tabs which are common) - if (code != 10 && code != 13 && code != 9) { - return false; - } - } - } - return true; - } - - /// Handle CurrentTime response (RESP_CODE_CURR_TIME) - /// - /// Protocol format: - /// - 4 bytes: current device time (uint32, epoch seconds, UTC) - void _handleCurrentTime(BufferReader reader) { - try { - print(' [CurrentTime] Parsing device time...'); - print(' Remaining bytes: ${reader.remainingBytesCount}'); - - if (reader.remainingBytesCount >= 4) { - final deviceTime = reader.readUInt32LE(); - final appTime = DateTime.now().millisecondsSinceEpoch ~/ 1000; - final drift = appTime - deviceTime; - - print(' πŸ“ CLOCK COMPARISON:'); - print(' Radio time: $deviceTime (${DateTime.fromMillisecondsSinceEpoch(deviceTime * 1000)})'); - print(' App time: $appTime (${DateTime.fromMillisecondsSinceEpoch(appTime * 1000)})'); - print(' Clock drift: $drift seconds'); - - if (drift.abs() > 60) { - print(' ⚠️ WARNING: Clock drift exceeds 60 seconds!'); - print(' This may cause login or message sync issues'); - print(' Consider calling setDeviceTime() to sync the radio\'s clock'); - } else if (drift.abs() > 5) { - print(' ℹ️ Minor clock drift detected (${drift}s)'); - } else { - print(' βœ… Clocks are well synchronized (drift: ${drift}s)'); - } - - print(' βœ… [CurrentTime] Parsed successfully'); - } else { - print(' ⚠️ [CurrentTime] Insufficient data for full parsing'); - } - } catch (e) { - print(' ❌ [CurrentTime] Parsing error: $e'); - onError?.call('CurrentTime parsing error: $e'); - } - } - - - /// Handle BatteryAndStorage response (RESP_CODE_BATT_AND_STORAGE) - /// - /// Protocol format (RESP_CODE_BATT_AND_STORAGE, code 12): - /// - 2 bytes: Millivolts (uint16) - /// - 4 bytes: (Optional) Used KB (uint32) - /// - 4 bytes: (Optional) Total KB (uint32, zero if unknown) - void _handleBatteryAndStorage(BufferReader reader) { - try { - print(' [BatteryAndStorage] Parsing battery and storage info...'); - print(' Remaining bytes: ${reader.remainingBytesCount}'); - - if (reader.remainingBytesCount >= 2) { - // Battery voltage is always present (uint16) - final millivolts = reader.readUInt16LE(); - final voltage = millivolts / 1000.0; - print(' Battery: ${millivolts}mV (${voltage.toStringAsFixed(2)}V)'); - - // Storage fields are optional - int? usedKb; - int? totalKb; - - if (reader.remainingBytesCount >= 8) { - // Both storage fields present - usedKb = reader.readUInt32LE(); - totalKb = reader.readUInt32LE(); - - print(' Storage Used: ${usedKb}KB'); - print(' Storage Total: ${totalKb}KB'); - - if (totalKb > 0) { - final usedPercent = (usedKb / totalKb) * 100.0; - final availableKb = totalKb - usedKb; - print(' Storage Available: ${availableKb}KB (${(100 - usedPercent).toStringAsFixed(1)}% free)'); - print(' Storage Usage: ${usedPercent.toStringAsFixed(1)}%'); - } else { - print(' Storage Total is 0 (size unknown)'); - } - } else if (reader.remainingBytesCount >= 4) { - // Only used KB present - usedKb = reader.readUInt32LE(); - print(' Storage Used: ${usedKb}KB'); - print(' Storage Total: Not available'); - } else { - print(' Storage: Not available'); - } - - // Trigger callback - onBatteryAndStorage?.call(millivolts, usedKb, totalKb); - print(' βœ… [BatteryAndStorage] Parsed successfully'); - } else { - print(' ⚠️ [BatteryAndStorage] Insufficient data (need at least 2 bytes for battery)'); - } - } catch (e) { - print(' ❌ [BatteryAndStorage] Parsing error: $e'); - onError?.call('BatteryAndStorage parsing error: $e'); - } - } - /// Handle Error response (RESP_CODE_ERR) - /// - /// Protocol format: - /// - 1 byte: error code (ERR_CODE_*) - void _handleError(BufferReader reader) { - try { - print(' [Error] Parsing error response...'); - print(' Remaining bytes: ${reader.remainingBytesCount}'); - - if (reader.hasRemaining) { - final errorCode = reader.readByte(); - String errorMsg = 'Error code: $errorCode'; - - switch (errorCode) { - case MeshCoreConstants.errUnsupportedCmd: - errorMsg = 'Unsupported command'; - break; - case MeshCoreConstants.errNotFound: - errorMsg = 'Not found'; - break; - case MeshCoreConstants.errTableFull: - errorMsg = 'Table full'; - break; - case MeshCoreConstants.errBadState: - errorMsg = 'Bad state'; - break; - case MeshCoreConstants.errFileIoError: - errorMsg = 'File I/O error'; - break; - case MeshCoreConstants.errIllegalArg: - errorMsg = 'Illegal argument'; - break; - } - - print(' ❌ [Error] $errorMsg'); - onError?.call(errorMsg); - } - } catch (e) { - print(' ❌ [Error] Parsing error: $e'); - } - } - - /// Send AppStart command - Future _sendAppStart() async { - final writer = BufferWriter(); - writer.writeByte(MeshCoreConstants.cmdAppStart); - writer.writeByte(1); // appVer - writer.writeBytes(Uint8List(6)); // reserved - writer.writeString('MeshCore SAR'); // appName - await _writeData(writer.toBytes()); - } - - /// Send DeviceQuery command + /// Send initial device query Future _sendDeviceQuery() async { - final writer = BufferWriter(); - writer.writeByte(MeshCoreConstants.cmdDeviceQuery); - writer.writeByte(MeshCoreConstants.supportedCompanionProtocolVersion); - await _writeData(writer.toBytes()); - await _sendAppStart(); + await _commandSender.writeData(FrameBuilder.buildDeviceQuery()); + await _commandSender.writeData(FrameBuilder.buildAppStart()); } /// Refresh device info (public method) @@ -1792,181 +198,80 @@ class MeshCoreBleService { /// Get contacts from device Future getContacts() async { - final writer = BufferWriter(); - writer.writeByte(MeshCoreConstants.cmdGetContacts); - await _writeData(writer.toBytes()); + await _commandSender.writeData(FrameBuilder.buildGetContacts()); } /// Manually add or update a contact on the companion radio - /// - /// This is useful when you need to add a room that hasn't advertised yet, - /// or restore a contact that was deleted from the radio's table. - /// - /// **Use case:** If you get ERR_CODE_NOT_FOUND when logging into a room, - /// use this to add the room contact to the radio's internal table first. - /// - /// Protocol format (CMD_ADD_UPDATE_CONTACT): - /// - 1 byte: command code (9) - /// - 32 bytes: public key - /// - 1 byte: type (ADV_TYPE_*) - /// - 1 byte: flags - /// - 1 byte: out path length (signed) - /// - 64 bytes: out path - /// - 32 bytes: advertised name (null-terminated) - /// - 4 bytes: last advert timestamp (uint32) - /// - 4 bytes: (optional) advert latitude * 1E6 (int32) - /// - 4 bytes: (optional) advert longitude * 1E6 (int32) Future addOrUpdateContact(Contact contact) async { print('πŸ“ [BLE] Adding/updating contact on companion radio:'); print(' Name: ${contact.advName}'); print(' Public key prefix: ${contact.publicKey.sublist(0, 6).map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}'); print(' Type: ${contact.type} (${contact.type.value})'); - final writer = BufferWriter(); - writer.writeByte(MeshCoreConstants.cmdAddUpdateContact); // 0x09 - writer.writeBytes(contact.publicKey); // 32 bytes - writer.writeByte(contact.type.value); // ADV_TYPE_* - writer.writeByte(contact.flags); // flags - writer.writeInt8(contact.outPathLen); // path length (signed byte) - writer.writeBytes(contact.outPath); // 64 bytes - - // Write name as null-terminated string in 32-byte field - final nameBytes = Uint8List(32); - final encoded = utf8.encode(contact.advName); - final copyLen = encoded.length > 31 ? 31 : encoded.length; - nameBytes.setRange(0, copyLen, encoded); - writer.writeBytes(nameBytes); - - writer.writeUInt32LE(contact.lastAdvert); // timestamp - writer.writeInt32LE(contact.advLat); // latitude * 1E6 - writer.writeInt32LE(contact.advLon); // longitude * 1E6 - - await _writeData(writer.toBytes()); + await _commandSender.writeData(FrameBuilder.buildAddUpdateContact(contact)); print('βœ… [BLE] CMD_ADD_UPDATE_CONTACT sent'); - print(' This adds/updates the contact in the radio\'s internal flash storage'); - print(' The contact will persist across reboots and can be used for login'); } /// Send text message to contact (DM) - /// - /// Protocol format (CMD_SEND_TXT_MSG): - /// - 1 byte: command code (2) - /// - 1 byte: text type (TXT_TYPE_*, 0=plain) - /// - 1 byte: attempt (0-3, attempt number) - /// - 4 bytes: sender timestamp (uint32, epoch seconds) - /// - 6 bytes: recipient public key prefix (first 6 bytes) - /// - N bytes: text (remainder of frame, varchar, max 160 bytes) Future sendTextMessage({ required Uint8List contactPublicKey, required String text, - int textType = 0, // TXT_TYPE_PLAIN + int textType = 0, int attempt = 0, }) async { if (text.length > 160) { throw ArgumentError('Text message exceeds 160 character limit'); } - final writer = BufferWriter(); - writer.writeByte(MeshCoreConstants.cmdSendTxtMsg); // 0x02 - writer.writeByte(textType); // TXT_TYPE_* - writer.writeByte(attempt); // 0-3 - writer.writeUInt32LE(DateTime.now().millisecondsSinceEpoch ~/ 1000); // epoch seconds - writer.writeBytes(contactPublicKey.sublist(0, 6)); // first 6 bytes of public key - writer.writeString(text); - await _writeData(writer.toBytes()); + await _commandSender.writeData(FrameBuilder.buildSendTxtMsg( + contactPublicKey: contactPublicKey, + text: text, + textType: textType, + attempt: attempt, + )); } /// Send flood-mode text message to channel - /// - /// Protocol format (CMD_SEND_CHANNEL_TXT_MSG): - /// - 1 byte: command code (3) - /// - 1 byte: text type (TXT_TYPE_*, 0=plain) - /// - 1 byte: channel index (reserved, 0 for 'public') - /// - 4 bytes: sender timestamp (uint32, epoch seconds) - /// - N bytes: text (remainder of frame, max 160 - len(advert_name) - 2) - /// - /// Note: For SAR messages, ensure text starts with "S::," Future sendChannelMessage({ required int channelIdx, required String text, - int textType = 0, // TXT_TYPE_PLAIN + int textType = 0, }) async { - // Note: Max length depends on advert name length, but typically ~140 chars if (text.length > 160) { throw ArgumentError('Channel message too long (max ~160 characters)'); } - final writer = BufferWriter(); - writer.writeByte(MeshCoreConstants.cmdSendChannelTxtMsg); // 0x03 - writer.writeByte(textType); // TXT_TYPE_* - writer.writeByte(channelIdx); // 0 for 'public' channel - writer.writeUInt32LE(DateTime.now().millisecondsSinceEpoch ~/ 1000); // epoch seconds - writer.writeString(text); - await _writeData(writer.toBytes()); + await _commandSender.writeData(FrameBuilder.buildSendChannelTxtMsg( + channelIdx: channelIdx, + text: text, + textType: textType, + )); } - /// Request telemetry from contact - /// [zeroHop] - if true, only direct connection (no mesh forwarding) + /// Request telemetry from contact (deprecated) @Deprecated('Use sendBinaryRequest() instead for better functionality') Future requestTelemetry(Uint8List contactPublicKey, {bool zeroHop = false}) async { - final writer = BufferWriter(); - writer.writeByte(MeshCoreConstants.cmdSendTelemetryReq); - writer.writeByte(zeroHop ? 0 : 255); // hop count: 0 = direct only, 255 = unlimited - writer.writeByte(0); // reserved - writer.writeByte(0); // reserved - writer.writeBytes(contactPublicKey); - await _writeData(writer.toBytes()); + await _commandSender.writeData(FrameBuilder.buildSendTelemetryReq( + contactPublicKey, + zeroHop: zeroHop, + )); } - /// Send binary request to contact (CMD_SEND_BINARY_REQ) - /// - /// Modern replacement for requestTelemetry() with better functionality. - /// Supports multiple request types including telemetry, access lists, and neighbors. - /// - /// Protocol format: - /// - 1 byte: command code (50) - /// - 32 bytes: contact public key - /// - N bytes: request code and params (requestData) - /// - /// Common request codes (first byte of requestData): - /// - 0x03: Get telemetry data (equivalent to old requestTelemetry) - /// - 0x04: Get average/min/max telemetry - /// - 0x05: Get access list - /// - 0x06: Get neighbors list - /// - /// Response arrives via onBinaryResponse callback with matching tag. - /// - /// Example - request telemetry: - /// ```dart - /// await sendBinaryRequest( - /// contactPublicKey: contact.publicKey, - /// requestData: Uint8List.fromList([0x03]), // BINARY_REQ_GET_TELEMETRY_DATA - /// ); - /// ``` + /// Send binary request to contact Future sendBinaryRequest({ required Uint8List contactPublicKey, required Uint8List requestData, }) async { - final writer = BufferWriter(); - writer.writeByte(MeshCoreConstants.cmdSendBinaryReq); // 0x32 (50) - writer.writeBytes(contactPublicKey); // 32 bytes - writer.writeBytes(requestData); // request code + params - await _writeData(writer.toBytes()); + await _commandSender.writeData(FrameBuilder.buildSendBinaryReq( + contactPublicKey: contactPublicKey, + requestData: requestData, + )); } /// Get battery voltage and storage information - /// - /// Sends CMD_GET_BATT_AND_STORAGE (20) to query: - /// - Battery voltage in millivolts (uint16) - /// - Used storage in KB (optional uint32) - /// - Total storage in KB (optional uint32, 0 if unknown) - /// - /// Response arrives via onBatteryAndStorage callback Future getBatteryAndStorage() async { - final writer = BufferWriter(); - writer.writeByte(MeshCoreConstants.cmdGetBatteryVoltage); - await _writeData(writer.toBytes()); + await _commandSender.writeData(FrameBuilder.buildGetBatteryAndStorage()); } /// Legacy method name for backward compatibility @@ -1976,59 +281,28 @@ class MeshCoreBleService { } /// Sync next message from device queue - /// Returns true if a message was retrieved, false if no more messages Future syncNextMessage() async { - final writer = BufferWriter(); - writer.writeByte(MeshCoreConstants.cmdSyncNextMessage); - await _writeData(writer.toBytes()); + await _commandSender.writeData(FrameBuilder.buildSyncNextMessage()); } /// Get device time from companion radio - /// - /// Queries the companion radio's current time to detect clock drift. - /// Response will be RESP_CODE_CURR_TIME (9). - /// - /// Protocol format (CMD_GET_DEVICE_TIME): - /// - 1 byte: command code (5) Future getDeviceTime() async { - final writer = BufferWriter(); - writer.writeByte(MeshCoreConstants.cmdGetDeviceTime); - await _writeData(writer.toBytes()); + await _commandSender.writeData(FrameBuilder.buildGetDeviceTime()); } /// Set device time Future setDeviceTime() async { - final writer = BufferWriter(); - writer.writeByte(MeshCoreConstants.cmdSetDeviceTime); - writer.writeUInt32LE(DateTime.now().millisecondsSinceEpoch ~/ 1000); - await _writeData(writer.toBytes()); + await _commandSender.writeData(FrameBuilder.buildSetDeviceTime()); } /// Send self advertisement packet to mesh network - /// - /// This broadcasts the device's current advertisement data (name, location, etc.) - /// to the mesh network. The device uses its internally stored values from - /// setAdvertName() and setAdvertLatLon(). - /// - /// Protocol format (CMD_SEND_SELF_ADVERT): - /// - 1 byte: command code (7) - /// - 1 byte: type (0=zero-hop/local, 1=flood/mesh-wide) - /// - /// [floodMode] - if true, broadcast to entire mesh network (default) - /// if false, only send to direct neighbors (zero-hop) Future sendSelfAdvert({bool floodMode = true}) async { - final writer = BufferWriter(); - writer.writeByte(MeshCoreConstants.cmdSendSelfAdvert); - writer.writeByte(floodMode ? MeshCoreConstants.selfAdvertFlood : MeshCoreConstants.selfAdvertZeroHop); - await _writeData(writer.toBytes()); + await _commandSender.writeData(FrameBuilder.buildSendSelfAdvert(floodMode: floodMode)); } /// Set advertised name Future setAdvertName(String name) async { - final writer = BufferWriter(); - writer.writeByte(MeshCoreConstants.cmdSetAdvertName); - writer.writeString(name); - await _writeData(writer.toBytes()); + await _commandSender.writeData(FrameBuilder.buildSetAdvertName(name)); } /// Set advertised latitude and longitude @@ -2036,84 +310,48 @@ class MeshCoreBleService { required double latitude, required double longitude, }) async { - final writer = BufferWriter(); - writer.writeByte(MeshCoreConstants.cmdSetAdvertLatLon); - writer.writeInt32LE((latitude * 1000000).round()); - writer.writeInt32LE((longitude * 1000000).round()); - await _writeData(writer.toBytes()); + await _commandSender.writeData(FrameBuilder.buildSetAdvertLatLon( + latitude: latitude, + longitude: longitude, + )); } /// Set radio parameters Future setRadioParams({ - required int frequency, // Hz - required int bandwidth, // 0-9 (see bandwidth options) - required int spreadingFactor, // 7-12 - required int codingRate, // 5-8 + required int frequency, + required int bandwidth, + required int spreadingFactor, + required int codingRate, }) async { - final writer = BufferWriter(); - writer.writeByte(MeshCoreConstants.cmdSetRadioParams); - writer.writeUInt32LE(frequency); - writer.writeUInt16LE(bandwidth); - writer.writeByte(spreadingFactor); - writer.writeByte(codingRate); - await _writeData(writer.toBytes()); + await _commandSender.writeData(FrameBuilder.buildSetRadioParams( + frequency: frequency, + bandwidth: bandwidth, + spreadingFactor: spreadingFactor, + codingRate: codingRate, + )); } /// Set transmit power Future setTxPower(int powerDbm) async { - final writer = BufferWriter(); - writer.writeByte(MeshCoreConstants.cmdSetTxPower); - writer.writeByte(powerDbm); - await _writeData(writer.toBytes()); + await _commandSender.writeData(FrameBuilder.buildSetTxPower(powerDbm)); } - /// Set other parameters (telemetry modes, advert location policy, manual add contacts) - /// - /// Protocol format (CMD_SET_OTHER_PARAMS): - /// - 1 byte: command code (38) - /// - 1 byte: manual add contacts (0 or 1) - /// - 1 byte: telemetry modes (bits 0-1: Base mode, bits 2-3: Location mode) - /// Modes: 0=DENY, 1=apply contact.flags, 2=ALLOW ALL - /// - 1 byte: advert location policy (0=don't share, 1=share) - /// - 1 byte: multi ACKs (0=no extra, 1=send extra ACK) + /// Set other parameters Future setOtherParams({ - required int manualAddContacts, // 0 or 1 - required int telemetryModes, // bits 0-1: Base, bits 2-3: Location - required int advertLocationPolicy, // 0=don't share, 1=share - int multiAcks = 0, // 0=no extra, 1=send extra + required int manualAddContacts, + required int telemetryModes, + required int advertLocationPolicy, + int multiAcks = 0, }) async { - final writer = BufferWriter(); - writer.writeByte(MeshCoreConstants.cmdSetOtherParams); - writer.writeByte(manualAddContacts); - writer.writeByte(telemetryModes); - writer.writeByte(advertLocationPolicy); - writer.writeByte(multiAcks); - await _writeData(writer.toBytes()); + await _commandSender.writeData(FrameBuilder.buildSetOtherParams( + manualAddContacts: manualAddContacts, + telemetryModes: telemetryModes, + advertLocationPolicy: advertLocationPolicy, + multiAcks: multiAcks, + )); } /// Send login request to room or repeater - /// - /// This sends a login request to the room server via the companion radio. - /// - /// **ACTUAL Protocol format (CMD_SEND_LOGIN):** - /// - 1 byte: command code (26) - /// - 32 bytes: room public key - /// - N bytes: password (varchar, max 15 bytes, null-terminated) - /// - /// NOTE: The documentation was wrong - there are NO timestamp/sync_since params - /// in the companion radio protocol. The companion radio's sendLogin() function - /// handles timestamp internally when it creates the PAYLOAD_TYPE_ANON_REQ packet. - /// - /// Response: PUSH_CODE_LOGIN_SUCCESS (0x85) or PUSH_CODE_LOGIN_FAIL (0x86) - /// - /// After successful login, the room server will automatically PUSH stored messages. - /// - /// IMPORTANT: The companion radio must have the room contact in its own - /// internal contact table. If you get ERR_CODE_NOT_FOUND (2), the radio - /// doesn't know about this room. You need to: - /// 1. Wait for the room to advertise (it will be added automatically) - /// 2. Import the room contact using CMD_IMPORT_CONTACT - /// 3. Manually add the room contact using CMD_ADD_UPDATE_CONTACT Future loginToRoom({ required Uint8List roomPublicKey, required String password, @@ -2125,147 +363,45 @@ class MeshCoreBleService { print('πŸ” [BLE] Preparing login request:'); print(' Room public key prefix: ${roomPublicKey.sublist(0, 6).map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}'); print(' Password: ${"*" * password.length} (${password.length} chars)'); - print(' ⚠️ NOTE: The companion radio must have this room in its contact table'); - print(' If you get ERR_CODE_NOT_FOUND, the room needs to advertise first or use CMD_ADD_UPDATE_CONTACT'); - final writer = BufferWriter(); - writer.writeByte(MeshCoreConstants.cmdSendLogin); // 0x1A - writer.writeBytes(roomPublicKey); // 32 bytes - writer.writeString(password); // Max 15 bytes, null-terminated - await _writeData(writer.toBytes()); + await _commandSender.writeData(FrameBuilder.buildSendLogin( + roomPublicKey: roomPublicKey, + password: password, + )); } /// Send status request to repeater or sensor node - /// - /// This sends a status request (CMD_SEND_STATUS_REQ, 0x1B) to a repeater - /// or sensor node to query its current operational status. - /// - /// Protocol format (CMD_SEND_STATUS_REQ): - /// - 1 byte: command code (27) - /// - 32 bytes: public key of target node (repeater or sensor) - /// - /// Response: PUSH_CODE_STATUS_RESPONSE (0x87) push notification - /// - /// The status data format is node-specific: - /// - Repeater nodes: uptime, message counts, relay statistics - /// - Sensor nodes: sensor readings, battery level, operational state - /// - Room nodes: user counts, message storage statistics - /// - /// Example usage: - /// ```dart - /// bleService.onStatusResponse = (publicKeyPrefix, statusData) { - /// print('Status from node: ${utf8.decode(statusData)}'); - /// }; - /// await bleService.sendStatusRequest(repeaterContact.publicKey); - /// ``` Future sendStatusRequest(Uint8List contactPublicKey) async { print('πŸ“Š [BLE] Preparing status request:'); print(' Target node public key prefix: ${contactPublicKey.sublist(0, 6).map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}'); - print(' ℹ️ Requesting status from repeater/sensor node'); - final writer = BufferWriter(); - writer.writeByte(MeshCoreConstants.cmdSendStatusReq); // 0x1B - writer.writeBytes(contactPublicKey); // 32 bytes - await _writeData(writer.toBytes()); + await _commandSender.writeData(FrameBuilder.buildSendStatusReq(contactPublicKey)); } - /// Log a packet - void _logPacket(Uint8List data, PacketDirection direction, {int? responseCode}) { - // Add new packet - _packetLogs.add(BlePacketLog( - timestamp: DateTime.now(), - rawData: data, - direction: direction, - responseCode: responseCode, - description: _getPacketDescription(responseCode, direction), - )); + /// Reset path for a contact - forces next message to flood and re-learn route + Future resetPath(Uint8List contactPublicKey) async { + print('πŸ”„ [BLE] Resetting path for contact:'); + print(' Contact public key prefix: ${contactPublicKey.sublist(0, 6).map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}'); - // Limit log size to prevent memory issues - if (_packetLogs.length > _maxLogSize) { - _packetLogs.removeAt(0); - } - } - - /// Get human-readable description of packet - String? _getPacketDescription(int? code, PacketDirection direction) { - if (direction == PacketDirection.tx) { - // TX packets - command codes - switch (code) { - case MeshCoreConstants.cmdGetContacts: - return 'Get Contacts'; - case MeshCoreConstants.cmdSendTxtMsg: - return 'Send Text Message'; - case MeshCoreConstants.cmdSendChannelTxtMsg: - return 'Send Channel Message'; - case MeshCoreConstants.cmdSendTelemetryReq: - return 'Request Telemetry'; - case MeshCoreConstants.cmdDeviceQuery: - return 'Device Query'; - case MeshCoreConstants.cmdAppStart: - return 'App Start'; - case MeshCoreConstants.cmdSendStatusReq: - return 'Status Request'; - default: - return null; - } - } else { - // RX packets - response codes - switch (code) { - case MeshCoreConstants.respContactsStart: - return 'Contacts Start'; - case MeshCoreConstants.respContact: - return 'Contact Info'; - case MeshCoreConstants.respEndOfContacts: - return 'End of Contacts'; - case MeshCoreConstants.respSent: - return 'Message Sent'; - case MeshCoreConstants.respContactMsgRecv: - return 'Contact Message'; - case MeshCoreConstants.respChannelMsgRecv: - return 'Channel Message'; - case MeshCoreConstants.pushTelemetryResponse: - return 'Telemetry Data'; - case MeshCoreConstants.respDeviceInfo: - return 'Device Info'; - case MeshCoreConstants.respSelfInfo: - return 'Self Info'; - case MeshCoreConstants.pushAdvert: - return 'Advertisement'; - case MeshCoreConstants.pushPathUpdated: - return 'Path Updated'; - case MeshCoreConstants.pushLogRxData: - return 'Log RX Data'; - case MeshCoreConstants.pushNewAdvert: - return 'New Advertisement'; - case MeshCoreConstants.pushStatusResponse: - return 'Status Response'; - case MeshCoreConstants.respNoMoreMessages: - return 'No More Messages'; - case MeshCoreConstants.respOk: - return 'OK'; - case MeshCoreConstants.respErr: - return 'ERROR'; - default: - return null; - } - } + await _commandSender.writeData(FrameBuilder.buildResetPath(contactPublicKey)); } /// Clear packet logs void clearPacketLogs() { - _packetLogs.clear(); + _commandSender.clearPacketLogs(); + _responseHandler.clearPacketLogs(); } /// Reset packet counters void resetCounters() { - _rxPacketCount = 0; - _txPacketCount = 0; + _commandSender.resetCounter(); + _responseHandler.resetCounter(); } /// Dispose resources void dispose() { - _txSubscription?.cancel(); - _pendingContacts.clear(); - _packetLogs.clear(); + _connectionManager.dispose(); + _commandSender.dispose(); + _responseHandler.dispose(); } } diff --git a/lib/services/protocol/frame_builder.dart b/lib/services/protocol/frame_builder.dart new file mode 100644 index 0000000..ca5aae1 --- /dev/null +++ b/lib/services/protocol/frame_builder.dart @@ -0,0 +1,238 @@ +import 'dart:convert'; +import 'dart:typed_data'; +import '../../models/contact.dart'; +import '../buffer_writer.dart'; +import '../meshcore_constants.dart'; + +/// Builds outgoing BLE frames for the MeshCore device +class FrameBuilder { + /// Build DeviceQuery command + static Uint8List buildDeviceQuery() { + final writer = BufferWriter(); + writer.writeByte(MeshCoreConstants.cmdDeviceQuery); + writer.writeByte(MeshCoreConstants.supportedCompanionProtocolVersion); + return writer.toBytes(); + } + + /// Build AppStart command + static Uint8List buildAppStart() { + final writer = BufferWriter(); + writer.writeByte(MeshCoreConstants.cmdAppStart); + writer.writeByte(1); // appVer + writer.writeBytes(Uint8List(6)); // reserved + writer.writeString('MeshCore SAR'); // appName + return writer.toBytes(); + } + + /// Build GetContacts command + static Uint8List buildGetContacts() { + final writer = BufferWriter(); + writer.writeByte(MeshCoreConstants.cmdGetContacts); + return writer.toBytes(); + } + + /// Build AddUpdateContact command + static Uint8List buildAddUpdateContact(Contact contact) { + final writer = BufferWriter(); + writer.writeByte(MeshCoreConstants.cmdAddUpdateContact); // 0x09 + writer.writeBytes(contact.publicKey); // 32 bytes + writer.writeByte(contact.type.value); // ADV_TYPE_* + writer.writeByte(contact.flags); // flags + writer.writeInt8(contact.outPathLen); // path length (signed byte) + writer.writeBytes(contact.outPath); // 64 bytes + + // Write name as null-terminated string in 32-byte field + final nameBytes = Uint8List(32); + final encoded = utf8.encode(contact.advName); + final copyLen = encoded.length > 31 ? 31 : encoded.length; + nameBytes.setRange(0, copyLen, encoded); + writer.writeBytes(nameBytes); + + writer.writeUInt32LE(contact.lastAdvert); // timestamp + writer.writeInt32LE(contact.advLat); // latitude * 1E6 + writer.writeInt32LE(contact.advLon); // longitude * 1E6 + + return writer.toBytes(); + } + + /// Build SendTxtMsg command + static Uint8List buildSendTxtMsg({ + required Uint8List contactPublicKey, + required String text, + int textType = 0, + int attempt = 0, + }) { + final writer = BufferWriter(); + writer.writeByte(MeshCoreConstants.cmdSendTxtMsg); // 0x02 + writer.writeByte(textType); // TXT_TYPE_* + writer.writeByte(attempt); // 0-3 + writer.writeUInt32LE(DateTime.now().millisecondsSinceEpoch ~/ 1000); + writer.writeBytes(contactPublicKey.sublist(0, 6)); + writer.writeString(text); + return writer.toBytes(); + } + + /// Build SendChannelTxtMsg command + static Uint8List buildSendChannelTxtMsg({ + required int channelIdx, + required String text, + int textType = 0, + }) { + final writer = BufferWriter(); + writer.writeByte(MeshCoreConstants.cmdSendChannelTxtMsg); // 0x03 + writer.writeByte(textType); // TXT_TYPE_* + writer.writeByte(channelIdx); // 0 for 'public' channel + writer.writeUInt32LE(DateTime.now().millisecondsSinceEpoch ~/ 1000); + writer.writeString(text); + return writer.toBytes(); + } + + /// Build SendTelemetryReq command (deprecated) + @Deprecated('Use buildSendBinaryReq() instead') + static Uint8List buildSendTelemetryReq(Uint8List contactPublicKey, {bool zeroHop = false}) { + final writer = BufferWriter(); + writer.writeByte(MeshCoreConstants.cmdSendTelemetryReq); + writer.writeByte(zeroHop ? 0 : 255); + writer.writeByte(0); // reserved + writer.writeByte(0); // reserved + writer.writeBytes(contactPublicKey); + return writer.toBytes(); + } + + /// Build SendBinaryReq command + static Uint8List buildSendBinaryReq({ + required Uint8List contactPublicKey, + required Uint8List requestData, + }) { + final writer = BufferWriter(); + writer.writeByte(MeshCoreConstants.cmdSendBinaryReq); // 0x32 (50) + writer.writeBytes(contactPublicKey); // 32 bytes + writer.writeBytes(requestData); // request code + params + return writer.toBytes(); + } + + /// Build GetBatteryVoltage command + static Uint8List buildGetBatteryAndStorage() { + final writer = BufferWriter(); + writer.writeByte(MeshCoreConstants.cmdGetBatteryVoltage); + return writer.toBytes(); + } + + /// Build SyncNextMessage command + static Uint8List buildSyncNextMessage() { + final writer = BufferWriter(); + writer.writeByte(MeshCoreConstants.cmdSyncNextMessage); + return writer.toBytes(); + } + + /// Build GetDeviceTime command + static Uint8List buildGetDeviceTime() { + final writer = BufferWriter(); + writer.writeByte(MeshCoreConstants.cmdGetDeviceTime); + return writer.toBytes(); + } + + /// Build SetDeviceTime command + static Uint8List buildSetDeviceTime() { + final writer = BufferWriter(); + writer.writeByte(MeshCoreConstants.cmdSetDeviceTime); + writer.writeUInt32LE(DateTime.now().millisecondsSinceEpoch ~/ 1000); + return writer.toBytes(); + } + + /// Build SendSelfAdvert command + static Uint8List buildSendSelfAdvert({bool floodMode = true}) { + final writer = BufferWriter(); + writer.writeByte(MeshCoreConstants.cmdSendSelfAdvert); + writer.writeByte(floodMode ? MeshCoreConstants.selfAdvertFlood : MeshCoreConstants.selfAdvertZeroHop); + return writer.toBytes(); + } + + /// Build SetAdvertName command + static Uint8List buildSetAdvertName(String name) { + final writer = BufferWriter(); + writer.writeByte(MeshCoreConstants.cmdSetAdvertName); + writer.writeString(name); + return writer.toBytes(); + } + + /// Build SetAdvertLatLon command + static Uint8List buildSetAdvertLatLon({ + required double latitude, + required double longitude, + }) { + final writer = BufferWriter(); + writer.writeByte(MeshCoreConstants.cmdSetAdvertLatLon); + writer.writeInt32LE((latitude * 1000000).round()); + writer.writeInt32LE((longitude * 1000000).round()); + return writer.toBytes(); + } + + /// Build SetRadioParams command + static Uint8List buildSetRadioParams({ + required int frequency, + required int bandwidth, + required int spreadingFactor, + required int codingRate, + }) { + final writer = BufferWriter(); + writer.writeByte(MeshCoreConstants.cmdSetRadioParams); + writer.writeUInt32LE(frequency); + writer.writeUInt16LE(bandwidth); + writer.writeByte(spreadingFactor); + writer.writeByte(codingRate); + return writer.toBytes(); + } + + /// Build SetTxPower command + static Uint8List buildSetTxPower(int powerDbm) { + final writer = BufferWriter(); + writer.writeByte(MeshCoreConstants.cmdSetTxPower); + writer.writeByte(powerDbm); + return writer.toBytes(); + } + + /// Build SetOtherParams command + static Uint8List buildSetOtherParams({ + required int manualAddContacts, + required int telemetryModes, + required int advertLocationPolicy, + int multiAcks = 0, + }) { + final writer = BufferWriter(); + writer.writeByte(MeshCoreConstants.cmdSetOtherParams); + writer.writeByte(manualAddContacts); + writer.writeByte(telemetryModes); + writer.writeByte(advertLocationPolicy); + writer.writeByte(multiAcks); + return writer.toBytes(); + } + + /// Build SendLogin command + static Uint8List buildSendLogin({ + required Uint8List roomPublicKey, + required String password, + }) { + final writer = BufferWriter(); + writer.writeByte(MeshCoreConstants.cmdSendLogin); // 0x1A + writer.writeBytes(roomPublicKey); // 32 bytes + writer.writeString(password); // Max 15 bytes, null-terminated + return writer.toBytes(); + } + + /// Build SendStatusReq command + static Uint8List buildSendStatusReq(Uint8List contactPublicKey) { + final writer = BufferWriter(); + writer.writeByte(MeshCoreConstants.cmdSendStatusReq); // 0x1B + writer.writeBytes(contactPublicKey); // 32 bytes + return writer.toBytes(); + } + + /// Build ResetPath command - clears learned path for a contact + static Uint8List buildResetPath(Uint8List contactPublicKey) { + final writer = BufferWriter(); + writer.writeByte(MeshCoreConstants.cmdResetPath); // 0x0D (13) + writer.writeBytes(contactPublicKey); // 32 bytes + return writer.toBytes(); + } +} diff --git a/lib/services/protocol/frame_parser.dart b/lib/services/protocol/frame_parser.dart new file mode 100644 index 0000000..7b2524d --- /dev/null +++ b/lib/services/protocol/frame_parser.dart @@ -0,0 +1,398 @@ +import 'dart:typed_data'; +import '../../models/contact.dart'; +import '../../models/message.dart'; +import '../buffer_reader.dart'; +import '../meshcore_constants.dart'; + +/// Parses incoming BLE frames from the MeshCore device +class FrameParser { + /// Parse ContactsStart response + static int parseContactsStart(BufferReader reader) { + return reader.readUInt32LE(); + } + + /// Parse Contact response + static Contact parseContact(BufferReader reader) { + final publicKey = reader.readBytes(32); + final typeByte = reader.readByte(); + final type = ContactType.fromValue(typeByte); + final flags = reader.readByte(); + final outPathLen = reader.readInt8(); + final outPath = reader.readBytes(64); + final advName = reader.readCString(32); + final lastAdvert = reader.readUInt32LE(); + final advLat = reader.readInt32LE(); + final advLon = reader.readInt32LE(); + final lastMod = reader.readUInt32LE(); + + return Contact( + publicKey: publicKey, + type: type, + flags: flags, + outPathLen: outPathLen, + outPath: outPath, + advName: advName, + lastAdvert: lastAdvert, + advLat: advLat, + advLon: advLon, + lastMod: lastMod, + ); + } + + /// Parse Sent confirmation response + static Map parseSentConfirmation(BufferReader reader) { + if (reader.remainingBytesCount >= 9) { + final sendType = reader.readByte(); + final isFloodMode = sendType == 1; + final expectedAckOrTagBytes = reader.readBytes(4); + final expectedAckTag = ByteData.sublistView(Uint8List.fromList(expectedAckOrTagBytes)) + .getUint32(0, Endian.little); + final suggestedTimeout = reader.readUInt32LE(); + + return { + 'expectedAckTag': expectedAckTag, + 'suggestedTimeout': suggestedTimeout, + 'isFloodMode': isFloodMode, + }; + } + return {}; + } + + /// Parse ContactMessage response + static Message parseContactMessage(BufferReader reader) { + final pubKeyPrefix = reader.readBytes(6); + final pathLen = reader.readByte(); + final txtTypeByte = reader.readByte(); + final txtType = MessageTextType.fromValue(txtTypeByte); + final senderTimestamp = reader.readUInt32LE(); + + String text; + if (txtType == MessageTextType.signedPlain) { + // Signed message format: [4-byte sender prefix][UTF-8 text] + if (reader.remainingBytesCount >= 4) { + reader.readBytes(4); // Skip extra sender prefix + text = reader.hasRemaining ? reader.readString() : ''; + } else { + text = reader.readString(); + } + } else { + text = reader.readString(); + } + + return Message( + id: '${DateTime.now().millisecondsSinceEpoch}_${pubKeyPrefix.map((b) => b.toRadixString(16)).join()}', + messageType: MessageType.contact, + senderPublicKeyPrefix: pubKeyPrefix, + pathLen: pathLen, + textType: txtType, + senderTimestamp: senderTimestamp, + text: text, + receivedAt: DateTime.now(), + ); + } + + /// Parse ChannelMessage response + static Message parseChannelMessage(BufferReader reader) { + final channelIdx = reader.readInt8(); + final pathLen = reader.readByte(); + final txtTypeByte = reader.readByte(); + final txtType = MessageTextType.fromValue(txtTypeByte); + final senderTimestamp = reader.readUInt32LE(); + + String text; + if (txtType == MessageTextType.signedPlain) { + if (reader.remainingBytesCount >= 4) { + reader.readBytes(4); // Skip extra sender prefix + text = reader.hasRemaining ? reader.readString() : ''; + } else { + text = reader.readString(); + } + } else { + text = reader.readString(); + } + + return Message( + id: '${DateTime.now().millisecondsSinceEpoch}_ch$channelIdx', + messageType: MessageType.channel, + channelIdx: channelIdx, + pathLen: pathLen, + textType: txtType, + senderTimestamp: senderTimestamp, + text: text, + receivedAt: DateTime.now(), + ); + } + + /// Parse TelemetryResponse push + static Map parseTelemetryResponse(BufferReader reader) { + reader.readByte(); // reserved + final pubKeyPrefix = reader.readBytes(6); + final lppSensorData = reader.readRemainingBytes(); + + return { + 'publicKeyPrefix': pubKeyPrefix, + 'lppSensorData': lppSensorData, + }; + } + + /// Parse BinaryResponse push + static Map parseBinaryResponse(BufferReader reader) { + reader.readByte(); // reserved + final tag = reader.readUInt32LE(); + final responseData = reader.readRemainingBytes(); + + return { + 'publicKeyPrefix': Uint8List(6), // Empty prefix + 'tag': tag, + 'responseData': responseData, + }; + } + + /// Parse DeviceInfo response + static Map parseDeviceInfo(BufferReader reader) { + if (reader.remainingBytesCount < 1) { + return {}; + } + + final firmwareVersion = reader.readByte(); + + int? maxContacts; + int? maxChannels; + int? blePin; + if (reader.remainingBytesCount >= 6) { + final maxContactsDiv2 = reader.readByte(); + maxContacts = maxContactsDiv2 * 2; + maxChannels = reader.readByte(); + blePin = reader.readUInt32LE(); + } + + String? firmwareBuildDate; + if (reader.remainingBytesCount >= 12) { + final buildDateBytes = reader.readBytes(12); + firmwareBuildDate = + String.fromCharCodes(buildDateBytes.takeWhile((b) => b != 0)); + } + + String? manufacturerModel; + if (reader.remainingBytesCount >= 40) { + final modelBytes = reader.readBytes(40); + manufacturerModel = + String.fromCharCodes(modelBytes.takeWhile((b) => b != 0)); + } + + String? semanticVersion; + if (reader.remainingBytesCount >= 20) { + final versionBytes = reader.readBytes(20); + semanticVersion = + String.fromCharCodes(versionBytes.takeWhile((b) => b != 0)); + } + + return { + 'firmwareVersion': firmwareVersion, + 'maxContacts': maxContacts, + 'maxChannels': maxChannels, + 'blePin': blePin, + 'firmwareBuildDate': firmwareBuildDate, + 'manufacturerModel': manufacturerModel, + 'semanticVersion': semanticVersion, + }; + } + + /// Parse SelfInfo response + static Map parseSelfInfo(BufferReader reader) { + if (reader.remainingBytesCount < 54) { + reader.readRemainingBytes(); + return {}; + } + + final deviceType = reader.readByte(); + final txPower = reader.readByte(); + final maxTxPower = reader.readByte(); + final publicKey = reader.readBytes(32); + + final advLatBytes = reader.readBytes(4); + final advLat = ByteData.sublistView(Uint8List.fromList(advLatBytes)) + .getInt32(0, Endian.little); + + final advLonBytes = reader.readBytes(4); + final advLon = ByteData.sublistView(Uint8List.fromList(advLonBytes)) + .getInt32(0, Endian.little); + + final multiAcks = reader.readByte(); + final advertLocPolicy = reader.readByte(); + final telemetryModes = reader.readByte(); + final manualAddContacts = reader.readByte(); + + final radioFreqBytes = reader.readBytes(4); + final radioFreq = ByteData.sublistView(Uint8List.fromList(radioFreqBytes)) + .getUint32(0, Endian.little); + + final radioBwBytes = reader.readBytes(4); + final radioBw = ByteData.sublistView(Uint8List.fromList(radioBwBytes)) + .getUint32(0, Endian.little); + + final radioSf = reader.readByte(); + final radioCr = reader.readByte(); + + String? selfName; + if (reader.hasRemaining) { + final nameBytes = reader.readRemainingBytes(); + selfName = String.fromCharCodes(nameBytes.takeWhile((b) => b != 0)); + } + + return { + 'deviceType': deviceType, + 'txPower': txPower, + 'maxTxPower': maxTxPower, + 'publicKey': publicKey, + 'advLat': advLat, + 'advLon': advLon, + 'manualAddContacts': manualAddContacts == 1, + 'radioFreq': radioFreq, + 'radioBw': radioBw, + 'radioSf': radioSf, + 'radioCr': radioCr, + 'selfName': selfName, + }; + } + + /// Parse Advert push + static Uint8List? parseAdvert(BufferReader reader) { + if (reader.remainingBytesCount >= 32) { + return reader.readBytes(32); + } + return null; + } + + /// Parse PathUpdated push + static Uint8List? parsePathUpdated(BufferReader reader) { + if (reader.remainingBytesCount >= 32) { + return reader.readBytes(32); + } + return null; + } + + /// Parse SendConfirmed push + static Map parseSendConfirmed(BufferReader reader) { + if (reader.remainingBytesCount >= 8) { + final ackCodeBytes = reader.readBytes(4); + final ackCode = ByteData.sublistView(Uint8List.fromList(ackCodeBytes)) + .getUint32(0, Endian.little); + final roundTripTime = reader.readUInt32LE(); + + return { + 'ackCode': ackCode, + 'roundTripTime': roundTripTime, + }; + } + return {}; + } + + /// Parse LoginSuccess push + static Map parseLoginSuccess(BufferReader reader) { + if (reader.remainingBytesCount >= 11) { + final permissions = reader.readByte(); + final isAdmin = (permissions & 0x01) != 0; + final publicKeyPrefix = reader.readBytes(6); + final tag = reader.readInt32LE(); + + int? newPermissions; + if (reader.hasRemaining) { + newPermissions = reader.readByte(); + } + + return { + 'publicKeyPrefix': publicKeyPrefix, + 'permissions': permissions, + 'isAdmin': isAdmin, + 'tag': tag, + 'newPermissions': newPermissions, + }; + } + return {}; + } + + /// Parse LoginFail push + static Uint8List? parseLoginFail(BufferReader reader) { + if (reader.remainingBytesCount >= 7) { + reader.readByte(); // reserved + return reader.readBytes(6); + } + return null; + } + + /// Parse StatusResponse push + static Map parseStatusResponse(BufferReader reader) { + if (reader.remainingBytesCount >= 7) { + reader.readByte(); // reserved + final publicKeyPrefix = reader.readBytes(6); + final statusData = reader.readRemainingBytes(); + + return { + 'publicKeyPrefix': publicKeyPrefix, + 'statusData': statusData, + }; + } + return {}; + } + + /// Parse CurrentTime response + static int? parseCurrentTime(BufferReader reader) { + if (reader.remainingBytesCount >= 4) { + return reader.readUInt32LE(); + } + return null; + } + + /// Parse BatteryAndStorage response + static Map parseBatteryAndStorage(BufferReader reader) { + if (reader.remainingBytesCount >= 2) { + final millivolts = reader.readUInt16LE(); + + int? usedKb; + int? totalKb; + + if (reader.remainingBytesCount >= 8) { + usedKb = reader.readUInt32LE(); + totalKb = reader.readUInt32LE(); + } else if (reader.remainingBytesCount >= 4) { + usedKb = reader.readUInt32LE(); + } + + return { + 'millivolts': millivolts, + 'usedKb': usedKb, + 'totalKb': totalKb, + }; + } + return {}; + } + + /// Parse Error response + static int? parseError(BufferReader reader) { + if (reader.hasRemaining) { + return reader.readByte(); + } + return null; + } + + /// Get error message from error code + static String getErrorMessage(int errorCode) { + switch (errorCode) { + case MeshCoreConstants.errUnsupportedCmd: + return 'Unsupported command'; + case MeshCoreConstants.errNotFound: + return 'Not found'; + case MeshCoreConstants.errTableFull: + return 'Table full'; + case MeshCoreConstants.errBadState: + return 'Bad state'; + case MeshCoreConstants.errFileIoError: + return 'File I/O error'; + case MeshCoreConstants.errIllegalArg: + return 'Illegal argument'; + default: + return 'Error code: $errorCode'; + } + } +} diff --git a/lib/widgets/contacts/contact_tile.dart b/lib/widgets/contacts/contact_tile.dart index 363b0cc..91ec3a4 100644 --- a/lib/widgets/contacts/contact_tile.dart +++ b/lib/widgets/contacts/contact_tile.dart @@ -580,6 +580,28 @@ class ContactTile extends StatelessWidget { ), ), ), + const SizedBox(height: 12), + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + onPressed: () { + connectionProvider.resetPath(contact.publicKey); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Path reset for ${contact.displayName}. Next message will find a new route.'), + duration: const Duration(seconds: 3), + ), + ); + }, + icon: const Icon(Icons.route), + label: const Text('Reset Path (Re-route)'), + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 14), + side: BorderSide(color: _getTypeColor(contact.type, context)), + foregroundColor: _getTypeColor(contact.type, context), + ), + ), + ), ], // Room Login button for room contacts (except Public Channel) if (contact.type == ContactType.room && contact.advName != 'Public Channel') ...[ diff --git a/lib/widgets/map/compass/compass_contact_list.dart b/lib/widgets/map/compass/compass_contact_list.dart new file mode 100644 index 0000000..adf4832 --- /dev/null +++ b/lib/widgets/map/compass/compass_contact_list.dart @@ -0,0 +1,176 @@ +import 'dart:math'; +import 'package:flutter/material.dart'; +import 'package:geolocator/geolocator.dart'; +import '../../../models/contact.dart'; + +/// Contact list section for the compass dialog. +/// Shows all contacts with location sorted by distance with bearing information. +class CompassContactList extends StatelessWidget { + final List contacts; + final Position? position; + final Contact? selectedContact; + final ValueChanged onContactTap; + + const CompassContactList({ + super.key, + required this.contacts, + required this.position, + required this.selectedContact, + required this.onContactTap, + }); + + @override + Widget build(BuildContext context) { + if (contacts.isEmpty) { + return const SizedBox.shrink(); + } + + if (position == null) { + return const Text('Location unavailable'); + } + + // Calculate bearings and distances + final contactsWithBearing = contacts.map((contact) { + if (contact.displayLocation == null) return null; + + final bearing = _calculateBearing( + position!.latitude, + position!.longitude, + contact.displayLocation!.latitude, + contact.displayLocation!.longitude, + ); + + final distance = _calculateDistance( + position!.latitude, + position!.longitude, + contact.displayLocation!.latitude, + contact.displayLocation!.longitude, + ); + + return { + 'contact': contact, + 'bearing': bearing, + 'distance': distance, + }; + }).whereType>().toList(); + + // Sort by distance + contactsWithBearing.sort((a, b) => + (a['distance'] as double).compareTo(b['distance'] as double)); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(left: 16, bottom: 8), + child: Text( + 'Nearby Contacts', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ), + ...contactsWithBearing.map((item) { + final contact = item['contact'] as Contact; + final bearing = item['bearing'] as double; + final distance = item['distance'] as double; + + return Container( + margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: selectedContact == contact + ? Theme.of(context).colorScheme.primaryContainer + : Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + border: selectedContact == contact + ? Border.all( + color: Theme.of(context).colorScheme.primary, + width: 2, + ) + : null, + ), + child: ListTile( + dense: true, + leading: contact.roleEmoji != null + ? Text( + contact.roleEmoji!, + style: const TextStyle(fontSize: 24), + ) + : Icon( + Icons.person, + color: Theme.of(context).colorScheme.primary, + size: 24, + ), + title: Text(contact.displayName), + subtitle: Text( + '${_bearingToCardinal(bearing)} β€’ ${_formatDistance(distance)}', + style: Theme.of(context).textTheme.bodySmall, + ), + trailing: Text( + '${bearing.round()}Β°', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + onTap: () { + if (selectedContact == contact) { + // Deselect if already selected + onContactTap(null); + } else { + // Select this contact + onContactTap(contact); + } + }, + ), + ); + }), + ], + ); + } + + // Calculate bearing between two points (in degrees) + double _calculateBearing( + double lat1, double lon1, double lat2, 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; + } + + // Calculate distance between two points (in meters) + double _calculateDistance( + double lat1, double lon1, double lat2, double lon2) { + const R = 6371000; // Earth's radius in meters + final dLat = (lat2 - lat1) * pi / 180; + final dLon = (lon2 - lon1) * pi / 180; + + final a = sin(dLat / 2) * sin(dLat / 2) + + cos(lat1 * pi / 180) * + cos(lat2 * pi / 180) * + sin(dLon / 2) * + sin(dLon / 2); + + final c = 2 * atan2(sqrt(a), sqrt(1 - a)); + return R * c; + } + + 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]; + } + + String _formatDistance(double meters) { + if (meters < 1000) { + return '${meters.round()}m'; + } else { + return '${(meters / 1000).toStringAsFixed(1)}km'; + } + } +} diff --git a/lib/widgets/map/compass/compass_filters.dart b/lib/widgets/map/compass/compass_filters.dart new file mode 100644 index 0000000..af15020 --- /dev/null +++ b/lib/widgets/map/compass/compass_filters.dart @@ -0,0 +1,181 @@ +import 'package:flutter/material.dart'; + +/// Filter controls for the compass dialog. +/// Allows filtering of contacts and SAR marker types. +class CompassFilters extends StatefulWidget { + final bool showContacts; + final bool showFoundPerson; + final bool showFire; + final bool showStagingArea; + final ValueChanged onShowContactsChanged; + final ValueChanged onShowFoundPersonChanged; + final ValueChanged onShowFireChanged; + final ValueChanged onShowStagingAreaChanged; + final VoidCallback onShowAll; + + const CompassFilters({ + super.key, + required this.showContacts, + required this.showFoundPerson, + required this.showFire, + required this.showStagingArea, + required this.onShowContactsChanged, + required this.onShowFoundPersonChanged, + required this.onShowFireChanged, + required this.onShowStagingAreaChanged, + required this.onShowAll, + }); + + @override + State createState() => _CompassFiltersState(); +} + +class _CompassFiltersState extends State { + void _showFilterDialog() { + showDialog( + context: context, + builder: (context) => StatefulBuilder( + builder: (context, setDialogState) => AlertDialog( + title: const Row( + children: [ + Icon(Icons.filter_list, size: 20), + SizedBox(width: 8), + Text('Filter Markers'), + ], + ), + contentPadding: const EdgeInsets.fromLTRB(24, 16, 24, 0), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Contacts filter + _CompactFilterItem( + icon: Icons.person, + color: Theme.of(context).colorScheme.primary, + label: 'Contacts', + value: widget.showContacts, + onChanged: (value) { + widget.onShowContactsChanged(value); + setDialogState(() {}); + }, + ), + const SizedBox(height: 4), + const Divider(height: 8), + const SizedBox(height: 4), + // SAR Markers section + Padding( + padding: const EdgeInsets.only(left: 8, bottom: 8, top: 4), + child: Text( + 'SAR Markers', + style: Theme.of(context).textTheme.labelLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ), + _CompactFilterItem( + icon: Icons.person_pin, + color: Colors.green, + label: 'Found Person', + value: widget.showFoundPerson, + onChanged: (value) { + widget.onShowFoundPersonChanged(value); + setDialogState(() {}); + }, + ), + const SizedBox(height: 4), + _CompactFilterItem( + icon: Icons.local_fire_department, + color: Colors.red, + label: 'Fire', + value: widget.showFire, + onChanged: (value) { + widget.onShowFireChanged(value); + setDialogState(() {}); + }, + ), + const SizedBox(height: 4), + _CompactFilterItem( + icon: Icons.home_work, + color: Colors.orange, + label: 'Staging Area', + value: widget.showStagingArea, + onChanged: (value) { + widget.onShowStagingAreaChanged(value); + setDialogState(() {}); + }, + ), + ], + ), + actions: [ + TextButton( + onPressed: () { + widget.onShowAll(); + setDialogState(() {}); + }, + child: const Text('Show All'), + ), + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Close'), + ), + ], + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + return IconButton( + icon: const Icon(Icons.filter_list), + tooltip: 'Filter markers', + onPressed: () => _showFilterDialog(), + ); + } +} + +/// Compact filter item widget +class _CompactFilterItem extends StatelessWidget { + final IconData icon; + final Color color; + final String label; + final bool value; + final ValueChanged onChanged; + + const _CompactFilterItem({ + required this.icon, + required this.color, + required this.label, + required this.value, + required this.onChanged, + }); + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: () => onChanged(!value), + borderRadius: BorderRadius.circular(8), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), + child: Row( + children: [ + Icon(icon, size: 20, color: color), + const SizedBox(width: 12), + Expanded( + child: Text( + label, + style: Theme.of(context).textTheme.bodyMedium, + ), + ), + Checkbox( + value: value, + onChanged: (val) => onChanged(val ?? false), + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + visualDensity: VisualDensity.compact, + ), + ], + ), + ), + ); + } +} diff --git a/lib/widgets/map/compass/compass_header.dart b/lib/widgets/map/compass/compass_header.dart new file mode 100644 index 0000000..13a0e5d --- /dev/null +++ b/lib/widgets/map/compass/compass_header.dart @@ -0,0 +1,598 @@ +import 'dart:math'; +import 'dart:ui' as ui; +import 'package:flutter/material.dart'; +import 'package:geolocator/geolocator.dart'; +import 'package:latlong2/latlong.dart'; +import '../../../models/contact.dart'; +import '../../../models/sar_marker.dart'; + +/// Header component for the compass dialog showing compass rose, +/// heading, elevation, accuracy, and current location in multiple formats. +class CompassHeader extends StatelessWidget { + final double? heading; + final Position? position; + final bool hasHeading; + final Position? currentPosition; + final List contacts; + final List sarMarkers; + final double zoomLevel; + final double previousScale; + final ValueChanged onZoomUpdate; + final VoidCallback onScaleStart; + final VoidCallback onScaleEnd; + + const CompassHeader({ + super.key, + required this.heading, + required this.position, + required this.hasHeading, + required this.currentPosition, + required this.contacts, + required this.sarMarkers, + required this.zoomLevel, + required this.previousScale, + required this.onZoomUpdate, + required this.onScaleStart, + required this.onScaleEnd, + }); + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Heading and Elevation info + _buildInfoRow(context, heading, position), + const SizedBox(height: 12), + // Current location in multiple formats + if (position != null) _LocationFormatToggle(position: position), + const SizedBox(height: 12), + // Large compass with zoom controls + GestureDetector( + onScaleStart: (details) { + onScaleStart(); + }, + onScaleUpdate: (details) { + onZoomUpdate(details.scale); + }, + onScaleEnd: (details) { + onScaleEnd(); + }, + child: SizedBox( + width: 300, + height: 300, + child: _DetailedCompassPainter( + heading: heading ?? 0, + hasHeading: hasHeading, + currentPosition: currentPosition, + contacts: contacts, + sarMarkers: sarMarkers, + zoomLevel: zoomLevel, + ), + ), + ), + ], + ); + } + + Widget _buildInfoRow(BuildContext context, double? heading, Position? position) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + _buildInfoCard( + context, + 'Heading', + heading != null ? '${heading.round()}Β°' : '--', + Icons.explore, + ), + _buildInfoCard( + context, + 'Elevation', + position?.altitude != null + ? '${position!.altitude.round()}m' + : '--', + Icons.terrain, + ), + _buildInfoCard( + context, + 'Accuracy', + position?.accuracy != null + ? 'Β±${position!.accuracy.round()}m' + : '--', + Icons.gps_fixed, + ), + ], + ); + } + + Widget _buildInfoCard( + BuildContext context, String label, String value, IconData icon) { + return Column( + children: [ + Icon(icon, size: 20, color: Theme.of(context).colorScheme.primary), + const SizedBox(height: 4), + Text( + value, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + Text( + label, + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ); + } +} + +/// Detailed Compass Painter with contacts +class _DetailedCompassPainter extends StatelessWidget { + final double heading; + final bool hasHeading; + final Position? currentPosition; + final List contacts; + final List sarMarkers; + final double zoomLevel; + + const _DetailedCompassPainter({ + required this.heading, + required this.hasHeading, + required this.currentPosition, + required this.contacts, + required this.sarMarkers, + this.zoomLevel = 1.0, + }); + + @override + Widget build(BuildContext context) { + return CustomPaint( + painter: _LargeCompassPainter( + heading: heading, + hasHeading: hasHeading, + currentPosition: currentPosition, + contacts: contacts, + sarMarkers: sarMarkers, + zoomLevel: zoomLevel, + ), + child: Container(), + ); + } +} + +class _LargeCompassPainter extends CustomPainter { + final double heading; + final bool hasHeading; + final Position? currentPosition; + final List contacts; + final List sarMarkers; + final double zoomLevel; + + _LargeCompassPainter({ + required this.heading, + required this.hasHeading, + required this.currentPosition, + required this.contacts, + required this.sarMarkers, + this.zoomLevel = 1.0, + }); + + @override + void paint(Canvas canvas, Size size) { + final center = Offset(size.width / 2, size.height / 2); + final radius = size.width / 2; + + // Draw outer circle + final circlePaint = Paint() + ..color = Colors.grey.withValues(alpha: 0.2) + ..style = PaintingStyle.stroke + ..strokeWidth = 2; + canvas.drawCircle(center, radius, circlePaint); + + // Draw degree markers + for (int i = 0; i < 360; i += 10) { + final angle = i * pi / 180 - pi / 2 + heading * pi / 180; + final isCardinal = i % 90 == 0; + final isMajor = i % 30 == 0; + + final startRadius = isCardinal ? radius - 25 : (isMajor ? radius - 15 : radius - 10); + final start = Offset( + center.dx + startRadius * cos(angle), + center.dy + startRadius * sin(angle), + ); + final end = Offset( + center.dx + radius * cos(angle), + center.dy + radius * sin(angle), + ); + + final markerPaint = Paint() + ..color = isCardinal ? Colors.red : Colors.grey + ..strokeWidth = isCardinal ? 3 : (isMajor ? 2 : 1); + + canvas.drawLine(start, end, markerPaint); + } + + // Draw cardinal directions + final textPainter = TextPainter(textDirection: TextDirection.ltr); + final directions = ['N', 'E', 'S', 'W']; + for (int i = 0; i < 4; i++) { + final angle = i * pi / 2 - pi / 2 + heading * pi / 180; + final x = center.dx + (radius - 35) * cos(angle); + final y = center.dy + (radius - 35) * sin(angle); + + textPainter.text = TextSpan( + text: directions[i], + style: TextStyle( + color: i == 0 ? Colors.red : Colors.grey.shade700, + fontSize: 24, + fontWeight: FontWeight.bold, + ), + ); + textPainter.layout(); + textPainter.paint( + canvas, + Offset(x - textPainter.width / 2, y - textPainter.height / 2), + ); + } + + // Draw contacts as dots relative to distance, scaled by zoom level + if (currentPosition != null && contacts.isNotEmpty) { + // Calculate distances for all contacts + final contactsWithDistance = contacts + .where((c) => c.displayLocation != null) + .map((contact) { + final bearing = _calculateBearing( + currentPosition!.latitude, + currentPosition!.longitude, + contact.displayLocation!.latitude, + contact.displayLocation!.longitude, + ); + final distance = _calculateDistance( + currentPosition!.latitude, + currentPosition!.longitude, + contact.displayLocation!.latitude, + contact.displayLocation!.longitude, + ); + return {'contact': contact, 'bearing': bearing, 'distance': distance}; + }).toList(); + + if (contactsWithDistance.isEmpty) return; + + // Base distance for zoom level 1.0 (in meters) + // At 1x zoom, contacts within 1km appear inside the compass + final baseDistance = 1000.0 / zoomLevel; + + for (final item in contactsWithDistance) { + final bearing = item['bearing'] as double; + final distance = item['distance'] as double; + + // Adjust bearing relative to current heading + final relativeBearing = (bearing - heading + 360) % 360; + final angle = relativeBearing * pi / 180 - pi / 2; + + // Calculate normalized distance (0 to 1, where 1 is at the rim) + // Apply zoom level: higher zoom = contacts appear closer + double normalizedDistance = (distance / baseDistance).clamp(0.0, 1.0); + + // Calculate contact position radius (from center to rim based on distance) + final contactRadius = radius * normalizedDistance * 0.85; // 0.85 to keep inside rim + + // Position of contact dot + final dotX = center.dx + contactRadius * cos(angle); + final dotY = center.dy + contactRadius * sin(angle); + + // Draw line from center to contact + final linePaint = Paint() + ..color = Colors.lightBlue.withValues(alpha: 0.3) + ..style = PaintingStyle.stroke + ..strokeWidth = 1.5; + canvas.drawLine( + center, + Offset(dotX, dotY), + linePaint, + ); + + // Draw contact dot (size varies with zoom) + final dotSize = (6.0 * (1.0 + zoomLevel * 0.3)).clamp(4.0, 12.0); + final dotPaint = Paint() + ..color = Colors.lightBlue + ..style = PaintingStyle.fill; + canvas.drawCircle(Offset(dotX, dotY), dotSize, dotPaint); + + // Draw white border + final borderPaint = Paint() + ..color = Colors.white + ..style = PaintingStyle.stroke + ..strokeWidth = 2; + canvas.drawCircle(Offset(dotX, dotY), dotSize, borderPaint); + + // Draw distance label near the contact (only if not too crowded) + if (zoomLevel >= 0.75) { + final distanceText = _formatDistance(distance); + final labelOffset = dotSize + 12; + final labelX = center.dx + (contactRadius + labelOffset) * cos(angle); + final labelY = center.dy + (contactRadius + labelOffset) * sin(angle); + + textPainter.text = TextSpan( + text: distanceText, + style: const TextStyle( + color: Colors.lightBlue, + fontSize: 9, + fontWeight: FontWeight.bold, + ), + ); + textPainter.layout(); + + // Draw background for readability + final bgRect = RRect.fromRectAndRadius( + Rect.fromCenter( + center: Offset(labelX, labelY), + width: textPainter.width + 4, + height: textPainter.height + 2, + ), + const Radius.circular(3), + ); + final bgPaint = Paint() + ..color = Colors.white.withValues(alpha: 0.9) + ..style = PaintingStyle.fill; + canvas.drawRRect(bgRect, bgPaint); + + textPainter.paint( + canvas, + Offset(labelX - textPainter.width / 2, labelY - textPainter.height / 2), + ); + } + } + } + + // Draw SAR markers as colored dots relative to distance, scaled by zoom level + if (currentPosition != null && sarMarkers.isNotEmpty) { + // Calculate distances for all SAR markers + final markersWithDistance = sarMarkers.map((marker) { + final bearing = _calculateBearing( + currentPosition!.latitude, + currentPosition!.longitude, + marker.location.latitude, + marker.location.longitude, + ); + final distance = _calculateDistance( + currentPosition!.latitude, + currentPosition!.longitude, + marker.location.latitude, + marker.location.longitude, + ); + return {'marker': marker, 'bearing': bearing, 'distance': distance}; + }).toList(); + + // Base distance for zoom level 1.0 (in meters) + final baseDistance = 1000.0 / zoomLevel; + + for (final item in markersWithDistance) { + final marker = item['marker'] as SarMarker; + final bearing = item['bearing'] as double; + final distance = item['distance'] as double; + + // Adjust bearing relative to current heading + final relativeBearing = (bearing - heading + 360) % 360; + final angle = relativeBearing * pi / 180 - pi / 2; + + // Calculate normalized distance (0 to 1, where 1 is at the rim) + double normalizedDistance = (distance / baseDistance).clamp(0.0, 1.0); + + // Calculate marker position radius (from center to rim based on distance) + final markerRadius = radius * normalizedDistance * 0.85; + + // Position of marker dot + final dotX = center.dx + markerRadius * cos(angle); + final dotY = center.dy + markerRadius * sin(angle); + + // Determine color based on SAR marker type + Color markerColor; + switch (marker.type) { + case SarMarkerType.foundPerson: + markerColor = Colors.green; + break; + case SarMarkerType.fire: + markerColor = Colors.red; + break; + case SarMarkerType.stagingArea: + markerColor = Colors.orange; + break; + case SarMarkerType.object: + markerColor = Colors.purple; + break; + case SarMarkerType.unknown: + markerColor = Colors.grey; + break; + } + + // Draw line from center to SAR marker + final linePaint = Paint() + ..color = markerColor.withValues(alpha: 0.3) + ..style = PaintingStyle.stroke + ..strokeWidth = 2; + canvas.drawLine( + center, + Offset(dotX, dotY), + linePaint, + ); + + // Draw SAR marker dot (slightly larger than contacts) + final dotSize = (8.0 * (1.0 + zoomLevel * 0.3)).clamp(6.0, 14.0); + final dotPaint = Paint() + ..color = markerColor + ..style = PaintingStyle.fill; + canvas.drawCircle(Offset(dotX, dotY), dotSize, dotPaint); + + // Draw white border + final borderPaint = Paint() + ..color = Colors.white + ..style = PaintingStyle.stroke + ..strokeWidth = 2.5; + canvas.drawCircle(Offset(dotX, dotY), dotSize, borderPaint); + + // Draw distance label near the SAR marker + if (zoomLevel >= 0.75) { + final distanceText = _formatDistance(distance); + final labelOffset = dotSize + 14; + final labelX = center.dx + (markerRadius + labelOffset) * cos(angle); + final labelY = center.dy + (markerRadius + labelOffset) * sin(angle); + + textPainter.text = TextSpan( + text: distanceText, + style: TextStyle( + color: markerColor, + fontSize: 10, + fontWeight: FontWeight.bold, + ), + ); + textPainter.layout(); + + // Draw background for readability + final bgRect = RRect.fromRectAndRadius( + Rect.fromCenter( + center: Offset(labelX, labelY), + width: textPainter.width + 4, + height: textPainter.height + 2, + ), + const Radius.circular(3), + ); + final bgPaint = Paint() + ..color = Colors.white.withValues(alpha: 0.9) + ..style = PaintingStyle.fill; + canvas.drawRRect(bgRect, bgPaint); + + textPainter.paint( + canvas, + Offset(labelX - textPainter.width / 2, labelY - textPainter.height / 2), + ); + } + } + } + + // Draw center heading indicator (fixed pointing up) + final indicatorPaint = Paint() + ..color = hasHeading ? Colors.red : Colors.grey + ..style = PaintingStyle.fill; + + final path = ui.Path() + ..moveTo(center.dx, center.dy - 40) + ..lineTo(center.dx - 10, center.dy + 10) + ..lineTo(center.dx + 10, center.dy + 10) + ..close(); + + canvas.drawPath(path, indicatorPaint); + } + + double _calculateBearing( + double lat1, double lon1, double lat2, 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; + } + + double _calculateDistance( + double lat1, double lon1, double lat2, double lon2) { + const R = 6371000; // Earth's radius in meters + final dLat = (lat2 - lat1) * pi / 180; + final dLon = (lon2 - lon1) * pi / 180; + + final a = sin(dLat / 2) * sin(dLat / 2) + + cos(lat1 * pi / 180) * + cos(lat2 * pi / 180) * + sin(dLon / 2) * + sin(dLon / 2); + + final c = 2 * atan2(sqrt(a), sqrt(1 - a)); + return R * c; + } + + String _formatDistance(double meters) { + if (meters < 1000) { + return '${meters.round()}m'; + } else { + return '${(meters / 1000).toStringAsFixed(1)}km'; + } + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => true; +} + +/// Location format toggle widget +class _LocationFormatToggle extends StatefulWidget { + final Position? position; + + const _LocationFormatToggle({required this.position}); + + @override + State<_LocationFormatToggle> createState() => _LocationFormatToggleState(); +} + +class _LocationFormatToggleState extends State<_LocationFormatToggle> { + bool _showDMS = false; + + String _formatDMS(double degrees, bool isLatitude) { + final direction = isLatitude + ? (degrees >= 0 ? 'N' : 'S') + : (degrees >= 0 ? 'E' : 'W'); + + final absolute = degrees.abs(); + final deg = absolute.floor(); + final minDecimal = (absolute - deg) * 60; + final min = minDecimal.floor(); + final sec = (minDecimal - min) * 60; + + return '$degΒ°${min.toString().padLeft(2, '0')}\'${sec.toStringAsFixed(2).padLeft(5, '0')}"$direction'; + } + + @override + Widget build(BuildContext context) { + final position = widget.position; + if (position == null) { + return const SizedBox.shrink(); + } + + final String displayText; + + if (_showDMS) { + displayText = '${_formatDMS(position.latitude, true)} ${_formatDMS(position.longitude, false)}'; + } else { + displayText = 'Lat: ${position.latitude.toStringAsFixed(5)} Lon: ${position.longitude.toStringAsFixed(5)}'; + } + + return GestureDetector( + onTap: () { + setState(() { + _showDMS = !_showDMS; + }); + }, + behavior: HitTestBehavior.opaque, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + ), + child: Center( + child: Text( + displayText, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + fontWeight: FontWeight.w500, + fontFamily: 'monospace', + fontSize: 11, + ), + ), + ), + ), + ); + } +} diff --git a/lib/widgets/map/compass/compass_sar_list.dart b/lib/widgets/map/compass/compass_sar_list.dart new file mode 100644 index 0000000..ea4013e --- /dev/null +++ b/lib/widgets/map/compass/compass_sar_list.dart @@ -0,0 +1,195 @@ +import 'dart:math'; +import 'package:flutter/material.dart'; +import 'package:geolocator/geolocator.dart'; +import '../../../models/sar_marker.dart'; + +/// SAR marker list section for the compass dialog. +/// Shows all filtered SAR markers sorted by distance with bearing information. +class CompassSarList extends StatelessWidget { + final List sarMarkers; + final Position? position; + final SarMarker? selectedSarMarker; + final ValueChanged onSarMarkerTap; + + const CompassSarList({ + super.key, + required this.sarMarkers, + required this.position, + required this.selectedSarMarker, + required this.onSarMarkerTap, + }); + + @override + Widget build(BuildContext context) { + if (sarMarkers.isEmpty) { + return const SizedBox.shrink(); + } + + if (position == null) { + return const Text('Location unavailable'); + } + + // Calculate bearings and distances for SAR markers + final markersWithBearing = sarMarkers.map((marker) { + final bearing = _calculateBearing( + position!.latitude, + position!.longitude, + marker.location.latitude, + marker.location.longitude, + ); + + final distance = _calculateDistance( + position!.latitude, + position!.longitude, + marker.location.latitude, + marker.location.longitude, + ); + + return { + 'marker': marker, + 'bearing': bearing, + 'distance': distance, + }; + }).toList(); + + // Sort by distance + markersWithBearing.sort((a, b) => + (a['distance'] as double).compareTo(b['distance'] as double)); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(left: 16, top: 16, bottom: 8), + child: Text( + 'SAR Markers', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ), + ...markersWithBearing.map((item) { + final marker = item['marker'] as SarMarker; + final bearing = item['bearing'] as double; + final distance = item['distance'] as double; + + // Determine color and icon based on marker type + Color markerColor; + IconData markerIcon; + switch (marker.type) { + case SarMarkerType.foundPerson: + markerColor = Colors.green; + markerIcon = Icons.person_pin; + break; + case SarMarkerType.fire: + markerColor = Colors.red; + markerIcon = Icons.local_fire_department; + break; + case SarMarkerType.stagingArea: + markerColor = Colors.orange; + markerIcon = Icons.home_work; + break; + case SarMarkerType.object: + markerColor = Colors.purple; + markerIcon = Icons.inventory_2; + break; + case SarMarkerType.unknown: + markerColor = Colors.grey; + markerIcon = Icons.help_outline; + break; + } + + return Container( + margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: selectedSarMarker == marker + ? Theme.of(context).colorScheme.primaryContainer + : Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + border: selectedSarMarker == marker + ? Border.all( + color: Theme.of(context).colorScheme.primary, + width: 2, + ) + : null, + ), + child: ListTile( + dense: true, + leading: Icon( + markerIcon, + color: markerColor, + size: 24, + ), + title: Text(marker.type.displayName), + subtitle: Text( + '${_bearingToCardinal(bearing)} β€’ ${_formatDistance(distance)} β€’ ${marker.timeAgo}', + style: Theme.of(context).textTheme.bodySmall, + ), + trailing: Text( + '${bearing.round()}Β°', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + onTap: () { + if (selectedSarMarker == marker) { + // Deselect if already selected + onSarMarkerTap(null); + } else { + // Select this marker + onSarMarkerTap(marker); + } + }, + ), + ); + }), + ], + ); + } + + // Calculate bearing between two points (in degrees) + double _calculateBearing( + double lat1, double lon1, double lat2, 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; + } + + // Calculate distance between two points (in meters) + double _calculateDistance( + double lat1, double lon1, double lat2, double lon2) { + const R = 6371000; // Earth's radius in meters + final dLat = (lat2 - lat1) * pi / 180; + final dLon = (lon2 - lon1) * pi / 180; + + final a = sin(dLat / 2) * sin(dLat / 2) + + cos(lat1 * pi / 180) * + cos(lat2 * pi / 180) * + sin(dLon / 2) * + sin(dLon / 2); + + final c = 2 * atan2(sqrt(a), sqrt(1 - a)); + return R * c; + } + + 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]; + } + + String _formatDistance(double meters) { + if (meters < 1000) { + return '${meters.round()}m'; + } else { + return '${(meters / 1000).toStringAsFixed(1)}km'; + } + } +} diff --git a/lib/widgets/map/detailed_compass_dialog.dart b/lib/widgets/map/detailed_compass_dialog.dart index 30ba413..1420c15 100644 --- a/lib/widgets/map/detailed_compass_dialog.dart +++ b/lib/widgets/map/detailed_compass_dialog.dart @@ -1,12 +1,15 @@ import 'dart:async'; import 'dart:math'; -import 'dart:ui' as ui; import 'package:flutter/material.dart'; import 'package:geolocator/geolocator.dart'; import 'package:flutter_compass/flutter_compass.dart'; import 'package:latlong2/latlong.dart'; import '../../models/contact.dart'; import '../../models/sar_marker.dart'; +import 'compass/compass_header.dart'; +import 'compass/compass_filters.dart'; +import 'compass/compass_sar_list.dart'; +import 'compass/compass_contact_list.dart'; class DetailedCompassDialog extends StatefulWidget { final Position? initialPosition; @@ -120,110 +123,28 @@ class _DetailedCompassDialogState extends State { }).toList(); } - void _showFilterDialog() { - showDialog( - context: context, - builder: (context) => StatefulBuilder( - builder: (context, setDialogState) => AlertDialog( - title: const Row( - children: [ - Icon(Icons.filter_list, size: 20), - SizedBox(width: 8), - Text('Filter Markers'), - ], - ), - contentPadding: const EdgeInsets.fromLTRB(24, 16, 24, 0), - content: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Contacts filter - _CompactFilterItem( - icon: Icons.person, - color: Theme.of(context).colorScheme.primary, - label: 'Contacts', - value: _showContacts, - onChanged: (value) { - setState(() { - _showContacts = value; - }); - setDialogState(() {}); - }, - ), - const SizedBox(height: 4), - const Divider(height: 8), - const SizedBox(height: 4), - // SAR Markers section - Padding( - padding: const EdgeInsets.only(left: 8, bottom: 8, top: 4), - child: Text( - 'SAR Markers', - style: Theme.of(context).textTheme.labelLarge?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - ), - _CompactFilterItem( - icon: Icons.person_pin, - color: Colors.green, - label: 'Found Person', - value: _showFoundPerson, - onChanged: (value) { - setState(() { - _showFoundPerson = value; - }); - setDialogState(() {}); - }, - ), - const SizedBox(height: 4), - _CompactFilterItem( - icon: Icons.local_fire_department, - color: Colors.red, - label: 'Fire', - value: _showFire, - onChanged: (value) { - setState(() { - _showFire = value; - }); - setDialogState(() {}); - }, - ), - const SizedBox(height: 4), - _CompactFilterItem( - icon: Icons.home_work, - color: Colors.orange, - label: 'Staging Area', - value: _showStagingArea, - onChanged: (value) { - setState(() { - _showStagingArea = value; - }); - setDialogState(() {}); - }, - ), - ], - ), - actions: [ - TextButton( - onPressed: () { - setState(() { - _showContacts = true; - _showFoundPerson = true; - _showFire = true; - _showStagingArea = true; - }); - setDialogState(() {}); - }, - child: const Text('Show All'), - ), - TextButton( - onPressed: () => Navigator.pop(context), - child: const Text('Close'), - ), - ], - ), - ), - ); + void _handleZoomUpdate(double scale) { + setState(() { + // Calculate scale delta from previous scale + final scaleDelta = scale - _previousScale; + + // Apply sensitivity factor to make it more coarse + final adjustedDelta = scaleDelta * _zoomSensitivity; + + // Apply the delta to current zoom level + _zoomLevel = (_zoomLevel * (1.0 + adjustedDelta)).clamp(_minZoom, _maxZoom); + + // Update previous scale + _previousScale = scale; + }); + } + + void _handleScaleStart() { + _previousScale = 1.0; + } + + void _handleScaleEnd() { + _previousScale = 1.0; } @override @@ -261,10 +182,39 @@ class _DetailedCompassDialogState extends State { ], ), ), - IconButton( - icon: const Icon(Icons.filter_list), - tooltip: 'Filter markers', - onPressed: () => _showFilterDialog(), + CompassFilters( + showContacts: _showContacts, + showFoundPerson: _showFoundPerson, + showFire: _showFire, + showStagingArea: _showStagingArea, + onShowContactsChanged: (value) { + setState(() { + _showContacts = value; + }); + }, + onShowFoundPersonChanged: (value) { + setState(() { + _showFoundPerson = value; + }); + }, + onShowFireChanged: (value) { + setState(() { + _showFire = value; + }); + }, + onShowStagingAreaChanged: (value) { + setState(() { + _showStagingArea = value; + }); + }, + onShowAll: () { + setState(() { + _showContacts = true; + _showFoundPerson = true; + _showFire = true; + _showStagingArea = true; + }); + }, ), ], ), @@ -276,55 +226,27 @@ class _DetailedCompassDialogState extends State { child: Column( mainAxisSize: MainAxisSize.min, children: [ - // Heading and Elevation info - _buildInfoRow(context, heading, position), - const SizedBox(height: 12), - // Current location in multiple formats - if (position != null) _buildLocationFormats(context, position), - const SizedBox(height: 12), - // Large compass with zoom controls - GestureDetector( - onScaleStart: (details) { - _previousScale = 1.0; - }, - onScaleUpdate: (details) { - setState(() { - // Calculate scale delta from previous scale - final scaleDelta = details.scale - _previousScale; - - // Apply sensitivity factor to make it more coarse - final adjustedDelta = scaleDelta * _zoomSensitivity; - - // Apply the delta to current zoom level - _zoomLevel = (_zoomLevel * (1.0 + adjustedDelta)).clamp(_minZoom, _maxZoom); - - // Update previous scale - _previousScale = details.scale; - }); - }, - onScaleEnd: (details) { - _previousScale = 1.0; - }, - child: SizedBox( - width: 300, - height: 300, - child: _DetailedCompassPainter( - heading: heading ?? 0, - hasHeading: heading != null, - currentPosition: position, - contacts: _selectedContact != null - ? [_selectedContact!] - : (_selectedSarMarker != null - ? [] - : (_showContacts ? widget.contacts : [])), - sarMarkers: _selectedSarMarker != null - ? [_selectedSarMarker!] - : (_selectedContact != null - ? [] - : _getFilteredSarMarkers()), - zoomLevel: _zoomLevel, - ), - ), + // Compass header with info and location formats + CompassHeader( + heading: heading, + position: position, + hasHeading: heading != null, + currentPosition: position, + contacts: _selectedContact != null + ? [_selectedContact!] + : (_selectedSarMarker != null + ? [] + : (_showContacts ? widget.contacts : [])), + sarMarkers: _selectedSarMarker != null + ? [_selectedSarMarker!] + : (_selectedContact != null + ? [] + : _getFilteredSarMarkers()), + zoomLevel: _zoomLevel, + previousScale: _previousScale, + onZoomUpdate: _handleZoomUpdate, + onScaleStart: _handleScaleStart, + onScaleEnd: _handleScaleEnd, ), const SizedBox(height: 12), // Selected item detail view @@ -332,9 +254,35 @@ class _DetailedCompassDialogState extends State { _buildSelectedItemDetail(context, heading, position), const SizedBox(height: 12), // Contacts list - if (_showContacts && widget.contacts.isNotEmpty) _buildContactsList(context, heading, position), + if (_showContacts && widget.contacts.isNotEmpty) + CompassContactList( + contacts: widget.contacts, + position: position, + selectedContact: _selectedContact, + onContactTap: (contact) { + setState(() { + _selectedContact = contact; + if (contact != null) { + _selectedSarMarker = null; + } + }); + }, + ), // SAR Markers list - if (_getFilteredSarMarkers().isNotEmpty) _buildSarMarkersList(context, heading, position), + if (_getFilteredSarMarkers().isNotEmpty) + CompassSarList( + sarMarkers: _getFilteredSarMarkers(), + position: position, + selectedSarMarker: _selectedSarMarker, + onSarMarkerTap: (marker) { + setState(() { + _selectedSarMarker = marker; + if (marker != null) { + _selectedContact = null; + } + }); + }, + ), ], ), ), @@ -344,60 +292,6 @@ class _DetailedCompassDialogState extends State { ); } - Widget _buildInfoRow(BuildContext context, double? heading, Position? position) { - return Row( - mainAxisAlignment: MainAxisAlignment.spaceAround, - children: [ - _buildInfoCard( - context, - 'Heading', - heading != null ? '${heading.round()}Β°' : '--', - Icons.explore, - ), - _buildInfoCard( - context, - 'Elevation', - position?.altitude != null - ? '${position!.altitude.round()}m' - : '--', - Icons.terrain, - ), - _buildInfoCard( - context, - 'Accuracy', - position?.accuracy != null - ? 'Β±${position!.accuracy.round()}m' - : '--', - Icons.gps_fixed, - ), - ], - ); - } - - Widget _buildInfoCard( - BuildContext context, String label, String value, IconData icon) { - return Column( - children: [ - Icon(icon, size: 20, color: Theme.of(context).colorScheme.primary), - const SizedBox(height: 4), - Text( - value, - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - Text( - label, - style: Theme.of(context).textTheme.bodySmall, - ), - ], - ); - } - - Widget _buildLocationFormats(BuildContext context, Position position) { - return _LocationFormatToggle(position: position); - } - Widget _buildSelectedItemDetail(BuildContext context, double? heading, Position? position) { if (position == null) { return const SizedBox.shrink(); @@ -628,257 +522,6 @@ class _DetailedCompassDialogState extends State { ); } - // Convert decimal degrees to DMS (Degrees, Minutes, Seconds) - String _formatDMS(double degrees, bool isLatitude) { - final direction = isLatitude - ? (degrees >= 0 ? 'N' : 'S') - : (degrees >= 0 ? 'E' : 'W'); - - final absolute = degrees.abs(); - final deg = absolute.floor(); - final minDecimal = (absolute - deg) * 60; - final min = minDecimal.floor(); - final sec = (minDecimal - min) * 60; - - return '$degΒ°${min.toString().padLeft(2, '0')}\'${sec.toStringAsFixed(2).padLeft(5, '0')}"$direction'; - } - - Widget _buildContactsList(BuildContext context, double? heading, Position? position) { - if (position == null) { - return const Text('Location unavailable'); - } - - // Calculate bearings and distances - final contactsWithBearing = widget.contacts.map((contact) { - if (contact.displayLocation == null) return null; - - final bearing = _calculateBearing( - position.latitude, - position.longitude, - contact.displayLocation!.latitude, - contact.displayLocation!.longitude, - ); - - final distance = _calculateDistance( - position.latitude, - position.longitude, - contact.displayLocation!.latitude, - contact.displayLocation!.longitude, - ); - - return { - 'contact': contact, - 'bearing': bearing, - 'distance': distance, - }; - }).whereType>().toList(); - - // Sort by distance - contactsWithBearing.sort((a, b) => - (a['distance'] as double).compareTo(b['distance'] as double)); - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.only(left: 16, bottom: 8), - child: Text( - 'Nearby Contacts', - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - ), - ...contactsWithBearing.map((item) { - final contact = item['contact'] as Contact; - final bearing = item['bearing'] as double; - final distance = item['distance'] as double; - - return Container( - margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: _selectedContact == contact - ? Theme.of(context).colorScheme.primaryContainer - : Theme.of(context).colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(8), - border: _selectedContact == contact - ? Border.all( - color: Theme.of(context).colorScheme.primary, - width: 2, - ) - : null, - ), - child: ListTile( - dense: true, - leading: contact.roleEmoji != null - ? Text( - contact.roleEmoji!, - style: const TextStyle(fontSize: 24), - ) - : Icon( - Icons.person, - color: Theme.of(context).colorScheme.primary, - size: 24, - ), - title: Text(contact.displayName), - subtitle: Text( - '${_bearingToCardinal(bearing)} β€’ ${_formatDistance(distance)}', - style: Theme.of(context).textTheme.bodySmall, - ), - trailing: Text( - '${bearing.round()}Β°', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - onTap: () { - setState(() { - if (_selectedContact == contact) { - // Deselect if already selected - _selectedContact = null; - } else { - // Select this contact and deselect SAR marker - _selectedContact = contact; - _selectedSarMarker = null; - } - }); - }, - ), - ); - }), - ], - ); - } - - Widget _buildSarMarkersList(BuildContext context, double? heading, Position? position) { - if (position == null) { - return const Text('Location unavailable'); - } - - // Use filtered SAR markers - final filteredMarkers = _getFilteredSarMarkers(); - - // Calculate bearings and distances for SAR markers - final markersWithBearing = filteredMarkers.map((marker) { - final bearing = _calculateBearing( - position.latitude, - position.longitude, - marker.location.latitude, - marker.location.longitude, - ); - - final distance = _calculateDistance( - position.latitude, - position.longitude, - marker.location.latitude, - marker.location.longitude, - ); - - return { - 'marker': marker, - 'bearing': bearing, - 'distance': distance, - }; - }).toList(); - - // Sort by distance - markersWithBearing.sort((a, b) => - (a['distance'] as double).compareTo(b['distance'] as double)); - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.only(left: 16, top: 16, bottom: 8), - child: Text( - 'SAR Markers', - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - ), - ...markersWithBearing.map((item) { - final marker = item['marker'] as SarMarker; - final bearing = item['bearing'] as double; - final distance = item['distance'] as double; - - // Determine color and icon based on marker type - Color markerColor; - IconData markerIcon; - switch (marker.type) { - case SarMarkerType.foundPerson: - markerColor = Colors.green; - markerIcon = Icons.person_pin; - break; - case SarMarkerType.fire: - markerColor = Colors.red; - markerIcon = Icons.local_fire_department; - break; - case SarMarkerType.stagingArea: - markerColor = Colors.orange; - markerIcon = Icons.home_work; - break; - case SarMarkerType.object: - markerColor = Colors.purple; - markerIcon = Icons.inventory_2; - break; - case SarMarkerType.unknown: - markerColor = Colors.grey; - markerIcon = Icons.help_outline; - break; - } - - return Container( - margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: _selectedSarMarker == marker - ? Theme.of(context).colorScheme.primaryContainer - : Theme.of(context).colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(8), - border: _selectedSarMarker == marker - ? Border.all( - color: Theme.of(context).colorScheme.primary, - width: 2, - ) - : null, - ), - child: ListTile( - dense: true, - leading: Icon( - markerIcon, - color: markerColor, - size: 24, - ), - title: Text(marker.type.displayName), - subtitle: Text( - '${_bearingToCardinal(bearing)} β€’ ${_formatDistance(distance)} β€’ ${marker.timeAgo}', - style: Theme.of(context).textTheme.bodySmall, - ), - trailing: Text( - '${bearing.round()}Β°', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - onTap: () { - setState(() { - if (_selectedSarMarker == marker) { - // Deselect if already selected - _selectedSarMarker = null; - } else { - // Select this marker and deselect contact - _selectedSarMarker = marker; - _selectedContact = null; - } - }); - }, - ), - ); - }), - ], - ); - } - // Calculate bearing between two points (in degrees) double _calculateBearing( double lat1, double lon1, double lat2, double lon2) { @@ -925,523 +568,3 @@ class _DetailedCompassDialogState extends State { } } } - -// Detailed Compass Painter with contacts -class _DetailedCompassPainter extends StatelessWidget { - final double heading; - final bool hasHeading; - final Position? currentPosition; - final List contacts; - final List sarMarkers; - final double zoomLevel; - - const _DetailedCompassPainter({ - required this.heading, - required this.hasHeading, - required this.currentPosition, - required this.contacts, - required this.sarMarkers, - this.zoomLevel = 1.0, - }); - - @override - Widget build(BuildContext context) { - return CustomPaint( - painter: _LargeCompassPainter( - heading: heading, - hasHeading: hasHeading, - currentPosition: currentPosition, - contacts: contacts, - sarMarkers: sarMarkers, - zoomLevel: zoomLevel, - ), - child: Container(), - ); - } -} - -class _LargeCompassPainter extends CustomPainter { - final double heading; - final bool hasHeading; - final Position? currentPosition; - final List contacts; - final List sarMarkers; - final double zoomLevel; - - _LargeCompassPainter({ - required this.heading, - required this.hasHeading, - required this.currentPosition, - required this.contacts, - required this.sarMarkers, - this.zoomLevel = 1.0, - }); - - @override - void paint(Canvas canvas, Size size) { - final center = Offset(size.width / 2, size.height / 2); - final radius = size.width / 2; - - // Draw outer circle - final circlePaint = Paint() - ..color = Colors.grey.withValues(alpha: 0.2) - ..style = PaintingStyle.stroke - ..strokeWidth = 2; - canvas.drawCircle(center, radius, circlePaint); - - // Draw degree markers - for (int i = 0; i < 360; i += 10) { - final angle = i * pi / 180 - pi / 2 + heading * pi / 180; - final isCardinal = i % 90 == 0; - final isMajor = i % 30 == 0; - - final startRadius = isCardinal ? radius - 25 : (isMajor ? radius - 15 : radius - 10); - final start = Offset( - center.dx + startRadius * cos(angle), - center.dy + startRadius * sin(angle), - ); - final end = Offset( - center.dx + radius * cos(angle), - center.dy + radius * sin(angle), - ); - - final markerPaint = Paint() - ..color = isCardinal ? Colors.red : Colors.grey - ..strokeWidth = isCardinal ? 3 : (isMajor ? 2 : 1); - - canvas.drawLine(start, end, markerPaint); - } - - // Draw cardinal directions - final textPainter = TextPainter(textDirection: TextDirection.ltr); - final directions = ['N', 'E', 'S', 'W']; - for (int i = 0; i < 4; i++) { - final angle = i * pi / 2 - pi / 2 + heading * pi / 180; - final x = center.dx + (radius - 35) * cos(angle); - final y = center.dy + (radius - 35) * sin(angle); - - textPainter.text = TextSpan( - text: directions[i], - style: TextStyle( - color: i == 0 ? Colors.red : Colors.grey.shade700, - fontSize: 24, - fontWeight: FontWeight.bold, - ), - ); - textPainter.layout(); - textPainter.paint( - canvas, - Offset(x - textPainter.width / 2, y - textPainter.height / 2), - ); - } - - // Draw contacts as dots relative to distance, scaled by zoom level - if (currentPosition != null && contacts.isNotEmpty) { - // Calculate distances for all contacts - final contactsWithDistance = contacts - .where((c) => c.displayLocation != null) - .map((contact) { - final bearing = _calculateBearing( - currentPosition!.latitude, - currentPosition!.longitude, - contact.displayLocation!.latitude, - contact.displayLocation!.longitude, - ); - final distance = _calculateDistance( - currentPosition!.latitude, - currentPosition!.longitude, - contact.displayLocation!.latitude, - contact.displayLocation!.longitude, - ); - return {'contact': contact, 'bearing': bearing, 'distance': distance}; - }).toList(); - - if (contactsWithDistance.isEmpty) return; - - // Find max distance for normalization - final maxDistance = contactsWithDistance - .map((c) => c['distance'] as double) - .reduce((a, b) => a > b ? a : b); - - // Base distance for zoom level 1.0 (in meters) - // At 1x zoom, contacts within 1km appear inside the compass - final baseDistance = 1000.0 / zoomLevel; - - for (final item in contactsWithDistance) { - final contact = item['contact'] as Contact; - final bearing = item['bearing'] as double; - final distance = item['distance'] as double; - - // Adjust bearing relative to current heading - final relativeBearing = (bearing - heading + 360) % 360; - final angle = relativeBearing * pi / 180 - pi / 2; - - // Calculate normalized distance (0 to 1, where 1 is at the rim) - // Apply zoom level: higher zoom = contacts appear closer - double normalizedDistance = (distance / baseDistance).clamp(0.0, 1.0); - - // Calculate contact position radius (from center to rim based on distance) - final contactRadius = radius * normalizedDistance * 0.85; // 0.85 to keep inside rim - - // Position of contact dot - final dotX = center.dx + contactRadius * cos(angle); - final dotY = center.dy + contactRadius * sin(angle); - - // Draw line from center to contact - final linePaint = Paint() - ..color = Colors.lightBlue.withValues(alpha: 0.3) - ..style = PaintingStyle.stroke - ..strokeWidth = 1.5; - canvas.drawLine( - center, - Offset(dotX, dotY), - linePaint, - ); - - // Draw contact dot (size varies with zoom) - final dotSize = (6.0 * (1.0 + zoomLevel * 0.3)).clamp(4.0, 12.0); - final dotPaint = Paint() - ..color = Colors.lightBlue - ..style = PaintingStyle.fill; - canvas.drawCircle(Offset(dotX, dotY), dotSize, dotPaint); - - // Draw white border - final borderPaint = Paint() - ..color = Colors.white - ..style = PaintingStyle.stroke - ..strokeWidth = 2; - canvas.drawCircle(Offset(dotX, dotY), dotSize, borderPaint); - - // Draw distance label near the contact (only if not too crowded) - if (zoomLevel >= 0.75) { - final distanceText = _formatDistance(distance); - final labelOffset = dotSize + 12; - final labelX = center.dx + (contactRadius + labelOffset) * cos(angle); - final labelY = center.dy + (contactRadius + labelOffset) * sin(angle); - - textPainter.text = TextSpan( - text: distanceText, - style: const TextStyle( - color: Colors.lightBlue, - fontSize: 9, - fontWeight: FontWeight.bold, - ), - ); - textPainter.layout(); - - // Draw background for readability - final bgRect = RRect.fromRectAndRadius( - Rect.fromCenter( - center: Offset(labelX, labelY), - width: textPainter.width + 4, - height: textPainter.height + 2, - ), - const Radius.circular(3), - ); - final bgPaint = Paint() - ..color = Colors.white.withValues(alpha: 0.9) - ..style = PaintingStyle.fill; - canvas.drawRRect(bgRect, bgPaint); - - textPainter.paint( - canvas, - Offset(labelX - textPainter.width / 2, labelY - textPainter.height / 2), - ); - } - } - } - - // Draw SAR markers as colored dots relative to distance, scaled by zoom level - if (currentPosition != null && sarMarkers.isNotEmpty) { - // Calculate distances for all SAR markers - final markersWithDistance = sarMarkers.map((marker) { - final bearing = _calculateBearing( - currentPosition!.latitude, - currentPosition!.longitude, - marker.location.latitude, - marker.location.longitude, - ); - final distance = _calculateDistance( - currentPosition!.latitude, - currentPosition!.longitude, - marker.location.latitude, - marker.location.longitude, - ); - return {'marker': marker, 'bearing': bearing, 'distance': distance}; - }).toList(); - - // Base distance for zoom level 1.0 (in meters) - final baseDistance = 1000.0 / zoomLevel; - - for (final item in markersWithDistance) { - final marker = item['marker'] as SarMarker; - final bearing = item['bearing'] as double; - final distance = item['distance'] as double; - - // Adjust bearing relative to current heading - final relativeBearing = (bearing - heading + 360) % 360; - final angle = relativeBearing * pi / 180 - pi / 2; - - // Calculate normalized distance (0 to 1, where 1 is at the rim) - double normalizedDistance = (distance / baseDistance).clamp(0.0, 1.0); - - // Calculate marker position radius (from center to rim based on distance) - final markerRadius = radius * normalizedDistance * 0.85; - - // Position of marker dot - final dotX = center.dx + markerRadius * cos(angle); - final dotY = center.dy + markerRadius * sin(angle); - - // Determine color based on SAR marker type - Color markerColor; - switch (marker.type) { - case SarMarkerType.foundPerson: - markerColor = Colors.green; - break; - case SarMarkerType.fire: - markerColor = Colors.red; - break; - case SarMarkerType.stagingArea: - markerColor = Colors.orange; - break; - case SarMarkerType.object: - markerColor = Colors.purple; - break; - case SarMarkerType.unknown: - markerColor = Colors.grey; - break; - } - - // Draw line from center to SAR marker - final linePaint = Paint() - ..color = markerColor.withValues(alpha: 0.3) - ..style = PaintingStyle.stroke - ..strokeWidth = 2; - canvas.drawLine( - center, - Offset(dotX, dotY), - linePaint, - ); - - // Draw SAR marker dot (slightly larger than contacts) - final dotSize = (8.0 * (1.0 + zoomLevel * 0.3)).clamp(6.0, 14.0); - final dotPaint = Paint() - ..color = markerColor - ..style = PaintingStyle.fill; - canvas.drawCircle(Offset(dotX, dotY), dotSize, dotPaint); - - // Draw white border - final borderPaint = Paint() - ..color = Colors.white - ..style = PaintingStyle.stroke - ..strokeWidth = 2.5; - canvas.drawCircle(Offset(dotX, dotY), dotSize, borderPaint); - - // Draw distance label near the SAR marker - if (zoomLevel >= 0.75) { - final distanceText = _formatDistance(distance); - final labelOffset = dotSize + 14; - final labelX = center.dx + (markerRadius + labelOffset) * cos(angle); - final labelY = center.dy + (markerRadius + labelOffset) * sin(angle); - - textPainter.text = TextSpan( - text: distanceText, - style: TextStyle( - color: markerColor, - fontSize: 10, - fontWeight: FontWeight.bold, - ), - ); - textPainter.layout(); - - // Draw background for readability - final bgRect = RRect.fromRectAndRadius( - Rect.fromCenter( - center: Offset(labelX, labelY), - width: textPainter.width + 4, - height: textPainter.height + 2, - ), - const Radius.circular(3), - ); - final bgPaint = Paint() - ..color = Colors.white.withValues(alpha: 0.9) - ..style = PaintingStyle.fill; - canvas.drawRRect(bgRect, bgPaint); - - textPainter.paint( - canvas, - Offset(labelX - textPainter.width / 2, labelY - textPainter.height / 2), - ); - } - } - } - - // Draw center heading indicator (fixed pointing up) - final indicatorPaint = Paint() - ..color = hasHeading ? Colors.red : Colors.grey - ..style = PaintingStyle.fill; - - final path = ui.Path() - ..moveTo(center.dx, center.dy - 40) - ..lineTo(center.dx - 10, center.dy + 10) - ..lineTo(center.dx + 10, center.dy + 10) - ..close(); - - canvas.drawPath(path, indicatorPaint); - } - - double _calculateBearing( - double lat1, double lon1, double lat2, 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; - } - - double _calculateDistance( - double lat1, double lon1, double lat2, double lon2) { - const R = 6371000; // Earth's radius in meters - final dLat = (lat2 - lat1) * pi / 180; - final dLon = (lon2 - lon1) * pi / 180; - - final a = sin(dLat / 2) * sin(dLat / 2) + - cos(lat1 * pi / 180) * - cos(lat2 * pi / 180) * - sin(dLon / 2) * - sin(dLon / 2); - - final c = 2 * atan2(sqrt(a), sqrt(1 - a)); - return R * c; - } - - String _formatDistance(double meters) { - if (meters < 1000) { - return '${meters.round()}m'; - } else { - return '${(meters / 1000).toStringAsFixed(1)}km'; - } - } - - @override - bool shouldRepaint(covariant CustomPainter oldDelegate) => true; -} - -// Compact filter item widget -class _CompactFilterItem extends StatelessWidget { - final IconData icon; - final Color color; - final String label; - final bool value; - final ValueChanged onChanged; - - const _CompactFilterItem({ - required this.icon, - required this.color, - required this.label, - required this.value, - required this.onChanged, - }); - - @override - Widget build(BuildContext context) { - return InkWell( - onTap: () => onChanged(!value), - borderRadius: BorderRadius.circular(8), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), - child: Row( - children: [ - Icon(icon, size: 20, color: color), - const SizedBox(width: 12), - Expanded( - child: Text( - label, - style: Theme.of(context).textTheme.bodyMedium, - ), - ), - Checkbox( - value: value, - onChanged: (val) => onChanged(val ?? false), - materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, - visualDensity: VisualDensity.compact, - ), - ], - ), - ), - ); - } -} - -// Location format toggle widget -class _LocationFormatToggle extends StatefulWidget { - final Position position; - - const _LocationFormatToggle({required this.position}); - - @override - State<_LocationFormatToggle> createState() => _LocationFormatToggleState(); -} - -class _LocationFormatToggleState extends State<_LocationFormatToggle> { - bool _showDMS = false; - - String _formatDMS(double degrees, bool isLatitude) { - final direction = isLatitude - ? (degrees >= 0 ? 'N' : 'S') - : (degrees >= 0 ? 'E' : 'W'); - - final absolute = degrees.abs(); - final deg = absolute.floor(); - final minDecimal = (absolute - deg) * 60; - final min = minDecimal.floor(); - final sec = (minDecimal - min) * 60; - - return '$degΒ°${min.toString().padLeft(2, '0')}\'${sec.toStringAsFixed(2).padLeft(5, '0')}"$direction'; - } - - @override - Widget build(BuildContext context) { - final position = widget.position; - - final String displayText; - - if (_showDMS) { - displayText = '${_formatDMS(position.latitude, true)} ${_formatDMS(position.longitude, false)}'; - } else { - displayText = 'Lat: ${position.latitude.toStringAsFixed(5)} Lon: ${position.longitude.toStringAsFixed(5)}'; - } - - return GestureDetector( - onTap: () { - setState(() { - _showDMS = !_showDMS; - }); - }, - behavior: HitTestBehavior.opaque, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(8), - ), - child: Center( - child: Text( - displayText, - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - fontWeight: FontWeight.w500, - fontFamily: 'monospace', - fontSize: 11, - ), - ), - ), - ), - ); - } -}