mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 16:30:28 +00:00
Refactor code for improved readability and consistency across multiple files
- Updated formatting in `drawing_toolbar.dart` for better readability, including breaking long lines and ensuring consistent indentation. - Enhanced the `recipient_selector_sheet.dart` by adjusting line breaks and improving the layout of text styles. - Refactored `sar_update_sheet.dart` to improve readability, including restructuring widget layouts and ensuring consistent formatting. - Cleaned up imports and removed unnecessary lines in `widget_test.dart`.
This commit is contained in:
@@ -29,11 +29,11 @@ enum MessageType {
|
||||
|
||||
/// Message delivery status
|
||||
enum MessageDeliveryStatus {
|
||||
sending, // Message is being sent
|
||||
sent, // Message queued with expected ACK
|
||||
delivered, // Delivery confirmed (ACK received)
|
||||
failed, // Delivery failed
|
||||
received, // Message received from another contact
|
||||
sending, // Message is being sent
|
||||
sent, // Message queued with expected ACK
|
||||
delivered, // Delivery confirmed (ACK received)
|
||||
failed, // Delivery failed
|
||||
received, // Message received from another contact
|
||||
}
|
||||
|
||||
/// MeshCore message model
|
||||
@@ -64,12 +64,14 @@ class Message {
|
||||
final int? suggestedTimeoutMs; // Suggested timeout from SENT response
|
||||
final int? roundTripTimeMs; // RTT from SEND_CONFIRMED
|
||||
final DateTime? deliveredAt; // When delivery was confirmed
|
||||
final Uint8List? recipientPublicKey; // Full 32-byte public key of recipient (for retry)
|
||||
final Uint8List?
|
||||
recipientPublicKey; // Full 32-byte public key of recipient (for retry)
|
||||
|
||||
// Retry tracking (for automatic retry with progressive timeouts)
|
||||
final int retryAttempt; // Current retry attempt (0-3), 0 = first send
|
||||
final DateTime? lastRetryAt; // When last retry was sent
|
||||
final bool usedFloodFallback; // Whether message fell back to flood mode after retries
|
||||
final bool
|
||||
usedFloodFallback; // Whether message fell back to flood mode after retries
|
||||
|
||||
// Read status tracking
|
||||
final bool isRead; // Whether message has been read by user
|
||||
@@ -176,10 +178,10 @@ class Message {
|
||||
|
||||
// Debug: Check what's in sarNotes
|
||||
debugPrint('📍 [Message.toSarMarker] Converting to marker:');
|
||||
debugPrint(' message.text: "${text}"');
|
||||
debugPrint(' message.sarNotes: "${sarNotes}"');
|
||||
debugPrint(' message.sarMarkerType: ${sarMarkerType}');
|
||||
debugPrint(' message.sarCustomEmoji: "${sarCustomEmoji}"');
|
||||
debugPrint(' message.text: "$text"');
|
||||
debugPrint(' message.sarNotes: "$sarNotes"');
|
||||
debugPrint(' message.sarMarkerType: $sarMarkerType');
|
||||
debugPrint(' message.sarCustomEmoji: "$sarCustomEmoji"');
|
||||
|
||||
return SarMarker(
|
||||
id: id,
|
||||
@@ -263,11 +265,11 @@ class Message {
|
||||
// Compare sender public key prefix with self public key prefix
|
||||
if (senderPublicKeyPrefix != null && senderPublicKeyPrefix!.length >= 6) {
|
||||
return senderPublicKeyPrefix![0] == selfPublicKey[0] &&
|
||||
senderPublicKeyPrefix![1] == selfPublicKey[1] &&
|
||||
senderPublicKeyPrefix![2] == selfPublicKey[2] &&
|
||||
senderPublicKeyPrefix![3] == selfPublicKey[3] &&
|
||||
senderPublicKeyPrefix![4] == selfPublicKey[4] &&
|
||||
senderPublicKeyPrefix![5] == selfPublicKey[5];
|
||||
senderPublicKeyPrefix![1] == selfPublicKey[1] &&
|
||||
senderPublicKeyPrefix![2] == selfPublicKey[2] &&
|
||||
senderPublicKeyPrefix![3] == selfPublicKey[3] &&
|
||||
senderPublicKeyPrefix![4] == selfPublicKey[4] &&
|
||||
senderPublicKeyPrefix![5] == selfPublicKey[5];
|
||||
}
|
||||
|
||||
return false;
|
||||
@@ -305,7 +307,8 @@ class Message {
|
||||
return Message(
|
||||
id: id ?? this.id,
|
||||
messageType: messageType ?? this.messageType,
|
||||
senderPublicKeyPrefix: senderPublicKeyPrefix ?? this.senderPublicKeyPrefix,
|
||||
senderPublicKeyPrefix:
|
||||
senderPublicKeyPrefix ?? this.senderPublicKeyPrefix,
|
||||
channelIdx: channelIdx ?? this.channelIdx,
|
||||
pathLen: pathLen ?? this.pathLen,
|
||||
textType: textType ?? this.textType,
|
||||
|
||||
@@ -62,7 +62,7 @@ class ConnectionProvider with ChangeNotifier {
|
||||
DeviceInfo _deviceInfo = DeviceInfo();
|
||||
DeviceInfo get deviceInfo => _deviceInfo;
|
||||
|
||||
List<ScannedDevice> _scannedDevices = [];
|
||||
final List<ScannedDevice> _scannedDevices = [];
|
||||
List<ScannedDevice> get scannedDevices => _scannedDevices;
|
||||
|
||||
bool _isScanning = false;
|
||||
@@ -162,14 +162,18 @@ class ConnectionProvider with ChangeNotifier {
|
||||
debugPrint(
|
||||
' Updated deviceInfo.connectionState: ${_deviceInfo.connectionState}',
|
||||
);
|
||||
debugPrint(' Updated deviceInfo.isConnected: ${_deviceInfo.isConnected}');
|
||||
debugPrint(
|
||||
' Updated deviceInfo.isConnected: ${_deviceInfo.isConnected}',
|
||||
);
|
||||
debugPrint(' isReconnecting: ${_bleService.isReconnecting}');
|
||||
notifyListeners();
|
||||
debugPrint(' Notified listeners');
|
||||
};
|
||||
|
||||
_bleService.onReconnectionAttempt = (attemptNumber, maxAttempts) {
|
||||
debugPrint('🔄 [Provider] Reconnection attempt $attemptNumber/$maxAttempts');
|
||||
debugPrint(
|
||||
'🔄 [Provider] Reconnection attempt $attemptNumber/$maxAttempts',
|
||||
);
|
||||
// Notify UI to update reconnection status display
|
||||
notifyListeners();
|
||||
};
|
||||
@@ -198,7 +202,9 @@ class ConnectionProvider with ChangeNotifier {
|
||||
};
|
||||
|
||||
_bleService.onContactNotFound = (contactPublicKey) async {
|
||||
debugPrint('🔧 [Provider] Contact not found error detected - initiating auto-recovery');
|
||||
debugPrint(
|
||||
'🔧 [Provider] Contact not found error detected - initiating auto-recovery',
|
||||
);
|
||||
|
||||
if (contactPublicKey == null) {
|
||||
debugPrint(' ⚠️ No contact public key available for recovery');
|
||||
@@ -206,15 +212,22 @@ class ConnectionProvider with ChangeNotifier {
|
||||
}
|
||||
|
||||
// Generate operation ID from public key
|
||||
final operationId = contactPublicKey.sublist(0, 6).map((b) => b.toRadixString(16).padLeft(2, '0')).join(':');
|
||||
final operationId = contactPublicKey
|
||||
.sublist(0, 6)
|
||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||
.join(':');
|
||||
final pendingOp = _pendingSendOperations[operationId];
|
||||
|
||||
if (pendingOp == null || pendingOp.contact == null) {
|
||||
debugPrint(' ⚠️ No pending operation found for recovery: $operationId');
|
||||
debugPrint(
|
||||
' ⚠️ No pending operation found for recovery: $operationId',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
debugPrint(' 📋 Found pending operation for: ${pendingOp.contact!.advName}');
|
||||
debugPrint(
|
||||
' 📋 Found pending operation for: ${pendingOp.contact!.advName}',
|
||||
);
|
||||
debugPrint(' 📤 Step 1: Adding contact to radio...');
|
||||
|
||||
try {
|
||||
@@ -266,7 +279,8 @@ class ConnectionProvider with ChangeNotifier {
|
||||
onMessageReceived?.call(enhancedMessage);
|
||||
|
||||
// Complete sync response completer (message received = continue syncing)
|
||||
if (_syncResponseCompleter != null && !_syncResponseCompleter!.isCompleted) {
|
||||
if (_syncResponseCompleter != null &&
|
||||
!_syncResponseCompleter!.isCompleted) {
|
||||
_syncResponseCompleter!.complete(true);
|
||||
}
|
||||
};
|
||||
@@ -295,7 +309,8 @@ class ConnectionProvider with ChangeNotifier {
|
||||
_noMoreMessages = true;
|
||||
|
||||
// Complete sync response completer (no more messages = stop syncing)
|
||||
if (_syncResponseCompleter != null && !_syncResponseCompleter!.isCompleted) {
|
||||
if (_syncResponseCompleter != null &&
|
||||
!_syncResponseCompleter!.isCompleted) {
|
||||
_syncResponseCompleter!.complete(false);
|
||||
}
|
||||
};
|
||||
@@ -397,7 +412,8 @@ class ConnectionProvider with ChangeNotifier {
|
||||
onMessageDelivered?.call(ackCode, roundTripTimeMs);
|
||||
};
|
||||
|
||||
_bleService.onMessageEchoDetected = (messageId, echoCount, snrRaw, rssiDbm) {
|
||||
_bleService
|
||||
.onMessageEchoDetected = (messageId, echoCount, snrRaw, rssiDbm) {
|
||||
debugPrint(
|
||||
'🔊 [Provider] Echo detected - Message: $messageId, Count: $echoCount',
|
||||
);
|
||||
@@ -588,7 +604,9 @@ class ConnectionProvider with ChangeNotifier {
|
||||
|
||||
/// Connect to a device
|
||||
Future<bool> connect(BluetoothDevice device) async {
|
||||
debugPrint('🔵 [Provider] connect() called for device: ${device.platformName}');
|
||||
debugPrint(
|
||||
'🔵 [Provider] connect() called for device: ${device.platformName}',
|
||||
);
|
||||
|
||||
_deviceInfo = _deviceInfo.copyWith(
|
||||
deviceId: device.remoteId.toString(),
|
||||
@@ -690,8 +708,12 @@ class ConnectionProvider with ChangeNotifier {
|
||||
}
|
||||
|
||||
try {
|
||||
debugPrint('📻 [Provider] Configuring default public channel (channel 0)');
|
||||
debugPrint(' Using secret: ${MeshCoreConstants.defaultPublicChannelSecret.map((b) => b.toRadixString(16).padLeft(2, '0')).join('')}');
|
||||
debugPrint(
|
||||
'📻 [Provider] Configuring default public channel (channel 0)',
|
||||
);
|
||||
debugPrint(
|
||||
' Using secret: ${MeshCoreConstants.defaultPublicChannelSecret.map((b) => b.toRadixString(16).padLeft(2, '0')).join('')}',
|
||||
);
|
||||
await _bleService.setChannel(
|
||||
channelIdx: 0,
|
||||
channelName: 'Public Channel',
|
||||
@@ -701,7 +723,9 @@ class ConnectionProvider with ChangeNotifier {
|
||||
} catch (e) {
|
||||
_error = 'Failed to configure public channel: $e';
|
||||
debugPrint('❌ [Provider] Public channel configuration failed: $e');
|
||||
debugPrint(' This may be normal if the channel is pre-configured in firmware');
|
||||
debugPrint(
|
||||
' This may be normal if the channel is pre-configured in firmware',
|
||||
);
|
||||
notifyListeners();
|
||||
rethrow; // Re-throw to notify caller of failure
|
||||
}
|
||||
@@ -752,9 +776,13 @@ class ConnectionProvider with ChangeNotifier {
|
||||
// Log path status and retry info
|
||||
if (contact != null) {
|
||||
if (retryAttempt > 0) {
|
||||
debugPrint('🔄 [ConnectionProvider] Sending message to ${contact.advName} (retry $retryAttempt/3)');
|
||||
debugPrint(
|
||||
'🔄 [ConnectionProvider] Sending message to ${contact.advName} (retry $retryAttempt/3)',
|
||||
);
|
||||
} else {
|
||||
debugPrint('📤 [ConnectionProvider] Sending message to ${contact.advName}');
|
||||
debugPrint(
|
||||
'📤 [ConnectionProvider] Sending message to ${contact.advName}',
|
||||
);
|
||||
}
|
||||
debugPrint(' Type: ${contact.type.displayName}');
|
||||
debugPrint(' Path status: ${contact.pathDescription}');
|
||||
@@ -764,12 +792,17 @@ class ConnectionProvider with ChangeNotifier {
|
||||
debugPrint(' ⚠️ No path available - will use flood mode');
|
||||
}
|
||||
} else if (retryAttempt > 0) {
|
||||
debugPrint('🔄 [ConnectionProvider] Sending message (retry $retryAttempt/3)');
|
||||
debugPrint(
|
||||
'🔄 [ConnectionProvider] Sending message (retry $retryAttempt/3)',
|
||||
);
|
||||
}
|
||||
|
||||
// Track pending operation for auto-recovery (if contact not found in radio)
|
||||
if (contact != null) {
|
||||
final operationId = contactPublicKey.sublist(0, 6).map((b) => b.toRadixString(16).padLeft(2, '0')).join(':');
|
||||
final operationId = contactPublicKey
|
||||
.sublist(0, 6)
|
||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||
.join(':');
|
||||
_pendingSendOperations[operationId] = _PendingSendOperation(
|
||||
contactPublicKey: contactPublicKey,
|
||||
text: text,
|
||||
@@ -777,7 +810,9 @@ class ConnectionProvider with ChangeNotifier {
|
||||
contact: contact,
|
||||
retryAttempt: retryAttempt,
|
||||
);
|
||||
debugPrint(' 📝 Tracked pending operation for auto-recovery: $operationId');
|
||||
debugPrint(
|
||||
' 📝 Tracked pending operation for auto-recovery: $operationId',
|
||||
);
|
||||
}
|
||||
|
||||
// IMPORTANT: Track pending message BEFORE sending to avoid race condition
|
||||
@@ -785,7 +820,9 @@ class ConnectionProvider with ChangeNotifier {
|
||||
// the callback will fire before we add the message ID to the queue.
|
||||
if (messageId != null) {
|
||||
_messageDeliveryTracker.trackPendingMessage(messageId);
|
||||
debugPrint(' Added message ID to pending queue BEFORE sending: $messageId');
|
||||
debugPrint(
|
||||
' Added message ID to pending queue BEFORE sending: $messageId',
|
||||
);
|
||||
}
|
||||
|
||||
// Send the message with retry attempt info
|
||||
@@ -798,7 +835,10 @@ class ConnectionProvider with ChangeNotifier {
|
||||
// Clear pending operation after successful send (no error)
|
||||
// If ERR_CODE_NOT_FOUND occurs, the operation will be recovered automatically
|
||||
if (contact != null) {
|
||||
final operationId = contactPublicKey.sublist(0, 6).map((b) => b.toRadixString(16).padLeft(2, '0')).join(':');
|
||||
final operationId = contactPublicKey
|
||||
.sublist(0, 6)
|
||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||
.join(':');
|
||||
// Use a small delay to allow error response to arrive before clearing
|
||||
Future.delayed(const Duration(milliseconds: 500), () {
|
||||
_pendingSendOperations.remove(operationId);
|
||||
@@ -838,7 +878,9 @@ class ConnectionProvider with ChangeNotifier {
|
||||
await _bleService.sendChannelMessage(channelIdx: channelIdx, text: text);
|
||||
|
||||
debugPrint('✅ [ConnectionProvider] BLE send completed');
|
||||
debugPrint(' Checking messageId: ${messageId != null ? "Present ($messageId)" : "NULL"}');
|
||||
debugPrint(
|
||||
' Checking messageId: ${messageId != null ? "Present ($messageId)" : "NULL"}',
|
||||
);
|
||||
|
||||
// Channel messages are ephemeral (not persisted) - mark as "sent" immediately
|
||||
// They don't have ACK/TAG mechanism like direct messages
|
||||
@@ -1347,7 +1389,9 @@ class ConnectionProvider with ChangeNotifier {
|
||||
},
|
||||
);
|
||||
|
||||
debugPrint(' After iteration ${i + 1}: hasMore=$hasMore, _noMoreMessages=$_noMoreMessages');
|
||||
debugPrint(
|
||||
' After iteration ${i + 1}: hasMore=$hasMore, _noMoreMessages=$_noMoreMessages',
|
||||
);
|
||||
|
||||
if (!hasMore) {
|
||||
debugPrint(' ✅ No more messages available, stopping sync');
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import 'dart:typed_data';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import '../models/contact.dart';
|
||||
import '../services/cayenne_lpp_parser.dart';
|
||||
@@ -23,11 +22,14 @@ class ContactsProvider with ChangeNotifier {
|
||||
if (_isInitialized) return;
|
||||
|
||||
try {
|
||||
debugPrint('📦 [ContactsProvider] Early loading persisted contacts (no filtering)...');
|
||||
debugPrint(
|
||||
'📦 [ContactsProvider] Early loading persisted contacts (no filtering)...',
|
||||
);
|
||||
final storedContacts = await _storageService.loadContacts();
|
||||
|
||||
// Add stored contacts (excluding any with all-zeros public key)
|
||||
const publicChannelKey = '0000000000000000000000000000000000000000000000000000000000000000';
|
||||
const publicChannelKey =
|
||||
'0000000000000000000000000000000000000000000000000000000000000000';
|
||||
for (final contact in storedContacts) {
|
||||
// Skip any contacts with all-zeros public key (shouldn't happen, but safety check)
|
||||
if (contact.publicKeyHex == publicChannelKey) {
|
||||
@@ -37,7 +39,9 @@ class ContactsProvider with ChangeNotifier {
|
||||
}
|
||||
|
||||
_isInitialized = true;
|
||||
debugPrint('✅ [ContactsProvider] Early loaded ${storedContacts.length} persisted contacts');
|
||||
debugPrint(
|
||||
'✅ [ContactsProvider] Early loaded ${storedContacts.length} persisted contacts',
|
||||
);
|
||||
|
||||
// Ensure public channel exists after loading
|
||||
_ensurePublicChannelExists();
|
||||
@@ -68,7 +72,8 @@ class ContactsProvider with ChangeNotifier {
|
||||
);
|
||||
|
||||
// Add stored contacts (excluding any with all-zeros public key)
|
||||
const publicChannelKey = '0000000000000000000000000000000000000000000000000000000000000000';
|
||||
const publicChannelKey =
|
||||
'0000000000000000000000000000000000000000000000000000000000000000';
|
||||
for (final contact in storedContacts) {
|
||||
// Skip any contacts with all-zeros public key (shouldn't happen, but safety check)
|
||||
if (contact.publicKeyHex == publicChannelKey) {
|
||||
@@ -78,7 +83,9 @@ class ContactsProvider with ChangeNotifier {
|
||||
}
|
||||
|
||||
_isInitialized = true;
|
||||
debugPrint('✅ [ContactsProvider] Loaded ${storedContacts.length} persisted contacts');
|
||||
debugPrint(
|
||||
'✅ [ContactsProvider] Loaded ${storedContacts.length} persisted contacts',
|
||||
);
|
||||
|
||||
// Ensure public channel exists after loading
|
||||
_ensurePublicChannelExists();
|
||||
@@ -93,10 +100,14 @@ class ContactsProvider with ChangeNotifier {
|
||||
|
||||
/// Remove self-contact from loaded contacts (called after BLE connection established)
|
||||
void _removeSelfContact(Uint8List devicePublicKey) {
|
||||
final selfKeyHex = devicePublicKey.map((b) => b.toRadixString(16).padLeft(2, '0')).join('');
|
||||
final selfKeyHex = devicePublicKey
|
||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||
.join('');
|
||||
if (_contacts.containsKey(selfKeyHex)) {
|
||||
final selfContact = _contacts[selfKeyHex]!;
|
||||
debugPrint('🗑️ [ContactsProvider] Removing self-contact: ${selfContact.advName}');
|
||||
debugPrint(
|
||||
'🗑️ [ContactsProvider] Removing self-contact: ${selfContact.advName}',
|
||||
);
|
||||
_contacts.remove(selfKeyHex);
|
||||
_persistContacts();
|
||||
notifyListeners();
|
||||
@@ -106,11 +117,14 @@ class ContactsProvider with ChangeNotifier {
|
||||
/// Ensure public channel always exists in the list
|
||||
void _ensurePublicChannelExists() {
|
||||
// Public channel has all-zeros public key (32 bytes = 64 hex chars)
|
||||
const publicChannelKey = '0000000000000000000000000000000000000000000000000000000000000000';
|
||||
const publicChannelKey =
|
||||
'0000000000000000000000000000000000000000000000000000000000000000';
|
||||
if (!_contacts.containsKey(publicChannelKey)) {
|
||||
// Create a pseudo-contact for the public channel (ephemeral broadcast)
|
||||
_contacts[publicChannelKey] = Contact(
|
||||
publicKey: Uint8List.fromList(List.filled(32, 0)), // Zero key for public
|
||||
publicKey: Uint8List.fromList(
|
||||
List.filled(32, 0),
|
||||
), // Zero key for public
|
||||
type: ContactType.channel, // Channel type (not room!)
|
||||
flags: 0,
|
||||
outPathLen: 0,
|
||||
@@ -128,7 +142,8 @@ class ContactsProvider with ChangeNotifier {
|
||||
Future<void> _persistContacts() async {
|
||||
try {
|
||||
// Don't persist the public channel pseudo-contact (all zeros key)
|
||||
const publicChannelKey = '0000000000000000000000000000000000000000000000000000000000000000';
|
||||
const publicChannelKey =
|
||||
'0000000000000000000000000000000000000000000000000000000000000000';
|
||||
final contactsToSave = _contacts.entries
|
||||
.where((entry) => entry.key != publicChannelKey)
|
||||
.map((entry) => entry.value)
|
||||
@@ -159,7 +174,8 @@ class ContactsProvider with ChangeNotifier {
|
||||
/// Get both rooms and channels (destinations for SAR markers)
|
||||
List<Contact> get roomsAndChannels {
|
||||
_ensurePublicChannelExists();
|
||||
return contacts.where((c) => c.isRoom || c.isChannel).toList()..sort(_sortByLastSeen);
|
||||
return contacts.where((c) => c.isRoom || c.isChannel).toList()
|
||||
..sort(_sortByLastSeen);
|
||||
}
|
||||
|
||||
/// Get contacts with location (for map display)
|
||||
@@ -179,8 +195,11 @@ class ContactsProvider with ChangeNotifier {
|
||||
/// Excludes contacts that match the device's own public key
|
||||
void addOrUpdateContact(Contact contact, {Uint8List? devicePublicKey}) {
|
||||
// Don't add contacts that match our device's public key
|
||||
if (devicePublicKey != null && _publicKeysMatch(contact.publicKey, devicePublicKey)) {
|
||||
debugPrint('ℹ️ [ContactsProvider] Ignoring contact with device\'s own public key: ${contact.advName}');
|
||||
if (devicePublicKey != null &&
|
||||
_publicKeysMatch(contact.publicKey, devicePublicKey)) {
|
||||
debugPrint(
|
||||
'ℹ️ [ContactsProvider] Ignoring contact with device\'s own public key: ${contact.advName}',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -192,8 +211,13 @@ class ContactsProvider with ChangeNotifier {
|
||||
// New contact - add initial location to history if available
|
||||
updatedContact = contact.copyWith(isNew: true);
|
||||
if (contact.advertLocation != null) {
|
||||
final timestamp = DateTime.fromMillisecondsSinceEpoch(contact.lastAdvert * 1000);
|
||||
updatedContact = updatedContact.addAdvertLocation(contact.advertLocation!, timestamp);
|
||||
final timestamp = DateTime.fromMillisecondsSinceEpoch(
|
||||
contact.lastAdvert * 1000,
|
||||
);
|
||||
updatedContact = updatedContact.addAdvertLocation(
|
||||
contact.advertLocation!,
|
||||
timestamp,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Existing contact - preserve history and isNew status
|
||||
@@ -207,8 +231,13 @@ class ContactsProvider with ChangeNotifier {
|
||||
|
||||
// Add new location to history if location has changed
|
||||
if (contact.advertLocation != null) {
|
||||
final timestamp = DateTime.fromMillisecondsSinceEpoch(contact.lastAdvert * 1000);
|
||||
updatedContact = updatedContact.addAdvertLocation(contact.advertLocation!, timestamp);
|
||||
final timestamp = DateTime.fromMillisecondsSinceEpoch(
|
||||
contact.lastAdvert * 1000,
|
||||
);
|
||||
updatedContact = updatedContact.addAdvertLocation(
|
||||
contact.advertLocation!,
|
||||
timestamp,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -232,15 +261,20 @@ class ContactsProvider with ChangeNotifier {
|
||||
int excluded = 0;
|
||||
for (final contact in contacts) {
|
||||
// Don't add contacts that match our device's public key
|
||||
if (devicePublicKey != null && _publicKeysMatch(contact.publicKey, devicePublicKey)) {
|
||||
debugPrint('ℹ️ [ContactsProvider] Ignoring contact with device\'s own public key: ${contact.advName}');
|
||||
if (devicePublicKey != null &&
|
||||
_publicKeysMatch(contact.publicKey, devicePublicKey)) {
|
||||
debugPrint(
|
||||
'ℹ️ [ContactsProvider] Ignoring contact with device\'s own public key: ${contact.advName}',
|
||||
);
|
||||
excluded++;
|
||||
continue;
|
||||
}
|
||||
_contacts[contact.publicKeyHex] = contact;
|
||||
}
|
||||
if (excluded > 0) {
|
||||
debugPrint('ℹ️ [ContactsProvider] Excluded $excluded contact(s) matching device public key');
|
||||
debugPrint(
|
||||
'ℹ️ [ContactsProvider] Excluded $excluded contact(s) matching device public key',
|
||||
);
|
||||
}
|
||||
_persistContacts();
|
||||
notifyListeners();
|
||||
@@ -249,7 +283,9 @@ class ContactsProvider with ChangeNotifier {
|
||||
/// Update contact telemetry
|
||||
void updateTelemetry(Uint8List publicKeyPrefix, Uint8List lppData) {
|
||||
debugPrint('📊 [ContactsProvider] updateTelemetry() called');
|
||||
debugPrint(' Public key prefix (hex): ${publicKeyPrefix.map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}');
|
||||
debugPrint(
|
||||
' Public key prefix (hex): ${publicKeyPrefix.map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}',
|
||||
);
|
||||
debugPrint(' LPP data size: ${lppData.length} bytes');
|
||||
|
||||
// Find contact by public key prefix
|
||||
@@ -303,8 +339,9 @@ class ContactsProvider with ChangeNotifier {
|
||||
|
||||
/// Find contact by public key
|
||||
Contact? findContactByKey(Uint8List publicKey) {
|
||||
final keyHex =
|
||||
publicKey.map((b) => b.toRadixString(16).padLeft(2, '0')).join('');
|
||||
final keyHex = publicKey
|
||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||
.join('');
|
||||
return _contacts[keyHex];
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
@@ -7,11 +6,7 @@ import '../models/map_drawing.dart';
|
||||
import '../utils/drawing_message_parser.dart';
|
||||
|
||||
/// Drawing mode state
|
||||
enum DrawingMode {
|
||||
none,
|
||||
line,
|
||||
rectangle,
|
||||
}
|
||||
enum DrawingMode { none, line, rectangle }
|
||||
|
||||
/// Provider for managing map drawings
|
||||
class DrawingProvider with ChangeNotifier {
|
||||
@@ -123,7 +118,8 @@ class DrawingProvider with ChangeNotifier {
|
||||
|
||||
/// Update rectangle end point (for preview)
|
||||
void updateRectangleEndPoint(LatLng endPoint) {
|
||||
if (_drawingMode != DrawingMode.rectangle || _rectangleStartPoint == null) return;
|
||||
if (_drawingMode != DrawingMode.rectangle || _rectangleStartPoint == null)
|
||||
return;
|
||||
|
||||
// Create preview rectangle
|
||||
_currentDrawing = RectangleDrawing(
|
||||
@@ -270,7 +266,8 @@ class DrawingProvider with ChangeNotifier {
|
||||
createdAt: DateTime.now(),
|
||||
points: _currentLinePoints,
|
||||
);
|
||||
} else if (_drawingMode == DrawingMode.rectangle && _currentDrawing != null) {
|
||||
} else if (_drawingMode == DrawingMode.rectangle &&
|
||||
_currentDrawing != null) {
|
||||
return _currentDrawing;
|
||||
}
|
||||
return null;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import '../models/message.dart';
|
||||
import '../models/contact.dart';
|
||||
@@ -41,7 +40,8 @@ class MessagesProvider with ChangeNotifier {
|
||||
required String messageId,
|
||||
required Contact contact,
|
||||
int retryAttempt,
|
||||
})? sendMessageCallback;
|
||||
})?
|
||||
sendMessageCallback;
|
||||
|
||||
List<Message> get messages => List.unmodifiable(_messages);
|
||||
|
||||
@@ -93,10 +93,7 @@ class MessagesProvider with ChangeNotifier {
|
||||
|
||||
/// Get count of unread messages (excluding sent messages and system messages)
|
||||
int get unreadCount => _messages
|
||||
.where((m) =>
|
||||
!m.isRead &&
|
||||
!m.isSentMessage &&
|
||||
!m.isSystemMessage)
|
||||
.where((m) => !m.isRead && !m.isSentMessage && !m.isSystemMessage)
|
||||
.length;
|
||||
|
||||
/// Initialize and load persisted messages
|
||||
@@ -124,7 +121,9 @@ class MessagesProvider with ChangeNotifier {
|
||||
}
|
||||
|
||||
_isInitialized = true;
|
||||
debugPrint('✅ [MessagesProvider] Loaded ${storedMessages.length} persisted messages');
|
||||
debugPrint(
|
||||
'✅ [MessagesProvider] Loaded ${storedMessages.length} persisted messages',
|
||||
);
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
debugPrint('❌ [MessagesProvider] Error initializing: $e');
|
||||
@@ -135,7 +134,10 @@ class MessagesProvider with ChangeNotifier {
|
||||
/// Add a message
|
||||
/// If [contactLookup] function is provided, it will be used to match channel
|
||||
/// message senders with known contacts by name
|
||||
void addMessage(Message message, {String Function(String name)? contactLookup}) {
|
||||
void addMessage(
|
||||
Message message, {
|
||||
String Function(String name)? contactLookup,
|
||||
}) {
|
||||
// Always enhance message with SAR parser to detect SAR markers
|
||||
final enhancedMessage = SarMessageParser.enhanceMessage(message);
|
||||
|
||||
@@ -165,7 +167,9 @@ class MessagesProvider with ChangeNotifier {
|
||||
|
||||
// Debug: Check if message is SAR
|
||||
if (message.text.startsWith('S:')) {
|
||||
debugPrint('🔍 [MessagesProvider] Processing SAR message: ${message.text}');
|
||||
debugPrint(
|
||||
'🔍 [MessagesProvider] Processing SAR message: ${message.text}',
|
||||
);
|
||||
debugPrint(' isSarMarker: ${finalMessage.isSarMarker}');
|
||||
debugPrint(' sarMarkerType: ${finalMessage.sarMarkerType}');
|
||||
}
|
||||
@@ -176,8 +180,12 @@ class MessagesProvider with ChangeNotifier {
|
||||
// - Multiple paths in the network
|
||||
// - Syncing messages from device queue
|
||||
if (_isDuplicate(finalMessage)) {
|
||||
debugPrint('⚠️ [MessagesProvider] Duplicate message detected, skipping: ${finalMessage.id}');
|
||||
debugPrint(' Text: ${finalMessage.text.substring(0, finalMessage.text.length > 50 ? 50 : finalMessage.text.length)}...');
|
||||
debugPrint(
|
||||
'⚠️ [MessagesProvider] Duplicate message detected, skipping: ${finalMessage.id}',
|
||||
);
|
||||
debugPrint(
|
||||
' Text: ${finalMessage.text.substring(0, finalMessage.text.length > 50 ? 50 : finalMessage.text.length)}...',
|
||||
);
|
||||
return; // Skip duplicate
|
||||
}
|
||||
|
||||
@@ -282,7 +290,9 @@ class MessagesProvider with ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
debugPrint('📥 [MessagesProvider] Added $addedCount messages, skipped $duplicateCount duplicates');
|
||||
debugPrint(
|
||||
'📥 [MessagesProvider] Added $addedCount messages, skipped $duplicateCount duplicates',
|
||||
);
|
||||
|
||||
// Persist to storage asynchronously
|
||||
_persistMessages();
|
||||
@@ -291,15 +301,22 @@ class MessagesProvider with ChangeNotifier {
|
||||
}
|
||||
|
||||
/// Trigger urgent notification for SAR marker
|
||||
Future<void> _triggerSarNotification(Message message, SarMarker marker) async {
|
||||
Future<void> _triggerSarNotification(
|
||||
Message message,
|
||||
SarMarker marker,
|
||||
) async {
|
||||
try {
|
||||
// Format coordinates
|
||||
final coords = '${marker.location.latitude.toStringAsFixed(5)}, ${marker.location.longitude.toStringAsFixed(5)}';
|
||||
final coords =
|
||||
'${marker.location.latitude.toStringAsFixed(5)}, ${marker.location.longitude.toStringAsFixed(5)}';
|
||||
|
||||
// Get sender name from message
|
||||
final senderName = message.senderName ?? message.senderKeyShort ?? 'Unknown';
|
||||
final senderName =
|
||||
message.senderName ?? message.senderKeyShort ?? 'Unknown';
|
||||
|
||||
debugPrint('🔔 [MessagesProvider] Triggering SAR notification for ${marker.type.displayName}');
|
||||
debugPrint(
|
||||
'🔔 [MessagesProvider] Triggering SAR notification for ${marker.type.displayName}',
|
||||
);
|
||||
debugPrint(' Sender: $senderName');
|
||||
debugPrint(' Coordinates: $coords');
|
||||
|
||||
@@ -319,7 +336,8 @@ class MessagesProvider with ChangeNotifier {
|
||||
Future<void> _triggerMessageNotification(Message message) async {
|
||||
try {
|
||||
// Get sender name from message
|
||||
final senderName = message.senderName ?? message.senderKeyShort ?? 'Unknown';
|
||||
final senderName =
|
||||
message.senderName ?? message.senderKeyShort ?? 'Unknown';
|
||||
|
||||
// Determine if it's a channel message
|
||||
final isChannelMessage = message.isChannelMessage;
|
||||
@@ -329,13 +347,17 @@ class MessagesProvider with ChangeNotifier {
|
||||
if (isChannelMessage) {
|
||||
// You could map channelIdx to channel name here if needed
|
||||
// For now, use "Public" for channel 0
|
||||
channelName = message.channelIdx == 0 ? 'Public' : 'Channel ${message.channelIdx}';
|
||||
channelName = message.channelIdx == 0
|
||||
? 'Public'
|
||||
: 'Channel ${message.channelIdx}';
|
||||
}
|
||||
|
||||
debugPrint('🔔 [MessagesProvider] Triggering message notification');
|
||||
debugPrint(' Sender: $senderName');
|
||||
debugPrint(' Type: ${isChannelMessage ? "Channel" : "Direct"}');
|
||||
debugPrint(' Message: ${message.text.substring(0, message.text.length > 50 ? 50 : message.text.length)}...');
|
||||
debugPrint(
|
||||
' Message: ${message.text.substring(0, message.text.length > 50 ? 50 : message.text.length)}...',
|
||||
);
|
||||
|
||||
await _notificationService.showMessageNotification(
|
||||
senderName: senderName,
|
||||
@@ -345,7 +367,9 @@ class MessagesProvider with ChangeNotifier {
|
||||
localizations: _localizations,
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('❌ [MessagesProvider] Error triggering message notification: $e');
|
||||
debugPrint(
|
||||
'❌ [MessagesProvider] Error triggering message notification: $e',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -361,10 +385,12 @@ class MessagesProvider with ChangeNotifier {
|
||||
/// Get messages for a specific contact
|
||||
List<Message> getMessagesForContact(String senderKeyShort) {
|
||||
return _messages
|
||||
.where((m) =>
|
||||
m.isContactMessage &&
|
||||
m.senderKeyShort != null &&
|
||||
m.senderKeyShort!.startsWith(senderKeyShort))
|
||||
.where(
|
||||
(m) =>
|
||||
m.isContactMessage &&
|
||||
m.senderKeyShort != null &&
|
||||
m.senderKeyShort!.startsWith(senderKeyShort),
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
|
||||
@@ -417,7 +443,9 @@ class MessagesProvider with ChangeNotifier {
|
||||
void markAllAsRead() {
|
||||
bool hasChanges = false;
|
||||
for (int i = 0; i < _messages.length; i++) {
|
||||
if (!_messages[i].isRead && !_messages[i].isSentMessage && !_messages[i].isSystemMessage) {
|
||||
if (!_messages[i].isRead &&
|
||||
!_messages[i].isSentMessage &&
|
||||
!_messages[i].isSystemMessage) {
|
||||
_messages[i] = _messages[i].copyWith(isRead: true);
|
||||
hasChanges = true;
|
||||
}
|
||||
@@ -552,14 +580,18 @@ class MessagesProvider with ChangeNotifier {
|
||||
debugPrint(' Message ID: ${message.id}');
|
||||
debugPrint(' Message type: ${message.messageType}');
|
||||
debugPrint(' Initial status: ${message.deliveryStatus}');
|
||||
debugPrint(' Message text preview: ${message.text.substring(0, message.text.length > 30 ? 30 : message.text.length)}...');
|
||||
debugPrint(
|
||||
' Message text preview: ${message.text.substring(0, message.text.length > 30 ? 30 : message.text.length)}...',
|
||||
);
|
||||
|
||||
// Always enhance message with SAR parser to detect SAR markers
|
||||
final enhancedMessage = SarMessageParser.enhanceMessage(message);
|
||||
|
||||
// Check for duplicates (shouldn't happen for sent messages, but be safe)
|
||||
if (_isDuplicate(enhancedMessage)) {
|
||||
debugPrint('⚠️ [MessagesProvider] Duplicate sent message detected, skipping: ${enhancedMessage.id}');
|
||||
debugPrint(
|
||||
'⚠️ [MessagesProvider] Duplicate sent message detected, skipping: ${enhancedMessage.id}',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -597,12 +629,20 @@ class MessagesProvider with ChangeNotifier {
|
||||
}
|
||||
|
||||
/// Update message status to sent with ACK tag
|
||||
void markMessageSent(String messageId, int expectedAckTag, int suggestedTimeoutMs) {
|
||||
void markMessageSent(
|
||||
String messageId,
|
||||
int expectedAckTag,
|
||||
int suggestedTimeoutMs,
|
||||
) {
|
||||
debugPrint('📤 [MessagesProvider] markMessageSent called');
|
||||
debugPrint(' Message ID: $messageId');
|
||||
debugPrint(' Expected ACK tag: $expectedAckTag (0x${expectedAckTag.toRadixString(16).padLeft(8, '0')})');
|
||||
debugPrint(
|
||||
' Expected ACK tag: $expectedAckTag (0x${expectedAckTag.toRadixString(16).padLeft(8, '0')})',
|
||||
);
|
||||
debugPrint(' Timeout: ${suggestedTimeoutMs}ms');
|
||||
debugPrint(' Current pending ACKs before adding: ${_pendingSentMessages.keys.toList()}');
|
||||
debugPrint(
|
||||
' Current pending ACKs before adding: ${_pendingSentMessages.keys.toList()}',
|
||||
);
|
||||
|
||||
final index = _messages.indexWhere((m) => m.id == messageId);
|
||||
debugPrint(' Message index in list: $index');
|
||||
@@ -611,7 +651,9 @@ class MessagesProvider with ChangeNotifier {
|
||||
final message = _messages[index];
|
||||
debugPrint(' Current status: ${message.deliveryStatus}');
|
||||
debugPrint(' Message type: ${message.messageType}');
|
||||
debugPrint(' Message text preview: ${message.text.substring(0, message.text.length > 30 ? 30 : message.text.length)}...');
|
||||
debugPrint(
|
||||
' Message text preview: ${message.text.substring(0, message.text.length > 30 ? 30 : message.text.length)}...',
|
||||
);
|
||||
|
||||
final updatedMessage = message.copyWith(
|
||||
deliveryStatus: MessageDeliveryStatus.sent,
|
||||
@@ -624,24 +666,34 @@ class MessagesProvider with ChangeNotifier {
|
||||
if (expectedAckTag > 0 && suggestedTimeoutMs > 0) {
|
||||
// Track by ACK tag for matching with delivery confirmation
|
||||
_pendingSentMessages[expectedAckTag] = updatedMessage;
|
||||
debugPrint(' ✅ Added to pending messages map with ACK: $expectedAckTag');
|
||||
debugPrint(
|
||||
' ✅ Added to pending messages map with ACK: $expectedAckTag',
|
||||
);
|
||||
debugPrint(' Total pending messages: ${_pendingSentMessages.length}');
|
||||
debugPrint(' Pending ACKs after adding: ${_pendingSentMessages.keys.toList()}');
|
||||
debugPrint(
|
||||
' Pending ACKs after adding: ${_pendingSentMessages.keys.toList()}',
|
||||
);
|
||||
|
||||
// Start timeout timer
|
||||
_timeoutTimers[expectedAckTag] = Timer(
|
||||
Duration(milliseconds: suggestedTimeoutMs),
|
||||
() {
|
||||
debugPrint('⏱️ [MessagesProvider] Timeout for message $messageId (ACK $expectedAckTag)');
|
||||
debugPrint(
|
||||
'⏱️ [MessagesProvider] Timeout for message $messageId (ACK $expectedAckTag)',
|
||||
);
|
||||
if (_pendingSentMessages.containsKey(expectedAckTag)) {
|
||||
markMessageFailed(messageId);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
debugPrint('⏱️ [MessagesProvider] Started ${suggestedTimeoutMs}ms timeout timer for message $messageId (ACK $expectedAckTag)');
|
||||
debugPrint(
|
||||
'⏱️ [MessagesProvider] Started ${suggestedTimeoutMs}ms timeout timer for message $messageId (ACK $expectedAckTag)',
|
||||
);
|
||||
} else {
|
||||
debugPrint(' ℹ️ Channel message (no ACK tracking) - marked as sent immediately');
|
||||
debugPrint(
|
||||
' ℹ️ Channel message (no ACK tracking) - marked as sent immediately',
|
||||
);
|
||||
}
|
||||
|
||||
debugPrint(' Calling notifyListeners() to update UI with "sent" status');
|
||||
@@ -661,7 +713,12 @@ class MessagesProvider with ChangeNotifier {
|
||||
}
|
||||
|
||||
/// Handle echo detection for public channel messages
|
||||
void handleMessageEcho(String messageId, int echoCount, int snrRaw, int rssiDbm) {
|
||||
void handleMessageEcho(
|
||||
String messageId,
|
||||
int echoCount,
|
||||
int snrRaw,
|
||||
int rssiDbm,
|
||||
) {
|
||||
debugPrint('🔊 [MessagesProvider] handleMessageEcho called');
|
||||
debugPrint(' Message ID: $messageId');
|
||||
debugPrint(' Echo count: $echoCount');
|
||||
@@ -672,7 +729,9 @@ class MessagesProvider with ChangeNotifier {
|
||||
final index = _messages.indexWhere((m) => m.id == messageId);
|
||||
if (index != -1) {
|
||||
final message = _messages[index];
|
||||
debugPrint(' ✅ Found message: ${message.text.substring(0, message.text.length > 30 ? 30 : message.text.length)}...');
|
||||
debugPrint(
|
||||
' ✅ Found message: ${message.text.substring(0, message.text.length > 30 ? 30 : message.text.length)}...',
|
||||
);
|
||||
|
||||
// Update echo count
|
||||
final updatedMessage = message.copyWith(
|
||||
@@ -692,8 +751,12 @@ class MessagesProvider with ChangeNotifier {
|
||||
|
||||
/// Update message status to delivered with RTT
|
||||
void markMessageDelivered(int ackCode, int roundTripTimeMs) {
|
||||
debugPrint('🔍 [MessagesProvider] markMessageDelivered called with ACK: $ackCode, RTT: ${roundTripTimeMs}ms');
|
||||
debugPrint(' Current pending messages: ${_pendingSentMessages.keys.toList()}');
|
||||
debugPrint(
|
||||
'🔍 [MessagesProvider] markMessageDelivered called with ACK: $ackCode, RTT: ${roundTripTimeMs}ms',
|
||||
);
|
||||
debugPrint(
|
||||
' Current pending messages: ${_pendingSentMessages.keys.toList()}',
|
||||
);
|
||||
debugPrint(' Total messages in list: ${_messages.length}');
|
||||
debugPrint(' Looking for ACK: $ackCode');
|
||||
|
||||
@@ -722,7 +785,9 @@ class MessagesProvider with ChangeNotifier {
|
||||
// Clear retry tracking on successful delivery
|
||||
_retryManager.clearRetry(message.id);
|
||||
|
||||
debugPrint('✅ [MessagesProvider] Message ${message.id} delivered in ${roundTripTimeMs}ms (ACK $ackCode)');
|
||||
debugPrint(
|
||||
'✅ [MessagesProvider] Message ${message.id} delivered in ${roundTripTimeMs}ms (ACK $ackCode)',
|
||||
);
|
||||
debugPrint(' Updated status to: ${updatedMessage.deliveryStatus}');
|
||||
debugPrint(' Calling notifyListeners() to update UI');
|
||||
|
||||
@@ -731,33 +796,58 @@ class MessagesProvider with ChangeNotifier {
|
||||
|
||||
debugPrint(' ✅ notifyListeners() called successfully');
|
||||
} else {
|
||||
debugPrint('⚠️ [MessagesProvider] Message not found in messages list (index=-1)');
|
||||
debugPrint(' This should never happen - message was in pending map but not in messages list');
|
||||
debugPrint(
|
||||
'⚠️ [MessagesProvider] Message not found in messages list (index=-1)',
|
||||
);
|
||||
debugPrint(
|
||||
' This should never happen - message was in pending map but not in messages list',
|
||||
);
|
||||
}
|
||||
} else {
|
||||
debugPrint('⚠️ [MessagesProvider] No pending message found for ACK code: $ackCode');
|
||||
debugPrint(
|
||||
'⚠️ [MessagesProvider] No pending message found for ACK code: $ackCode',
|
||||
);
|
||||
debugPrint(' Pending ACK codes: ${_pendingSentMessages.keys.toList()}');
|
||||
debugPrint(' This means either:');
|
||||
debugPrint(' 1. markMessageSent() was never called for this message (ACK tag not stored)');
|
||||
debugPrint(' 2. The ACK code from PUSH_CODE_SEND_CONFIRMED doesn\'t match the expected ACK tag from RESP_CODE_SENT');
|
||||
debugPrint(
|
||||
' 1. markMessageSent() was never called for this message (ACK tag not stored)',
|
||||
);
|
||||
debugPrint(
|
||||
' 2. The ACK code from PUSH_CODE_SEND_CONFIRMED doesn\'t match the expected ACK tag from RESP_CODE_SENT',
|
||||
);
|
||||
debugPrint(' 3. The message was already delivered or timed out');
|
||||
debugPrint(' Searching all messages for debugging...');
|
||||
|
||||
// Debug: Search for any message with this ACK tag
|
||||
final matchingMessages = _messages.where((m) => m.expectedAckTag == ackCode).toList();
|
||||
final matchingMessages = _messages
|
||||
.where((m) => m.expectedAckTag == ackCode)
|
||||
.toList();
|
||||
if (matchingMessages.isNotEmpty) {
|
||||
debugPrint(' ⚠️ Found ${matchingMessages.length} message(s) with matching ACK tag but NOT in pending map:');
|
||||
debugPrint(
|
||||
' ⚠️ Found ${matchingMessages.length} message(s) with matching ACK tag but NOT in pending map:',
|
||||
);
|
||||
for (final m in matchingMessages) {
|
||||
debugPrint(' - Message ID: ${m.id}, Status: ${m.deliveryStatus}, ACK: ${m.expectedAckTag}');
|
||||
debugPrint(
|
||||
' - Message ID: ${m.id}, Status: ${m.deliveryStatus}, ACK: ${m.expectedAckTag}',
|
||||
);
|
||||
}
|
||||
debugPrint(' This indicates the message was sent but never added to _pendingSentMessages map');
|
||||
debugPrint(' Likely cause: markMessageSent() was not called with correct message ID');
|
||||
debugPrint(
|
||||
' This indicates the message was sent but never added to _pendingSentMessages map',
|
||||
);
|
||||
debugPrint(
|
||||
' Likely cause: markMessageSent() was not called with correct message ID',
|
||||
);
|
||||
} else {
|
||||
debugPrint(' No messages found with ACK tag $ackCode');
|
||||
debugPrint(' Recent sent messages:');
|
||||
final sentMessages = _messages.where((m) => m.isSentMessage).take(5).toList();
|
||||
final sentMessages = _messages
|
||||
.where((m) => m.isSentMessage)
|
||||
.take(5)
|
||||
.toList();
|
||||
for (final m in sentMessages) {
|
||||
debugPrint(' - ID: ${m.id}, Status: ${m.deliveryStatus}, ACK: ${m.expectedAckTag}');
|
||||
debugPrint(
|
||||
' - ID: ${m.id}, Status: ${m.deliveryStatus}, ACK: ${m.expectedAckTag}',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -767,7 +857,9 @@ class MessagesProvider with ChangeNotifier {
|
||||
void markMessageFailed(String messageId) {
|
||||
final index = _messages.indexWhere((m) => m.id == messageId);
|
||||
if (index == -1) {
|
||||
debugPrint('⚠️ [MessagesProvider] markMessageFailed: Message not found: $messageId');
|
||||
debugPrint(
|
||||
'⚠️ [MessagesProvider] markMessageFailed: Message not found: $messageId',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -783,7 +875,8 @@ class MessagesProvider with ChangeNotifier {
|
||||
if (contact != null && _retryManager.canRetry(message, contact)) {
|
||||
// RETRY: Contact has path and retry attempts < 3
|
||||
_scheduleRetry(messageId, message, contact);
|
||||
} else if (contact != null && _retryManager.shouldUseFloodFallback(message, contact)) {
|
||||
} else if (contact != null &&
|
||||
_retryManager.shouldUseFloodFallback(message, contact)) {
|
||||
// FLOOD FALLBACK: After 3 retries failed, try flood once
|
||||
_sendWithFloodMode(messageId, message, contact);
|
||||
} else {
|
||||
@@ -797,7 +890,9 @@ class MessagesProvider with ChangeNotifier {
|
||||
final nextAttempt = message.retryAttempt + 1;
|
||||
final timeout = _retryManager.getTimeoutForAttempt(message.retryAttempt);
|
||||
|
||||
debugPrint('🔄 [MessagesProvider] Scheduling retry $nextAttempt/3 for message $messageId');
|
||||
debugPrint(
|
||||
'🔄 [MessagesProvider] Scheduling retry $nextAttempt/3 for message $messageId',
|
||||
);
|
||||
debugPrint(' Timeout: ${timeout}ms');
|
||||
|
||||
// Update message with new retry attempt
|
||||
@@ -823,7 +918,9 @@ class MessagesProvider with ChangeNotifier {
|
||||
|
||||
// Schedule actual retry after delay
|
||||
Timer(Duration(milliseconds: timeout), () async {
|
||||
debugPrint('⏰ [MessagesProvider] Executing retry $nextAttempt for message $messageId');
|
||||
debugPrint(
|
||||
'⏰ [MessagesProvider] Executing retry $nextAttempt for message $messageId',
|
||||
);
|
||||
if (sendMessageCallback != null) {
|
||||
await sendMessageCallback!(
|
||||
contactPublicKey: contact.publicKey,
|
||||
@@ -833,7 +930,9 @@ class MessagesProvider with ChangeNotifier {
|
||||
retryAttempt: nextAttempt,
|
||||
);
|
||||
} else {
|
||||
debugPrint('⚠️ [MessagesProvider] sendMessageCallback not set, cannot retry');
|
||||
debugPrint(
|
||||
'⚠️ [MessagesProvider] sendMessageCallback not set, cannot retry',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -842,8 +941,14 @@ class MessagesProvider with ChangeNotifier {
|
||||
}
|
||||
|
||||
/// Send message with flood mode as last resort
|
||||
Future<void> _sendWithFloodMode(String messageId, Message message, Contact contact) async {
|
||||
debugPrint('🌊 [MessagesProvider] Trying flood mode for message $messageId');
|
||||
Future<void> _sendWithFloodMode(
|
||||
String messageId,
|
||||
Message message,
|
||||
Contact contact,
|
||||
) async {
|
||||
debugPrint(
|
||||
'🌊 [MessagesProvider] Trying flood mode for message $messageId',
|
||||
);
|
||||
|
||||
final index = _messages.indexWhere((m) => m.id == messageId);
|
||||
if (index != -1) {
|
||||
@@ -871,7 +976,9 @@ class MessagesProvider with ChangeNotifier {
|
||||
retryAttempt: 0, // Reset attempt for flood
|
||||
);
|
||||
} else {
|
||||
debugPrint('⚠️ [MessagesProvider] sendMessageCallback not set, cannot send flood');
|
||||
debugPrint(
|
||||
'⚠️ [MessagesProvider] sendMessageCallback not set, cannot send flood',
|
||||
);
|
||||
}
|
||||
|
||||
_persistMessages();
|
||||
@@ -907,7 +1014,9 @@ class MessagesProvider with ChangeNotifier {
|
||||
Future<void> resendMessage(String messageId) async {
|
||||
final index = _messages.indexWhere((m) => m.id == messageId);
|
||||
if (index == -1) {
|
||||
debugPrint('⚠️ [MessagesProvider] resendMessage: Message not found: $messageId');
|
||||
debugPrint(
|
||||
'⚠️ [MessagesProvider] resendMessage: Message not found: $messageId',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -915,7 +1024,9 @@ class MessagesProvider with ChangeNotifier {
|
||||
final contact = _messageContactMap[messageId];
|
||||
|
||||
if (contact == null) {
|
||||
debugPrint('⚠️ [MessagesProvider] Cannot resend: Contact not found for message $messageId');
|
||||
debugPrint(
|
||||
'⚠️ [MessagesProvider] Cannot resend: Contact not found for message $messageId',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -944,7 +1055,9 @@ class MessagesProvider with ChangeNotifier {
|
||||
retryAttempt: 0,
|
||||
);
|
||||
} else {
|
||||
debugPrint('⚠️ [MessagesProvider] sendMessageCallback not set, cannot resend');
|
||||
debugPrint(
|
||||
'⚠️ [MessagesProvider] sendMessageCallback not set, cannot resend',
|
||||
);
|
||||
}
|
||||
|
||||
_persistMessages();
|
||||
|
||||
@@ -209,7 +209,9 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(AppLocalizations.of(context)!.failedToSave(e.toString())),
|
||||
content: Text(
|
||||
AppLocalizations.of(context)!.failedToSave(e.toString()),
|
||||
),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
@@ -282,7 +284,9 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(AppLocalizations.of(context)!.failedToSave(e.toString())),
|
||||
content: Text(
|
||||
AppLocalizations.of(context)!.failedToSave(e.toString()),
|
||||
),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
@@ -365,9 +369,13 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
|
||||
}
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(AppLocalizations.of(context)!.failedToGetLocation(e.toString()))));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
AppLocalizations.of(context)!.failedToGetLocation(e.toString()),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -452,14 +460,33 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_InfoRow(AppLocalizations.of(context)!.bleName, deviceInfo.deviceName ?? AppLocalizations.of(context)!.unknown),
|
||||
_InfoRow(AppLocalizations.of(context)!.meshName, deviceInfo.selfName ?? AppLocalizations.of(context)!.notSet),
|
||||
_InfoRow(AppLocalizations.of(context)!.type, _getDeviceTypeString(context, deviceInfo.deviceType)),
|
||||
_InfoRow(AppLocalizations.of(context)!.model, deviceInfo.manufacturerModel ?? AppLocalizations.of(context)!.unknown),
|
||||
_InfoRow(AppLocalizations.of(context)!.version, deviceInfo.semanticVersion ?? AppLocalizations.of(context)!.unknown),
|
||||
_InfoRow(
|
||||
AppLocalizations.of(context)!.bleName,
|
||||
deviceInfo.deviceName ??
|
||||
AppLocalizations.of(context)!.unknown,
|
||||
),
|
||||
_InfoRow(
|
||||
AppLocalizations.of(context)!.meshName,
|
||||
deviceInfo.selfName ?? AppLocalizations.of(context)!.notSet,
|
||||
),
|
||||
_InfoRow(
|
||||
AppLocalizations.of(context)!.type,
|
||||
_getDeviceTypeString(context, deviceInfo.deviceType),
|
||||
),
|
||||
_InfoRow(
|
||||
AppLocalizations.of(context)!.model,
|
||||
deviceInfo.manufacturerModel ??
|
||||
AppLocalizations.of(context)!.unknown,
|
||||
),
|
||||
_InfoRow(
|
||||
AppLocalizations.of(context)!.version,
|
||||
deviceInfo.semanticVersion ??
|
||||
AppLocalizations.of(context)!.unknown,
|
||||
),
|
||||
_InfoRow(
|
||||
AppLocalizations.of(context)!.buildDate,
|
||||
deviceInfo.firmwareBuildDate ?? AppLocalizations.of(context)!.unknown,
|
||||
deviceInfo.firmwareBuildDate ??
|
||||
AppLocalizations.of(context)!.unknown,
|
||||
),
|
||||
_InfoRow(
|
||||
AppLocalizations.of(context)!.firmware,
|
||||
@@ -467,11 +494,13 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
|
||||
),
|
||||
_InfoRow(
|
||||
AppLocalizations.of(context)!.maxContacts,
|
||||
deviceInfo.maxContacts?.toString() ?? AppLocalizations.of(context)!.unknown,
|
||||
deviceInfo.maxContacts?.toString() ??
|
||||
AppLocalizations.of(context)!.unknown,
|
||||
),
|
||||
_InfoRow(
|
||||
AppLocalizations.of(context)!.maxChannels,
|
||||
deviceInfo.maxChannels?.toString() ?? AppLocalizations.of(context)!.unknown,
|
||||
deviceInfo.maxChannels?.toString() ??
|
||||
AppLocalizations.of(context)!.unknown,
|
||||
),
|
||||
_CopyableInfoRow(
|
||||
AppLocalizations.of(context)!.publicKey,
|
||||
@@ -521,7 +550,9 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
|
||||
decoration: InputDecoration(
|
||||
labelText: AppLocalizations.of(context)!.meshNetworkName,
|
||||
border: const OutlineInputBorder(),
|
||||
helperText: AppLocalizations.of(context)!.nameBroadcastInMesh,
|
||||
helperText: AppLocalizations.of(
|
||||
context,
|
||||
)!.nameBroadcastInMesh,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
@@ -531,7 +562,9 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
AppLocalizations.of(context)!.telemetryAndLocationSharing,
|
||||
AppLocalizations.of(
|
||||
context,
|
||||
)!.telemetryAndLocationSharing,
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
),
|
||||
@@ -584,7 +617,9 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
|
||||
IconButton.filled(
|
||||
onPressed: _useCurrentLocation,
|
||||
icon: const Icon(Icons.my_location, size: 20),
|
||||
tooltip: AppLocalizations.of(context)!.useCurrentLocation,
|
||||
tooltip: AppLocalizations.of(
|
||||
context,
|
||||
)!.useCurrentLocation,
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -627,7 +662,9 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
|
||||
decoration: InputDecoration(
|
||||
labelText: AppLocalizations.of(context)!.frequencyMHz,
|
||||
border: const OutlineInputBorder(),
|
||||
helperText: AppLocalizations.of(context)!.frequencyExample,
|
||||
helperText: AppLocalizations.of(
|
||||
context,
|
||||
)!.frequencyExample,
|
||||
),
|
||||
keyboardType: const TextInputType.numberWithOptions(
|
||||
decimal: true,
|
||||
@@ -637,7 +674,7 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
|
||||
|
||||
// Bandwidth
|
||||
DropdownButtonFormField<String>(
|
||||
value: _selectedBandwidth,
|
||||
initialValue: _selectedBandwidth,
|
||||
decoration: InputDecoration(
|
||||
labelText: AppLocalizations.of(context)!.bandwidth,
|
||||
border: const OutlineInputBorder(),
|
||||
@@ -660,7 +697,7 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
|
||||
|
||||
// Spreading Factor
|
||||
DropdownButtonFormField<int>(
|
||||
value: _selectedSpreadingFactor,
|
||||
initialValue: _selectedSpreadingFactor,
|
||||
decoration: InputDecoration(
|
||||
labelText: AppLocalizations.of(context)!.spreadingFactor,
|
||||
border: const OutlineInputBorder(),
|
||||
@@ -685,7 +722,7 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
|
||||
|
||||
// Coding Rate
|
||||
DropdownButtonFormField<int>(
|
||||
value: _selectedCodingRate,
|
||||
initialValue: _selectedCodingRate,
|
||||
decoration: InputDecoration(
|
||||
labelText: AppLocalizations.of(context)!.codingRate,
|
||||
border: const OutlineInputBorder(),
|
||||
@@ -714,7 +751,9 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
|
||||
decoration: InputDecoration(
|
||||
labelText: AppLocalizations.of(context)!.txPowerDbm,
|
||||
border: const OutlineInputBorder(),
|
||||
helperText: AppLocalizations.of(context)!.maxPowerDbm(deviceInfo.maxTxPower ?? 22),
|
||||
helperText: AppLocalizations.of(
|
||||
context,
|
||||
)!.maxPowerDbm(deviceInfo.maxTxPower ?? 22),
|
||||
),
|
||||
keyboardType: TextInputType.number,
|
||||
),
|
||||
@@ -815,7 +854,11 @@ class _CopyableInfoRow extends StatelessWidget {
|
||||
Clipboard.setData(ClipboardData(text: value));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(AppLocalizations.of(context)!.copiedToClipboardShort(label)),
|
||||
content: Text(
|
||||
AppLocalizations.of(
|
||||
context,
|
||||
)!.copiedToClipboardShort(label),
|
||||
),
|
||||
duration: const Duration(seconds: 2),
|
||||
backgroundColor: Colors.green,
|
||||
),
|
||||
|
||||
@@ -426,7 +426,7 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'${rssi} dBm',
|
||||
'$rssi dBm',
|
||||
style: TextStyle(
|
||||
color: signalColor,
|
||||
fontSize: 12,
|
||||
@@ -789,8 +789,9 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) =>
|
||||
PacketLogScreen(bleService: provider.bleService),
|
||||
builder: (context) => PacketLogScreen(
|
||||
bleService: provider.bleService,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -109,7 +109,9 @@ class _MapManagementScreenState extends State<MapManagementScreen> {
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_statusMessage = AppLocalizations.of(context)!.errorLoadingStats(e.toString());
|
||||
_statusMessage = AppLocalizations.of(
|
||||
context,
|
||||
)!.errorLoadingStats(e.toString());
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
@@ -153,7 +155,9 @@ class _MapManagementScreenState extends State<MapManagementScreen> {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(AppLocalizations.of(context)!.mbtilesImportedSuccessfully),
|
||||
content: Text(
|
||||
AppLocalizations.of(context)!.mbtilesImportedSuccessfully,
|
||||
),
|
||||
backgroundColor: Colors.green,
|
||||
),
|
||||
);
|
||||
@@ -174,7 +178,9 @@ class _MapManagementScreenState extends State<MapManagementScreen> {
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(AppLocalizations.of(context)!.deleteMbtilesConfirmTitle),
|
||||
content: Text(
|
||||
AppLocalizations.of(context)!.deleteMbtilesConfirmMessage(metadata.name),
|
||||
AppLocalizations.of(
|
||||
context,
|
||||
)!.deleteMbtilesConfirmMessage(metadata.name),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
@@ -205,7 +211,9 @@ class _MapManagementScreenState extends State<MapManagementScreen> {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(AppLocalizations.of(context)!.mbtilesDeletedSuccessfully),
|
||||
content: Text(
|
||||
AppLocalizations.of(context)!.mbtilesDeletedSuccessfully,
|
||||
),
|
||||
backgroundColor: Colors.green,
|
||||
),
|
||||
);
|
||||
@@ -246,13 +254,21 @@ class _MapManagementScreenState extends State<MapManagementScreen> {
|
||||
// Validate zoom levels
|
||||
final minZoomResult = validator.validateZoomLevel(_minZoom);
|
||||
if (!minZoomResult.isValid) {
|
||||
_showError(AppLocalizations.of(context)!.minZoomError(minZoomResult.errorMessage!));
|
||||
_showError(
|
||||
AppLocalizations.of(
|
||||
context,
|
||||
)!.minZoomError(minZoomResult.errorMessage!),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final maxZoomResult = validator.validateZoomLevel(_maxZoom);
|
||||
if (!maxZoomResult.isValid) {
|
||||
_showError(AppLocalizations.of(context)!.maxZoomError(maxZoomResult.errorMessage!));
|
||||
_showError(
|
||||
AppLocalizations.of(
|
||||
context,
|
||||
)!.maxZoomError(maxZoomResult.errorMessage!),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -261,10 +277,7 @@ class _MapManagementScreenState extends State<MapManagementScreen> {
|
||||
return;
|
||||
}
|
||||
|
||||
final bounds = LatLngBounds(
|
||||
LatLng(south!, west!),
|
||||
LatLng(north!, east!),
|
||||
);
|
||||
final bounds = LatLngBounds(LatLng(south!, west!), LatLng(north!, east!));
|
||||
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
@@ -291,7 +304,9 @@ class _MapManagementScreenState extends State<MapManagementScreen> {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_isDownloading = false;
|
||||
_statusMessage = AppLocalizations.of(context)!.downloadCompletedSuccessfully;
|
||||
_statusMessage = AppLocalizations.of(
|
||||
context,
|
||||
)!.downloadCompletedSuccessfully;
|
||||
});
|
||||
|
||||
await _loadCacheStats();
|
||||
@@ -308,7 +323,9 @@ class _MapManagementScreenState extends State<MapManagementScreen> {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_isDownloading = false;
|
||||
_statusMessage = AppLocalizations.of(context)!.downloadFailed(e.toString());
|
||||
_statusMessage = AppLocalizations.of(
|
||||
context,
|
||||
)!.downloadFailed(e.toString());
|
||||
});
|
||||
_showError(AppLocalizations.of(context)!.downloadFailed(e.toString()));
|
||||
}
|
||||
@@ -317,7 +334,9 @@ class _MapManagementScreenState extends State<MapManagementScreen> {
|
||||
Future<void> _cancelDownload() async {
|
||||
try {
|
||||
if (!mounted) return;
|
||||
setState(() => _statusMessage = AppLocalizations.of(context)!.cancellingDownload);
|
||||
setState(
|
||||
() => _statusMessage = AppLocalizations.of(context)!.cancellingDownload,
|
||||
);
|
||||
|
||||
await widget.tileCacheService.cancelDownload();
|
||||
|
||||
@@ -341,7 +360,9 @@ class _MapManagementScreenState extends State<MapManagementScreen> {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_isDownloading = false;
|
||||
_statusMessage = AppLocalizations.of(context)!.cancelFailed(e.toString());
|
||||
_statusMessage = AppLocalizations.of(
|
||||
context,
|
||||
)!.cancelFailed(e.toString());
|
||||
});
|
||||
_showError(AppLocalizations.of(context)!.cancelFailed(e.toString()));
|
||||
}
|
||||
@@ -352,9 +373,7 @@ class _MapManagementScreenState extends State<MapManagementScreen> {
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(AppLocalizations.of(context)!.clearMapsConfirmTitle),
|
||||
content: Text(
|
||||
AppLocalizations.of(context)!.clearMapsConfirmMessage,
|
||||
),
|
||||
content: Text(AppLocalizations.of(context)!.clearMapsConfirmMessage),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
@@ -382,7 +401,9 @@ class _MapManagementScreenState extends State<MapManagementScreen> {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(AppLocalizations.of(context)!.cacheClearedSuccessfully),
|
||||
content: Text(
|
||||
AppLocalizations.of(context)!.cacheClearedSuccessfully,
|
||||
),
|
||||
backgroundColor: Colors.green,
|
||||
),
|
||||
);
|
||||
@@ -410,10 +431,13 @@ class _MapManagementScreenState extends State<MapManagementScreen> {
|
||||
|
||||
// Export to temporary directory first (works on all platforms)
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final fileName = 'meshcore_tiles_${DateTime.now().millisecondsSinceEpoch}.fmtc';
|
||||
final fileName =
|
||||
'meshcore_tiles_${DateTime.now().millisecondsSinceEpoch}.fmtc';
|
||||
final tempFilePath = '${tempDir.path}/$fileName';
|
||||
|
||||
final exportedCount = await widget.tileCacheService.exportStore(tempFilePath);
|
||||
final exportedCount = await widget.tileCacheService.exportStore(
|
||||
tempFilePath,
|
||||
);
|
||||
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
@@ -443,7 +467,9 @@ class _MapManagementScreenState extends State<MapManagementScreen> {
|
||||
if (result.status == ShareResultStatus.success) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(AppLocalizations.of(context)!.exportSuccess(exportedCount)),
|
||||
content: Text(
|
||||
AppLocalizations.of(context)!.exportSuccess(exportedCount),
|
||||
),
|
||||
backgroundColor: Colors.green,
|
||||
),
|
||||
);
|
||||
@@ -484,7 +510,9 @@ class _MapManagementScreenState extends State<MapManagementScreen> {
|
||||
|
||||
// Optional: Preview stores in archive before importing
|
||||
try {
|
||||
final stores = await widget.tileCacheService.listArchiveStores(filePath);
|
||||
final stores = await widget.tileCacheService.listArchiveStores(
|
||||
filePath,
|
||||
);
|
||||
debugPrint('Archive contains stores: $stores');
|
||||
} catch (e) {
|
||||
debugPrint('Could not list stores: $e');
|
||||
@@ -504,7 +532,9 @@ class _MapManagementScreenState extends State<MapManagementScreen> {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
AppLocalizations.of(context)!.importSuccess(importResult['successfulStores'] as int),
|
||||
AppLocalizations.of(
|
||||
context,
|
||||
)!.importSuccess(importResult['successfulStores'] as int),
|
||||
),
|
||||
backgroundColor: Colors.green,
|
||||
),
|
||||
@@ -535,9 +565,7 @@ class _MapManagementScreenState extends State<MapManagementScreen> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(AppLocalizations.of(context)!.mapManagement),
|
||||
),
|
||||
appBar: AppBar(title: Text(AppLocalizations.of(context)!.mapManagement)),
|
||||
body: _isLoading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: SingleChildScrollView(
|
||||
@@ -622,7 +650,10 @@ class _MapManagementScreenState extends State<MapManagementScreen> {
|
||||
Icon(icon, size: 20, color: Colors.grey[600]),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(label, style: const TextStyle(fontWeight: FontWeight.w500)),
|
||||
child: Text(
|
||||
label,
|
||||
style: const TextStyle(fontWeight: FontWeight.w500),
|
||||
),
|
||||
),
|
||||
Text(value, style: TextStyle(color: Colors.grey[600])),
|
||||
],
|
||||
@@ -667,7 +698,11 @@ class _MapManagementScreenState extends State<MapManagementScreen> {
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(Icons.map_outlined, size: 48, color: Colors.grey[400]),
|
||||
Icon(
|
||||
Icons.map_outlined,
|
||||
size: 48,
|
||||
color: Colors.grey[400],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
AppLocalizations.of(context)!.noMbtilesFiles,
|
||||
@@ -678,74 +713,79 @@ class _MapManagementScreenState extends State<MapManagementScreen> {
|
||||
),
|
||||
)
|
||||
else
|
||||
..._mbtilesFiles.map((metadata) => Card(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
child: ExpansionTile(
|
||||
leading: Icon(
|
||||
metadata.isVector ? Icons.layers : Icons.image,
|
||||
color: metadata.isVector ? Colors.blue : Colors.orange,
|
||||
),
|
||||
title: Text(
|
||||
metadata.name,
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
subtitle: Text(
|
||||
'${metadata.fileSizeFormatted} • ${metadata.format?.toUpperCase() ?? "Unknown"}',
|
||||
),
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (metadata.description != null) ...[
|
||||
Text(
|
||||
metadata.description!,
|
||||
style: TextStyle(color: Colors.grey[700]),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
_buildInfoRow(
|
||||
AppLocalizations.of(context)!.zoomLevels,
|
||||
'${metadata.minZoom ?? "?"} - ${metadata.maxZoom ?? "?"}',
|
||||
..._mbtilesFiles.map(
|
||||
(metadata) => Card(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
child: ExpansionTile(
|
||||
leading: Icon(
|
||||
metadata.isVector ? Icons.layers : Icons.image,
|
||||
color: metadata.isVector ? Colors.blue : Colors.orange,
|
||||
),
|
||||
title: Text(
|
||||
metadata.name,
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
subtitle: Text(
|
||||
'${metadata.fileSizeFormatted} • ${metadata.format?.toUpperCase() ?? "Unknown"}',
|
||||
),
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (metadata.description != null) ...[
|
||||
Text(
|
||||
metadata.description!,
|
||||
style: TextStyle(color: Colors.grey[700]),
|
||||
),
|
||||
if (metadata.bounds != null)
|
||||
_buildInfoRow(
|
||||
AppLocalizations.of(context)!.bounds,
|
||||
metadata.bounds!,
|
||||
),
|
||||
if (metadata.isVector) ...[
|
||||
_buildInfoRow(
|
||||
AppLocalizations.of(context)!.type,
|
||||
AppLocalizations.of(context)!.vectorTiles,
|
||||
),
|
||||
_buildInfoRow(
|
||||
AppLocalizations.of(context)!.schema,
|
||||
_mbtilesService.getVectorSchema(metadata) ??
|
||||
AppLocalizations.of(context)!.unknown,
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
TextButton.icon(
|
||||
onPressed: () => _deleteMbtilesFile(metadata),
|
||||
icon: const Icon(Icons.delete, color: Colors.red),
|
||||
label: Text(
|
||||
AppLocalizations.of(context)!.delete,
|
||||
style: const TextStyle(color: Colors.red),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
_buildInfoRow(
|
||||
AppLocalizations.of(context)!.zoomLevels,
|
||||
'${metadata.minZoom ?? "?"} - ${metadata.maxZoom ?? "?"}',
|
||||
),
|
||||
if (metadata.bounds != null)
|
||||
_buildInfoRow(
|
||||
AppLocalizations.of(context)!.bounds,
|
||||
metadata.bounds!,
|
||||
),
|
||||
if (metadata.isVector) ...[
|
||||
_buildInfoRow(
|
||||
AppLocalizations.of(context)!.type,
|
||||
AppLocalizations.of(context)!.vectorTiles,
|
||||
),
|
||||
_buildInfoRow(
|
||||
AppLocalizations.of(context)!.schema,
|
||||
_mbtilesService.getVectorSchema(metadata) ??
|
||||
AppLocalizations.of(context)!.unknown,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
TextButton.icon(
|
||||
onPressed: () => _deleteMbtilesFile(metadata),
|
||||
icon: const Icon(
|
||||
Icons.delete,
|
||||
color: Colors.red,
|
||||
),
|
||||
label: Text(
|
||||
AppLocalizations.of(context)!.delete,
|
||||
style: const TextStyle(color: Colors.red),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
@@ -840,10 +880,7 @@ class _MapManagementScreenState extends State<MapManagementScreen> {
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
value,
|
||||
style: TextStyle(color: Colors.grey[800]),
|
||||
),
|
||||
child: Text(value, style: TextStyle(color: Colors.grey[800])),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -865,7 +902,7 @@ class _MapManagementScreenState extends State<MapManagementScreen> {
|
||||
|
||||
// Map Layer Selection
|
||||
DropdownButtonFormField<MapLayer>(
|
||||
value: _selectedLayer,
|
||||
initialValue: _selectedLayer,
|
||||
decoration: InputDecoration(
|
||||
labelText: AppLocalizations.of(context)!.mapLayer,
|
||||
border: const OutlineInputBorder(),
|
||||
@@ -876,11 +913,13 @@ class _MapManagementScreenState extends State<MapManagementScreen> {
|
||||
child: Text(layer.getLocalizedName(context)),
|
||||
);
|
||||
}).toList(),
|
||||
onChanged: _isDownloading ? null : (layer) {
|
||||
if (layer != null) {
|
||||
setState(() => _selectedLayer = layer);
|
||||
}
|
||||
},
|
||||
onChanged: _isDownloading
|
||||
? null
|
||||
: (layer) {
|
||||
if (layer != null) {
|
||||
setState(() => _selectedLayer = layer);
|
||||
}
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
@@ -900,7 +939,9 @@ class _MapManagementScreenState extends State<MapManagementScreen> {
|
||||
border: const OutlineInputBorder(),
|
||||
hintText: '46.1',
|
||||
),
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
keyboardType: const TextInputType.numberWithOptions(
|
||||
decimal: true,
|
||||
),
|
||||
enabled: !_isDownloading,
|
||||
),
|
||||
),
|
||||
@@ -913,7 +954,9 @@ class _MapManagementScreenState extends State<MapManagementScreen> {
|
||||
border: const OutlineInputBorder(),
|
||||
hintText: '46.0',
|
||||
),
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
keyboardType: const TextInputType.numberWithOptions(
|
||||
decimal: true,
|
||||
),
|
||||
enabled: !_isDownloading,
|
||||
),
|
||||
),
|
||||
@@ -930,7 +973,9 @@ class _MapManagementScreenState extends State<MapManagementScreen> {
|
||||
border: const OutlineInputBorder(),
|
||||
hintText: '14.6',
|
||||
),
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
keyboardType: const TextInputType.numberWithOptions(
|
||||
decimal: true,
|
||||
),
|
||||
enabled: !_isDownloading,
|
||||
),
|
||||
),
|
||||
@@ -943,7 +988,9 @@ class _MapManagementScreenState extends State<MapManagementScreen> {
|
||||
border: const OutlineInputBorder(),
|
||||
hintText: '14.4',
|
||||
),
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
keyboardType: const TextInputType.numberWithOptions(
|
||||
decimal: true,
|
||||
),
|
||||
enabled: !_isDownloading,
|
||||
),
|
||||
),
|
||||
@@ -970,14 +1017,16 @@ class _MapManagementScreenState extends State<MapManagementScreen> {
|
||||
max: 19,
|
||||
divisions: 18,
|
||||
label: '$_minZoom',
|
||||
onChanged: _isDownloading ? null : (value) {
|
||||
setState(() {
|
||||
_minZoom = value.toInt();
|
||||
if (_minZoom > _maxZoom) {
|
||||
_maxZoom = _minZoom;
|
||||
}
|
||||
});
|
||||
},
|
||||
onChanged: _isDownloading
|
||||
? null
|
||||
: (value) {
|
||||
setState(() {
|
||||
_minZoom = value.toInt();
|
||||
if (_minZoom > _maxZoom) {
|
||||
_maxZoom = _minZoom;
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -994,14 +1043,16 @@ class _MapManagementScreenState extends State<MapManagementScreen> {
|
||||
max: 19,
|
||||
divisions: 18,
|
||||
label: '$_maxZoom',
|
||||
onChanged: _isDownloading ? null : (value) {
|
||||
setState(() {
|
||||
_maxZoom = value.toInt();
|
||||
if (_maxZoom < _minZoom) {
|
||||
_minZoom = _maxZoom;
|
||||
}
|
||||
});
|
||||
},
|
||||
onChanged: _isDownloading
|
||||
? null
|
||||
: (value) {
|
||||
setState(() {
|
||||
_maxZoom = value.toInt();
|
||||
if (_maxZoom < _minZoom) {
|
||||
_minZoom = _maxZoom;
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -1018,7 +1069,9 @@ class _MapManagementScreenState extends State<MapManagementScreen> {
|
||||
color: Theme.of(context).colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: Theme.of(context).colorScheme.primary.withOpacity(0.3),
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.primary.withOpacity(0.3),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
@@ -1029,10 +1082,13 @@ class _MapManagementScreenState extends State<MapManagementScreen> {
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
_statusMessage ?? AppLocalizations.of(context)!.downloadingDots,
|
||||
_statusMessage ??
|
||||
AppLocalizations.of(context)!.downloadingDots,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Theme.of(context).colorScheme.onPrimaryContainer,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onPrimaryContainer,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
@@ -1043,7 +1099,9 @@ class _MapManagementScreenState extends State<MapManagementScreen> {
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16,
|
||||
color: Theme.of(context).colorScheme.onPrimaryContainer,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onPrimaryContainer,
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -1054,7 +1112,9 @@ class _MapManagementScreenState extends State<MapManagementScreen> {
|
||||
child: LinearProgressIndicator(
|
||||
value: _downloadProgress / 100,
|
||||
minHeight: 8,
|
||||
backgroundColor: Theme.of(context).colorScheme.primary.withOpacity(0.2),
|
||||
backgroundColor: Theme.of(
|
||||
context,
|
||||
).colorScheme.primary.withOpacity(0.2),
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
|
||||
@@ -35,7 +35,8 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
String? _highlightedMessageId;
|
||||
|
||||
// Message destination state
|
||||
String _destinationType = MessageDestinationPreferences.destinationTypeChannel;
|
||||
String _destinationType =
|
||||
MessageDestinationPreferences.destinationTypeChannel;
|
||||
Contact? _selectedRecipient;
|
||||
|
||||
/// Helper method to compare two public keys for equality
|
||||
@@ -129,7 +130,8 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
|
||||
/// Load saved message destination from preferences
|
||||
Future<void> _loadSavedDestination() async {
|
||||
final savedDestination = await MessageDestinationPreferences.getDestination();
|
||||
final savedDestination =
|
||||
await MessageDestinationPreferences.getDestination();
|
||||
|
||||
if (savedDestination == null || !mounted) {
|
||||
// Default to public channel
|
||||
@@ -160,7 +162,8 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
'⚠️ [MessagesTab] Saved recipient not found, falling back to public channel',
|
||||
);
|
||||
setState(() {
|
||||
_destinationType = MessageDestinationPreferences.destinationTypeChannel;
|
||||
_destinationType =
|
||||
MessageDestinationPreferences.destinationTypeChannel;
|
||||
_selectedRecipient = null;
|
||||
});
|
||||
await MessageDestinationPreferences.clearDestination();
|
||||
@@ -197,7 +200,8 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
/// Handle recipient selection
|
||||
Future<void> _onRecipientSelected(String type, Contact? recipient) async {
|
||||
// Get display name before async gap
|
||||
final recipientName = type == MessageDestinationPreferences.destinationTypeChannel
|
||||
final recipientName =
|
||||
type == MessageDestinationPreferences.destinationTypeChannel
|
||||
? AppLocalizations.of(context)!.publicChannel
|
||||
: (recipient?.displayName ?? recipient?.advName ?? 'Unknown');
|
||||
|
||||
@@ -214,17 +218,16 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
|
||||
// Show confirmation toast
|
||||
if (!mounted) return;
|
||||
ToastLogger.success(
|
||||
context,
|
||||
'Messages will be sent to: $recipientName',
|
||||
);
|
||||
ToastLogger.success(context, 'Messages will be sent to: $recipientName');
|
||||
}
|
||||
|
||||
/// Get icon for current destination type
|
||||
IconData _getDestinationIcon() {
|
||||
if (_destinationType == MessageDestinationPreferences.destinationTypeChannel) {
|
||||
if (_destinationType ==
|
||||
MessageDestinationPreferences.destinationTypeChannel) {
|
||||
return Icons.public;
|
||||
} else if (_destinationType == MessageDestinationPreferences.destinationTypeRoom) {
|
||||
} else if (_destinationType ==
|
||||
MessageDestinationPreferences.destinationTypeRoom) {
|
||||
return Icons.meeting_room;
|
||||
} else {
|
||||
return Icons.person;
|
||||
@@ -234,10 +237,12 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
/// Get tooltip for destination button
|
||||
String _getDestinationTooltip() {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
if (_destinationType == MessageDestinationPreferences.destinationTypeChannel) {
|
||||
if (_destinationType ==
|
||||
MessageDestinationPreferences.destinationTypeChannel) {
|
||||
return '${l10n.publicChannel} (tap to change)';
|
||||
} else if (_selectedRecipient != null) {
|
||||
final recipientName = _selectedRecipient!.displayName ?? _selectedRecipient!.advName;
|
||||
final recipientName =
|
||||
_selectedRecipient!.displayName ?? _selectedRecipient!.advName;
|
||||
return '$recipientName (tap to change)';
|
||||
}
|
||||
return 'Select recipient';
|
||||
@@ -259,7 +264,8 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
|
||||
try {
|
||||
// Check destination type and send accordingly
|
||||
if (_destinationType == MessageDestinationPreferences.destinationTypeChannel) {
|
||||
if (_destinationType ==
|
||||
MessageDestinationPreferences.destinationTypeChannel) {
|
||||
// Send to public channel
|
||||
await _sendToChannel(text, connectionProvider, messagesProvider);
|
||||
} else if (_selectedRecipient != null) {
|
||||
@@ -272,7 +278,9 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
);
|
||||
} else {
|
||||
// Fallback to public channel if no recipient selected
|
||||
debugPrint('⚠️ [MessagesTab] No recipient selected, falling back to channel');
|
||||
debugPrint(
|
||||
'⚠️ [MessagesTab] No recipient selected, falling back to channel',
|
||||
);
|
||||
await _sendToChannel(text, connectionProvider, messagesProvider);
|
||||
}
|
||||
|
||||
@@ -422,7 +430,8 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
|
||||
if (sendToChannel) {
|
||||
// Create message ID
|
||||
final messageId = '${DateTime.now().millisecondsSinceEpoch}_channel_sent';
|
||||
final messageId =
|
||||
'${DateTime.now().millisecondsSinceEpoch}_channel_sent';
|
||||
final timestamp = DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
||||
|
||||
// Get current device's public key (first 6 bytes)
|
||||
@@ -455,10 +464,7 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
);
|
||||
|
||||
if (!mounted) return;
|
||||
ToastLogger.success(
|
||||
context,
|
||||
'SAR marker broadcast to public channel',
|
||||
);
|
||||
ToastLogger.success(context, 'SAR marker broadcast to public channel');
|
||||
} else {
|
||||
// Create message ID
|
||||
final messageId = '${DateTime.now().millisecondsSinceEpoch}_sent';
|
||||
@@ -490,7 +496,7 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
final contactsProvider = context.read<ContactsProvider>();
|
||||
final roomContact = contactsProvider.contacts.where((c) {
|
||||
return c.publicKey.length >= roomPublicKey!.length &&
|
||||
_publicKeysMatch(c.publicKey, roomPublicKey!);
|
||||
_publicKeysMatch(c.publicKey, roomPublicKey);
|
||||
}).firstOrNull;
|
||||
|
||||
// Send SAR message to selected room (persisted and immutable)
|
||||
@@ -507,10 +513,7 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
ToastLogger.success(
|
||||
context,
|
||||
'SAR marker sent to room',
|
||||
);
|
||||
ToastLogger.success(context, 'SAR marker sent to room');
|
||||
}
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
@@ -615,7 +618,8 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
itemCount: messages.length,
|
||||
itemBuilder: (context, index) {
|
||||
final message = messages[index];
|
||||
final isHighlighted = message.id == _highlightedMessageId;
|
||||
final isHighlighted =
|
||||
message.id == _highlightedMessageId;
|
||||
|
||||
// Display system messages with minimal styling
|
||||
if (message.isSystemMessage) {
|
||||
@@ -680,10 +684,18 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
tooltip: _getDestinationTooltip(),
|
||||
onPressed: _showRecipientSelector,
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: _destinationType == MessageDestinationPreferences.destinationTypeChannel
|
||||
? Theme.of(context).colorScheme.surfaceContainerHighest
|
||||
backgroundColor:
|
||||
_destinationType ==
|
||||
MessageDestinationPreferences
|
||||
.destinationTypeChannel
|
||||
? Theme.of(
|
||||
context,
|
||||
).colorScheme.surfaceContainerHighest
|
||||
: Theme.of(context).colorScheme.secondaryContainer,
|
||||
foregroundColor: _destinationType == MessageDestinationPreferences.destinationTypeChannel
|
||||
foregroundColor:
|
||||
_destinationType ==
|
||||
MessageDestinationPreferences
|
||||
.destinationTypeChannel
|
||||
? Theme.of(context).colorScheme.onSurface
|
||||
: Theme.of(context).colorScheme.onSecondaryContainer,
|
||||
),
|
||||
@@ -699,9 +711,7 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
maxLengthEnforcement: MaxLengthEnforcement.enforced,
|
||||
style: const TextStyle(fontSize: 14),
|
||||
decoration: InputDecoration(
|
||||
hintText: AppLocalizations.of(
|
||||
context,
|
||||
)!.typeYourMessage,
|
||||
hintText: AppLocalizations.of(context)!.typeYourMessage,
|
||||
hintStyle: const TextStyle(fontSize: 14),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
@@ -718,9 +728,7 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
fontSize: 10,
|
||||
color: _characterCount > _maxCharacters * 0.9
|
||||
? Colors.orange
|
||||
: Theme.of(
|
||||
context,
|
||||
).textTheme.bodySmall?.color,
|
||||
: Theme.of(context).textTheme.bodySmall?.color,
|
||||
),
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(
|
||||
@@ -885,7 +893,10 @@ class _MessageBubble extends StatelessWidget {
|
||||
onTap: () {
|
||||
Clipboard.setData(ClipboardData(text: message.text));
|
||||
Navigator.pop(context);
|
||||
ToastLogger.success(context, AppLocalizations.of(context)!.textCopiedToClipboard);
|
||||
ToastLogger.success(
|
||||
context,
|
||||
AppLocalizations.of(context)!.textCopiedToClipboard,
|
||||
);
|
||||
},
|
||||
),
|
||||
// Delete message option
|
||||
@@ -1100,8 +1111,8 @@ class _MessageBubble extends StatelessWidget {
|
||||
color: isHighlighted
|
||||
? Theme.of(context).colorScheme.primaryContainer
|
||||
: isSarMarker
|
||||
? _getSarMarkerColor(context, isDarkMode)
|
||||
: _getMessageBubbleColor(context, isOwnMessage, isDarkMode),
|
||||
? _getSarMarkerColor(context, isDarkMode)
|
||||
: _getMessageBubbleColor(context, isOwnMessage, isDarkMode),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: isHighlighted
|
||||
? Border.all(
|
||||
@@ -1109,43 +1120,45 @@ class _MessageBubble extends StatelessWidget {
|
||||
width: 3,
|
||||
)
|
||||
: isSarMarker
|
||||
? Border.all(
|
||||
color: _getSarMarkerBorderColor(context, isDarkMode),
|
||||
width: 2,
|
||||
)
|
||||
: isOwnMessage
|
||||
? Border.all(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.primary.withValues(alpha: 0.3),
|
||||
width: 1.5,
|
||||
)
|
||||
: !message.isRead &&
|
||||
!message.isSentMessage &&
|
||||
!message.isSystemMessage
|
||||
? Border.all(color: Colors.blue, width: 1.5)
|
||||
: null,
|
||||
? Border.all(
|
||||
color: _getSarMarkerBorderColor(context, isDarkMode),
|
||||
width: 2,
|
||||
)
|
||||
: isOwnMessage
|
||||
? Border.all(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.primary.withValues(alpha: 0.3),
|
||||
width: 1.5,
|
||||
)
|
||||
: !message.isRead &&
|
||||
!message.isSentMessage &&
|
||||
!message.isSystemMessage
|
||||
? Border.all(color: Colors.blue, width: 1.5)
|
||||
: null,
|
||||
boxShadow: isHighlighted
|
||||
? [
|
||||
BoxShadow(
|
||||
color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.5),
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.primary.withValues(alpha: 0.5),
|
||||
blurRadius: 12,
|
||||
spreadRadius: 2,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
]
|
||||
: isSarMarker
|
||||
? [
|
||||
BoxShadow(
|
||||
color: _getSarMarkerBorderColor(
|
||||
context,
|
||||
isDarkMode,
|
||||
).withValues(alpha: 0.3),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
]
|
||||
: null,
|
||||
? [
|
||||
BoxShadow(
|
||||
color: _getSarMarkerBorderColor(
|
||||
context,
|
||||
isDarkMode,
|
||||
).withValues(alpha: 0.3),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
]
|
||||
: null,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -1272,9 +1285,12 @@ class _MessageBubble extends StatelessWidget {
|
||||
children: [
|
||||
Text(
|
||||
// Show template name (sarNotes) if available, otherwise show localized type name
|
||||
message.sarNotes != null && message.sarNotes!.isNotEmpty
|
||||
message.sarNotes != null &&
|
||||
message.sarNotes!.isNotEmpty
|
||||
? message.sarNotes!
|
||||
: message.sarMarkerType!.getLocalizedName(context),
|
||||
: message.sarMarkerType!.getLocalizedName(
|
||||
context,
|
||||
),
|
||||
style: Theme.of(context).textTheme.titleSmall
|
||||
?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
@@ -1411,7 +1427,7 @@ class _MessageBubble extends StatelessWidget {
|
||||
).colorScheme.primaryContainer.withValues(alpha: 0.15);
|
||||
} else {
|
||||
// Others' messages: default surface color
|
||||
return Theme.of(context).colorScheme.surfaceVariant;
|
||||
return Theme.of(context).colorScheme.surfaceContainerHighest;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -86,10 +86,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
_locationService.onError = (error) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(error),
|
||||
backgroundColor: Colors.orange,
|
||||
),
|
||||
SnackBar(content: Text(error), backgroundColor: Colors.orange),
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -119,7 +116,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
|
||||
// Load settings and restore tracking state
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final wasTracking = prefs.getBool('background_tracking_enabled') ?? false;
|
||||
final wasTracking =
|
||||
prefs.getBool('background_tracking_enabled') ?? false;
|
||||
|
||||
if (wasTracking) {
|
||||
await _startBackgroundTracking();
|
||||
@@ -239,7 +237,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(AppLocalizations.of(context)!.failedToLoadSampleData(e.toString())),
|
||||
content: Text(
|
||||
AppLocalizations.of(context)!.failedToLoadSampleData(e.toString()),
|
||||
),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
@@ -296,7 +296,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
// Permission granted
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(AppLocalizations.of(context)!.locationPermissionGranted),
|
||||
content: Text(
|
||||
AppLocalizations.of(context)!.locationPermissionGranted,
|
||||
),
|
||||
backgroundColor: Colors.green,
|
||||
),
|
||||
);
|
||||
@@ -305,7 +307,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
// Permission denied
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(AppLocalizations.of(context)!.locationPermissionRequiredForGps),
|
||||
content: Text(
|
||||
AppLocalizations.of(context)!.locationPermissionRequiredForGps,
|
||||
),
|
||||
backgroundColor: Colors.orange,
|
||||
duration: const Duration(seconds: 4),
|
||||
),
|
||||
@@ -316,7 +320,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(AppLocalizations.of(context)!.locationPermissionAlreadyGranted),
|
||||
content: Text(
|
||||
AppLocalizations.of(context)!.locationPermissionAlreadyGranted,
|
||||
),
|
||||
backgroundColor: Colors.blue,
|
||||
),
|
||||
);
|
||||
@@ -325,10 +331,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
debugPrint('Error handling location permission: $e');
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Error: $e'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
SnackBar(content: Text('Error: $e'), backgroundColor: Colors.red),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -366,9 +369,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(AppLocalizations.of(context)!.clearAllDataConfirmTitle),
|
||||
content: Text(
|
||||
AppLocalizations.of(context)!.clearAllDataConfirmMessage,
|
||||
),
|
||||
content: Text(AppLocalizations.of(context)!.clearAllDataConfirmMessage),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
@@ -438,7 +439,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
builder: (context, appProvider, child) => SwitchListTile(
|
||||
secondary: const Icon(Icons.visibility_off),
|
||||
title: Text(AppLocalizations.of(context)!.simpleMode),
|
||||
subtitle: Text(AppLocalizations.of(context)!.simpleModeDescription),
|
||||
subtitle: Text(
|
||||
AppLocalizations.of(context)!.simpleModeDescription,
|
||||
),
|
||||
value: appProvider.isSimpleMode,
|
||||
onChanged: (value) async {
|
||||
await appProvider.toggleSimpleMode(value);
|
||||
@@ -485,19 +488,27 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
|
||||
switch (permission) {
|
||||
case LocationPermission.always:
|
||||
statusText = AppLocalizations.of(context)!.locationPermissionGrantedAlways;
|
||||
statusText = AppLocalizations.of(
|
||||
context,
|
||||
)!.locationPermissionGrantedAlways;
|
||||
statusColor = Colors.green;
|
||||
break;
|
||||
case LocationPermission.whileInUse:
|
||||
statusText = AppLocalizations.of(context)!.locationPermissionGrantedWhileInUse;
|
||||
statusText = AppLocalizations.of(
|
||||
context,
|
||||
)!.locationPermissionGrantedWhileInUse;
|
||||
statusColor = Colors.green;
|
||||
break;
|
||||
case LocationPermission.denied:
|
||||
statusText = AppLocalizations.of(context)!.locationPermissionDeniedTapToRequest;
|
||||
statusText = AppLocalizations.of(
|
||||
context,
|
||||
)!.locationPermissionDeniedTapToRequest;
|
||||
statusColor = Colors.orange;
|
||||
break;
|
||||
case LocationPermission.deniedForever:
|
||||
statusText = AppLocalizations.of(context)!.locationPermissionPermanentlyDeniedOpenSettings;
|
||||
statusText = AppLocalizations.of(
|
||||
context,
|
||||
)!.locationPermissionPermanentlyDeniedOpenSettings;
|
||||
statusColor = Colors.red;
|
||||
break;
|
||||
default:
|
||||
@@ -505,10 +516,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
statusColor = Colors.grey;
|
||||
}
|
||||
|
||||
return Text(
|
||||
statusText,
|
||||
style: TextStyle(color: statusColor),
|
||||
);
|
||||
return Text(statusText, style: TextStyle(color: statusColor));
|
||||
},
|
||||
),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
@@ -517,13 +525,17 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
const Divider(),
|
||||
|
||||
// Location Settings Section
|
||||
_buildSectionHeader(AppLocalizations.of(context)!.locationBroadcasting),
|
||||
_buildSectionHeader(
|
||||
AppLocalizations.of(context)!.locationBroadcasting,
|
||||
),
|
||||
|
||||
// Automatic tracking settings
|
||||
SwitchListTile(
|
||||
secondary: const Icon(Icons.location_on),
|
||||
title: Text(AppLocalizations.of(context)!.autoLocationTracking),
|
||||
subtitle: Text(AppLocalizations.of(context)!.automaticallyBroadcastPosition),
|
||||
subtitle: Text(
|
||||
AppLocalizations.of(context)!.automaticallyBroadcastPosition,
|
||||
),
|
||||
value: _locationService.isTracking,
|
||||
onChanged: (value) {
|
||||
if (value) {
|
||||
@@ -538,7 +550,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
ListTile(
|
||||
leading: const Icon(Icons.tune),
|
||||
title: Text(AppLocalizations.of(context)!.configureTracking),
|
||||
subtitle: Text(AppLocalizations.of(context)!.distanceAndTimeThresholds),
|
||||
subtitle: Text(
|
||||
AppLocalizations.of(context)!.distanceAndTimeThresholds,
|
||||
),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => _showTrackingConfigDialog(),
|
||||
),
|
||||
@@ -654,7 +668,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
context: context,
|
||||
builder: (context) => StatefulBuilder(
|
||||
builder: (context, setDialogState) => AlertDialog(
|
||||
title: Text(AppLocalizations.of(context)!.locationTrackingConfiguration),
|
||||
title: Text(
|
||||
AppLocalizations.of(context)!.locationTrackingConfiguration,
|
||||
),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
@@ -663,29 +679,31 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
// Description
|
||||
Text(
|
||||
AppLocalizations.of(context)!.configureWhenLocationBroadcasts,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Colors.grey,
|
||||
),
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodySmall?.copyWith(color: Colors.grey),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Minimum Distance
|
||||
Text(
|
||||
AppLocalizations.of(context)!.minimumDistance,
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
AppLocalizations.of(context)!.broadcastAfterMoving(tempMinDistance.toStringAsFixed(0)),
|
||||
AppLocalizations.of(
|
||||
context,
|
||||
)!.broadcastAfterMoving(tempMinDistance.toStringAsFixed(0)),
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
SliderTheme(
|
||||
data: SliderTheme.of(context).copyWith(
|
||||
showValueIndicator: ShowValueIndicator.always,
|
||||
),
|
||||
data: SliderTheme.of(
|
||||
context,
|
||||
).copyWith(showValueIndicator: ShowValueIndicator.onDrag),
|
||||
child: Slider(
|
||||
value: tempMinDistance,
|
||||
min: 1,
|
||||
@@ -718,20 +736,22 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
// Maximum Distance
|
||||
Text(
|
||||
AppLocalizations.of(context)!.maximumDistance,
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
AppLocalizations.of(context)!.alwaysBroadcastAfterMoving(tempMaxDistance.toStringAsFixed(0)),
|
||||
AppLocalizations.of(context)!.alwaysBroadcastAfterMoving(
|
||||
tempMaxDistance.toStringAsFixed(0),
|
||||
),
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
SliderTheme(
|
||||
data: SliderTheme.of(context).copyWith(
|
||||
showValueIndicator: ShowValueIndicator.always,
|
||||
),
|
||||
data: SliderTheme.of(
|
||||
context,
|
||||
).copyWith(showValueIndicator: ShowValueIndicator.onDrag),
|
||||
child: Slider(
|
||||
value: tempMaxDistance,
|
||||
min: tempMinDistance,
|
||||
@@ -750,9 +770,14 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text('${tempMinDistance.toStringAsFixed(0)}m',
|
||||
style: Theme.of(context).textTheme.bodySmall),
|
||||
Text('500m', style: Theme.of(context).textTheme.bodySmall),
|
||||
Text(
|
||||
'${tempMinDistance.toStringAsFixed(0)}m',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
Text(
|
||||
'500m',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -761,20 +786,22 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
// Minimum Time Interval
|
||||
Text(
|
||||
AppLocalizations.of(context)!.minimumTimeInterval,
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
AppLocalizations.of(context)!.alwaysBroadcastEvery(_formatDuration(tempTimeInterval)),
|
||||
AppLocalizations.of(
|
||||
context,
|
||||
)!.alwaysBroadcastEvery(_formatDuration(tempTimeInterval)),
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
SliderTheme(
|
||||
data: SliderTheme.of(context).copyWith(
|
||||
showValueIndicator: ShowValueIndicator.always,
|
||||
),
|
||||
data: SliderTheme.of(
|
||||
context,
|
||||
).copyWith(showValueIndicator: ShowValueIndicator.onDrag),
|
||||
child: Slider(
|
||||
value: tempTimeInterval.toDouble(),
|
||||
min: 10,
|
||||
@@ -794,7 +821,10 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text('10s', style: Theme.of(context).textTheme.bodySmall),
|
||||
Text('10min', style: Theme.of(context).textTheme.bodySmall),
|
||||
Text(
|
||||
'10min',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -819,7 +849,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
|
||||
// Update tracking if active
|
||||
if (_locationService.isTracking) {
|
||||
await _locationService.updateDistanceThreshold(tempMinDistance);
|
||||
await _locationService.updateDistanceThreshold(
|
||||
tempMinDistance,
|
||||
);
|
||||
}
|
||||
|
||||
// Close dialog before setState
|
||||
@@ -897,7 +929,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
),
|
||||
],
|
||||
),
|
||||
subtitle: Text(AppLocalizations.of(context)!.alertEmergencyMode),
|
||||
subtitle: Text(
|
||||
AppLocalizations.of(context)!.alertEmergencyMode,
|
||||
),
|
||||
value: AppThemeMode.sarRed,
|
||||
groupValue: _selectedTheme,
|
||||
onChanged: (value) {
|
||||
@@ -945,7 +979,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
),
|
||||
],
|
||||
),
|
||||
subtitle: Text(AppLocalizations.of(context)!.sarNavyBlueDescription),
|
||||
subtitle: Text(
|
||||
AppLocalizations.of(context)!.sarNavyBlueDescription,
|
||||
),
|
||||
value: AppThemeMode.sarNavyBlue,
|
||||
groupValue: _selectedTheme,
|
||||
onChanged: (value) {
|
||||
@@ -1043,9 +1079,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
AppLocalizations.of(context)!.aboutDescription,
|
||||
),
|
||||
Text(AppLocalizations.of(context)!.aboutDescription),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
AppLocalizations.of(context)!.technologiesUsed,
|
||||
@@ -1054,9 +1088,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
AppLocalizations.of(context)!.technologiesList,
|
||||
),
|
||||
Text(AppLocalizations.of(context)!.technologiesList),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import 'dart:async';
|
||||
import 'dart:ui';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_background_service/flutter_background_service.dart';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'meshcore_ble_service.dart';
|
||||
@@ -32,7 +30,9 @@ class BackgroundLocationService {
|
||||
/// additional platform-specific configuration is required.
|
||||
Future<bool> startTracking({double distanceThreshold = 10.0}) async {
|
||||
if (!_isInitialized || _bleService == null) {
|
||||
debugPrint('⚠️ [BackgroundLocation] Service not initialized or BLE service null');
|
||||
debugPrint(
|
||||
'⚠️ [BackgroundLocation] Service not initialized or BLE service null',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -52,7 +52,9 @@ class BackgroundLocationService {
|
||||
}
|
||||
|
||||
if (permission == LocationPermission.deniedForever) {
|
||||
debugPrint('⚠️ [BackgroundLocation] Location permission permanently denied');
|
||||
debugPrint(
|
||||
'⚠️ [BackgroundLocation] Location permission permanently denied',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -64,60 +66,77 @@ class BackgroundLocationService {
|
||||
// Start listening to position updates
|
||||
Position? lastPosition;
|
||||
try {
|
||||
_positionSubscription = Geolocator.getPositionStream(
|
||||
locationSettings: LocationSettings(
|
||||
accuracy: LocationAccuracy.best,
|
||||
distanceFilter: distanceThreshold.toInt(),
|
||||
),
|
||||
).listen((Position position) async {
|
||||
debugPrint('📍 [BackgroundLocation] New position: ${position.latitude}, ${position.longitude}');
|
||||
|
||||
// Calculate distance from last position
|
||||
if (lastPosition != null) {
|
||||
final distance = Geolocator.distanceBetween(
|
||||
lastPosition!.latitude,
|
||||
lastPosition!.longitude,
|
||||
position.latitude,
|
||||
position.longitude,
|
||||
);
|
||||
|
||||
debugPrint(' Distance moved: ${distance.toStringAsFixed(1)}m (threshold: ${distanceThreshold}m)');
|
||||
|
||||
// Skip if haven't moved enough
|
||||
if (distance < distanceThreshold) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Update last position
|
||||
lastPosition = position;
|
||||
|
||||
// Save to preferences
|
||||
await prefs.setDouble(_prefKeyLastLat, position.latitude);
|
||||
await prefs.setDouble(_prefKeyLastLon, position.longitude);
|
||||
|
||||
// Update device's advertised location
|
||||
if (_bleService != null && _bleService!.isConnected) {
|
||||
try {
|
||||
debugPrint('📤 [BackgroundLocation] Updating device location...');
|
||||
await _bleService!.setAdvertLatLon(
|
||||
latitude: position.latitude,
|
||||
longitude: position.longitude,
|
||||
_positionSubscription =
|
||||
Geolocator.getPositionStream(
|
||||
locationSettings: LocationSettings(
|
||||
accuracy: LocationAccuracy.best,
|
||||
distanceFilter: distanceThreshold.toInt(),
|
||||
),
|
||||
).listen((Position position) async {
|
||||
debugPrint(
|
||||
'📍 [BackgroundLocation] New position: ${position.latitude}, ${position.longitude}',
|
||||
);
|
||||
|
||||
// Send advertisement to mesh network
|
||||
debugPrint('📡 [BackgroundLocation] Broadcasting self advertisement...');
|
||||
await _bleService!.sendSelfAdvert(floodMode: true);
|
||||
debugPrint('✅ [BackgroundLocation] Location update sent successfully');
|
||||
} catch (e) {
|
||||
debugPrint('❌ [BackgroundLocation] Failed to send location update: $e');
|
||||
}
|
||||
} else {
|
||||
debugPrint('⚠️ [BackgroundLocation] BLE disconnected, cannot send update');
|
||||
}
|
||||
});
|
||||
// Calculate distance from last position
|
||||
if (lastPosition != null) {
|
||||
final distance = Geolocator.distanceBetween(
|
||||
lastPosition!.latitude,
|
||||
lastPosition!.longitude,
|
||||
position.latitude,
|
||||
position.longitude,
|
||||
);
|
||||
|
||||
debugPrint('✅ [BackgroundLocation] Tracking started with ${distanceThreshold}m threshold');
|
||||
debugPrint(
|
||||
' Distance moved: ${distance.toStringAsFixed(1)}m (threshold: ${distanceThreshold}m)',
|
||||
);
|
||||
|
||||
// Skip if haven't moved enough
|
||||
if (distance < distanceThreshold) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Update last position
|
||||
lastPosition = position;
|
||||
|
||||
// Save to preferences
|
||||
await prefs.setDouble(_prefKeyLastLat, position.latitude);
|
||||
await prefs.setDouble(_prefKeyLastLon, position.longitude);
|
||||
|
||||
// Update device's advertised location
|
||||
if (_bleService != null && _bleService!.isConnected) {
|
||||
try {
|
||||
debugPrint(
|
||||
'📤 [BackgroundLocation] Updating device location...',
|
||||
);
|
||||
await _bleService!.setAdvertLatLon(
|
||||
latitude: position.latitude,
|
||||
longitude: position.longitude,
|
||||
);
|
||||
|
||||
// Send advertisement to mesh network
|
||||
debugPrint(
|
||||
'📡 [BackgroundLocation] Broadcasting self advertisement...',
|
||||
);
|
||||
await _bleService!.sendSelfAdvert(floodMode: true);
|
||||
debugPrint(
|
||||
'✅ [BackgroundLocation] Location update sent successfully',
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint(
|
||||
'❌ [BackgroundLocation] Failed to send location update: $e',
|
||||
);
|
||||
}
|
||||
} else {
|
||||
debugPrint(
|
||||
'⚠️ [BackgroundLocation] BLE disconnected, cannot send update',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
debugPrint(
|
||||
'✅ [BackgroundLocation] Tracking started with ${distanceThreshold}m threshold',
|
||||
);
|
||||
return true;
|
||||
} catch (e) {
|
||||
debugPrint('❌ [BackgroundLocation] Failed to start tracking: $e');
|
||||
@@ -141,7 +160,9 @@ class BackgroundLocationService {
|
||||
Future<void> updateDistanceThreshold(double distance) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setDouble(_prefKeyDistance, distance);
|
||||
debugPrint('📏 [BackgroundLocation] Distance threshold updated to ${distance}m');
|
||||
debugPrint(
|
||||
'📏 [BackgroundLocation] Distance threshold updated to ${distance}m',
|
||||
);
|
||||
|
||||
// Restart tracking if currently enabled
|
||||
final isEnabled = prefs.getBool(_prefKeyEnabled) ?? false;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// Type of response expected from a command
|
||||
@@ -95,7 +94,8 @@ class BleCommandQueue {
|
||||
Duration? timeout,
|
||||
}) async {
|
||||
// Determine timeout based on response type
|
||||
final cmdTimeout = timeout ??
|
||||
final cmdTimeout =
|
||||
timeout ??
|
||||
(responseType == CommandResponseType.data
|
||||
? const Duration(seconds: 10)
|
||||
: const Duration(seconds: 5));
|
||||
@@ -114,7 +114,9 @@ class BleCommandQueue {
|
||||
_queue.add(command);
|
||||
onQueueSizeChanged?.call(_queue.length);
|
||||
|
||||
debugPrint('📋 [CommandQueue] Enqueued command 0x${commandCode.toRadixString(16).padLeft(2, '0')} (queue size: ${_queue.length})');
|
||||
debugPrint(
|
||||
'📋 [CommandQueue] Enqueued command 0x${commandCode.toRadixString(16).padLeft(2, '0')} (queue size: ${_queue.length})',
|
||||
);
|
||||
|
||||
// Start processing if not already running
|
||||
if (!_isProcessing) {
|
||||
@@ -125,9 +127,13 @@ class BleCommandQueue {
|
||||
return command.completer.future.timeout(
|
||||
cmdTimeout,
|
||||
onTimeout: () {
|
||||
debugPrint('⏱️ [CommandQueue] Command 0x${commandCode.toRadixString(16).padLeft(2, '0')} timed out after ${cmdTimeout.inSeconds}s');
|
||||
debugPrint(
|
||||
'⏱️ [CommandQueue] Command 0x${commandCode.toRadixString(16).padLeft(2, '0')} timed out after ${cmdTimeout.inSeconds}s',
|
||||
);
|
||||
_pendingResponses.remove(commandCode);
|
||||
throw TimeoutException('Command 0x${commandCode.toRadixString(16).padLeft(2, '0')} timed out');
|
||||
throw TimeoutException(
|
||||
'Command 0x${commandCode.toRadixString(16).padLeft(2, '0')} timed out',
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -152,7 +158,9 @@ class BleCommandQueue {
|
||||
final remainingDelay = _minDelayMs - elapsed.inMilliseconds;
|
||||
|
||||
if (remainingDelay > 0) {
|
||||
debugPrint('⏸️ [CommandQueue] Waiting ${remainingDelay}ms before next command');
|
||||
debugPrint(
|
||||
'⏸️ [CommandQueue] Waiting ${remainingDelay}ms before next command',
|
||||
);
|
||||
await Future.delayed(Duration(milliseconds: remainingDelay));
|
||||
}
|
||||
}
|
||||
@@ -170,7 +178,9 @@ class BleCommandQueue {
|
||||
|
||||
// Execute command (handled by BleCommandSender)
|
||||
// The completer will be completed by completeCommand() when response arrives
|
||||
debugPrint('📤 [CommandQueue] Executing command 0x${command.commandCode.toRadixString(16).padLeft(2, '0')}');
|
||||
debugPrint(
|
||||
'📤 [CommandQueue] Executing command 0x${command.commandCode.toRadixString(16).padLeft(2, '0')}',
|
||||
);
|
||||
|
||||
// For fire-and-forget commands, complete immediately
|
||||
if (command.responseType == CommandResponseType.none) {
|
||||
@@ -209,7 +219,9 @@ class BleCommandQueue {
|
||||
void completeCommand<T>(int responseCode, T data) {
|
||||
final command = _pendingResponses.remove(responseCode);
|
||||
if (command != null) {
|
||||
debugPrint('✅ [CommandQueue] Completing command 0x${command.commandCode.toRadixString(16).padLeft(2, '0')} with response 0x${responseCode.toRadixString(16).padLeft(2, '0')}');
|
||||
debugPrint(
|
||||
'✅ [CommandQueue] Completing command 0x${command.commandCode.toRadixString(16).padLeft(2, '0')} with response 0x${responseCode.toRadixString(16).padLeft(2, '0')}',
|
||||
);
|
||||
if (!command.completer.isCompleted) {
|
||||
command.completer.complete(data);
|
||||
}
|
||||
@@ -219,10 +231,16 @@ class BleCommandQueue {
|
||||
/// Complete a pending command with error
|
||||
///
|
||||
/// Called by BleResponseHandler when RESP_CODE_ERR is received
|
||||
void completeCommandWithError(int commandCode, String error, {int? errorCode}) {
|
||||
void completeCommandWithError(
|
||||
int commandCode,
|
||||
String error, {
|
||||
int? errorCode,
|
||||
}) {
|
||||
final command = _pendingResponses.remove(commandCode);
|
||||
if (command != null) {
|
||||
debugPrint('❌ [CommandQueue] Command 0x${commandCode.toRadixString(16).padLeft(2, '0')} failed: $error (code: $errorCode)');
|
||||
debugPrint(
|
||||
'❌ [CommandQueue] Command 0x${commandCode.toRadixString(16).padLeft(2, '0')} failed: $error (code: $errorCode)',
|
||||
);
|
||||
if (!command.completer.isCompleted) {
|
||||
command.completer.completeError(
|
||||
Exception('Command failed: $error (error code: $errorCode)'),
|
||||
@@ -245,7 +263,9 @@ class BleCommandQueue {
|
||||
|
||||
/// Clear all pending commands (use with caution)
|
||||
void clear() {
|
||||
debugPrint('🗑️ [CommandQueue] Clearing queue (${_queue.length} commands, ${_pendingResponses.length} pending responses)');
|
||||
debugPrint(
|
||||
'🗑️ [CommandQueue] Clearing queue (${_queue.length} commands, ${_pendingResponses.length} pending responses)',
|
||||
);
|
||||
|
||||
// Complete all pending commands with error
|
||||
for (final command in _pendingResponses.values) {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import 'dart:typed_data';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
|
||||
import '../meshcore_opcode_names.dart';
|
||||
@@ -130,10 +129,13 @@ class BleCommandSender {
|
||||
|
||||
debugPrint('📤 [TX] Sending command: $opcodeName ($opcodeHex)');
|
||||
debugPrint(' Data size: ${data.length} bytes');
|
||||
debugPrint(' Hex: ${data.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ')}');
|
||||
debugPrint(
|
||||
' 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 supportsWriteWithoutResponse =
|
||||
_rxCharacteristic!.properties.writeWithoutResponse;
|
||||
final supportsWrite = _rxCharacteristic!.properties.write;
|
||||
|
||||
if (supportsWriteWithoutResponse) {
|
||||
@@ -160,15 +162,21 @@ class BleCommandSender {
|
||||
}
|
||||
|
||||
/// Log a packet
|
||||
void _logPacket(Uint8List data, PacketDirection direction, {int? responseCode}) {
|
||||
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),
|
||||
));
|
||||
_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) {
|
||||
|
||||
@@ -17,24 +17,38 @@ import 'ble_command_queue.dart';
|
||||
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 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 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 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, {int? errorCode});
|
||||
typedef OnContactNotFoundCallback = void Function(Uint8List? contactPublicKey);
|
||||
typedef OnChannelInfoCallback = void Function(int channelIdx, String channelName);
|
||||
typedef OnMessageEchoDetectedCallback = void Function(String messageId, int echoCount, int snrRaw, int rssiDbm);
|
||||
typedef OnChannelInfoCallback =
|
||||
void Function(int channelIdx, String channelName);
|
||||
typedef OnMessageEchoDetectedCallback =
|
||||
void Function(String messageId, int echoCount, int snrRaw, int rssiDbm);
|
||||
|
||||
/// Processes incoming responses from the BLE device
|
||||
class BleResponseHandler {
|
||||
@@ -118,12 +132,18 @@ class BleResponseHandler {
|
||||
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()}';
|
||||
final opcodeName = MeshCoreOpcodeNames.getOpcodeName(
|
||||
responseCode,
|
||||
isTx: false,
|
||||
);
|
||||
final opcodeHex =
|
||||
'0x${responseCode.toRadixString(16).padLeft(2, '0').toUpperCase()}';
|
||||
|
||||
debugPrint('📥 [RX] Received: $opcodeName ($opcodeHex)');
|
||||
debugPrint(' Data size: ${data.length} bytes');
|
||||
debugPrint(' Hex: ${data.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ')}');
|
||||
debugPrint(
|
||||
' Hex: ${data.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ')}',
|
||||
);
|
||||
debugPrint(' Payload: ${reader.remainingBytesCount} bytes');
|
||||
|
||||
// Log RX packet (before processing so we capture everything)
|
||||
@@ -254,7 +274,9 @@ class BleResponseHandler {
|
||||
try {
|
||||
final contact = FrameParser.parseContact(reader);
|
||||
debugPrint(' ✅ [Contact] Parsed successfully: ${contact.advName}');
|
||||
debugPrint(' outPathLen: ${contact.outPathLen} (${contact.pathDescription})');
|
||||
debugPrint(
|
||||
' outPathLen: ${contact.outPathLen} (${contact.pathDescription})',
|
||||
);
|
||||
_pendingContacts.add(contact);
|
||||
onContactReceived?.call(contact);
|
||||
} catch (e) {
|
||||
@@ -416,7 +438,9 @@ class BleResponseHandler {
|
||||
/// Handle LogRxData push - includes extensive decoding logic
|
||||
void _handleLogRxData(BufferReader reader) {
|
||||
try {
|
||||
debugPrint(' [LogRxData] Parsing log rx data from over-the-air packet...');
|
||||
debugPrint(
|
||||
' [LogRxData] Parsing log rx data from over-the-air packet...',
|
||||
);
|
||||
final data = reader.readRemainingBytes();
|
||||
|
||||
if (data.length < 2) {
|
||||
@@ -445,25 +469,35 @@ class BleResponseHandler {
|
||||
final payloadType = (header >> 2) & 0x0F;
|
||||
final pathLen = rawPacketData[1];
|
||||
|
||||
debugPrint(' Packet type: 0x${payloadType.toRadixString(16).padLeft(2, '0')}');
|
||||
debugPrint(
|
||||
' Packet type: 0x${payloadType.toRadixString(16).padLeft(2, '0')}',
|
||||
);
|
||||
|
||||
if (pathLen > 0 && rawPacketData.length >= 2 + pathLen) {
|
||||
final path = rawPacketData.sublist(2, 2 + pathLen);
|
||||
final pathStr = path.map((b) => '0x${b.toRadixString(16).padLeft(2, '0')}').join(' → ');
|
||||
final pathStr = path
|
||||
.map((b) => '0x${b.toRadixString(16).padLeft(2, '0')}')
|
||||
.join(' → ');
|
||||
debugPrint(' Path ($pathLen hops): $pathStr');
|
||||
|
||||
// Highlight multi-hop packets
|
||||
if (pathLen > 1) {
|
||||
debugPrint(' 🔄 MULTI-HOP PACKET! Original sender: 0x${path[0].toRadixString(16).padLeft(2, '0')}');
|
||||
debugPrint(
|
||||
' 🔄 MULTI-HOP PACKET! Original sender: 0x${path[0].toRadixString(16).padLeft(2, '0')}',
|
||||
);
|
||||
}
|
||||
|
||||
// Check if our node hash is in the path
|
||||
if (_ourNodeHash != null && path.contains(_ourNodeHash!)) {
|
||||
debugPrint(' ✅✅✅ ECHO DETECTED! Path contains our hash (0x${_ourNodeHash!.toRadixString(16).padLeft(2, '0')}) ✅✅✅');
|
||||
debugPrint(
|
||||
' ✅✅✅ ECHO DETECTED! Path contains our hash (0x${_ourNodeHash!.toRadixString(16).padLeft(2, '0')}) ✅✅✅',
|
||||
);
|
||||
if (path[0] == _ourNodeHash) {
|
||||
debugPrint(' 👉 WE are the original sender!');
|
||||
} else {
|
||||
debugPrint(' 👉 Original sender: 0x${path[0].toRadixString(16).padLeft(2, '0')}, WE sent it to the network');
|
||||
debugPrint(
|
||||
' 👉 Original sender: 0x${path[0].toRadixString(16).padLeft(2, '0')}, WE sent it to the network',
|
||||
);
|
||||
}
|
||||
} else {
|
||||
debugPrint(' ℹ️ Does NOT contain our hash (not our message)');
|
||||
@@ -526,7 +560,11 @@ class BleResponseHandler {
|
||||
hash = hash & 0xFFFFFFFF; // Keep 32-bit
|
||||
}
|
||||
if (packet.length > 16) {
|
||||
for (int i = packet.length ~/ 2; i < packet.length ~/ 2 + 8 && i < packet.length; i++) {
|
||||
for (
|
||||
int i = packet.length ~/ 2;
|
||||
i < packet.length ~/ 2 + 8 && i < packet.length;
|
||||
i++
|
||||
) {
|
||||
hash = ((hash << 5) - hash) + packet[i];
|
||||
hash = hash & 0xFFFFFFFF;
|
||||
}
|
||||
@@ -543,7 +581,9 @@ class BleResponseHandler {
|
||||
/// Check if received packet is an echo of a sent message
|
||||
void _checkForEcho(Uint8List rawPacket, int snrRaw, int rssiDbm) {
|
||||
try {
|
||||
debugPrint(' 🔍 [Echo] _checkForEcho called, packet size: ${rawPacket.length} bytes');
|
||||
debugPrint(
|
||||
' 🔍 [Echo] _checkForEcho called, packet size: ${rawPacket.length} bytes',
|
||||
);
|
||||
|
||||
// Need at least header + path_len
|
||||
if (rawPacket.length < 2) {
|
||||
@@ -553,7 +593,9 @@ class BleResponseHandler {
|
||||
|
||||
final header = rawPacket[0];
|
||||
final payloadType = (header >> 2) & 0x0F;
|
||||
debugPrint(' 🔍 [Echo] Payload type: 0x${payloadType.toRadixString(16).padLeft(2, '0')}');
|
||||
debugPrint(
|
||||
' 🔍 [Echo] Payload type: 0x${payloadType.toRadixString(16).padLeft(2, '0')}',
|
||||
);
|
||||
if (payloadType != 0x05) {
|
||||
debugPrint(' ⚠️ [Echo] Not GRP_TXT, ignoring');
|
||||
return; // Only track GRP_TXT
|
||||
@@ -568,10 +610,13 @@ class BleResponseHandler {
|
||||
|
||||
// Extract path for unique echo tracking
|
||||
final path = rawPacket.sublist(2, 2 + pathLen);
|
||||
final pathSignature = path.map((b) => b.toRadixString(16).padLeft(2, '0')).join(':');
|
||||
final pathSignature = path
|
||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||
.join(':');
|
||||
|
||||
// Check if our node hash is in the path (meaning this is our message being rebroadcast)
|
||||
final containsOurHash = _ourNodeHash != null && path.contains(_ourNodeHash!);
|
||||
final containsOurHash =
|
||||
_ourNodeHash != null && path.contains(_ourNodeHash!);
|
||||
if (!containsOurHash) {
|
||||
// This packet doesn't have our hash in the path, so it's not our message
|
||||
return;
|
||||
@@ -599,9 +644,16 @@ class BleResponseHandler {
|
||||
debugPrint(' Unique paths: ${tracker.uniqueEchoPaths.length}');
|
||||
|
||||
// Notify callback
|
||||
onMessageEchoDetected?.call(tracker.messageId, tracker.echoCount, snrRaw, rssiDbm);
|
||||
onMessageEchoDetected?.call(
|
||||
tracker.messageId,
|
||||
tracker.echoCount,
|
||||
snrRaw,
|
||||
rssiDbm,
|
||||
);
|
||||
} else {
|
||||
debugPrint(' ♻️ [Echo] Duplicate path (already counted): $pathSignature');
|
||||
debugPrint(
|
||||
' ♻️ [Echo] Duplicate path (already counted): $pathSignature',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -631,7 +683,9 @@ class BleResponseHandler {
|
||||
|
||||
// Store by message ID temporarily
|
||||
_sentMessageTrackers[messageId] = tracker;
|
||||
debugPrint(' 📤 [Echo] Tracking message $messageId (will match any GRP_TXT within 10000ms)');
|
||||
debugPrint(
|
||||
' 📤 [Echo] Tracking message $messageId (will match any GRP_TXT within 10000ms)',
|
||||
);
|
||||
debugPrint(' 📊 [Echo] Total trackers: ${_sentMessageTrackers.length}');
|
||||
|
||||
// Cleanup if too many trackers
|
||||
@@ -649,8 +703,12 @@ class BleResponseHandler {
|
||||
/// Set our node hash for packet identification
|
||||
void setOurNodeHash(int nodeHash) {
|
||||
_ourNodeHash = nodeHash;
|
||||
debugPrint(' 🔑 [Echo] Our node hash set to: 0x${nodeHash.toRadixString(16).padLeft(2, '0')}');
|
||||
debugPrint(' ℹ️ [Echo] Will track packets containing our hash in the path');
|
||||
debugPrint(
|
||||
' 🔑 [Echo] Our node hash set to: 0x${nodeHash.toRadixString(16).padLeft(2, '0')}',
|
||||
);
|
||||
debugPrint(
|
||||
' ℹ️ [Echo] Will track packets containing our hash in the path',
|
||||
);
|
||||
}
|
||||
|
||||
/// Associate a captured packet with a sent message
|
||||
@@ -666,7 +724,9 @@ class BleResponseHandler {
|
||||
/// [3+] = rest of path + encrypted payload
|
||||
void _associatePacketWithSentMessage(Uint8List rawPacket) {
|
||||
try {
|
||||
debugPrint(' 🔍 [Echo] _associatePacketWithSentMessage called, packet size: ${rawPacket.length}');
|
||||
debugPrint(
|
||||
' 🔍 [Echo] _associatePacketWithSentMessage called, packet size: ${rawPacket.length}',
|
||||
);
|
||||
|
||||
// Need at least 3 bytes: header + path_len + first path byte
|
||||
if (rawPacket.length < 3) {
|
||||
@@ -677,8 +737,11 @@ class BleResponseHandler {
|
||||
// Check if this is a GRP_TXT packet (payload type = 0x05)
|
||||
final header = rawPacket[0];
|
||||
final payloadType = (header >> 2) & 0x0F;
|
||||
debugPrint(' 🔍 [Echo] Association check - Payload type: 0x${payloadType.toRadixString(16).padLeft(2, '0')}');
|
||||
if (payloadType != 0x05) { // Not a group message
|
||||
debugPrint(
|
||||
' 🔍 [Echo] Association check - Payload type: 0x${payloadType.toRadixString(16).padLeft(2, '0')}',
|
||||
);
|
||||
if (payloadType != 0x05) {
|
||||
// Not a group message
|
||||
debugPrint(' ⚠️ [Echo] Not GRP_TXT, skipping association');
|
||||
return;
|
||||
}
|
||||
@@ -694,16 +757,21 @@ class BleResponseHandler {
|
||||
|
||||
// Extract the path from the packet for unique echo tracking
|
||||
final path = rawPacket.sublist(2, 2 + pathLen);
|
||||
final pathSignature = path.map((b) => b.toRadixString(16).padLeft(2, '0')).join(':');
|
||||
final pathSignature = path
|
||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||
.join(':');
|
||||
|
||||
// Check if our node hash is in the path (meaning this is our message being rebroadcast)
|
||||
final containsOurHash = _ourNodeHash != null && path.contains(_ourNodeHash!);
|
||||
final containsOurHash =
|
||||
_ourNodeHash != null && path.contains(_ourNodeHash!);
|
||||
if (!containsOurHash) {
|
||||
// This packet doesn't have our hash in the path, so it's not our message
|
||||
return;
|
||||
}
|
||||
|
||||
debugPrint(' ✅ [Echo] Packet contains our hash (0x${_ourNodeHash!.toRadixString(16).padLeft(2, '0')}) in path: $pathSignature');
|
||||
debugPrint(
|
||||
' ✅ [Echo] Packet contains our hash (0x${_ourNodeHash!.toRadixString(16).padLeft(2, '0')}) in path: $pathSignature',
|
||||
);
|
||||
|
||||
// Extract encrypted payload (everything after path)
|
||||
final payloadStart = 2 + pathLen;
|
||||
@@ -754,13 +822,17 @@ class BleResponseHandler {
|
||||
|
||||
/// Remove expired trackers
|
||||
void _cleanupExpiredTrackers() {
|
||||
final expiredCount = _sentMessageTrackers.values.where((t) => t.isExpired).length;
|
||||
final expiredCount = _sentMessageTrackers.values
|
||||
.where((t) => t.isExpired)
|
||||
.length;
|
||||
if (expiredCount > 0) {
|
||||
debugPrint(' 🧹 [Echo] Cleaning up $expiredCount expired tracker(s)');
|
||||
}
|
||||
_sentMessageTrackers.removeWhere((key, tracker) {
|
||||
if (tracker.isExpired && tracker.packetHashHex == 'pending') {
|
||||
debugPrint(' ⏱️ [Echo] Tracker expired without capturing: ${tracker.messageId}');
|
||||
debugPrint(
|
||||
' ⏱️ [Echo] Tracker expired without capturing: ${tracker.messageId}',
|
||||
);
|
||||
}
|
||||
return tracker.isExpired;
|
||||
});
|
||||
@@ -774,7 +846,9 @@ class BleResponseHandler {
|
||||
final sortedEntries = _sentMessageTrackers.entries.toList()
|
||||
..sort((a, b) => a.value.sentTime.compareTo(b.value.sentTime));
|
||||
|
||||
final toRemove = sortedEntries.take(_sentMessageTrackers.length - _maxTrackers);
|
||||
final toRemove = sortedEntries.take(
|
||||
_sentMessageTrackers.length - _maxTrackers,
|
||||
);
|
||||
for (final entry in toRemove) {
|
||||
_sentMessageTrackers.remove(entry.key);
|
||||
}
|
||||
@@ -787,7 +861,9 @@ class BleResponseHandler {
|
||||
try {
|
||||
final contact = FrameParser.parseContact(reader);
|
||||
debugPrint(' ✅ [NewAdvert] Parsed successfully: ${contact.advName}');
|
||||
debugPrint(' outPathLen: ${contact.outPathLen} (${contact.pathDescription})');
|
||||
debugPrint(
|
||||
' outPathLen: ${contact.outPathLen} (${contact.pathDescription})',
|
||||
);
|
||||
onContactReceived?.call(contact);
|
||||
} catch (e) {
|
||||
debugPrint(' ❌ [NewAdvert] Parsing error: $e');
|
||||
@@ -937,7 +1013,7 @@ class BleResponseHandler {
|
||||
final channelIdx = info['channelIdx'] as int;
|
||||
final channelName = info['channelName'] as String;
|
||||
|
||||
debugPrint(' ✅ [ChannelInfo] Channel $channelIdx: "${channelName}"');
|
||||
debugPrint(' ✅ [ChannelInfo] Channel $channelIdx: "$channelName"');
|
||||
onChannelInfoReceived?.call(channelIdx, channelName);
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -956,14 +1032,17 @@ class BleResponseHandler {
|
||||
|
||||
// Complete any pending ACK command with error
|
||||
_commandQueue?.completeCommandWithError(
|
||||
MeshCoreConstants.respOk, // Command was expecting OK, got ERR
|
||||
MeshCoreConstants.respOk, // Command was expecting OK, got ERR
|
||||
errorMsg,
|
||||
errorCode: errorCode,
|
||||
);
|
||||
|
||||
// Special handling for ERR_CODE_NOT_FOUND (2) - contact not in radio
|
||||
if (errorCode == 2) { // ERR_CODE_NOT_FOUND
|
||||
debugPrint(' ⚠️ [Error] Contact not found in radio - attempting auto-recovery');
|
||||
if (errorCode == 2) {
|
||||
// ERR_CODE_NOT_FOUND
|
||||
debugPrint(
|
||||
' ⚠️ [Error] Contact not found in radio - attempting auto-recovery',
|
||||
);
|
||||
onContactNotFound?.call(_lastContactPublicKey);
|
||||
}
|
||||
|
||||
@@ -980,14 +1059,20 @@ class BleResponseHandler {
|
||||
}
|
||||
|
||||
/// 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),
|
||||
));
|
||||
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);
|
||||
|
||||
@@ -12,7 +12,9 @@ class CayenneLppParser {
|
||||
static ContactTelemetry parse(Uint8List data) {
|
||||
debugPrint(' [CayenneLPP] Parsing LPP data...');
|
||||
debugPrint(' Data length: ${data.length} bytes');
|
||||
debugPrint(' Data (hex): ${data.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ')}');
|
||||
debugPrint(
|
||||
' Data (hex): ${data.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ')}',
|
||||
);
|
||||
|
||||
final reader = BufferReader(data);
|
||||
|
||||
@@ -28,13 +30,17 @@ class CayenneLppParser {
|
||||
while (reader.hasRemaining) {
|
||||
try {
|
||||
fieldCount++;
|
||||
debugPrint(' [Field $fieldCount] Position: ${data.length - reader.remainingBytesCount}');
|
||||
debugPrint(
|
||||
' [Field $fieldCount] Position: ${data.length - reader.remainingBytesCount}',
|
||||
);
|
||||
|
||||
final channel = reader.readByte();
|
||||
debugPrint(' Channel: $channel');
|
||||
|
||||
final type = reader.readByte();
|
||||
debugPrint(' Type: $type (0x${type.toRadixString(16).padLeft(2, '0')})');
|
||||
debugPrint(
|
||||
' Type: $type (0x${type.toRadixString(16).padLeft(2, '0')})',
|
||||
);
|
||||
|
||||
switch (type) {
|
||||
case MeshCoreConstants.lppDigitalInput:
|
||||
@@ -59,7 +65,9 @@ class CayenneLppParser {
|
||||
if (channel == 0 || channel == 1) {
|
||||
batteryMilliVolts = value * 1000;
|
||||
batteryPercentage = _calculateBatteryPercentage(value);
|
||||
debugPrint(' → Battery: ${batteryPercentage?.toStringAsFixed(1)}% (${batteryMilliVolts?.toStringAsFixed(0)}mV)');
|
||||
debugPrint(
|
||||
' → Battery: ${batteryPercentage.toStringAsFixed(1)}% (${batteryMilliVolts.toStringAsFixed(0)}mV)',
|
||||
);
|
||||
}
|
||||
break;
|
||||
|
||||
@@ -87,14 +95,16 @@ class CayenneLppParser {
|
||||
final rawValue = reader.readInt16BE();
|
||||
temperature = rawValue / 10.0;
|
||||
debugPrint(' Temperature (raw): $rawValue');
|
||||
debugPrint(' Temperature: ${temperature?.toStringAsFixed(1)}°C');
|
||||
debugPrint(
|
||||
' Temperature: ${temperature.toStringAsFixed(1)}°C',
|
||||
);
|
||||
break;
|
||||
|
||||
case MeshCoreConstants.lppHumiditySensor:
|
||||
final rawValue = reader.readByte();
|
||||
humidity = rawValue / 2.0;
|
||||
debugPrint(' Humidity (raw): $rawValue');
|
||||
debugPrint(' Humidity: ${humidity?.toStringAsFixed(1)}%');
|
||||
debugPrint(' Humidity: ${humidity.toStringAsFixed(1)}%');
|
||||
break;
|
||||
|
||||
case MeshCoreConstants.lppAccelerometer:
|
||||
@@ -102,14 +112,18 @@ class CayenneLppParser {
|
||||
final y = reader.readInt16BE() / 1000.0;
|
||||
final z = reader.readInt16BE() / 1000.0;
|
||||
debugPrint(' Accelerometer: x=$x, y=$y, z=$z');
|
||||
extraSensorData['accelerometer_$channel'] = {'x': x, 'y': y, 'z': z};
|
||||
extraSensorData['accelerometer_$channel'] = {
|
||||
'x': x,
|
||||
'y': y,
|
||||
'z': z,
|
||||
};
|
||||
break;
|
||||
|
||||
case MeshCoreConstants.lppBarometer:
|
||||
final rawValue = reader.readUInt16BE();
|
||||
pressure = rawValue / 10.0;
|
||||
debugPrint(' Barometer (raw): $rawValue');
|
||||
debugPrint(' Barometer: ${pressure?.toStringAsFixed(1)} hPa');
|
||||
debugPrint(' Barometer: ${pressure.toStringAsFixed(1)} hPa');
|
||||
break;
|
||||
|
||||
case MeshCoreConstants.lppVoltageSensor:
|
||||
@@ -120,7 +134,9 @@ class CayenneLppParser {
|
||||
// Treat voltage sensor as battery reading
|
||||
batteryMilliVolts = value * 1000;
|
||||
batteryPercentage = _calculateBatteryPercentage(value);
|
||||
debugPrint(' → Battery: ${batteryPercentage?.toStringAsFixed(1)}% (${batteryMilliVolts?.toStringAsFixed(0)}mV)');
|
||||
debugPrint(
|
||||
' → Battery: ${batteryPercentage.toStringAsFixed(1)}% (${batteryMilliVolts.toStringAsFixed(0)}mV)',
|
||||
);
|
||||
break;
|
||||
|
||||
case MeshCoreConstants.lppGyrometer:
|
||||
@@ -138,14 +154,18 @@ class CayenneLppParser {
|
||||
final lat = rawLat / 1000000.0;
|
||||
final lon = rawLon / 1000000.0;
|
||||
final alt = rawAlt / 100.0;
|
||||
debugPrint(' GPS Location (raw): lat=$rawLat, lon=$rawLon, alt=$rawAlt');
|
||||
debugPrint(' GPS Location: ${lat}°, ${lon}°, altitude=${alt}m');
|
||||
debugPrint(
|
||||
' GPS Location (raw): lat=$rawLat, lon=$rawLon, alt=$rawAlt',
|
||||
);
|
||||
debugPrint(' GPS Location: $lat°, $lon°, altitude=${alt}m');
|
||||
gpsLocation = LatLng(lat, lon);
|
||||
extraSensorData['altitude_$channel'] = alt;
|
||||
break;
|
||||
|
||||
default:
|
||||
debugPrint(' ⚠️ Unknown type, skipping remaining ${reader.remainingBytesCount} bytes');
|
||||
debugPrint(
|
||||
' ⚠️ Unknown type, skipping remaining ${reader.remainingBytesCount} bytes',
|
||||
);
|
||||
// Unknown type, skip remaining to avoid parsing errors
|
||||
reader.skip(reader.remainingBytesCount);
|
||||
break;
|
||||
@@ -159,9 +179,15 @@ class CayenneLppParser {
|
||||
|
||||
debugPrint(' Parsed $fieldCount fields');
|
||||
debugPrint(' ✅ [CayenneLPP] Parsing complete');
|
||||
debugPrint(' GPS: ${gpsLocation != null ? '${gpsLocation.latitude}°, ${gpsLocation.longitude}°' : 'none'}');
|
||||
debugPrint(' Battery: ${batteryPercentage != null ? '${batteryPercentage.toStringAsFixed(1)}%' : 'none'}');
|
||||
debugPrint(' Temperature: ${temperature != null ? '${temperature.toStringAsFixed(1)}°C' : 'none'}');
|
||||
debugPrint(
|
||||
' GPS: ${gpsLocation != null ? '${gpsLocation.latitude}°, ${gpsLocation.longitude}°' : 'none'}',
|
||||
);
|
||||
debugPrint(
|
||||
' Battery: ${batteryPercentage != null ? '${batteryPercentage.toStringAsFixed(1)}%' : 'none'}',
|
||||
);
|
||||
debugPrint(
|
||||
' Temperature: ${temperature != null ? '${temperature.toStringAsFixed(1)}°C' : 'none'}',
|
||||
);
|
||||
|
||||
// IMPORTANT: Cayenne LPP format does NOT include a timestamp field.
|
||||
// We use DateTime.now() as the timestamp, which represents when the data
|
||||
@@ -173,7 +199,9 @@ class CayenneLppParser {
|
||||
// - The actual age of the telemetry data cannot be determined from the LPP format
|
||||
// - Devices may cache telemetry for hours and send it later when requested
|
||||
final parseTimestamp = DateTime.now();
|
||||
debugPrint(' Timestamp: $parseTimestamp (parse time, NOT device collection time)');
|
||||
debugPrint(
|
||||
' Timestamp: $parseTimestamp (parse time, NOT device collection time)',
|
||||
);
|
||||
|
||||
return ContactTelemetry(
|
||||
gpsLocation: gpsLocation,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../models/contact.dart';
|
||||
@@ -17,7 +16,9 @@ class ContactStorageService {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
|
||||
// Convert contacts to JSON
|
||||
final jsonList = contacts.map((contact) => _contactToJson(contact)).toList();
|
||||
final jsonList = contacts
|
||||
.map((contact) => _contactToJson(contact))
|
||||
.toList();
|
||||
|
||||
// Limit to max stored contacts (keep most recent)
|
||||
final limitedList = jsonList.length > _maxStoredContacts
|
||||
@@ -27,7 +28,9 @@ class ContactStorageService {
|
||||
final jsonString = jsonEncode(limitedList);
|
||||
await prefs.setString(_contactsKey, jsonString);
|
||||
|
||||
debugPrint('✅ [ContactStorage] Saved ${limitedList.length} contacts to storage');
|
||||
debugPrint(
|
||||
'✅ [ContactStorage] Saved ${limitedList.length} contacts to storage',
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('❌ [ContactStorage] Error saving contacts: $e');
|
||||
}
|
||||
@@ -55,16 +58,23 @@ class ContactStorageService {
|
||||
// Filter out contacts with the excluded public key
|
||||
final filteredContacts = excludePublicKey != null
|
||||
? contacts.where((contact) {
|
||||
final matches = _publicKeysMatch(contact.publicKey, excludePublicKey);
|
||||
final matches = _publicKeysMatch(
|
||||
contact.publicKey,
|
||||
excludePublicKey,
|
||||
);
|
||||
if (matches) {
|
||||
debugPrint('ℹ️ [ContactStorage] Excluding contact with matching public key: ${contact.advName}');
|
||||
debugPrint(
|
||||
'ℹ️ [ContactStorage] Excluding contact with matching public key: ${contact.advName}',
|
||||
);
|
||||
}
|
||||
return !matches;
|
||||
}).toList()
|
||||
: contacts;
|
||||
|
||||
debugPrint('✅ [ContactStorage] Loaded ${filteredContacts.length} contacts from storage'
|
||||
'${excludePublicKey != null ? ' (${contacts.length - filteredContacts.length} excluded)' : ''}');
|
||||
debugPrint(
|
||||
'✅ [ContactStorage] Loaded ${filteredContacts.length} contacts from storage'
|
||||
'${excludePublicKey != null ? ' (${contacts.length - filteredContacts.length} excluded)' : ''}',
|
||||
);
|
||||
return filteredContacts;
|
||||
} catch (e) {
|
||||
debugPrint('❌ [ContactStorage] Error loading contacts: $e');
|
||||
@@ -99,11 +109,7 @@ class ContactStorageService {
|
||||
final jsonString = prefs.getString(_contactsKey);
|
||||
|
||||
if (jsonString == null || jsonString.isEmpty) {
|
||||
return {
|
||||
'contactCount': 0,
|
||||
'storageSizeBytes': 0,
|
||||
'storageSizeKB': 0,
|
||||
};
|
||||
return {'contactCount': 0, 'storageSizeBytes': 0, 'storageSizeKB': 0};
|
||||
}
|
||||
|
||||
final sizeBytes = jsonString.length;
|
||||
@@ -116,11 +122,7 @@ class ContactStorageService {
|
||||
};
|
||||
} catch (e) {
|
||||
debugPrint('❌ [ContactStorage] Error getting storage stats: $e');
|
||||
return {
|
||||
'contactCount': 0,
|
||||
'storageSizeBytes': 0,
|
||||
'storageSizeKB': 0,
|
||||
};
|
||||
return {'contactCount': 0, 'storageSizeBytes': 0, 'storageSizeKB': 0};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,7 +139,9 @@ class ContactStorageService {
|
||||
'advLat': contact.advLat,
|
||||
'advLon': contact.advLon,
|
||||
'lastMod': contact.lastMod,
|
||||
'telemetry': contact.telemetry != null ? _telemetryToJson(contact.telemetry!) : null,
|
||||
'telemetry': contact.telemetry != null
|
||||
? _telemetryToJson(contact.telemetry!)
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -145,7 +149,9 @@ class ContactStorageService {
|
||||
Contact? _contactFromJson(Map<String, dynamic> json) {
|
||||
try {
|
||||
return Contact(
|
||||
publicKey: Uint8List.fromList(base64Decode(json['publicKey'] as String)),
|
||||
publicKey: Uint8List.fromList(
|
||||
base64Decode(json['publicKey'] as String),
|
||||
),
|
||||
type: ContactType.fromValue(json['type'] as int),
|
||||
flags: json['flags'] as int,
|
||||
outPathLen: json['outPathLen'] as int,
|
||||
@@ -200,7 +206,8 @@ class ContactStorageService {
|
||||
humidity: json['humidity'] as double?,
|
||||
pressure: json['pressure'] as double?,
|
||||
timestamp: DateTime.fromMillisecondsSinceEpoch(
|
||||
json['timestampMillis'] as int),
|
||||
json['timestampMillis'] as int,
|
||||
),
|
||||
extraSensorData: json['extraSensorData'] as Map<String, dynamic>?,
|
||||
);
|
||||
} catch (e) {
|
||||
|
||||
@@ -129,7 +129,7 @@ class MbtilesService {
|
||||
final mbtiles = MbTiles(mbtilesPath: file.path);
|
||||
|
||||
// Get metadata from MBTiles
|
||||
final metadata = await mbtiles.getMetadata();
|
||||
final metadata = mbtiles.getMetadata();
|
||||
|
||||
// Convert bounds object to string if available
|
||||
String? boundsStr;
|
||||
@@ -222,7 +222,7 @@ class MbtilesService {
|
||||
final mbtiles = MbTiles(mbtilesPath: file.path);
|
||||
|
||||
// Try to get metadata to check for compression hints
|
||||
final metadata = await mbtiles.getMetadata();
|
||||
final metadata = mbtiles.getMetadata();
|
||||
final format = metadata.format;
|
||||
|
||||
// For Geofabrik files, format is 'pbf' and data is gzipped
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
|
||||
import '../models/contact.dart';
|
||||
@@ -15,26 +14,41 @@ import 'meshcore_constants.dart';
|
||||
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 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 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 OnMessageEchoDetectedCallback = void Function(String messageId, int echoCount, int snrRaw, int rssiDbm);
|
||||
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 OnMessageSentCallback =
|
||||
void Function(int expectedAckTag, int suggestedTimeoutMs, bool isFloodMode);
|
||||
typedef OnMessageDeliveredCallback =
|
||||
void Function(int ackCode, int roundTripTimeMs);
|
||||
typedef OnMessageEchoDetectedCallback =
|
||||
void Function(String messageId, int echoCount, int snrRaw, int rssiDbm);
|
||||
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, {int? errorCode});
|
||||
typedef OnContactNotFoundCallback = void Function(Uint8List? contactPublicKey);
|
||||
typedef OnChannelInfoCallback = void Function(int channelIdx, String channelName);
|
||||
typedef OnChannelInfoCallback =
|
||||
void Function(int channelIdx, String channelName);
|
||||
typedef OnConnectionStateCallback = void Function(bool isConnected);
|
||||
typedef OnReconnectionAttemptCallback = void Function(int attemptNumber, int maxAttempts);
|
||||
typedef OnReconnectionAttemptCallback =
|
||||
void Function(int attemptNumber, int maxAttempts);
|
||||
typedef OnRssiUpdateCallback = void Function(int rssi);
|
||||
|
||||
/// MeshCore BLE Service - coordinates BLE communication components
|
||||
@@ -89,7 +103,9 @@ class MeshCoreBleService {
|
||||
onError?.call(error);
|
||||
};
|
||||
_connectionManager.onReconnectionAttempt = (attemptNumber, maxAttempts) {
|
||||
debugPrint('🔄 [Service] Reconnection attempt $attemptNumber/$maxAttempts');
|
||||
debugPrint(
|
||||
'🔄 [Service] Reconnection attempt $attemptNumber/$maxAttempts',
|
||||
);
|
||||
onReconnectionAttempt?.call(attemptNumber, maxAttempts);
|
||||
};
|
||||
_connectionManager.onRssiUpdate = (rssi) {
|
||||
@@ -136,9 +152,10 @@ class MeshCoreBleService {
|
||||
_responseHandler.onMessageWaiting = () {
|
||||
onMessageWaiting?.call();
|
||||
};
|
||||
_responseHandler.onLoginSuccess = (publicKeyPrefix, permissions, isAdmin, tag) {
|
||||
onLoginSuccess?.call(publicKeyPrefix, permissions, isAdmin, tag);
|
||||
};
|
||||
_responseHandler.onLoginSuccess =
|
||||
(publicKeyPrefix, permissions, isAdmin, tag) {
|
||||
onLoginSuccess?.call(publicKeyPrefix, permissions, isAdmin, tag);
|
||||
};
|
||||
_responseHandler.onLoginFail = (publicKeyPrefix) {
|
||||
onLoginFail?.call(publicKeyPrefix);
|
||||
};
|
||||
@@ -148,15 +165,17 @@ class MeshCoreBleService {
|
||||
_responseHandler.onPathUpdated = (publicKey) {
|
||||
onPathUpdated?.call(publicKey);
|
||||
};
|
||||
_responseHandler.onMessageSent = (expectedAckTag, suggestedTimeoutMs, isFloodMode) {
|
||||
onMessageSent?.call(expectedAckTag, suggestedTimeoutMs, isFloodMode);
|
||||
};
|
||||
_responseHandler.onMessageSent =
|
||||
(expectedAckTag, suggestedTimeoutMs, isFloodMode) {
|
||||
onMessageSent?.call(expectedAckTag, suggestedTimeoutMs, isFloodMode);
|
||||
};
|
||||
_responseHandler.onMessageDelivered = (ackCode, roundTripTimeMs) {
|
||||
onMessageDelivered?.call(ackCode, roundTripTimeMs);
|
||||
};
|
||||
_responseHandler.onMessageEchoDetected = (messageId, echoCount, snrRaw, rssiDbm) {
|
||||
onMessageEchoDetected?.call(messageId, echoCount, snrRaw, rssiDbm);
|
||||
};
|
||||
_responseHandler.onMessageEchoDetected =
|
||||
(messageId, echoCount, snrRaw, rssiDbm) {
|
||||
onMessageEchoDetected?.call(messageId, echoCount, snrRaw, rssiDbm);
|
||||
};
|
||||
_responseHandler.onStatusResponse = (publicKeyPrefix, statusData) {
|
||||
onStatusResponse?.call(publicKeyPrefix, statusData);
|
||||
};
|
||||
@@ -189,13 +208,18 @@ class MeshCoreBleService {
|
||||
int get txPacketCount => _commandSender.txPacketCount;
|
||||
List<BlePacketLog> get packetLogs {
|
||||
// Merge logs from both sender and handler
|
||||
final allLogs = [..._commandSender.packetLogs, ..._responseHandler.packetLogs];
|
||||
final allLogs = [
|
||||
..._commandSender.packetLogs,
|
||||
..._responseHandler.packetLogs,
|
||||
];
|
||||
allLogs.sort((a, b) => a.timestamp.compareTo(b.timestamp));
|
||||
return allLogs;
|
||||
}
|
||||
|
||||
/// Scan for MeshCore devices
|
||||
Stream<ScanResult> scanForDevices({Duration timeout = const Duration(seconds: 10)}) {
|
||||
Stream<ScanResult> scanForDevices({
|
||||
Duration timeout = const Duration(seconds: 10),
|
||||
}) {
|
||||
return _connectionManager.scanForDevices(timeout: timeout);
|
||||
}
|
||||
|
||||
@@ -212,7 +236,9 @@ class MeshCoreBleService {
|
||||
|
||||
// Setup response handler with TX characteristic
|
||||
if (_connectionManager.txCharacteristic != null) {
|
||||
_responseHandler.subscribeToNotifications(_connectionManager.txCharacteristic!);
|
||||
_responseHandler.subscribeToNotifications(
|
||||
_connectionManager.txCharacteristic!,
|
||||
);
|
||||
}
|
||||
|
||||
// Send initial device query and wait for responses
|
||||
@@ -240,20 +266,26 @@ class MeshCoreBleService {
|
||||
Future<void> _sendDeviceQuery() async {
|
||||
// STEP 1: Send device query FIRST to get device capabilities
|
||||
// This is the first command to send per protocol documentation
|
||||
debugPrint('🔍 [Service] Querying device information (CMD_DEVICE_QUERY)...');
|
||||
final deviceInfo = await _commandSender.writeDataAndWaitForResponse<Map<String, dynamic>>(
|
||||
FrameBuilder.buildDeviceQuery(),
|
||||
MeshCoreConstants.respDeviceInfo,
|
||||
debugPrint(
|
||||
'🔍 [Service] Querying device information (CMD_DEVICE_QUERY)...',
|
||||
);
|
||||
final deviceInfo = await _commandSender
|
||||
.writeDataAndWaitForResponse<Map<String, dynamic>>(
|
||||
FrameBuilder.buildDeviceQuery(),
|
||||
MeshCoreConstants.respDeviceInfo,
|
||||
);
|
||||
debugPrint(
|
||||
'✅ [Service] Device info received: firmware=${deviceInfo['firmwareVersion']}',
|
||||
);
|
||||
debugPrint('✅ [Service] Device info received: firmware=${deviceInfo['firmwareVersion']}');
|
||||
|
||||
// STEP 2: Send app start to initialize the app session
|
||||
// This is the first command after connection per protocol documentation
|
||||
debugPrint('🚀 [Service] Sending app start (CMD_APP_START)...');
|
||||
final selfInfo = await _commandSender.writeDataAndWaitForResponse<Map<String, dynamic>>(
|
||||
FrameBuilder.buildAppStart(),
|
||||
MeshCoreConstants.respSelfInfo,
|
||||
);
|
||||
final selfInfo = await _commandSender
|
||||
.writeDataAndWaitForResponse<Map<String, dynamic>>(
|
||||
FrameBuilder.buildAppStart(),
|
||||
MeshCoreConstants.respSelfInfo,
|
||||
);
|
||||
debugPrint('✅ [Service] Self info received: node initialized');
|
||||
|
||||
// STEP 3: Set device clock AFTER initialization
|
||||
@@ -278,7 +310,9 @@ class MeshCoreBleService {
|
||||
Future<void> addOrUpdateContact(Contact contact) async {
|
||||
debugPrint('📝 [BLE] Adding/updating contact on companion radio:');
|
||||
debugPrint(' Name: ${contact.advName}');
|
||||
debugPrint(' Public key prefix: ${contact.publicKey.sublist(0, 6).map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}');
|
||||
debugPrint(
|
||||
' Public key prefix: ${contact.publicKey.sublist(0, 6).map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}',
|
||||
);
|
||||
debugPrint(' Type: ${contact.type} (${contact.type.value})');
|
||||
|
||||
await _commandSender.writeData(FrameBuilder.buildAddUpdateContact(contact));
|
||||
@@ -300,18 +334,22 @@ class MeshCoreBleService {
|
||||
// Track the last contact for auto-recovery if contact not found
|
||||
_responseHandler.setLastContactPublicKey(contactPublicKey);
|
||||
|
||||
await _commandSender.writeData(FrameBuilder.buildSendTxtMsg(
|
||||
contactPublicKey: contactPublicKey,
|
||||
text: text,
|
||||
textType: textType,
|
||||
attempt: attempt,
|
||||
));
|
||||
await _commandSender.writeData(
|
||||
FrameBuilder.buildSendTxtMsg(
|
||||
contactPublicKey: contactPublicKey,
|
||||
text: text,
|
||||
textType: textType,
|
||||
attempt: attempt,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Send flood-mode text message to channel
|
||||
/// Track a sent channel message for echo detection
|
||||
void trackSentChannelMessage(String messageId) {
|
||||
debugPrint('🔵 [MeshCoreBleService] trackSentChannelMessage called for: $messageId');
|
||||
debugPrint(
|
||||
'🔵 [MeshCoreBleService] trackSentChannelMessage called for: $messageId',
|
||||
);
|
||||
_responseHandler.trackSentMessage(messageId, null);
|
||||
}
|
||||
|
||||
@@ -333,20 +371,24 @@ class MeshCoreBleService {
|
||||
|
||||
// Channel messages use fire-and-forget (no ACK expected)
|
||||
// The firmware responds with RESP_CODE_OK but we don't wait for it
|
||||
await _commandSender.writeData(FrameBuilder.buildSendChannelTxtMsg(
|
||||
channelIdx: channelIdx,
|
||||
text: text,
|
||||
textType: textType,
|
||||
));
|
||||
await _commandSender.writeData(
|
||||
FrameBuilder.buildSendChannelTxtMsg(
|
||||
channelIdx: channelIdx,
|
||||
text: text,
|
||||
textType: textType,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Request telemetry from contact (deprecated)
|
||||
@Deprecated('Use sendBinaryRequest() instead for better functionality')
|
||||
Future<void> requestTelemetry(Uint8List contactPublicKey, {bool zeroHop = false}) async {
|
||||
await _commandSender.writeData(FrameBuilder.buildSendTelemetryReq(
|
||||
contactPublicKey,
|
||||
zeroHop: zeroHop,
|
||||
));
|
||||
Future<void> requestTelemetry(
|
||||
Uint8List contactPublicKey, {
|
||||
bool zeroHop = false,
|
||||
}) async {
|
||||
await _commandSender.writeData(
|
||||
FrameBuilder.buildSendTelemetryReq(contactPublicKey, zeroHop: zeroHop),
|
||||
);
|
||||
}
|
||||
|
||||
/// Send binary request to contact
|
||||
@@ -354,10 +396,12 @@ class MeshCoreBleService {
|
||||
required Uint8List contactPublicKey,
|
||||
required Uint8List requestData,
|
||||
}) async {
|
||||
await _commandSender.writeData(FrameBuilder.buildSendBinaryReq(
|
||||
contactPublicKey: contactPublicKey,
|
||||
requestData: requestData,
|
||||
));
|
||||
await _commandSender.writeData(
|
||||
FrameBuilder.buildSendBinaryReq(
|
||||
contactPublicKey: contactPublicKey,
|
||||
requestData: requestData,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Get battery voltage and storage information
|
||||
@@ -388,12 +432,16 @@ class MeshCoreBleService {
|
||||
|
||||
/// Send self advertisement packet to mesh network
|
||||
Future<void> sendSelfAdvert({bool floodMode = true}) async {
|
||||
await _commandSender.writeData(FrameBuilder.buildSendSelfAdvert(floodMode: floodMode));
|
||||
await _commandSender.writeData(
|
||||
FrameBuilder.buildSendSelfAdvert(floodMode: floodMode),
|
||||
);
|
||||
}
|
||||
|
||||
/// Set advertised name
|
||||
Future<void> setAdvertName(String name) async {
|
||||
await _commandSender.writeDataAndWaitForAck(FrameBuilder.buildSetAdvertName(name));
|
||||
await _commandSender.writeDataAndWaitForAck(
|
||||
FrameBuilder.buildSetAdvertName(name),
|
||||
);
|
||||
}
|
||||
|
||||
/// Set advertised latitude and longitude
|
||||
@@ -402,10 +450,12 @@ class MeshCoreBleService {
|
||||
required double longitude,
|
||||
}) async {
|
||||
// This command returns OK (0x00) response, so wait for acknowledgment
|
||||
await _commandSender.writeDataAndWaitForAck(FrameBuilder.buildSetAdvertLatLon(
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
));
|
||||
await _commandSender.writeDataAndWaitForAck(
|
||||
FrameBuilder.buildSetAdvertLatLon(
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Set radio parameters
|
||||
@@ -415,17 +465,21 @@ class MeshCoreBleService {
|
||||
required int spreadingFactor,
|
||||
required int codingRate,
|
||||
}) async {
|
||||
await _commandSender.writeDataAndWaitForAck(FrameBuilder.buildSetRadioParams(
|
||||
frequency: frequency,
|
||||
bandwidth: bandwidth,
|
||||
spreadingFactor: spreadingFactor,
|
||||
codingRate: codingRate,
|
||||
));
|
||||
await _commandSender.writeDataAndWaitForAck(
|
||||
FrameBuilder.buildSetRadioParams(
|
||||
frequency: frequency,
|
||||
bandwidth: bandwidth,
|
||||
spreadingFactor: spreadingFactor,
|
||||
codingRate: codingRate,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Set transmit power
|
||||
Future<void> setTxPower(int powerDbm) async {
|
||||
await _commandSender.writeDataAndWaitForAck(FrameBuilder.buildSetTxPower(powerDbm));
|
||||
await _commandSender.writeDataAndWaitForAck(
|
||||
FrameBuilder.buildSetTxPower(powerDbm),
|
||||
);
|
||||
}
|
||||
|
||||
/// Set other parameters
|
||||
@@ -435,12 +489,14 @@ class MeshCoreBleService {
|
||||
required int advertLocationPolicy,
|
||||
int multiAcks = 0,
|
||||
}) async {
|
||||
await _commandSender.writeDataAndWaitForAck(FrameBuilder.buildSetOtherParams(
|
||||
manualAddContacts: manualAddContacts,
|
||||
telemetryModes: telemetryModes,
|
||||
advertLocationPolicy: advertLocationPolicy,
|
||||
multiAcks: multiAcks,
|
||||
));
|
||||
await _commandSender.writeDataAndWaitForAck(
|
||||
FrameBuilder.buildSetOtherParams(
|
||||
manualAddContacts: manualAddContacts,
|
||||
telemetryModes: telemetryModes,
|
||||
advertLocationPolicy: advertLocationPolicy,
|
||||
multiAcks: multiAcks,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Send login request to room or repeater
|
||||
@@ -453,37 +509,55 @@ class MeshCoreBleService {
|
||||
}
|
||||
|
||||
debugPrint('🔐 [BLE] Preparing login request:');
|
||||
debugPrint(' Room public key prefix: ${roomPublicKey.sublist(0, 6).map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}');
|
||||
debugPrint(' Password: ${"*" * password.length} (${password.length} chars)');
|
||||
debugPrint(
|
||||
' Room public key prefix: ${roomPublicKey.sublist(0, 6).map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}',
|
||||
);
|
||||
debugPrint(
|
||||
' Password: ${"*" * password.length} (${password.length} chars)',
|
||||
);
|
||||
|
||||
await _commandSender.writeData(FrameBuilder.buildSendLogin(
|
||||
roomPublicKey: roomPublicKey,
|
||||
password: password,
|
||||
));
|
||||
await _commandSender.writeData(
|
||||
FrameBuilder.buildSendLogin(
|
||||
roomPublicKey: roomPublicKey,
|
||||
password: password,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Send status request to repeater or sensor node
|
||||
Future<void> sendStatusRequest(Uint8List contactPublicKey) async {
|
||||
debugPrint('📊 [BLE] Preparing status request:');
|
||||
debugPrint(' Target node public key prefix: ${contactPublicKey.sublist(0, 6).map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}');
|
||||
debugPrint(
|
||||
' Target node public key prefix: ${contactPublicKey.sublist(0, 6).map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}',
|
||||
);
|
||||
|
||||
await _commandSender.writeData(FrameBuilder.buildSendStatusReq(contactPublicKey));
|
||||
await _commandSender.writeData(
|
||||
FrameBuilder.buildSendStatusReq(contactPublicKey),
|
||||
);
|
||||
}
|
||||
|
||||
/// Reset path for a contact - forces next message to flood and re-learn route
|
||||
Future<void> resetPath(Uint8List contactPublicKey) async {
|
||||
debugPrint('🔄 [BLE] Resetting path for contact:');
|
||||
debugPrint(' Contact public key prefix: ${contactPublicKey.sublist(0, 6).map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}');
|
||||
debugPrint(
|
||||
' Contact public key prefix: ${contactPublicKey.sublist(0, 6).map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}',
|
||||
);
|
||||
|
||||
await _commandSender.writeData(FrameBuilder.buildResetPath(contactPublicKey));
|
||||
await _commandSender.writeData(
|
||||
FrameBuilder.buildResetPath(contactPublicKey),
|
||||
);
|
||||
}
|
||||
|
||||
/// Remove a contact from the companion radio
|
||||
Future<void> removeContact(Uint8List contactPublicKey) async {
|
||||
debugPrint('🗑️ [BLE] Removing contact from companion radio:');
|
||||
debugPrint(' Public key prefix: ${contactPublicKey.sublist(0, 6).map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}');
|
||||
debugPrint(
|
||||
' Public key prefix: ${contactPublicKey.sublist(0, 6).map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}',
|
||||
);
|
||||
|
||||
await _commandSender.writeData(FrameBuilder.buildRemoveContact(contactPublicKey));
|
||||
await _commandSender.writeData(
|
||||
FrameBuilder.buildRemoveContact(contactPublicKey),
|
||||
);
|
||||
debugPrint('✅ [BLE] CMD_REMOVE_CONTACT sent');
|
||||
}
|
||||
|
||||
@@ -506,11 +580,13 @@ class MeshCoreBleService {
|
||||
debugPrint(' Channel name: $channelName');
|
||||
debugPrint(' Secret length: ${secret.length} bytes');
|
||||
|
||||
await _commandSender.writeDataAndWaitForAck(FrameBuilder.buildSetChannel(
|
||||
channelIdx: channelIdx,
|
||||
channelName: channelName,
|
||||
secret: secret,
|
||||
));
|
||||
await _commandSender.writeDataAndWaitForAck(
|
||||
FrameBuilder.buildSetChannel(
|
||||
channelIdx: channelIdx,
|
||||
channelName: channelName,
|
||||
secret: secret,
|
||||
),
|
||||
);
|
||||
debugPrint('✅ [BLE] CMD_SET_CHANNEL sent successfully');
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../models/message.dart';
|
||||
@@ -27,7 +26,9 @@ class MessageStorageService {
|
||||
final jsonString = jsonEncode(limitedList);
|
||||
await prefs.setString(_messagesKey, jsonString);
|
||||
|
||||
debugPrint('✅ [MessageStorage] Saved ${limitedList.length} messages to storage');
|
||||
debugPrint(
|
||||
'✅ [MessageStorage] Saved ${limitedList.length} messages to storage',
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('❌ [MessageStorage] Error saving messages: $e');
|
||||
}
|
||||
@@ -51,7 +52,9 @@ class MessageStorageService {
|
||||
.cast<Message>()
|
||||
.toList();
|
||||
|
||||
debugPrint('✅ [MessageStorage] Loaded ${messages.length} messages from storage');
|
||||
debugPrint(
|
||||
'✅ [MessageStorage] Loaded ${messages.length} messages from storage',
|
||||
);
|
||||
return messages;
|
||||
} catch (e) {
|
||||
debugPrint('❌ [MessageStorage] Error loading messages: $e');
|
||||
@@ -77,11 +80,7 @@ class MessageStorageService {
|
||||
final jsonString = prefs.getString(_messagesKey);
|
||||
|
||||
if (jsonString == null || jsonString.isEmpty) {
|
||||
return {
|
||||
'messageCount': 0,
|
||||
'storageSizeBytes': 0,
|
||||
'storageSizeKB': 0,
|
||||
};
|
||||
return {'messageCount': 0, 'storageSizeBytes': 0, 'storageSizeKB': 0};
|
||||
}
|
||||
|
||||
final sizeBytes = jsonString.length;
|
||||
@@ -94,11 +93,7 @@ class MessageStorageService {
|
||||
};
|
||||
} catch (e) {
|
||||
debugPrint('❌ [MessageStorage] Error getting storage stats: $e');
|
||||
return {
|
||||
'messageCount': 0,
|
||||
'storageSizeBytes': 0,
|
||||
'storageSizeKB': 0,
|
||||
};
|
||||
return {'messageCount': 0, 'storageSizeBytes': 0, 'storageSizeKB': 0};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,7 +139,8 @@ class MessageStorageService {
|
||||
),
|
||||
senderPublicKeyPrefix: json['senderPublicKeyPrefix'] != null
|
||||
? Uint8List.fromList(
|
||||
base64Decode(json['senderPublicKeyPrefix'] as String))
|
||||
base64Decode(json['senderPublicKeyPrefix'] as String),
|
||||
)
|
||||
: null,
|
||||
channelIdx: json['channelIdx'] as int?,
|
||||
pathLen: json['pathLen'] as int,
|
||||
@@ -158,12 +154,13 @@ class MessageStorageService {
|
||||
orElse: () => SarMarkerType.unknown,
|
||||
)
|
||||
: null,
|
||||
sarGpsCoordinates: json['sarGpsLat'] != null &&
|
||||
json['sarGpsLon'] != null
|
||||
sarGpsCoordinates:
|
||||
json['sarGpsLat'] != null && json['sarGpsLon'] != null
|
||||
? LatLng(json['sarGpsLat'] as double, json['sarGpsLon'] as double)
|
||||
: null,
|
||||
receivedAt: DateTime.fromMillisecondsSinceEpoch(
|
||||
json['receivedAtMillis'] as int),
|
||||
json['receivedAtMillis'] as int,
|
||||
),
|
||||
senderName: json['senderName'] as String?,
|
||||
deliveryStatus: json['deliveryStatus'] != null
|
||||
? MessageDeliveryStatus.values.firstWhere(
|
||||
@@ -176,11 +173,13 @@ class MessageStorageService {
|
||||
roundTripTimeMs: json['roundTripTimeMs'] as int?,
|
||||
deliveredAt: json['deliveredAtMillis'] != null
|
||||
? DateTime.fromMillisecondsSinceEpoch(
|
||||
json['deliveredAtMillis'] as int)
|
||||
json['deliveredAtMillis'] as int,
|
||||
)
|
||||
: null,
|
||||
recipientPublicKey: json['recipientPublicKey'] != null
|
||||
? Uint8List.fromList(
|
||||
base64Decode(json['recipientPublicKey'] as String))
|
||||
base64Decode(json['recipientPublicKey'] as String),
|
||||
)
|
||||
: null,
|
||||
isRead: json['isRead'] as bool? ?? false,
|
||||
);
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
import 'package:timezone/timezone.dart' as tz;
|
||||
import 'package:timezone/data/latest_all.dart' as tz;
|
||||
import '../models/sar_marker.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
@@ -44,7 +43,9 @@ class NotificationService {
|
||||
tz.initializeTimeZones();
|
||||
|
||||
// Android initialization settings
|
||||
const androidSettings = AndroidInitializationSettings('@mipmap/ic_launcher');
|
||||
const androidSettings = AndroidInitializationSettings(
|
||||
'@mipmap/ic_launcher',
|
||||
);
|
||||
|
||||
// iOS initialization settings
|
||||
final darwinSettings = DarwinInitializationSettings(
|
||||
@@ -85,25 +86,34 @@ class NotificationService {
|
||||
try {
|
||||
// iOS permissions
|
||||
final iosPlugin = _notificationsPlugin
|
||||
.resolvePlatformSpecificImplementation<IOSFlutterLocalNotificationsPlugin>();
|
||||
.resolvePlatformSpecificImplementation<
|
||||
IOSFlutterLocalNotificationsPlugin
|
||||
>();
|
||||
if (iosPlugin != null) {
|
||||
final granted = await iosPlugin.requestPermissions(
|
||||
alert: true,
|
||||
badge: true,
|
||||
sound: true,
|
||||
critical: true, // Request critical alert permission for urgent SAR notifications
|
||||
critical:
|
||||
true, // Request critical alert permission for urgent SAR notifications
|
||||
);
|
||||
_permissionGranted = granted ?? false;
|
||||
debugPrint('📱 [NotificationService] iOS permissions granted: $_permissionGranted');
|
||||
debugPrint(
|
||||
'📱 [NotificationService] iOS permissions granted: $_permissionGranted',
|
||||
);
|
||||
}
|
||||
|
||||
// Android 13+ permissions
|
||||
final androidPlugin = _notificationsPlugin
|
||||
.resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>();
|
||||
.resolvePlatformSpecificImplementation<
|
||||
AndroidFlutterLocalNotificationsPlugin
|
||||
>();
|
||||
if (androidPlugin != null) {
|
||||
final granted = await androidPlugin.requestNotificationsPermission();
|
||||
_permissionGranted = granted ?? false;
|
||||
debugPrint('🤖 [NotificationService] Android permissions granted: $_permissionGranted');
|
||||
debugPrint(
|
||||
'🤖 [NotificationService] Android permissions granted: $_permissionGranted',
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('⚠️ [NotificationService] Error requesting permissions: $e');
|
||||
@@ -114,7 +124,9 @@ class NotificationService {
|
||||
Future<void> _createNotificationChannels() async {
|
||||
try {
|
||||
final androidPlugin = _notificationsPlugin
|
||||
.resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>();
|
||||
.resolvePlatformSpecificImplementation<
|
||||
AndroidFlutterLocalNotificationsPlugin
|
||||
>();
|
||||
|
||||
if (androidPlugin == null) return;
|
||||
|
||||
@@ -152,7 +164,9 @@ class NotificationService {
|
||||
|
||||
/// Handle notification tap (foreground)
|
||||
void _onNotificationResponse(NotificationResponse response) {
|
||||
debugPrint('🔔 [NotificationService] Notification tapped: ${response.payload}');
|
||||
debugPrint(
|
||||
'🔔 [NotificationService] Notification tapped: ${response.payload}',
|
||||
);
|
||||
// TODO: Navigate to map tab and show SAR marker
|
||||
// This would require a callback to the app layer
|
||||
}
|
||||
@@ -166,18 +180,23 @@ class NotificationService {
|
||||
AppLocalizations? localizations,
|
||||
}) async {
|
||||
if (!_isInitialized) {
|
||||
debugPrint('⚠️ [NotificationService] Not initialized, skipping notification');
|
||||
debugPrint(
|
||||
'⚠️ [NotificationService] Not initialized, skipping notification',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_permissionGranted) {
|
||||
debugPrint('⚠️ [NotificationService] Permission not granted, skipping notification');
|
||||
debugPrint(
|
||||
'⚠️ [NotificationService] Permission not granted, skipping notification',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Generate unique notification ID based on timestamp
|
||||
final notificationId = _sarNotificationId + (DateTime.now().millisecondsSinceEpoch % 1000);
|
||||
final notificationId =
|
||||
_sarNotificationId + (DateTime.now().millisecondsSinceEpoch % 1000);
|
||||
|
||||
// Build notification title and body
|
||||
final title = _buildNotificationTitle(type, localizations);
|
||||
@@ -222,7 +241,8 @@ class NotificationService {
|
||||
badgeNumber: 1,
|
||||
threadIdentifier: 'sar_markers',
|
||||
categoryIdentifier: 'SAR_ALERT',
|
||||
interruptionLevel: InterruptionLevel.critical, // Critical alert (bypasses silent mode)
|
||||
interruptionLevel:
|
||||
InterruptionLevel.critical, // Critical alert (bypasses silent mode)
|
||||
);
|
||||
|
||||
// Combined notification details
|
||||
@@ -250,7 +270,10 @@ class NotificationService {
|
||||
}
|
||||
|
||||
/// Build notification title based on SAR marker type
|
||||
String _buildNotificationTitle(SarMarkerType type, AppLocalizations? localizations) {
|
||||
String _buildNotificationTitle(
|
||||
SarMarkerType type,
|
||||
AppLocalizations? localizations,
|
||||
) {
|
||||
if (localizations == null) {
|
||||
return '🚨 ${type.displayName} Detected';
|
||||
}
|
||||
@@ -330,27 +353,33 @@ class NotificationService {
|
||||
AppLocalizations? localizations,
|
||||
}) async {
|
||||
if (!_isInitialized) {
|
||||
debugPrint('⚠️ [NotificationService] Not initialized, skipping notification');
|
||||
debugPrint(
|
||||
'⚠️ [NotificationService] Not initialized, skipping notification',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_permissionGranted) {
|
||||
debugPrint('⚠️ [NotificationService] Permission not granted, skipping notification');
|
||||
debugPrint(
|
||||
'⚠️ [NotificationService] Permission not granted, skipping notification',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Generate unique notification ID based on timestamp
|
||||
final notificationId = _messageNotificationId + (DateTime.now().millisecondsSinceEpoch % 1000);
|
||||
final notificationId =
|
||||
_messageNotificationId +
|
||||
(DateTime.now().millisecondsSinceEpoch % 1000);
|
||||
|
||||
// Build notification title and body
|
||||
final title = isChannelMessage
|
||||
? (localizations != null
|
||||
? '${localizations.channel}: ${channelName ?? "Public"}'
|
||||
: 'Channel: ${channelName ?? "Public"}')
|
||||
? '${localizations.channel}: ${channelName ?? "Public"}'
|
||||
: 'Channel: ${channelName ?? "Public"}')
|
||||
: (localizations != null
|
||||
? '${localizations.newMessage} ${localizations.from} $senderName'
|
||||
: 'New message from $senderName');
|
||||
? '${localizations.newMessage} ${localizations.from} $senderName'
|
||||
: 'New message from $senderName');
|
||||
|
||||
final body = messageText.length > 200
|
||||
? '${messageText.substring(0, 200)}...'
|
||||
@@ -381,7 +410,9 @@ class NotificationService {
|
||||
presentBadge: true,
|
||||
presentSound: true,
|
||||
sound: 'default',
|
||||
threadIdentifier: isChannelMessage ? 'channel_messages' : 'direct_messages',
|
||||
threadIdentifier: isChannelMessage
|
||||
? 'channel_messages'
|
||||
: 'direct_messages',
|
||||
subtitle: senderName,
|
||||
);
|
||||
|
||||
@@ -404,7 +435,9 @@ class NotificationService {
|
||||
debugPrint(' Sender: $senderName');
|
||||
debugPrint(' Type: ${isChannelMessage ? "Channel" : "Direct"}');
|
||||
} catch (e) {
|
||||
debugPrint('❌ [NotificationService] Error showing message notification: $e');
|
||||
debugPrint(
|
||||
'❌ [NotificationService] Error showing message notification: $e',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -432,7 +465,9 @@ class NotificationService {
|
||||
Future<bool> areNotificationsEnabled() async {
|
||||
try {
|
||||
final androidPlugin = _notificationsPlugin
|
||||
.resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>();
|
||||
.resolvePlatformSpecificImplementation<
|
||||
AndroidFlutterLocalNotificationsPlugin
|
||||
>();
|
||||
if (androidPlugin != null) {
|
||||
final enabled = await androidPlugin.areNotificationsEnabled();
|
||||
return enabled ?? false;
|
||||
@@ -441,7 +476,9 @@ class NotificationService {
|
||||
// For iOS, assume enabled if permission was granted
|
||||
return _permissionGranted;
|
||||
} catch (e) {
|
||||
debugPrint('⚠️ [NotificationService] Error checking notification status: $e');
|
||||
debugPrint(
|
||||
'⚠️ [NotificationService] Error checking notification status: $e',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -451,7 +488,9 @@ class NotificationService {
|
||||
try {
|
||||
return await _notificationsPlugin.pendingNotificationRequests();
|
||||
} catch (e) {
|
||||
debugPrint('⚠️ [NotificationService] Error getting pending notifications: $e');
|
||||
debugPrint(
|
||||
'⚠️ [NotificationService] Error getting pending notifications: $e',
|
||||
);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,13 +35,17 @@ class ContactTile extends StatelessWidget {
|
||||
final appProvider = context.watch<AppProvider>();
|
||||
final isSimpleMode = appProvider.isSimpleMode;
|
||||
|
||||
final hasTelemetry = contact.telemetry != null && contact.telemetry!.isRecent;
|
||||
final hasTelemetry =
|
||||
contact.telemetry != null && contact.telemetry!.isRecent;
|
||||
final battery = contact.displayBattery;
|
||||
final location = contact.displayLocation;
|
||||
|
||||
// Calculate distance if both positions are available
|
||||
String? distanceText;
|
||||
if (location != null && currentPosition != null && calculateDistance != null && formatDistance != null) {
|
||||
if (location != null &&
|
||||
currentPosition != null &&
|
||||
calculateDistance != null &&
|
||||
formatDistance != null) {
|
||||
final distanceMeters = calculateDistance!(
|
||||
currentPosition!.latitude,
|
||||
currentPosition!.longitude,
|
||||
@@ -69,10 +73,7 @@ class ContactTile extends StatelessWidget {
|
||||
contact.roleEmoji!,
|
||||
style: const TextStyle(fontSize: 24),
|
||||
)
|
||||
: Icon(
|
||||
_getTypeIcon(contact.type),
|
||||
color: Colors.white,
|
||||
),
|
||||
: Icon(_getTypeIcon(contact.type), color: Colors.white),
|
||||
),
|
||||
// New contact indicator badge (top-right)
|
||||
if (contact.isNew)
|
||||
@@ -161,7 +162,9 @@ class ContactTile extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(width: 2),
|
||||
Text(
|
||||
contact.hasPath ? AppLocalizations.of(context)!.direct : AppLocalizations.of(context)!.flood,
|
||||
contact.hasPath
|
||||
? AppLocalizations.of(context)!.direct
|
||||
: AppLocalizations.of(context)!.flood,
|
||||
style: TextStyle(
|
||||
fontSize: 9,
|
||||
fontWeight: FontWeight.w600,
|
||||
@@ -183,7 +186,11 @@ class ContactTile extends StatelessWidget {
|
||||
if (location != null) ...[
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.location_on, size: 12, color: Colors.blue),
|
||||
const Icon(
|
||||
Icons.location_on,
|
||||
size: 12,
|
||||
color: Colors.blue,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(
|
||||
child: Text(
|
||||
@@ -198,14 +205,19 @@ class ContactTile extends StatelessWidget {
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.straighten, size: 12, color: Colors.blue),
|
||||
const Icon(
|
||||
Icons.straighten,
|
||||
size: 12,
|
||||
color: Colors.blue,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'${AppLocalizations.of(context)!.distance}: $distanceText',
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: Colors.blue,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
style: Theme.of(context).textTheme.labelSmall
|
||||
?.copyWith(
|
||||
color: Colors.blue,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -213,9 +225,9 @@ class ContactTile extends StatelessWidget {
|
||||
] else
|
||||
Text(
|
||||
AppLocalizations.of(context)!.noGpsData,
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: Colors.grey,
|
||||
),
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.labelSmall?.copyWith(color: Colors.grey),
|
||||
),
|
||||
],
|
||||
)
|
||||
@@ -229,7 +241,10 @@ class ContactTile extends StatelessWidget {
|
||||
children: [
|
||||
if (roomLoginState.isAdmin)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 4,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red.withOpacity(0.2),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
@@ -238,22 +253,30 @@ class ContactTile extends StatelessWidget {
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.admin_panel_settings, size: 10, color: Colors.red),
|
||||
const Icon(
|
||||
Icons.admin_panel_settings,
|
||||
size: 10,
|
||||
color: Colors.red,
|
||||
),
|
||||
const SizedBox(width: 2),
|
||||
Text(
|
||||
AppLocalizations.of(context)!.admin,
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: Colors.red,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 10,
|
||||
),
|
||||
style: Theme.of(context).textTheme.labelSmall
|
||||
?.copyWith(
|
||||
color: Colors.red,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 10,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (roomLoginState.isAdmin) const SizedBox(width: 4),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 4,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.green.withOpacity(0.2),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
@@ -262,15 +285,20 @@ class ContactTile extends StatelessWidget {
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.check_circle, size: 10, color: Colors.green),
|
||||
const Icon(
|
||||
Icons.check_circle,
|
||||
size: 10,
|
||||
color: Colors.green,
|
||||
),
|
||||
const SizedBox(width: 2),
|
||||
Text(
|
||||
AppLocalizations.of(context)!.loggedIn,
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: Colors.green,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 10,
|
||||
),
|
||||
style: Theme.of(context).textTheme.labelSmall
|
||||
?.copyWith(
|
||||
color: Colors.green,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 10,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -285,7 +313,9 @@ class ContactTile extends StatelessWidget {
|
||||
Icon(
|
||||
Icons.access_time,
|
||||
size: 12,
|
||||
color: contact.isRecentlySeen ? Colors.green : Colors.grey,
|
||||
color: contact.isRecentlySeen
|
||||
? Colors.green
|
||||
: Colors.grey,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
@@ -297,9 +327,17 @@ class ContactTile extends StatelessWidget {
|
||||
const Text('•', style: TextStyle(color: Colors.grey)),
|
||||
const SizedBox(width: 8),
|
||||
if (hasTelemetry)
|
||||
const Icon(Icons.sensors, size: 12, color: Colors.green)
|
||||
const Icon(
|
||||
Icons.sensors,
|
||||
size: 12,
|
||||
color: Colors.green,
|
||||
)
|
||||
else
|
||||
const Icon(Icons.sensors_off, size: 12, color: Colors.grey),
|
||||
const Icon(
|
||||
Icons.sensors_off,
|
||||
size: 12,
|
||||
color: Colors.grey,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(
|
||||
child: Text(
|
||||
@@ -312,7 +350,11 @@ class ContactTile extends StatelessWidget {
|
||||
const SizedBox(width: 8),
|
||||
const Text('•', style: TextStyle(color: Colors.grey)),
|
||||
const SizedBox(width: 8),
|
||||
const Icon(Icons.sensors_off, size: 12, color: Colors.grey),
|
||||
const Icon(
|
||||
Icons.sensors_off,
|
||||
size: 12,
|
||||
color: Colors.grey,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
AppLocalizations.of(context)!.noGpsData,
|
||||
@@ -326,14 +368,19 @@ class ContactTile extends StatelessWidget {
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.straighten, size: 12, color: Colors.blue),
|
||||
const Icon(
|
||||
Icons.straighten,
|
||||
size: 12,
|
||||
color: Colors.blue,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'${AppLocalizations.of(context)!.distance}: $distanceText',
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: Colors.blue,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
style: Theme.of(context).textTheme.labelSmall
|
||||
?.copyWith(
|
||||
color: Colors.blue,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -348,7 +395,9 @@ class ContactTile extends StatelessWidget {
|
||||
} else if (isSimpleMode && contact.type == ContactType.repeater) {
|
||||
// In simple mode, tapping a repeater jumps to the map
|
||||
_jumpToMapForRepeater(context, contact);
|
||||
} else if (isSimpleMode && contact.type == ContactType.room && !contact.isPublicChannel) {
|
||||
} else if (isSimpleMode &&
|
||||
contact.type == ContactType.room &&
|
||||
!contact.isPublicChannel) {
|
||||
_showRoomLoginDialog(context, contact);
|
||||
} else {
|
||||
_showContactDetails(context, contact);
|
||||
@@ -364,8 +413,12 @@ class ContactTile extends StatelessWidget {
|
||||
ToastLogger.info(
|
||||
context,
|
||||
hasPath
|
||||
? AppLocalizations.of(context)!.pingingDirect(contact.displayName)
|
||||
: AppLocalizations.of(context)!.pingingFlood(contact.displayName),
|
||||
? AppLocalizations.of(
|
||||
context,
|
||||
)!.pingingDirect(contact.displayName)
|
||||
: AppLocalizations.of(
|
||||
context,
|
||||
)!.pingingFlood(contact.displayName),
|
||||
);
|
||||
|
||||
// Use smart ping with automatic fallback
|
||||
@@ -377,7 +430,9 @@ class ContactTile extends StatelessWidget {
|
||||
if (context.mounted) {
|
||||
ToastLogger.warning(
|
||||
context,
|
||||
AppLocalizations.of(context)!.directPingTimeout(contact.displayName),
|
||||
AppLocalizations.of(
|
||||
context,
|
||||
)!.directPingTimeout(contact.displayName),
|
||||
);
|
||||
}
|
||||
},
|
||||
@@ -390,7 +445,9 @@ class ContactTile extends StatelessWidget {
|
||||
context,
|
||||
AppLocalizations.of(context)!.pingSuccessful(
|
||||
contact.displayName,
|
||||
result.retriedWithFlooding ? AppLocalizations.of(context)!.viaFloodingFallback : '',
|
||||
result.retriedWithFlooding
|
||||
? AppLocalizations.of(context)!.viaFloodingFallback
|
||||
: '',
|
||||
),
|
||||
);
|
||||
} else {
|
||||
@@ -452,7 +509,9 @@ class ContactTile extends StatelessWidget {
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(AppLocalizations.of(context)!.deleteContact),
|
||||
content: Text(
|
||||
AppLocalizations.of(context)!.deleteContactConfirmation(contact.displayName),
|
||||
AppLocalizations.of(
|
||||
context,
|
||||
)!.deleteContactConfirmation(contact.displayName),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
@@ -479,7 +538,10 @@ class ContactTile extends StatelessWidget {
|
||||
|
||||
try {
|
||||
// Show loading toast
|
||||
ToastLogger.info(context, AppLocalizations.of(context)!.removingContact(contact.displayName));
|
||||
ToastLogger.info(
|
||||
context,
|
||||
AppLocalizations.of(context)!.removingContact(contact.displayName),
|
||||
);
|
||||
|
||||
// Remove contact from provider (which will also remove from device)
|
||||
await contactsProvider.removeContact(
|
||||
@@ -492,11 +554,17 @@ class ContactTile extends StatelessWidget {
|
||||
);
|
||||
|
||||
if (context.mounted) {
|
||||
ToastLogger.success(context, AppLocalizations.of(context)!.contactRemoved(contact.displayName));
|
||||
ToastLogger.success(
|
||||
context,
|
||||
AppLocalizations.of(context)!.contactRemoved(contact.displayName),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
ToastLogger.error(context, AppLocalizations.of(context)!.failedToRemoveContact(e.toString()));
|
||||
ToastLogger.error(
|
||||
context,
|
||||
AppLocalizations.of(context)!.failedToRemoveContact(e.toString()),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -543,10 +611,7 @@ class ContactTile extends StatelessWidget {
|
||||
contact.roleEmoji!,
|
||||
style: const TextStyle(fontSize: 24),
|
||||
)
|
||||
: Icon(
|
||||
_getTypeIcon(contact.type),
|
||||
color: Colors.white,
|
||||
),
|
||||
: Icon(_getTypeIcon(contact.type), color: Colors.white),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
@@ -572,7 +637,10 @@ class ContactTile extends StatelessWidget {
|
||||
controller: scrollController,
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
_DetailRow(AppLocalizations.of(context)!.type, contact.type.displayName),
|
||||
_DetailRow(
|
||||
AppLocalizations.of(context)!.type,
|
||||
contact.type.displayName,
|
||||
),
|
||||
// Public Key with copy button
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
@@ -586,16 +654,18 @@ class ContactTile extends StatelessWidget {
|
||||
style: const TextStyle(fontWeight: FontWeight.w500),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(contact.publicKeyShort),
|
||||
),
|
||||
Expanded(child: Text(contact.publicKeyShort)),
|
||||
const SizedBox(width: 8),
|
||||
InkWell(
|
||||
onTap: () {
|
||||
Clipboard.setData(ClipboardData(text: contact.publicKeyHex));
|
||||
Clipboard.setData(
|
||||
ClipboardData(text: contact.publicKeyHex),
|
||||
);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(AppLocalizations.of(context)!.publicKeyCopied),
|
||||
content: Text(
|
||||
AppLocalizations.of(context)!.publicKeyCopied,
|
||||
),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
@@ -613,7 +683,10 @@ class ContactTile extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
),
|
||||
_DetailRow(AppLocalizations.of(context)!.lastSeen, contact.timeSinceLastSeen),
|
||||
_DetailRow(
|
||||
AppLocalizations.of(context)!.lastSeen,
|
||||
contact.timeSinceLastSeen,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
// Room Login Status
|
||||
if (roomLoginState != null) ...[
|
||||
@@ -627,12 +700,16 @@ class ContactTile extends StatelessWidget {
|
||||
const SizedBox(height: 8),
|
||||
_DetailRow(
|
||||
AppLocalizations.of(context)!.loginStatus,
|
||||
roomLoginState.isLoggedIn ? AppLocalizations.of(context)!.loggedIn : AppLocalizations.of(context)!.notLoggedIn,
|
||||
roomLoginState.isLoggedIn
|
||||
? AppLocalizations.of(context)!.loggedIn
|
||||
: AppLocalizations.of(context)!.notLoggedIn,
|
||||
),
|
||||
if (roomLoginState.isLoggedIn) ...[
|
||||
_DetailRow(
|
||||
AppLocalizations.of(context)!.adminAccess,
|
||||
roomLoginState.isAdmin ? AppLocalizations.of(context)!.yes : AppLocalizations.of(context)!.no,
|
||||
roomLoginState.isAdmin
|
||||
? AppLocalizations.of(context)!.yes
|
||||
: AppLocalizations.of(context)!.no,
|
||||
),
|
||||
_DetailRow(
|
||||
AppLocalizations.of(context)!.permissions,
|
||||
@@ -646,7 +723,9 @@ class ContactTile extends StatelessWidget {
|
||||
],
|
||||
_DetailRow(
|
||||
AppLocalizations.of(context)!.passwordSaved,
|
||||
roomLoginState.hasPassword ? AppLocalizations.of(context)!.yes : AppLocalizations.of(context)!.no,
|
||||
roomLoginState.hasPassword
|
||||
? AppLocalizations.of(context)!.yes
|
||||
: AppLocalizations.of(context)!.no,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
@@ -679,7 +758,10 @@ class ContactTile extends StatelessWidget {
|
||||
icon: const Icon(Icons.map, size: 18),
|
||||
label: Text(AppLocalizations.of(context)!.viewOnMap),
|
||||
style: TextButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 4,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -695,25 +777,37 @@ class ContactTile extends StatelessWidget {
|
||||
_DetailRowWithCopy(
|
||||
context,
|
||||
'DMS',
|
||||
_convertToDMS(contact.displayLocation!.latitude, contact.displayLocation!.longitude),
|
||||
_convertToDMS(
|
||||
contact.displayLocation!.latitude,
|
||||
contact.displayLocation!.longitude,
|
||||
),
|
||||
),
|
||||
// Degrees Decimal Minutes (DDM)
|
||||
_DetailRowWithCopy(
|
||||
context,
|
||||
'DDM',
|
||||
_convertToDDM(contact.displayLocation!.latitude, contact.displayLocation!.longitude),
|
||||
_convertToDDM(
|
||||
contact.displayLocation!.latitude,
|
||||
contact.displayLocation!.longitude,
|
||||
),
|
||||
),
|
||||
// MGRS (Military Grid Reference System)
|
||||
_DetailRowWithCopy(
|
||||
context,
|
||||
'MGRS',
|
||||
_convertToMGRS(contact.displayLocation!.latitude, contact.displayLocation!.longitude),
|
||||
_convertToMGRS(
|
||||
contact.displayLocation!.latitude,
|
||||
contact.displayLocation!.longitude,
|
||||
),
|
||||
),
|
||||
// Google Plus Code
|
||||
_DetailRowWithCopy(
|
||||
context,
|
||||
'Plus Code',
|
||||
_convertToPlusCode(contact.displayLocation!.latitude, contact.displayLocation!.longitude),
|
||||
_convertToPlusCode(
|
||||
contact.displayLocation!.latitude,
|
||||
contact.displayLocation!.longitude,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
@@ -730,14 +824,26 @@ class ContactTile extends StatelessWidget {
|
||||
),
|
||||
TextButton.icon(
|
||||
onPressed: () {
|
||||
final connectionProvider = context.read<ConnectionProvider>();
|
||||
connectionProvider.requestTelemetry(contact.publicKey, zeroHop: true);
|
||||
ToastLogger.info(context, AppLocalizations.of(context)!.requestingTelemetry(contact.displayName));
|
||||
final connectionProvider = context
|
||||
.read<ConnectionProvider>();
|
||||
connectionProvider.requestTelemetry(
|
||||
contact.publicKey,
|
||||
zeroHop: true,
|
||||
);
|
||||
ToastLogger.info(
|
||||
context,
|
||||
AppLocalizations.of(
|
||||
context,
|
||||
)!.requestingTelemetry(contact.displayName),
|
||||
);
|
||||
},
|
||||
icon: const Icon(Icons.refresh, size: 18),
|
||||
label: Text(AppLocalizations.of(context)!.refresh),
|
||||
style: TextButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 4,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -750,13 +856,25 @@ class ContactTile extends StatelessWidget {
|
||||
'${contact.telemetry!.batteryPercentage != null ? ' (${contact.telemetry!.batteryPercentage!.toStringAsFixed(1)}%)' : ''}',
|
||||
)
|
||||
else if (contact.telemetry!.batteryPercentage != null)
|
||||
_DetailRow(AppLocalizations.of(context)!.battery, '${contact.telemetry!.batteryPercentage!.toStringAsFixed(1)}%'),
|
||||
_DetailRow(
|
||||
AppLocalizations.of(context)!.battery,
|
||||
'${contact.telemetry!.batteryPercentage!.toStringAsFixed(1)}%',
|
||||
),
|
||||
if (contact.telemetry!.temperature != null)
|
||||
_DetailRow(AppLocalizations.of(context)!.temperature, '${contact.telemetry!.temperature!.toStringAsFixed(1)}°C'),
|
||||
_DetailRow(
|
||||
AppLocalizations.of(context)!.temperature,
|
||||
'${contact.telemetry!.temperature!.toStringAsFixed(1)}°C',
|
||||
),
|
||||
if (contact.telemetry!.humidity != null)
|
||||
_DetailRow(AppLocalizations.of(context)!.humidity, '${contact.telemetry!.humidity!.toStringAsFixed(1)}%'),
|
||||
_DetailRow(
|
||||
AppLocalizations.of(context)!.humidity,
|
||||
'${contact.telemetry!.humidity!.toStringAsFixed(1)}%',
|
||||
),
|
||||
if (contact.telemetry!.pressure != null)
|
||||
_DetailRow(AppLocalizations.of(context)!.pressure, '${contact.telemetry!.pressure!.toStringAsFixed(1)} hPa'),
|
||||
_DetailRow(
|
||||
AppLocalizations.of(context)!.pressure,
|
||||
'${contact.telemetry!.pressure!.toStringAsFixed(1)} hPa',
|
||||
),
|
||||
if (contact.telemetry!.gpsLocation != null)
|
||||
_DetailRow(
|
||||
AppLocalizations.of(context)!.gpsTelemetry,
|
||||
@@ -778,7 +896,9 @@ class ContactTile extends StatelessWidget {
|
||||
_showDirectMessageDialog(context, contact);
|
||||
},
|
||||
icon: const Icon(Icons.message),
|
||||
label: Text(AppLocalizations.of(context)!.sendDirectMessage),
|
||||
label: Text(
|
||||
AppLocalizations.of(context)!.sendDirectMessage,
|
||||
),
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
backgroundColor: _getTypeColor(contact.type, context),
|
||||
@@ -792,20 +912,28 @@ class ContactTile extends StatelessWidget {
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: () {
|
||||
connectionProvider.resetPath(contact.publicKey);
|
||||
ToastLogger.info(context, AppLocalizations.of(context)!.pathResetInfo(contact.displayName));
|
||||
ToastLogger.info(
|
||||
context,
|
||||
AppLocalizations.of(
|
||||
context,
|
||||
)!.pathResetInfo(contact.displayName),
|
||||
);
|
||||
},
|
||||
icon: const Icon(Icons.route),
|
||||
label: Text(AppLocalizations.of(context)!.resetPath),
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
side: BorderSide(color: _getTypeColor(contact.type, context)),
|
||||
side: BorderSide(
|
||||
color: _getTypeColor(contact.type, context),
|
||||
),
|
||||
foregroundColor: _getTypeColor(contact.type, context),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
// Room Login button for room contacts (except Public Channel)
|
||||
if (contact.type == ContactType.room && !contact.isPublicChannel) ...[
|
||||
if (contact.type == ContactType.room &&
|
||||
!contact.isPublicChannel) ...[
|
||||
const SizedBox(height: 24),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
@@ -815,7 +943,11 @@ class ContactTile extends StatelessWidget {
|
||||
_showRoomLoginDialog(context, contact);
|
||||
},
|
||||
icon: const Icon(Icons.login),
|
||||
label: Text(roomLoginState?.isLoggedIn == true ? AppLocalizations.of(context)!.reLoginToRoom : AppLocalizations.of(context)!.loginToRoom),
|
||||
label: Text(
|
||||
roomLoginState?.isLoggedIn == true
|
||||
? AppLocalizations.of(context)!.reLoginToRoom
|
||||
: AppLocalizations.of(context)!.loginToRoom,
|
||||
),
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
backgroundColor: _getTypeColor(contact.type, context),
|
||||
@@ -830,9 +962,12 @@ class ContactTile extends StatelessWidget {
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: () => _showDeleteConfirmation(context, contact),
|
||||
onPressed: () =>
|
||||
_showDeleteConfirmation(context, contact),
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
label: Text(AppLocalizations.of(context)!.deleteContact),
|
||||
label: Text(
|
||||
AppLocalizations.of(context)!.deleteContact,
|
||||
),
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
side: const BorderSide(color: Colors.red),
|
||||
@@ -863,9 +998,7 @@ class ContactTile extends StatelessWidget {
|
||||
style: const TextStyle(fontWeight: FontWeight.w500),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(value),
|
||||
),
|
||||
Expanded(child: Text(value)),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -884,16 +1017,16 @@ class ContactTile extends StatelessWidget {
|
||||
style: const TextStyle(fontWeight: FontWeight.w500),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(value),
|
||||
),
|
||||
Expanded(child: Text(value)),
|
||||
const SizedBox(width: 8),
|
||||
InkWell(
|
||||
onTap: () {
|
||||
Clipboard.setData(ClipboardData(text: value));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(AppLocalizations.of(context)!.copiedToClipboard(label)),
|
||||
content: Text(
|
||||
AppLocalizations.of(context)!.copiedToClipboard(label),
|
||||
),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
@@ -931,7 +1064,7 @@ class ContactTile extends StatelessWidget {
|
||||
int lonMin = lonMinDec.floor();
|
||||
double lonSec = (lonMinDec - lonMin) * 60;
|
||||
|
||||
return '$latDeg°${latMin}\'${latSec.toStringAsFixed(2)}"$latDir, $lonDeg°${lonMin}\'${lonSec.toStringAsFixed(2)}"$lonDir';
|
||||
return '$latDeg°$latMin\'${latSec.toStringAsFixed(2)}"$latDir, $lonDeg°$lonMin\'${lonSec.toStringAsFixed(2)}"$lonDir';
|
||||
}
|
||||
|
||||
/// Convert to Degrees Decimal Minutes (DDM) format
|
||||
@@ -966,7 +1099,7 @@ class ContactTile extends StatelessWidget {
|
||||
|
||||
// Simplified - just show zone designation
|
||||
// Full MGRS would require UTM conversion library
|
||||
return '${zone}$letter (approximate)';
|
||||
return '$zone$letter (approximate)';
|
||||
}
|
||||
|
||||
/// Convert to Google Plus Code format
|
||||
@@ -1061,7 +1194,11 @@ class ContactTile extends StatelessWidget {
|
||||
String _formatTimestamp(DateTime timestamp) {
|
||||
final now = DateTime.now();
|
||||
final today = DateTime(now.year, now.month, now.day);
|
||||
final timestampDate = DateTime(timestamp.year, timestamp.month, timestamp.day);
|
||||
final timestampDate = DateTime(
|
||||
timestamp.year,
|
||||
timestamp.month,
|
||||
timestamp.day,
|
||||
);
|
||||
|
||||
if (timestampDate == today) {
|
||||
// Today - show time only
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import 'dart:typed_data';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
@@ -86,7 +85,9 @@ class _RoomLoginSheetState extends State<RoomLoginSheet> {
|
||||
});
|
||||
|
||||
// 🕐 CLOCK DRIFT CHECK: Get device time to detect synchronization issues
|
||||
debugPrint('🕐 [RoomLogin] Checking for clock drift between app and radio...');
|
||||
debugPrint(
|
||||
'🕐 [RoomLogin] Checking for clock drift between app and radio...',
|
||||
);
|
||||
try {
|
||||
await connectionProvider.getDeviceTime();
|
||||
// Give time for response to be logged
|
||||
@@ -97,18 +98,26 @@ class _RoomLoginSheetState extends State<RoomLoginSheet> {
|
||||
}
|
||||
|
||||
// 🔍 PRE-LOGIN CHECK: Ensure room contact exists in device
|
||||
debugPrint('🔍 [RoomLogin] Checking if room "${widget.contact.advName}" exists in contacts...');
|
||||
debugPrint(' Target public key prefix: ${widget.contact.publicKeyPrefix.map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}');
|
||||
debugPrint(
|
||||
'🔍 [RoomLogin] Checking if room "${widget.contact.advName}" exists in contacts...',
|
||||
);
|
||||
debugPrint(
|
||||
' Target public key prefix: ${widget.contact.publicKeyPrefix.map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}',
|
||||
);
|
||||
|
||||
// Check if the room exists in our local contacts
|
||||
bool roomExists = contactsProvider.rooms.any(
|
||||
(room) => room.publicKeyHex == widget.contact.publicKeyHex,
|
||||
);
|
||||
|
||||
debugPrint(' Local contact list: ${roomExists ? "✅ Found" : "❌ Not found"}');
|
||||
debugPrint(
|
||||
' Local contact list: ${roomExists ? "✅ Found" : "❌ Not found"}',
|
||||
);
|
||||
|
||||
if (!roomExists) {
|
||||
debugPrint('⚠️ [RoomLogin] Room not in local contacts - syncing with device...');
|
||||
debugPrint(
|
||||
'⚠️ [RoomLogin] Room not in local contacts - syncing with device...',
|
||||
);
|
||||
|
||||
try {
|
||||
// Sync contacts from device
|
||||
@@ -122,24 +131,32 @@ class _RoomLoginSheetState extends State<RoomLoginSheet> {
|
||||
(room) => room.publicKeyHex == widget.contact.publicKeyHex,
|
||||
);
|
||||
|
||||
debugPrint(' After sync: ${roomExists ? "✅ Found" : "❌ Still not found"}');
|
||||
debugPrint(
|
||||
' After sync: ${roomExists ? "✅ Found" : "❌ Still not found"}',
|
||||
);
|
||||
|
||||
if (!roomExists) {
|
||||
// Room still doesn't exist on the device - try to add it manually
|
||||
debugPrint('❌ [RoomLogin] Room still not found after sync');
|
||||
debugPrint('🔧 [RoomLogin] Attempting to add room contact to companion radio...');
|
||||
debugPrint(
|
||||
'🔧 [RoomLogin] Attempting to add room contact to companion radio...',
|
||||
);
|
||||
|
||||
try {
|
||||
// Manually add the room contact to the radio's flash storage
|
||||
await connectionProvider.addOrUpdateContact(widget.contact);
|
||||
|
||||
debugPrint('✅ [RoomLogin] Room contact added via CMD_ADD_UPDATE_CONTACT');
|
||||
debugPrint(
|
||||
'✅ [RoomLogin] Room contact added via CMD_ADD_UPDATE_CONTACT',
|
||||
);
|
||||
debugPrint(' Waiting 500ms for radio to save to flash...');
|
||||
|
||||
// Give the radio time to save the contact to flash
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
|
||||
debugPrint('✅ [RoomLogin] Room contact should now be available - proceeding with login');
|
||||
debugPrint(
|
||||
'✅ [RoomLogin] Room contact should now be available - proceeding with login',
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('❌ [RoomLogin] Failed to add room contact: $e');
|
||||
|
||||
@@ -151,7 +168,9 @@ class _RoomLoginSheetState extends State<RoomLoginSheet> {
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(AppLocalizations.of(context)!.failedToAddRoom(e.toString())),
|
||||
content: Text(
|
||||
AppLocalizations.of(context)!.failedToAddRoom(e.toString()),
|
||||
),
|
||||
backgroundColor: Theme.of(context).colorScheme.error,
|
||||
duration: const Duration(seconds: 7),
|
||||
),
|
||||
@@ -159,16 +178,22 @@ class _RoomLoginSheetState extends State<RoomLoginSheet> {
|
||||
|
||||
// Log available rooms for debugging
|
||||
final availableRooms = contactsProvider.rooms;
|
||||
debugPrint('📋 [RoomLogin] Available rooms on device (${availableRooms.length}):');
|
||||
debugPrint(
|
||||
'📋 [RoomLogin] Available rooms on device (${availableRooms.length}):',
|
||||
);
|
||||
for (final room in availableRooms) {
|
||||
debugPrint(' - ${room.advName} (${room.publicKeyPrefix.map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')})');
|
||||
debugPrint(
|
||||
' - ${room.advName} (${room.publicKeyPrefix.map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')})',
|
||||
);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
debugPrint('✅ [RoomLogin] Room contact found after sync - proceeding with login');
|
||||
debugPrint(
|
||||
'✅ [RoomLogin] Room contact found after sync - proceeding with login',
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('❌ [RoomLogin] Contact sync failed: $e');
|
||||
|
||||
@@ -180,14 +205,18 @@ class _RoomLoginSheetState extends State<RoomLoginSheet> {
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(AppLocalizations.of(context)!.failedToSyncContacts(e.toString())),
|
||||
content: Text(
|
||||
AppLocalizations.of(context)!.failedToSyncContacts(e.toString()),
|
||||
),
|
||||
backgroundColor: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
debugPrint('✅ [RoomLogin] Room contact found in local contacts - proceeding with login');
|
||||
debugPrint(
|
||||
'✅ [RoomLogin] Room contact found in local contacts - proceeding with login',
|
||||
);
|
||||
}
|
||||
|
||||
// Save password before sending
|
||||
@@ -200,14 +229,21 @@ class _RoomLoginSheetState extends State<RoomLoginSheet> {
|
||||
originalOnSuccess = connectionProvider.onLoginSuccess;
|
||||
originalOnFail = connectionProvider.onLoginFail;
|
||||
|
||||
connectionProvider.onLoginSuccess = (publicKeyPrefix, permissions, isAdmin, tag) async {
|
||||
connectionProvider
|
||||
.onLoginSuccess = (publicKeyPrefix, permissions, isAdmin, tag) async {
|
||||
// Restore original callback
|
||||
connectionProvider.onLoginSuccess = originalOnSuccess;
|
||||
connectionProvider.onLoginFail = originalOnFail;
|
||||
|
||||
debugPrint('✅ [RoomLogin] Login successful! Tag: $tag, Permissions: $permissions, Admin: $isAdmin');
|
||||
debugPrint('📡 [RoomLogin] Room server will now push messages automatically via PUSH_CODE_MSG_WAITING');
|
||||
debugPrint(' Messages will be fetched when onMessageWaiting callback is triggered');
|
||||
debugPrint(
|
||||
'✅ [RoomLogin] Login successful! Tag: $tag, Permissions: $permissions, Admin: $isAdmin',
|
||||
);
|
||||
debugPrint(
|
||||
'📡 [RoomLogin] Room server will now push messages automatically via PUSH_CODE_MSG_WAITING',
|
||||
);
|
||||
debugPrint(
|
||||
' Messages will be fetched when onMessageWaiting callback is triggered',
|
||||
);
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
@@ -252,7 +288,9 @@ class _RoomLoginSheetState extends State<RoomLoginSheet> {
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(AppLocalizations.of(context)!.loggingIn(widget.contact.displayName)),
|
||||
content: Text(
|
||||
AppLocalizations.of(context)!.loggingIn(widget.contact.displayName),
|
||||
),
|
||||
backgroundColor: Theme.of(context).colorScheme.primary,
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
@@ -265,7 +303,9 @@ class _RoomLoginSheetState extends State<RoomLoginSheet> {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(AppLocalizations.of(context)!.failedToSendLogin(e.toString())),
|
||||
content: Text(
|
||||
AppLocalizations.of(context)!.failedToSendLogin(e.toString()),
|
||||
),
|
||||
backgroundColor: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
);
|
||||
@@ -350,7 +390,11 @@ class _RoomLoginSheetState extends State<RoomLoginSheet> {
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(Icons.info_outline, color: colorScheme.onPrimaryContainer, size: 20),
|
||||
Icon(
|
||||
Icons.info_outline,
|
||||
color: colorScheme.onPrimaryContainer,
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
@@ -378,72 +422,83 @@ class _RoomLoginSheetState extends State<RoomLoginSheet> {
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextField(
|
||||
controller: _passwordController,
|
||||
focusNode: _focusNode,
|
||||
maxLength: 15, // Max password length from protocol
|
||||
obscureText: _obscurePassword,
|
||||
autofocus: true,
|
||||
maxLengthEnforcement: MaxLengthEnforcement.enforced,
|
||||
style: TextStyle(color: colorScheme.onSurface),
|
||||
decoration: InputDecoration(
|
||||
labelText: AppLocalizations.of(context)!.password,
|
||||
labelStyle: TextStyle(color: colorScheme.onSurfaceVariant),
|
||||
hintText: AppLocalizations.of(context)!.enterRoomPassword,
|
||||
hintStyle: TextStyle(color: colorScheme.onSurfaceVariant),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(color: colorScheme.outline),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(color: colorScheme.outline),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(color: colorScheme.primary, width: 2),
|
||||
),
|
||||
contentPadding: const EdgeInsets.all(16),
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(
|
||||
_obscurePassword ? Icons.visibility : Icons.visibility_off,
|
||||
children: [
|
||||
TextField(
|
||||
controller: _passwordController,
|
||||
focusNode: _focusNode,
|
||||
maxLength: 15, // Max password length from protocol
|
||||
obscureText: _obscurePassword,
|
||||
autofocus: true,
|
||||
maxLengthEnforcement: MaxLengthEnforcement.enforced,
|
||||
style: TextStyle(color: colorScheme.onSurface),
|
||||
decoration: InputDecoration(
|
||||
labelText: AppLocalizations.of(context)!.password,
|
||||
labelStyle: TextStyle(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_obscurePassword = !_obscurePassword;
|
||||
});
|
||||
},
|
||||
hintText: AppLocalizations.of(context)!.enterRoomPassword,
|
||||
hintStyle: TextStyle(color: colorScheme.onSurfaceVariant),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(color: colorScheme.outline),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(color: colorScheme.outline),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(
|
||||
color: colorScheme.primary,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
contentPadding: const EdgeInsets.all(16),
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(
|
||||
_obscurePassword
|
||||
? Icons.visibility
|
||||
: Icons.visibility_off,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_obscurePassword = !_obscurePassword;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
textInputAction: TextInputAction.done,
|
||||
onSubmitted: (_) => _loginToRoom(),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: _isLoggingIn ? null : _loginToRoom,
|
||||
icon: _isLoggingIn
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.login),
|
||||
label: Text(
|
||||
_isLoggingIn
|
||||
? AppLocalizations.of(context)!.loggingInDots
|
||||
: AppLocalizations.of(context)!.login,
|
||||
),
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
),
|
||||
),
|
||||
),
|
||||
textInputAction: TextInputAction.done,
|
||||
onSubmitted: (_) => _loginToRoom(),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: _isLoggingIn ? null : _loginToRoom,
|
||||
icon: _isLoggingIn
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.login),
|
||||
label: Text(_isLoggingIn ? AppLocalizations.of(context)!.loggingInDots : AppLocalizations.of(context)!.login),
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import 'dart:typed_data';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../providers/drawing_provider.dart';
|
||||
@@ -352,10 +351,18 @@ class DrawingToolbar extends StatelessWidget {
|
||||
|
||||
// Read providers BEFORE any async operations or dialogs
|
||||
// This ensures we have the correct BuildContext
|
||||
final connectionProvider = Provider.of<ConnectionProvider>(context, listen: false);
|
||||
final contactsProvider = Provider.of<ContactsProvider>(context, listen: false);
|
||||
final connectionProvider = Provider.of<ConnectionProvider>(
|
||||
context,
|
||||
listen: false,
|
||||
);
|
||||
final contactsProvider = Provider.of<ContactsProvider>(
|
||||
context,
|
||||
listen: false,
|
||||
);
|
||||
|
||||
debugPrint(' Connection status: ${connectionProvider.deviceInfo.isConnected}');
|
||||
debugPrint(
|
||||
' Connection status: ${connectionProvider.deviceInfo.isConnected}',
|
||||
);
|
||||
|
||||
if (!connectionProvider.deviceInfo.isConnected) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
@@ -444,9 +451,13 @@ class DrawingToolbar extends StatelessWidget {
|
||||
(room) => ListTile(
|
||||
leading: const Icon(Icons.meeting_room, color: Colors.green),
|
||||
title: Text(room.advName),
|
||||
subtitle: Text(AppLocalizations.of(context)!.storedPermanently),
|
||||
subtitle: Text(
|
||||
AppLocalizations.of(context)!.storedPermanently,
|
||||
),
|
||||
onTap: () async {
|
||||
debugPrint('📤 [DrawingToolbar] Room ${room.advName} tapped');
|
||||
debugPrint(
|
||||
'📤 [DrawingToolbar] Room ${room.advName} tapped',
|
||||
);
|
||||
// Share BEFORE popping the navigator
|
||||
await _shareDrawingsToRoom(
|
||||
sheetContext,
|
||||
@@ -485,7 +496,10 @@ class DrawingToolbar extends StatelessWidget {
|
||||
return;
|
||||
}
|
||||
|
||||
final drawingProvider = Provider.of<DrawingProvider>(context, listen: false);
|
||||
final drawingProvider = Provider.of<DrawingProvider>(
|
||||
context,
|
||||
listen: false,
|
||||
);
|
||||
int successCount = 0;
|
||||
|
||||
for (final drawing in drawings) {
|
||||
@@ -493,7 +507,9 @@ class DrawingToolbar extends StatelessWidget {
|
||||
debugPrint(' Creating message for drawing ${drawing.id}...');
|
||||
// Sender name is no longer included in JSON - will be extracted from packet metadata
|
||||
final message = drawingProvider.createDrawingBroadcastMessage(drawing);
|
||||
debugPrint(' Message created (${message.length} chars): ${message.substring(0, message.length > 100 ? 100 : message.length)}...');
|
||||
debugPrint(
|
||||
' Message created (${message.length} chars): ${message.substring(0, message.length > 100 ? 100 : message.length)}...',
|
||||
);
|
||||
debugPrint(' Sending to channel 0...');
|
||||
await connectionProvider.sendChannelMessage(
|
||||
channelIdx: 0,
|
||||
@@ -518,10 +534,14 @@ class DrawingToolbar extends StatelessWidget {
|
||||
}
|
||||
|
||||
// Add informational message to chat
|
||||
final messagesProvider = Provider.of<MessagesProvider>(context, listen: false);
|
||||
final messagesProvider = Provider.of<MessagesProvider>(
|
||||
context,
|
||||
listen: false,
|
||||
);
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
messagesProvider.logSystemMessage(
|
||||
text: '📤 ${l10n.drawingsSentToPublicChannel(drawings.length, drawings.length > 1 ? 's' : '')}',
|
||||
text:
|
||||
'📤 ${l10n.drawingsSentToPublicChannel(drawings.length, drawings.length > 1 ? 's' : '')}',
|
||||
level: 'info',
|
||||
);
|
||||
|
||||
@@ -556,7 +576,10 @@ class DrawingToolbar extends StatelessWidget {
|
||||
return;
|
||||
}
|
||||
|
||||
final drawingProvider = Provider.of<DrawingProvider>(context, listen: false);
|
||||
final drawingProvider = Provider.of<DrawingProvider>(
|
||||
context,
|
||||
listen: false,
|
||||
);
|
||||
int successCount = 0;
|
||||
|
||||
for (final drawing in drawings) {
|
||||
@@ -564,7 +587,9 @@ class DrawingToolbar extends StatelessWidget {
|
||||
debugPrint(' Creating message for drawing ${drawing.id}...');
|
||||
// Sender name is no longer included in JSON - will be extracted from packet metadata
|
||||
final message = drawingProvider.createDrawingBroadcastMessage(drawing);
|
||||
debugPrint(' Message created (${message.length} chars): ${message.substring(0, message.length > 100 ? 100 : message.length)}...');
|
||||
debugPrint(
|
||||
' Message created (${message.length} chars): ${message.substring(0, message.length > 100 ? 100 : message.length)}...',
|
||||
);
|
||||
debugPrint(' Sending to room ${room.advName}...');
|
||||
await connectionProvider.sendTextMessage(
|
||||
contactPublicKey: room.publicKey,
|
||||
@@ -591,9 +616,13 @@ class DrawingToolbar extends StatelessWidget {
|
||||
}
|
||||
|
||||
// Add informational message to chat
|
||||
final messagesProvider = Provider.of<MessagesProvider>(context, listen: false);
|
||||
final messagesProvider = Provider.of<MessagesProvider>(
|
||||
context,
|
||||
listen: false,
|
||||
);
|
||||
messagesProvider.logSystemMessage(
|
||||
text: '📤 Sent ${drawings.length} map drawing${drawings.length > 1 ? 's' : ''} to ${room.advName}',
|
||||
text:
|
||||
'📤 Sent ${drawings.length} map drawing${drawings.length > 1 ? 's' : ''} to ${room.advName}',
|
||||
level: 'info',
|
||||
);
|
||||
|
||||
|
||||
@@ -37,7 +37,8 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
|
||||
if (_searchQuery.isEmpty) return contacts;
|
||||
final query = _searchQuery.toLowerCase();
|
||||
return contacts.where((contact) {
|
||||
final name = contact.displayName?.toLowerCase() ?? contact.advName.toLowerCase();
|
||||
final name =
|
||||
contact.displayName.toLowerCase() ?? contact.advName.toLowerCase();
|
||||
return name.contains(query);
|
||||
}).toList();
|
||||
}
|
||||
@@ -81,9 +82,9 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
|
||||
children: [
|
||||
Text(
|
||||
l10n.selectRecipient,
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
@@ -254,17 +255,19 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
l10n.noContactsOrRoomsAvailable,
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: Theme.of(context).disabledColor,
|
||||
),
|
||||
style: Theme.of(context).textTheme.bodyLarge
|
||||
?.copyWith(
|
||||
color: Theme.of(context).disabledColor,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
l10n.messagesWillBeSentToPublicChannel,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).disabledColor,
|
||||
),
|
||||
style: Theme.of(context).textTheme.bodySmall
|
||||
?.copyWith(
|
||||
color: Theme.of(context).disabledColor,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
@@ -297,7 +300,7 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? Theme.of(context).colorScheme.primaryContainer
|
||||
: Theme.of(context).colorScheme.surfaceVariant,
|
||||
: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Icon(
|
||||
@@ -325,11 +328,10 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
|
||||
),
|
||||
subtitle: Text(
|
||||
subtitle,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontFamily: 'monospace',
|
||||
).copyWith(
|
||||
color: Theme.of(context).textTheme.bodySmall?.color?.withValues(alpha: 0.6),
|
||||
style: const TextStyle(fontSize: 12, fontFamily: 'monospace').copyWith(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).textTheme.bodySmall?.color?.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
trailing: isSelected
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user