mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 16:30:28 +00:00
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.
This commit is contained in:
@@ -2,7 +2,6 @@ import 'dart:async';
|
|||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
|
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
|
||||||
import '../models/device_info.dart';
|
import '../models/device_info.dart';
|
||||||
import '../models/contact.dart';
|
import '../models/contact.dart';
|
||||||
import '../models/message.dart';
|
import '../models/message.dart';
|
||||||
@@ -10,6 +9,8 @@ import '../models/room_login_state.dart';
|
|||||||
import '../services/meshcore_ble_service.dart';
|
import '../services/meshcore_ble_service.dart';
|
||||||
import '../services/cayenne_lpp_parser.dart';
|
import '../services/cayenne_lpp_parser.dart';
|
||||||
import '../utils/sar_message_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
|
/// Connection Provider - manages MeshCore BLE connection
|
||||||
class ConnectionProvider with ChangeNotifier {
|
class ConnectionProvider with ChangeNotifier {
|
||||||
@@ -46,13 +47,12 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
// Message sync state
|
// Message sync state
|
||||||
bool _noMoreMessages = false;
|
bool _noMoreMessages = false;
|
||||||
|
|
||||||
// Room login state tracking
|
// Helper instances
|
||||||
final Map<String, RoomLoginState> _roomLoginStates = {};
|
final RoomLoginManager _roomLoginManager = RoomLoginManager();
|
||||||
Map<String, RoomLoginState> get roomLoginStates => Map.unmodifiable(_roomLoginStates);
|
final MessageDeliveryTracker _messageDeliveryTracker = MessageDeliveryTracker();
|
||||||
|
|
||||||
// Track sent message IDs by ACK tag for delivery confirmation
|
// Expose room login states
|
||||||
final Map<int, String> _ackTagToMessageId = {};
|
Map<String, RoomLoginState> get roomLoginStates => _roomLoginManager.roomLoginStates;
|
||||||
final List<String> _pendingSentMessageIds = []; // Queue of pending message IDs
|
|
||||||
|
|
||||||
// Callbacks for other providers
|
// Callbacks for other providers
|
||||||
Function(Contact)? onContactReceived;
|
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(' Public key prefix: ${publicKeyPrefix.map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}');
|
||||||
print(' Permissions: $permissions, Admin: $isAdmin, Tag: $tag');
|
print(' Permissions: $permissions, Admin: $isAdmin, Tag: $tag');
|
||||||
|
|
||||||
// Update room login state
|
// Update room login state via helper
|
||||||
final prefixHex = publicKeyPrefix.map((b) => b.toRadixString(16).padLeft(2, '0')).join(':');
|
await _roomLoginManager.handleLoginSuccess(
|
||||||
final hasPassword = await _hasPasswordForRoom(publicKeyPrefix);
|
|
||||||
_roomLoginStates[prefixHex] = RoomLoginState.loggedIn(
|
|
||||||
publicKeyPrefix: publicKeyPrefix,
|
publicKeyPrefix: publicKeyPrefix,
|
||||||
permissions: permissions,
|
permissions: permissions,
|
||||||
isAdmin: isAdmin,
|
isAdmin: isAdmin,
|
||||||
tag: tag,
|
tag: tag,
|
||||||
hasPassword: hasPassword,
|
|
||||||
);
|
);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|
||||||
@@ -168,11 +165,9 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
print('📥 [Provider] Login failed to room');
|
print('📥 [Provider] Login failed to room');
|
||||||
print(' Public key prefix: ${publicKeyPrefix.map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}');
|
print(' Public key prefix: ${publicKeyPrefix.map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}');
|
||||||
|
|
||||||
// Update room login state to logged out
|
// Update room login state to logged out via helper
|
||||||
final prefixHex = publicKeyPrefix.map((b) => b.toRadixString(16).padLeft(2, '0')).join(':');
|
_roomLoginManager.handleLoginFail(
|
||||||
_roomLoginStates[prefixHex] = RoomLoginState.loggedOut(
|
|
||||||
publicKeyPrefix: publicKeyPrefix,
|
publicKeyPrefix: publicKeyPrefix,
|
||||||
hasPassword: false, // Password was incorrect
|
|
||||||
);
|
);
|
||||||
notifyListeners();
|
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<void> 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
|
/// Clear error message
|
||||||
void clearError() {
|
void clearError() {
|
||||||
_error = null;
|
_error = null;
|
||||||
|
|||||||
71
lib/providers/helpers/message_delivery_tracker.dart
Normal file
71
lib/providers/helpers/message_delivery_tracker.dart
Normal file
@@ -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<int, String> _ackTagToMessageId = {};
|
||||||
|
|
||||||
|
/// Queue of pending message IDs (FIFO)
|
||||||
|
/// Messages must be sent sequentially for proper matching
|
||||||
|
final List<String> _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;
|
||||||
|
}
|
||||||
85
lib/providers/helpers/room_login_manager.dart
Normal file
85
lib/providers/helpers/room_login_manager.dart
Normal file
@@ -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<String, RoomLoginState>)
|
||||||
|
/// - Password checking logic
|
||||||
|
/// - Login success/fail state updates
|
||||||
|
class RoomLoginManager {
|
||||||
|
/// Map of room public key prefix (hex string) to login state
|
||||||
|
final Map<String, RoomLoginState> _roomLoginStates = {};
|
||||||
|
|
||||||
|
/// Get all room login states (unmodifiable view)
|
||||||
|
Map<String, RoomLoginState> 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<void> 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<bool> _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(':');
|
||||||
|
}
|
||||||
|
}
|
||||||
131
lib/services/ble/ble_command_sender.dart
Normal file
131
lib/services/ble/ble_command_sender.dart
Normal file
@@ -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<BlePacketLog> _packetLogs = [];
|
||||||
|
static const int _maxLogSize = 1000;
|
||||||
|
|
||||||
|
// Callbacks
|
||||||
|
OnErrorCallback? onError;
|
||||||
|
VoidCallback? onTxActivity;
|
||||||
|
|
||||||
|
// Getters
|
||||||
|
int get txPacketCount => _txPacketCount;
|
||||||
|
List<BlePacketLog> get packetLogs => List.unmodifiable(_packetLogs);
|
||||||
|
|
||||||
|
/// Set the RX characteristic to write to
|
||||||
|
void setRxCharacteristic(BluetoothCharacteristic? characteristic) {
|
||||||
|
_rxCharacteristic = characteristic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write data to RX characteristic
|
||||||
|
Future<void> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
176
lib/services/ble/ble_connection_manager.dart
Normal file
176
lib/services/ble/ble_connection_manager.dart
Normal file
@@ -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<BluetoothDevice> 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<bool> 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<void> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
656
lib/services/ble/ble_response_handler.dart
Normal file
656
lib/services/ble/ble_response_handler.dart
Normal file
@@ -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<Contact> contacts);
|
||||||
|
typedef OnMessageCallback = void Function(Message message);
|
||||||
|
typedef OnTelemetryCallback = void Function(Uint8List publicKey, Uint8List lppData);
|
||||||
|
typedef OnSelfInfoCallback = void Function(Map<String, dynamic> selfInfo);
|
||||||
|
typedef OnDeviceInfoCallback = void Function(Map<String, dynamic> 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<Contact> _pendingContacts = [];
|
||||||
|
int _rxPacketCount = 0;
|
||||||
|
final List<BlePacketLog> _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<BlePacketLog> 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<int> 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<void> dispose() async {
|
||||||
|
await _txSubscription?.cancel();
|
||||||
|
_pendingContacts.clear();
|
||||||
|
_packetLogs.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
238
lib/services/protocol/frame_builder.dart
Normal file
238
lib/services/protocol/frame_builder.dart
Normal file
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
398
lib/services/protocol/frame_parser.dart
Normal file
398
lib/services/protocol/frame_parser.dart
Normal file
@@ -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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
// Room Login button for room contacts (except Public Channel)
|
||||||
if (contact.type == ContactType.room && contact.advName != 'Public Channel') ...[
|
if (contact.type == ContactType.room && contact.advName != 'Public Channel') ...[
|
||||||
|
|||||||
176
lib/widgets/map/compass/compass_contact_list.dart
Normal file
176
lib/widgets/map/compass/compass_contact_list.dart
Normal file
@@ -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<Contact> contacts;
|
||||||
|
final Position? position;
|
||||||
|
final Contact? selectedContact;
|
||||||
|
final ValueChanged<Contact?> 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<Map<String, dynamic>>().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';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
181
lib/widgets/map/compass/compass_filters.dart
Normal file
181
lib/widgets/map/compass/compass_filters.dart
Normal file
@@ -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<bool> onShowContactsChanged;
|
||||||
|
final ValueChanged<bool> onShowFoundPersonChanged;
|
||||||
|
final ValueChanged<bool> onShowFireChanged;
|
||||||
|
final ValueChanged<bool> 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<CompassFilters> createState() => _CompassFiltersState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CompassFiltersState extends State<CompassFilters> {
|
||||||
|
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<bool> 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,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
598
lib/widgets/map/compass/compass_header.dart
Normal file
598
lib/widgets/map/compass/compass_header.dart
Normal file
@@ -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<Contact> contacts;
|
||||||
|
final List<SarMarker> sarMarkers;
|
||||||
|
final double zoomLevel;
|
||||||
|
final double previousScale;
|
||||||
|
final ValueChanged<double> 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<Contact> contacts;
|
||||||
|
final List<SarMarker> 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<Contact> contacts;
|
||||||
|
final List<SarMarker> 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
195
lib/widgets/map/compass/compass_sar_list.dart
Normal file
195
lib/widgets/map/compass/compass_sar_list.dart
Normal file
@@ -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<SarMarker> sarMarkers;
|
||||||
|
final Position? position;
|
||||||
|
final SarMarker? selectedSarMarker;
|
||||||
|
final ValueChanged<SarMarker?> 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';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user